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