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
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 -21
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",
@@ -978,7 +973,8 @@
"src/ui/unitLabels.js",
"src/geo/",
"src/globe/cesium/doubleClick.js",
"src/globe/cesium/orbital.js"
"src/globe/cesium/orbital.js",
"src/globe/cesium/orbitalImagery.js"
],
"external": ["cesium"]
},
@@ -1261,11 +1257,9 @@
"src/globe/cesium/doubleClick.js",
"src/celestialRing.js",
"src/celestialRing/ephemeris.js",
"src/celestialRing/view.js",
"src/data/detectionCohort.js",
"src/data/labelArbiter.js",
"src/globe/cesium/celestial.js",
"src/layers/shared/render/engineView.js",
"src/overlays/worldOverlay.js",
"src/overlays/worldOverlayDraw.js",
"src/overlays/worldOverlayTokens.js",
@@ -1273,7 +1267,9 @@
"src/data/trackedReadout.js",
"src/ui/releaseTrackers.js",
"src/globe/cesium/orbital.js",
"src/time/clock.js"
"src/time/clock.js",
"src/globe/cesium/celestialHost.js",
"src/globe/cesium/orbitalImagery.js"
],
"external": ["cesium", "vue", "pinia", "vue-i18n"]
},
@@ -2521,11 +2517,8 @@
"src/layers/wind/model.js",
"src/layers/wind/fields.js",
"src/layers/wind/streamlines.js",
"src/layers/wind/gpuRendering.js",
"src/layers/wind/relief.js",
"src/layers/wind/inspection.js",
"src/layers/wind/presentation.js",
"src/layers/wind/rendering.js",
"src/layers/wind/source.js",
"src/sources/windBody.js",
"src/data/detection.js",
@@ -3458,7 +3451,6 @@
"src/layers/localAdsb/render/display.js",
"src/layers/shared/render/engineView.js",
"src/globe/cesium/celestial.js",
"src/celestialRing/view.js",
"src/globe/navara/ground/pulse.js",
"src/globe/navara/shaderChunks.js",
"src/globe/navara/sceneDepth.js",
@@ -3593,7 +3585,12 @@
"src/globe/cesium/orbital.js",
"src/globe/cesium/worldTime.js",
"src/data/geoImportLimits.js",
"src/data/geoImportPlan.js"
"src/data/geoImportPlan.js",
"src/globe/cesium/celestialHost.js",
"src/globe/cesium/orbitalImagery.js",
"src/globe/cesium/windGpuRendering.js",
"src/globe/cesium/windRelief.js",
"src/globe/cesium/windRendering.js"
],
"external": [
"hls.js",
@@ -3650,11 +3647,8 @@
"src/layers/wind/model.js",
"src/layers/wind/fields.js",
"src/layers/wind/streamlines.js",
"src/layers/wind/gpuRendering.js",
"src/layers/wind/relief.js",
"src/layers/wind/inspection.js",
"src/layers/wind/presentation.js",
"src/layers/wind/rendering.js",
"src/app/layers/aisLiveVessels.js",
"src/app/layers/alprCameras.js",
"src/app/layers/bikeshare.js",
@@ -4212,7 +4206,6 @@
"src/layers/localAdsb/render/display.js",
"src/layers/shared/render/moverBank.js",
"src/globe/cesium/celestial.js",
"src/celestialRing/view.js",
"src/layers/shared/render/engineView.js",
"src/layers/firms/rectangle.js",
"src/time/worldClock.js",
@@ -4306,7 +4299,11 @@
"src/data/ucsSatellites.js",
"src/data/imagingSensors.js",
"src/data/planespottersPhoto.js",
"src/data/geoImportLimits.js"
"src/data/geoImportLimits.js",
"src/globe/cesium/celestialHost.js",
"src/globe/cesium/windGpuRendering.js",
"src/globe/cesium/windRelief.js",
"src/globe/cesium/windRendering.js"
],
"external": [
"hls.js",
@@ -5013,7 +5010,6 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/celestialRing/view.js",
"src/layers/shared/render/engineView.js",
"src/globe/cesium/sceneProjection.js",
"src/layers/shared/color.js",
@@ -5031,7 +5027,8 @@
"src/data/trackedReadout.js",
"src/data/trackingGesture.js",
"src/time/worldClock.js",
"src/time/clock.js"
"src/time/clock.js",
"src/globe/cesium/celestialHost.js"
],
"external": ["cesium", "egm96-universal", "pbf"]
},
+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();
});
+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
* position (at the latest when read). For per-frame readers, which must not
* allocate; read-only, copy it to keep a value.
* @property {(radians: number) => void} setFovy Set the vertical field of
* view (finite, strictly between 0 and PI). Resizing retains the angle
* across the wider canvas side, as Cesium does. Does not cancel flights
* or following; pose, projection and picking use the new field of view.
* @property {() => number} height The camera's height above the WGS84
* ellipsoid (the pose's cartographic height), without building a pose.
* @property {(view: { destination: Cartesian3 | Rectangle, orientation?: Orientation }) => void} setView
@@ -975,6 +979,7 @@ const SHAPE = {
host: { container: isObject, canvas: isObject, size: isFunction },
camera: {
pose: isFunction,
setFovy: isFunction,
livePosition: (value) =>
value != null &&
isNumber(value.x) &&
+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(),
@@ -535,6 +535,13 @@ export function createFakeEngine(options = {}) {
}
const camera = {
setFovy(radians) {
if (!Number.isFinite(radians) || radians <= 0 || radians >= Math.PI)
throw new RangeError('Field of view must be between 0 and PI');
record('camera.setFovy', [radians]);
fovy = radians;
engine.render.request('camera-fov');
},
pose() {
const angles = poseAngles();
return {
@@ -1013,8 +1020,16 @@ export function createFakeEngine(options = {}) {
);
},
setSize(nextWidth, nextHeight) {
const fov =
width > height
? 2 * Math.atan((Math.tan(fovy / 2) * width) / height)
: fovy;
width = nextWidth;
height = nextHeight;
fovy =
width > height
? 2 * Math.atan((Math.tan(fov / 2) * height) / width)
: fov;
},
callsOf: (method) => fake.calls.filter((call) => call.method === method),
resetCalls() {
+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
@@ -1,6 +1,8 @@
// Camera and aircraft ownership used by the shared first-person controller,
// on the globe contract: the ridden aircraft is the engine follow's target,
// the aircraft display's tracked glyph handle.
import { routeViewerClick } from '../globe/cesium/orbital.js';
import { prefetchOrbitalImagery } from '../globe/cesium/orbitalImagery.js';
import { routeViewerDoubleClick } from '../globe/cesium/doubleClick.js';
/**
@@ -25,6 +27,8 @@ export function cockpitHost(engine, { viewer = null } = {}) {
release: () => engine.camera.following()?.stop(),
restore: (target) => target.resumeAircraftFollow(),
camera: engine.camera,
imageryReady: () =>
engine.maps.photorealActive() || engine.surface.tilesLoaded(),
cartographic: () => engine.camera.pose().cartographic,
terrainHeight: (place) => engine.surface.terrainHeight(place),
surface: engine,
@@ -51,6 +55,12 @@ export function cockpitHost(engine, { viewer = null } = {}) {
resumeInputs = null;
} else resumeInputs ??= engine.input.suspendCameraInputs();
},
prefetchImagery: (state, info, fov, aspect) =>
viewer ? prefetchOrbitalImagery(viewer, state, info, fov, aspect) : 0,
routeClick: (handle) =>
viewer
? routeViewerClick(viewer, handle)
: engine.input.on('click', (event) => handle(event.position)),
canvasPosition: (world) => engine.projector.worldToWindow(world) ?? null,
routeDoubleClick: (handle) =>
viewer
+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');
});