mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(globe): follow camera on both engines
camera.follow({ position, viewFrom, minRange = 150, native? }) keeps a
moving target centred at an east-north-up offset the user may turn and
zoom; camera.following() and camera.followChanged report it; stop() frees
the camera and a camera command (setView, flyTo, flyToSphere, lookAt,
cancelFlight) ends it.
Probe ruling: Navara 0.2.1's cameraFollow holds the target but ignores the
user's drag and wheel, the aircraft sits ~13 px off centre with 7.6 px of
spread, and calling it without an offset each frame gives a NaN camera.
So Navara (and the fake engine) follow with src/globe/follow.js: each
frame, before Navara draws, the offset is re-read from where the user put
the camera relative to the previous target, kept outside the minimum
range and carried to the new target.
Cesium follows with viewer.trackedEntity: the layer's own Entity
(`native`) or a proxy Entity at position() with a 1 px transparent point,
since EntityView moves the camera only for an entity with a bounding
sphere. applyTrackedCameraFrame and the zoom limits moved to
src/globe/cesium/follow.js; src/data/trackedCamera.js keeps the pure parts
(the range clamp now lives in src/globe/follow.js). following() also
reports an entity the app tracked itself. Camera sensitivity claims made
on a viewer's Cesium camera now share the engine camera's ledger, and the
orientation controls and the world jump read the follow from the engine.
Contract (both engines): a target flying at 250 m/s stays within 3 px of
the centre for 2 s, zoomIn through it stops at 150 m, stop() and setView
end the follow. Cesium 71/71, Navara 77/77; shell 23/25/4.
This commit is contained in:
1 parent
a89719aba9
commit
f640890cf6
24 files changed
+1226
-218
No files matched your search
@@ -1085,5 +1085,9 @@
|
||||
"src/tooling/w1Feeds.test.mjs",
|
||||
"src/tooling/navaraDependencies.test.mjs",
|
||||
"build/navaraDependencies.js",
|
||||
"build/initialChunkGuard.js"
|
||||
"build/initialChunkGuard.js",
|
||||
"src/globe/follow.js",
|
||||
"src/globe/follow.test.mjs",
|
||||
"src/globe/cesium/follow.js",
|
||||
"src/globe/cesium/follow.test.mjs"
|
||||
]
|
||||
@@ -1220,7 +1220,9 @@
|
||||
"src/geo/scaleToGeodeticSurface.js",
|
||||
"src/geo/check.js",
|
||||
"src/geo/debug.js",
|
||||
"src/geo/developerError.js"
|
||||
"src/geo/developerError.js",
|
||||
"src/globe/contract.js",
|
||||
"src/globe/cesium/engineFromViewer.js"
|
||||
],
|
||||
"external": ["cesium", "@mapbox/vector-tile", "pbf"]
|
||||
},
|
||||
@@ -1316,7 +1318,9 @@
|
||||
"src/geo/scaleToGeodeticSurface.js",
|
||||
"src/geo/check.js",
|
||||
"src/geo/debug.js",
|
||||
"src/geo/developerError.js"
|
||||
"src/geo/developerError.js",
|
||||
"src/globe/contract.js",
|
||||
"src/globe/cesium/engineFromViewer.js"
|
||||
],
|
||||
"external": ["cesium", "pbf", "@mapbox/vector-tile"]
|
||||
},
|
||||
@@ -1353,7 +1357,9 @@
|
||||
"src/geo/scaleToGeodeticSurface.js",
|
||||
"src/geo/check.js",
|
||||
"src/geo/debug.js",
|
||||
"src/geo/developerError.js"
|
||||
"src/geo/developerError.js",
|
||||
"src/globe/contract.js",
|
||||
"src/globe/cesium/engineFromViewer.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -2542,10 +2548,12 @@
|
||||
"src/globe/pickDelivery.js",
|
||||
"src/globe/navara/clock.js",
|
||||
"src/globe/cesium/lines.js",
|
||||
"src/globe/follow.js",
|
||||
"src/globe/lines.js",
|
||||
"src/globe/navara/movers/pickIds.js",
|
||||
"src/globe/cesium/movers.js",
|
||||
"src/globe/movers.js",
|
||||
"src/globe/cesium/follow.js",
|
||||
"src/globe/cpuPicks.js",
|
||||
"src/globe/navara/picking.js",
|
||||
"src/globe/navara/lines/lineMesh.js",
|
||||
@@ -3122,7 +3130,9 @@
|
||||
"src/geo/check.js",
|
||||
"src/geo/debug.js",
|
||||
"src/geo/developerError.js",
|
||||
"src/time/clock.js"
|
||||
"src/time/clock.js",
|
||||
"src/globe/follow.js",
|
||||
"src/globe/cesium/follow.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -3735,7 +3745,9 @@
|
||||
"src/geo/scaleToGeodeticSurface.js",
|
||||
"src/geo/check.js",
|
||||
"src/geo/debug.js",
|
||||
"src/geo/developerError.js"
|
||||
"src/geo/developerError.js",
|
||||
"src/globe/contract.js",
|
||||
"src/globe/cesium/engineFromViewer.js"
|
||||
],
|
||||
"external": ["cesium", "pbf"]
|
||||
},
|
||||
|
||||
@@ -3,7 +3,8 @@ import * as picking from '../../data/pickRegistry.js';
|
||||
import * as sprites from '../../data/spriteOrder.js';
|
||||
import * as trails from '../../data/trailRenderer.js';
|
||||
import * as aircraftPresentation from '../../data/tr3bRegistry.js';
|
||||
import * as camera from '../../data/trackedCamera.js';
|
||||
import * as trackedCamera from '../../data/trackedCamera.js';
|
||||
import { applyTrackedCameraFrame } from '../../globe/cesium/follow.js';
|
||||
import * as labels from '../../data/detectionDraw.js';
|
||||
import * as geoid from '../../data/geoid.js';
|
||||
import * as focus from '../../data/focusDeemphasis.js';
|
||||
@@ -29,7 +30,7 @@ export function createApplicationFlights({
|
||||
sprites,
|
||||
trails,
|
||||
aircraftPresentation,
|
||||
camera,
|
||||
camera: { ...trackedCamera, applyTrackedCameraFrame },
|
||||
militaryRegistry,
|
||||
labels,
|
||||
groundFloor,
|
||||
|
||||
@@ -3,7 +3,8 @@ import * as picking from '../../data/pickRegistry.js';
|
||||
import * as sprites from '../../data/spriteOrder.js';
|
||||
import * as trails from '../../data/trailRenderer.js';
|
||||
import * as aircraftPresentation from '../../data/tr3bRegistry.js';
|
||||
import * as camera from '../../data/trackedCamera.js';
|
||||
import * as trackedCamera from '../../data/trackedCamera.js';
|
||||
import { applyTrackedCameraFrame } from '../../globe/cesium/follow.js';
|
||||
import * as labels from '../../data/detectionDraw.js';
|
||||
import * as geoid from '../../data/geoid.js';
|
||||
import * as focus from '../../data/focusDeemphasis.js';
|
||||
@@ -29,7 +30,7 @@ export function createApplicationMilitary({
|
||||
sprites,
|
||||
trails,
|
||||
aircraftPresentation,
|
||||
camera,
|
||||
camera: { ...trackedCamera, applyTrackedCameraFrame },
|
||||
militaryRegistry,
|
||||
labels,
|
||||
groundFloor,
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
* for, only that nobody's answer is taken away while they still need it.
|
||||
*/
|
||||
|
||||
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
|
||||
|
||||
/** @type {WeakMap<object, {base: number, claims: Map<string, number>}>} */
|
||||
const _cameras = new WeakMap();
|
||||
|
||||
@@ -31,10 +33,13 @@ const _cameras = new WeakMap();
|
||||
const DEFAULT_PERCENTAGE_CHANGED = 0.5;
|
||||
|
||||
/**
|
||||
* A globe engine (or a viewer) claims through its camera; anything else is
|
||||
* the camera itself (a camera has no `camera` of its own).
|
||||
* One key per camera: the engine's camera. A globe engine claims through its
|
||||
* camera; a Cesium viewer or its camera through the engine registered for
|
||||
* that viewer (engineFromViewer); anything else is the camera itself (a
|
||||
* camera has no `camera` of its own).
|
||||
*/
|
||||
const cameraOf = (target) => target?.camera ?? target;
|
||||
const cameraOf = (target) =>
|
||||
engineFromViewer(target)?.camera ?? target?.camera ?? target;
|
||||
|
||||
function ledgerFor(camera) {
|
||||
let ledger = _cameras.get(camera);
|
||||
@@ -60,9 +65,8 @@ function applyLedger(camera, ledger) {
|
||||
/**
|
||||
* Ask for a camera-change sensitivity at least as fine as `value`.
|
||||
* @param {object|null|undefined} target A globe engine, its camera, or a
|
||||
* Cesium camera (anything with `percentageChanged`). Layers still pass the
|
||||
* viewer's Cesium camera until phase 1c; that is a separate claimant from
|
||||
* the engine's camera, so one camera must not be claimed both ways.
|
||||
* Cesium viewer or camera (anything with `percentageChanged`). A viewer
|
||||
* with a registered engine, and its camera, claim on the engine camera.
|
||||
* @param {string} owner Stable claim name, usually the layer id.
|
||||
* @param {number} value Requested `percentageChanged`.
|
||||
* @returns {number|null} The value now in force, or null when there is no camera.
|
||||
|
||||
@@ -25,3 +25,31 @@ test('claims on a globe engine set its camera sensitivity and share one ledger',
|
||||
assert.equal(engine.camera.percentageChanged, 0.5);
|
||||
assert.deepEqual(cameraSensitivityClaims(engine), []);
|
||||
});
|
||||
|
||||
test("a Cesium viewer's camera claims on the same key as its engine's camera", async () => {
|
||||
const { createFakeViewer } =
|
||||
await import('../globe/cesium/testing/fakeViewer.mjs');
|
||||
const { createCesiumEngine } =
|
||||
await import('../globe/cesium/createCesiumEngine.js');
|
||||
const { registerViewerEngine } =
|
||||
await import('../globe/cesium/engineFromViewer.js');
|
||||
const viewer = createFakeViewer();
|
||||
const engine = createCesiumEngine(viewer, { requestRender: () => {} });
|
||||
const unregister = registerViewerEngine(viewer, engine);
|
||||
try {
|
||||
claimCameraSensitivity(viewer.camera, 'traffic', 0.05);
|
||||
claimCameraSensitivity(engine, 'transit', 0.05);
|
||||
// One ledger: both claims are on it, and releasing one keeps the other.
|
||||
assert.deepEqual(cameraSensitivityClaims(viewer).sort(), [
|
||||
'traffic',
|
||||
'transit',
|
||||
]);
|
||||
releaseCameraSensitivity(engine, 'transit');
|
||||
assert.equal(viewer.camera.percentageChanged, 0.05);
|
||||
releaseCameraSensitivity(viewer.camera, 'traffic');
|
||||
assert.equal(viewer.camera.percentageChanged, 0.5);
|
||||
} finally {
|
||||
unregister();
|
||||
engine.destroy();
|
||||
}
|
||||
});
|
||||
+5
-175
@@ -1,8 +1,10 @@
|
||||
// The pure parts of the tracked aircraft camera. The Cesium follow itself
|
||||
// (the one-off tracked frame, the zoom limits) is the engine's:
|
||||
// src/globe/cesium/follow.js; the range clamp is src/globe/follow.js.
|
||||
|
||||
import * as geo from '../geo/index.js';
|
||||
|
||||
const MAX_FRAME_ATTEMPTS = 120;
|
||||
const MIN_TRACKED_RANGE_M = 150;
|
||||
const ZOOM_INERTIA_STATES = new WeakMap();
|
||||
export { clampTrackedCameraPosition } from '../globe/follow.js';
|
||||
|
||||
/**
|
||||
* Preserve real-world model scale except when a very close tracked camera
|
||||
@@ -62,175 +64,3 @@ export function trackedDisplayPositionForCamera(entity, time, result) {
|
||||
? geo.cartesian3.clone(displayedPosition, result)
|
||||
: entity?.position?.getValue(time, result);
|
||||
}
|
||||
|
||||
function acquireStableTrackedZoom(controller, entity) {
|
||||
if (!controller) return () => {};
|
||||
let state = ZOOM_INERTIA_STATES.get(controller);
|
||||
if (!state) {
|
||||
state = {
|
||||
originalInertiaZoom: controller.inertiaZoom,
|
||||
originalMinimumZoomDistance: controller.minimumZoomDistance,
|
||||
owners: new Set(),
|
||||
};
|
||||
ZOOM_INERTIA_STATES.set(controller, state);
|
||||
}
|
||||
state.owners.add(entity);
|
||||
controller.inertiaZoom = 0;
|
||||
controller.minimumZoomDistance = Math.max(
|
||||
state.originalMinimumZoomDistance,
|
||||
MIN_TRACKED_RANGE_M,
|
||||
);
|
||||
return () => {
|
||||
const current = ZOOM_INERTIA_STATES.get(controller);
|
||||
if (!current) return;
|
||||
current.owners.delete(entity);
|
||||
if (current.owners.size > 0) return;
|
||||
controller.inertiaZoom = current.originalInertiaZoom;
|
||||
controller.minimumZoomDistance = current.originalMinimumZoomDistance;
|
||||
ZOOM_INERTIA_STATES.delete(controller);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a tracked-frame camera on the same side of its target and outside the
|
||||
* minimum readable range.
|
||||
*
|
||||
* @param {{position: Cesium.Cartesian3, direction: Cesium.Cartesian3}} camera Camera-like object.
|
||||
* @param {Cesium.Cartesian3} previousPosition Previous tracked-frame camera position.
|
||||
* @param {number} [minimumRangeM=MIN_TRACKED_RANGE_M] Minimum target range.
|
||||
* @returns {boolean} Whether the camera position was corrected.
|
||||
*/
|
||||
export function clampTrackedCameraPosition(
|
||||
camera,
|
||||
previousPosition,
|
||||
minimumRangeM = MIN_TRACKED_RANGE_M,
|
||||
) {
|
||||
const crossedOrigin =
|
||||
geo.cartesian3.dot(camera.position, previousPosition) <= 0;
|
||||
const forwardDistance = -geo.cartesian3.dot(
|
||||
camera.position,
|
||||
camera.direction,
|
||||
);
|
||||
const rangeSquared = geo.cartesian3.magnitudeSquared(camera.position);
|
||||
if (crossedOrigin) {
|
||||
geo.cartesian3.normalize(previousPosition, camera.position);
|
||||
geo.cartesian3.multiplyByScalar(
|
||||
camera.position,
|
||||
minimumRangeM,
|
||||
camera.position,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
forwardDistance < minimumRangeM ||
|
||||
rangeSquared < minimumRangeM * minimumRangeM
|
||||
) {
|
||||
geo.cartesian3.multiplyByScalar(
|
||||
camera.direction,
|
||||
-minimumRangeM,
|
||||
camera.position,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an entity's requested follow offset once, then let Cesium's ENU
|
||||
* EntityView exclusively own continuous following.
|
||||
*
|
||||
* Cesium can retain the previous world-space camera position when tracking
|
||||
* switches across a large distance. The view then only rotates toward the new
|
||||
* aircraft, and the next zoom can drive the camera through the ellipsoid.
|
||||
* Applying the exact frame for the handoff makes selection deterministic. We
|
||||
* deliberately do not rebuild it every frame: Cesium's EntityView already
|
||||
* follows the same ENU entity, and two writers create sub-pixel camera jitter
|
||||
* that becomes visible at minimum range.
|
||||
*
|
||||
* @param {Cesium.Viewer} viewer Active viewer.
|
||||
* @param {Cesium.Entity} entity Newly tracked entity.
|
||||
* @param {Cesium.Cartesian3} viewFrom Desired camera offset in the tracked frame.
|
||||
* @returns {(() => void)|undefined} Disposer for this one tracked-frame owner.
|
||||
*/
|
||||
export function applyTrackedCameraFrame(viewer, entity, viewFrom) {
|
||||
if (!viewer || !entity || !viewFrom) return;
|
||||
let attempts = 0;
|
||||
const trackedTransform = geo.matrix4.create();
|
||||
const trackedPosition = geo.cartesian3.create();
|
||||
const resolvedViewFrom = geo.cartesian3.create();
|
||||
const cameraOffset = geo.cartesian3.create();
|
||||
const previousCameraPosition = geo.cartesian3.create();
|
||||
let framed = false;
|
||||
const restoreZoomInertia = acquireStableTrackedZoom(
|
||||
viewer.scene.screenSpaceCameraController,
|
||||
entity,
|
||||
);
|
||||
let stopped = false;
|
||||
let remove = null;
|
||||
const stop = () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
remove?.();
|
||||
restoreZoomInertia();
|
||||
};
|
||||
|
||||
// preUpdate is deliberate. preRender fires after Cesium has prepared
|
||||
// billboard/model draw state, so changing the tracked transform there makes
|
||||
// the camera and target visual disagree for one frame (visible as a
|
||||
// front/back oscillation). The controller then enforces the minimum range
|
||||
// during the same update pass.
|
||||
remove = viewer.scene.preUpdate.addEventListener(() => {
|
||||
if (viewer.isDestroyed() || viewer.trackedEntity !== entity) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
attempts += framed ? 0 : 1;
|
||||
// Prefer the display cache exposed by the flight layer. Calling the
|
||||
// CallbackProperty from preUpdate can advance dead reckoning again while
|
||||
// Cesium's billboard/label still holds the prior visualizer sample. At a
|
||||
// 150 m follow range that sub-frame difference becomes visible as the
|
||||
// label swinging around the icon during an orbit, especially immediately
|
||||
// after a 2D→3D→2D handoff.
|
||||
if (!framed) {
|
||||
const position = trackedDisplayPositionForCamera(
|
||||
entity,
|
||||
viewer.clock.currentTime,
|
||||
trackedPosition,
|
||||
);
|
||||
if (!position) {
|
||||
if (attempts >= MAX_FRAME_ATTEMPTS) stop();
|
||||
return;
|
||||
}
|
||||
const transform = geo.transforms.eastNorthUpToFixedFrame(
|
||||
position,
|
||||
geo.ellipsoid.WGS84,
|
||||
trackedTransform,
|
||||
);
|
||||
const offset =
|
||||
typeof viewFrom.getValue === 'function'
|
||||
? viewFrom.getValue(viewer.clock.currentTime, resolvedViewFrom)
|
||||
: geo.cartesian3.clone(viewFrom, resolvedViewFrom);
|
||||
if (
|
||||
!offset ||
|
||||
geo.cartesian3.magnitudeSquared(offset) < geo.math.EPSILON12
|
||||
) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
viewer.camera.lookAtTransform(transform, offset);
|
||||
geo.cartesian3.clone(viewer.camera.position, previousCameraPosition);
|
||||
framed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal frames are intentionally read-only: EntityView is the sole
|
||||
// continuous camera writer. Only a programmatic zoom that bypasses the
|
||||
// controller's minimumZoomDistance needs correction.
|
||||
if (clampTrackedCameraPosition(viewer.camera, previousCameraPosition)) {
|
||||
geo.cartesian3.clone(viewer.camera.position, cameraOffset);
|
||||
viewer.camera.lookAtTransform(viewer.camera.transform, cameraOffset);
|
||||
}
|
||||
geo.cartesian3.clone(viewer.camera.position, previousCameraPosition);
|
||||
});
|
||||
return stop;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import * as geo from '../../geo/index.js';
|
||||
import { createEngineEvent } from '../contract.js';
|
||||
import { bridgeEvent } from './events.js';
|
||||
import { toCesium2, toCesium3, toGeo3 } from './convert.js';
|
||||
import { createCesiumFollow } from './follow.js';
|
||||
|
||||
const { math } = geo;
|
||||
const EASINGS = {
|
||||
@@ -143,6 +144,10 @@ export function createCesiumCamera(viewer) {
|
||||
moveEnd: bridgeEvent(cesium.moveEnd),
|
||||
commanded: commandedEvent(viewer, cesium),
|
||||
};
|
||||
// camera.follow: the viewer's trackedEntity (follow.js).
|
||||
const follow = createCesiumFollow(viewer, {
|
||||
commanded: bridges.commanded.event,
|
||||
});
|
||||
|
||||
// The engine's own flight, so flightActive is exact even for flights that
|
||||
// completed synchronously (Cesium keeps no current flight for those).
|
||||
@@ -287,6 +292,7 @@ export function createCesiumCamera(viewer) {
|
||||
moveStart: bridges.moveStart.event,
|
||||
moveEnd: bridges.moveEnd.event,
|
||||
commanded: bridges.commanded.event,
|
||||
...follow.camera,
|
||||
get percentageChanged() {
|
||||
return cesium.percentageChanged;
|
||||
},
|
||||
@@ -298,6 +304,7 @@ export function createCesiumCamera(viewer) {
|
||||
return {
|
||||
camera,
|
||||
destroy() {
|
||||
follow.destroy();
|
||||
if (flight) camera.cancelFlight();
|
||||
for (const bridge of Object.values(bridges)) bridge.detach();
|
||||
},
|
||||
|
||||
@@ -112,6 +112,7 @@ test('the viewer clock itself is left alone', () => {
|
||||
const viewer = createFakeViewer();
|
||||
viewer.clock.shouldAnimate = false;
|
||||
viewer.clock.multiplier = 1;
|
||||
const currentTime = viewer.clock.currentTime.clone();
|
||||
const { clock } = createCesiumClock(viewer, {
|
||||
appClock: manualAppClock().appClock,
|
||||
});
|
||||
@@ -119,5 +120,5 @@ test('the viewer clock itself is left alone', () => {
|
||||
cesiumFrame(viewer);
|
||||
assert.equal(viewer.clock.shouldAnimate, false);
|
||||
assert.equal(viewer.clock.multiplier, 1);
|
||||
assert.equal(viewer.clock.currentTime, undefined);
|
||||
assert.ok(viewer.clock.currentTime.equals(currentTime));
|
||||
});
|
||||
@@ -13,15 +13,21 @@ const engines = new WeakMap();
|
||||
* @returns {() => void} Unregister (only while it is still the registered one).
|
||||
*/
|
||||
export function registerViewerEngine(viewer, engine) {
|
||||
engines.set(viewer, engine);
|
||||
// The viewer's Cesium camera names the same engine, so callers that hold
|
||||
// only viewer.camera (camera sensitivity claims) reach the engine camera.
|
||||
const keys = [viewer, viewer?.camera, viewer?.scene?.camera].filter(
|
||||
(key, index, all) => key != null && all.indexOf(key) === index,
|
||||
);
|
||||
for (const key of keys) engines.set(key, engine);
|
||||
return () => {
|
||||
if (engines.get(viewer) === engine) engines.delete(viewer);
|
||||
for (const key of keys)
|
||||
if (engines.get(key) === engine) engines.delete(key);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object | null | undefined} viewer A Cesium viewer, or an engine
|
||||
* (returned as is).
|
||||
* @param {object | null | undefined} viewer A Cesium viewer or its camera,
|
||||
* or an engine (returned as is).
|
||||
* @returns {import('../contract.js').GlobeEngine | null} The registered
|
||||
* engine, or null.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
// camera.follow on Cesium: the viewer's trackedEntity (Cesium's EntityView
|
||||
// owns the continuous follow, the user turns and zooms around the target).
|
||||
// With `native` the layer's own Entity is tracked; without it an internal
|
||||
// proxy Entity whose position is the caller's `position()`. Each follow gets
|
||||
// one exact tracked frame on preUpdate (applyTrackedCameraFrame) and the
|
||||
// zoom limits that keep the camera outside the minimum range.
|
||||
// following() also reports a trackedEntity the app set itself, and
|
||||
// followChanged is viewer.trackedEntityChanged.
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { createEngineEvent } from '../contract.js';
|
||||
import {
|
||||
MIN_FOLLOW_RANGE_M,
|
||||
clampTrackedCameraPosition,
|
||||
normalizeFollowOptions,
|
||||
} from '../follow.js';
|
||||
import { toCesium3 } from './convert.js';
|
||||
|
||||
const MAX_FRAME_ATTEMPTS = 120;
|
||||
const ZOOM_INERTIA_STATES = new WeakMap();
|
||||
|
||||
function acquireStableTrackedZoom(controller, owner, minRange) {
|
||||
if (!controller) return () => {};
|
||||
let state = ZOOM_INERTIA_STATES.get(controller);
|
||||
if (!state) {
|
||||
state = {
|
||||
originalInertiaZoom: controller.inertiaZoom,
|
||||
originalMinimumZoomDistance: controller.minimumZoomDistance,
|
||||
owners: new Set(),
|
||||
};
|
||||
ZOOM_INERTIA_STATES.set(controller, state);
|
||||
}
|
||||
state.owners.add(owner);
|
||||
controller.inertiaZoom = 0;
|
||||
controller.minimumZoomDistance = Math.max(
|
||||
state.originalMinimumZoomDistance ?? 0,
|
||||
minRange,
|
||||
);
|
||||
return () => {
|
||||
const current = ZOOM_INERTIA_STATES.get(controller);
|
||||
if (!current) return;
|
||||
current.owners.delete(owner);
|
||||
if (current.owners.size > 0) return;
|
||||
controller.inertiaZoom = current.originalInertiaZoom;
|
||||
controller.minimumZoomDistance = current.originalMinimumZoomDistance;
|
||||
ZOOM_INERTIA_STATES.delete(controller);
|
||||
};
|
||||
}
|
||||
|
||||
// The position the tracked visual already drew (the flight layers' display
|
||||
// cache), without advancing its CallbackProperty again.
|
||||
function displayPosition(entity, time, result) {
|
||||
const displayed = entity?.gevDisplayPosition?.();
|
||||
return displayed
|
||||
? geo.cartesian3.clone(displayed, result)
|
||||
: entity?.position?.getValue(time, result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an entity's requested follow offset once, then let Cesium's ENU
|
||||
* EntityView exclusively own continuous following.
|
||||
*
|
||||
* Cesium can retain the previous world-space camera position when tracking
|
||||
* switches across a large distance. The view then only rotates toward the new
|
||||
* aircraft, and the next zoom can drive the camera through the ellipsoid.
|
||||
* Applying the exact frame for the handoff makes selection deterministic. We
|
||||
* deliberately do not rebuild it every frame: Cesium's EntityView already
|
||||
* follows the same ENU entity, and two writers create sub-pixel camera jitter
|
||||
* that becomes visible at minimum range.
|
||||
*
|
||||
* @param {Cesium.Viewer} viewer Active viewer.
|
||||
* @param {Cesium.Entity} entity Newly tracked entity.
|
||||
* @param {Cesium.Cartesian3 | Cesium.Property} viewFrom Desired camera offset
|
||||
* in the tracked frame.
|
||||
* @param {{ minRange?: number }} [options]
|
||||
* @returns {(() => void)|undefined} Disposer for this one tracked-frame owner.
|
||||
*/
|
||||
export function applyTrackedCameraFrame(
|
||||
viewer,
|
||||
entity,
|
||||
viewFrom,
|
||||
{ minRange = MIN_FOLLOW_RANGE_M } = {},
|
||||
) {
|
||||
if (!viewer || !entity || !viewFrom) return;
|
||||
let attempts = 0;
|
||||
const trackedTransform = geo.matrix4.create();
|
||||
const trackedPosition = geo.cartesian3.create();
|
||||
const resolvedViewFrom = geo.cartesian3.create();
|
||||
const cameraOffset = geo.cartesian3.create();
|
||||
const previousCameraPosition = geo.cartesian3.create();
|
||||
let framed = false;
|
||||
const restoreZoomInertia = acquireStableTrackedZoom(
|
||||
viewer.scene.screenSpaceCameraController,
|
||||
entity,
|
||||
minRange,
|
||||
);
|
||||
let stopped = false;
|
||||
let remove = null;
|
||||
const stop = () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
remove?.();
|
||||
restoreZoomInertia();
|
||||
};
|
||||
|
||||
// preUpdate is deliberate. preRender fires after Cesium has prepared
|
||||
// billboard/model draw state, so changing the tracked transform there makes
|
||||
// the camera and target visual disagree for one frame (visible as a
|
||||
// front/back oscillation). The controller then enforces the minimum range
|
||||
// during the same update pass.
|
||||
remove = viewer.scene.preUpdate.addEventListener(() => {
|
||||
if (viewer.isDestroyed?.() || viewer.trackedEntity !== entity) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
attempts += framed ? 0 : 1;
|
||||
// Prefer the display cache exposed by the flight layer. Calling the
|
||||
// CallbackProperty from preUpdate can advance dead reckoning again while
|
||||
// Cesium's billboard/label still holds the prior visualizer sample. At a
|
||||
// 150 m follow range that sub-frame difference becomes visible as the
|
||||
// label swinging around the icon during an orbit, especially immediately
|
||||
// after a 2D→3D→2D handoff.
|
||||
if (!framed) {
|
||||
const position = displayPosition(
|
||||
entity,
|
||||
viewer.clock.currentTime,
|
||||
trackedPosition,
|
||||
);
|
||||
if (!position) {
|
||||
if (attempts >= MAX_FRAME_ATTEMPTS) stop();
|
||||
return;
|
||||
}
|
||||
const transform = geo.transforms.eastNorthUpToFixedFrame(
|
||||
position,
|
||||
geo.ellipsoid.WGS84,
|
||||
trackedTransform,
|
||||
);
|
||||
const offset =
|
||||
typeof viewFrom.getValue === 'function'
|
||||
? viewFrom.getValue(viewer.clock.currentTime, resolvedViewFrom)
|
||||
: geo.cartesian3.clone(viewFrom, resolvedViewFrom);
|
||||
if (
|
||||
!offset ||
|
||||
geo.cartesian3.magnitudeSquared(offset) < geo.math.EPSILON12
|
||||
) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
viewer.camera.lookAtTransform(transform, offset);
|
||||
geo.cartesian3.clone(viewer.camera.position, previousCameraPosition);
|
||||
framed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal frames are intentionally read-only: EntityView is the sole
|
||||
// continuous camera writer. Only a programmatic zoom that bypasses the
|
||||
// controller's minimumZoomDistance needs correction.
|
||||
if (
|
||||
clampTrackedCameraPosition(
|
||||
viewer.camera,
|
||||
previousCameraPosition,
|
||||
minRange,
|
||||
)
|
||||
) {
|
||||
geo.cartesian3.clone(viewer.camera.position, cameraOffset);
|
||||
viewer.camera.lookAtTransform(viewer.camera.transform, cameraOffset);
|
||||
}
|
||||
geo.cartesian3.clone(viewer.camera.position, previousCameraPosition);
|
||||
});
|
||||
return stop;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Cesium.Viewer} viewer
|
||||
* @param {{ commanded: object }} ports The engine camera's commanded event.
|
||||
*/
|
||||
export function createCesiumFollow(viewer, { commanded }) {
|
||||
const followChanged = createEngineEvent();
|
||||
// The follow this engine started.
|
||||
let own = null;
|
||||
// Handles for entities the app tracked itself, one per entity.
|
||||
const wrapped = new WeakMap();
|
||||
let switching = false;
|
||||
|
||||
const wrap = (entity) => {
|
||||
let handle = wrapped.get(entity);
|
||||
if (!handle) {
|
||||
handle = {
|
||||
native: entity,
|
||||
stop() {
|
||||
if (viewer.trackedEntity === entity) viewer.trackedEntity = undefined;
|
||||
},
|
||||
retarget: (next) =>
|
||||
follow({
|
||||
native: entity,
|
||||
position: () =>
|
||||
displayPosition(entity, viewer.clock.currentTime, undefined),
|
||||
viewFrom: () =>
|
||||
entity.viewFrom?.getValue?.(viewer.clock.currentTime) ??
|
||||
entity.viewFrom,
|
||||
...next,
|
||||
}),
|
||||
};
|
||||
wrapped.set(entity, handle);
|
||||
}
|
||||
return handle;
|
||||
};
|
||||
|
||||
const following = () => {
|
||||
const entity = viewer.trackedEntity;
|
||||
if (!entity) return null;
|
||||
if (own && own.entity === entity) return own.handle;
|
||||
return wrap(entity);
|
||||
};
|
||||
|
||||
// followChanged rides on trackedEntityChanged, subscribed once something
|
||||
// listens or follows (creating the engine subscribes nothing).
|
||||
let removeTracked = null;
|
||||
const onTracked = () => {
|
||||
if (switching) return;
|
||||
if (own && viewer.trackedEntity !== own.entity) release(own);
|
||||
followChanged.raiseEvent(following());
|
||||
};
|
||||
const watchTracking = () => {
|
||||
if (removeTracked || !viewer.trackedEntityChanged) return;
|
||||
removeTracked = viewer.trackedEntityChanged.addEventListener(onTracked);
|
||||
};
|
||||
const addListener = followChanged.addEventListener;
|
||||
followChanged.addEventListener = (listener) => {
|
||||
watchTracking();
|
||||
return addListener(listener);
|
||||
};
|
||||
|
||||
// Undo what one follow put on the viewer (not trackedEntity itself).
|
||||
function teardown(state) {
|
||||
state.stopFrame?.();
|
||||
state.stopFrame = null;
|
||||
state.removeCommanded?.();
|
||||
state.removeCommanded = null;
|
||||
if (state.proxy) viewer.entities.remove(state.proxy);
|
||||
state.proxy = null;
|
||||
}
|
||||
function release(state) {
|
||||
if (own !== state) return;
|
||||
own = null;
|
||||
teardown(state);
|
||||
}
|
||||
|
||||
function start(state, options) {
|
||||
state.options = options;
|
||||
let entity = options.native;
|
||||
if (!entity) {
|
||||
const scratch = new Cesium.Cartesian3();
|
||||
entity = state.proxy = viewer.entities.add({
|
||||
position: new Cesium.CallbackProperty(() => {
|
||||
let position;
|
||||
try {
|
||||
position = state.options.position();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return position
|
||||
? Cesium.Cartesian3.clone(toCesium3(position), scratch)
|
||||
: undefined;
|
||||
}, false),
|
||||
viewFrom: toCesium3(state.options.viewFrom()),
|
||||
// Cesium's EntityView moves the camera only once the tracked
|
||||
// entity has a bounding sphere: a 1 px transparent point gives it
|
||||
// one (an entity without graphics is framed once, then left).
|
||||
point: { pixelSize: 1, color: Cesium.Color.TRANSPARENT },
|
||||
});
|
||||
}
|
||||
state.entity = entity;
|
||||
state.handle.native = options.native;
|
||||
viewer.camera.cancelFlight();
|
||||
viewer.trackedEntity = entity;
|
||||
const viewFrom =
|
||||
options.native?.viewFrom ?? toCesium3(state.options.viewFrom());
|
||||
state.stopFrame =
|
||||
applyTrackedCameraFrame(viewer, entity, viewFrom, {
|
||||
minRange: options.minRange,
|
||||
}) ?? null;
|
||||
// A command that takes the camera ends the follow; so does another
|
||||
// tracked entity (Cesium raises commanded for that too).
|
||||
state.removeCommanded = commanded.addEventListener(() => {
|
||||
if (switching || own !== state) return;
|
||||
if (viewer.trackedEntity === state.entity)
|
||||
viewer.trackedEntity = undefined;
|
||||
release(state);
|
||||
});
|
||||
viewer.scene.requestRender?.();
|
||||
}
|
||||
|
||||
function follow(input) {
|
||||
const options = normalizeFollowOptions(input);
|
||||
watchTracking();
|
||||
if (own) {
|
||||
const previous = own;
|
||||
own = null;
|
||||
teardown(previous);
|
||||
}
|
||||
const state = { entity: null, proxy: null, options, handle: null };
|
||||
state.handle = {
|
||||
native: options.native,
|
||||
stop() {
|
||||
if (own !== state) return;
|
||||
if (viewer.trackedEntity === state.entity)
|
||||
viewer.trackedEntity = undefined;
|
||||
release(state);
|
||||
},
|
||||
retarget(next) {
|
||||
if (own !== state) return;
|
||||
const updated = normalizeFollowOptions({ ...state.options, ...next });
|
||||
switching = true;
|
||||
try {
|
||||
teardown(state);
|
||||
start(state, updated);
|
||||
} finally {
|
||||
switching = false;
|
||||
}
|
||||
followChanged.raiseEvent(state.handle);
|
||||
},
|
||||
};
|
||||
own = state;
|
||||
// Assigning trackedEntity raises followChanged with this handle.
|
||||
start(state, options);
|
||||
return state.handle;
|
||||
}
|
||||
|
||||
return {
|
||||
camera: { follow, following, followChanged },
|
||||
destroy() {
|
||||
if (own) {
|
||||
const state = own;
|
||||
if (viewer.trackedEntity === state.entity)
|
||||
viewer.trackedEntity = undefined;
|
||||
release(state);
|
||||
}
|
||||
removeTracked?.();
|
||||
removeTracked = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { createCesiumCamera } from './camera.js';
|
||||
import { createFakeViewer } from './testing/fakeViewer.mjs';
|
||||
|
||||
const TARGET = geo.cartesian3.fromDegrees(-73.78, 40.64, 3000);
|
||||
|
||||
function setup() {
|
||||
const viewer = createFakeViewer();
|
||||
const { camera, destroy } = createCesiumCamera(viewer);
|
||||
const changes = [];
|
||||
camera.followChanged.addEventListener((handle) => changes.push(handle));
|
||||
const frame = () => viewer.scene.preUpdate.raiseEvent();
|
||||
return { viewer, camera, destroy, changes, frame };
|
||||
}
|
||||
|
||||
test('follow tracks a native entity: one exact frame, zoom limits, stop restores', () => {
|
||||
const { viewer, camera, changes, frame } = setup();
|
||||
const controller = viewer.scene.screenSpaceCameraController;
|
||||
controller.inertiaZoom = 0.8;
|
||||
controller.minimumZoomDistance = 1;
|
||||
const native = new Cesium.Entity({
|
||||
position: Cesium.Cartesian3.clone(TARGET),
|
||||
viewFrom: new Cesium.Cartesian3(0, -900, 400),
|
||||
});
|
||||
const handle = camera.follow({
|
||||
native,
|
||||
position: () => TARGET,
|
||||
viewFrom: { x: 0, y: -900, z: 400 },
|
||||
});
|
||||
assert.equal(viewer.trackedEntity, native);
|
||||
assert.equal(camera.following(), handle);
|
||||
assert.equal(handle.native, native);
|
||||
assert.deepEqual(changes, [handle]);
|
||||
assert.equal(controller.inertiaZoom, 0);
|
||||
assert.equal(controller.minimumZoomDistance, 150);
|
||||
frame();
|
||||
frame();
|
||||
const frames = viewer.callsOf('camera.lookAtTransform');
|
||||
assert.equal(frames.length, 1, 'the tracked frame is applied once');
|
||||
const [transform, offset] = frames[0].args;
|
||||
assert.deepEqual([offset.x, offset.y, offset.z], [0, -900, 400]);
|
||||
const enu = geo.transforms.eastNorthUpToFixedFrame(
|
||||
TARGET,
|
||||
geo.ellipsoid.WGS84,
|
||||
);
|
||||
for (let i = 0; i < 16; i++)
|
||||
assert.ok(Math.abs(transform[i] - enu[i]) < 1e-6);
|
||||
handle.stop();
|
||||
assert.equal(viewer.trackedEntity, undefined);
|
||||
assert.equal(camera.following(), null);
|
||||
assert.deepEqual(changes, [handle, null]);
|
||||
assert.equal(controller.inertiaZoom, 0.8);
|
||||
assert.equal(controller.minimumZoomDistance, 1);
|
||||
});
|
||||
|
||||
test('without native the follow tracks a proxy entity at position()', () => {
|
||||
const { viewer, camera, frame } = setup();
|
||||
let target = TARGET;
|
||||
const handle = camera.follow({
|
||||
position: () => target,
|
||||
viewFrom: { x: 0, y: -500, z: 200 },
|
||||
});
|
||||
const proxy = viewer.trackedEntity;
|
||||
assert.ok(viewer.entities.contains(proxy));
|
||||
assert.equal(handle.native, undefined);
|
||||
const time = viewer.clock.currentTime;
|
||||
assert.ok(Cesium.Cartesian3.equals(proxy.position.getValue(time), TARGET));
|
||||
target = geo.cartesian3.fromDegrees(-73.7, 40.6, 3000);
|
||||
assert.ok(Cesium.Cartesian3.equals(proxy.position.getValue(time), target));
|
||||
frame();
|
||||
assert.equal(viewer.callsOf('camera.lookAtTransform').length, 1);
|
||||
handle.stop();
|
||||
assert.equal(viewer.entities.contains(proxy), false);
|
||||
assert.equal(viewer.trackedEntity, undefined);
|
||||
});
|
||||
|
||||
test('a camera command or another tracked entity ends the follow', () => {
|
||||
const { viewer, camera, changes } = setup();
|
||||
const options = { position: () => TARGET, viewFrom: { x: 0, y: -500, z: 0 } };
|
||||
const first = camera.follow(options);
|
||||
camera.setView({ destination: TARGET });
|
||||
assert.equal(viewer.trackedEntity, undefined);
|
||||
assert.equal(camera.following(), null);
|
||||
assert.equal(viewer.entities.values.length, 0);
|
||||
|
||||
camera.follow(options);
|
||||
const appEntity = new Cesium.Entity({ position: TARGET });
|
||||
viewer.trackedEntity = appEntity;
|
||||
assert.equal(viewer.entities.values.length, 0, 'the proxy is gone');
|
||||
const wrapped = camera.following();
|
||||
assert.equal(wrapped.native, appEntity);
|
||||
assert.equal(camera.following(), wrapped, 'one handle per entity');
|
||||
assert.equal(changes.at(-1), wrapped);
|
||||
wrapped.stop();
|
||||
assert.equal(viewer.trackedEntity, undefined);
|
||||
assert.equal(changes.at(-1), null);
|
||||
assert.ok(changes.includes(first));
|
||||
});
|
||||
|
||||
test('a programmatic zoom inside the minimum range is pushed back to it', () => {
|
||||
const { viewer, camera, frame } = setup();
|
||||
camera.follow({ position: () => TARGET, viewFrom: { x: 0, y: -500, z: 0 } });
|
||||
frame();
|
||||
// zoomIn bypasses the controller's minimumZoomDistance.
|
||||
Cesium.Cartesian3.fromElements(0, -20, 0, viewer.camera.position);
|
||||
frame();
|
||||
const range = Cesium.Cartesian3.magnitude(viewer.camera.position);
|
||||
assert.ok(Math.abs(range - 150) < 1e-6, `range ${range}`);
|
||||
});
|
||||
|
||||
test('retarget switches the tracked entity without ending the follow', () => {
|
||||
const { viewer, camera, changes } = setup();
|
||||
const handle = camera.follow({
|
||||
position: () => TARGET,
|
||||
viewFrom: { x: 0, y: -500, z: 0 },
|
||||
});
|
||||
const proxy = viewer.trackedEntity;
|
||||
const native = new Cesium.Entity({ position: TARGET });
|
||||
handle.retarget({ native });
|
||||
assert.equal(viewer.trackedEntity, native);
|
||||
assert.equal(viewer.entities.contains(proxy), false);
|
||||
assert.equal(camera.following(), handle);
|
||||
assert.deepEqual(changes, [handle, handle]);
|
||||
});
|
||||
|
||||
test('destroy ends an engine follow and leaves an app-tracked entity alone', () => {
|
||||
const first = setup();
|
||||
first.camera.follow({
|
||||
position: () => TARGET,
|
||||
viewFrom: { x: 0, y: -500, z: 0 },
|
||||
});
|
||||
first.destroy();
|
||||
assert.equal(first.viewer.trackedEntity, undefined);
|
||||
assert.equal(first.viewer.entities.values.length, 0);
|
||||
|
||||
const second = setup();
|
||||
const appEntity = new Cesium.Entity({ position: TARGET });
|
||||
second.viewer.trackedEntity = appEntity;
|
||||
second.destroy();
|
||||
assert.equal(second.viewer.trackedEntity, appEntity);
|
||||
});
|
||||
@@ -14,6 +14,8 @@ export function createFakeViewer({ width = 1024, height = 768 } = {}) {
|
||||
return typeof value === 'function' ? value(...args) : value;
|
||||
};
|
||||
const primitiveList = [];
|
||||
const entityList = [];
|
||||
let trackedEntity;
|
||||
const imageryList = [];
|
||||
const staticCredits = [];
|
||||
const creditDisplay = {
|
||||
@@ -28,6 +30,9 @@ export function createFakeViewer({ width = 1024, height = 768 } = {}) {
|
||||
const fovy = 2 * Math.atan(Math.tan(Math.PI / 6) * (height / width));
|
||||
const camera = {
|
||||
positionWC: new Cesium.Cartesian3(7e6, 1e5, 2e5),
|
||||
// Position and direction in the camera's transform (identity: world).
|
||||
position: new Cesium.Cartesian3(7e6, 1e5, 2e5),
|
||||
direction: new Cesium.Cartesian3(-1, 0, 0),
|
||||
directionWC: new Cesium.Cartesian3(-1, 0, 0),
|
||||
upWC: new Cesium.Cartesian3(0, 0, 1),
|
||||
rightWC: new Cesium.Cartesian3(0, 1, 0),
|
||||
@@ -81,9 +86,18 @@ export function createFakeViewer({ width = 1024, height = 768 } = {}) {
|
||||
flight.options.cancel?.();
|
||||
}),
|
||||
lookAt: record('camera.lookAt'),
|
||||
lookAtTransform: record('camera.lookAtTransform', (transform) =>
|
||||
Cesium.Matrix4.clone(transform, camera.transform),
|
||||
),
|
||||
// With an offset (a Cartesian3), the camera sits there in the new frame
|
||||
// looking at its origin.
|
||||
lookAtTransform: record('camera.lookAtTransform', (transform, offset) => {
|
||||
Cesium.Matrix4.clone(transform, camera.transform);
|
||||
if (offset instanceof Cesium.Cartesian3) {
|
||||
Cesium.Cartesian3.clone(offset, camera.position);
|
||||
Cesium.Cartesian3.normalize(
|
||||
Cesium.Cartesian3.negate(offset, camera.direction),
|
||||
camera.direction,
|
||||
);
|
||||
}
|
||||
}),
|
||||
zoomIn: record('camera.zoomIn'),
|
||||
zoomOut: record('camera.zoomOut'),
|
||||
computeViewRectangle: record(
|
||||
@@ -165,9 +179,37 @@ export function createFakeViewer({ width = 1024, height = 768 } = {}) {
|
||||
staticCredits,
|
||||
primitiveList,
|
||||
trackedEntityChanged: new Cesium.Event(),
|
||||
// The viewer clock: only its tick event (CesiumWidget ticks it right
|
||||
// before scene.render on every animation frame).
|
||||
clock: { onTick: new Cesium.Event() },
|
||||
// Assigning raises trackedEntityChanged, as Cesium's Viewer does.
|
||||
get trackedEntity() {
|
||||
return trackedEntity;
|
||||
},
|
||||
set trackedEntity(value) {
|
||||
if (value === trackedEntity) return;
|
||||
trackedEntity = value;
|
||||
calls.push({ name: 'trackedEntity', args: [value] });
|
||||
viewer.trackedEntityChanged.raiseEvent(value);
|
||||
},
|
||||
entities: {
|
||||
values: entityList,
|
||||
add: record('entities.add', (options) => {
|
||||
const entity = new Cesium.Entity(options);
|
||||
entityList.push(entity);
|
||||
return entity;
|
||||
}),
|
||||
remove: record('entities.remove', (entity) => {
|
||||
const index = entityList.indexOf(entity);
|
||||
if (index >= 0) entityList.splice(index, 1);
|
||||
return index >= 0;
|
||||
}),
|
||||
contains: (entity) => entityList.includes(entity),
|
||||
},
|
||||
isDestroyed: () => false,
|
||||
// The viewer clock: its tick event (CesiumWidget ticks it right before
|
||||
// scene.render on every animation frame) and current time.
|
||||
clock: {
|
||||
onTick: new Cesium.Event(),
|
||||
currentTime: Cesium.JulianDate.fromIso8601('2026-10-16T12:00:00Z'),
|
||||
},
|
||||
imageryList,
|
||||
terrainProvider: undefined,
|
||||
imageryLayers: {
|
||||
|
||||
@@ -124,8 +124,31 @@
|
||||
* lookAt and cancelFlight. Cesium also raises it for those calls made on the
|
||||
* viewer's own camera and when the viewer's tracked entity changes, so a
|
||||
* pending correction yields to every new camera owner.
|
||||
* @property {(options: FollowOptions) => FollowHandle} follow Keeps a moving
|
||||
* target in the centre (Cesium: trackedEntity; Navara and the fake engine:
|
||||
* src/globe/follow.js each frame). The user may turn and zoom around it;
|
||||
* a command that takes the camera (setView, flyTo, flyToSphere, lookAt,
|
||||
* cancelFlight) ends it. A new follow replaces the current one.
|
||||
* @property {() => (FollowHandle | null)} following The current follow
|
||||
* (Cesium: also one the app started on viewer.trackedEntity itself).
|
||||
* @property {EngineEvent} followChanged Listener receives the new handle or
|
||||
* null (Cesium: viewer.trackedEntityChanged).
|
||||
* @property {number} percentageChanged
|
||||
*
|
||||
* @typedef {object} FollowOptions
|
||||
* @property {() => Cartesian3} position Where the target is (read each frame).
|
||||
* @property {Cartesian3 | (() => Cartesian3)} viewFrom The camera's offset
|
||||
* from the target in its east-north-up frame, metres.
|
||||
* @property {number} [minRange] Closest the camera comes (150 m).
|
||||
* @property {'enu'} [frame]
|
||||
* @property {any} [native] Cesium only (layers' render/cesium.js): the
|
||||
* Entity to put on viewer.trackedEntity instead of a proxy.
|
||||
*
|
||||
* @typedef {object} FollowHandle
|
||||
* @property {() => void} stop
|
||||
* @property {(next: Partial<FollowOptions>) => void} retarget
|
||||
* @property {any} [native]
|
||||
*
|
||||
* @typedef {'click'|'doubleClick'|'move'|'down'|'up'} InputType
|
||||
* @typedef {'ctrl'|'shift'|'alt'} InputModifier
|
||||
* @typedef {object} InputEvent
|
||||
@@ -531,6 +554,9 @@ const SHAPE = {
|
||||
moveStart: isEngineEvent,
|
||||
moveEnd: isEngineEvent,
|
||||
commanded: isEngineEvent,
|
||||
follow: isFunction,
|
||||
following: isFunction,
|
||||
followChanged: isEngineEvent,
|
||||
percentageChanged: isNumber,
|
||||
},
|
||||
picker: {
|
||||
|
||||
@@ -364,3 +364,15 @@ test('the contract carries dynamic lines', () => {
|
||||
/lines\.createLine.*lines\.list/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the contract carries the follow camera', () => {
|
||||
for (const member of [
|
||||
'camera.follow',
|
||||
'camera.following',
|
||||
'camera.followChanged',
|
||||
])
|
||||
assert.ok(GLOBE_ENGINE_MEMBERS.includes(member), member);
|
||||
const engine = createFakeEngine();
|
||||
const broken = { ...engine, camera: { ...engine.camera, followChanged: {} } };
|
||||
assert.throws(() => assertGlobeEngine(broken), /camera\.followChanged/);
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
// camera.follow, the engine-neutral part: a camera that keeps a moving
|
||||
// target in the centre at an offset in the target's east-north-up frame
|
||||
// (Cesium's EntityView, trackedEntity's viewFrom), which the user may turn
|
||||
// and zoom: each frame the offset is re-read from where the camera is
|
||||
// (the user's gesture moved it) relative to where the target was, carried
|
||||
// to where the target is now, kept outside the minimum range, and the
|
||||
// camera aimed at the target. Engines without a native follow (Navara, the
|
||||
// fake engine) run it on their frames; Cesium follows with trackedEntity.
|
||||
// A command that takes the camera (setView, flyTo…) ends the follow.
|
||||
|
||||
import * as geo from '../geo/index.js';
|
||||
import { createEngineEvent } from './contract.js';
|
||||
|
||||
const { cartesian3, matrix4 } = geo;
|
||||
const WGS84 = geo.ellipsoid.WGS84;
|
||||
|
||||
export const MIN_FOLLOW_RANGE_M = 150;
|
||||
|
||||
const isCartesian = (value) =>
|
||||
value != null &&
|
||||
Number.isFinite(value.x) &&
|
||||
Number.isFinite(value.y) &&
|
||||
Number.isFinite(value.z);
|
||||
|
||||
/** follow() options checked: position and viewFrom as functions. */
|
||||
export function normalizeFollowOptions(options) {
|
||||
if (!options || typeof options.position !== 'function')
|
||||
throw new TypeError('camera.follow needs position: () => Cartesian3');
|
||||
const { viewFrom } = options;
|
||||
const viewFromOf =
|
||||
typeof viewFrom === 'function'
|
||||
? viewFrom
|
||||
: isCartesian(viewFrom)
|
||||
? () => viewFrom
|
||||
: null;
|
||||
if (!viewFromOf)
|
||||
throw new TypeError('camera.follow needs viewFrom (ENU metres)');
|
||||
const minRange = options.minRange ?? MIN_FOLLOW_RANGE_M;
|
||||
if (!(Number.isFinite(minRange) && minRange >= 0))
|
||||
throw new TypeError(`camera.follow minRange must be >= 0: ${minRange}`);
|
||||
if (options.frame !== undefined && options.frame !== 'enu')
|
||||
throw new TypeError("camera.follow frame must be 'enu'");
|
||||
return {
|
||||
position: options.position,
|
||||
viewFrom: viewFromOf,
|
||||
minRange,
|
||||
native: options.native,
|
||||
};
|
||||
}
|
||||
|
||||
const scratchFrame = matrix4.create();
|
||||
const scratchInverse = matrix4.create();
|
||||
const scratchOffset = cartesian3.create();
|
||||
const scratchWorld = cartesian3.create();
|
||||
const scratchUp = cartesian3.create();
|
||||
|
||||
/**
|
||||
* One follow step: the camera pose that keeps `target` centred at `offset`
|
||||
* (ENU of the target, metres, kept outside `minRange`).
|
||||
* @returns {{ position, direction, up }}
|
||||
*/
|
||||
export function followPose(target, offset, minRange) {
|
||||
const range = cartesian3.magnitude(offset);
|
||||
const local =
|
||||
range >= minRange || range === 0
|
||||
? cartesian3.clone(offset, scratchOffset)
|
||||
: cartesian3.multiplyByScalar(offset, minRange / range, scratchOffset);
|
||||
const frame = geo.transforms.eastNorthUpToFixedFrame(
|
||||
target,
|
||||
WGS84,
|
||||
scratchFrame,
|
||||
);
|
||||
const position = matrix4.multiplyByPoint(frame, local, cartesian3.create());
|
||||
const direction = cartesian3.normalize(
|
||||
cartesian3.subtract(target, position, cartesian3.create()),
|
||||
cartesian3.create(),
|
||||
);
|
||||
// The target's local up, made perpendicular to the view direction.
|
||||
cartesian3.fromElements(frame[8], frame[9], frame[10], scratchUp);
|
||||
let right = cartesian3.cross(direction, scratchUp, cartesian3.create());
|
||||
if (cartesian3.magnitude(right) < 1e-9)
|
||||
right = cartesian3.fromElements(frame[0], frame[1], frame[2]);
|
||||
cartesian3.normalize(right, right);
|
||||
const up = cartesian3.normalize(
|
||||
cartesian3.cross(right, direction, cartesian3.create()),
|
||||
cartesian3.create(),
|
||||
);
|
||||
return { position, direction, up };
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a camera (in a target-centred frame: the target at the origin) on the
|
||||
* same side of its target and outside the minimum range. Cesium's tracked
|
||||
* camera (cesium/follow.js) runs it after a programmatic zoom.
|
||||
*
|
||||
* @param {{position: Cartesian3, direction: Cartesian3}} camera Camera-like
|
||||
* object, corrected in place.
|
||||
* @param {Cartesian3} previousPosition The previous frame's camera position.
|
||||
* @param {number} [minimumRangeM=MIN_FOLLOW_RANGE_M] Minimum target range.
|
||||
* @returns {boolean} Whether the camera position was corrected.
|
||||
*/
|
||||
export function clampTrackedCameraPosition(
|
||||
camera,
|
||||
previousPosition,
|
||||
minimumRangeM = MIN_FOLLOW_RANGE_M,
|
||||
) {
|
||||
const crossedOrigin = cartesian3.dot(camera.position, previousPosition) <= 0;
|
||||
const forwardDistance = -cartesian3.dot(camera.position, camera.direction);
|
||||
const rangeSquared = cartesian3.magnitudeSquared(camera.position);
|
||||
if (crossedOrigin) {
|
||||
cartesian3.normalize(previousPosition, camera.position);
|
||||
cartesian3.multiplyByScalar(
|
||||
camera.position,
|
||||
minimumRangeM,
|
||||
camera.position,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
forwardDistance < minimumRangeM ||
|
||||
rangeSquared < minimumRangeM * minimumRangeM
|
||||
) {
|
||||
cartesian3.multiplyByScalar(
|
||||
camera.direction,
|
||||
-minimumRangeM,
|
||||
camera.position,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The camera's offset from `target` in the target's ENU frame. */
|
||||
export function enuOffset(
|
||||
target,
|
||||
cameraPosition,
|
||||
result = cartesian3.create(),
|
||||
) {
|
||||
const frame = geo.transforms.eastNorthUpToFixedFrame(
|
||||
target,
|
||||
WGS84,
|
||||
scratchFrame,
|
||||
);
|
||||
const inverse = matrix4.inverseTransformation(frame, scratchInverse);
|
||||
return matrix4.multiplyByPoint(
|
||||
inverse,
|
||||
cartesian3.clone(cameraPosition, scratchWorld),
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* camera: { pose: () => object, commanded: object },
|
||||
* aim: (pose: { position, direction, up }) => void,
|
||||
* frame: { addEventListener: Function },
|
||||
* requestRender: () => void,
|
||||
* }} ports `aim` places the camera without camera.commanded; `frame` is
|
||||
* the event the follow steps on (the engine's preRender, after it read
|
||||
* the user's camera).
|
||||
*/
|
||||
export function createFollowController({ camera, aim, frame, requestRender }) {
|
||||
const followChanged = createEngineEvent();
|
||||
let current = null;
|
||||
let removeFrame = null;
|
||||
let removeCommanded = null;
|
||||
let aiming = false;
|
||||
|
||||
const end = (handle) => {
|
||||
if (current !== handle) return;
|
||||
current = null;
|
||||
removeFrame?.();
|
||||
removeCommanded?.();
|
||||
removeFrame = null;
|
||||
removeCommanded = null;
|
||||
followChanged.raiseEvent(null);
|
||||
};
|
||||
|
||||
const step = () => {
|
||||
const handle = current;
|
||||
if (!handle) return;
|
||||
let target;
|
||||
try {
|
||||
target = handle.options.position();
|
||||
} catch {
|
||||
target = null;
|
||||
}
|
||||
if (!isCartesian(target)) return;
|
||||
if (!handle.offset) {
|
||||
const from = handle.options.viewFrom();
|
||||
if (!isCartesian(from)) return;
|
||||
handle.offset = cartesian3.clone(from);
|
||||
} else if (handle.previous) {
|
||||
// The user may have turned or zoomed the camera since the last step.
|
||||
enuOffset(handle.previous, camera.pose().position, handle.offset);
|
||||
}
|
||||
const pose = followPose(target, handle.offset, handle.options.minRange);
|
||||
handle.previous = cartesian3.clone(target, handle.previous ?? undefined);
|
||||
// The offset in force (the minimum range applied).
|
||||
enuOffset(target, pose.position, handle.offset);
|
||||
aiming = true;
|
||||
try {
|
||||
aim(pose);
|
||||
} finally {
|
||||
aiming = false;
|
||||
}
|
||||
// A moving target needs the next frame.
|
||||
requestRender();
|
||||
};
|
||||
|
||||
return {
|
||||
followChanged,
|
||||
following: () => current,
|
||||
follow(input) {
|
||||
const options = normalizeFollowOptions(input);
|
||||
if (current) end(current);
|
||||
const handle = {
|
||||
options,
|
||||
native: options.native,
|
||||
offset: null,
|
||||
previous: null,
|
||||
stop: () => end(handle),
|
||||
retarget(next) {
|
||||
const updated = normalizeFollowOptions({ ...options, ...next });
|
||||
handle.options = updated;
|
||||
handle.native = updated.native;
|
||||
handle.offset = null;
|
||||
handle.previous = null;
|
||||
requestRender();
|
||||
},
|
||||
};
|
||||
current = handle;
|
||||
removeFrame = frame.addEventListener(step);
|
||||
removeCommanded = camera.commanded.addEventListener(() => {
|
||||
if (!aiming) end(handle);
|
||||
});
|
||||
followChanged.raiseEvent(handle);
|
||||
requestRender();
|
||||
return handle;
|
||||
},
|
||||
destroy() {
|
||||
if (current) end(current);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as geo from '../geo/index.js';
|
||||
import { createEngineEvent } from './contract.js';
|
||||
import {
|
||||
MIN_FOLLOW_RANGE_M,
|
||||
createFollowController,
|
||||
enuOffset,
|
||||
followPose,
|
||||
} from './follow.js';
|
||||
|
||||
const { cartesian3 } = geo;
|
||||
|
||||
function harness() {
|
||||
let pose = {
|
||||
position: cartesian3.fromDegrees(0, 0, 1e7),
|
||||
direction: cartesian3.create(-1, 0, 0),
|
||||
up: cartesian3.create(0, 0, 1),
|
||||
};
|
||||
const commanded = createEngineEvent();
|
||||
const frame = createEngineEvent();
|
||||
let renders = 0;
|
||||
const controller = createFollowController({
|
||||
camera: { pose: () => pose, commanded },
|
||||
aim: (next) => {
|
||||
pose = next;
|
||||
},
|
||||
frame,
|
||||
requestRender: () => renders++,
|
||||
});
|
||||
return {
|
||||
controller,
|
||||
frame: () => frame.raiseEvent(),
|
||||
pose: () => pose,
|
||||
move: (next) => {
|
||||
pose = next;
|
||||
},
|
||||
command: () => commanded.raiseEvent(),
|
||||
renders: () => renders,
|
||||
};
|
||||
}
|
||||
|
||||
const target0 = cartesian3.fromDegrees(-74, 40.7, 1000);
|
||||
|
||||
test('the follow pose looks at the target from its ENU offset', () => {
|
||||
const pose = followPose(target0, cartesian3.create(0, -400, 300), 150);
|
||||
assert.ok(Math.abs(cartesian3.distance(pose.position, target0) - 500) < 1e-6);
|
||||
const back = enuOffset(target0, pose.position);
|
||||
assert.ok(Math.abs(back.y + 400) < 1e-6 && Math.abs(back.z - 300) < 1e-6);
|
||||
const toTarget = cartesian3.normalize(
|
||||
cartesian3.subtract(target0, pose.position, cartesian3.create()),
|
||||
cartesian3.create(),
|
||||
);
|
||||
assert.ok(cartesian3.dot(toTarget, pose.direction) > 1 - 1e-12);
|
||||
assert.ok(Math.abs(cartesian3.dot(pose.up, pose.direction)) < 1e-12);
|
||||
// Inside the minimum range: pushed out along the same direction.
|
||||
const near = followPose(target0, cartesian3.create(0, -30, 40), 150);
|
||||
assert.ok(Math.abs(cartesian3.distance(near.position, target0) - 150) < 1e-6);
|
||||
});
|
||||
|
||||
test('a follow carries the camera with the target, keeps user changes, ends on a command', () => {
|
||||
const h = harness();
|
||||
let target = target0;
|
||||
const changes = [];
|
||||
h.controller.followChanged.addEventListener((handle) => changes.push(handle));
|
||||
const handle = h.controller.follow({
|
||||
position: () => target,
|
||||
viewFrom: cartesian3.create(0, -400, 300),
|
||||
});
|
||||
assert.equal(h.controller.following(), handle);
|
||||
h.frame();
|
||||
assert.ok(
|
||||
Math.abs(cartesian3.distance(h.pose().position, target) - 500) < 1e-6,
|
||||
);
|
||||
// The target moves 250 m east: the camera moves with it.
|
||||
target = cartesian3.fromDegrees(-74 + 250 / 84_400, 40.7, 1000);
|
||||
h.frame();
|
||||
assert.ok(
|
||||
Math.abs(cartesian3.distance(h.pose().position, target) - 500) < 1e-3,
|
||||
);
|
||||
// The user zooms in to 100 m: kept at the minimum range (150 m).
|
||||
const pose = h.pose();
|
||||
h.move({
|
||||
...pose,
|
||||
position: cartesian3.add(
|
||||
pose.position,
|
||||
cartesian3.multiplyByScalar(pose.direction, 400, cartesian3.create()),
|
||||
cartesian3.create(),
|
||||
),
|
||||
});
|
||||
h.frame();
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
cartesian3.distance(h.pose().position, target) - MIN_FOLLOW_RANGE_M,
|
||||
) < 1e-3,
|
||||
);
|
||||
assert.ok(h.renders() >= 3, 'a follow keeps frames coming');
|
||||
h.command();
|
||||
assert.equal(h.controller.following(), null);
|
||||
assert.deepEqual(changes, [handle, null]);
|
||||
const before = h.pose();
|
||||
h.frame();
|
||||
assert.equal(h.pose(), before, 'no more steps after the end');
|
||||
});
|
||||
|
||||
test('stop ends the follow; a new follow replaces the old one', () => {
|
||||
const h = harness();
|
||||
const first = h.controller.follow({
|
||||
position: () => target0,
|
||||
viewFrom: () => cartesian3.create(0, -1000, 500),
|
||||
});
|
||||
const second = h.controller.follow({
|
||||
position: () => target0,
|
||||
viewFrom: cartesian3.create(500, 0, 500),
|
||||
});
|
||||
assert.equal(h.controller.following(), second);
|
||||
first.stop();
|
||||
assert.equal(
|
||||
h.controller.following(),
|
||||
second,
|
||||
'a stale handle stops nothing',
|
||||
);
|
||||
second.stop();
|
||||
assert.equal(h.controller.following(), null);
|
||||
assert.throws(
|
||||
() => h.controller.follow({ position: 1, viewFrom: {} }),
|
||||
TypeError,
|
||||
);
|
||||
});
|
||||
@@ -408,6 +408,13 @@ export function createCameraModel({
|
||||
lift(position) {
|
||||
place({ ...state, position: copy3(position) });
|
||||
},
|
||||
/**
|
||||
* Places the camera at `pose` ({ position, direction, up }) without
|
||||
* camera.commanded: the follow camera's own step, not a new owner.
|
||||
*/
|
||||
aim(pose) {
|
||||
place(pose);
|
||||
},
|
||||
/** The current camera for projections: pose, vertical fov and CSS size. */
|
||||
view,
|
||||
worldToWindow: (position, result) =>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { createNavaraClock } from './clock.js';
|
||||
import { createNavaraCredits } from './credits.js';
|
||||
import { createNavaraInput } from './input.js';
|
||||
import { createNavaraPicking } from './picking.js';
|
||||
import { createFollowController } from '../follow.js';
|
||||
import { createNavaraMaps } from './maps.js';
|
||||
import { createNavaraSurface } from './surface.js';
|
||||
import { createGroundFloor, GROUND_FLOOR } from './groundFloor.js';
|
||||
@@ -143,6 +144,21 @@ export function createNavaraEngine(
|
||||
postRender: createEngineEvent(),
|
||||
};
|
||||
const engineClock = createNavaraClock(appClock ? { appClock } : {});
|
||||
// camera.follow (src/globe/follow.js): Navara's own cameraFollow takes
|
||||
// no user rotation or zoom (phase 3 Task 5 probe), so the engine steps
|
||||
// its own follow each frame.
|
||||
const followStep = createEngineEvent();
|
||||
const follow = createFollowController({
|
||||
camera: model.camera,
|
||||
aim: (pose) => model.aim(pose),
|
||||
frame: followStep,
|
||||
requestRender: () => view.forceUpdate(),
|
||||
});
|
||||
Object.assign(model.camera, {
|
||||
follow: (options) => follow.follow(options),
|
||||
following: () => follow.following(),
|
||||
followChanged: follow.followChanged,
|
||||
});
|
||||
// Defined below, with the surface and input it needs.
|
||||
let groundFloor = null;
|
||||
const handlers = {
|
||||
@@ -152,6 +168,8 @@ export function createNavaraEngine(
|
||||
},
|
||||
preRender: (time) => {
|
||||
model.sync();
|
||||
// After the user's camera is read, before anything draws.
|
||||
followStep.raiseEvent();
|
||||
groundFloor?.update();
|
||||
frames.preRender.raiseEvent(time);
|
||||
},
|
||||
@@ -278,6 +296,7 @@ export function createNavaraEngine(
|
||||
destroyed = true;
|
||||
for (const [name, handler] of Object.entries(handlers))
|
||||
view.off(name, handler);
|
||||
follow.destroy();
|
||||
model.destroy();
|
||||
input.destroy();
|
||||
picking.destroy();
|
||||
|
||||
@@ -2357,6 +2357,115 @@ export function createContractSuite(
|
||||
ensure(raised === count, 'a removed listener was called');
|
||||
},
|
||||
],
|
||||
[
|
||||
'camera.follow: holds a moving target centred, keeps the minimum range, ends on stop or a command',
|
||||
async () => {
|
||||
// An aircraft at 3 km over JFK flying east at 250 m/s, one step per
|
||||
// drawn frame (the step after postRender: every stage of the next
|
||||
// frame sees the same position, and the check reads the drawn one).
|
||||
const start = cartographic.fromDegrees(-73.78, 40.64, 3000);
|
||||
const stepRad = 250 / 60 / (6378137 * Math.cos(start.latitude));
|
||||
let step = 0;
|
||||
let drawn = 0;
|
||||
const at = (index) =>
|
||||
cartesian3.fromRadians(
|
||||
start.longitude + index * stepRad,
|
||||
start.latitude,
|
||||
start.height,
|
||||
);
|
||||
const removeStep = engine.frames.postRender.addEventListener(() => {
|
||||
drawn = step;
|
||||
step++;
|
||||
});
|
||||
const changes = [];
|
||||
const removeChanged = camera.followChanged.addEventListener((handle) =>
|
||||
changes.push(handle),
|
||||
);
|
||||
const offCentre = () => {
|
||||
const screen = engine.projector.worldToWindow(at(drawn));
|
||||
if (!screen) return Infinity;
|
||||
const middle = centre();
|
||||
return Math.hypot(screen.x - middle.x, screen.y - middle.y);
|
||||
};
|
||||
const range = () =>
|
||||
cartesian3.distance(camera.pose().position, at(drawn));
|
||||
try {
|
||||
camera.setView({
|
||||
destination: cartesian3.fromDegrees(-73.8, 40.6, 8000),
|
||||
orientation: { heading: 0, pitch: -0.6, roll: 0 },
|
||||
});
|
||||
await settle();
|
||||
const handle = camera.follow({
|
||||
position: () => at(step),
|
||||
viewFrom: { x: 0, y: -1000, z: 400 },
|
||||
});
|
||||
ensure(
|
||||
camera.following() === handle,
|
||||
'following() is not the handle',
|
||||
);
|
||||
ensure(
|
||||
changes.at(-1) === handle,
|
||||
'followChanged did not report the handle',
|
||||
);
|
||||
await until(() => offCentre() <= 3, 'the follow centring its target');
|
||||
near(range(), Math.hypot(1000, 400), 5, 'follow range at viewFrom');
|
||||
// 2 s of motion (at least 120 frames), centred within 3 px.
|
||||
const began = performance.now();
|
||||
const firstStep = step;
|
||||
let worst = 0;
|
||||
for (
|
||||
let frames = 0;
|
||||
frames < 600 && (frames < 120 || performance.now() - began < 2000);
|
||||
frames++
|
||||
) {
|
||||
await settle(1);
|
||||
worst = Math.max(worst, offCentre());
|
||||
}
|
||||
ensure(
|
||||
worst <= 3,
|
||||
`the target drifted ${worst.toFixed(2)} px off centre`,
|
||||
);
|
||||
ensure(step - firstStep >= 120, 'the target did not move');
|
||||
// A zoom through the target stops at the minimum range.
|
||||
camera.zoomIn(5000);
|
||||
await settle(4);
|
||||
ensure(camera.following() === handle, 'zoomIn ended the follow');
|
||||
ensure(range() >= 149, `zoomIn reached ${range().toFixed(1)} m`);
|
||||
ensure(offCentre() <= 3, 'the target left the centre after zoomIn');
|
||||
// stop() frees the camera: the target moves on without it.
|
||||
handle.stop();
|
||||
ensure(camera.following() === null, 'following() after stop');
|
||||
ensure(changes.at(-1) === null, 'followChanged did not report null');
|
||||
camera.release();
|
||||
await settle();
|
||||
const free = camera.pose().position;
|
||||
await settle(10);
|
||||
ensure(
|
||||
cartesian3.distance(camera.pose().position, free) < 1e-3,
|
||||
'the camera still follows after stop',
|
||||
);
|
||||
// A command that takes the camera ends the follow.
|
||||
camera.follow({
|
||||
position: () => at(step),
|
||||
viewFrom: { x: 0, y: -1000, z: 400 },
|
||||
});
|
||||
await settle(4);
|
||||
camera.setView({
|
||||
destination: cartesian3.fromDegrees(20, 10, 1e6),
|
||||
orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 },
|
||||
});
|
||||
ensure(camera.following() === null, 'setView did not end the follow');
|
||||
ensure(changes.at(-1) === null, 'followChanged missed the command');
|
||||
await settle();
|
||||
near(camera.height(), 1e6, 1, 'height after the command');
|
||||
} finally {
|
||||
camera.following()?.stop();
|
||||
camera.release();
|
||||
removeChanged();
|
||||
removeStep();
|
||||
}
|
||||
},
|
||||
],
|
||||
];
|
||||
if (mapSources?.photoreal && mapSources.imagery?.length >= 2)
|
||||
checks.push(...mapChecks(engine.maps, mapSources));
|
||||
|
||||
@@ -30,6 +30,7 @@ import { createAppClock } from '../../time/clock.js';
|
||||
import { createLineRegistry } from '../lines.js';
|
||||
import { createMoverRegistry } from '../movers.js';
|
||||
import { createCpuPicker } from '../cpuPicks.js';
|
||||
import { createFollowController } from '../follow.js';
|
||||
|
||||
const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo;
|
||||
const WGS84 = ellipsoid.WGS84;
|
||||
@@ -260,6 +261,8 @@ export function createFakeEngine(options = {}) {
|
||||
'frames.preRender': createEngineEvent(),
|
||||
'frames.postRender': createEngineEvent(),
|
||||
'clock.ticked': createEngineEvent(),
|
||||
// The follow steps here, before preRender (as on Navara).
|
||||
followStep: createEngineEvent(),
|
||||
};
|
||||
let wallMs = options.nowMs ?? FAKE_EPOCH_MS;
|
||||
const appClock = options.appClock ?? createAppClock({ now: () => wallMs });
|
||||
@@ -792,6 +795,11 @@ export function createFakeEngine(options = {}) {
|
||||
moveStart: events['camera.moveStart'],
|
||||
moveEnd: events['camera.moveEnd'],
|
||||
commanded: events['camera.commanded'],
|
||||
follow: (options) => follow.follow(options),
|
||||
following: () => follow.following(),
|
||||
get followChanged() {
|
||||
return follow.followChanged;
|
||||
},
|
||||
get percentageChanged() {
|
||||
return percentageChanged;
|
||||
},
|
||||
@@ -833,6 +841,18 @@ export function createFakeEngine(options = {}) {
|
||||
modelMatrix: state.modelMatrix,
|
||||
})),
|
||||
});
|
||||
// camera.follow on the fake's frames (src/globe/follow.js), as Navara.
|
||||
const follow = createFollowController({
|
||||
camera: {
|
||||
pose: () => engine.camera.pose(),
|
||||
commanded: events['camera.commanded'],
|
||||
},
|
||||
aim: (pose) => apply(pose),
|
||||
frame: events.followStep,
|
||||
requestRender: () => {
|
||||
fake.renderRequests++;
|
||||
},
|
||||
});
|
||||
const engine = {
|
||||
kind: options.kind ?? 'cesium',
|
||||
host,
|
||||
@@ -1092,6 +1112,7 @@ export function createFakeEngine(options = {}) {
|
||||
events['clock.ticked'].raiseValue(appClock.beginFrame());
|
||||
events['frames.preUpdate'].raiseEvent(clock);
|
||||
advanceFlight(dt);
|
||||
events.followStep.raiseEvent();
|
||||
events['frames.preRender'].raiseEvent(clock);
|
||||
cameraEvents();
|
||||
events['frames.postRender'].raiseEvent(clock);
|
||||
|
||||
@@ -126,7 +126,11 @@ async function firstPlacement(appClockLagMs) {
|
||||
sprites: await import('../../data/spriteOrder.js'),
|
||||
trails: await import('../../data/trailRenderer.js'),
|
||||
aircraftPresentation: await import('../../data/tr3bRegistry.js'),
|
||||
camera: await import('../../data/trackedCamera.js'),
|
||||
camera: {
|
||||
...(await import('../../data/trackedCamera.js')),
|
||||
applyTrackedCameraFrame: (await import('../../globe/cesium/follow.js'))
|
||||
.applyTrackedCameraFrame,
|
||||
},
|
||||
labels: await import('../../data/detectionDraw.js'),
|
||||
geoid: await import('../../data/geoid.js'),
|
||||
focus: await import('../../data/focusDeemphasis.js'),
|
||||
|
||||
@@ -3,10 +3,12 @@ import { isPickedWorldPosition } from '../data/scenePick.js';
|
||||
import { isGlobeEngine } from '../globe/contract.js';
|
||||
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
|
||||
|
||||
// The camera is the globe engine's. Entity tracking stays on the Cesium viewer
|
||||
// until phase 1c, so the viewer, when there is one, is only read for its
|
||||
// tracked entity: callers pass an engine and that viewer, or a viewer whose
|
||||
// engine is registered (engineFromViewer).
|
||||
// The camera is the globe engine's, and so is the follow: the followed
|
||||
// entity is engine.camera.following().native (on Cesium also an entity the
|
||||
// app put on viewer.trackedEntity). The viewer, when there is one, is read
|
||||
// only for its clock, and for its tracked entity when the engine reports no
|
||||
// follow: callers pass an engine and that viewer, or a viewer whose engine
|
||||
// is registered (engineFromViewer).
|
||||
const engineOf = (target) => engineFromViewer(target);
|
||||
const trackerOf = (target, tracker) =>
|
||||
tracker ?? (target && !isGlobeEngine(target) ? target : null);
|
||||
@@ -17,13 +19,17 @@ function cubicInOut(amount) {
|
||||
return 0.5 * ((amount -= 2) * amount * amount + 2);
|
||||
}
|
||||
|
||||
function trackedTarget(viewer) {
|
||||
const entity = viewer.trackedEntity;
|
||||
function followedEntity(engine, viewer) {
|
||||
const handle = engine?.camera.following?.();
|
||||
return handle ? (handle.native ?? null) : (viewer?.trackedEntity ?? null);
|
||||
}
|
||||
|
||||
function trackedTarget(entity, viewer) {
|
||||
// Read the visual's sample first; sampling a flight CallbackProperty twice
|
||||
// can advance dead reckoning ahead of the displayed aircraft.
|
||||
return (
|
||||
entity?.gevDisplayPosition?.() ||
|
||||
entity?.position?.getValue(viewer.clock?.currentTime)
|
||||
entity?.position?.getValue(viewer?.clock?.currentTime)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,8 +88,9 @@ export function readCameraTargetFrame(target, tracker) {
|
||||
const engine = engineOf(target);
|
||||
if (!engine) return null;
|
||||
const viewer = trackerOf(target, tracker);
|
||||
const center = viewer?.trackedEntity
|
||||
? trackedTarget(viewer)
|
||||
const entity = followedEntity(engine, viewer);
|
||||
const center = entity
|
||||
? trackedTarget(entity, viewer)
|
||||
: pickViewTarget(engine);
|
||||
const pose = engine.camera.pose();
|
||||
if (!center || !isPickedWorldPosition(pose.position)) return null;
|
||||
@@ -127,7 +134,9 @@ export function setCameraTargetFrame(target, frame, tracker) {
|
||||
const viewer = trackerOf(target, tracker);
|
||||
const { camera } = engine;
|
||||
try {
|
||||
const trackedTransform = viewer?.trackedEntity ? camera.transform() : null;
|
||||
const trackedTransform = followedEntity(engine, viewer)
|
||||
? camera.transform()
|
||||
: null;
|
||||
camera.lookAt(frame.target, {
|
||||
heading: normalizedHeading(frame.heading),
|
||||
pitch: frame.pitch,
|
||||
@@ -217,20 +226,20 @@ export function createCameraOrientationAnimator(
|
||||
if (!engine) return false;
|
||||
if (duration <= 0)
|
||||
return setCameraTargetFrame(engine, { ...frame, ...destination }, viewer);
|
||||
const entity = viewer?.trackedEntity;
|
||||
const entity = followedEntity(engine, viewer);
|
||||
pending = destination;
|
||||
const start = now();
|
||||
const headingDelta = geo.math.negativePiToPi(
|
||||
destination.heading - frame.heading,
|
||||
);
|
||||
remove = engine.frames.preUpdate.addEventListener(() => {
|
||||
if (engine.isDestroyed() || viewer?.trackedEntity !== entity) {
|
||||
if (engine.isDestroyed() || followedEntity(engine, viewer) !== entity) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
const progress = geo.math.clamp((now() - start) / duration, 0, 1);
|
||||
const eased = cubicInOut(progress);
|
||||
const center = entity ? trackedTarget(viewer) : frame.target;
|
||||
const center = entity ? trackedTarget(entity, viewer) : frame.target;
|
||||
if (
|
||||
!isPickedWorldPosition(center) ||
|
||||
!setCameraTargetFrame(
|
||||
@@ -397,6 +406,7 @@ export function bindCameraOrientationControls({
|
||||
// The exact tilt state is refreshed once per gesture, not once per frame —
|
||||
// moveEnd fires when the camera stops, which is where the pick is affordable.
|
||||
subscribe(engine?.camera.moveEnd, refreshTilt);
|
||||
subscribe(engine?.camera.followChanged, animator.cancel);
|
||||
subscribe(viewer?.trackedEntityChanged, animator.cancel);
|
||||
for (const type of ['pointerdown', 'wheel']) {
|
||||
const canvas = engine?.host.canvas;
|
||||
|
||||
@@ -5,8 +5,9 @@ import { LocationControls } from './location.js';
|
||||
|
||||
/**
|
||||
* Own destination selection, lookup, orbit and world-jump lifetime. The camera
|
||||
* is the globe `engine`'s; `viewer` is optional and only clears Cesium's
|
||||
* tracked entity (entity tracking moves to the engine in phase 1c).
|
||||
* is the globe `engine`'s, and a world jump stops its follow
|
||||
* (`engine.camera.following()`); `viewer` is optional and only clears a
|
||||
* Cesium tracked entity that no registered engine reports.
|
||||
*/
|
||||
export class LocationNavigation {
|
||||
constructor({
|
||||
@@ -355,6 +356,7 @@ export class LocationNavigation {
|
||||
} catch {
|
||||
/* best-effort release */
|
||||
}
|
||||
this.engine.camera.following()?.stop();
|
||||
if (this.viewer) this.viewer.trackedEntity = undefined;
|
||||
this.engine.camera.cancelFlight();
|
||||
this.engine.camera.release();
|
||||
|
||||
Reference in new issue
Block a user