Merge branch 'german/refactor-e10-layers-without-viewer' into german/navara-refactor

# Conflicts:
#	scripts/package-boundaries.json
This commit is contained in:
German Khokhlov committed 2026-11-10 16:16:32 +04:00
commit d51e4140b7
45 files changed
+662 -468

No files matched your search

+9 -7
View File
@@ -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` The temporary `engineImportExceptions` list in `scripts/package-boundaries.json`
contains exact file/package pairs with reasons. The checker reports the used contains exact file/package pairs with reasons. The checker reports the used
entries and rejects duplicates, missing reasons and unused entries. Its three entries and rejects duplicates, missing reasons and unused entries. Its two
exceptions are the Bhote Koshi event, locator and embedded-media models in exceptions are the Bhote Koshi event and locator models in `src/data/`, which
`src/data/`, which still construct Cesium property records. Their drawing uses still construct Cesium property records. Their drawing uses shared displays.
shared displays. Exceptions do not relax the portable, contract or cross-engine Exceptions do not relax the portable, contract or cross-engine graph checks.
graph checks. Cesium annotation renderers live in `src/globe/cesium/annotations/`; Cesium annotation renderers live in `src/globe/cesium/annotations/`. Wind's
wind backend adapters live in their engine folders. Wind's older injected-SDK Cesium rendering (the particle canvas, its GPU path and the relief shading)
helpers and the remaining annotation migration are separate debt. 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/<family>/source` exports where Source-only factories use dedicated `layers/<family>/source` exports where
available, preserving the same factory implementation without loading rendering. available, preserving the same factory implementation without loading rendering.
+6 -5
View File
@@ -33,14 +33,15 @@ Status: Open (manual map selection)
Navara does not report tile errors, so a failing map source does not trigger 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. 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) Status: Open (Cesium only)
The Navara engine does not offer the satellite cockpit, including sensor The orbital cockpit works on both engines. On Navara it loads imagery for
zoom, ground focus and satellite switching. Satellites can still be selected its current view but does not prefetch imagery ahead of the ground track.
and tracked on the globe. The orbital entry control stays hidden. Switch to At high optical zoom, imagery can arrive later than on Cesium. Navara's
Cesium to use the orbital cockpit. package does not expose a raster prefetch operation sharing its tile cache
and request priorities.
### Ship bridge waterline uses loaded geometry on Navara ### Ship bridge waterline uses loaded geometry on Navara
+14
View File
@@ -15,6 +15,20 @@ const handle = (engine, area, factory, mutate) =>
}); });
const mutants = [ 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', 'retarget omits followChanged',
'camera.following: set, retarget', 'camera.following: set, retarget',
+4 -4
View File
@@ -6,14 +6,14 @@
"engineSwitchLayerIds": 3, "engineSwitchLayerIds": 3,
"cesiumLayerRendererLines": 0, "cesiumLayerRendererLines": 0,
"navaraLayerRendererLines": 0, "navaraLayerRendererLines": 0,
"cesiumImportFiles": 3, "cesiumImportFiles": 2,
"viewerLines": 784, "viewerLines": 666,
"navaraKindChecks": 1, "navaraKindChecks": 1,
"engineSniffCalls": 0, "engineSniffCalls": 0,
"engineExtensionReads": 3, "engineExtensionReads": 2,
"groundEffectPasses": 0, "groundEffectPasses": 0,
"cesiumSdkLoads": 3, "cesiumSdkLoads": 3,
"screenSpaceHandlerFiles": 2, "screenSpaceHandlerFiles": 2,
"engineNameLines": 21 "engineNameLines": 20
} }
} }
+18 -15
View File
@@ -1,10 +1,5 @@
{ {
"engineImportExceptions": [ "engineImportExceptions": [
{
"file": "src/data/bhoteKoshiEmbeddedMedia.js",
"package": "cesium",
"reason": "Authored story media still uses Cesium positions and property records."
},
{ {
"file": "src/data/bhoteKoshiEvent.js", "file": "src/data/bhoteKoshiEvent.js",
"package": "cesium", "package": "cesium",
@@ -855,7 +850,8 @@
"src/globe/cesium/doubleClick.js", "src/globe/cesium/doubleClick.js",
"src/globe/cesium/orbital.js", "src/globe/cesium/orbital.js",
"src/globe/surfaceDetail.js", "src/globe/surfaceDetail.js",
"src/globe/surfacePlacement.js" "src/globe/surfacePlacement.js",
"src/globe/cesium/orbitalImagery.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1108,7 +1104,6 @@
"src/data/detectionCohort.js", "src/data/detectionCohort.js",
"src/data/labelArbiter.js", "src/data/labelArbiter.js",
"src/globe/cesium/celestial.js", "src/globe/cesium/celestial.js",
"src/layers/shared/render/engineView.js",
"src/overlays/worldOverlay.js", "src/overlays/worldOverlay.js",
"src/overlays/worldOverlayDraw.js", "src/overlays/worldOverlayDraw.js",
"src/overlays/worldOverlayTokens.js", "src/overlays/worldOverlayTokens.js",
@@ -1116,7 +1111,9 @@
"src/data/trackedReadout.js", "src/data/trackedReadout.js",
"src/ui/releaseTrackers.js", "src/ui/releaseTrackers.js",
"src/globe/cesium/orbital.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"] "external": ["cesium", "vue", "pinia", "vue-i18n"]
}, },
@@ -2820,7 +2817,12 @@
"src/data/geoImportLimits.js", "src/data/geoImportLimits.js",
"src/data/geoImportPlan.js", "src/data/geoImportPlan.js",
"src/globe/cesium/qaTileset.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": [ "external": [
"hls.js", "hls.js",
@@ -2877,11 +2879,8 @@
"src/layers/wind/model.js", "src/layers/wind/model.js",
"src/layers/wind/fields.js", "src/layers/wind/fields.js",
"src/layers/wind/streamlines.js", "src/layers/wind/streamlines.js",
"src/layers/wind/gpuRendering.js",
"src/layers/wind/relief.js",
"src/layers/wind/inspection.js", "src/layers/wind/inspection.js",
"src/layers/wind/presentation.js", "src/layers/wind/presentation.js",
"src/layers/wind/rendering.js",
"src/app/layers/aisLiveVessels.js", "src/app/layers/aisLiveVessels.js",
"src/app/layers/alprCameras.js", "src/app/layers/alprCameras.js",
"src/app/layers/bikeshare.js", "src/app/layers/bikeshare.js",
@@ -3260,7 +3259,11 @@
"src/data/ucsSatellites.js", "src/data/ucsSatellites.js",
"src/data/imagingSensors.js", "src/data/imagingSensors.js",
"src/data/planespottersPhoto.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": [ "external": [
"hls.js", "hls.js",
@@ -3882,7 +3885,6 @@
"src/sources/featureSource.js", "src/sources/featureSource.js",
"src/sources/capability.js", "src/sources/capability.js",
"src/i18n/", "src/i18n/",
"src/celestialRing/view.js",
"src/layers/shared/render/engineView.js", "src/layers/shared/render/engineView.js",
"src/globe/cesium/sceneProjection.js", "src/globe/cesium/sceneProjection.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
@@ -3900,7 +3902,8 @@
"src/data/trackedReadout.js", "src/data/trackedReadout.js",
"src/data/trackingGesture.js", "src/data/trackingGesture.js",
"src/time/worldClock.js", "src/time/worldClock.js",
"src/time/clock.js" "src/time/clock.js",
"src/globe/cesium/celestialHost.js"
], ],
"external": ["cesium", "egm96-universal", "pbf"] "external": ["cesium", "egm96-universal", "pbf"]
}, },
+1 -1
View File
@@ -6,7 +6,7 @@ try {
const page = await browser.newPage(); const page = await browser.newPage();
await page.goto(`${process.env.QA_BASE_URL || 'http://localhost:4173'}/`, { waitUntil: 'domcontentloaded' }); await page.goto(`${process.env.QA_BASE_URL || 'http://localhost:4173'}/`, { waitUntil: 'domcontentloaded' });
const result = await page.evaluate(async () => { 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 raf = window.requestAnimationFrame; const caf = window.cancelAnimationFrame;
const callbacks = new Map(); let sequence = 0; const callbacks = new Map(); let sequence = 0;
window.requestAnimationFrame = callback => { callbacks.set(++sequence, callback); return sequence; }; window.requestAnimationFrame = callback => { callbacks.set(++sequence, callback); return sequence; };
+17 -26
View File
@@ -101,7 +101,7 @@ export async function createApplicationTools({
// Cesium only for now: it reads Cesium's camera (positionWC, its // Cesium only for now: it reads Cesium's camera (positionWC, its
// running flight). // running flight).
attachScene(next, { defer: deferScene }) { attachScene(next, { defer: deferScene }) {
const viewer = cesiumViewerOf(next.engine); const viewer = next.viewer;
if (!viewer) return; if (!viewer) return;
deferScene( deferScene(
installGlobeInViewRecovery(viewer, { 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 * The tool backend of each engine kind (see attachEngineTools). Cesium's
* holds the tools that still drive its viewer, so they stay absent on * 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. */ /** The Cesium tool backend: the shared tools and its own on the viewer. */
function cesiumToolBackend({ engine, tileset }) { function cesiumToolBackend({ engine, viewer, tileset }) {
const viewer = cesiumViewerOf(engine); const { governorRequestRender } = renderGovernorFor(engine);
const {
governorRequestRender,
holdContinuousRender,
releaseContinuousRender,
} = renderGovernorFor(engine);
return { return {
viewer, viewer,
startupChrome: {}, startupChrome: {},
@@ -300,18 +289,6 @@ function cesiumToolBackend({ engine, tileset }) {
requestRender: governorRequestRender, 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, // Hidden-state suspension (perf wave 2): when the window/tab is hidden,
// stop the default render loop outright — a hidden canvas repaints for // stop the default render loop outright — a hidden canvas repaints for
@@ -418,6 +395,20 @@ async function attachEngineTools(
governor.install(); governor.install();
backend.attach?.({ data, styleManager, dataManager }, { signal, defer }); 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 // Install the explicit scope mask used by the DISPLAY controls: a canvas
// in the engine's container, its terminus following the camera's height. // in the engine's container, its terminus following the camera's height.
installScopeMask(engine); installScopeMask(engine);
+41 -60
View File
@@ -1,12 +1,7 @@
import * as geo from './geo/index.js'; import * as geo from './geo/index.js';
import { createCelestialView } from './celestialRing/view.js'; import { cesiumIcrfToFixed } from './globe/cesium/celestial.js';
import {
cesiumIcrfToFixed,
projectCelestialPoint,
flyCelestialViewer,
} from './globe/cesium/celestial.js';
import { createCelestialEphemeris } from './celestialRing/ephemeris.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'; import { worldClock, worldTimeFor } from './time/worldClock.js';
// The ring shows the sun and moon at the time the sky is drawn. // 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 * is inverted. When the celestial vector points almost directly into/out of the
* camera, retain the prior stable bearing and fade the marker. * camera, retain the prior stable bearing and fade the marker.
* *
* @param {number} rightComponent - Dot(direction, camera.rightWC). * @param {number} rightComponent - Dot(direction, camera.right).
* @param {number} upComponent - Dot(direction, camera.upWC). * @param {number} upComponent - Dot(direction, camera.up).
* @param {number} lastAngle - Previous stable angle in radians. * @param {number} lastAngle - Previous stable angle in radians.
* @returns {{angle:number, opacity:number, stable:boolean}} * @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. * Project the visible Earth disc into viewport coordinates.
* Shared by full-globe UI treatments that must agree on visual containment. * 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} width - Viewport width in CSS pixels.
* @param {number} height - Viewport height in CSS pixels. * @param {number} height - Viewport height in CSS pixels.
* @param {object} [scratchCenter] - Reusable Cesium Cartesian2 result. * @param {object} [scratchCenter] - Reusable Cartesian2 result.
* @param {object} [scratchToCenter] - Reusable Cesium Cartesian3 result. * @param {object} [scratchToCenter] - Reusable Cartesian3 result.
* @param {import('./globe/contract.js').GlobeProjector | null} [projector] - * @param {import('./globe/contract.js').GlobeProjector | null} [projector] -
* The globe engine's projector; without one, Cesium's SceneTransforms * The globe engine's projector; without one, Cesium's SceneTransforms
* (the same projection on a Cesium scene). * (the same projection on a Cesium scene).
* @returns {object|null} * @returns {object|null}
*/ */
export function projectEarthDiscToViewport( export function projectEarthDiscToViewport(
viewer, engine,
width, width,
height, height,
scratchCenter = undefined, scratchCenter = undefined,
scratchToCenter = undefined, scratchToCenter = undefined,
projector = null, projector = null,
) { ) {
const camera = viewer?.camera; if (!engine?.camera || !(width > 0) || !(height > 0)) return null;
const scene = viewer?.scene; const host = engine.camera.pose ? engine : celestialHost(engine);
if (!camera || !scene || !(width > 0) || !(height > 0)) return null; const camera = host.camera.pose();
const distance = geo.cartesian3.magnitude(camera.positionWC); const distance = geo.cartesian3.magnitude(camera.position);
if ( if (
!Number.isFinite(distance) || !Number.isFinite(distance) ||
distance <= geo.ellipsoid.WGS84.maximumRadius distance <= geo.ellipsoid.WGS84.maximumRadius
@@ -275,22 +270,19 @@ export function projectEarthDiscToViewport(
return null; return null;
const toCenter = geo.cartesian3.negate( const toCenter = geo.cartesian3.negate(
camera.positionWC, camera.position,
scratchToCenter || geo.cartesian3.create(), 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 const center = (projector ?? host.projector).worldToWindow(
? projector.worldToWindow(geo.cartesian3.ZERO, scratchCenter) geo.cartesian3.ZERO,
: projectCelestialPoint(scene, geo.cartesian3.ZERO, scratchCenter); scratchCenter,
);
if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y)) if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y))
return null; return null;
const earthRadius = earthDiscScreenRadius( const earthRadius = earthDiscScreenRadius(distance, height, camera.fovy);
distance,
height,
camera.frustum?.fovy,
);
if (!earthRadius) return null; if (!earthRadius) return null;
const keyhole = getKeyholeGeometry(width, height); const keyhole = getKeyholeGeometry(width, height);
@@ -401,7 +393,7 @@ let rings = 0;
/** /**
* Screen-space ring and celestial direction overlay for the full-globe view. * 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 * 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 { export class CelestialRing {
/** /**
@@ -416,13 +408,7 @@ export class CelestialRing {
viewer, viewer,
{ enabled = true, onAutoDisable = null, store, engine = null } = {}, { enabled = true, onAutoDisable = null, store, engine = null } = {},
) { ) {
this._engine = engine ?? (viewer?.kind ? viewer : null); this._engine = engine ?? (viewer?.kind ? viewer : celestialHost(viewer));
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.enabled = !!enabled; this.enabled = !!enabled;
this.visible = false; this.visible = false;
this._onAutoDisable = this._onAutoDisable =
@@ -455,8 +441,8 @@ export class CelestialRing {
); );
this._initView(store); this._initView(store);
this._removePostRender = this.viewer.scene.postRender.addEventListener(() => this._removePostRender = this._engine.frames.postRender.addEventListener(
this._draw(), () => this._draw(),
); );
// Pre-existing staleness fix (perf wave 2 review): the ephemeris was // Pre-existing staleness fix (perf wave 2 review): the ephemeris was
// sampled once per visible-enable from the FROZEN app clock, so the // 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) // dirty flag up immediately. (review finding)
this._ephemerisDirty = true; this._ephemerisDirty = true;
if (typeof document !== 'undefined' && document.hidden) return; if (typeof document !== 'undefined' && document.hidden) return;
this._renderGovernor?.governorRequestRender('celestial-ephemeris'); this._engine.render.request('celestial-ephemeris');
}, 60_000); }, 60_000);
// A jump in the world time moves the sun and moon at once. // 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. // Played through, the sky moves on each minute of world time it passes.
@@ -495,7 +481,7 @@ export class CelestialRing {
this._view = { this._view = {
id: ++rings, id: ++rings,
owner, owner,
container: this.viewer.container, container: this._engine.host.container,
enabled: this.enabled, enabled: this.enabled,
visible: false, visible: false,
globeVisible: null, globeVisible: null,
@@ -548,8 +534,7 @@ export class CelestialRing {
this._moonCtx = ctx; this._moonCtx = ctx;
this._moonRenderKey = ''; this._moonRenderKey = '';
} else return; } else return;
if (canvas && this.enabled) if (canvas && this.enabled) this._engine.render.request('celestial-ring');
this._renderGovernor?.governorRequestRender('celestial-ring');
} }
/** Enable or disable the user preference for the effect. */ /** 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 // all. Camera motion covers every later repaint; the 60 s ephemeris
// timer requests its own. (perf wave 2 fix — owner playtest finding) // timer requests its own. (perf wave 2 fix — owner playtest finding)
if (this.enabled !== wasEnabled) if (this.enabled !== wasEnabled)
this._renderGovernor?.governorRequestRender('celestial-ring'); this._engine.render.request('celestial-ring');
if (!this.enabled) { if (!this.enabled) {
this.visible = false; this.visible = false;
this._clear(); this._clear();
@@ -576,7 +561,7 @@ export class CelestialRing {
/** Whether the current camera already frames the complete globe inside the keyhole. */ /** Whether the current camera already frames the complete globe inside the keyhole. */
isGlobeFullyVisible() { isGlobeFullyVisible() {
const canvas = this.viewer.scene.canvas; const canvas = this._engine.host.canvas;
const width = canvas.clientWidth || canvas.width; const width = canvas.clientWidth || canvas.width;
const height = canvas.clientHeight || canvas.height; const height = canvas.clientHeight || canvas.height;
if (!(width > 0 && height > 0)) return false; if (!(width > 0 && height > 0)) return false;
@@ -591,10 +576,10 @@ export class CelestialRing {
* @returns {boolean} Whether a valid camera flight was started. * @returns {boolean} Whether a valid camera flight was started.
*/ */
focusFullGlobe({ duration = 2.4 } = {}) { focusFullGlobe({ duration = 2.4 } = {}) {
const canvas = this.viewer.scene.canvas; const canvas = this._engine.host.canvas;
const height = canvas.clientHeight || canvas.height; const height = canvas.clientHeight || canvas.height;
const cartographic = this.viewer.camera.positionCartographic; const pose = this._engine.camera.pose();
const fovy = this.viewer.camera.frustum?.fovy; const { cartographic, fovy } = pose;
if ( if (
!(height > 0) || !(height > 0) ||
!cartographic || !cartographic ||
@@ -620,7 +605,7 @@ export class CelestialRing {
this._focusInProgress = true; this._focusInProgress = true;
const finishFocus = () => { const finishFocus = () => {
this._focusInProgress = false; this._focusInProgress = false;
this.viewer.scene.requestRender?.(); this._engine.render.request('celestial-ring');
}; };
const flight = { const flight = {
destination: geo.cartesian3.fromRadians( destination: geo.cartesian3.fromRadians(
@@ -629,7 +614,7 @@ export class CelestialRing {
altitude, altitude,
), ),
orientation: { orientation: {
heading: this.viewer.camera.heading, heading: pose.heading,
pitch: -geo.math.PI_OVER_TWO, pitch: -geo.math.PI_OVER_TWO,
roll: 0, roll: 0,
}, },
@@ -640,9 +625,7 @@ export class CelestialRing {
if (!this.isGlobeFullyVisible()) this._autoDisable(); if (!this.isGlobeFullyVisible()) this._autoDisable();
}, },
}; };
if (this._engine) this._engine.camera.flyTo({ ...flight, easing: 'cubicInOut' });
this._engine.camera.flyTo({ ...flight, easing: 'cubicInOut' });
else flyCelestialViewer(this.viewer, flight);
return true; return true;
} }
@@ -713,12 +696,11 @@ export class CelestialRing {
/** Return the projected Earth disc used for the full-globe gate. */ /** Return the projected Earth disc used for the full-globe gate. */
_projectedEarthDisc(width, height) { _projectedEarthDisc(width, height) {
return projectEarthDiscToViewport( return projectEarthDiscToViewport(
this.viewer, this._engine,
width, width,
height, height,
this._screenCenter, this._screenCenter,
this._toCenter, this._toCenter,
this._projector,
); );
} }
@@ -795,7 +777,7 @@ export class CelestialRing {
const now = performance.now(); const now = performance.now();
if (now < this._nextDrawAt) return; if (now < this._nextDrawAt) return;
this._nextDrawAt = now + 1000 / CELESTIAL_MAX_FRAME_RATE; 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 width = canvas.clientWidth || canvas.width;
const height = canvas.clientHeight || canvas.height; const height = canvas.clientHeight || canvas.height;
if (!(width > 0 && height > 0)) return; if (!(width > 0 && height > 0)) return;
@@ -828,15 +810,15 @@ export class CelestialRing {
const time = skyTime(); const time = skyTime();
if (!this._updateEphemeris(time)) return; if (!this._updateEphemeris(time)) return;
this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) }); this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) });
const camera = this.viewer.camera; const camera = this._engine.camera.pose();
const sunProjection = celestialScreenAngle( const sunProjection = celestialScreenAngle(
geo.cartesian3.dot(this._sunFixed, camera.rightWC), geo.cartesian3.dot(this._sunFixed, camera.right),
geo.cartesian3.dot(this._sunFixed, camera.upWC), geo.cartesian3.dot(this._sunFixed, camera.up),
this._sunAngle, this._sunAngle,
); );
const moonProjection = celestialScreenAngle( const moonProjection = celestialScreenAngle(
geo.cartesian3.dot(this._moonFixed, camera.rightWC), geo.cartesian3.dot(this._moonFixed, camera.right),
geo.cartesian3.dot(this._moonFixed, camera.upWC), geo.cartesian3.dot(this._moonFixed, camera.up),
this._moonAngle, this._moonAngle,
); );
if (sunProjection.stable) this._sunAngle = sunProjection.angle; if (sunProjection.stable) this._sunAngle = sunProjection.angle;
@@ -923,6 +905,5 @@ export class CelestialRing {
this._store.publish(null); this._store.publish(null);
} }
this._sunCanvas = this._sunCtx = this._moonCanvas = this._moonCtx = null; this._sunCanvas = this._sunCtx = this._moonCanvas = this._moonCtx = null;
this._engineView?.destroy();
} }
} }
+8 -14
View File
@@ -5,25 +5,19 @@ import { CelestialRing } from './celestialRing.js';
test('full globe focus uses the engine without a Cesium global', () => { test('full globe focus uses the engine without a Cesium global', () => {
let flight; let flight;
const engine = { const engine = {
host: { canvas: { clientWidth: 1200, clientHeight: 800 } },
render: { request() {} },
camera: { camera: {
flyTo: (options) => { pose: () => ({
flight = options; cartographic: { longitude: 0, latitude: 0 },
}, heading: 0,
fovy: Math.PI / 3,
}),
flyTo: (options) => { flight = options; },
}, },
}; };
const ring = Object.assign(Object.create(CelestialRing.prototype), { const ring = Object.assign(Object.create(CelestialRing.prototype), {
_engine: engine, _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(globalThis.Cesium, undefined);
assert.equal(ring.focusFullGlobe(), true); assert.equal(ring.focusFullGlobe(), true);
+6 -20
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import { t, msg, onLocaleChange } from '../i18n/index.js'; import { t, msg, onLocaleChange } from '../i18n/index.js';
const YOUTUBE_HOSTS = new Set([ const YOUTUBE_HOSTS = new Set([
@@ -538,25 +537,12 @@ function ensureProviderConnections(documentRef, provider) {
} }
} }
function anchorOnScreen(viewer, engine, anchor, root) { function anchorOnScreen(engine, anchor, root) {
if ((!viewer?.scene && !engine) || !anchor || !root) return null; if (!engine || !anchor || !root) return null;
const camera = viewer?.camera; if (!engine.projector.horizonVisible(anchor)) return null;
if (engine && !engine.projector.horizonVisible(anchor)) return null; const pixel = engine.projector.worldToWindow(anchor, {});
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);
if (!pixel) return null; if (!pixel) return null;
const canvasBounds = ( const canvasBounds = engine.host.canvas?.getBoundingClientRect?.();
engine?.host.canvas ?? viewer?.canvas
)?.getBoundingClientRect?.();
const rootBounds = root.getBoundingClientRect?.(); const rootBounds = root.getBoundingClientRect?.();
if (!canvasBounds || !rootBounds) return null; if (!canvasBounds || !rootBounds) return null;
const x = pixel.x + canvasBounds.left - rootBounds.left; const x = pixel.x + canvasBounds.left - rootBounds.left;
@@ -855,7 +841,7 @@ export function createBhoteKoshiEmbeddedMedia({
const position = () => { const position = () => {
if (!record) return; if (!record) return;
const projected = anchorOnScreen(viewer, engine, record.anchor, root); const projected = anchorOnScreen(engine, record.anchor, root);
record.card.hidden = !projected; record.card.hidden = !projected;
if (projected) { if (projected) {
placeCard(record.card, record.leader, projected); placeCard(record.card, record.leader, projected);
+14 -27
View File
@@ -885,7 +885,7 @@ function openExternal(url) {
if (target) target.opener = null; if (target) target.opener = null;
} }
function focusView(viewer, viewpoint, engine) { function focusView(engine, viewpoint) {
announceNavigationAuthority('bhote-koshi-event-focus'); announceNavigationAuthority('bhote-koshi-event-focus');
const target = Cesium.Cartesian3.fromDegrees( const target = Cesium.Cartesian3.fromDegrees(
viewpoint.lon, viewpoint.lon,
@@ -897,32 +897,20 @@ function focusView(viewer, viewpoint, engine) {
Cesium.Math.toRadians(viewpoint.pitchDeg), Cesium.Math.toRadians(viewpoint.pitchDeg),
viewpoint.rangeM, viewpoint.rangeM,
); );
if (engine) { engine.camera.cancelFlight();
engine.camera.cancelFlight(); engine.camera.flyToSphere(
engine.camera.flyToSphere( { center: target, radius: 80 },
{ center: target, radius: 80 }, {
{ heading: offset.heading,
heading: offset.heading, pitch: offset.pitch,
pitch: offset.pitch, range: offset.range,
range: offset.range, duration: 2.2,
duration: 2.2, complete: () => {
complete: () => { engine.camera.lookAt(target, offset);
engine.camera.lookAt(target, offset); engine.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
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) { function setElementProperty(element, property, value) {
@@ -3524,8 +3512,7 @@ export function createBhoteKoshiEventLayer({
_embeddedEvidenceIndex = -1; _embeddedEvidenceIndex = -1;
_embeddedMedia?.hide?.(); _embeddedMedia?.hide?.();
setSplit(0.5); setSplit(0.5);
if (cameraForScene()) if (_engine) focusView(_engine, _event.viewpoints.corridor);
focusView(_viewer, _event.viewpoints.corridor, _engine);
} }
// Each attachment and release advances this. An attachment that resumes // Each attachment and release advances this. An attachment that resumes
+8
View File
@@ -179,6 +179,14 @@ export function createCesiumCamera(viewer) {
} }
const camera = { 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() { pose() {
const position = toGeo3(cesium.positionWC); const position = toGeo3(cesium.positionWC);
return { return {
+44
View File
@@ -579,3 +579,47 @@ test('setView ends the engine flight through its cancellation callback', () => {
assert.deepEqual(outcome, ['cancel']); assert.deepEqual(outcome, ['cancel']);
assert.equal(camera.flightActive(), false); 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();
});
+26
View File
@@ -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?.() },
};
}
+128
View File
@@ -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;
}
+2 -2
View File
@@ -1,7 +1,7 @@
// Cesium's backend of the wind display: the wind renderer's streamlines // 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 * as Cesium from 'cesium';
import { createWindRendering } from '../../layers/wind/rendering.js'; import { createWindRendering } from './windRendering.js';
export function createWindCesiumFlow(engine, { onStatusChange } = {}) { export function createWindCesiumFlow(engine, { onStatusChange } = {}) {
return createWindRendering({ return createWindRendering({
@@ -4,7 +4,7 @@ import {
WIND_DISPLAY_HEIGHT_METERS, WIND_DISPLAY_HEIGHT_METERS,
WIND_PATH_LIMIT, WIND_PATH_LIMIT,
WIND_NARROW_PATH_LIMIT, WIND_NARROW_PATH_LIMIT,
} from './streamlines.js'; } from '../../layers/wind/streamlines.js';
const WIND_FADE_LOW_METERS = 15000; const WIND_FADE_LOW_METERS = 15000;
const WIND_FADE_HIGH_METERS = 60000; const WIND_FADE_HIGH_METERS = 60000;
File renamed without changes.
@@ -1,7 +1,11 @@
import { createWindRelief } from './relief.js'; import { createWindRelief } from './windRelief.js';
import { createWindGpuRendering } from './gpuRendering.js'; import { createWindGpuRendering } from './windGpuRendering.js';
import { advectParticle, sampleWind, sameWind } from './model.js'; import {
import { trailEraseAlpha, windTrailColor } from './fields.js'; 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, /** The wind's streamlines on Cesium (the wind display's flow backend,
* render/display.js, which draws the colour field): globe-projected surface * render/display.js, which draws the colour field): globe-projected surface
+5
View File
@@ -89,6 +89,10 @@
* engine-owned object: the same object on every read, holding the current * 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 * position (at the latest when read). For per-frame readers, which must not
* allocate; read-only, copy it to keep a value. * 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 * @property {() => number} height The camera's height above the WGS84
* ellipsoid (the pose's cartographic height), without building a pose. * ellipsoid (the pose's cartographic height), without building a pose.
* @property {(view: { destination: Cartesian3 | Rectangle, orientation?: Orientation }) => void} setView * @property {(view: { destination: Cartesian3 | Rectangle, orientation?: Orientation }) => void} setView
@@ -978,6 +982,7 @@ const SHAPE = {
host: { container: isObject, canvas: isObject, size: isFunction }, host: { container: isObject, canvas: isObject, size: isFunction },
camera: { camera: {
pose: isFunction, pose: isFunction,
setFovy: isFunction,
livePosition: (value) => livePosition: (value) =>
value != null && value != null &&
isNumber(value.x) && isNumber(value.x) &&
+22 -9
View File
@@ -19,7 +19,6 @@
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import { createEngineEvent } from '../contract.js'; import { createEngineEvent } from '../contract.js';
import { import {
cesiumFovy,
headingPitchRoll, headingPitchRoll,
horizonVisible, horizonVisible,
lookAtFrame, lookAtFrame,
@@ -83,6 +82,7 @@ function posesNear(a, b, distance, angle) {
* stop: () => void, * stop: () => void,
* size: () => { width: number, height: number }, * size: () => { width: number, height: number },
* requestFrame: () => void, * requestFrame: () => void,
* setFovy?: (radians: number) => void,
* suspendCameraInputs?: () => () => void, * suspendCameraInputs?: () => () => void,
* }, * },
* clock?: { now: () => number, setTimeout: Function, clearTimeout: Function }, * clock?: { now: () => number, setTimeout: Function, clearTimeout: Function },
@@ -120,6 +120,13 @@ export function createCameraModel({
...orientationVectors(position, 0, -math.PI_OVER_TWO, 0), ...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 transform = [...matrix4.IDENTITY];
let percentageChanged = DEFAULT_PERCENTAGE_CHANGED; let percentageChanged = DEFAULT_PERCENTAGE_CHANGED;
let flight = null; let flight = null;
@@ -139,7 +146,7 @@ export function createCameraModel({
const view = () => { const view = () => {
const { width, height } = port.size(); 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. // A programmatic pose: exact here, pushed to Navara for drawing.
@@ -154,11 +161,7 @@ export function createCameraModel({
const { width, height } = port.size(); const { width, height } = port.size();
const position = const position =
'west' in destination 'west' in destination
? rectangleViewPosition( ? rectangleViewPosition(destination, fovy(), width / height)
destination,
cesiumFovy(width, height),
width / height,
)
: copy3(destination); : copy3(destination);
if (orientation && 'direction' in orientation) if (orientation && 'direction' in orientation)
return { return {
@@ -311,6 +314,17 @@ export function createCameraModel({
const livePosition = cartesian3.create(); const livePosition = cartesian3.create();
const camera = { 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() { get livePosition() {
return cartesian3.clone(state.position, livePosition); return cartesian3.clone(state.position, livePosition);
}, },
@@ -356,8 +370,7 @@ export function createCameraModel({
transform = [...matrix4.IDENTITY]; transform = [...matrix4.IDENTITY];
const { width, height } = port.size(); const { width, height } = port.size();
const range = const range =
options.range || options.range || sphereRange(sphere.radius, fovy(), width / height);
sphereRange(sphere.radius, cesiumFovy(width, height), width / height);
const frame = geo.transforms.eastNorthUpToFixedFrame( const frame = geo.transforms.eastNorthUpToFixedFrame(
sphere.center, sphere.center,
WGS84, WGS84,
+9 -3
View File
@@ -114,10 +114,13 @@ export function createNavaraEngine(
width: canvas.clientWidth, width: canvas.clientWidth,
height: canvas.clientHeight, height: canvas.clientHeight,
}); });
const applyFovy = (radians) => {
if (hooks.setCameraFovy) hooks.setCameraFovy(radians);
else view.camera.fov = math.toDegrees(radians);
};
const matchFov = () => { const matchFov = () => {
const { width, height } = size(); const { width, height } = size();
if (width > 0 && height > 0) if (width > 0 && height > 0) applyFovy(cesiumFovy(width, height));
view.camera.fov = math.toDegrees(cesiumFovy(width, height));
}; };
matchFov(); matchFov();
@@ -131,6 +134,7 @@ export function createNavaraEngine(
stop: () => view.setCamera({}), stop: () => view.setCamera({}),
size, size,
requestFrame: () => view.forceUpdate(), requestFrame: () => view.forceUpdate(),
setFovy: applyFovy,
suspendCameraInputs: () => input.input.suspendCameraInputs(), suspendCameraInputs: () => input.input.suspendCameraInputs(),
}, },
...(clock ? { clock } : {}), ...(clock ? { clock } : {}),
@@ -201,7 +205,9 @@ export function createNavaraEngine(
model.afterFrame(); model.afterFrame();
frames.postRender.raiseEvent(time); frames.postRender.raiseEvent(time);
}, },
resize: () => matchFov(), resize: () => {
applyFovy(model.camera.pose().fovy);
},
}; };
for (const [name, handler] of Object.entries(handlers)) for (const [name, handler] of Object.entries(handlers))
view.on(name, handler); view.on(name, handler);
@@ -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); assert.equal(engine.input.cameraInputsEnabled(), true);
engine.destroy(); 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();
});
+14
View File
@@ -233,11 +233,25 @@ export function featureChangeQueue(view) {
: null; : 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. */ /** The hooks createNavaraEngine takes. */
export const navaraHooks = (view) => { export const navaraHooks = (view) => {
assertNavaraInternals(view); assertNavaraInternals(view);
return { return {
retainTileGeometry, retainTileGeometry,
setCameraFovy: (radians) => setCameraFovy(view, radians),
tileRoots: () => tileRoots(view), tileRoots: () => tileRoots(view),
isIdle: () => isIdle(view), isIdle: () => isIdle(view),
dropOrphanFeatureChanges: () => dropOrphanFeatureChanges: () =>
+30 -1
View File
@@ -13,7 +13,8 @@ const loader = registerHooks({
: next(specifier, context); : next(specifier, context);
}, },
}); });
const { navaraHooks, forEachLuminanceUniform } = await import('./internals.js'); const { navaraHooks, forEachLuminanceUniform, setCameraFovy } =
await import('./internals.js');
loader.deregister(); loader.deregister();
const { ModelMesh } = await import(sdkStub); const { ModelMesh } = await import(sdkStub);
@@ -201,3 +202,31 @@ test('feature changes of a deleted vector layer leave the queue; live ones stay'
0, 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();
}
});
@@ -20,6 +20,61 @@ import { enuOffset } from '../../follow.js';
export function poseChecks(engine, { settle, until, nadirView }) { export function poseChecks(engine, { settle, until, nadirView }) {
const { camera } = engine; const { camera } = engine;
return [ 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', 'camera.setView: heading, pitch and roll round-trip through pose',
async () => { async () => {
+16 -1
View File
@@ -195,7 +195,7 @@ export function createFakeEngine(options = {}) {
let width = options.width ?? 1024; let width = options.width ?? 1024;
let height = options.height ?? 768; let height = options.height ?? 768;
// Cesium's default: 60 degrees across the wider canvas dimension. // 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 = { const state = {
position: cartesian3.create(), position: cartesian3.create(),
direction: cartesian3.create(), direction: cartesian3.create(),
@@ -579,6 +579,13 @@ export function createFakeEngine(options = {}) {
} }
const camera = { 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() { pose() {
const angles = poseAngles(); const angles = poseAngles();
return { return {
@@ -1057,8 +1064,16 @@ export function createFakeEngine(options = {}) {
); );
}, },
setSize(nextWidth, nextHeight) { setSize(nextWidth, nextHeight) {
const fov =
width > height
? 2 * Math.atan((Math.tan(fovy / 2) * width) / height)
: fovy;
width = nextWidth; width = nextWidth;
height = nextHeight; height = nextHeight;
fovy =
width > height
? 2 * Math.atan((Math.tan(fov / 2) * height) / width)
: fov;
}, },
callsOf: (method) => fake.calls.filter((call) => call.method === method), callsOf: (method) => fake.calls.filter((call) => call.method === method),
resetCalls() { resetCalls() {
+5 -5
View File
@@ -23,13 +23,13 @@ const options = {
validTime: '2026-09-14 18:00 UTC', validTime: '2026-09-14 18:00 UTC',
status: 'Model forecast', status: 'Model forecast',
}; };
/** The Cesium path picks the centre itself; Navara hands in the position. */ /** Inspect either the current map centre or a captured position. */
const viaViewer = (snapshot, point) => const viaEngine = (snapshot, point) =>
inspectWindAtCenter( inspectWindAtCenter(
snapshot, snapshot,
{ {
camera: { pickEllipsoid: () => point }, camera: { pickEllipsoid: () => point },
scene: { canvas: { clientWidth: 800, clientHeight: 600 } }, host: { canvas: { clientWidth: 800, clientHeight: 600 } },
}, },
options, 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. // 5 m/s blowing toward the west: the wind comes FROM the east.
const snapshot = field(-5, 0); const snapshot = field(-5, 0);
for (const point of [at(10, 20), at(180, 0), at(-180, 0), at(-45.5, -60)]) { 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); const navaraReading = viaPosition(snapshot, point);
for (const reading of [cesiumReading, navaraReading]) { for (const reading of [cesiumReading, navaraReading]) {
assert.equal(reading.from, 'E'); 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 snapshot of empty) {
for (const reading of [ for (const reading of [
viaViewer(snapshot, point), viaEngine(snapshot, point),
viaPosition(snapshot, point), viaPosition(snapshot, point),
]) { ]) {
assert.equal(reading.speed, undefined, 'no speed is invented'); assert.equal(reading.speed, undefined, 'no speed is invented');
+1 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createWindGpuRendering } from './gpuRendering.js'; import { createWindGpuRendering } from '../../globe/cesium/windGpuRendering.js';
import { import {
BoundingSphere, BoundingSphere,
Cartesian3, Cartesian3,
+2 -10
View File
@@ -82,14 +82,7 @@ export function createWindLayer({
inspectionMarker?.clear(); inspectionMarker?.clear();
}; };
const sample = (position) => { const sample = (position) => {
if (!position && engine && !viewer) { reading = inspectWindAtCenter(manifest, engine, {
const canvas = engine.host.canvas;
position = engine.camera.pickEllipsoid({
x: canvas.clientWidth / 2,
y: canvas.clientHeight / 2,
});
}
reading = inspectWindAtCenter(manifest, viewer, {
position, position,
units, units,
overlay, overlay,
@@ -130,7 +123,6 @@ export function createWindLayer({
rendering?.setOptions?.({ overlay, paused }); rendering?.setOptions?.({ overlay, paused });
inspectionMarker = createWindInspectionMarker({ inspectionMarker = createWindInspectionMarker({
container: engine.host.container, container: engine.host.container,
viewer,
engine, engine,
}); });
if (enabled && manifest && !manifest.unavailable) { if (enabled && manifest && !manifest.unavailable) {
@@ -254,7 +246,7 @@ export function createWindLayer({
loading = enabled && needsField; loading = enabled && needsField;
if (enabled && needsField) if (enabled && needsField)
queueMicrotask(() => { queueMicrotask(() => {
if (enabled && generation === current) void layer.update(viewer); if (enabled && generation === current) void layer.update();
}); });
else if (enabled && manifest && !manifest.unavailable) else if (enabled && manifest && !manifest.unavailable)
rendering?.start(); rendering?.start();
+4 -2
View File
@@ -365,8 +365,10 @@ test('sample stays fixed across model, field and unit changes; dismissal and dis
feed: { getSnapshot: async ({ model }) => complete(model) }, feed: { getSnapshot: async ({ model }) => complete(model) },
createNativeRendering: () => rendering, createNativeRendering: () => rendering,
}); });
// A Cesium page: the viewer samples, the engine projects the marker. const engine = createFakeEngine({ container, canvas: viewer.scene.canvas });
layer.init(viewer, { engine: createFakeEngine({ container }) }); engine.camera.pickEllipsoid = (point) => viewer.camera.pickEllipsoid(point);
engine.frames.postRender = viewer.scene.postRender;
layer.init(viewer, { engine });
layer.enable(); layer.enable();
await layer.update(); await layer.update();
layer.setParams({ inspect: true }); layer.setParams({ inspect: true });
+7 -10
View File
@@ -45,20 +45,17 @@ export function formatWindSpeed(speed, units = 'km/h') {
/** A deliberate snapshot at the map center; does not own pointer input. */ /** A deliberate snapshot at the map center; does not own pointer input. */
export function inspectWindAtCenter( export function inspectWindAtCenter(
snapshot, snapshot,
viewer, engine,
{ units, overlay, model, validTime, status, position } = {}, { units, overlay, model, validTime, status, position } = {},
) { ) {
const scene = viewer?.scene; const canvas = engine?.host.canvas;
const canvas = scene?.canvas;
// The globe's own ellipsoid, else WGS84 (geo's default).
const ellipsoid = scene?.globe?.ellipsoid;
const point = const point =
position || position ||
(canvas && (canvas &&
viewer.camera?.pickEllipsoid?.( engine.camera.pickEllipsoid({
geo.cartesian2.create(canvas.clientWidth / 2, canvas.clientHeight / 2), x: canvas.clientWidth / 2,
ellipsoid, y: canvas.clientHeight / 2,
)); }));
const noReading = () => ({ const noReading = () => ({
coordinates: msg('No surface reading'), coordinates: msg('No surface reading'),
wind: msg('Aim the map center at Earth'), wind: msg('Aim the map center at Earth'),
@@ -70,7 +67,7 @@ export function inspectWindAtCenter(
), ),
}); });
if (!point || !snapshot?.u || !snapshot?.v) return noReading(); 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 lon = geo.math.toDegrees(location.longitude);
const lat = geo.math.toDegrees(location.latitude); const lat = geo.math.toDegrees(location.latitude);
const vector = sampleWind( const vector = sampleWind(
+5 -5
View File
@@ -27,11 +27,11 @@ test('center inspection uses a fixed forecast reading without double-converting
values: Float32Array.from([20, 20, 20, 20]), values: Float32Array.from([20, 20, 20, 20]),
}, },
}; };
const viewer = { const engine = {
camera: { pickEllipsoid: () => geo.cartesian3.fromDegrees(0, 0) }, 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', overlay: 'temperature',
units: 'km/h', units: 'km/h',
model: 'GFS', 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.scalarValue, '20.0 °C');
assert.equal(result.validTime, 'fixed'); assert.equal(result.validTime, 'fixed');
assert.equal(result.coordinates, '0.00°N · 0.00°E'); assert.equal(result.coordinates, '0.00°N · 0.00°E');
viewer.camera.pickEllipsoid = () => null; engine.camera.pickEllipsoid = () => null;
assert.equal( assert.equal(
inspectWindAtCenter(snapshot, viewer).coordinates, inspectWindAtCenter(snapshot, engine).coordinates,
'No surface reading', 'No surface reading',
); );
}); });
+7 -24
View File
@@ -9,9 +9,7 @@ import { applicationPinia } from '../../ui/vueRuntime.js';
*/ */
export function createWindInspectionMarker({ export function createWindInspectionMarker({
container, container,
viewer,
engine, engine,
cesium,
store = useWindInspectionStore(applicationPinia()), store = useWindInspectionStore(applicationPinia()),
}) { }) {
const owner = {}; const owner = {};
@@ -28,45 +26,31 @@ export function createWindInspectionMarker({
removeRender = null; removeRender = null;
if (shown()) store.publish(null); if (shown()) store.publish(null);
marker = null; marker = null;
if (engine) engine.render.request('wind-inspection'); engine.render.request('wind-inspection');
else viewer?.scene?.requestRender?.();
}; };
return { return {
show(position) { show(position) {
clear(); clear();
const scene = viewer?.scene; const canvas = engine.host.canvas;
const canvas = scene?.canvas ?? engine?.host.canvas; const frames = engine.frames.postRender;
const frames = scene?.postRender ?? engine?.frames.postRender;
if ( if (
!position || !position ||
!container?.ownerDocument || !container?.ownerDocument ||
!frames?.addEventListener || !frames?.addEventListener ||
(!engine?.projector && !scene?.cartesianToCanvasCoordinates) !engine.projector
) )
return; return;
present({ owner, container, visible: false, left: 0, top: 0 }); 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 = () => { const render = () => {
if (!shown()) return; if (!shown()) return;
if (occluder) occluder.cameraPosition = viewer.camera.positionWC; const point = engine.projector.worldToWindow(position, {});
const point = engine
? engine.projector.worldToWindow(position, {})
: scene.cartesianToCanvasCoordinates(position);
const visible = const visible =
point && point &&
point.x >= 0 && point.x >= 0 &&
point.y >= 0 && point.y >= 0 &&
point.x <= canvas.clientWidth && point.x <= canvas.clientWidth &&
point.y <= canvas.clientHeight && point.y <= canvas.clientHeight &&
(engine engine.projector.horizonVisible(position);
? engine.projector.horizonVisible(position)
: scene.mode !== cesium.SceneMode.SCENE3D ||
occluder.isPointVisible(position));
if (!visible) { if (!visible) {
// A hidden marker keeps its last place, as the element did. // A hidden marker keeps its last place, as the element did.
if (marker.visible) present({ visible: false }); if (marker.visible) present({ visible: false });
@@ -82,8 +66,7 @@ export function createWindInspectionMarker({
}; };
removeRender = frames.addEventListener(render); removeRender = frames.addEventListener(render);
render(); render();
if (engine) engine.render.request('wind-inspection'); engine.render.request('wind-inspection');
else scene.requestRender?.();
}, },
clear, clear,
destroy: clear, destroy: clear,
+17 -19
View File
@@ -10,16 +10,7 @@ import { useWindInspectionStore } from '../../ui/stores/windInspection.js';
import WindInspectionMarker from '../../ui/views/panels/WindInspectionMarker.vue'; import WindInspectionMarker from '../../ui/views/panels/WindInspectionMarker.vue';
import { createWindInspectionMarker } from './inspectionMarker.js'; import { createWindInspectionMarker } from './inspectionMarker.js';
const cesium = { const visibility = { visible: true };
SceneMode: { SCENE3D: 3 },
Ellipsoid: { WGS84: {} },
EllipsoidalOccluder: class {
isPointVisible() {
return cesium.visible;
}
},
visible: true,
};
function fixture(t) { function fixture(t) {
document.body.replaceChildren(); document.body.replaceChildren();
@@ -56,18 +47,26 @@ function fixture(t) {
requestRender() {}, 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); const store = useWindInspectionStore(pinia);
cesium.visible = true; visibility.visible = true;
const owner = createWindInspectionMarker({ const owner = createWindInspectionMarker({
container, container,
viewer, engine,
cesium,
store, store,
}); });
const nodes = () => [ const nodes = () => [
...container.querySelectorAll('.gev-wind-inspection-marker'), ...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) => { 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.style.top, '220px');
assert.equal(marker.hidden, false); assert.equal(marker.hidden, false);
viewer.camera.positionWC = { moved: true }; viewer.camera.positionWC = { moved: true };
cesium.visible = false; visibility.visible = false;
frames.listener(); frames.listener();
await nextTick(); await nextTick();
assert.equal(marker.hidden, true); assert.equal(marker.hidden, true);
assert.equal(marker.style.left, '110px', 'a hidden marker keeps its place'); assert.equal(marker.style.left, '110px', 'a hidden marker keeps its place');
assert.ok(projected.every((value) => value === point)); assert.ok(projected.every((value) => value === point));
cesium.visible = true; visibility.visible = true;
frames.listener(); frames.listener();
await nextTick(); await nextTick();
assert.equal(marker.hidden, false); 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) => { 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 }); owner.show({ x: 1 });
let publishes = 0; let publishes = 0;
const publish = store.publish; 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; frames.listener = null;
const newer = createWindInspectionMarker({ const newer = createWindInspectionMarker({
container, container,
viewer, engine,
cesium,
store, store,
}); });
newer.show({ x: 2 }); newer.show({ x: 2 });
+1 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createWindRelief } from './relief.js'; import { createWindRelief } from '../../globe/cesium/windRelief.js';
function fixture({ material = undefined, normals = true } = {}) { function fixture({ material = undefined, normals = true } = {}) {
const materials = []; const materials = [];
+1 -1
View File
@@ -4,7 +4,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createWindRendering } from './rendering.js'; import { createWindRendering } from '../../globe/cesium/windRendering.js';
function event() { function event() {
const listeners = new Set(); const listeners = new Set();
+2 -3
View File
@@ -137,7 +137,6 @@ export class CockpitViewController {
} = {}, } = {},
) { ) {
this.host = cockpitHost(engine, { viewer }); this.host = cockpitHost(engine, { viewer });
// Orbital optics and imagery prefetch still belong to the Cesium viewer.
this.viewer = viewer; this.viewer = viewer;
this.services = services; this.services = services;
this.destroyed = false; this.destroyed = false;
@@ -206,7 +205,7 @@ export class CockpitViewController {
: () => false; : () => false;
// Nor a satellite layer to ride without one. // Nor a satellite layer to ride without one.
this.isOrbitEntryAllowed = this.isOrbitEntryAllowed =
viewer && typeof isOrbitEntryAllowed === 'function' typeof isOrbitEntryAllowed === 'function'
? isOrbitEntryAllowed ? isOrbitEntryAllowed
: () => false; : () => false;
this.isUtilityDisclosureOpen = this.isUtilityDisclosureOpen =
@@ -311,7 +310,7 @@ export class CockpitViewController {
); );
this._listenerRemovers.push(routeSceneDoubleClick.call(this)); this._listenerRemovers.push(routeSceneDoubleClick.call(this));
// In orbit a click on a mapped point keeps the camera on it. // 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 // In orbit the wheel steps the optical zoom (Cesium's own wheel zoom is
// off while the Cockpit holds the camera). // off while the Cockpit holds the camera).
this._listen( this._listen(
+4 -4
View File
@@ -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(); const env = environment();
try { try {
const owner = new CockpitViewController(env.engine, { 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 }, militaryFlightsLayer: { getTrackedInfo: () => null },
satellitesLayer: { satellitesLayer: {
getCockpitSubject: () => ({ noradId: 25544, layerId: 'satellites' }), getCockpitSubject: () => ({ noradId: 25544, layerId: 'satellites' }),
setCockpitSubject: () => assert.fail('no satellite may be hidden'), setCockpitSubject: () => true,
}, },
isTr3b: () => false, isTr3b: () => false,
}, },
isOrbitEntryAllowed: () => true, isOrbitEntryAllowed: () => true,
}); });
owner.syncEntry(); owner.syncEntry();
assert.equal(owner.presence.entryAvailable, false); assert.equal(owner.presence.entryAvailable, true);
assert.equal(owner.enter({ kind: 'satellite' }), false); assert.equal(owner.presence.entryKind, 'satellite');
assert.equal(owner.orbital, null); assert.equal(owner.orbital, null);
assert.equal(env.engine.input.cameraInputsEnabled(), true); assert.equal(env.engine.input.cameraInputsEnabled(), true);
owner.dispose(); owner.dispose();
+10
View File
@@ -2,6 +2,8 @@ import { readSurface } from '../globe/surfacePlacement.js';
// Camera and aircraft ownership used by the shared first-person controller, // Camera and aircraft ownership used by the shared first-person controller,
// on the globe contract: the ridden aircraft is the engine follow's target, // on the globe contract: the ridden aircraft is the engine follow's target,
// the aircraft display's tracked glyph handle. // 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'; import { routeViewerDoubleClick } from '../globe/cesium/doubleClick.js';
/** /**
@@ -26,6 +28,8 @@ export function cockpitHost(engine, { viewer = null } = {}) {
release: () => engine.camera.following()?.stop(), release: () => engine.camera.following()?.stop(),
restore: (target) => target.resumeAircraftFollow(), restore: (target) => target.resumeAircraftFollow(),
camera: engine.camera, camera: engine.camera,
imageryReady: () =>
engine.maps.photorealActive() || engine.surface.tilesLoaded(),
cartographic: () => engine.camera.pose().cartographic, cartographic: () => engine.camera.pose().cartographic,
terrainHeight: (place) => readSurface(engine, place, { prefer: 'terrain' }), terrainHeight: (place) => readSurface(engine, place, { prefer: 'terrain' }),
surface: engine, surface: engine,
@@ -52,6 +56,12 @@ export function cockpitHost(engine, { viewer = null } = {}) {
resumeInputs = null; resumeInputs = null;
} else resumeInputs ??= engine.input.suspendCameraInputs(); } 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, canvasPosition: (world) => engine.projector.worldToWindow(world) ?? null,
routeDoubleClick: (handle) => routeDoubleClick: (handle) =>
viewer viewer
+1 -4
View File
@@ -9,7 +9,6 @@
* it ends with the session. * it ends with the session.
*/ */
import { cartesian3 } from '../geo/index.js'; import { cartesian3 } from '../geo/index.js';
import { routeViewerClick } from '../globe/cesium/orbital.js';
import { clampOrbitalLook, easeLook } from '../cockpitLook.js'; import { clampOrbitalLook, easeLook } from '../cockpitLook.js';
import { ORBITAL_EARTH_RADIUS_M } from '../cockpitOrbital.js'; import { ORBITAL_EARTH_RADIUS_M } from '../cockpitOrbital.js';
import { import {
@@ -264,9 +263,7 @@ export function onOrbitalClick(position) {
* (the draw tool borrows it) has replaced ours since. * (the draw tool borrows it) has replaced ours since.
*/ */
export function routeSceneClick() { export function routeSceneClick() {
return routeViewerClick(this.viewer, (position) => return this.host.routeClick((position) => this.onOrbitalClick(position));
this.onOrbitalClick(position),
);
} }
/** /**
+3
View File
@@ -289,6 +289,9 @@ function fixture(t, { reducedMotion = false } = {}) {
const onMode = (event) => modeEvents.push(event.detail); const onMode = (event) => modeEvents.push(event.detail);
window.addEventListener('gev:cockpit-mode-changed', onMode); window.addEventListener('gev:cockpit-mode-changed', onMode);
const engine = createFakeEngine({ kind: 'cesium' }); 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}`)); engine.fake.script('pick', (position) => picks.get(`${position.x},${position.y}`));
const cockpit = new CockpitViewController(engine, { const cockpit = new CockpitViewController(engine, {
viewer, viewer,
+2 -2
View File
@@ -122,8 +122,8 @@ export function switchOrbitalSatellite(noradId) {
} catch { } catch {
tracked = false; tracked = false;
} }
// Tracking hands the camera to Cesium's follow; the orbital view keeps it. // Tracking hands the camera to follow; the orbital view keeps it.
this.viewer.trackedEntity = undefined; this.host.release();
const info = tracked ? this.readSatelliteInfo() : null; const info = tracked ? this.readSatelliteInfo() : null;
if (!info || String(info.noradId) !== String(noradId)) { if (!info || String(info.noradId) !== String(noradId)) {
// Nothing changed hands, or the layer could not take it: the view ends // Nothing changed hands, or the layer could not take it: the view ends
+25 -174
View File
@@ -1,23 +1,14 @@
/** The orbital (satellite) view of the Cockpit controller: camera, zoom, look and HUD. */ /** 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 { formatNumber, msg, t } from '../i18n/index.js';
import { import {
ORBITAL_DEFAULT_ZOOM, ORBITAL_DEFAULT_ZOOM,
ORBITAL_PREFETCH_INTERVAL_MS, ORBITAL_PREFETCH_INTERVAL_MS,
centreLinesFirst,
ORBITAL_PREFETCH_MAX_TILES,
ORBITAL_PREFETCH_MIN_ZOOM, ORBITAL_PREFETCH_MIN_ZOOM,
ORBITAL_SETTLE_BLUR_PX, ORBITAL_SETTLE_BLUR_PX,
ORBITAL_ZOOM_LADDER, ORBITAL_ZOOM_LADDER,
ORBITAL_ZOOM_TRANSITION_MS, ORBITAL_ZOOM_TRANSITION_MS,
cesiumFrustumFovRad,
footprintWidthKm, footprintWidthKm,
fovForZoom, fovForZoom,
groundTrackAhead,
imageryLevelForView,
interpolateZoomFov, interpolateZoomFov,
nadirOrientation, nadirOrientation,
nextOrbitalZoom, nextOrbitalZoom,
@@ -30,7 +21,6 @@ import {
previousOrbitalZoom, previousOrbitalZoom,
sensorStripFraction, sensorStripFraction,
sensorSwathZoom, sensorSwathZoom,
tilesForPoints,
wheelZoomStep, wheelZoomStep,
zoomSettleHolding, zoomSettleHolding,
zoomTransitionState, zoomTransitionState,
@@ -86,7 +76,7 @@ const NADIR_LOOK = Object.freeze({ yawDeg: 0, pitchDeg: 0 });
* the exit can put it back exactly. * the exit can put it back exactly.
*/ */
export function beginOrbital() { export function beginOrbital() {
const frustum = this.viewer.camera?.frustum; const pose = this.host.camera.pose();
const state = { const state = {
imagery: acquireOrbitImagery(this.services), imagery: acquireOrbitImagery(this.services),
zoom: ORBITAL_DEFAULT_ZOOM, zoom: ORBITAL_DEFAULT_ZOOM,
@@ -102,7 +92,9 @@ export function beginOrbital() {
// A zoom step in flight: the magnification it started from and when. // A zoom step in flight: the magnification it started from and when.
transition: null, transition: null,
previousUp: 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, altitudeM: null,
verticalFov: null, verticalFov: null,
lastBriefMs: 0, lastBriefMs: 0,
@@ -135,8 +127,12 @@ export function endOrbital() {
// Pre-loads still queued or in flight are for a view no longer shown. // Pre-loads still queued or in flight are for a view no longer shown.
for (const request of state.prefetchRequests) request.cancel?.(); for (const request of state.prefetchRequests) request.cancel?.();
state.prefetchRequests.clear(); state.prefetchRequests.clear();
const frustum = this.viewer.camera?.frustum; if (state.savedFov !== null) {
if (frustum && state.savedFov !== null) frustum.fov = state.savedFov; 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. // Back to the map the operator had, unless they picked another meanwhile.
state.imagery?.release?.()?.catch?.(() => {}); state.imagery?.release?.()?.catch?.(() => {});
this.publishOrbital(null); this.publishOrbital(null);
@@ -153,10 +149,7 @@ function trackPlace(info, nowMs) {
if (!state?.placeTracker) return; if (!state?.placeTracker) return;
let nearKm = null; let nearKm = null;
if (Number.isFinite(info.altitudeM)) { if (Number.isFinite(info.altitudeM)) {
const frustum = this.viewer.camera?.frustum; const aspect = viewAspect(this.host);
const aspect = Number.isFinite(frustum?.aspectRatio)
? frustum.aspectRatio
: canvasAspect(this.viewer);
const fov = fovForZoom(orbitalBaseFovRad(info.altitudeM), state.zoom); const fov = fovForZoom(orbitalBaseFovRad(info.altitudeM), state.zoom);
// The field of view is vertical: aspect 1 gives the ground height. // The field of view is vertical: aspect 1 gives the ground height.
nearKm = nearPlaceRadiusKm( nearKm = nearPlaceRadiusKm(
@@ -241,7 +234,7 @@ function applyFov(state, altitudeM, nowMs) {
state, state,
baseFov, baseFov,
nowMs, nowMs,
tilesLoaded(this.viewer), this.host.imageryReady(),
); );
// The step's last frame (its lock tick over) publishes the idle state. // The step's last frame (its lock tick over) publishes the idle state.
if (state.transition && !reading.moving) { if (state.transition && !reading.moving) {
@@ -249,33 +242,11 @@ function applyFov(state, altitudeM, nowMs) {
reading.transition = IDLE_TRANSITION; reading.transition = IDLE_TRANSITION;
} }
state.verticalFov = reading.fov; state.verticalFov = reading.fov;
const frustum = this.viewer.camera?.frustum; this.host.camera.setFovy(reading.fov);
if (frustum && 'fov' in frustum) {
const aspect = Number.isFinite(frustum.aspectRatio)
? frustum.aspectRatio
: canvasAspect(this.viewer);
frustum.fov = cesiumFrustumFovRad(reading.fov, aspect);
}
return reading; return reading;
} }
/** /** Copy a plain vector into the camera scratch. */
* 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. */
function intoCartesian(vector, result) { function intoCartesian(vector, result) {
result.x = vector.x; result.x = vector.x;
result.y = vector.y; result.y = vector.y;
@@ -293,8 +264,8 @@ function intoCartesian(vector, result) {
*/ */
export function updateOrbital(nowMs) { export function updateOrbital(nowMs) {
// Tracking anything from orbit (an aircraft, another satellite) ends the // Tracking anything from orbit (an aircraft, another satellite) ends the
// view and leaves Cesium tracking with that target. // view and leaves the engine tracking with that target.
if (this.host.current() || this.viewer.trackedEntity?.position) { if (this.host.camera.following()) {
this.exit({ restoreTracking: false }); this.exit({ restoreTracking: false });
return; return;
} }
@@ -343,7 +314,7 @@ export function updateOrbital(nowMs) {
}); });
const zoomStepping = Boolean(state.transition); const zoomStepping = Boolean(state.transition);
const reading = applyFov.call(this, state, info.altitudeM, nowMs); const reading = applyFov.call(this, state, info.altitudeM, nowMs);
this.viewer.camera.setView({ this.host.camera.setView({
destination: eye, destination: eye,
orientation: { orientation: {
direction: intoCartesian(view.direction, this.scratchForward), direction: intoCartesian(view.direction, this.scratchForward),
@@ -372,9 +343,6 @@ export function updateOrbital(nowMs) {
prefetchImageryAhead.call(this, info, 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. */ /** How far the shown time may be from now for the look-ahead to run, ms. */
const PREFETCH_LIVE_TOLERANCE_MS = 60_000; 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 * lowest priority: Cesium's request scheduler declines one while the
* imagery server's slots are taken by the tiles on screen or the scheduler * 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 * 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 * 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 * 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 * 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; return 0;
state.prefetchMs = nowMs; state.prefetchMs = nowMs;
try { const fov = fovForZoom(orbitalBaseFovRad(info.altitudeM), state.zoom);
const globe = this.viewer.scene?.globe; return this.host.prefetchImagery(state, info, fov, viewAspect(this.host));
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;
} }
/** /**
@@ -623,9 +478,7 @@ export function publishOrbitalView(info, transition = IDLE_TRANSITION) {
if (!state || !info) return; if (!state || !info) return;
const focus = orbitalFocusReadout.call(this); const focus = orbitalFocusReadout.call(this);
const offset = Boolean(focus) || lookIsOffset(state.lookTarget, 0); const offset = Boolean(focus) || lookIsOffset(state.lookTarget, 0);
const aspect = Number.isFinite(this.viewer.camera?.frustum?.aspectRatio) const aspect = viewAspect(this.host);
? this.viewer.camera.frustum.aspectRatio
: canvasAspect(this.viewer);
const settledFov = fovForZoom(orbitalBaseFovRad(info.altitudeM), state.zoom); const settledFov = fovForZoom(orbitalBaseFovRad(info.altitudeM), state.zoom);
const footprintKm = footprintWidthKm(info.altitudeM, settledFov, aspect); const footprintKm = footprintWidthKm(info.altitudeM, settledFov, aspect);
// The strip is drawn over the picture, so it follows the view as a step // 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( const zoom = sensorSwathZoom(
info.altitudeM, info.altitudeM,
swathKm, swathKm,
viewAspect(this.viewer), viewAspect(this.host),
orbitalMaxZoomFor.call(this, info), orbitalMaxZoomFor.call(this, info),
); );
return zoom === null ? false : goToZoom.call(this, zoom); return zoom === null ? false : goToZoom.call(this, zoom);
@@ -840,14 +693,12 @@ function orbitalMaxZoomFor(info) {
return orbitalMaxZoom( return orbitalMaxZoom(
info?.altitudeM, info?.altitudeM,
zoomableSwathKm(info?.sensor), zoomableSwathKm(info?.sensor),
viewAspect(this.viewer), viewAspect(this.host),
); );
} }
function viewAspect(viewer) { function viewAspect(host) {
return Number.isFinite(viewer.camera?.frustum?.aspectRatio) return host.camera.pose().aspectRatio;
? viewer.camera.frustum.aspectRatio
: canvasAspect(viewer);
} }
/** /**
+45 -3
View File
@@ -109,6 +109,8 @@ function fixture(
t, t,
{ {
satellite = ISS, satellite = ISS,
kind = 'cesium',
nativeViewer = true,
orbitAllowed = true, orbitAllowed = true,
reducedMotion = true, reducedMotion = true,
aircraftInfo = null, aircraftInfo = null,
@@ -136,7 +138,7 @@ function fixture(
const listeners = () => ({ addEventListener: () => () => {} }); const listeners = () => ({ addEventListener: () => () => {} });
const shots = []; const shots = [];
const aircraftPosition = Cesium.Cartesian3.fromDegrees(10.2, 54.5, 3000); const aircraftPosition = Cesium.Cartesian3.fromDegrees(10.2, 54.5, 3000);
const globe = createCockpitEngine({ kind: 'cesium' }); const globe = createCockpitEngine({ kind });
const { engine } = globe; const { engine } = globe;
const aircraft = globe.aircraft({ const aircraft = globe.aircraft({
position: aircraftPosition, position: aircraftPosition,
@@ -174,7 +176,16 @@ function fixture(
}); });
engine.camera.setView = (options) => viewer.camera.setView(options); engine.camera.setView = (options) => viewer.camera.setView(options);
const pose = engine.camera.pose; 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) => engine.projector.worldToWindow = (position) =>
viewer.scene.cartesianToCanvasCoordinates?.(position, {}) ?? null; viewer.scene.cartesianToCanvasCoordinates?.(position, {}) ?? null;
engine.surface.sampleHeight = (position) => viewer.scene.sampleHeight?.(position); engine.surface.sampleHeight = (position) => viewer.scene.sampleHeight?.(position);
@@ -186,7 +197,7 @@ function fixture(
get: () => engine.input.cameraInputsEnabled(), get: () => engine.input.cameraInputsEnabled(),
}); });
const cockpit = new CockpitViewController(engine, { const cockpit = new CockpitViewController(engine, {
viewer, viewer: nativeViewer ? viewer : null,
services: { services: {
flightsLayer: { getTrackedInfo: () => aircraftInfo }, flightsLayer: { getTrackedInfo: () => aircraftInfo },
militaryFlightsLayer: { getTrackedInfo: () => null }, 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(); await settle();
assert.ok(store.orbital.sensor, 'back on the Earth'); 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');
});