diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index cd3c2bf2c..24cf1491d 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -109,13 +109,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/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 30a197698..49857ab6a 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 defccda2b..b05cf9980 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 88ab5fa5a..ecaaa2655 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", @@ -855,7 +850,8 @@ "src/globe/cesium/doubleClick.js", "src/globe/cesium/orbital.js", "src/globe/surfaceDetail.js", - "src/globe/surfacePlacement.js" + "src/globe/surfacePlacement.js", + "src/globe/cesium/orbitalImagery.js" ], "external": ["cesium"] }, @@ -1108,7 +1104,6 @@ "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", @@ -1116,7 +1111,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"] }, @@ -2820,7 +2817,12 @@ "src/data/geoImportLimits.js", "src/data/geoImportPlan.js", "src/globe/cesium/qaTileset.js", - "src/maps/qaTileset.js" + "src/maps/qaTileset.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", @@ -2877,11 +2879,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", @@ -3260,7 +3259,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", @@ -3882,7 +3885,6 @@ "src/sources/featureSource.js", "src/sources/capability.js", "src/i18n/", - "src/celestialRing/view.js", "src/layers/shared/render/engineView.js", "src/globe/cesium/sceneProjection.js", "src/layers/shared/color.js", @@ -3900,7 +3902,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/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/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 6b9d3cd55..78d3b001a 100644 --- a/src/data/bhoteKoshiEvent.js +++ b/src/data/bhoteKoshiEvent.js @@ -885,7 +885,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, @@ -897,32 +897,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) { @@ -3524,8 +3512,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/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/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?.() }, + }; +} 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/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/globe/contract.js b/src/globe/contract.js index 9db45cea6..9129e30fb 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 @@ -978,6 +982,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 78badf667..e4e75fe5c 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(), @@ -579,6 +579,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 { @@ -1057,8 +1064,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/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/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/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 }); 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(); 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 51afc53bb..8431cb171 100644 --- a/src/ui/cockpitHost.js +++ b/src/ui/cockpitHost.js @@ -2,6 +2,8 @@ import { readSurface } from '../globe/surfacePlacement.js'; // 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'; /** @@ -26,6 +28,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) => readSurface(engine, place, { prefer: 'terrain' }), surface: engine, @@ -52,6 +56,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'); +});