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