mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 13:52:19 +00:00
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:
1 parent
6727aaadcd
commit
aac65c21c4
23 files changed
+479
-234
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user