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'); +});