From 4e0955933735433feba875edb154f77746b1e94e Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 03:57:18 +0400 Subject: [PATCH] Carry the Nepal event images, playback and media across globes --- scripts/package-boundaries.json | 1 + scripts/qa-nepal-event-switch.mjs | 152 ++++++++++++++++++ src/app/constructCatalog.js | 4 +- src/app/data.js | 4 +- src/app/navaraLayers.test.mjs | 1 + src/app/navaraMode.js | 15 +- src/app/tools.js | 33 ++-- src/app/viewerlessTools.test.mjs | 17 +- src/data/bhoteKoshiEmbeddedMedia.js | 33 ++-- src/data/bhoteKoshiEvent.js | 133 ++++++++++++--- src/data/lifecycle.js | 2 +- src/layers/bhoteKoshi/render/navara.js | 67 ++++++++ src/layers/earthquakes/render/navara.js | 2 +- .../recentImagery/render/navaraGround.js | 34 ++-- src/layers/shared/render/navaraGroundCells.js | 2 + .../shared/render/navaraGroundOrder.test.mjs | 4 +- src/layers/weather/render/navaraSurface.js | 2 +- src/voice/gevActions.js | 3 +- src/voice/gevActionsEngine.test.mjs | 1 - 19 files changed, 426 insertions(+), 84 deletions(-) create mode 100644 scripts/qa-nepal-event-switch.mjs create mode 100644 src/layers/bhoteKoshi/render/navara.js diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index cdadd9b6a..b9d450de8 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3680,6 +3680,7 @@ "src/layers/submarineCables/render/navara.js", "src/layers/directions/render/navara.js", "src/layers/shared/render/navaraStory.js", + "src/layers/bhoteKoshi/render/navara.js", "src/layers/weather/render/navara.js", "src/layers/weather/render/navaraSurface.js", "src/layers/wind/render/navara.js", diff --git a/scripts/qa-nepal-event-switch.mjs b/scripts/qa-nepal-event-switch.mjs new file mode 100644 index 000000000..5ae7adf64 --- /dev/null +++ b/scripts/qa-nepal-event-switch.mjs @@ -0,0 +1,152 @@ +import sharp from 'sharp'; +import puppeteer from 'puppeteer'; +import assert from 'node:assert/strict'; +import { blockPaidQaMaps } from './qa-map-policy.mjs'; + +const base = new URL(process.argv[2]); +if ( + !['localhost', '127.0.0.1'].includes(base.hostname) || + ['', '4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes( + base.port, + ) +) + throw new Error('Supply an unreserved local keyless preview URL'); +base.search = '?engine=navara&welcome=0&look=cinematic'; +base.hash = + 'v=2&lat=28.2110&lon=85.3380&alt=9000&heading=0&pitch=-90&roll=0&map=sentinel2&l='; +const b = await puppeteer.launch({ + headless: true, + args: ['--use-angle=metal', '--enable-gpu'], +}); +try { + const p = await b.newPage(); + await blockPaidQaMaps(p); + await p.setViewport({ width: 1024, height: 768 }); + const errors = []; + p.on('pageerror', (e) => { + errors.push(e.message); + console.log('error', e.message.split('?')[0]); + }); + p.on('console', (msg) => { + if (msg.type() === 'error' && /Shader|WebGLProgram/i.test(msg.text())) + errors.push(msg.text()); + }); + const assets = []; + p.on('request', (req) => { + const path = new URL(req.url()).pathname; + if ( + /\/events\/bhote-koshi-2026\/(event\.json|pre\.webp|post\.webp)$/.test( + path, + ) + ) + assets.push(path); + }); + await p.goto(base.href); + const ready = async (kind) => { + await p.waitForFunction( + (k) => + window.__godsEyeView?.engine.kind === k && + !document.querySelector('#globe-engine')?.disabled && + !document.querySelector('.engine-switch-frame') && + document.querySelector('#loading-screen.hidden'), + { timeout: 90000 }, + kind, + ); + await p.evaluate( + () => window.__godsEyeView.styleManager.initialRestorePromise, + ); + }; + await ready('navara'); + await p.evaluate(async () => { + const { tileset, dataManager, sceneDirector } = window.__godsEyeView; + if (!sceneDirector?.listScenes().length) throw Error('No scenes available on Navara'); + if (tileset || window.__GOOGLE_MAPS_API_KEY__) + throw Error('Keyless preview required'); + const mod = dataManager.layers.get('bhote-koshi-2026').module; + window.qaEvent = mod; + await dataManager.setEnabled(mod.id, true, { origin: 'user' }); + mod.setParams({ + presentation: 'standalone', + progress: 0.52, + split: 0.27, + autoPlay: false, + }); + }); + const sample = async () => { + await new Promise((r) => setTimeout(r, 500)); + return sharp(await p.screenshot()) + .extract({ left: 490, top: 370, width: 25, height: 25 }) + .removeAlpha() + .raw() + .toBuffer(); + }; + for (const kind of ['navara', 'cesium', 'navara']) { + if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) { + await p.select('#globe-engine', kind); + await ready(kind); + } + const state = await p.evaluate(() => { + const { dataManager } = window.__godsEyeView; + return { + same: + window.qaEvent === dataManager.layers.get('bhote-koshi-2026').module, + state: window.qaEvent.getPlaybackState(), + player: + document.querySelector('#bhote-koshi-embedded-media-root') === + window.qaPlayer, + }; + }); + assert.equal(state.same, true); + assert.equal(state.state.enabled, true); + assert.equal(state.state.split, 0.27); + assert.equal(state.state.playing, false); + assert.equal(state.state.progress, 0.52); + await p.evaluate(() => { + window.qaPlayer ??= document.querySelector( + '#bhote-koshi-embedded-media-root', + ); + window.qaEvent.setParams({ split: 0.75 }); + }); + assert.ok(await p.evaluate(()=>Boolean(window.qaPlayer)), 'persistent embedded media root'); + const before = await sample(); + await p.evaluate(() => window.qaEvent.setParams({ split: 0.27 })); + const after = await sample(); + const diff = + before.reduce((n, v, i) => n + Math.abs(v - after[i]), 0) / before.length; + console.log(kind, state.state, 'imagery difference', diff); + assert.ok(diff > 6, 'divider changes the actual imagery in the viewport'); + assert.equal( + await p.evaluate( + () => + document.querySelector('#bhote-koshi-embedded-media-root') === + window.qaPlayer, + ), + true, + ); + } + for (const suffix of ['/event.json', '/pre.webp', '/post.webp']) + assert.equal( + assets.filter((a) => a.endsWith(suffix)).length, + 1, + JSON.stringify(assets), + ); + await p.evaluate(() => window.qaEvent.setParams({ autoPlay: true })); + await p.waitForFunction( + () => window.qaEvent.getPlaybackState().progress > 0.53, + ); + await p.select('#globe-engine', 'cesium'); + await ready('cesium'); + const live = await p.evaluate(() => window.qaEvent.getPlaybackState()); + assert.ok(live.playing && live.progress > 0.53); + await p.evaluate(() => + window.__godsEyeView.dataManager.setEnabled('bhote-koshi-2026', false, { + origin: 'user', + }), + ); + assert.deepEqual(errors, []); + console.log( + 'PASS event timeline, paused and playing handoff, image pair, divider, player host and single asset acquisition', + ); +} finally { + await b.close(); +} diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 0fd82b3cc..290f6226b 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -160,7 +160,9 @@ export function createApplicationCatalog({ // Catalog order: [layer id(s), construction]. Only the layers the // engine can show are constructed (renderers.js). const entries = [ - ['bhote-koshi-2026', () => createBhoteKoshiEventLayer()], + ['bhote-koshi-2026', () => sessionModel('bhote-koshi-2026', () => createBhoteKoshiEventLayer({ + createNativeRenderer: (engine) => rendererFor('bhote-koshi-2026', engine), + }))], [ 'bhote-koshi-locator', () => diff --git a/src/app/data.js b/src/app/data.js index b780282c0..60727f4f2 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -45,10 +45,10 @@ export function createApplicationData({ onData?.(dataManager); if (!catalog?.layers || !catalog?.metadata) throw new TypeError('An application layer catalog is required'); - const attached = dataManager.bindCatalog(viewer, engine, catalog); - for (const layer of catalog.layers) layer.attachDataManager?.(dataManager); for (const layer of catalog.layers) layer.attachMapStackController?.(mapStackController); + const attached = dataManager.bindCatalog(viewer, engine, catalog); + for (const layer of catalog.layers) layer.attachDataManager?.(dataManager); if (allowQaRegistration) { window.__gevQaRegisterLayer = (targetManager, layerModule) => { diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index 78eb43c03..b99b8c6ee 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -43,6 +43,7 @@ test('the Navara mode registers migrated renderers once', async () => { .available() .map(({ id }) => id), [ + 'bhote-koshi-2026', 'bhote-koshi-locator', 'flights', 'military', diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 7578506c0..4453ce41c 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -38,9 +38,7 @@ export function navaraModeNotice(availability) { const names = availability.available().map(({ name }) => name); return Object.freeze({ label: t('Experimental Navara engine'), - detail: names.length - ? `Data layers: ${names.join(', ')}; the other layers and director are unavailable` - : 'No data layers on this engine yet; cockpit and director are unavailable', + detail: names.length ? `Data layers: ${names.join(', ')}` : 'No registered data layers', }); } @@ -120,6 +118,10 @@ export async function registerNavaraLayerRenderers() { return (engine, options) => createWeatherNavaraRenderer(engine, navaraViews.get(engine), options); }, ])), + 'bhote-koshi-2026': async () => { + const { createNepalEventDisplay } = await import('../layers/bhoteKoshi/render/navara.js'); + return (engine) => createNepalEventDisplay(engine, navaraViews.get(engine)); + }, 'bhote-koshi-locator': async () => { const { drawStorySource } = await import('../layers/shared/render/navaraStory.js'); return (engine, { data }) => drawStorySource(engine, navaraViews.get(engine), data); @@ -331,6 +333,7 @@ export function createViewerlessTools({ placeSearch, voice = {}, startChrome, + sceneDirector, signal, defer, }) { @@ -402,7 +405,7 @@ export function createViewerlessTools({ styleManager, tileset: null, dataManager, - sceneDirector: null, + sceneDirector, mapStackController, annotations: null, weatherEffects: null, @@ -428,7 +431,7 @@ export function createViewerlessTools({ engine, styleManager, dataManager, - sceneDirector: null, + sceneDirector, annotations: null, }); defer(() => { @@ -437,5 +440,5 @@ export function createViewerlessTools({ delete window.__gevVoiceCommands; }); debug.voiceCommands = voiceCommands; - return { sceneDirector: null, annotations: null, voiceCommands }; + return { sceneDirector, annotations: null, voiceCommands }; } diff --git a/src/app/tools.js b/src/app/tools.js index 89e0fc62d..0ad6a2e2b 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -41,22 +41,6 @@ export function createApplicationTools({ const { viewer, engine, tileset, mapStackController, operations } = scene; const { styleManager, weatherEffects, cockpitCloudEffects } = controls; const { dataManager } = data; - // Without a viewer (the Navara mode) the tools load with that mode's code. - if (!viewer) { - return import('./navaraMode.js').then(({ createViewerlessTools }) => - createViewerlessTools({ - scene, - controls, - data, - loadingScreen, - placeSearch, - voice, - startChrome, - signal, - defer, - }), - ); - } const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, { engine, dataPacks: sceneDataPacks, @@ -68,6 +52,23 @@ export function createApplicationTools({ ?.module.attachSceneController(sceneDirector); defer(() => sceneDirector.destroy()); onSceneDirector?.(sceneDirector); + // Without a viewer (the Navara mode) the tools load with that mode's code. + if (!viewer) { + return import('./navaraMode.js').then(({ createViewerlessTools }) => + createViewerlessTools({ + scene, + controls, + data, + loadingScreen, + placeSearch, + voice, + sceneDirector, + startChrome, + signal, + defer, + }), + ); + } const annotations = initAnnotations({ viewer, tileset, diff --git a/src/app/viewerlessTools.test.mjs b/src/app/viewerlessTools.test.mjs index 5f246f3ac..3ab52138e 100644 --- a/src/app/viewerlessTools.test.mjs +++ b/src/app/viewerlessTools.test.mjs @@ -17,11 +17,15 @@ import { // The tools without a viewer (the experimental Navara engine); they load // from src/app/navaraMode.js. test('without a viewer the tools run the governor on the engine, show the notice, create no viewer tools and run voice on the engine', async (t) => { - const saved = { window: globalThis.window, document: globalThis.document }; + const saved = { window: globalThis.window, document: globalThis.document, localStorage:globalThis.localStorage }; const listeners = new Map(); globalThis.window = {}; + globalThis.localStorage = { getItem:()=>null,setItem(){},removeItem(){} }; globalThis.document = { hidden: false, + getElementById: () => null, + querySelector: () => null, + body: {classList:{add(){},remove(){},toggle(){},contains(){return false;}},appendChild(){}}, addEventListener: (type, fn) => listeners.set(type, fn), removeEventListener: (type, fn) => { if (listeners.get(type) === fn) listeners.delete(type); @@ -84,7 +88,7 @@ test('without a viewer the tools run the governor on the engine, show the notice signal: new AbortController().signal, defer: (dispose) => cleanups.push(dispose), }); - assert.equal(tools.sceneDirector, null); + assert.ok(tools.sceneDirector); assert.equal(tools.annotations, null); assert.ok(tools.voiceCommands, 'voice runs on the engine'); assert.equal(window.__gevVoiceCommands, tools.voiceCommands); @@ -94,8 +98,7 @@ test('without a viewer the tools run the governor on the engine, show the notice const scene = await voice.runner('control_scene', { action: 'list', }); - assert.equal(scene.ok, false); - assert.equal(scene.code, 'UNAVAILABLE_ON_ENGINE'); + assert.equal(scene.ok, true); assert.equal(chrome.length, 1); assert.equal(chrome[0].initializeWelcome, null, 'no first-run welcome'); assert.equal(chrome[0].styleManager, styleManager); @@ -112,14 +115,14 @@ test('without a viewer the tools run the governor on the engine, show the notice ]); assert.match( `${notice.label}: ${notice.detail}`, - /^Experimental Navara engine: no data layers on this engine yet; cockpit and director are unavailable$/i, + /^Experimental Navara engine: no registered data layers$/i, ); // With Navara renderers, the notice names the layers it shows. const registry = createRendererRegistry(); registry.register('navara', { satellites: () => ({}), flights: () => ({}) }); assert.equal( navaraModeNotice(createLayerAvailability('navara', { registry })).detail, - 'Data layers: Live Flights, Satellites; the other layers and director are unavailable', + 'Data layers: Live Flights, Satellites', ); // The governor drives the engine's continuous rendering. assert.equal(isRenderGovernorInstalled(engine), true); @@ -130,7 +133,7 @@ test('without a viewer the tools run the governor on the engine, show the notice const debug = window.__godsEyeView; assert.equal(debug.viewer, null); assert.equal(debug.engine, engine); - assert.equal(debug.sceneDirector, null); + assert.equal(debug.sceneDirector, tools.sceneDirector); assert.equal(debug.dataManager, dataManager); assert.equal(debug.voiceCommands, tools.voiceCommands); listeners.get('visibilitychange')(); diff --git a/src/data/bhoteKoshiEmbeddedMedia.js b/src/data/bhoteKoshiEmbeddedMedia.js index fb2d4ca7c..621f5c1a6 100644 --- a/src/data/bhoteKoshiEmbeddedMedia.js +++ b/src/data/bhoteKoshiEmbeddedMedia.js @@ -538,9 +538,10 @@ function ensureProviderConnections(documentRef, provider) { } } -function anchorOnScreen(viewer, anchor, root) { - if (!viewer?.scene || !anchor || !root) return null; - const camera = viewer.camera; +function anchorOnScreen(viewer, engine, anchor, root) { + if ((!viewer?.scene && !engine) || !anchor || !root) return null; + const camera = viewer?.camera; + if (engine && !engine.projector.horizonVisible(anchor)) return null; if (camera?.positionWC && camera?.directionWC) { const delta = Cesium.Cartesian3.subtract( anchor, @@ -549,12 +550,12 @@ function anchorOnScreen(viewer, anchor, root) { ); if (Cesium.Cartesian3.dot(delta, camera.directionWC) <= 0) return null; } - const pixel = Cesium.SceneTransforms.worldToWindowCoordinates( + const pixel = engine ? engine.projector.worldToWindow(anchor, {}) : Cesium.SceneTransforms.worldToWindowCoordinates( viewer.scene, anchor, ); if (!pixel) return null; - const canvasBounds = viewer.canvas?.getBoundingClientRect?.(); + const canvasBounds = (engine?.host.canvas ?? viewer?.canvas)?.getBoundingClientRect?.(); const rootBounds = root.getBoundingClientRect?.(); if (!canvasBounds || !rootBounds) return null; const x = pixel.x + canvasBounds.left - rootBounds.left; @@ -637,7 +638,7 @@ function createRoot(documentRef, viewer) { const root = documentRef.createElement('div'); root.id = 'bhote-koshi-embedded-media-root'; root.setAttribute('aria-live', 'polite'); - (viewer?.container || documentRef.body)?.append(root); + (documentRef.getElementById?.('cesiumContainer') || viewer?.container || documentRef.body)?.append(root); return root; } @@ -648,6 +649,7 @@ function createRoot(documentRef, viewer) { */ export function createBhoteKoshiEmbeddedMedia({ viewer, + engine = null, documentRef = globalThis.document, globalRef = globalThis, xLoader = loadXWidgets, @@ -660,7 +662,7 @@ export function createBhoteKoshiEmbeddedMedia({ const pinokioShell = /(?:^|\s)Pinokio\/[^\s]+/i.test( globalRef.navigator?.userAgent || '', ); - if (pinokioShell || !documentRef?.createElement || !viewer) { + if (pinokioShell || !documentRef?.createElement || (!viewer && !engine)) { return { supportsPlayback: false, warm: () => false, @@ -671,7 +673,7 @@ export function createBhoteKoshiEmbeddedMedia({ destroy: () => {}, }; } - const root = createRoot(documentRef, viewer); + const root = createRoot(documentRef, engine?.host ?? viewer); let record = null; let warmRecord = null; let generation = 0; @@ -848,7 +850,7 @@ export function createBhoteKoshiEmbeddedMedia({ const position = () => { if (!record) return; - const projected = anchorOnScreen(viewer, record.anchor, root); + const projected = anchorOnScreen(viewer, engine, record.anchor, root); record.card.hidden = !projected; if (projected) { placeCard(record.card, record.leader, projected); @@ -864,8 +866,16 @@ export function createBhoteKoshiEmbeddedMedia({ } } }; - const removePostRender = - viewer.scene?.postRender?.addEventListener?.(position); + let removePostRender; + function attachScene(nextViewer, nextEngine) { + removePostRender?.(); + viewer = nextViewer; + engine = nextEngine; + const frames = engine?.frames.postRender ?? viewer?.scene?.postRender; + removePostRender = frames?.addEventListener?.(position); + position(); + } + attachScene(viewer, engine); function hide({ immediate = false, preserveWarm = false } = {}) { if (!preserveWarm) clearWarm(); @@ -1223,6 +1233,7 @@ export function createBhoteKoshiEmbeddedMedia({ } return { + attachScene, supportsPlayback: true, warm, show, diff --git a/src/data/bhoteKoshiEvent.js b/src/data/bhoteKoshiEvent.js index 8211cebb5..d14470b5b 100644 --- a/src/data/bhoteKoshiEvent.js +++ b/src/data/bhoteKoshiEvent.js @@ -889,7 +889,7 @@ function openExternal(url) { if (target) target.opener = null; } -function focusView(viewer, viewpoint) { +function focusView(viewer, viewpoint, engine) { announceNavigationAuthority('bhote-koshi-event-focus'); const target = Cesium.Cartesian3.fromDegrees( viewpoint.lon, @@ -901,6 +901,17 @@ function focusView(viewer, viewpoint) { Cesium.Math.toRadians(viewpoint.pitchDeg), viewpoint.rangeM, ); + if (engine) { + engine.camera.cancelFlight(); + engine.camera.flyToSphere({center: target, radius: 80}, { + heading:offset.heading, pitch:offset.pitch, range:offset.range, duration:2.2, + complete: () => { + engine.camera.lookAt(target, offset); + engine.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + }, + }); + return; + } viewer.camera.cancelFlight?.(); viewer.camera.flyToBoundingSphere(new Cesium.BoundingSphere(target, 80), { offset, @@ -994,9 +1005,16 @@ export function createBhoteKoshiEventLayer({ evidenceFrameUpdater = updateEvidenceFrame, videoFactory = DEFAULT_VIDEO_FACTORY, embeddedMediaFactory = createBhoteKoshiEmbeddedMedia, + createNativeRenderer, panelStore = useBhoteKoshiPanelStore(applicationPinia()), } = {}) { let _viewer = null; + let _engine = null; + let _native = null; + let _nativeFlood = null; + let _providers = null; + const cameraForScene = () => _engine?.camera ?? _viewer?.camera; + const canvasForScene = () => _engine?.host.canvas ?? _viewer?.scene?.canvas; let _dataManager = null; let _mapStackController = null; let _event = null; @@ -1160,7 +1178,7 @@ export function createBhoteKoshiEventLayer({ event.imagery.rectangle.east, event.imagery.rectangle.north, ); - const [beforeProvider, afterProvider] = await Promise.all([ + const [beforeProvider, afterProvider] = await (_providers ??= Promise.all([ imageryProviderFactory(eventAsset(event.imagery.before.path), { rectangle, credit: 'Vantor WorldView Open Data · CC BY-NC 4.0', @@ -1169,8 +1187,13 @@ export function createBhoteKoshiEventLayer({ rectangle, credit: 'Vantor WorldView Open Data · CC BY-NC 4.0', }), - ]); + ])); if (!_enabled) return; + if (_native) { + await _native.loadImages(event.imagery.rectangle, [beforeProvider, afterProvider]); + setSplit(_split); + return; + } _imageryLayerCollection = _viewer.imageryLayers; _beforeLayer = _imageryLayerCollection.addImageryProvider(beforeProvider); _afterLayer = _imageryLayerCollection.addImageryProvider(afterProvider); @@ -1187,7 +1210,11 @@ export function createBhoteKoshiEventLayer({ : []; let sampled = null; try { - sampled = await terrainSampler(_viewer, observations, { signal }); + sampled = _engine && !_viewer + ? observations.map(({lon,lat}) => ({ + height: _engine.surface.terrainHeight(Cesium.Cartographic.fromDegrees(lon,lat)), + })) + : await terrainSampler(_viewer, observations, { signal }); } catch (error) { if (error?.name === 'AbortError') throw error; console.warn( @@ -1574,13 +1601,13 @@ export function createBhoteKoshiEventLayer({ if (_sceneControls.evidencePathElevation !== 'source') { // Sample once while preparing the shot, never from an animation frame. try { - height = _viewer?.scene?.sampleHeight?.( + height = _engine?.surface?.sampleHeight?.(position) ?? _viewer?.scene?.sampleHeight?.( position, [_floodCore, _floodHalo, _surgePoint].filter(Boolean), ); } catch {} if (!Number.isFinite(height)) - height = _viewer?.scene?.globe?.getHeight?.(position); + height = _engine?.surface?.terrainHeight(position) ?? _viewer?.scene?.globe?.getHeight?.(position); } if (!Number.isFinite(height)) height = point.fallbackElevationM; return Cesium.Cartesian3.fromDegrees(point.lon, point.lat, height + 8); @@ -2582,13 +2609,16 @@ export function createBhoteKoshiEventLayer({ disableDepthTestDistance: tileSafe ? Number.POSITIVE_INFINITY : 0, }, }); - _viewer.dataSources.add(_floodDataSource); + if (_native) _nativeFlood = _native.draw(_floodDataSource); + else _viewer.dataSources.add(_floodDataSource); } function syncFloodSurfaceMode() { const nextMode = usesPhotorealFloodSurface() ? 'tiles' : 'terrain'; - if (!_viewer || !_floodDataSource || _floodSurfaceMode === nextMode) return; - _viewer.dataSources.remove(_floodDataSource, true); + if ((!_viewer && !_native) || !_floodDataSource || _floodSurfaceMode === nextMode) return; + _nativeFlood?.destroy(); + _nativeFlood = null; + _viewer?.dataSources.remove(_floodDataSource, true); _floodDataSource = null; _floodHalo = null; _floodCore = null; @@ -2601,7 +2631,7 @@ export function createBhoteKoshiEventLayer({ function createSplitControl(event) { _splitControl = createImagerySplit({ - scene: _viewer.scene, + scene: _viewer?.scene, initialValue: _split, id: 'bhote-koshi-split-line', handleClass: 'bhote-koshi-split-handle', @@ -2621,7 +2651,7 @@ export function createBhoteKoshiEventLayer({ function applyCinematicCamera() { if ( !_cinematicActive || - !_viewer?.camera || + !cameraForScene() || _cinematicKeyframes.length === 0 ) return; @@ -2635,7 +2665,7 @@ export function createBhoteKoshiEventLayer({ _cinematicOffset.heading = Cesium.Math.toRadians(pose.headingDeg); _cinematicOffset.pitch = Cesium.Math.toRadians(pose.pitchDeg); _cinematicOffset.range = pose.rangeM; - _viewer.camera.lookAt(pose.target, _cinematicOffset); + cameraForScene().lookAt(pose.target, _cinematicOffset); _lastCinematicProgress = _progress; } @@ -2705,7 +2735,7 @@ export function createBhoteKoshiEventLayer({ _cinematicActive = true; _lastCinematicProgress = null; announceNavigationAuthority('bhote-koshi-cinematic'); - _viewer?.camera?.cancelFlight?.(); + cameraForScene()?.cancelFlight?.(); } syncCinematicButton(); return true; @@ -2723,7 +2753,7 @@ export function createBhoteKoshiEventLayer({ _cinematicActive = false; _lastCinematicProgress = null; if (releaseCamera) - _viewer?.camera?.lookAtTransform?.(Cesium.Matrix4.IDENTITY); + cameraForScene()?.lookAtTransform?.(Cesium.Matrix4.IDENTITY); syncCinematicButton(); renderHost.request('bhote-koshi-cinematic-stop'); } @@ -2757,7 +2787,7 @@ export function createBhoteKoshiEventLayer({ } function attachCameraInputRelease() { - _cameraInputTarget = _viewer?.scene?.canvas || null; + _cameraInputTarget = canvasForScene() || null; for (const type of CINEMATIC_INPUT_EVENTS) { _cameraInputTarget?.addEventListener?.(type, handleManualCameraInput); } @@ -2803,7 +2833,7 @@ export function createBhoteKoshiEventLayer({ } function attachEvidenceCardActivation() { - _evidenceClickTarget = _viewer?.scene?.canvas || null; + _evidenceClickTarget = canvasForScene() || null; _evidenceClickTarget?.addEventListener?.('click', handleEvidenceCardClick); } @@ -3273,6 +3303,7 @@ export function createBhoteKoshiEventLayer({ if (_panelRefs?.progressInput) _panelRefs.progressInput.hidden = !scrubberAvailable; _splitControl?.setVisible(imageryAvailable); + _native?.setVisible(imageryAvailable); if (_beforeLayer) _beforeLayer.show = imageryAvailable; if (_afterLayer) _afterLayer.show = imageryAvailable; if (_panelRefs?.caveat) { @@ -3391,6 +3422,7 @@ export function createBhoteKoshiEventLayer({ function setSplit(split) { _split = clampUnit(split); _splitControl?.setValue(_split); + _native?.setSplit(_split); syncPanel(); renderHost.request('bhote-koshi-split'); } @@ -3451,7 +3483,7 @@ export function createBhoteKoshiEventLayer({ _embeddedEvidenceIndex = -1; _embeddedMedia?.hide?.(); setSplit(0.5); - focusView(_viewer, _event.viewpoints.corridor); + if (cameraForScene()) focusView(_viewer, _event.viewpoints.corridor, _engine); } function removeImagery() { @@ -3462,9 +3494,59 @@ export function createBhoteKoshiEventLayer({ _imageryLayerCollection = null; } - async function init(viewer) { + async function attachRenderer(viewer, {engine = null} = {}) { _viewer = viewer; - _embeddedMedia = embeddedMediaFactory({ viewer }); + _engine = engine; + if (!_viewer && _engine) _native = createNativeRenderer(engine); + _embeddedMedia?.attachScene?.(viewer,engine); + if (!_enabled) return; + // The catalog has rebound the incoming map controller before this call. + if (_mapStackController) { + _comparisonLease = acquireImageryComparison(_mapStackController, { + owner: BHOTE_KOSHI_LAYER_ID, + switchPolicy: _presentation === BHOTE_KOSHI_SCENE_PRESENTATION ? 'preserve' : 'esri', + }); + await _comparisonLease.ready; + } + await createImagery(_event); + if (_floodDataSource) { + if (_native) _nativeFlood = _native.draw(_floodDataSource); + else await _viewer.dataSources.add(_floodDataSource); + } + registerDynamicCredit(engine ?? viewer, BHOTE_KOSHI_CREDIT); + _scenePathPositionCache.clear(); + _lastCinematicProgress = null; + await syncTerrainComparison(); + createSplitControl(_event); + syncPresentationMode(); + attachCameraInputRelease(); + attachEvidenceCardActivation(); + updateProgress(_progress); + setSplit(_split); + } + + async function detachRenderer() { + detachCameraInputRelease(); + detachEvidenceCardActivation(); + _splitControl?.destroy(); + _splitControl = null; + _embeddedMedia?.attachScene?.(null,null); + removeImagery(); + _nativeFlood?.destroy(); + _nativeFlood = null; + if (_floodDataSource && _viewer) _viewer.dataSources.remove(_floodDataSource,false); + _native?.destroy(); + _native = null; + _engine = _viewer = null; + _mapStackController?.clearTerrainComparison?.(); + const lease = _comparisonLease; + _comparisonLease = _mapStackController = null; + await lease?.release(); + } + + async function init(viewer, options) { + await attachRenderer(viewer,options); + _embeddedMedia = embeddedMediaFactory({ viewer, engine: _engine }); await loadEvent(); console.log('[Data:BhoteKoshi] Event pack ready'); return true; @@ -3480,7 +3562,7 @@ export function createBhoteKoshiEventLayer({ try { const event = await loadEvent(); throwIfEnableCancelled(signal, _enabled); - registerDynamicCredit(viewer, BHOTE_KOSHI_CREDIT); + registerDynamicCredit(_engine ?? viewer, BHOTE_KOSHI_CREDIT); if (_mapStackController) { _comparisonLease = acquireImageryComparison(_mapStackController, { owner: BHOTE_KOSHI_LAYER_ID, @@ -3618,7 +3700,11 @@ export function createBhoteKoshiEventLayer({ closeFrame(slot.poster); } _evidenceFrameSlots = []; - if (_floodDataSource) _viewer.dataSources.remove(_floodDataSource, true); + _nativeFlood?.destroy(); + _nativeFlood = null; + _native?.clear(); + _providers = null; + if (_floodDataSource && _viewer) _viewer.dataSources.remove(_floodDataSource, true); _floodDataSource = null; _floodSurfaceMode = null; _floodHalo = null; @@ -3672,7 +3758,8 @@ export function createBhoteKoshiEventLayer({ _embeddedMedia?.destroy?.(); _embeddedMedia = null; _event = null; - _viewer = null; + _native?.destroy(); + _native = _engine = _viewer = null; } /** @@ -3849,6 +3936,8 @@ export function createBhoteKoshiEventLayer({ source: 'Vantor + GeoPera', updateInterval: 0, init, + attachRenderer, + detachRenderer, enable, disable, update: async () => true, diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 729971ebb..2cd179983 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -130,7 +130,7 @@ export class LayerLifecycle { for (const [id, entry] of [...this.layers]) { await entry.toggleChain.catch(() => {}); if (entry.module.attachRenderer && entry.module.detachRenderer) { - if (entry.initialized) entry.module.detachRenderer(this.viewer); + if (entry.initialized) await entry.module.detachRenderer(this.viewer); } else if (!(await this.destroyLayer(id))) { throw new Error(`Cannot detach layer ${id}`); } diff --git a/src/layers/bhoteKoshi/render/navara.js b/src/layers/bhoteKoshi/render/navara.js new file mode 100644 index 000000000..ebde50cc7 --- /dev/null +++ b/src/layers/bhoteKoshi/render/navara.js @@ -0,0 +1,67 @@ +import { Texture, LinearFilter } from 'three'; +import { createImageryGround } from '../../recentImagery/render/navaraGround.js'; +import { drawStorySource } from '../../shared/render/navaraStory.js'; + +export function createNepalEventDisplay(engine, native) { + const owner = { slots: { a: null, b: null }, preview: null, split: 0.5 }; + const ground = createImageryGround(engine, native, owner, 'gevNepalEvent'); + let disposed = false; + function clear() { + for (const id of ['a', 'b']) { + owner.slots[id]?.atlas.texture.dispose(); + owner.slots[id] = null; + } + if (!disposed) ground.sync(); + } + return { + draw: (data) => drawStorySource(engine, native, data), + async loadImages(box, providers) { + const images = await Promise.all( + providers.map((p) => p.requestImage(0, 0, 0)), + ); + if (disposed) return; + clear(); + for (const [i, id] of ['a', 'b'].entries()) { + const texture = new Texture(images[i]); + texture.flipY = false; + texture.minFilter = texture.magFilter = LinearFilter; + texture.generateMipmaps = false; + texture.needsUpdate = true; + owner.slots[id] = { + atlas: { + texture, + plan: { + geographic: true, + bounds: [ + box.west, + box.north, + box.east - box.west, + box.south - box.north, + ], + }, + }, + box, + alpha: 0.96, + split: i === 0 ? 'left' : 'right', + }; + } + ground.sync(); + engine.render.request('nepal-imagery'); + }, + setVisible(show) { + for (const slot of Object.values(owner.slots)) + if (slot) slot.alpha = show ? 0.96 : 0; + engine.render.request('nepal-imagery'); + }, + setSplit(x) { + owner.split = x; + engine.render.request('nepal-split'); + }, + clear, + destroy() { + disposed = true; + clear(); + ground.destroy(); + }, + }; +} diff --git a/src/layers/earthquakes/render/navara.js b/src/layers/earthquakes/render/navara.js index 4e0bb9825..5741c4cc3 100644 --- a/src/layers/earthquakes/render/navara.js +++ b/src/layers/earthquakes/render/navara.js @@ -199,7 +199,7 @@ function buildState(discs) { function groundEffectClass(EffectDesc, NavaraEffect) { return class GroundEffectDesc extends EffectDesc { static key = KEY; - static insertAfter = ['gevRecentImagery', 'toneMapping']; + static insertAfter = ['gevObservedWeather', 'gevNepalEvent', 'gevRecentImagery', 'toneMapping']; static insertBefore = [ 'gevFirmsGround', 'gevPreSharpenOverlay', diff --git a/src/layers/recentImagery/render/navaraGround.js b/src/layers/recentImagery/render/navaraGround.js index 291fd9868..3bfd0b57b 100644 --- a/src/layers/recentImagery/render/navaraGround.js +++ b/src/layers/recentImagery/render/navaraGround.js @@ -6,14 +6,14 @@ import { } from '../../shared/render/navaraGroundSurface.js'; const KEY = 'gevRecentImagery', - registered = new WeakSet(); + registered = new WeakMap(); const shader = /* glsl */ ` uniform sampler2D tAllDepth,tSurfaceDepth,tA,tB; uniform mat4 uInverseProjection,uWorld; uniform float uLogDepthFC,uSplit,uPixelRatio; uniform vec4 uBoundsA,uBoundsB,uBoxA,uBoxB,uPreview; uniform vec2 uLookA,uLookB; -uniform bool uPreviewVisible; +uniform bool uPreviewVisible,uGeographicA,uGeographicB; vec3 displayColor(vec3 c) { return mix(c*12.92,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c)); } @@ -21,10 +21,10 @@ vec3 linearColor(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)); } bool inBox(vec2 geo,vec4 box) { return geo.x>=box.x && geo.y>=box.y && geo.x<=box.z && geo.y<=box.w; } -vec3 imageColor(vec3 color,sampler2D image,vec4 bounds,vec4 box,vec2 look,vec2 geo,vec2 mercator,float x) { +vec3 imageColor(vec3 color,sampler2D image,vec4 bounds,vec4 box,vec2 look,vec2 geo,vec2 mercator,float x,bool geographic) { if(look.x<=0.0 || !inBox(geo,box)) return color; if((look.y<0.0 && x>uSplit)||(look.y>0.0 && x [ - ...source + ...(key === 'gevNepalEvent' && name === 'insertAfter' ? source.split('class NepalEffect')[1] : source) .match(new RegExp(`static ${name} = \\[([^\\]]*)\\]`))[1] .matchAll(/'([^']+)'/g), ].map((m) => m[1]); diff --git a/src/layers/weather/render/navaraSurface.js b/src/layers/weather/render/navaraSurface.js index d98a88e64..c9d166703 100644 --- a/src/layers/weather/render/navaraSurface.js +++ b/src/layers/weather/render/navaraSurface.js @@ -89,7 +89,7 @@ function weatherPass(engine, { view, EffectDesc, NavaraEffect }) { empty = null; class WeatherEffect extends EffectDesc { static key = KEY; - static insertAfter = ['toneMapping', 'gevRecentImagery']; + static insertAfter = ['gevNepalEvent', 'gevRecentImagery', 'toneMapping']; static insertBefore = [ 'gevEarthquakeGround', 'gevFirmsGround', diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index a58c5c213..9dc2543c4 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -234,7 +234,6 @@ const FRAME_TARGETS = new Map([ * refusal (UNAVAILABLE_ON_ENGINE) instead of running. */ export const VIEWER_ONLY_ACTIONS = Object.freeze([ - 'control_scene', 'annotate_map', 'clear_annotations', 'fly_route', @@ -470,7 +469,7 @@ export function createGevActionRunner({ }; if ( !viewer && - (VIEWER_ONLY_ACTIONS.includes(name) || CESIUM_LOOK_ACTIONS.includes(name)) + ((name === 'control_scene' && !sceneDirector) || VIEWER_ONLY_ACTIONS.includes(name) || CESIUM_LOOK_ACTIONS.includes(name)) ) return unavailableOnEngine(name, engine); diff --git a/src/voice/gevActionsEngine.test.mjs b/src/voice/gevActionsEngine.test.mjs index 929c3d1e8..572b13be1 100644 --- a/src/voice/gevActionsEngine.test.mjs +++ b/src/voice/gevActionsEngine.test.mjs @@ -89,7 +89,6 @@ const follow = (engine, position) => test('the viewer-only actions are one exported list of real tools', () => { assert.deepEqual(VIEWER_ONLY_ACTIONS, [ - 'control_scene', 'annotate_map', 'clear_annotations', 'fly_route',