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:
German Khokhlov committed 2026-10-16 20:26:43 +04:00
1 parent a89719aba9
commit f640890cf6
24 files changed
+1226 -218

No files matched your search

+5 -1
View File
@@ -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"
]
+17 -5
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -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,
+10 -6
View File
@@ -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.
+28
View File
@@ -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
View File
@@ -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;
}
+7
View File
@@ -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();
},
+2 -1
View File
@@ -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));
});
+10 -4
View File
@@ -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.
*/
+344
View File
@@ -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;
},
};
}
+144
View File
@@ -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);
});
+48 -6
View File
@@ -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: {
+26
View File
@@ -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: {
+12
View File
@@ -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/);
});
+245
View File
@@ -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);
},
};
}
+129
View File
@@ -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,
);
});
+7
View File
@@ -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) =>
+19
View File
@@ -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();
+109
View File
@@ -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));
+21
View File
@@ -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);
+5 -1
View File
@@ -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'),
+23 -13
View File
@@ -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;
+4 -2
View File
@@ -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();