Open the orbital cockpit on Navara

The orbital view, sensor zoom, ground focus and satellite switching run on
the contract: camera.setFovy sets the vertical lens on both engines and the
fake, including sub-degree angles on Navara through the guarded core
bridge, with a contract check and mutants. Cesium's imagery look-ahead
stays inside its adapter. KNOWN-ISSUES keeps the two parts Navara still
lacks: imagery prefetch for the orbital view and finest-detail meshes for
the ship bridge waterline.
This commit is contained in:
German Khokhlov committed 2026-11-10 16:14:14 +04:00
1 parent 6727aaadcd
commit aac65c21c4
23 files changed
+479 -234

No files matched your search

+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 -21
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",
@@ -978,7 +973,8 @@
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/geo/", "src/geo/",
"src/globe/cesium/doubleClick.js", "src/globe/cesium/doubleClick.js",
"src/globe/cesium/orbital.js" "src/globe/cesium/orbital.js",
"src/globe/cesium/orbitalImagery.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1261,11 +1257,9 @@
"src/globe/cesium/doubleClick.js", "src/globe/cesium/doubleClick.js",
"src/celestialRing.js", "src/celestialRing.js",
"src/celestialRing/ephemeris.js", "src/celestialRing/ephemeris.js",
"src/celestialRing/view.js",
"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",
@@ -1273,7 +1267,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"]
}, },
@@ -2521,11 +2517,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/layers/wind/source.js", "src/layers/wind/source.js",
"src/sources/windBody.js", "src/sources/windBody.js",
"src/data/detection.js", "src/data/detection.js",
@@ -3458,7 +3451,6 @@
"src/layers/localAdsb/render/display.js", "src/layers/localAdsb/render/display.js",
"src/layers/shared/render/engineView.js", "src/layers/shared/render/engineView.js",
"src/globe/cesium/celestial.js", "src/globe/cesium/celestial.js",
"src/celestialRing/view.js",
"src/globe/navara/ground/pulse.js", "src/globe/navara/ground/pulse.js",
"src/globe/navara/shaderChunks.js", "src/globe/navara/shaderChunks.js",
"src/globe/navara/sceneDepth.js", "src/globe/navara/sceneDepth.js",
@@ -3593,7 +3585,12 @@
"src/globe/cesium/orbital.js", "src/globe/cesium/orbital.js",
"src/globe/cesium/worldTime.js", "src/globe/cesium/worldTime.js",
"src/data/geoImportLimits.js", "src/data/geoImportLimits.js",
"src/data/geoImportPlan.js" "src/data/geoImportPlan.js",
"src/globe/cesium/celestialHost.js",
"src/globe/cesium/orbitalImagery.js",
"src/globe/cesium/windGpuRendering.js",
"src/globe/cesium/windRelief.js",
"src/globe/cesium/windRendering.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
@@ -3650,11 +3647,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",
@@ -4212,7 +4206,6 @@
"src/layers/localAdsb/render/display.js", "src/layers/localAdsb/render/display.js",
"src/layers/shared/render/moverBank.js", "src/layers/shared/render/moverBank.js",
"src/globe/cesium/celestial.js", "src/globe/cesium/celestial.js",
"src/celestialRing/view.js",
"src/layers/shared/render/engineView.js", "src/layers/shared/render/engineView.js",
"src/layers/firms/rectangle.js", "src/layers/firms/rectangle.js",
"src/time/worldClock.js", "src/time/worldClock.js",
@@ -4306,7 +4299,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",
@@ -5013,7 +5010,6 @@
"src/i18n/locales/uk.json", "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"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",
@@ -5031,7 +5027,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"]
}, },
+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();
});
+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;
}
+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
@@ -975,6 +979,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(),
@@ -535,6 +535,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 {
@@ -1013,8 +1020,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() {
+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
@@ -1,6 +1,8 @@
// 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';
/** /**
@@ -25,6 +27,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) => engine.surface.terrainHeight(place), terrainHeight: (place) => engine.surface.terrainHeight(place),
surface: engine, surface: engine,
@@ -51,6 +55,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');
});