diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f51c4de0b..136e1cc6b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -9,6 +9,7 @@ "src/data/infrastructure.js", "src/data/localGeojsonCore.js", "src/render/cesium/camera.js", + "src/render/cesium/follow.js", "src/data/localGeojsonLod.js", "src/data/inputOwnership.js", "src/data/pickRegistry.js", @@ -866,6 +867,7 @@ "src/ui/cockpitRollingValue.js", "src/ui/cockpitCamera.js", "src/render/cesium/camera.js", + "src/render/cesium/follow.js", "src/render/rendererOf.js", "src/render/camera/events.js", "src/geo/celestial.js", @@ -991,6 +993,7 @@ "src/styles/thermal.js", "src/ui/applicationShell.js", "src/render/cesium/camera.js", + "src/render/cesium/follow.js", "src/render/rendererOf.js", "src/render/camera/events.js", "src/render/cesium/internals.js", @@ -1254,6 +1257,7 @@ "src/maps/catalog.js", "src/scenes/director.js", "src/render/cesium/camera.js", + "src/render/cesium/follow.js", "src/geo/index.js", "src/render/rendererOf.js", "src/render/camera/events.js", @@ -1749,6 +1753,7 @@ "src/layers/launches/controls.js", "src/layers/launches/index.js", "src/render/cesium/camera.js", + "src/render/cesium/follow.js", "src/layers/launches/ingestion.js", "src/layers/launches/launchPad.js", "src/layers/launches/lifecycle.js", @@ -1809,6 +1814,7 @@ "src/data/installationNames.js", "src/layers/installations/controls.js", "src/render/cesium/camera.js", + "src/render/cesium/follow.js", "src/geo/index.js", "src/render/rendererOf.js", "src/render/camera/events.js", @@ -1888,6 +1894,7 @@ "src/layers/awareness/dependencies.js", "src/layers/awareness/focus.js", "src/render/cesium/camera.js", + "src/render/cesium/follow.js", "src/render/rendererOf.js", "src/render/camera/events.js", "src/geo/celestial.js", @@ -2066,6 +2073,7 @@ "src/layers/cctv/modelSpecs.js", "src/layers/cctv/navigation.js", "src/render/cesium/camera.js", + "src/render/cesium/follow.js", "src/render/rendererOf.js", "src/render/camera/events.js", "src/geo/celestial.js", @@ -2135,6 +2143,7 @@ "src/layers/streetLevel/viewerHost.js", "src/layers/streetLevel/cameraFraming.js", "src/render/cesium/camera.js", + "src/render/cesium/follow.js", "src/render/rendererOf.js", "src/render/camera/events.js", "src/geo/celestial.js", @@ -2221,6 +2230,7 @@ "src/layers/alpr/policy.js", "src/layers/alpr/presentation.js", "src/render/cesium/camera.js", + "src/render/cesium/follow.js", "src/render/rendererOf.js", "src/render/camera/events.js", "src/geo/celestial.js", @@ -2291,6 +2301,7 @@ "src/layers/radio/model.js", "src/layers/radio/navigation.js", "src/render/cesium/camera.js", + "src/render/cesium/follow.js", "src/render/rendererOf.js", "src/render/camera/events.js", "src/geo/celestial.js", @@ -2346,6 +2357,7 @@ "src/geo/index.js", "src/layers/submarineCables/interaction.js", "src/render/cesium/camera.js", + "src/render/cesium/follow.js", "src/render/rendererOf.js", "src/render/camera/events.js", "src/geo/celestial.js", @@ -2626,6 +2638,7 @@ "src/render/cesium/ports.js", "src/render/cesium/common.js", "src/render/cesium/camera.js", + "src/render/cesium/follow.js", "src/render/camera/events.js", "src/render/contract/index.js", "src/sources/sourceSlot.js", @@ -3598,6 +3611,7 @@ "src/data/bhoteKoshiEmbeddedMedia.js", "src/data/bhoteKoshiLocator.js", "src/render/cesium/camera.js", + "src/render/cesium/follow.js", "src/data/bhoteKoshiIncidentPlaces.js", "src/data/bhoteKoshiFloodPath.js", "src/data/bhoteKoshiShotPaths.js", @@ -4241,6 +4255,7 @@ "src/layers/firms/anchors.js", "src/locations.js", "src/render/cesium/camera.js", + "src/render/cesium/follow.js", "src/render/rendererOf.js", "src/render/camera/events.js", "src/geo/index.js", @@ -4842,6 +4857,7 @@ "src/cyberSonar.js", "src/celestialRing.js", "src/render/cesium/camera.js", + "src/render/cesium/follow.js", "src/render/rendererOf.js", "src/render/camera/events.js", "src/geo/celestial.js", diff --git a/src/data/infrastructure.test.mjs b/src/data/infrastructure.test.mjs index 5829f58b7..30cd35a86 100644 --- a/src/data/infrastructure.test.mjs +++ b/src/data/infrastructure.test.mjs @@ -193,6 +193,7 @@ test('consumer build includes only infrastructure code and resolves assets under 'ctrlWheelZoom.js', 'dispatcher.js', 'events.js', + 'follow.js', 'heights.js', 'infrastructure.js', 'infrastructureData.js', diff --git a/src/data/trackedCamera.js b/src/data/trackedCamera.js index 37b04de89..622bcb833 100644 --- a/src/data/trackedCamera.js +++ b/src/data/trackedCamera.js @@ -1,9 +1,9 @@ import * as Cesium from 'cesium'; import { readEntityRecord } from './entityRecords.js'; +import { clampFollowEye, holdFollowZoom } from '../render/cesium/follow.js'; const MAX_FRAME_ATTEMPTS = 120; const MIN_TRACKED_RANGE_M = 150; -const ZOOM_INERTIA_STATES = new WeakMap(); /** * Preserve real-world model scale except when a very close tracked camera @@ -65,31 +65,7 @@ export function trackedDisplayPositionForCamera(entity, 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); - }; + return holdFollowZoom(controller, entity, MIN_TRACKED_RANGE_M); } /** @@ -106,34 +82,7 @@ export function clampTrackedCameraPosition( previousPosition, minimumRangeM = MIN_TRACKED_RANGE_M, ) { - const crossedOrigin = - Cesium.Cartesian3.dot(camera.position, previousPosition) <= 0; - const forwardDistance = -Cesium.Cartesian3.dot( - camera.position, - camera.direction, - ); - const rangeSquared = Cesium.Cartesian3.magnitudeSquared(camera.position); - if (crossedOrigin) { - Cesium.Cartesian3.normalize(previousPosition, camera.position); - Cesium.Cartesian3.multiplyByScalar( - camera.position, - minimumRangeM, - camera.position, - ); - return true; - } - if ( - forwardDistance < minimumRangeM || - rangeSquared < minimumRangeM * minimumRangeM - ) { - Cesium.Cartesian3.multiplyByScalar( - camera.direction, - -minimumRangeM, - camera.position, - ); - return true; - } - return false; + return clampFollowEye(camera, previousPosition, minimumRangeM); } /** diff --git a/src/render/cesium/appRenderer.test.mjs b/src/render/cesium/appRenderer.test.mjs index 2e88bbf65..c0f6eaf05 100644 --- a/src/render/cesium/appRenderer.test.mjs +++ b/src/render/cesium/appRenderer.test.mjs @@ -333,12 +333,6 @@ const EXPECTED_MISSING = [ 'camera.controls.suspend', 'camera.controls.zoomFactor', 'camera.controls.zoomInputs', - 'camera.follow', - 'camera.followFramed', - 'camera.followOffset', - 'camera.followOffset.get', - 'camera.followOffset.set', - 'camera.followed', 'camera.matrices', 'camera.setFov', 'draw.atlas', diff --git a/src/render/cesium/camera.js b/src/render/cesium/camera.js index 7592d4026..291b4b438 100644 --- a/src/render/cesium/camera.js +++ b/src/render/cesium/camera.js @@ -8,6 +8,7 @@ import * as Cesium from 'cesium'; import { createCameraEvents } from '../camera/events.js'; import { listenerList, toCartesian, uncaught } from './common.js'; import { cameraFlightActive } from './internals.js'; +import { createCesiumFollow } from './follow.js'; import { cameraOf, provideUnboundCamera } from '../rendererOf.js'; /** @@ -68,13 +69,18 @@ export function toOrientation(o = {}) { }; } +/** The follow offset of a camera that cannot follow. */ +const NO_FOLLOW_OFFSET = Object.freeze({ get: () => null, set() {} }); + /** - * @param {{scene: object, camera: object, report: (error: unknown) => void}} - * options `scene` gives `requestRender` and the `postUpdate` tick - * `changed` is measured on. + * @param {{scene: object, camera: object, report: (error: unknown) => void, + * widget?: object}} options `scene` gives `requestRender` and the + * `postUpdate` tick `changed` is measured on; `widget`, the Viewer or + * CesiumWidget, gives the entity tracking `follow` runs on (without it the + * camera cannot follow). * @returns {{port: import('../contract/camera').CameraPort, dispose: () => void}} */ -export function createCesiumCamera({ scene, camera, report }) { +export function createCesiumCamera({ scene, camera, report, widget }) { const removers = []; // Camera events: Cesium's own moveStart and moveEnd (500 ms after the // last change, never while following), and `changed` measured per @@ -89,6 +95,15 @@ export function createCesiumCamera({ scene, camera, report }) { camera.moveEnd.addEventListener(() => cameraEvents.emit('moveEnd')), ); + const following = widget?.trackedEntityChanged + ? createCesiumFollow({ + widget, + scene, + camera, + onChanged: () => cameraEvents.emit('followChanged'), + }) + : null; + // Camera commands: hooks run before each one, so an owner can yield. const commandHooks = listenerList(report); const command = (kind) => commandHooks.fire({ kind }); @@ -235,6 +250,20 @@ export function createCesiumCamera({ scene, camera, report }) { ); scene.requestRender(); }, + follow(target) { + if (!following) + throw new Error('This camera cannot follow: it has no entity tracking'); + command('follow'); + following.follow(target); + scene.requestRender(); + }, + get followed() { + return following?.followed ?? null; + }, + get followFramed() { + return following?.followFramed ?? false; + }, + followOffset: following?.followOffset ?? NO_FOLLOW_OFFSET, release() { command('release'); camera.lookAtTransform(Cesium.Matrix4.IDENTITY); @@ -304,6 +333,7 @@ export function createCesiumCamera({ scene, camera, report }) { return { port: cameraPort, dispose() { + following?.dispose(); for (const off of removers.splice(0)) off(); cameraEvents.clear(); commandHooks.clear(); @@ -323,6 +353,7 @@ provideUnboundCamera((viewer) => scene: viewer.scene, camera: viewer.camera, report: uncaught, + widget: viewer, }) : null, ); diff --git a/src/render/cesium/follow.js b/src/render/cesium/follow.js new file mode 100644 index 000000000..5d9655c00 --- /dev/null +++ b/src/render/cesium/follow.js @@ -0,0 +1,337 @@ +/** + * @module render/cesium/follow + * @description The Cesium camera port's follow. It is Cesium's own entity + * tracking (`trackedEntity` and its EntityView) on a hidden helper entity + * per target, so the camera behaves as it does for any tracked entity: + * the user orbits and zooms around the target, tilt is off, and `moveEnd` + * waits. On top of it, a target with a `minRange` gets GEV's close-range + * rules: an exact first framing, no zoom coasting, a zoom floor, and an eye + * that never crosses through the target. + * + * Whatever sets Cesium's `trackedEntity` takes the camera, so a follow ends + * when anything else is tracked, and `follow(null)` ends any tracking. + * `autodetect` is Cesium's AUTODETECT frame: a frame from inertial motion + * (x forward, y left, z up) for a fast target such as a satellite, ENU for + * a slow or still one. It is not the stub's frame along the ground track, + * so an autodetect `viewFrom` is renderer-specific. + */ +import * as Cesium from 'cesium'; +import { toCartesian } from './common.js'; +import { trackedEntityViewEntity } from './internals.js'; + +/** Frames to wait for a target's first position before giving up framing. */ +const MAX_FRAME_ATTEMPTS = 120; +const TRANSPARENT = new Cesium.Color(0, 0, 0, 0); +const ZOOM_STATES = new WeakMap(); + +/** + * Turn zoom coasting off and raise the zoom floor to `minRange` while any + * owner holds it; the last release restores the controller's own values. + * @param {object|undefined} controller A ScreenSpaceCameraController. + * @param {object} owner + * @param {number} minRange + * @returns {() => void} Release. + */ +export function holdFollowZoom(controller, owner, minRange) { + if (!controller) return () => {}; + let state = ZOOM_STATES.get(controller); + if (!state) { + state = { + inertiaZoom: controller.inertiaZoom, + minimumZoomDistance: controller.minimumZoomDistance, + owners: new Map(), + }; + ZOOM_STATES.set(controller, state); + } + state.owners.set(owner, minRange); + const apply = (current) => { + controller.inertiaZoom = 0; + controller.minimumZoomDistance = Math.max( + current.minimumZoomDistance, + ...current.owners.values(), + ); + }; + apply(state); + return () => { + const current = ZOOM_STATES.get(controller); + if (!current?.owners.delete(owner)) return; + if (current.owners.size > 0) { + apply(current); + return; + } + controller.inertiaZoom = current.inertiaZoom; + controller.minimumZoomDistance = current.minimumZoomDistance; + ZOOM_STATES.delete(controller); + }; +} + +/** + * Keep a follow-frame eye on the same side of its target and at least + * `minimumRangeM` from it, in front and in all. + * @param {{position: Cesium.Cartesian3, direction: Cesium.Cartesian3}} camera + * Eye in the follow frame; corrected in place. + * @param {Cesium.Cartesian3} previousPosition The eye's last frame. + * @param {number} minimumRangeM + * @returns {boolean} Whether the eye was corrected. + */ +export function clampFollowEye(camera, previousPosition, minimumRangeM) { + const crossedOrigin = + Cesium.Cartesian3.dot(camera.position, previousPosition) <= 0; + const forwardDistance = -Cesium.Cartesian3.dot( + camera.position, + camera.direction, + ); + const rangeSquared = Cesium.Cartesian3.magnitudeSquared(camera.position); + if (crossedOrigin) { + Cesium.Cartesian3.normalize(previousPosition, camera.position); + Cesium.Cartesian3.multiplyByScalar( + camera.position, + minimumRangeM, + camera.position, + ); + return true; + } + if ( + forwardDistance < minimumRangeM || + rangeSquared < minimumRangeM * minimumRangeM + ) { + Cesium.Cartesian3.multiplyByScalar( + camera.direction, + -minimumRangeM, + camera.position, + ); + return true; + } + return false; +} + +const gregorianScratch = new Cesium.GregorianDate(); +/** + * Scene time in epoch ms, sub-millisecond included: `JulianDate.toDate` + * drops the fraction, and EntityView samples motion 1 ms apart. + */ +function msOf(time) { + const g = Cesium.JulianDate.toGregorianDate(time, gregorianScratch); + const second = g.isLeapSecond ? g.second - 1 : g.second; + return ( + Date.UTC(g.year, g.month - 1, g.day, g.hour, g.minute, second) + + g.millisecond + ); +} + +/** + * The eye's starting offset: `viewFrom`, put out to the target's minimum + * range. Cesium's EntityView frames from it too, so the two agree. + */ +function startOffset(target, viewFrom, result) { + const offset = Cesium.Cartesian3.fromElements( + viewFrom.x, + viewFrom.y, + viewFrom.z, + result, + ); + const range = Cesium.Cartesian3.magnitude(offset); + const minRange = Math.max(target.minRange ?? 0, target.boundingRadius ?? 0); + if (range >= Cesium.Math.EPSILON6 && range < minRange) + Cesium.Cartesian3.multiplyByScalar(offset, minRange / range, offset); + return offset; +} + +/** + * @param {{widget: object, scene: object, camera: object, + * onChanged: () => void}} options `widget` is the Viewer or CesiumWidget + * whose `trackedEntity` this follows with; `onChanged` runs on every + * change of what it tracks. + */ +export function createCesiumFollow({ widget, scene, camera, onChanged }) { + /** @type {null | {target: object, helper: object, framed: boolean, + * attempts: number, previous: Cesium.Cartesian3, releaseZoom: () => void}} */ + let current = null; + let removePreUpdate = null; + /** What `followChanged` last announced: it fires only when that changes. */ + let announced = null; + const followed = () => + current && widget.trackedEntity === current.helper ? current.target : null; + + function release(entry) { + if (!entry) return; + entry.releaseZoom(); + if (!widget.isDestroyed?.()) widget.entities.remove(entry.helper); + if (!current && removePreUpdate) { + removePreUpdate(); + removePreUpdate = null; + } + } + + // Anything that changes what Cesium tracks (a follow, `trackedEntity` + // set elsewhere, the helper removed) ends a follow it replaced. + const removeChanged = widget.trackedEntityChanged.addEventListener( + (value) => { + if (current && value !== current.helper) { + const ended = current; + current = null; + release(ended); + } + const now = followed(); + if (now !== announced) { + announced = now; + onChanged(); + } + }, + ); + + const viewFromAt = (target, time) => + typeof target.viewFrom === 'function' + ? target.viewFrom(msOf(time)) + : target.viewFrom; + + function helperFor(target) { + return widget.entities.add({ + position: new Cesium.CallbackProperty((time, result) => { + const p = target.position(msOf(time)); + return p ? Cesium.Cartesian3.fromElements(p.x, p.y, p.z, result) : p; + }, false), + // EntityView tracks an entity only once its bounding sphere is + // ready, and an entity without graphics has none. + point: { pixelSize: 1, color: TRANSPARENT }, + viewFrom: new Cesium.CallbackProperty((time, result) => { + const v = viewFromAt(target, time); + return v ? startOffset(target, v, result) : v; + }, false), + trackingReferenceFrame: + target.frame === 'autodetect' + ? Cesium.TrackingReferenceFrame.AUTODETECT + : Cesium.TrackingReferenceFrame.ENU, + }); + } + + // preUpdate on purpose: in preRender the target's drawing is already + // prepared, so moving the eye there parts it from the target for a frame. + function step() { + const entry = current; + if (!entry || entry.gaveUp || widget.trackedEntity !== entry.helper) return; + if (!entry.framed) frame(entry); + else if ( + entry.target.minRange > 0 && + clampFollowEye(camera, entry.previous, entry.target.minRange) + ) + camera.lookAtTransform( + camera.transform, + Cesium.Cartesian3.clone(camera.position), + ); + if (entry.framed) Cesium.Cartesian3.clone(camera.position, entry.previous); + } + + /** + * The first framing. Cesium's EntityView keeps the old eye when tracking + * switches across a large distance, so an ENU follow places it exactly; + * an autodetected frame is Cesium's own to place. + */ + function frame(entry) { + const { target } = entry; + entry.attempts += 1; + const time = widget.clock.currentTime; + const at = target.position(msOf(time)); + if (target.frame !== 'enu') { + // Framed once Cesium's EntityView has taken the helper on: until + // then the camera may still ride the previous tracking's frame. + entry.framed = trackedEntityViewEntity(widget) === entry.helper; + if (!entry.framed) giveUpAfterWaiting(entry); + return; + } + const viewFrom = at && viewFromAt(target, time); + if (!at || !viewFrom) { + giveUpAfterWaiting(entry); + return; + } + const offset = startOffset(target, viewFrom); + if (Cesium.Cartesian3.magnitude(offset) < Cesium.Math.EPSILON6) { + // No offset to frame at: Cesium's own framing stands, unpoliced. + giveUp(entry); + return; + } + camera.lookAtTransform( + Cesium.Transforms.eastNorthUpToFixedFrame( + toCartesian(at), + Cesium.Ellipsoid.WGS84, + ), + offset, + ); + entry.framed = true; + } + + /** + * With no position to frame for MAX_FRAME_ATTEMPTS frames, the follow + * leaves the camera to Cesium: no framing, no zoom hold, no clamp. + */ + function giveUpAfterWaiting(entry) { + if (entry.attempts >= MAX_FRAME_ATTEMPTS) giveUp(entry); + } + function giveUp(entry) { + entry.gaveUp = true; + entry.releaseZoom(); + } + + return { + /** @param {object|null} target A contract FollowTarget. */ + follow(target) { + const previous = current; + if (!target) { + current = null; + widget.trackedEntity = undefined; + release(previous); + return; + } + const entry = { + target, + helper: helperFor(target), + framed: false, + gaveUp: false, + attempts: 0, + previous: new Cesium.Cartesian3(), + releaseZoom: () => {}, + }; + if (target.minRange > 0) + entry.releaseZoom = holdFollowZoom( + scene.screenSpaceCameraController, + entry, + target.minRange, + ); + current = entry; + removePreUpdate ??= scene.preUpdate.addEventListener(step); + widget.trackedEntity = entry.helper; + release(previous); + }, + get followed() { + return followed(); + }, + get followFramed() { + return Boolean(current?.framed); + }, + followOffset: Object.freeze({ + get() { + if (!current?.framed) return null; + const p = camera.position; + return { x: p.x, y: p.y, z: p.z }; + }, + set(offset) { + if (!current?.framed) return; + camera.lookAtTransform(camera.transform, toCartesian(offset)); + Cesium.Cartesian3.clone(camera.position, current.previous); + scene.requestRender(); + }, + }), + dispose() { + removeChanged(); + const ended = current; + current = null; + if (ended && !widget.isDestroyed?.()) { + if (widget.trackedEntity === ended.helper) + widget.trackedEntity = undefined; + } + release(ended); + removePreUpdate?.(); + removePreUpdate = null; + }, + }; +} diff --git a/src/render/cesium/follow.test.mjs b/src/render/cesium/follow.test.mjs new file mode 100644 index 000000000..c26530294 --- /dev/null +++ b/src/render/cesium/follow.test.mjs @@ -0,0 +1,279 @@ +/** + * The Cesium port's follow over a widget-shaped host: Cesium's own + * `trackedEntity` setter semantics (one change event per value, release to + * the world frame) on a headless camera, with `preUpdate` stepped by hand. + * The follow itself, frame after frame, is the browser conformance run's. + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { headlessCamera } from './cameraGoldens.js'; +import { createCesiumCamera } from './camera.js'; +import { holdFollowZoom } from './follow.js'; + +function trackingHost() { + const { scene, camera } = headlessCamera(); + for (const name of ['preUpdate', 'postUpdate', 'preRender', 'postRender']) + scene[name] = new Cesium.Event(); + scene.requestRender = () => {}; + scene.screenSpaceCameraController = { + inertiaZoom: 0.8, + minimumZoomDistance: 1, + }; + const entities = new Cesium.EntityCollection(); + const changed = new Cesium.Event(); + let tracked; + const widget = { + scene, + camera, + entities, + clock: { currentTime: Cesium.JulianDate.fromDate(new Date(0)) }, + trackedEntityChanged: changed, + get trackedEntity() { + return tracked; + }, + // Cesium's setter: a change event per new value, and a release to the + // world frame without moving the eye. + set trackedEntity(value) { + if (tracked === value) return; + tracked = value; + if (!value) camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + changed.raiseEvent(value); + }, + }; + // Removing the tracked entity ends the tracking, as Cesium's does. + entities.collectionChanged.addEventListener((_c, _added, removed) => { + if (removed.includes(tracked)) widget.trackedEntity = undefined; + }); + const part = createCesiumCamera({ + scene, + camera, + widget, + report: (error) => { + throw error; + }, + }); + return { widget, scene, camera, port: part.port, dispose: part.dispose }; +} + +const AUSTIN = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1000); +const target = (key, extra = {}) => ({ + key, + position: () => ({ x: AUSTIN.x, y: AUSTIN.y, z: AUSTIN.z }), + frame: 'enu', + viewFrom: { x: 0, y: -800, z: 400 }, + ...extra, +}); + +test('follow announces each change once and keeps one helper entity', () => { + const h = trackingHost(); + const seen = []; + h.port.on('followChanged', () => seen.push(h.port.followed?.key ?? null)); + const a = target('a'); + const b = target('b'); + h.port.follow(a); + assert.equal(h.port.followed, a); + h.port.follow(b); + assert.equal(h.port.followed, b); + assert.equal(h.widget.entities.values.length, 1, 'the old helper is gone'); + h.port.follow(null); + assert.equal(h.port.followed, null); + assert.equal(h.widget.entities.values.length, 0); + assert.deepEqual(seen, ['a', 'b', null]); + h.dispose(); +}); + +test('tracking set elsewhere ends the follow', () => { + const h = trackingHost(); + h.port.follow(target('a')); + const other = h.widget.entities.add({ position: AUSTIN }); + h.widget.trackedEntity = other; + assert.equal(h.port.followed, null); + assert.deepEqual( + h.widget.entities.values, + [other], + 'the helper went with it', + ); + h.dispose(); +}); + +test('an ENU follow frames exactly at viewFrom, scaled up to its minimum range', () => { + const h = trackingHost(); + h.port.follow( + target('a', { viewFrom: { x: 0, y: -60, z: 80 }, minRange: 150 }), + ); + assert.equal(h.port.followFramed, false); + assert.equal(h.port.followOffset.get(), null); + h.scene.preUpdate.raiseEvent(h.scene); + assert.equal(h.port.followFramed, true); + const offset = h.port.followOffset.get(); + assert.ok(Math.abs(Math.hypot(offset.x, offset.y, offset.z) - 150) < 1e-6); + assert.ok(Math.abs(offset.y / offset.z - -60 / 80) < 1e-9, 'same bearing'); + const eye = h.camera.positionWC; + assert.ok(Math.abs(Cesium.Cartesian3.distance(eye, AUSTIN) - 150) < 1e-6); + h.dispose(); +}); + +test('within minRange the eye is put back, and the offset can be set', () => { + const h = trackingHost(); + h.port.follow(target('a', { minRange: 150 })); + h.scene.preUpdate.raiseEvent(h.scene); + // A programmatic zoom past the floor, as the controller's would not. + h.camera.zoomIn(Cesium.Cartesian3.magnitude(h.camera.position) - 20); + h.scene.preUpdate.raiseEvent(h.scene); + const range = Cesium.Cartesian3.magnitude(h.camera.position); + assert.ok(Math.abs(range - 150) < 1e-6, `range ${range}`); + h.port.followOffset.set({ x: 0, y: -2000, z: 1000 }); + const offset = h.port.followOffset.get(); + assert.deepEqual( + [offset.x, offset.y, offset.z].map(Math.round), + [0, -2000, 1000], + ); + h.dispose(); +}); + +test('a follow with a minimum range holds the zoom floor and stops coasting', () => { + const h = trackingHost(); + const controller = h.scene.screenSpaceCameraController; + h.port.follow(target('a', { minRange: 150 })); + assert.equal(controller.inertiaZoom, 0); + assert.equal(controller.minimumZoomDistance, 150); + h.port.follow(target('b')); + assert.equal(controller.inertiaZoom, 0.8, 'restored for a follow without'); + assert.equal(controller.minimumZoomDistance, 1); + h.dispose(); +}); + +test('zoom floors from several owners take the highest, and restore last', () => { + const controller = { inertiaZoom: 0.5, minimumZoomDistance: 10 }; + const a = {}; + const b = {}; + const releaseA = holdFollowZoom(controller, a, 150); + const releaseB = holdFollowZoom(controller, b, 400); + assert.equal(controller.minimumZoomDistance, 400); + releaseB(); + assert.equal(controller.minimumZoomDistance, 150); + assert.equal(controller.inertiaZoom, 0); + releaseA(); + releaseA(); + assert.deepEqual(controller, { inertiaZoom: 0.5, minimumZoomDistance: 10 }); +}); + +test('dispose ends a follow it still has', () => { + const h = trackingHost(); + h.port.follow(target('a')); + h.dispose(); + assert.equal(h.widget.trackedEntity, undefined); + assert.equal(h.widget.entities.values.length, 0); +}); + +test('a camera without entity tracking cannot follow', () => { + const { scene, camera } = headlessCamera(); + for (const name of ['postUpdate']) scene[name] = new Cesium.Event(); + const { port } = createCesiumCamera({ scene, camera, report: () => {} }); + assert.throws(() => port.follow(target('a')), /cannot follow/); + assert.equal(port.followed, null); + assert.equal(port.followOffset.get(), null); +}); + +test('Cesium frames from viewFrom put out to the minimum range too', () => { + const h = trackingHost(); + h.port.follow( + target('a', { viewFrom: { x: 0, y: -60, z: 80 }, boundingRadius: 500 }), + ); + const [helper] = h.widget.entities.values; + const v = helper.viewFrom.getValue(h.widget.clock.currentTime); + assert.ok(Math.abs(Cesium.Cartesian3.magnitude(v) - 500) < 1e-6); + h.dispose(); +}); + +test('targets are asked for their position at sub-millisecond scene time', () => { + const h = trackingHost(); + const asked = []; + h.port.follow( + target('a', { + position: (t) => { + asked.push(t); + return { x: AUSTIN.x, y: AUSTIN.y, z: AUSTIN.z }; + }, + }), + ); + const [helper] = h.widget.entities.values; + const at = Cesium.JulianDate.fromDate(new Date(Date.UTC(2026, 10, 6))); + helper.position.getValue(at); + helper.position.getValue(Cesium.JulianDate.addSeconds(at, 0.0005, at)); + assert.equal(asked.at(-2), Date.UTC(2026, 10, 6)); + assert.ok(Math.abs(asked.at(-1) - asked.at(-2) - 0.5) < 1e-6); + h.dispose(); +}); + +test('a target without a position gives up framing after 120 frames', () => { + const h = trackingHost(); + const controller = h.scene.screenSpaceCameraController; + h.port.follow(target('a', { position: () => null, minRange: 150 })); + for (let i = 0; i < 119; i++) h.scene.preUpdate.raiseEvent(h.scene); + assert.equal(controller.inertiaZoom, 0, 'still waiting'); + h.scene.preUpdate.raiseEvent(h.scene); + assert.equal(h.port.followFramed, false); + assert.equal(controller.inertiaZoom, 0.8, 'the zoom hold let go'); + assert.equal(h.port.followed?.key, 'a', 'Cesium still tracks it'); + h.dispose(); +}); + +test('followChanged fires only when what is followed changes', () => { + const h = trackingHost(); + let count = 0; + h.port.on('followChanged', () => (count += 1)); + h.port.follow(null); + // A layer tracking an entity of its own, while the port follows nothing. + const other = h.widget.entities.add({ position: AUSTIN }); + h.widget.trackedEntity = other; + h.widget.trackedEntity = undefined; + assert.equal(count, 0); + h.port.follow(target('a')); + assert.equal(count, 1); + // The helper removed from outside ends the follow, announced once. + h.widget.entities.removeAll(); + assert.equal(h.port.followed, null); + assert.equal(count, 2); + h.dispose(); +}); + +test('an autodetected follow is framed once Cesium tracks its helper', () => { + const h = trackingHost(); + h.port.follow(target('a', { frame: 'autodetect' })); + h.scene.preUpdate.raiseEvent(h.scene); + assert.equal(h.port.followFramed, false); + const [helper] = h.widget.entities.values; + // Cesium's EntityView, as CesiumWidget builds it once the sphere is ready. + h.widget._entityView = { entity: helper }; + h.scene.preUpdate.raiseEvent(h.scene); + assert.equal(h.port.followFramed, true); + h.dispose(); +}); + +test('a port follow and a layer frame hold one controller together', async () => { + const { applyTrackedCameraFrame } = + await import('../../data/trackedCamera.js'); + const h = trackingHost(); + const controller = h.scene.screenSpaceCameraController; + const viewer = { + scene: h.scene, + camera: h.camera, + clock: h.widget.clock, + isDestroyed: () => false, + trackedEntity: {}, + }; + const stopLayer = applyTrackedCameraFrame( + viewer, + viewer.trackedEntity, + new Cesium.Cartesian3(0, -800, 400), + ); + h.port.follow(target('a', { minRange: 300 })); + assert.equal(controller.minimumZoomDistance, 300); + stopLayer(); + assert.equal(controller.minimumZoomDistance, 300, 'the port still holds'); + h.port.follow(null); + assert.deepEqual(controller, { inertiaZoom: 0.8, minimumZoomDistance: 1 }); + h.dispose(); +}); diff --git a/src/render/cesium/harness.js b/src/render/cesium/harness.js index 8dd88d010..184343c7a 100644 --- a/src/render/cesium/harness.js +++ b/src/render/cesium/harness.js @@ -23,6 +23,7 @@ export const CESIUM_CHECKS = new Set([ 'camera.trace.commands inside a lookAt frame', 'camera.trace.changed fires by each listener’s threshold', 'camera.moveEnd', + 'camera.follow', 'camera.trace.flyTo with a duration of 0 completes inside the call', 'camera.trace.flyTo where the camera already is completes inside the call', 'camera.viewRectangle', diff --git a/src/render/cesium/internals.js b/src/render/cesium/internals.js index 49fa1cdd4..1df6d0ea4 100644 --- a/src/render/cesium/internals.js +++ b/src/render/cesium/internals.js @@ -140,6 +140,16 @@ export function webglContext(scene) { return scene?.context?._gl; } +/** + * The entity Cesium's EntityView tracks: set once tracking has started and + * framed its entity, which can be frames after `trackedEntity` is set. + * @param {object} widget Viewer or CesiumWidget. + * @returns {object|null} Entity. + */ +export function trackedEntityViewEntity(widget) { + return (widget?._cesiumWidget ?? widget)?._entityView?.entity ?? null; +} + /** * Whether a camera flight is under way. Cesium clears it before calling a * flight's `complete`, so it reads false inside that callback. diff --git a/src/render/cesium/internals.test.mjs b/src/render/cesium/internals.test.mjs index fe74af373..6e80b4972 100644 --- a/src/render/cesium/internals.test.mjs +++ b/src/render/cesium/internals.test.mjs @@ -46,6 +46,7 @@ test('every private field Core reaches is still declared by the installed Cesium [WIDGETS, 'Viewer/Viewer.js', '_cesiumWidget'], [ENGINE, 'Renderer/Context.js', '_gl'], [ENGINE, 'Scene/Camera.js', '_currentFlight'], + [ENGINE, 'Widget/CesiumWidget.js', '_entityView'], [ENGINE, 'Scene/Scene.js', '_renderRequested'], [ENGINE, 'Scene/Scene.js', '_logDepthBufferDirty'], [ENGINE, 'Scene/Scene.js', '_hdrDirty'], diff --git a/src/render/cesium/ports.js b/src/render/cesium/ports.js index 3325dd9b5..d95b9e7f5 100644 --- a/src/render/cesium/ports.js +++ b/src/render/cesium/ports.js @@ -731,7 +731,7 @@ export function createCesiumPorts(widget, { capabilities }) { models: (spec) => makeSet('models', { ...spec }, buildModels), }; - const cameraPart = createCesiumCamera({ scene, camera, report }); + const cameraPart = createCesiumCamera({ scene, camera, report, widget }); const cameraPort = cameraPart.port; // --- Query ----------------------------------------------------------------- diff --git a/src/render/contract/camera.d.ts b/src/render/contract/camera.d.ts index 98572522e..38b3e3112 100644 --- a/src/render/contract/camera.d.ts +++ b/src/render/contract/camera.d.ts @@ -233,6 +233,10 @@ export interface CameraPort { look(direction: 'left' | 'right' | 'up' | 'down', radians: number): void; rotateAround(axis: Vec3, radians: number): void; zoom(metres: number): void; + /** + * Follow `target`, or stop following with null; `followChanged` fires + * whenever `followed` changes, and only then. + */ follow(target: FollowTarget | null): void; readonly followed: FollowTarget | null; /** Whether the follow has placed its first frame. */ diff --git a/src/render/stub/camera.js b/src/render/stub/camera.js index 865f66d94..26927a16b 100644 --- a/src/render/stub/camera.js +++ b/src/render/stub/camera.js @@ -681,10 +681,11 @@ export function createStubCamera({ function follow(target) { command('follow'); + const changed = followed !== target; followed = target; followFramed = false; if (!target) setFrame(IDENTITY); - emit('followChanged'); + if (changed) emit('followChanged'); requestRender('follow'); }