diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 8012bc4b3..1fc801cff 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index e4d49a207..a4ec90a5d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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"] }, diff --git a/src/app/layers/flights.js b/src/app/layers/flights.js index 38f880a05..87c25efc4 100644 --- a/src/app/layers/flights.js +++ b/src/app/layers/flights.js @@ -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, diff --git a/src/app/layers/militaryFlights.js b/src/app/layers/militaryFlights.js index 839220c54..f66689562 100644 --- a/src/app/layers/militaryFlights.js +++ b/src/app/layers/militaryFlights.js @@ -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, diff --git a/src/data/cameraSensitivity.js b/src/data/cameraSensitivity.js index ff7d959c4..2a5ed685e 100644 --- a/src/data/cameraSensitivity.js +++ b/src/data/cameraSensitivity.js @@ -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}>} */ 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. diff --git a/src/data/cameraSensitivity.test.mjs b/src/data/cameraSensitivity.test.mjs index d0d68c867..8570ce9a7 100644 --- a/src/data/cameraSensitivity.test.mjs +++ b/src/data/cameraSensitivity.test.mjs @@ -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(); + } +}); diff --git a/src/data/trackedCamera.js b/src/data/trackedCamera.js index 3a1d9f1e2..68e7de827 100644 --- a/src/data/trackedCamera.js +++ b/src/data/trackedCamera.js @@ -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; -} diff --git a/src/globe/cesium/camera.js b/src/globe/cesium/camera.js index e34a50727..cf247c3e6 100644 --- a/src/globe/cesium/camera.js +++ b/src/globe/cesium/camera.js @@ -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(); }, diff --git a/src/globe/cesium/clock.test.mjs b/src/globe/cesium/clock.test.mjs index 055645783..5f8e063e4 100644 --- a/src/globe/cesium/clock.test.mjs +++ b/src/globe/cesium/clock.test.mjs @@ -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)); }); diff --git a/src/globe/cesium/engineFromViewer.js b/src/globe/cesium/engineFromViewer.js index 9f926b071..98825e4e6 100644 --- a/src/globe/cesium/engineFromViewer.js +++ b/src/globe/cesium/engineFromViewer.js @@ -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. */ diff --git a/src/globe/cesium/follow.js b/src/globe/cesium/follow.js new file mode 100644 index 000000000..bb8391601 --- /dev/null +++ b/src/globe/cesium/follow.js @@ -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; + }, + }; +} diff --git a/src/globe/cesium/follow.test.mjs b/src/globe/cesium/follow.test.mjs new file mode 100644 index 000000000..674f92b25 --- /dev/null +++ b/src/globe/cesium/follow.test.mjs @@ -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); +}); diff --git a/src/globe/cesium/testing/fakeViewer.mjs b/src/globe/cesium/testing/fakeViewer.mjs index d6ba09cc6..170713fe2 100644 --- a/src/globe/cesium/testing/fakeViewer.mjs +++ b/src/globe/cesium/testing/fakeViewer.mjs @@ -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: { diff --git a/src/globe/contract.js b/src/globe/contract.js index d36d9796f..c915afd8b 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -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) => 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: { diff --git a/src/globe/contract.test.mjs b/src/globe/contract.test.mjs index 6bd1f0ac1..a8215d4a4 100644 --- a/src/globe/contract.test.mjs +++ b/src/globe/contract.test.mjs @@ -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/); +}); diff --git a/src/globe/follow.js b/src/globe/follow.js new file mode 100644 index 000000000..fcbffefcd --- /dev/null +++ b/src/globe/follow.js @@ -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); + }, + }; +} diff --git a/src/globe/follow.test.mjs b/src/globe/follow.test.mjs new file mode 100644 index 000000000..d9877996e --- /dev/null +++ b/src/globe/follow.test.mjs @@ -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, + ); +}); diff --git a/src/globe/navara/cameraModel.js b/src/globe/navara/cameraModel.js index a58c20d64..c556e4a84 100644 --- a/src/globe/navara/cameraModel.js +++ b/src/globe/navara/cameraModel.js @@ -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) => diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js index 2de679362..ec17248d4 100644 --- a/src/globe/navara/createNavaraEngine.js +++ b/src/globe/navara/createNavaraEngine.js @@ -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(); diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index f5493c21f..744597719 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -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)); diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index 6921ed985..d21b54d48 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -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); diff --git a/src/layers/military/ownership.test.mjs b/src/layers/military/ownership.test.mjs index e180f3ced..15ae70e44 100644 --- a/src/layers/military/ownership.test.mjs +++ b/src/layers/military/ownership.test.mjs @@ -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'), diff --git a/src/ui/cameraOrientationControls.js b/src/ui/cameraOrientationControls.js index 255445200..58cd97a99 100644 --- a/src/ui/cameraOrientationControls.js +++ b/src/ui/cameraOrientationControls.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; diff --git a/src/ui/locationNavigation.js b/src/ui/locationNavigation.js index 42b3e2f0a..342a57b20 100644 --- a/src/ui/locationNavigation.js +++ b/src/ui/locationNavigation.js @@ -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();