From e0d3e93a283104a622a76176617b3e41caead0c7 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 16:14:14 +0400 Subject: [PATCH 1/4] Move the wind's Cesium rendering into the Cesium adapter The particle canvas, its GPU path and the relief shading move unchanged from src/layers/wind/ to src/globe/cesium/; the adapter reads the Cesium viewer itself, so the layer no longer hands one in. --- scripts/qa-wind-canvas.mjs | 2 +- src/globe/cesium/windFlow.js | 4 ++-- .../cesium/windGpuRendering.js} | 2 +- .../wind/relief.js => globe/cesium/windRelief.js} | 0 .../rendering.js => globe/cesium/windRendering.js} | 12 ++++++++---- src/layers/wind/gpuRendering.test.mjs | 2 +- src/layers/wind/relief.test.mjs | 2 +- src/layers/wind/rendering.test.mjs | 2 +- 8 files changed, 15 insertions(+), 11 deletions(-) rename src/{layers/wind/gpuRendering.js => globe/cesium/windGpuRendering.js} (99%) rename src/{layers/wind/relief.js => globe/cesium/windRelief.js} (100%) rename src/{layers/wind/rendering.js => globe/cesium/windRendering.js} (98%) diff --git a/scripts/qa-wind-canvas.mjs b/scripts/qa-wind-canvas.mjs index dd93cc588..885a52c47 100644 --- a/scripts/qa-wind-canvas.mjs +++ b/scripts/qa-wind-canvas.mjs @@ -6,7 +6,7 @@ try { const page = await browser.newPage(); await page.goto(`${process.env.QA_BASE_URL || 'http://localhost:4173'}/`, { waitUntil: 'domcontentloaded' }); const result = await page.evaluate(async () => { - const { createWindRendering } = await import('/src/layers/wind/rendering.js'); + const { createWindRendering } = await import('/src/globe/cesium/windRendering.js'); const raf = window.requestAnimationFrame; const caf = window.cancelAnimationFrame; const callbacks = new Map(); let sequence = 0; window.requestAnimationFrame = callback => { callbacks.set(++sequence, callback); return sequence; }; diff --git a/src/globe/cesium/windFlow.js b/src/globe/cesium/windFlow.js index f14467ca1..10c185287 100644 --- a/src/globe/cesium/windFlow.js +++ b/src/globe/cesium/windFlow.js @@ -1,7 +1,7 @@ // Cesium's backend of the wind display: the wind renderer's streamlines -// (src/layers/wind/rendering.js) over the viewer, read only once a Cesium globe draws. +// over the viewer, read only once a Cesium globe draws. import * as Cesium from 'cesium'; -import { createWindRendering } from '../../layers/wind/rendering.js'; +import { createWindRendering } from './windRendering.js'; export function createWindCesiumFlow(engine, { onStatusChange } = {}) { return createWindRendering({ diff --git a/src/layers/wind/gpuRendering.js b/src/globe/cesium/windGpuRendering.js similarity index 99% rename from src/layers/wind/gpuRendering.js rename to src/globe/cesium/windGpuRendering.js index 0873fa63b..e6f16d7a5 100644 --- a/src/layers/wind/gpuRendering.js +++ b/src/globe/cesium/windGpuRendering.js @@ -4,7 +4,7 @@ import { WIND_DISPLAY_HEIGHT_METERS, WIND_PATH_LIMIT, WIND_NARROW_PATH_LIMIT, -} from './streamlines.js'; +} from '../../layers/wind/streamlines.js'; const WIND_FADE_LOW_METERS = 15000; const WIND_FADE_HIGH_METERS = 60000; diff --git a/src/layers/wind/relief.js b/src/globe/cesium/windRelief.js similarity index 100% rename from src/layers/wind/relief.js rename to src/globe/cesium/windRelief.js diff --git a/src/layers/wind/rendering.js b/src/globe/cesium/windRendering.js similarity index 98% rename from src/layers/wind/rendering.js rename to src/globe/cesium/windRendering.js index 3314e1b9d..f9669181f 100644 --- a/src/layers/wind/rendering.js +++ b/src/globe/cesium/windRendering.js @@ -1,7 +1,11 @@ -import { createWindRelief } from './relief.js'; -import { createWindGpuRendering } from './gpuRendering.js'; -import { advectParticle, sampleWind, sameWind } from './model.js'; -import { trailEraseAlpha, windTrailColor } from './fields.js'; +import { createWindRelief } from './windRelief.js'; +import { createWindGpuRendering } from './windGpuRendering.js'; +import { + advectParticle, + sampleWind, + sameWind, +} from '../../layers/wind/model.js'; +import { trailEraseAlpha, windTrailColor } from '../../layers/wind/fields.js'; /** The wind's streamlines on Cesium (the wind display's flow backend, * render/display.js, which draws the colour field): globe-projected surface diff --git a/src/layers/wind/gpuRendering.test.mjs b/src/layers/wind/gpuRendering.test.mjs index 1a420e23b..9c886b291 100644 --- a/src/layers/wind/gpuRendering.test.mjs +++ b/src/layers/wind/gpuRendering.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { createWindGpuRendering } from './gpuRendering.js'; +import { createWindGpuRendering } from '../../globe/cesium/windGpuRendering.js'; import { BoundingSphere, Cartesian3, diff --git a/src/layers/wind/relief.test.mjs b/src/layers/wind/relief.test.mjs index 9e1b94b33..0258178e2 100644 --- a/src/layers/wind/relief.test.mjs +++ b/src/layers/wind/relief.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { createWindRelief } from './relief.js'; +import { createWindRelief } from '../../globe/cesium/windRelief.js'; function fixture({ material = undefined, normals = true } = {}) { const materials = []; diff --git a/src/layers/wind/rendering.test.mjs b/src/layers/wind/rendering.test.mjs index 9ed0334e5..b1d35d992 100644 --- a/src/layers/wind/rendering.test.mjs +++ b/src/layers/wind/rendering.test.mjs @@ -4,7 +4,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { createWindRendering } from './rendering.js'; +import { createWindRendering } from '../../globe/cesium/windRendering.js'; function event() { const listeners = new Set(); From 4f654df3c06842711e2275a06ec71483868ad7da Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 16:14:14 +0400 Subject: [PATCH 2/4] Inspect the wind field through the engine contract The inspection marker and the centre probe use the engine's host, projection, frame events, ellipsoid pick and render requests instead of Cesium's scene, so they behave the same on Navara. --- src/layers/wind/edgeCases.test.mjs | 10 +++---- src/layers/wind/index.js | 12 ++------ src/layers/wind/index.test.mjs | 6 ++-- src/layers/wind/inspection.js | 17 +++++------ src/layers/wind/inspection.test.mjs | 10 +++---- src/layers/wind/inspectionMarker.js | 31 +++++-------------- src/layers/wind/inspectionMarker.test.mjs | 36 +++++++++++------------ 7 files changed, 47 insertions(+), 75 deletions(-) diff --git a/src/layers/wind/edgeCases.test.mjs b/src/layers/wind/edgeCases.test.mjs index 1b7a0e2ec..2ae1512ae 100644 --- a/src/layers/wind/edgeCases.test.mjs +++ b/src/layers/wind/edgeCases.test.mjs @@ -23,13 +23,13 @@ const options = { validTime: '2026-09-14 18:00 UTC', status: 'Model forecast', }; -/** The Cesium path picks the centre itself; Navara hands in the position. */ -const viaViewer = (snapshot, point) => +/** Inspect either the current map centre or a captured position. */ +const viaEngine = (snapshot, point) => inspectWindAtCenter( snapshot, { camera: { pickEllipsoid: () => point }, - scene: { canvas: { clientWidth: 800, clientHeight: 600 } }, + host: { canvas: { clientWidth: 800, clientHeight: 600 } }, }, options, ); @@ -42,7 +42,7 @@ test('edge:wind: a fixed grid gives the same direction, speed and legend colour // 5 m/s blowing toward the west: the wind comes FROM the east. const snapshot = field(-5, 0); for (const point of [at(10, 20), at(180, 0), at(-180, 0), at(-45.5, -60)]) { - const cesiumReading = viaViewer(snapshot, point); + const cesiumReading = viaEngine(snapshot, point); const navaraReading = viaPosition(snapshot, point); for (const reading of [cesiumReading, navaraReading]) { assert.equal(reading.from, 'E'); @@ -76,7 +76,7 @@ test('edge:wind: an empty or unavailable field is not reported as calm, a real 0 ]; for (const snapshot of empty) { for (const reading of [ - viaViewer(snapshot, point), + viaEngine(snapshot, point), viaPosition(snapshot, point), ]) { assert.equal(reading.speed, undefined, 'no speed is invented'); diff --git a/src/layers/wind/index.js b/src/layers/wind/index.js index 928573057..3d7b659fd 100644 --- a/src/layers/wind/index.js +++ b/src/layers/wind/index.js @@ -82,14 +82,7 @@ export function createWindLayer({ inspectionMarker?.clear(); }; const sample = (position) => { - if (!position && engine && !viewer) { - const canvas = engine.host.canvas; - position = engine.camera.pickEllipsoid({ - x: canvas.clientWidth / 2, - y: canvas.clientHeight / 2, - }); - } - reading = inspectWindAtCenter(manifest, viewer, { + reading = inspectWindAtCenter(manifest, engine, { position, units, overlay, @@ -130,7 +123,6 @@ export function createWindLayer({ rendering?.setOptions?.({ overlay, paused }); inspectionMarker = createWindInspectionMarker({ container: engine.host.container, - viewer, engine, }); if (enabled && manifest && !manifest.unavailable) { @@ -254,7 +246,7 @@ export function createWindLayer({ loading = enabled && needsField; if (enabled && needsField) queueMicrotask(() => { - if (enabled && generation === current) void layer.update(viewer); + if (enabled && generation === current) void layer.update(); }); else if (enabled && manifest && !manifest.unavailable) rendering?.start(); diff --git a/src/layers/wind/index.test.mjs b/src/layers/wind/index.test.mjs index 30ef80423..5ef39e521 100644 --- a/src/layers/wind/index.test.mjs +++ b/src/layers/wind/index.test.mjs @@ -365,8 +365,10 @@ test('sample stays fixed across model, field and unit changes; dismissal and dis feed: { getSnapshot: async ({ model }) => complete(model) }, createNativeRendering: () => rendering, }); - // A Cesium page: the viewer samples, the engine projects the marker. - layer.init(viewer, { engine: createFakeEngine({ container }) }); + const engine = createFakeEngine({ container, canvas: viewer.scene.canvas }); + engine.camera.pickEllipsoid = (point) => viewer.camera.pickEllipsoid(point); + engine.frames.postRender = viewer.scene.postRender; + layer.init(viewer, { engine }); layer.enable(); await layer.update(); layer.setParams({ inspect: true }); diff --git a/src/layers/wind/inspection.js b/src/layers/wind/inspection.js index 3459a1df2..8299ddfe1 100644 --- a/src/layers/wind/inspection.js +++ b/src/layers/wind/inspection.js @@ -45,20 +45,17 @@ export function formatWindSpeed(speed, units = 'km/h') { /** A deliberate snapshot at the map center; does not own pointer input. */ export function inspectWindAtCenter( snapshot, - viewer, + engine, { units, overlay, model, validTime, status, position } = {}, ) { - const scene = viewer?.scene; - const canvas = scene?.canvas; - // The globe's own ellipsoid, else WGS84 (geo's default). - const ellipsoid = scene?.globe?.ellipsoid; + const canvas = engine?.host.canvas; const point = position || (canvas && - viewer.camera?.pickEllipsoid?.( - geo.cartesian2.create(canvas.clientWidth / 2, canvas.clientHeight / 2), - ellipsoid, - )); + engine.camera.pickEllipsoid({ + x: canvas.clientWidth / 2, + y: canvas.clientHeight / 2, + })); const noReading = () => ({ coordinates: msg('No surface reading'), wind: msg('Aim the map center at Earth'), @@ -70,7 +67,7 @@ export function inspectWindAtCenter( ), }); if (!point || !snapshot?.u || !snapshot?.v) return noReading(); - const location = geo.cartographic.fromCartesian(point, ellipsoid); + const location = geo.cartographic.fromCartesian(point); const lon = geo.math.toDegrees(location.longitude); const lat = geo.math.toDegrees(location.latitude); const vector = sampleWind( diff --git a/src/layers/wind/inspection.test.mjs b/src/layers/wind/inspection.test.mjs index a19801228..1161c1d76 100644 --- a/src/layers/wind/inspection.test.mjs +++ b/src/layers/wind/inspection.test.mjs @@ -27,11 +27,11 @@ test('center inspection uses a fixed forecast reading without double-converting values: Float32Array.from([20, 20, 20, 20]), }, }; - const viewer = { + const engine = { camera: { pickEllipsoid: () => geo.cartesian3.fromDegrees(0, 0) }, - scene: { canvas: { clientWidth: 800, clientHeight: 600 } }, + host: { canvas: { clientWidth: 800, clientHeight: 600 } }, }; - const result = inspectWindAtCenter(snapshot, viewer, { + const result = inspectWindAtCenter(snapshot, engine, { overlay: 'temperature', units: 'km/h', model: 'GFS', @@ -42,9 +42,9 @@ test('center inspection uses a fixed forecast reading without double-converting assert.equal(result.scalarValue, '20.0 °C'); assert.equal(result.validTime, 'fixed'); assert.equal(result.coordinates, '0.00°N · 0.00°E'); - viewer.camera.pickEllipsoid = () => null; + engine.camera.pickEllipsoid = () => null; assert.equal( - inspectWindAtCenter(snapshot, viewer).coordinates, + inspectWindAtCenter(snapshot, engine).coordinates, 'No surface reading', ); }); diff --git a/src/layers/wind/inspectionMarker.js b/src/layers/wind/inspectionMarker.js index 7291158bb..028bd7ade 100644 --- a/src/layers/wind/inspectionMarker.js +++ b/src/layers/wind/inspectionMarker.js @@ -9,9 +9,7 @@ import { applicationPinia } from '../../ui/vueRuntime.js'; */ export function createWindInspectionMarker({ container, - viewer, engine, - cesium, store = useWindInspectionStore(applicationPinia()), }) { const owner = {}; @@ -28,45 +26,31 @@ export function createWindInspectionMarker({ removeRender = null; if (shown()) store.publish(null); marker = null; - if (engine) engine.render.request('wind-inspection'); - else viewer?.scene?.requestRender?.(); + engine.render.request('wind-inspection'); }; return { show(position) { clear(); - const scene = viewer?.scene; - const canvas = scene?.canvas ?? engine?.host.canvas; - const frames = scene?.postRender ?? engine?.frames.postRender; + const canvas = engine.host.canvas; + const frames = engine.frames.postRender; if ( !position || !container?.ownerDocument || !frames?.addEventListener || - (!engine?.projector && !scene?.cartesianToCanvasCoordinates) + !engine.projector ) return; present({ owner, container, visible: false, left: 0, top: 0 }); - const occluder = - !engine && - new cesium.EllipsoidalOccluder( - scene.globe?.ellipsoid || cesium.Ellipsoid.WGS84, - viewer.camera.positionWC, - ); const render = () => { if (!shown()) return; - if (occluder) occluder.cameraPosition = viewer.camera.positionWC; - const point = engine - ? engine.projector.worldToWindow(position, {}) - : scene.cartesianToCanvasCoordinates(position); + const point = engine.projector.worldToWindow(position, {}); const visible = point && point.x >= 0 && point.y >= 0 && point.x <= canvas.clientWidth && point.y <= canvas.clientHeight && - (engine - ? engine.projector.horizonVisible(position) - : scene.mode !== cesium.SceneMode.SCENE3D || - occluder.isPointVisible(position)); + engine.projector.horizonVisible(position); if (!visible) { // A hidden marker keeps its last place, as the element did. if (marker.visible) present({ visible: false }); @@ -82,8 +66,7 @@ export function createWindInspectionMarker({ }; removeRender = frames.addEventListener(render); render(); - if (engine) engine.render.request('wind-inspection'); - else scene.requestRender?.(); + engine.render.request('wind-inspection'); }, clear, destroy: clear, diff --git a/src/layers/wind/inspectionMarker.test.mjs b/src/layers/wind/inspectionMarker.test.mjs index 49f0a6574..94d84630d 100644 --- a/src/layers/wind/inspectionMarker.test.mjs +++ b/src/layers/wind/inspectionMarker.test.mjs @@ -10,16 +10,7 @@ import { useWindInspectionStore } from '../../ui/stores/windInspection.js'; import WindInspectionMarker from '../../ui/views/panels/WindInspectionMarker.vue'; import { createWindInspectionMarker } from './inspectionMarker.js'; -const cesium = { - SceneMode: { SCENE3D: 3 }, - Ellipsoid: { WGS84: {} }, - EllipsoidalOccluder: class { - isPointVisible() { - return cesium.visible; - } - }, - visible: true, -}; +const visibility = { visible: true }; function fixture(t) { document.body.replaceChildren(); @@ -56,18 +47,26 @@ function fixture(t) { requestRender() {}, }, }; + const engine = { + host: { canvas: viewer.scene.canvas }, + frames: { postRender: viewer.scene.postRender }, + projector: { + worldToWindow: viewer.scene.cartesianToCanvasCoordinates, + horizonVisible: () => visibility.visible, + }, + render: { request() {} }, + }; const store = useWindInspectionStore(pinia); - cesium.visible = true; + visibility.visible = true; const owner = createWindInspectionMarker({ container, - viewer, - cesium, + engine, store, }); const nodes = () => [ ...container.querySelectorAll('.gev-wind-inspection-marker'), ]; - return { owner, viewer, store, frames, projected, nodes, container, cesium }; + return { owner, viewer, store, frames, projected, nodes, container, engine }; } test('the marker follows only its captured point, hides beyond the limb, and releases its listener', async (t) => { @@ -85,13 +84,13 @@ test('the marker follows only its captured point, hides beyond the limb, and rel assert.equal(marker.style.top, '220px'); assert.equal(marker.hidden, false); viewer.camera.positionWC = { moved: true }; - cesium.visible = false; + visibility.visible = false; frames.listener(); await nextTick(); assert.equal(marker.hidden, true); assert.equal(marker.style.left, '110px', 'a hidden marker keeps its place'); assert.ok(projected.every((value) => value === point)); - cesium.visible = true; + visibility.visible = true; frames.listener(); await nextTick(); assert.equal(marker.hidden, false); @@ -111,7 +110,7 @@ test('the marker follows only its captured point, hides beyond the limb, and rel }); test('an unchanged frame publishes nothing, and an older owner leaves a newer marker', (t) => { - const { owner, viewer, store, frames, container } = fixture(t); + const { owner, engine, store, frames, container } = fixture(t); owner.show({ x: 1 }); let publishes = 0; const publish = store.publish; @@ -124,8 +123,7 @@ test('an unchanged frame publishes nothing, and an older owner leaves a newer ma frames.listener = null; const newer = createWindInspectionMarker({ container, - viewer, - cesium, + engine, store, }); newer.show({ x: 2 }); From 6727aaadcd68e7c88b70c2d4931f266ff1bd6fe4 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 16:14:14 +0400 Subject: [PATCH 3/4] Fly, follow and draw the celestial ring through the contract The Bhote Koshi event flies with engine.camera instead of the viewer's camera, tools listens to camera.followChanged instead of the viewer's tracked entity, and the celestial ring uses the engine's host, frames, pose and render requests. The embedded story media no longer imports Cesium, which removes one of the three import exceptions. --- docs/CODE-BOUNDARIES.md | 16 +++-- src/app/tools.js | 43 +++++------- src/celestialRing.js | 101 +++++++++++----------------- src/celestialRingFocus.test.mjs | 22 +++--- src/data/bhoteKoshiEmbeddedMedia.js | 26 ++----- src/data/bhoteKoshiEvent.js | 41 ++++------- src/globe/cesium/celestialHost.js | 26 +++++++ 7 files changed, 121 insertions(+), 154 deletions(-) create mode 100644 src/globe/cesium/celestialHost.js diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index e5a9999ca..5e7d13243 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -100,13 +100,15 @@ panel carries no Navara or three. There is no blanket exception for layer The temporary `engineImportExceptions` list in `scripts/package-boundaries.json` contains exact file/package pairs with reasons. The checker reports the used -entries and rejects duplicates, missing reasons and unused entries. Its three -exceptions are the Bhote Koshi event, locator and embedded-media models in -`src/data/`, which still construct Cesium property records. Their drawing uses -shared displays. Exceptions do not relax the portable, contract or cross-engine -graph checks. Cesium annotation renderers live in `src/globe/cesium/annotations/`; -wind backend adapters live in their engine folders. Wind's older injected-SDK -helpers and the remaining annotation migration are separate debt. +entries and rejects duplicates, missing reasons and unused entries. Its two +exceptions are the Bhote Koshi event and locator models in `src/data/`, which +still construct Cesium property records. Their drawing uses shared displays. +Exceptions do not relax the portable, contract or cross-engine graph checks. +Cesium annotation renderers live in `src/globe/cesium/annotations/`. Wind's +Cesium rendering (the particle canvas, its GPU path and the relief shading) +lives in `src/globe/cesium/`; `src/layers/wind/` keeps the data, model, +presentation and engine-neutral inspection. The remaining annotation migration +is separate debt. Source-only factories use dedicated `layers//source` exports where available, preserving the same factory implementation without loading rendering. diff --git a/src/app/tools.js b/src/app/tools.js index 8533195bc..94fcec1d7 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -101,7 +101,7 @@ export async function createApplicationTools({ // Cesium only for now: it reads Cesium's camera (positionWC, its // running flight). attachScene(next, { defer: deferScene }) { - const viewer = cesiumViewerOf(next.engine); + const viewer = next.viewer; if (!viewer) return; deferScene( installGlobeInViewRecovery(viewer, { @@ -255,12 +255,6 @@ export async function createApplicationTools({ }; } -/** - * The Cesium viewer under an engine, or null on an engine without one: the - * one place the tools read it. - */ -const cesiumViewerOf = (engine) => engine?.extensions?.cesium?.viewer ?? null; - /** * The tool backend of each engine kind (see attachEngineTools). Cesium's * holds the tools that still drive its viewer, so they stay absent on @@ -273,13 +267,8 @@ const TOOL_BACKENDS = Object.freeze({ }); /** The Cesium tool backend: the shared tools and its own on the viewer. */ -function cesiumToolBackend({ engine, tileset }) { - const viewer = cesiumViewerOf(engine); - const { - governorRequestRender, - holdContinuousRender, - releaseContinuousRender, - } = renderGovernorFor(engine); +function cesiumToolBackend({ engine, viewer, tileset }) { + const { governorRequestRender } = renderGovernorFor(engine); return { viewer, startupChrome: {}, @@ -300,18 +289,6 @@ function cesiumToolBackend({ engine, tileset }) { requestRender: governorRequestRender, }), ); - // The follow camera recomputes the tracked target's dead-reckon - // position every frame — tracking anything is a per-frame animation. - // (perf wave 2) - const removeTrackingListener = - viewer.trackedEntityChanged.addEventListener(() => { - if (viewer.trackedEntity) holdContinuousRender('tracked-entity'); - else releaseContinuousRender('tracked-entity'); - }); - defer(() => { - removeTrackingListener(); - releaseContinuousRender('tracked-entity'); - }); }, // Hidden-state suspension (perf wave 2): when the window/tab is hidden, // stop the default render loop outright — a hidden canvas repaints for @@ -418,6 +395,20 @@ async function attachEngineTools( governor.install(); backend.attach?.({ data, styleManager, dataManager }, { signal, defer }); + // A followed target moves every frame, even while the camera is still. + const updateFollow = () => { + if (engine.camera.following()) + governor.holdContinuousRender('tracked-entity'); + else governor.releaseContinuousRender('tracked-entity'); + }; + const removeFollow = + engine.camera.followChanged.addEventListener(updateFollow); + updateFollow(); + defer(() => { + removeFollow(); + governor.releaseContinuousRender('tracked-entity'); + }); + // Install the explicit scope mask used by the DISPLAY controls: a canvas // in the engine's container, its terminus following the camera's height. installScopeMask(engine); diff --git a/src/celestialRing.js b/src/celestialRing.js index 887bcc801..5bb9b8f63 100644 --- a/src/celestialRing.js +++ b/src/celestialRing.js @@ -1,12 +1,7 @@ import * as geo from './geo/index.js'; -import { createCelestialView } from './celestialRing/view.js'; -import { - cesiumIcrfToFixed, - projectCelestialPoint, - flyCelestialViewer, -} from './globe/cesium/celestial.js'; +import { cesiumIcrfToFixed } from './globe/cesium/celestial.js'; import { createCelestialEphemeris } from './celestialRing/ephemeris.js'; -import { renderGovernorFor } from './renderGovernor.js'; +import { celestialHost } from './globe/cesium/celestialHost.js'; import { worldClock, worldTimeFor } from './time/worldClock.js'; // The ring shows the sun and moon at the time the sky is drawn. @@ -154,8 +149,8 @@ export function isCelestialRingStyleSupported(styleName) { * is inverted. When the celestial vector points almost directly into/out of the * camera, retain the prior stable bearing and fade the marker. * - * @param {number} rightComponent - Dot(direction, camera.rightWC). - * @param {number} upComponent - Dot(direction, camera.upWC). + * @param {number} rightComponent - Dot(direction, camera.right). + * @param {number} upComponent - Dot(direction, camera.up). * @param {number} lastAngle - Previous stable angle in radians. * @returns {{angle:number, opacity:number, stable:boolean}} */ @@ -245,29 +240,29 @@ export function earthDiscScreenRadius(cameraDistance, viewportHeight, fovy) { * Project the visible Earth disc into viewport coordinates. * Shared by full-globe UI treatments that must agree on visual containment. * - * @param {object} viewer - Cesium Viewer-like object. + * @param {object} engine - Globe engine or legacy scene host. * @param {number} width - Viewport width in CSS pixels. * @param {number} height - Viewport height in CSS pixels. - * @param {object} [scratchCenter] - Reusable Cesium Cartesian2 result. - * @param {object} [scratchToCenter] - Reusable Cesium Cartesian3 result. + * @param {object} [scratchCenter] - Reusable Cartesian2 result. + * @param {object} [scratchToCenter] - Reusable Cartesian3 result. * @param {import('./globe/contract.js').GlobeProjector | null} [projector] - * The globe engine's projector; without one, Cesium's SceneTransforms * (the same projection on a Cesium scene). * @returns {object|null} */ export function projectEarthDiscToViewport( - viewer, + engine, width, height, scratchCenter = undefined, scratchToCenter = undefined, projector = null, ) { - const camera = viewer?.camera; - const scene = viewer?.scene; - if (!camera || !scene || !(width > 0) || !(height > 0)) return null; + if (!engine?.camera || !(width > 0) || !(height > 0)) return null; + const host = engine.camera.pose ? engine : celestialHost(engine); + const camera = host.camera.pose(); - const distance = geo.cartesian3.magnitude(camera.positionWC); + const distance = geo.cartesian3.magnitude(camera.position); if ( !Number.isFinite(distance) || distance <= geo.ellipsoid.WGS84.maximumRadius @@ -275,22 +270,19 @@ export function projectEarthDiscToViewport( return null; const toCenter = geo.cartesian3.negate( - camera.positionWC, + camera.position, scratchToCenter || geo.cartesian3.create(), ); - if (geo.cartesian3.dot(camera.directionWC, toCenter) <= 0) return null; + if (geo.cartesian3.dot(camera.direction, toCenter) <= 0) return null; - const center = projector - ? projector.worldToWindow(geo.cartesian3.ZERO, scratchCenter) - : projectCelestialPoint(scene, geo.cartesian3.ZERO, scratchCenter); + const center = (projector ?? host.projector).worldToWindow( + geo.cartesian3.ZERO, + scratchCenter, + ); if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y)) return null; - const earthRadius = earthDiscScreenRadius( - distance, - height, - camera.frustum?.fovy, - ); + const earthRadius = earthDiscScreenRadius(distance, height, camera.fovy); if (!earthRadius) return null; const keyhole = getKeyholeGeometry(width, height); @@ -401,7 +393,7 @@ let rings = 0; /** * Screen-space ring and celestial direction overlay for the full-globe view. * The overlay is presented to the celestial ring store, whose view renders it - * inside the viewer's container and hands back the two canvases this paints. + * inside the engine's container and hands back the two canvases this paints. */ export class CelestialRing { /** @@ -416,13 +408,7 @@ export class CelestialRing { viewer, { enabled = true, onAutoDisable = null, store, engine = null } = {}, ) { - this._engine = engine ?? (viewer?.kind ? viewer : null); - this._engineView = viewer?.kind ? createCelestialView(viewer) : null; - this.viewer = this._engineView?.view ?? viewer; - this._projector = engine?.projector ?? null; - this._renderGovernor = renderGovernorFor( - engine ?? (viewer?.kind ? viewer : null), - ); + this._engine = engine ?? (viewer?.kind ? viewer : celestialHost(viewer)); this.enabled = !!enabled; this.visible = false; this._onAutoDisable = @@ -455,8 +441,8 @@ export class CelestialRing { ); this._initView(store); - this._removePostRender = this.viewer.scene.postRender.addEventListener(() => - this._draw(), + this._removePostRender = this._engine.frames.postRender.addEventListener( + () => this._draw(), ); // Pre-existing staleness fix (perf wave 2 review): the ephemeris was // sampled once per visible-enable from the FROZEN app clock, so the @@ -470,7 +456,7 @@ export class CelestialRing { // dirty flag up immediately. (review finding) this._ephemerisDirty = true; if (typeof document !== 'undefined' && document.hidden) return; - this._renderGovernor?.governorRequestRender('celestial-ephemeris'); + this._engine.render.request('celestial-ephemeris'); }, 60_000); // A jump in the world time moves the sun and moon at once. // Played through, the sky moves on each minute of world time it passes. @@ -495,7 +481,7 @@ export class CelestialRing { this._view = { id: ++rings, owner, - container: this.viewer.container, + container: this._engine.host.container, enabled: this.enabled, visible: false, globeVisible: null, @@ -548,8 +534,7 @@ export class CelestialRing { this._moonCtx = ctx; this._moonRenderKey = ''; } else return; - if (canvas && this.enabled) - this._renderGovernor?.governorRequestRender('celestial-ring'); + if (canvas && this.enabled) this._engine.render.request('celestial-ring'); } /** Enable or disable the user preference for the effect. */ @@ -563,7 +548,7 @@ export class CelestialRing { // all. Camera motion covers every later repaint; the 60 s ephemeris // timer requests its own. (perf wave 2 fix — owner playtest finding) if (this.enabled !== wasEnabled) - this._renderGovernor?.governorRequestRender('celestial-ring'); + this._engine.render.request('celestial-ring'); if (!this.enabled) { this.visible = false; this._clear(); @@ -576,7 +561,7 @@ export class CelestialRing { /** Whether the current camera already frames the complete globe inside the keyhole. */ isGlobeFullyVisible() { - const canvas = this.viewer.scene.canvas; + const canvas = this._engine.host.canvas; const width = canvas.clientWidth || canvas.width; const height = canvas.clientHeight || canvas.height; if (!(width > 0 && height > 0)) return false; @@ -591,10 +576,10 @@ export class CelestialRing { * @returns {boolean} Whether a valid camera flight was started. */ focusFullGlobe({ duration = 2.4 } = {}) { - const canvas = this.viewer.scene.canvas; + const canvas = this._engine.host.canvas; const height = canvas.clientHeight || canvas.height; - const cartographic = this.viewer.camera.positionCartographic; - const fovy = this.viewer.camera.frustum?.fovy; + const pose = this._engine.camera.pose(); + const { cartographic, fovy } = pose; if ( !(height > 0) || !cartographic || @@ -620,7 +605,7 @@ export class CelestialRing { this._focusInProgress = true; const finishFocus = () => { this._focusInProgress = false; - this.viewer.scene.requestRender?.(); + this._engine.render.request('celestial-ring'); }; const flight = { destination: geo.cartesian3.fromRadians( @@ -629,7 +614,7 @@ export class CelestialRing { altitude, ), orientation: { - heading: this.viewer.camera.heading, + heading: pose.heading, pitch: -geo.math.PI_OVER_TWO, roll: 0, }, @@ -640,9 +625,7 @@ export class CelestialRing { if (!this.isGlobeFullyVisible()) this._autoDisable(); }, }; - if (this._engine) - this._engine.camera.flyTo({ ...flight, easing: 'cubicInOut' }); - else flyCelestialViewer(this.viewer, flight); + this._engine.camera.flyTo({ ...flight, easing: 'cubicInOut' }); return true; } @@ -713,12 +696,11 @@ export class CelestialRing { /** Return the projected Earth disc used for the full-globe gate. */ _projectedEarthDisc(width, height) { return projectEarthDiscToViewport( - this.viewer, + this._engine, width, height, this._screenCenter, this._toCenter, - this._projector, ); } @@ -795,7 +777,7 @@ export class CelestialRing { const now = performance.now(); if (now < this._nextDrawAt) return; this._nextDrawAt = now + 1000 / CELESTIAL_MAX_FRAME_RATE; - const canvas = this.viewer.scene.canvas; + const canvas = this._engine.host.canvas; const width = canvas.clientWidth || canvas.width; const height = canvas.clientHeight || canvas.height; if (!(width > 0 && height > 0)) return; @@ -828,15 +810,15 @@ export class CelestialRing { const time = skyTime(); if (!this._updateEphemeris(time)) return; this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) }); - const camera = this.viewer.camera; + const camera = this._engine.camera.pose(); const sunProjection = celestialScreenAngle( - geo.cartesian3.dot(this._sunFixed, camera.rightWC), - geo.cartesian3.dot(this._sunFixed, camera.upWC), + geo.cartesian3.dot(this._sunFixed, camera.right), + geo.cartesian3.dot(this._sunFixed, camera.up), this._sunAngle, ); const moonProjection = celestialScreenAngle( - geo.cartesian3.dot(this._moonFixed, camera.rightWC), - geo.cartesian3.dot(this._moonFixed, camera.upWC), + geo.cartesian3.dot(this._moonFixed, camera.right), + geo.cartesian3.dot(this._moonFixed, camera.up), this._moonAngle, ); if (sunProjection.stable) this._sunAngle = sunProjection.angle; @@ -923,6 +905,5 @@ export class CelestialRing { this._store.publish(null); } this._sunCanvas = this._sunCtx = this._moonCanvas = this._moonCtx = null; - this._engineView?.destroy(); } } diff --git a/src/celestialRingFocus.test.mjs b/src/celestialRingFocus.test.mjs index 382d5c8ef..3033bff2c 100644 --- a/src/celestialRingFocus.test.mjs +++ b/src/celestialRingFocus.test.mjs @@ -5,25 +5,19 @@ import { CelestialRing } from './celestialRing.js'; test('full globe focus uses the engine without a Cesium global', () => { let flight; const engine = { + host: { canvas: { clientWidth: 1200, clientHeight: 800 } }, + render: { request() {} }, camera: { - flyTo: (options) => { - flight = options; - }, + pose: () => ({ + cartographic: { longitude: 0, latitude: 0 }, + heading: 0, + fovy: Math.PI / 3, + }), + flyTo: (options) => { flight = options; }, }, }; const ring = Object.assign(Object.create(CelestialRing.prototype), { _engine: engine, - viewer: { - scene: { - canvas: { clientWidth: 1200, clientHeight: 800 }, - requestRender() {}, - }, - camera: { - positionCartographic: { longitude: 0, latitude: 0 }, - heading: 0, - frustum: { fovy: Math.PI / 3 }, - }, - }, }); assert.equal(globalThis.Cesium, undefined); assert.equal(ring.focusFullGlobe(), true); diff --git a/src/data/bhoteKoshiEmbeddedMedia.js b/src/data/bhoteKoshiEmbeddedMedia.js index d75bf8612..63ee90c10 100644 --- a/src/data/bhoteKoshiEmbeddedMedia.js +++ b/src/data/bhoteKoshiEmbeddedMedia.js @@ -1,4 +1,3 @@ -import * as Cesium from 'cesium'; import { t, msg, onLocaleChange } from '../i18n/index.js'; const YOUTUBE_HOSTS = new Set([ @@ -538,25 +537,12 @@ function ensureProviderConnections(documentRef, provider) { } } -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, - camera.positionWC, - new Cesium.Cartesian3(), - ); - if (Cesium.Cartesian3.dot(delta, camera.directionWC) <= 0) return null; - } - const pixel = engine - ? engine.projector.worldToWindow(anchor, {}) - : Cesium.SceneTransforms.worldToWindowCoordinates(viewer.scene, anchor); +function anchorOnScreen(engine, anchor, root) { + if (!engine || !anchor || !root) return null; + if (!engine.projector.horizonVisible(anchor)) return null; + const pixel = engine.projector.worldToWindow(anchor, {}); if (!pixel) return null; - const canvasBounds = ( - engine?.host.canvas ?? viewer?.canvas - )?.getBoundingClientRect?.(); + const canvasBounds = engine.host.canvas?.getBoundingClientRect?.(); const rootBounds = root.getBoundingClientRect?.(); if (!canvasBounds || !rootBounds) return null; const x = pixel.x + canvasBounds.left - rootBounds.left; @@ -855,7 +841,7 @@ export function createBhoteKoshiEmbeddedMedia({ const position = () => { if (!record) return; - const projected = anchorOnScreen(viewer, engine, record.anchor, root); + const projected = anchorOnScreen(engine, record.anchor, root); record.card.hidden = !projected; if (projected) { placeCard(record.card, record.leader, projected); diff --git a/src/data/bhoteKoshiEvent.js b/src/data/bhoteKoshiEvent.js index 5082bc48e..66971de2e 100644 --- a/src/data/bhoteKoshiEvent.js +++ b/src/data/bhoteKoshiEvent.js @@ -884,7 +884,7 @@ function openExternal(url) { if (target) target.opener = null; } -function focusView(viewer, viewpoint, engine) { +function focusView(engine, viewpoint) { announceNavigationAuthority('bhote-koshi-event-focus'); const target = Cesium.Cartesian3.fromDegrees( viewpoint.lon, @@ -896,32 +896,20 @@ function focusView(viewer, viewpoint, engine) { 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); - }, + 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, - duration: 2.2, - complete: () => { - viewer.camera.lookAt(target, offset); - viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); }, - }); + ); } function setElementProperty(element, property, value) { @@ -3521,8 +3509,7 @@ export function createBhoteKoshiEventLayer({ _embeddedEvidenceIndex = -1; _embeddedMedia?.hide?.(); setSplit(0.5); - if (cameraForScene()) - focusView(_viewer, _event.viewpoints.corridor, _engine); + if (_engine) focusView(_engine, _event.viewpoints.corridor); } // Each attachment and release advances this. An attachment that resumes diff --git a/src/globe/cesium/celestialHost.js b/src/globe/cesium/celestialHost.js new file mode 100644 index 000000000..079d09286 --- /dev/null +++ b/src/globe/cesium/celestialHost.js @@ -0,0 +1,26 @@ +// Compatibility for callers that still construct the ring on a bare viewer. +import { flyCelestialViewer, projectCelestialPoint } from './celestial.js'; + +export function celestialHost(viewer) { + return { + host: { container: viewer.container, canvas: viewer.scene.canvas }, + frames: { postRender: viewer.scene.postRender }, + camera: { + pose: () => ({ + position: viewer.camera.positionWC, + cartographic: viewer.camera.positionCartographic, + direction: viewer.camera.directionWC, + up: viewer.camera.upWC, + right: viewer.camera.rightWC, + heading: viewer.camera.heading, + fovy: viewer.camera.frustum?.fovy, + }), + flyTo: (options) => flyCelestialViewer(viewer, options), + }, + projector: { + worldToWindow: (position, result) => + projectCelestialPoint(viewer.scene, position, result), + }, + render: { request: () => viewer.scene.requestRender?.() }, + }; +} From aac65c21c40c736c4ba7c23e3f4cbc72d03053d3 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 16:14:14 +0400 Subject: [PATCH 4/4] Open the orbital cockpit on Navara The orbital view, sensor zoom, ground focus and satellite switching run on the contract: camera.setFovy sets the vertical lens on both engines and the fake, including sub-degree angles on Navara through the guarded core bridge, with a contract check and mutants. Cesium's imagery look-ahead stays inside its adapter. KNOWN-ISSUES keeps the two parts Navara still lacks: imagery prefetch for the orbital view and finest-detail meshes for the ship bridge waterline. --- docs/KNOWN-ISSUES.md | 11 +- scripts/contract-mutants.test.mjs | 14 ++ scripts/engine-coupling-baseline.json | 8 +- scripts/package-boundaries.json | 39 ++-- src/globe/cesium/camera.js | 8 + src/globe/cesium/camera.test.mjs | 44 ++++ src/globe/cesium/orbitalImagery.js | 128 ++++++++++++ src/globe/contract.js | 5 + src/globe/navara/cameraModel.js | 31 ++- src/globe/navara/createNavaraEngine.js | 12 +- src/globe/navara/createNavaraEngine.test.mjs | 14 ++ src/globe/navara/internals.js | 14 ++ src/globe/navara/internals.test.mjs | 31 ++- src/globe/testing/contractSuite/camera.mjs | 55 +++++ src/globe/testing/fakeEngine.js | 17 +- src/ui/cockpitController.js | 5 +- src/ui/cockpitController.test.mjs | 8 +- src/ui/cockpitHost.js | 10 + src/ui/cockpitOrbitalFocus.js | 5 +- src/ui/cockpitOrbitalFocus.test.mjs | 3 + src/ui/cockpitOrbitalSatellites.js | 4 +- src/ui/cockpitOrbitalView.js | 199 +++---------------- src/ui/cockpitOrbitalView.test.mjs | 48 ++++- 23 files changed, 479 insertions(+), 234 deletions(-) create mode 100644 src/globe/cesium/orbitalImagery.js diff --git a/docs/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md index 1ed1f74d9..d4396825f 100644 --- a/docs/KNOWN-ISSUES.md +++ b/docs/KNOWN-ISSUES.md @@ -33,14 +33,15 @@ Status: Open (manual map selection) Navara does not report tile errors, so a failing map source does not trigger fallback to another map. Select another map manually when tiles fail to load. -### Orbital cockpit is unavailable on Navara +### Orbital imagery look-ahead is unavailable on Navara Status: Open (Cesium only) -The Navara engine does not offer the satellite cockpit, including sensor -zoom, ground focus and satellite switching. Satellites can still be selected -and tracked on the globe. The orbital entry control stays hidden. Switch to -Cesium to use the orbital cockpit. +The orbital cockpit works on both engines. On Navara it loads imagery for +its current view but does not prefetch imagery ahead of the ground track. +At high optical zoom, imagery can arrive later than on Cesium. Navara's +package does not expose a raster prefetch operation sharing its tile cache +and request priorities. ### Ship bridge waterline uses loaded geometry on Navara diff --git a/scripts/contract-mutants.test.mjs b/scripts/contract-mutants.test.mjs index 96252b91a..c6779527c 100644 --- a/scripts/contract-mutants.test.mjs +++ b/scripts/contract-mutants.test.mjs @@ -15,6 +15,20 @@ const handle = (engine, area, factory, mutate) => }); const mutants = [ + [ + 'field of view changes are ignored', + 'camera.setFovy: changes projection', + (e) => { e.camera.setFovy = () => {}; }, + ], + [ + 'optical fields below one degree are ignored', + 'camera.setFovy: changes projection', + (e) => { + replace(e.camera, 'setFovy', (set) => (radians) => { + if (radians >= Math.PI / 180) set(radians); + }); + }, + ], [ 'retarget omits followChanged', 'camera.following: set, retarget', diff --git a/scripts/engine-coupling-baseline.json b/scripts/engine-coupling-baseline.json index 3d2914c0f..dd2d4413b 100644 --- a/scripts/engine-coupling-baseline.json +++ b/scripts/engine-coupling-baseline.json @@ -6,14 +6,14 @@ "engineSwitchLayerIds": 3, "cesiumLayerRendererLines": 0, "navaraLayerRendererLines": 0, - "cesiumImportFiles": 3, - "viewerLines": 784, + "cesiumImportFiles": 2, + "viewerLines": 666, "navaraKindChecks": 1, "engineSniffCalls": 0, - "engineExtensionReads": 3, + "engineExtensionReads": 2, "groundEffectPasses": 0, "cesiumSdkLoads": 3, "screenSpaceHandlerFiles": 2, - "engineNameLines": 21 + "engineNameLines": 20 } } diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 33b529d69..5a87daee3 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1,10 +1,5 @@ { "engineImportExceptions": [ - { - "file": "src/data/bhoteKoshiEmbeddedMedia.js", - "package": "cesium", - "reason": "Authored story media still uses Cesium positions and property records." - }, { "file": "src/data/bhoteKoshiEvent.js", "package": "cesium", @@ -978,7 +973,8 @@ "src/ui/unitLabels.js", "src/geo/", "src/globe/cesium/doubleClick.js", - "src/globe/cesium/orbital.js" + "src/globe/cesium/orbital.js", + "src/globe/cesium/orbitalImagery.js" ], "external": ["cesium"] }, @@ -1261,11 +1257,9 @@ "src/globe/cesium/doubleClick.js", "src/celestialRing.js", "src/celestialRing/ephemeris.js", - "src/celestialRing/view.js", "src/data/detectionCohort.js", "src/data/labelArbiter.js", "src/globe/cesium/celestial.js", - "src/layers/shared/render/engineView.js", "src/overlays/worldOverlay.js", "src/overlays/worldOverlayDraw.js", "src/overlays/worldOverlayTokens.js", @@ -1273,7 +1267,9 @@ "src/data/trackedReadout.js", "src/ui/releaseTrackers.js", "src/globe/cesium/orbital.js", - "src/time/clock.js" + "src/time/clock.js", + "src/globe/cesium/celestialHost.js", + "src/globe/cesium/orbitalImagery.js" ], "external": ["cesium", "vue", "pinia", "vue-i18n"] }, @@ -2521,11 +2517,8 @@ "src/layers/wind/model.js", "src/layers/wind/fields.js", "src/layers/wind/streamlines.js", - "src/layers/wind/gpuRendering.js", - "src/layers/wind/relief.js", "src/layers/wind/inspection.js", "src/layers/wind/presentation.js", - "src/layers/wind/rendering.js", "src/layers/wind/source.js", "src/sources/windBody.js", "src/data/detection.js", @@ -3458,7 +3451,6 @@ "src/layers/localAdsb/render/display.js", "src/layers/shared/render/engineView.js", "src/globe/cesium/celestial.js", - "src/celestialRing/view.js", "src/globe/navara/ground/pulse.js", "src/globe/navara/shaderChunks.js", "src/globe/navara/sceneDepth.js", @@ -3593,7 +3585,12 @@ "src/globe/cesium/orbital.js", "src/globe/cesium/worldTime.js", "src/data/geoImportLimits.js", - "src/data/geoImportPlan.js" + "src/data/geoImportPlan.js", + "src/globe/cesium/celestialHost.js", + "src/globe/cesium/orbitalImagery.js", + "src/globe/cesium/windGpuRendering.js", + "src/globe/cesium/windRelief.js", + "src/globe/cesium/windRendering.js" ], "external": [ "hls.js", @@ -3650,11 +3647,8 @@ "src/layers/wind/model.js", "src/layers/wind/fields.js", "src/layers/wind/streamlines.js", - "src/layers/wind/gpuRendering.js", - "src/layers/wind/relief.js", "src/layers/wind/inspection.js", "src/layers/wind/presentation.js", - "src/layers/wind/rendering.js", "src/app/layers/aisLiveVessels.js", "src/app/layers/alprCameras.js", "src/app/layers/bikeshare.js", @@ -4212,7 +4206,6 @@ "src/layers/localAdsb/render/display.js", "src/layers/shared/render/moverBank.js", "src/globe/cesium/celestial.js", - "src/celestialRing/view.js", "src/layers/shared/render/engineView.js", "src/layers/firms/rectangle.js", "src/time/worldClock.js", @@ -4306,7 +4299,11 @@ "src/data/ucsSatellites.js", "src/data/imagingSensors.js", "src/data/planespottersPhoto.js", - "src/data/geoImportLimits.js" + "src/data/geoImportLimits.js", + "src/globe/cesium/celestialHost.js", + "src/globe/cesium/windGpuRendering.js", + "src/globe/cesium/windRelief.js", + "src/globe/cesium/windRendering.js" ], "external": [ "hls.js", @@ -5013,7 +5010,6 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/celestialRing/view.js", "src/layers/shared/render/engineView.js", "src/globe/cesium/sceneProjection.js", "src/layers/shared/color.js", @@ -5031,7 +5027,8 @@ "src/data/trackedReadout.js", "src/data/trackingGesture.js", "src/time/worldClock.js", - "src/time/clock.js" + "src/time/clock.js", + "src/globe/cesium/celestialHost.js" ], "external": ["cesium", "egm96-universal", "pbf"] }, diff --git a/src/globe/cesium/camera.js b/src/globe/cesium/camera.js index 516457a5f..142dc5279 100644 --- a/src/globe/cesium/camera.js +++ b/src/globe/cesium/camera.js @@ -179,6 +179,14 @@ export function createCesiumCamera(viewer) { } const camera = { + setFovy(radians) { + if (!Number.isFinite(radians) || radians <= 0 || radians >= Math.PI) + throw new RangeError('Field of view must be between 0 and PI'); + const aspect = cesium.frustum.aspectRatio; + cesium.frustum.fov = + aspect > 1 ? 2 * Math.atan(Math.tan(radians / 2) * aspect) : radians; + viewer.scene.requestRender(); + }, pose() { const position = toGeo3(cesium.positionWC); return { diff --git a/src/globe/cesium/camera.test.mjs b/src/globe/cesium/camera.test.mjs index a5dd334d1..8a88a9205 100644 --- a/src/globe/cesium/camera.test.mjs +++ b/src/globe/cesium/camera.test.mjs @@ -579,3 +579,47 @@ test('setView ends the engine flight through its cancellation callback', () => { assert.deepEqual(outcome, ['cancel']); assert.equal(camera.flightActive(), false); }); + +test('completing a sphere flight fires only complete before its look-at', () => { + const { scene, engine } = createCameraViewer(); + scene.tweens = new Cesium.TweenCollection(); + scene.screenSpaceCameraController = { enableInputs: true }; + const target = geo.cartesian3.fromDegrees(85.9, 27.9, 2000); + const offset = { heading: 0.4, pitch: -0.6, range: 12000 }; + const outcome = []; + engine.camera.flyToSphere( + { center: target, radius: 80 }, + { + ...offset, + duration: 2.2, + complete() { + outcome.push('complete'); + engine.camera.lookAt(target, offset); + engine.camera.release(); + }, + cancel: () => outcome.push('cancel'), + }, + ); + engine.camera.completeFlight(); + engine.camera.completeFlight(); + assert.deepEqual(outcome, ['complete']); + assert.equal(engine.camera.flightActive(), false); + assert.ok( + Math.abs( + geo.cartesian3.distance(engine.camera.pose().position, target) - + offset.range, + ) < 0.01, + ); + engine.destroy(); +}); + +test('vertical field of view changes the real Cesium projection and restores it', () => { + const { camera, engine } = createCameraViewer(); + const saved = camera.frustum.fovy; + engine.camera.setFovy(0.2); + assert.ok(Math.abs(camera.frustum.fovy - 0.2) < 1e-12); + assert.ok(Math.abs(engine.camera.pose().fovy - 0.2) < 1e-12); + engine.camera.setFovy(saved); + assert.ok(Math.abs(camera.frustum.fovy - saved) < 1e-12); + engine.destroy(); +}); diff --git a/src/globe/cesium/orbitalImagery.js b/src/globe/cesium/orbitalImagery.js new file mode 100644 index 000000000..6d31ff366 --- /dev/null +++ b/src/globe/cesium/orbitalImagery.js @@ -0,0 +1,128 @@ +import { + createOrbitalImageryRequest, + isGeographicProjection, +} from './orbital.js'; +import { + centreLinesFirst, + ORBITAL_PREFETCH_MAX_TILES, + footprintWidthKm, + groundTrackAhead, + imageryLevelForView, + tilesForPoints, +} from '../../cockpitOrbital.js'; + +const PREFETCH_MEMORY = 512; + +/** Warm the base imagery cache without taking slots from visible tiles. */ +export function prefetchOrbitalImagery(viewer, state, info, fov, aspect) { + try { + const globe = viewer.scene?.globe; + if (!globe || globe.show === false) return 0; + const provider = baseImageryProvider(viewer); + if (!provider) return 0; + // Another base map: its tiles have not been asked for. + // A fresh set, so a late failure from the old map's requests cannot + // drop a tile the new one has fetched. + if (provider !== state.prefetchProvider) { + for (const request of state.prefetchRequests) request.cancel?.(); + state.prefetchRequests = new Set(); + state.prefetched = new Set(); + state.prefetchProvider = provider; + } + const viewKm = footprintWidthKm(info.altitudeM, fov, aspect); + // The ground one CSS pixel covers at the nadir point, as Cesium's + // screen-space error measures it: the vertical field of view over the + // canvas height, at the satellite's altitude. + const canvas = viewer.scene?.canvas; + const heightPx = canvas?.clientHeight || canvas?.height; + const terrain = + viewer.terrainProvider ?? viewer.scene?.globe?.terrainProvider; + const level = imageryLevelForView(provider.tilingScheme, { + metresPerPixel: (info.altitudeM * 2 * Math.tan(fov / 2)) / heightPx, + latitudeDeg: info.latitude, + geographic: isGeographicProjection(provider.tilingScheme.projection), + tileWidthPx: provider.tileWidth, + minimumLevel: provider.minimumLevel, + maximumLevel: provider.maximumLevel, + maximumScreenSpaceError: globe.maximumScreenSpaceError, + terrainLevelErrorM: + typeof terrain?.getLevelMaximumGeometricError === 'function' + ? (terrainLevel) => + terrain.getLevelMaximumGeometricError(terrainLevel) + : undefined, + }); + if (level === null) return 0; + const memory = state.prefetched; + const tiles = tilesForPoints( + provider.tilingScheme, + centreLinesFirst(groundTrackAhead(info, viewKm)), + level, + { + maxTiles: ORBITAL_PREFETCH_MAX_TILES, + skip: (key) => memory.has(key), + }, + ); + let asked = 0; + for (const tile of tiles) { + let pending; + let request; + try { + // The tiles on screen keep their slots: Cesium's scheduler declines + // this request while the imagery server's slots are taken (shared + // with the globe's own tile requests, throttleByServer) or the + // scheduler is full, and at the lowest priority it is the first + // dropped from the priority heap, never one the screen asked for. + pending = provider.requestImage( + tile.x, + tile.y, + tile.level, + (request = createOrbitalImageryRequest()), + ); + } catch { + continue; + } + // A provider that declines now (throttled) is asked again later. + if (!pending) continue; + const requests = state.prefetchRequests; + requests.add(request); + Promise.resolve(pending).then( + () => requests.delete(request), + (error) => { + requests.delete(request); + // Dropped by the scheduler (rejected with no error): asked again + // later. A tile that failed to load stays remembered, so one + // missing from the map is not asked for every look-ahead. + if (error === undefined) memory.delete(tile.key); + }, + ); + memory.add(tile.key); + if (memory.size > PREFETCH_MEMORY) + memory.delete(memory.values().next().value); + asked += 1; + } + return asked; + } catch { + return 0; + } +} + +/** + * The provider of the lowest shown imagery layer, the base map the globe + * drapes, when it can be asked for a tile; else null. + */ +function baseImageryProvider(viewer) { + const layers = viewer.imageryLayers ?? viewer.scene?.imageryLayers; + const count = Number.isInteger(layers?.length) ? layers.length : 0; + for (let i = 0; i < count; i += 1) { + const layer = layers.get(i); + if (!layer || layer.show === false || layer.alpha === 0) continue; + const provider = layer.imageryProvider; + if ( + typeof provider?.requestImage === 'function' && + typeof provider.tilingScheme?.positionToTileXY === 'function' + ) + return provider; + return null; + } + return null; +} diff --git a/src/globe/contract.js b/src/globe/contract.js index 08dfc95c8..b46d02a28 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -89,6 +89,10 @@ * engine-owned object: the same object on every read, holding the current * position (at the latest when read). For per-frame readers, which must not * allocate; read-only, copy it to keep a value. + * @property {(radians: number) => void} setFovy Set the vertical field of + * view (finite, strictly between 0 and PI). Resizing retains the angle + * across the wider canvas side, as Cesium does. Does not cancel flights + * or following; pose, projection and picking use the new field of view. * @property {() => number} height The camera's height above the WGS84 * ellipsoid (the pose's cartographic height), without building a pose. * @property {(view: { destination: Cartesian3 | Rectangle, orientation?: Orientation }) => void} setView @@ -975,6 +979,7 @@ const SHAPE = { host: { container: isObject, canvas: isObject, size: isFunction }, camera: { pose: isFunction, + setFovy: isFunction, livePosition: (value) => value != null && isNumber(value.x) && diff --git a/src/globe/navara/cameraModel.js b/src/globe/navara/cameraModel.js index c3ad4d4f1..fb2c231c1 100644 --- a/src/globe/navara/cameraModel.js +++ b/src/globe/navara/cameraModel.js @@ -19,7 +19,6 @@ import * as geo from '../../geo/index.js'; import { createEngineEvent } from '../contract.js'; import { - cesiumFovy, headingPitchRoll, horizonVisible, lookAtFrame, @@ -83,6 +82,7 @@ function posesNear(a, b, distance, angle) { * stop: () => void, * size: () => { width: number, height: number }, * requestFrame: () => void, + * setFovy?: (radians: number) => void, * suspendCameraInputs?: () => () => void, * }, * clock?: { now: () => number, setTimeout: Function, clearTimeout: Function }, @@ -120,6 +120,13 @@ export function createCameraModel({ ...orientationVectors(position, 0, -math.PI_OVER_TWO, 0), }; })(); + let fov = Math.PI / 3; + const fovy = () => { + const { width, height } = port.size(); + return width > height + ? 2 * Math.atan((Math.tan(fov / 2) * height) / width) + : fov; + }; let transform = [...matrix4.IDENTITY]; let percentageChanged = DEFAULT_PERCENTAGE_CHANGED; let flight = null; @@ -139,7 +146,7 @@ export function createCameraModel({ const view = () => { const { width, height } = port.size(); - return { ...state, fovy: cesiumFovy(width, height), width, height }; + return { ...state, fovy: fovy(), width, height }; }; // A programmatic pose: exact here, pushed to Navara for drawing. @@ -154,11 +161,7 @@ export function createCameraModel({ const { width, height } = port.size(); const position = 'west' in destination - ? rectangleViewPosition( - destination, - cesiumFovy(width, height), - width / height, - ) + ? rectangleViewPosition(destination, fovy(), width / height) : copy3(destination); if (orientation && 'direction' in orientation) return { @@ -311,6 +314,17 @@ export function createCameraModel({ const livePosition = cartesian3.create(); const camera = { + setFovy(radians) { + if (!Number.isFinite(radians) || radians <= 0 || radians >= Math.PI) + throw new RangeError('Field of view must be between 0 and PI'); + const { width, height } = port.size(); + fov = + width > height + ? 2 * Math.atan((Math.tan(radians / 2) * width) / height) + : radians; + port.setFovy?.(radians); + port.requestFrame(); + }, get livePosition() { return cartesian3.clone(state.position, livePosition); }, @@ -356,8 +370,7 @@ export function createCameraModel({ transform = [...matrix4.IDENTITY]; const { width, height } = port.size(); const range = - options.range || - sphereRange(sphere.radius, cesiumFovy(width, height), width / height); + options.range || sphereRange(sphere.radius, fovy(), width / height); const frame = geo.transforms.eastNorthUpToFixedFrame( sphere.center, WGS84, diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js index f5bd521a0..b91a92f97 100644 --- a/src/globe/navara/createNavaraEngine.js +++ b/src/globe/navara/createNavaraEngine.js @@ -114,10 +114,13 @@ export function createNavaraEngine( width: canvas.clientWidth, height: canvas.clientHeight, }); + const applyFovy = (radians) => { + if (hooks.setCameraFovy) hooks.setCameraFovy(radians); + else view.camera.fov = math.toDegrees(radians); + }; const matchFov = () => { const { width, height } = size(); - if (width > 0 && height > 0) - view.camera.fov = math.toDegrees(cesiumFovy(width, height)); + if (width > 0 && height > 0) applyFovy(cesiumFovy(width, height)); }; matchFov(); @@ -131,6 +134,7 @@ export function createNavaraEngine( stop: () => view.setCamera({}), size, requestFrame: () => view.forceUpdate(), + setFovy: applyFovy, suspendCameraInputs: () => input.input.suspendCameraInputs(), }, ...(clock ? { clock } : {}), @@ -201,7 +205,9 @@ export function createNavaraEngine( model.afterFrame(); frames.postRender.raiseEvent(time); }, - resize: () => matchFov(), + resize: () => { + applyFovy(model.camera.pose().fovy); + }, }; for (const [name, handler] of Object.entries(handlers)) view.on(name, handler); diff --git a/src/globe/navara/createNavaraEngine.test.mjs b/src/globe/navara/createNavaraEngine.test.mjs index c95a5642a..1ef45efb5 100644 --- a/src/globe/navara/createNavaraEngine.test.mjs +++ b/src/globe/navara/createNavaraEngine.test.mjs @@ -769,3 +769,17 @@ test('a wheel during a flight with the camera inputs suspended does not cancel i assert.equal(engine.input.cameraInputsEnabled(), true); engine.destroy(); }); + +test('optical zoom reaches the drawn camera and survives a resize', () => { + const { engine, view } = setup(); + engine.camera.setFovy(0.2); + near(view.camera.fovs.at(-1), geo.math.toDegrees(0.2), 1e-12, 'drawn fovy'); + near(engine.camera.pose().fovy, 0.2, 1e-12, 'projector fovy'); + const fov = 2 * Math.atan(Math.tan(0.1) * engine.camera.pose().aspectRatio); + view.canvas.clientWidth = 600; + view.canvas.clientHeight = 900; + view.emit('resize'); + near(view.camera.fovs.at(-1), geo.math.toDegrees(fov), 1e-12, 'resized fovy'); + near(engine.camera.pose().fovy, fov, 1e-12, 'resized projector'); + engine.destroy(); +}); diff --git a/src/globe/navara/internals.js b/src/globe/navara/internals.js index 0bd314a3d..7003303ad 100644 --- a/src/globe/navara/internals.js +++ b/src/globe/navara/internals.js @@ -233,11 +233,25 @@ export function featureChangeQueue(view) { : null; } +/** + * Navara's public camera.fov setter drops angles below one degree, although + * the core supports them. Optical zoom needs the same frustum for drawing, + * tile selection and picking. Upstream ask: accept every positive FOV. + */ +export function setCameraFovy(view, radians) { + requireMember( + 'view._core.setFrustum', + typeof view?._core?.setFrustum === 'function', + ); + view._core.setFrustum((radians * 180) / Math.PI, undefined, undefined); +} + /** The hooks createNavaraEngine takes. */ export const navaraHooks = (view) => { assertNavaraInternals(view); return { retainTileGeometry, + setCameraFovy: (radians) => setCameraFovy(view, radians), tileRoots: () => tileRoots(view), isIdle: () => isIdle(view), dropOrphanFeatureChanges: () => diff --git a/src/globe/navara/internals.test.mjs b/src/globe/navara/internals.test.mjs index 07a06d8ea..8093481d8 100644 --- a/src/globe/navara/internals.test.mjs +++ b/src/globe/navara/internals.test.mjs @@ -13,7 +13,8 @@ const loader = registerHooks({ : next(specifier, context); }, }); -const { navaraHooks, forEachLuminanceUniform } = await import('./internals.js'); +const { navaraHooks, forEachLuminanceUniform, setCameraFovy } = + await import('./internals.js'); loader.deregister(); const { ModelMesh } = await import(sdkStub); @@ -201,3 +202,31 @@ test('feature changes of a deleted vector layer leave the queue; live ones stay' 0, ); }); + +test('optical zoom below one degree reaches the native frustum', async () => { + const { initSync, Core } = await import('@navaramap/engine'); + initSync({ + module: readFileSync( + new URL( + '../../../node_modules/@navaramap/engine/navara_wasm_bg.wasm', + import.meta.url, + ), + ), + }); + const core = new Core('optical-zoom-test'); + core.start(); + core.resize(1280, 800, 1); + core.update(0); + try { + const view = { _core: core }; + setCameraFovy(view, 0.0002); + core.update(16); + assert.ok(Math.abs(core.getCameraFOV() - 0.0002) < 1e-12); + setCameraFovy(view, Math.PI / 3); + core.update(32); + assert.ok(Math.abs(core.getCameraFOV() - Math.PI / 3) < 1e-12); + assert.throws(() => setCameraFovy({}, 0.0002), /setFrustum/); + } finally { + core.free(); + } +}); diff --git a/src/globe/testing/contractSuite/camera.mjs b/src/globe/testing/contractSuite/camera.mjs index a5cd60de4..021225eca 100644 --- a/src/globe/testing/contractSuite/camera.mjs +++ b/src/globe/testing/contractSuite/camera.mjs @@ -20,6 +20,61 @@ import { enuOffset } from '../../follow.js'; export function poseChecks(engine, { settle, until, nadirView }) { const { camera } = engine; return [ + [ + 'camera.setFovy: changes projection and rays without moving the camera', + async () => { + const saved = camera.pose(); + const size = engine.host.size(); + const pixel = { x: size.width * 0.65, y: size.height * 0.5 }; + const ray = camera.pickRay(pixel); + const point = cartesian3.add( + ray.origin, + cartesian3.multiplyByScalar(ray.direction, 1000, cartesian3.create()), + cartesian3.create(), + ); + const before = engine.projector.worldToWindow(point); + try { + camera.setFovy(saved.fovy / 128); + await settle(); + near( + camera.pose().fovy, + saved.fovy / 128, + 1e-10, + 'vertical field of view', + ); + near( + cartesian3.distance(camera.pose().position, saved.position), + 0, + 0.01, + 'position unchanged', + ); + const after = engine.projector.worldToWindow(point); + ensure(after.x > before.x, 'narrowing the field magnifies the image'); + const narrower = camera.pickRay(pixel); + ensure( + cartesian3.distance(narrower.direction, ray.direction) > 1e-3, + 'pick ray follows the lens', + ); + for (const invalid of [0, -1, Math.PI, NaN, Infinity]) { + let rejected = false; + try { + camera.setFovy(invalid); + } catch { + rejected = true; + } + ensure(rejected, 'invalid field of view is rejected'); + } + near( + camera.pose().fovy, + saved.fovy / 128, + 1e-10, + 'invalid input keeps the lens', + ); + } finally { + camera.setFovy(saved.fovy); + } + }, + ], [ 'camera.setView: heading, pitch and roll round-trip through pose', async () => { diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index 5cafbb937..e2575ae5c 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -195,7 +195,7 @@ export function createFakeEngine(options = {}) { let width = options.width ?? 1024; let height = options.height ?? 768; // Cesium's default: 60 degrees across the wider canvas dimension. - const fovy = options.fovy ?? cesiumFovy(width, height); + let fovy = options.fovy ?? cesiumFovy(width, height); const state = { position: cartesian3.create(), direction: cartesian3.create(), @@ -535,6 +535,13 @@ export function createFakeEngine(options = {}) { } const camera = { + setFovy(radians) { + if (!Number.isFinite(radians) || radians <= 0 || radians >= Math.PI) + throw new RangeError('Field of view must be between 0 and PI'); + record('camera.setFovy', [radians]); + fovy = radians; + engine.render.request('camera-fov'); + }, pose() { const angles = poseAngles(); return { @@ -1013,8 +1020,16 @@ export function createFakeEngine(options = {}) { ); }, setSize(nextWidth, nextHeight) { + const fov = + width > height + ? 2 * Math.atan((Math.tan(fovy / 2) * width) / height) + : fovy; width = nextWidth; height = nextHeight; + fovy = + width > height + ? 2 * Math.atan((Math.tan(fov / 2) * height) / width) + : fov; }, callsOf: (method) => fake.calls.filter((call) => call.method === method), resetCalls() { diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index f6689538d..07ffd3ddb 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -137,7 +137,6 @@ export class CockpitViewController { } = {}, ) { this.host = cockpitHost(engine, { viewer }); - // Orbital optics and imagery prefetch still belong to the Cesium viewer. this.viewer = viewer; this.services = services; this.destroyed = false; @@ -206,7 +205,7 @@ export class CockpitViewController { : () => false; // Nor a satellite layer to ride without one. this.isOrbitEntryAllowed = - viewer && typeof isOrbitEntryAllowed === 'function' + typeof isOrbitEntryAllowed === 'function' ? isOrbitEntryAllowed : () => false; this.isUtilityDisclosureOpen = @@ -311,7 +310,7 @@ export class CockpitViewController { ); this._listenerRemovers.push(routeSceneDoubleClick.call(this)); // In orbit a click on a mapped point keeps the camera on it. - if (viewer) this._listenerRemovers.push(routeSceneClick.call(this)); + this._listenerRemovers.push(routeSceneClick.call(this)); // In orbit the wheel steps the optical zoom (Cesium's own wheel zoom is // off while the Cockpit holds the camera). this._listen( diff --git a/src/ui/cockpitController.test.mjs b/src/ui/cockpitController.test.mjs index 982827223..4f0e388dd 100644 --- a/src/ui/cockpitController.test.mjs +++ b/src/ui/cockpitController.test.mjs @@ -59,7 +59,7 @@ test('Cockpit subscriptions are released once and retained actions cannot restar } }); -test('without a Cesium viewer orbital entry stays hidden and has no side effects', () => { +test('orbital entry is offered through the engine without a native viewer', () => { const env = environment(); try { const owner = new CockpitViewController(env.engine, { @@ -68,15 +68,15 @@ test('without a Cesium viewer orbital entry stays hidden and has no side effects militaryFlightsLayer: { getTrackedInfo: () => null }, satellitesLayer: { getCockpitSubject: () => ({ noradId: 25544, layerId: 'satellites' }), - setCockpitSubject: () => assert.fail('no satellite may be hidden'), + setCockpitSubject: () => true, }, isTr3b: () => false, }, isOrbitEntryAllowed: () => true, }); owner.syncEntry(); - assert.equal(owner.presence.entryAvailable, false); - assert.equal(owner.enter({ kind: 'satellite' }), false); + assert.equal(owner.presence.entryAvailable, true); + assert.equal(owner.presence.entryKind, 'satellite'); assert.equal(owner.orbital, null); assert.equal(env.engine.input.cameraInputsEnabled(), true); owner.dispose(); diff --git a/src/ui/cockpitHost.js b/src/ui/cockpitHost.js index f5d3b3b34..f636eab75 100644 --- a/src/ui/cockpitHost.js +++ b/src/ui/cockpitHost.js @@ -1,6 +1,8 @@ // Camera and aircraft ownership used by the shared first-person controller, // on the globe contract: the ridden aircraft is the engine follow's target, // the aircraft display's tracked glyph handle. +import { routeViewerClick } from '../globe/cesium/orbital.js'; +import { prefetchOrbitalImagery } from '../globe/cesium/orbitalImagery.js'; import { routeViewerDoubleClick } from '../globe/cesium/doubleClick.js'; /** @@ -25,6 +27,8 @@ export function cockpitHost(engine, { viewer = null } = {}) { release: () => engine.camera.following()?.stop(), restore: (target) => target.resumeAircraftFollow(), camera: engine.camera, + imageryReady: () => + engine.maps.photorealActive() || engine.surface.tilesLoaded(), cartographic: () => engine.camera.pose().cartographic, terrainHeight: (place) => engine.surface.terrainHeight(place), surface: engine, @@ -51,6 +55,12 @@ export function cockpitHost(engine, { viewer = null } = {}) { resumeInputs = null; } else resumeInputs ??= engine.input.suspendCameraInputs(); }, + prefetchImagery: (state, info, fov, aspect) => + viewer ? prefetchOrbitalImagery(viewer, state, info, fov, aspect) : 0, + routeClick: (handle) => + viewer + ? routeViewerClick(viewer, handle) + : engine.input.on('click', (event) => handle(event.position)), canvasPosition: (world) => engine.projector.worldToWindow(world) ?? null, routeDoubleClick: (handle) => viewer diff --git a/src/ui/cockpitOrbitalFocus.js b/src/ui/cockpitOrbitalFocus.js index 5084823cb..2bd23ef2a 100644 --- a/src/ui/cockpitOrbitalFocus.js +++ b/src/ui/cockpitOrbitalFocus.js @@ -9,7 +9,6 @@ * it ends with the session. */ import { cartesian3 } from '../geo/index.js'; -import { routeViewerClick } from '../globe/cesium/orbital.js'; import { clampOrbitalLook, easeLook } from '../cockpitLook.js'; import { ORBITAL_EARTH_RADIUS_M } from '../cockpitOrbital.js'; import { @@ -264,9 +263,7 @@ export function onOrbitalClick(position) { * (the draw tool borrows it) has replaced ours since. */ export function routeSceneClick() { - return routeViewerClick(this.viewer, (position) => - this.onOrbitalClick(position), - ); + return this.host.routeClick((position) => this.onOrbitalClick(position)); } /** diff --git a/src/ui/cockpitOrbitalFocus.test.mjs b/src/ui/cockpitOrbitalFocus.test.mjs index e4396a212..62e25fd78 100644 --- a/src/ui/cockpitOrbitalFocus.test.mjs +++ b/src/ui/cockpitOrbitalFocus.test.mjs @@ -289,6 +289,9 @@ function fixture(t, { reducedMotion = false } = {}) { const onMode = (event) => modeEvents.push(event.detail); window.addEventListener('gev:cockpit-mode-changed', onMode); const engine = createFakeEngine({ kind: 'cesium' }); + engine.camera.setView = (options) => viewer.camera.setView(options); + const following = engine.camera.following; + engine.camera.following = () => viewer.trackedEntity ? { stop() { viewer.trackedEntity = undefined; } } : following(); engine.fake.script('pick', (position) => picks.get(`${position.x},${position.y}`)); const cockpit = new CockpitViewController(engine, { viewer, diff --git a/src/ui/cockpitOrbitalSatellites.js b/src/ui/cockpitOrbitalSatellites.js index 43a29c302..c0f656b99 100644 --- a/src/ui/cockpitOrbitalSatellites.js +++ b/src/ui/cockpitOrbitalSatellites.js @@ -122,8 +122,8 @@ export function switchOrbitalSatellite(noradId) { } catch { tracked = false; } - // Tracking hands the camera to Cesium's follow; the orbital view keeps it. - this.viewer.trackedEntity = undefined; + // Tracking hands the camera to follow; the orbital view keeps it. + this.host.release(); const info = tracked ? this.readSatelliteInfo() : null; if (!info || String(info.noradId) !== String(noradId)) { // Nothing changed hands, or the layer could not take it: the view ends diff --git a/src/ui/cockpitOrbitalView.js b/src/ui/cockpitOrbitalView.js index a9536d60d..d51479c36 100644 --- a/src/ui/cockpitOrbitalView.js +++ b/src/ui/cockpitOrbitalView.js @@ -1,23 +1,14 @@ /** The orbital (satellite) view of the Cockpit controller: camera, zoom, look and HUD. */ -import { - createOrbitalImageryRequest, - isGeographicProjection, -} from '../globe/cesium/orbital.js'; import { formatNumber, msg, t } from '../i18n/index.js'; import { ORBITAL_DEFAULT_ZOOM, ORBITAL_PREFETCH_INTERVAL_MS, - centreLinesFirst, - ORBITAL_PREFETCH_MAX_TILES, ORBITAL_PREFETCH_MIN_ZOOM, ORBITAL_SETTLE_BLUR_PX, ORBITAL_ZOOM_LADDER, ORBITAL_ZOOM_TRANSITION_MS, - cesiumFrustumFovRad, footprintWidthKm, fovForZoom, - groundTrackAhead, - imageryLevelForView, interpolateZoomFov, nadirOrientation, nextOrbitalZoom, @@ -30,7 +21,6 @@ import { previousOrbitalZoom, sensorStripFraction, sensorSwathZoom, - tilesForPoints, wheelZoomStep, zoomSettleHolding, zoomTransitionState, @@ -86,7 +76,7 @@ const NADIR_LOOK = Object.freeze({ yawDeg: 0, pitchDeg: 0 }); * the exit can put it back exactly. */ export function beginOrbital() { - const frustum = this.viewer.camera?.frustum; + const pose = this.host.camera.pose(); const state = { imagery: acquireOrbitImagery(this.services), zoom: ORBITAL_DEFAULT_ZOOM, @@ -102,7 +92,9 @@ export function beginOrbital() { // A zoom step in flight: the magnification it started from and when. transition: null, previousUp: null, - savedFov: Number.isFinite(frustum?.fov) ? frustum.fov : null, + savedFov: Number.isFinite(pose.fovy) + ? 2 * Math.atan(Math.tan(pose.fovy / 2) * Math.max(1, pose.aspectRatio)) + : null, altitudeM: null, verticalFov: null, lastBriefMs: 0, @@ -135,8 +127,12 @@ export function endOrbital() { // Pre-loads still queued or in flight are for a view no longer shown. for (const request of state.prefetchRequests) request.cancel?.(); state.prefetchRequests.clear(); - const frustum = this.viewer.camera?.frustum; - if (frustum && state.savedFov !== null) frustum.fov = state.savedFov; + if (state.savedFov !== null) { + const aspect = Math.max(1, this.host.camera.pose().aspectRatio); + this.host.camera.setFovy( + 2 * Math.atan(Math.tan(state.savedFov / 2) / aspect), + ); + } // Back to the map the operator had, unless they picked another meanwhile. state.imagery?.release?.()?.catch?.(() => {}); this.publishOrbital(null); @@ -153,10 +149,7 @@ function trackPlace(info, nowMs) { if (!state?.placeTracker) return; let nearKm = null; if (Number.isFinite(info.altitudeM)) { - const frustum = this.viewer.camera?.frustum; - const aspect = Number.isFinite(frustum?.aspectRatio) - ? frustum.aspectRatio - : canvasAspect(this.viewer); + const aspect = viewAspect(this.host); const fov = fovForZoom(orbitalBaseFovRad(info.altitudeM), state.zoom); // The field of view is vertical: aspect 1 gives the ground height. nearKm = nearPlaceRadiusKm( @@ -241,7 +234,7 @@ function applyFov(state, altitudeM, nowMs) { state, baseFov, nowMs, - tilesLoaded(this.viewer), + this.host.imageryReady(), ); // The step's last frame (its lock tick over) publishes the idle state. if (state.transition && !reading.moving) { @@ -249,33 +242,11 @@ function applyFov(state, altitudeM, nowMs) { reading.transition = IDLE_TRANSITION; } state.verticalFov = reading.fov; - const frustum = this.viewer.camera?.frustum; - if (frustum && 'fov' in frustum) { - const aspect = Number.isFinite(frustum.aspectRatio) - ? frustum.aspectRatio - : canvasAspect(this.viewer); - frustum.fov = cesiumFrustumFovRad(reading.fov, aspect); - } + this.host.camera.setFovy(reading.fov); return reading; } -/** - * Whether the imagery under the view has loaded. Flat imagery drapes the - * globe; with the globe hidden (Google 3D kept) there is nothing to wait for. - */ -function tilesLoaded(viewer) { - const globe = viewer.scene?.globe; - return !globe?.show || globe.tilesLoaded !== false; -} - -function canvasAspect(viewer) { - const canvas = viewer.scene?.canvas; - const width = canvas?.clientWidth; - const height = canvas?.clientHeight; - return width > 0 && height > 0 ? width / height : 1; -} - -/** Copy a plain vector into a Cesium scratch. */ +/** Copy a plain vector into the camera scratch. */ function intoCartesian(vector, result) { result.x = vector.x; result.y = vector.y; @@ -293,8 +264,8 @@ function intoCartesian(vector, result) { */ export function updateOrbital(nowMs) { // Tracking anything from orbit (an aircraft, another satellite) ends the - // view and leaves Cesium tracking with that target. - if (this.host.current() || this.viewer.trackedEntity?.position) { + // view and leaves the engine tracking with that target. + if (this.host.camera.following()) { this.exit({ restoreTracking: false }); return; } @@ -343,7 +314,7 @@ export function updateOrbital(nowMs) { }); const zoomStepping = Boolean(state.transition); const reading = applyFov.call(this, state, info.altitudeM, nowMs); - this.viewer.camera.setView({ + this.host.camera.setView({ destination: eye, orientation: { direction: intoCartesian(view.direction, this.scratchForward), @@ -372,9 +343,6 @@ export function updateOrbital(nowMs) { prefetchImageryAhead.call(this, info, nowMs); } -/** Tiles remembered as asked for in one orbital session. */ -const PREFETCH_MEMORY = 512; - /** How far the shown time may be from now for the look-ahead to run, ms. */ const PREFETCH_LIVE_TOLERANCE_MS = 60_000; @@ -388,7 +356,7 @@ const PREFETCH_LIVE_TOLERANCE_MS = 60_000; * lowest priority: Cesium's request scheduler declines one while the * imagery server's slots are taken by the tiles on screen or the scheduler * is full, and a declined tile is asked again on a later look-ahead. At most - * ORBITAL_PREFETCH_MAX_TILES a time; the last PREFETCH_MEMORY tiles asked + * 12 a time; the last 512 tiles asked * for are not asked again unless their request failed, and a new base map * starts the memory over. Exit cancels the requests still pending. Nothing * below 16x, while the view is tilted off nadir (it no longer looks at the @@ -411,121 +379,8 @@ export function prefetchImageryAhead(info, nowMs) { ) return 0; state.prefetchMs = nowMs; - try { - const globe = this.viewer.scene?.globe; - if (!globe || globe.show === false) return 0; - const provider = baseImageryProvider(this.viewer); - if (!provider) return 0; - // Another base map: its tiles have not been asked for. - // A fresh set, so a late failure from the old map's requests cannot - // drop a tile the new one has fetched. - if (provider !== state.prefetchProvider) { - for (const request of state.prefetchRequests) request.cancel?.(); - state.prefetchRequests = new Set(); - state.prefetched = new Set(); - state.prefetchProvider = provider; - } - const fov = fovForZoom(orbitalBaseFovRad(info.altitudeM), state.zoom); - const viewKm = footprintWidthKm( - info.altitudeM, - fov, - viewAspect(this.viewer), - ); - // The ground one CSS pixel covers at the nadir point, as Cesium's - // screen-space error measures it: the vertical field of view over the - // canvas height, at the satellite's altitude. - const canvas = this.viewer.scene?.canvas; - const heightPx = canvas?.clientHeight || canvas?.height; - const terrain = - this.viewer.terrainProvider ?? this.viewer.scene?.globe?.terrainProvider; - const level = imageryLevelForView(provider.tilingScheme, { - metresPerPixel: (info.altitudeM * 2 * Math.tan(fov / 2)) / heightPx, - latitudeDeg: info.latitude, - geographic: isGeographicProjection(provider.tilingScheme.projection), - tileWidthPx: provider.tileWidth, - minimumLevel: provider.minimumLevel, - maximumLevel: provider.maximumLevel, - maximumScreenSpaceError: globe.maximumScreenSpaceError, - terrainLevelErrorM: - typeof terrain?.getLevelMaximumGeometricError === 'function' - ? (terrainLevel) => - terrain.getLevelMaximumGeometricError(terrainLevel) - : undefined, - }); - if (level === null) return 0; - const memory = state.prefetched; - const tiles = tilesForPoints( - provider.tilingScheme, - centreLinesFirst(groundTrackAhead(info, viewKm)), - level, - { - maxTiles: ORBITAL_PREFETCH_MAX_TILES, - skip: (key) => memory.has(key), - }, - ); - let asked = 0; - for (const tile of tiles) { - let pending; - let request; - try { - // The tiles on screen keep their slots: Cesium's scheduler declines - // this request while the imagery server's slots are taken (shared - // with the globe's own tile requests, throttleByServer) or the - // scheduler is full, and at the lowest priority it is the first - // dropped from the priority heap, never one the screen asked for. - pending = provider.requestImage( - tile.x, - tile.y, - tile.level, - (request = createOrbitalImageryRequest()), - ); - } catch { - continue; - } - // A provider that declines now (throttled) is asked again later. - if (!pending) continue; - const requests = state.prefetchRequests; - requests.add(request); - Promise.resolve(pending).then( - () => requests.delete(request), - (error) => { - requests.delete(request); - // Dropped by the scheduler (rejected with no error): asked again - // later. A tile that failed to load stays remembered, so one - // missing from the map is not asked for every look-ahead. - if (error === undefined) memory.delete(tile.key); - }, - ); - memory.add(tile.key); - if (memory.size > PREFETCH_MEMORY) - memory.delete(memory.values().next().value); - asked += 1; - } - return asked; - } catch { - return 0; - } -} - -/** - * The provider of the lowest shown imagery layer, the base map the globe - * drapes, when it can be asked for a tile; else null. - */ -function baseImageryProvider(viewer) { - const layers = viewer.imageryLayers ?? viewer.scene?.imageryLayers; - const count = Number.isInteger(layers?.length) ? layers.length : 0; - for (let i = 0; i < count; i += 1) { - const layer = layers.get(i); - if (!layer || layer.show === false || layer.alpha === 0) continue; - const provider = layer.imageryProvider; - if ( - typeof provider?.requestImage === 'function' && - typeof provider.tilingScheme?.positionToTileXY === 'function' - ) - return provider; - return null; - } - return null; + const fov = fovForZoom(orbitalBaseFovRad(info.altitudeM), state.zoom); + return this.host.prefetchImagery(state, info, fov, viewAspect(this.host)); } /** @@ -623,9 +478,7 @@ export function publishOrbitalView(info, transition = IDLE_TRANSITION) { if (!state || !info) return; const focus = orbitalFocusReadout.call(this); const offset = Boolean(focus) || lookIsOffset(state.lookTarget, 0); - const aspect = Number.isFinite(this.viewer.camera?.frustum?.aspectRatio) - ? this.viewer.camera.frustum.aspectRatio - : canvasAspect(this.viewer); + const aspect = viewAspect(this.host); const settledFov = fovForZoom(orbitalBaseFovRad(info.altitudeM), state.zoom); const footprintKm = footprintWidthKm(info.altitudeM, settledFov, aspect); // The strip is drawn over the picture, so it follows the view as a step @@ -796,7 +649,7 @@ export function jumpToSensorSwath() { const zoom = sensorSwathZoom( info.altitudeM, swathKm, - viewAspect(this.viewer), + viewAspect(this.host), orbitalMaxZoomFor.call(this, info), ); return zoom === null ? false : goToZoom.call(this, zoom); @@ -840,14 +693,12 @@ function orbitalMaxZoomFor(info) { return orbitalMaxZoom( info?.altitudeM, zoomableSwathKm(info?.sensor), - viewAspect(this.viewer), + viewAspect(this.host), ); } -function viewAspect(viewer) { - return Number.isFinite(viewer.camera?.frustum?.aspectRatio) - ? viewer.camera.frustum.aspectRatio - : canvasAspect(viewer); +function viewAspect(host) { + return host.camera.pose().aspectRatio; } /** diff --git a/src/ui/cockpitOrbitalView.test.mjs b/src/ui/cockpitOrbitalView.test.mjs index f93a62bad..bcd1ce6de 100644 --- a/src/ui/cockpitOrbitalView.test.mjs +++ b/src/ui/cockpitOrbitalView.test.mjs @@ -109,6 +109,8 @@ function fixture( t, { satellite = ISS, + kind = 'cesium', + nativeViewer = true, orbitAllowed = true, reducedMotion = true, aircraftInfo = null, @@ -136,7 +138,7 @@ function fixture( const listeners = () => ({ addEventListener: () => () => {} }); const shots = []; const aircraftPosition = Cesium.Cartesian3.fromDegrees(10.2, 54.5, 3000); - const globe = createCockpitEngine({ kind: 'cesium' }); + const globe = createCockpitEngine({ kind }); const { engine } = globe; const aircraft = globe.aircraft({ position: aircraftPosition, @@ -174,7 +176,16 @@ function fixture( }); engine.camera.setView = (options) => viewer.camera.setView(options); const pose = engine.camera.pose; - engine.camera.pose = () => ({ ...pose(), heading: viewer.camera.heading ?? 0 }); + engine.camera.pose = () => ({ + ...pose(), + heading: viewer.camera.heading ?? 0, + fovy: 2 * Math.atan(Math.tan(viewer.camera.frustum.fov / 2) / Math.max(1, viewer.camera.frustum.aspectRatio)), + aspectRatio: viewer.camera.frustum.aspectRatio, + }); + engine.camera.setFovy = (radians) => { + viewer.camera.frustum.fov = cesiumFrustumFovRad(radians, viewer.camera.frustum.aspectRatio); + }; + engine.surface.tilesLoaded = () => !viewer.scene.globe?.show || viewer.scene.globe.tilesLoaded !== false; engine.projector.worldToWindow = (position) => viewer.scene.cartesianToCanvasCoordinates?.(position, {}) ?? null; engine.surface.sampleHeight = (position) => viewer.scene.sampleHeight?.(position); @@ -186,7 +197,7 @@ function fixture( get: () => engine.input.cameraInputsEnabled(), }); const cockpit = new CockpitViewController(engine, { - viewer, + viewer: nativeViewer ? viewer : null, services: { flightsLayer: { getTrackedInfo: () => aircraftInfo }, militaryFlightsLayer: { getTrackedInfo: () => null }, @@ -1649,3 +1660,34 @@ test('back to nadir from past the limb, the sensor waits for the view', async (t await settle(); assert.ok(store.orbital.sensor, 'back on the Earth'); }); + +test('Navara orbital entry, zoom, look and exit need only the contract', async (t) => { + const { cockpit, engine, shots, satellites } = fixture(t, { + kind: 'navara', nativeViewer: false, + }); + const saved = engine.camera.pose().fovy; + assert.equal(cockpit.enter({ kind: 'satellite' }), true); + cockpit.updateOrbital(performance.now()); + assert.equal(cockpit.subjectKind, 'satellite'); + assert.equal(engine.input.cameraInputsEnabled(), false); + assert.ok(shots.length > 0); + const first = engine.camera.pose().fovy; + cockpit.stepOrbitalZoom(1); + assert.ok(engine.camera.pose().fovy < first); + cockpit.turnOrbitalLook({ axis: 'yaw', sign: 1, stepDeg: 10 }); + cockpit.updateOrbital(performance.now()); + assert.ok(cockpit.orbital.lookTarget.yawDeg > 0); + cockpit.exit(); + assert.equal(cockpit.active, false); + assert.equal(engine.input.cameraInputsEnabled(), true); + near(engine.camera.pose().fovy, saved, 'restored lens'); + assert.ok(satellites.calls.some(([name, id]) => name === 'subject' && id === ISS.noradId)); +}); + +test('leaving orbit after a resize restores the wider-side lens angle', async (t) => { + const { cockpit, viewer } = await orbitAt(t); + viewer.camera.frustum.aspectRatio = 0.75; + cockpit.updateOrbital(performance.now()); + cockpit.exit(); + near(viewer.camera.frustum.fov, SAVED_FOV, 'restored lens after resize'); +});