diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b4aece251..d466ebc3a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1400,6 +1400,8 @@ "src/layers/flights/motion.js", "src/layers/flights/policy.js", "src/layers/flights/queries.js", + "src/render/cesium/camera.js", + "src/data/followed.js", "src/layers/flights/rendering.js", "src/layers/flights/icons.js", "src/data/aircraftIconLayer.js", @@ -1414,6 +1416,7 @@ "src/layers/flights/state.js", "src/layers/flights/testing.js", "src/layers/flights/tracking.js", + "src/geo/index.js", "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", "src/layers/flights/snapshotRenderer.js", @@ -1439,7 +1442,16 @@ "src/data/scenePick.js", "src/data/pickRegistry.js", "src/input/dispatcher.js", + "src/render/cesium/follow.js", + "src/render/camera/events.js", + "src/geo/quaternion.js", + "src/geo/celestial.js", + "src/geo/frames.js", + "src/geo/ellipsoid.js", + "src/geo/vector.js", "src/render/cesium/cameraInput.js", + "src/render/cesium/common.js", + "src/render/rendererOf.js", "src/render/cesium/input.js", "src/render/cesium/ctrlWheelZoom.js", "src/render/input/router.js", @@ -1478,6 +1490,9 @@ "src/layers/military/motion.js", "src/layers/military/policy.js", "src/layers/military/queries.js", + "src/render/cesium/camera.js", + "src/geo/index.js", + "src/data/followed.js", "src/layers/military/rendering.js", "src/layers/military/icons.js", "src/data/aircraftIconLayer.js", @@ -1515,7 +1530,17 @@ "src/input/sensorPick.js", "src/data/pickRegistry.js", "src/input/dispatcher.js", + "src/render/cesium/follow.js", + "src/render/camera/events.js", + "src/geo/celestial.js", + "src/geo/quaternion.js", + "src/geo/frames.js", + "src/geo/ellipsoid.js", + "src/geo/vector.js", "src/render/cesium/cameraInput.js", + "src/render/cesium/internals.js", + "src/render/cesium/common.js", + "src/render/rendererOf.js", "src/render/cesium/input.js", "src/render/cesium/ctrlWheelZoom.js", "src/render/input/router.js" @@ -1604,7 +1629,10 @@ "src/data/pickRegistry.js", "src/input/sensorPick.js", "src/input/dispatcher.js", + "src/render/cesium/follow.js", "src/render/cesium/cameraInput.js", + "src/render/cesium/common.js", + "src/render/cesium/internals.js", "src/render/rendererOf.js", "src/render/cesium/input.js", "src/render/cesium/ctrlWheelZoom.js", @@ -1696,7 +1724,10 @@ "src/data/scenePick.js", "src/data/pickRegistry.js", "src/input/dispatcher.js", + "src/render/cesium/follow.js", "src/render/cesium/cameraInput.js", + "src/render/cesium/internals.js", + "src/render/cesium/common.js", "src/render/cesium/input.js", "src/render/cesium/ctrlWheelZoom.js", "src/render/input/router.js" @@ -1707,6 +1738,7 @@ "exports": ["./layers/satellites"], "modules": [ "src/data/satelliteClass.js", + "src/geo/index.js", "src/data/satellitePass.js", "src/sources/tle.js", "src/layers/satellites/catalog.js", @@ -1714,6 +1746,7 @@ "src/layers/satellites/index.js", "src/layers/satellites/ingestion.js", "src/layers/satellites/interaction.js", + "src/data/followed.js", "src/layers/satellites/labels.js", "src/layers/satellites/lifecycle.js", "src/layers/satellites/orbits.js", @@ -1724,6 +1757,7 @@ "src/layers/satellites/state.js", "src/layers/satellites/testing.js", "src/layers/satellites/tracking.js", + "src/render/cesium/camera.js", "src/data/inputOwnership.js", "src/i18n/index.js", "src/i18n/locale.js", @@ -1744,7 +1778,17 @@ "src/data/scenePick.js", "src/data/pickRegistry.js", "src/input/dispatcher.js", + "src/render/cesium/follow.js", + "src/render/camera/events.js", + "src/geo/celestial.js", + "src/geo/ellipsoid.js", + "src/geo/quaternion.js", + "src/geo/frames.js", + "src/geo/vector.js", "src/render/cesium/cameraInput.js", + "src/render/cesium/common.js", + "src/render/rendererOf.js", + "src/render/cesium/internals.js", "src/render/cesium/input.js", "src/render/cesium/ctrlWheelZoom.js", "src/render/input/router.js" @@ -2042,7 +2086,10 @@ "src/data/scenePick.js", "src/data/pickRegistry.js", "src/input/dispatcher.js", + "src/render/cesium/follow.js", "src/render/cesium/cameraInput.js", + "src/render/cesium/internals.js", + "src/render/cesium/common.js", "src/render/cesium/input.js", "src/render/cesium/ctrlWheelZoom.js", "src/render/input/router.js", @@ -4347,7 +4394,10 @@ "exports": ["./input"], "modules": [ "src/input/dispatcher.js", + "src/render/cesium/follow.js", "src/render/cesium/cameraInput.js", + "src/render/cesium/internals.js", + "src/render/cesium/common.js", "src/render/cesium/input.js", "src/render/cesium/ctrlWheelZoom.js", "src/render/input/router.js", @@ -4625,7 +4675,10 @@ "src/input/sensorPick.js", "src/data/pickRegistry.js", "src/input/dispatcher.js", + "src/render/cesium/follow.js", "src/render/cesium/cameraInput.js", + "src/render/cesium/common.js", + "src/render/cesium/internals.js", "src/render/cesium/input.js", "src/render/cesium/ctrlWheelZoom.js", "src/render/input/router.js" @@ -5033,7 +5086,9 @@ "src/data/scenePick.js", "src/data/pickRegistry.js", "src/input/dispatcher.js", + "src/render/cesium/follow.js", "src/render/cesium/cameraInput.js", + "src/render/cesium/common.js", "src/render/cesium/input.js", "src/render/cesium/ctrlWheelZoom.js", "src/render/input/router.js", @@ -5105,7 +5160,10 @@ "src/data/scenePick.js", "src/data/pickRegistry.js", "src/input/dispatcher.js", + "src/render/cesium/follow.js", "src/render/cesium/cameraInput.js", + "src/render/cesium/common.js", + "src/render/cesium/internals.js", "src/render/cesium/input.js", "src/render/cesium/ctrlWheelZoom.js", "src/render/input/router.js" @@ -5210,7 +5268,10 @@ "src/input/sensorPick.js", "src/data/pickRegistry.js", "src/input/dispatcher.js", + "src/render/cesium/follow.js", "src/render/cesium/cameraInput.js", + "src/render/cesium/common.js", + "src/render/cesium/internals.js", "src/render/rendererOf.js", "src/data/entityRecords.js", "src/render/cesium/input.js", diff --git a/src/data/flights.test.mjs b/src/data/flights.test.mjs index 0c911b8fa..9100a140a 100644 --- a/src/data/flights.test.mjs +++ b/src/data/flights.test.mjs @@ -3,6 +3,8 @@ // Pure function — no viewer/DOM needed; imported directly. import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { followedOf } from './followed.js'; +import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs'; import * as Cesium from 'cesium'; import flightsLayer, { _addFlightTrackingCandidateForTest, @@ -439,6 +441,7 @@ test('real civil track path creates no native label and publishes every cached h }, }); viewer.scene.camera = viewer.camera; + bindFakeCesiumCamera(viewer); const billboard = { position, rotation: 0.3, @@ -512,7 +515,7 @@ test('real civil track path creates no native label and publishes every cached h flightsLayer.trackById(icao24, { origin: 'programmatic' }), true, ); - const entity = viewer.trackedEntity; + const entity = followedOf(viewer)?.entity; assert.ok( entity instanceof Cesium.Entity, 'trackById must create the real Cesium entity', @@ -554,7 +557,7 @@ test('real civil track path creates no native label and publishes every cached h assert.equal(flightsLayer.refocusTrackedById(icao24), true); viewer.scene.preUpdate.raiseEvent(); assert.equal( - viewer.trackedEntity, + followedOf(viewer)?.entity, entity, 'refocus must retain the exact tracked entity', ); diff --git a/src/data/militaryFlights.test.mjs b/src/data/militaryFlights.test.mjs index ee840f70d..c6415c43a 100644 --- a/src/data/militaryFlights.test.mjs +++ b/src/data/militaryFlights.test.mjs @@ -3,6 +3,8 @@ // Pure function — no viewer/DOM needed; imported directly. import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { followedOf } from './followed.js'; +import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs'; import * as Cesium from 'cesium'; import militaryFlightsLayer, { _addMilitaryTrackingCandidateForTest, @@ -367,6 +369,7 @@ test('real military track path creates no native label and publishes every cache isDestroyed: () => false, }; viewer.scene.camera = viewer.camera; + bindFakeCesiumCamera(viewer); Object.defineProperty(viewer, 'trackedEntity', { get: () => trackedEntity, set(value) { @@ -434,7 +437,7 @@ test('real military track path creates no native label and publishes every cache militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), true, ); - const entity = viewer.trackedEntity; + const entity = followedOf(viewer)?.entity; assert.ok( entity instanceof Cesium.Entity, 'trackById must create the real Cesium entity', @@ -482,7 +485,7 @@ test('real military track path creates no native label and publishes every cache assert.equal(militaryFlightsLayer.refocusTrackedById(icao24), true); viewer.scene.preUpdate.raiseEvent(); assert.equal( - viewer.trackedEntity, + followedOf(viewer)?.entity, entity, 'refocus must retain the exact tracked entity', ); diff --git a/src/data/satellitesTrackedRefresh.test.mjs b/src/data/satellitesTrackedRefresh.test.mjs index 9a6e3b26e..fdb99a198 100644 --- a/src/data/satellitesTrackedRefresh.test.mjs +++ b/src/data/satellitesTrackedRefresh.test.mjs @@ -16,6 +16,8 @@ import { _trackIssForTest, createIssOverlayEntry, } from './satellites.js'; +import { followedOf } from './followed.js'; +import { bindRenderer } from '../render/rendererOf.js'; import satellitesLayer from './satellites.js'; import { createTrackedOverlayEntry } from './trackedReadout.js'; import { readEntityRecord, withEntityRecord } from './entityRecords.js'; @@ -25,6 +27,22 @@ const L1 = const L2 = '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537'; + +/** A camera port that only follows, for viewers without a Cesium camera. */ +function followingPort() { + let followed = null; + return { + on: () => () => {}, + get followed() { + return followed; + }, + follow(target) { + followed = target; + }, + cancelFlight() {}, + }; +} + test('partial and dense catalogs do not make an early false missing decision', async () => { _setSatelliteTrackingRefreshOutcomeForTest({ status: 'partial', @@ -90,7 +108,7 @@ test('satellite first update aborts every parallel catalog request', async () => } }); -test('selected satellite params survive delayed arrival and yield to newer explicit intent', () => { +test('selected satellite params survive delayed arrival and yield to newer explicit intent', (t) => { const point = { position: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 420_000), show: true, @@ -108,6 +126,7 @@ test('selected satellite params survive delayed arrival and yield to newer expli primitives: { remove() {} }, }, }; + t.after(bindRenderer(viewer, { camera: followingPort() })); _setSatelliteLabelLifecycleStateForTest({ viewer, satrec: twoline2satrec(L1, L2), @@ -118,7 +137,7 @@ test('selected satellite params survive delayed arrival and yield to newer expli satellitesLayer.setParams({ selectedSatTrackingId: 25544 }), ); assert.equal(satellitesLayer.getParams().selectedSatTrackingId, 25544); - assert.equal(readEntityRecord(viewer.trackedEntity)?.trackedId, 'satellites:25544'); + assert.equal(followedOf(viewer)?.key, 'satellites:25544'); satellitesLayer.stopTracking(); _removeSatelliteTrackingCandidateForTest(25544); @@ -288,7 +307,7 @@ test('satellite pre-render refreshes the tracked altitude on each propagated fra ); }); -test('ISS ambient and tracked lifecycle uses cached host entries with no native labels or duplicate text', () => { +test('ISS ambient and tracked lifecycle uses cached host entries with no native labels or duplicate text', (t) => { const calls = []; const overlayHost = { setEntries: (...args) => calls.push(['entries', ...args]), @@ -312,6 +331,7 @@ test('ISS ambient and tracked lifecycle uses cached host entries with no native primitives: { remove() {} }, }, }; + t.after(bindRenderer(viewer, { camera: followingPort() })); let cleaned = false; _setSatelliteLabelLifecycleStateForTest({ viewer, diff --git a/src/data/trackedCamera.js b/src/data/trackedCamera.js index 622bcb833..4f6f26699 100644 --- a/src/data/trackedCamera.js +++ b/src/data/trackedCamera.js @@ -1,9 +1,8 @@ -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; +/** + * Closest a follow camera comes to a tracked aircraft, metres: the camera + * port's `minRange` for the aircraft layers' FollowTargets. + */ +export const FOLLOW_MIN_RANGE_M = 150; /** * Preserve real-world model scale except when a very close tracked camera @@ -47,140 +46,3 @@ export function trackedModelScaleForPixelCap({ if (projectedDiameterPx <= maximumPixelSize) return baseScale; return baseScale * (maximumPixelSize / projectedDiameterPx); } - -/** - * Resolve the exact position already consumed by the tracked visual whenever - * the layer exposes one, without advancing its CallbackProperty again. - * - * @param {Cesium.Entity} entity Tracked entity. - * @param {Cesium.JulianDate} time Current viewer time. - * @param {Cesium.Cartesian3} result Destination. - * @returns {Cesium.Cartesian3|undefined} Camera-frame position. - */ -export function trackedDisplayPositionForCamera(entity, time, result) { - const displayedPosition = readEntityRecord(entity)?.displayPosition?.(); - return displayedPosition - ? Cesium.Cartesian3.clone(displayedPosition, result) - : entity?.position?.getValue(time, result); -} - -function acquireStableTrackedZoom(controller, entity) { - return holdFollowZoom(controller, entity, MIN_TRACKED_RANGE_M); -} - -/** - * 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, -) { - return clampFollowEye(camera, previousPosition, minimumRangeM); -} - -/** - * 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 = new Cesium.Matrix4(); - const trackedPosition = new Cesium.Cartesian3(); - const resolvedViewFrom = new Cesium.Cartesian3(); - const cameraOffset = new Cesium.Cartesian3(); - const previousCameraPosition = new Cesium.Cartesian3(); - 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 = Cesium.Transforms.eastNorthUpToFixedFrame( - position, - Cesium.Ellipsoid.WGS84, - trackedTransform, - ); - const offset = - typeof viewFrom.getValue === 'function' - ? viewFrom.getValue(viewer.clock.currentTime, resolvedViewFrom) - : Cesium.Cartesian3.clone(viewFrom, resolvedViewFrom); - if ( - !offset || - Cesium.Cartesian3.magnitudeSquared(offset) < Cesium.Math.EPSILON12 - ) { - stop(); - return; - } - viewer.camera.lookAtTransform(transform, offset); - Cesium.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)) { - Cesium.Cartesian3.clone(viewer.camera.position, cameraOffset); - viewer.camera.lookAtTransform(viewer.camera.transform, cameraOffset); - } - Cesium.Cartesian3.clone(viewer.camera.position, previousCameraPosition); - }); - return stop; -} diff --git a/src/data/trackedCamera.test.mjs b/src/data/trackedCamera.test.mjs index fc436507b..95e542eca 100644 --- a/src/data/trackedCamera.test.mjs +++ b/src/data/trackedCamera.test.mjs @@ -1,12 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; -import { - clampTrackedCameraPosition, - trackedDisplayPositionForCamera, - trackedModelScaleForPixelCap, -} from './trackedCamera.js'; -import { withEntityRecord } from './entityRecords.js'; +import { trackedModelScaleForPixelCap } from './trackedCamera.js'; +import { clampFollowEye as clampTrackedCameraPosition } from '../render/cesium/follow.js'; test('tracked camera origin crossing returns to the prior side at the minimum range', () => { const camera = { @@ -71,22 +67,3 @@ test('tracked model shrinks smoothly when close enough to exceed the pixel cap', assert.ok(scale < 24); assert.ok(Math.abs(projectedDiameterPx - 104) < 1e-9); }); - -test('tracked camera prefers the already-rendered display cache', () => { - let callbackReads = 0; - const cached = new Cesium.Cartesian3(1, 2, 3); - const entity = withEntityRecord({ position: { - getValue: () => { - callbackReads += 1; - return new Cesium.Cartesian3(4, 5, 6); - }, - } }, { displayPosition: () => cached }); - const result = trackedDisplayPositionForCamera( - entity, - Cesium.JulianDate.now(), - new Cesium.Cartesian3(), - ); - assert.deepEqual(result, cached); - assert.notEqual(result, cached); - assert.equal(callbackReads, 0); -}); diff --git a/src/input/dispatcher.js b/src/input/dispatcher.js index 3db77e559..cf43edd2b 100644 --- a/src/input/dispatcher.js +++ b/src/input/dispatcher.js @@ -33,6 +33,7 @@ * A dispatcher is made per scene on first use ({@link inputFor}) and lives * until {@link InputDispatcher.destroy}; the application scene owns it. */ +import { pickThroughFollowHelper } from '../render/cesium/follow.js'; import * as Cesium from 'cesium'; import { isLeaseCurrent, isPointerFree } from '../data/inputOwnership.js'; import { acquireCameraInput } from '../render/cesium/cameraInput.js'; @@ -310,7 +311,7 @@ function createClickEvent(viewer, click, gesture) { position, gesture, get pick() { - return once('pick', () => scene.pick(position)); + return once('pick', () => pickThroughFollowHelper(scene.pick(position))); }, get hit() { return once('hit', () => @@ -335,7 +336,7 @@ function createClickEvent(viewer, click, gesture) { }, pickWindow(width, height) { return once(`window:${width}x${height}`, () => - scene.pick(position, width, height), + pickThroughFollowHelper(scene.pick(position, width, height)), ); }, drill(limit = DRILL_DEPTH, width, height) { @@ -350,7 +351,9 @@ function createClickEvent(viewer, click, gesture) { width === undefined && height === undefined ? Math.max(limit, DRILL_DEPTH) : limit; - const hits = scene.drillPick(position, depth, width, height) || []; + const hits = (scene.drillPick(position, depth, width, height) || []) + .map(pickThroughFollowHelper) + .filter(Boolean); drills.set(key, { limit: depth, hits }); return hits.slice(0, limit); }, diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index 83cbdf726..c7668321f 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -1,3 +1,5 @@ +import { cameraOf } from '../../render/cesium/camera.js'; +import { followedOf } from '../../data/followed.js'; import { approxDistanceKm as _approxDistanceKm } from './recordPolicy.js'; import { displayedKinematics } from '../../data/motionModel.js'; import * as Cesium from 'cesium'; @@ -28,7 +30,6 @@ export function createQueries({ const { tr3bTypeLabel, tr3bAircraftClass } = services.aircraftPresentation; const { formatFlightLevel } = services.labels; const { isMilitaryIcao } = services.militaryRegistry; - const { applyTrackedCameraFrame } = services.camera; const { setFocusDeemphasisParams, getFocusDeemphasisParams, @@ -828,18 +829,13 @@ export function createQueries({ if ( id !== flightState._trackedIcao || !flightState._viewer.entities?.contains?.(flightState._trackedEntity) || - flightState._viewer.trackedEntity !== flightState._trackedEntity + followedOf(flightState._viewer) !== flightState._followTarget ) return false; - flightState._trackedCameraFrameStop?.(); - flightState._viewer.camera.cancelFlight(); - flightState._viewer.trackedEntity = flightState._trackedEntity; - flightState._trackedCameraFrameStop = - applyTrackedCameraFrame( - flightState._viewer, - flightState._trackedEntity, - flightState._trackedEntity.viewFrom, - ) || null; + // Following the same target again frames it afresh. + const camera = cameraOf(flightState._viewer); + camera.cancelFlight(); + camera.follow(flightState._followTarget); parts.tracking._publishTrackedSelection(id, origin); return true; }, diff --git a/src/layers/flights/state.js b/src/layers/flights/state.js index 7486cf34b..efb20c03b 100644 --- a/src/layers/flights/state.js +++ b/src/layers/flights/state.js @@ -150,15 +150,13 @@ export function createFlightState({ source, services }) { flightState._trackedEntity = null; - /** Disposes the single active tracked-camera framing owner. */ - - flightState._trackedCameraFrameStop = null; + /** The FollowTarget the camera follows the tracked aircraft with. */ + flightState._followTarget = null; /** @type {Cesium.Model|null} Standalone 3D model for the tracked aircraft. Deliberately NOT a - * graphic on _trackedEntity: viewer.trackedEntity derives the follow-camera from the entity's - * bounding sphere, and a model graphic reports PENDING until its glTF loads — which stalls (or, on - * 3D-toggle, freezes) the centering. A pure-billboard entity always supplies a ready sphere; the - * model rides in _modelCollection and is driven per-frame, fully decoupled from the camera. */ + * graphic on _trackedEntity: a model graphic reports PENDING until its glTF loads, and the + * tracked entity must stay a plain, always-ready billboard. The model rides in _modelCollection + * and is driven per-frame, fully decoupled from the camera, which follows its own target. */ flightState._trackedModel = null; diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index 721f5b2d3..9b910d665 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -1,3 +1,9 @@ +import { cameraOf } from '../../render/cesium/camera.js'; +import { + entityFollowTarget, + onFollowChanged, + stopFollowing, +} from '../../data/followed.js'; import * as Cesium from 'cesium'; import { formatNumber, t } from '../../i18n/index.js'; import { unitLabel } from '../../ui/unitLabels.js'; @@ -26,7 +32,7 @@ import { TRACKED_BILLBOARD_SCALE_BY_DISTANCE, } from './policy.js'; import { roundHeadingDegrees } from '../../data/headingDegrees.js'; -import { entityRecord, readEntityRecord } from '../../data/entityRecords.js'; +import { entityRecord } from '../../data/entityRecords.js'; export function createTracking({ flightState, @@ -49,7 +55,7 @@ export function createTracking({ const { clearFocusTarget } = services.focus; const { isMilitaryLayerActive, isMilitaryIcao } = services.militaryRegistry; const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout; - const { applyTrackedCameraFrame } = services.camera; + const { FOLLOW_MIN_RANGE_M } = services.camera; const { resolvePickId, isOwnedByOtherLayer } = services.picking; const flightsLayer = layer; @@ -615,7 +621,7 @@ export function createTracking({ * aged-out plane, layer disable, and voice stopTracking. * * @param {boolean} [skipViewerUntrack=false] - ANOTHER layer just grabbed the - * follow-camera: tear down our own state but leave viewer.trackedEntity + * follow-camera: tear down our own state but leave the camera's follow * alone (the new owner controls it). * @param {object} [options] - Clear origin. * @param {boolean} [options.evicted=false] - The contact aged out of the feed @@ -628,8 +634,7 @@ export function createTracking({ skipViewerUntrack = false, { evicted = false, origin = 'programmatic' } = {}, ) { - flightState._trackedCameraFrameStop?.(); - flightState._trackedCameraFrameStop = null; + flightState._followTarget = null; if (!flightState._trackedIcao) { clearFocusTarget('flights'); return; @@ -642,12 +647,12 @@ export function createTracking({ const trackedCourseDeg = flightState._displayCourse.get(clearedIcao); // Stop tracking and remove the entity. skipViewerUntrack: when ANOTHER layer just grabbed the - // follow-camera, we tear down our own state but must NOT clear viewer.trackedEntity (the new owner + // follow-camera, we tear down our own state but must NOT clear the camera's follow (the new owner // controls it now — clearing it would yank the camera off their plane). Releasing trackedEntity // does NOT move the camera: Cesium resets the lookAt transform in place, so the view stays where // the follow left it and the user can immediately orbit/zoom. if (flightState._viewer && !skipViewerUntrack) { - flightState._viewer.trackedEntity = undefined; + stopFollowing(flightState._viewer); } if (flightState._trackedEntity) { flightState._viewer.entities.remove(flightState._trackedEntity); @@ -933,15 +938,11 @@ export function createTracking({ // resolves into an aircraft. // // The billboard stays SHOWN at all times and is hidden by going TRANSPARENT (alpha 0), not by - // show=false. This is deliberate: viewer.trackedEntity derives the follow-camera framing from - // the entity's bounding sphere, and a 3D model graphic reports a PENDING sphere until its glTF - // finishes loading. If we hid the billboard outright, tracking a plane while already zoomed in - // would stall the centering until the model loaded. A shown-but-transparent billboard always - // supplies a ready sphere, so framing is instant; we only drop its alpha once the model GLB is - // preloaded (_planeModelLoaded), so there's neither a billboard+model double-image nor a gap. + // show=false: it drops its alpha only once the model GLB is preloaded (_planeModelLoaded), so + // there's neither a billboard+model double-image nor a gap. The camera follows its own target + // through the camera port, so nothing here frames the camera. // The tracked entity is a PURE BILLBOARD — no label or model graphic. The 3D model for the - // tracked plane is a standalone primitive driven by _updateTrackedModel(); keeping it off the - // entity is what makes the follow-camera's bounding sphere always ready (see _trackedModel). + // tracked plane is a standalone primitive driven by _updateTrackedModel(); it stays off the entity (see _trackedModel). // Keep Cesium's generated entity ID: re-init without destroy can temporarily // overlap collections, and an explicit ICAO ID would throw on that duplicate. // What the tracked billboard is drawn at, kept in its entity record so the @@ -957,11 +958,6 @@ export function createTracking({ }; flightState._trackedEntity = flightState._viewer.entities.add({ position: positionProperty, - // Force Cesium's built-in EntityView and our close-range camera guard to - // use the same local frame. AUTO can select a velocity frame while the - // model matrix uses aircraft orientation; alternating between those - // frames makes the target oscillate forward/back on screen. - trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU, billboard: { image: aircraftIcon( parts.rendering._iconKind( @@ -1028,7 +1024,7 @@ export function createTracking({ Math.max((info.altitude || 1500) * 1.1 + 2500, 3000), 30000, ); - flightState._trackedEntity.viewFrom = new Cesium.Cartesian3( + const viewFrom = new Cesium.Cartesian3( 0, -followRange * 0.8, followRange * 0.55, @@ -1049,15 +1045,18 @@ export function createTracking({ entityRecord(flightState._trackedEntity).visualPosition = parts.motion._trackedVisualCached; refreshTrackedReadout(flightState._trackedEntity); - flightState._viewer.camera.cancelFlight(); - // Camera follows the tracked entity - flightState._viewer.trackedEntity = flightState._trackedEntity; - flightState._trackedCameraFrameStop = - applyTrackedCameraFrame( - flightState._viewer, - flightState._trackedEntity, - flightState._trackedEntity.viewFrom, - ) || null; + // The camera follows through its port: a target framed from viewFrom in + // ENU, never closer than the readable minimum, whose presentation (key, + // label, sprite, display position) reads the tracked entity's record. + const camera = cameraOf(flightState._viewer); + camera.cancelFlight(); + flightState._followTarget = entityFollowTarget(flightState._trackedEntity, { + position: getTrackedPosition, + frame: 'enu', + viewFrom, + minRange: FOLLOW_MIN_RANGE_M, + }); + camera.follow(flightState._followTarget); // Track-history trail (PRD F1): seed from local history + async backfill. // Ground traffic draws NO trail (a taxi path is noise, not a track) — if the @@ -1143,25 +1142,25 @@ export function createTracking({ if (flightState._clickSubscription) return; // already installed // Cross-layer untrack: if ANOTHER layer (military, vessels, …) grabs the follow-camera, drop our - // tracking so its model/entity/update-loop don't orphan — without touching viewer.trackedEntity + // tracking so its model/entity/update-loop don't orphan — without touching the camera's follow // (the new owner controls it). Guarded so the intermediate untrack→retrack of OUR OWN switch - // (viewer.trackedEntity briefly undefined) doesn't self-clear. + // (the follow briefly ended) doesn't self-clear. if (!flightState._trackedEntityChangedRemove) { - flightState._trackedEntityChangedRemove = - viewer.trackedEntityChanged.addEventListener(() => { + flightState._trackedEntityChangedRemove = onFollowChanged( + viewer, + (followed) => { if ( flightState._trackedIcao && flightState._viewer && - flightState._viewer.trackedEntity && - flightState._viewer.trackedEntity !== flightState._trackedEntity + followed && + followed !== flightState._followTarget ) { _clearTracking(true, { - origin: - readEntityRecord(flightState._viewer.trackedEntity) - ?.selectionOrigin || 'programmatic', + origin: followed.selectionOrigin || 'programmatic', }); } - }); + }, + ); } flightState._clickSubscription = inputFor(viewer).onClick((event) => { diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index 8e4710a6e..b20bd00fd 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -1,3 +1,5 @@ +import { cameraOf } from '../../render/cesium/camera.js'; +import { followedOf } from '../../data/followed.js'; import { displayedKinematics } from '../../data/motionModel.js'; import { t } from '../../i18n/index.js'; import * as Cesium from 'cesium'; @@ -21,7 +23,6 @@ export function createQueries({ }) { const { tr3bAircraftClass, tr3bTypeLabel } = services.aircraftPresentation; const { formatFlightLevel } = services.labels; - const { applyTrackedCameraFrame } = services.camera; /** * Coerce a value to a finite number, returning null if not possible. @@ -732,18 +733,13 @@ export function createQueries({ if ( id !== flightState._trackedIcao || !flightState._viewer.entities?.contains?.(flightState._trackedEntity) || - flightState._viewer.trackedEntity !== flightState._trackedEntity + followedOf(flightState._viewer) !== flightState._followTarget ) return false; - flightState._trackedCameraFrameStop?.(); - flightState._viewer.camera.cancelFlight(); - flightState._viewer.trackedEntity = flightState._trackedEntity; - flightState._trackedCameraFrameStop = - applyTrackedCameraFrame( - flightState._viewer, - flightState._trackedEntity, - flightState._trackedEntity.viewFrom, - ) || null; + // Following the same target again frames it afresh. + const camera = cameraOf(flightState._viewer); + camera.cancelFlight(); + camera.follow(flightState._followTarget); parts.tracking._publishTrackedSelection(id, origin); return true; }, diff --git a/src/layers/military/state.js b/src/layers/military/state.js index e6c9d5c2a..50cba4dc7 100644 --- a/src/layers/military/state.js +++ b/src/layers/military/state.js @@ -126,13 +126,12 @@ export function createFlightState({ source, services }) { flightState._trackedEntity = null; - /** Disposes the single active tracked-camera framing owner. */ - - flightState._trackedCameraFrameStop = null; + /** The FollowTarget the camera follows the tracked aircraft with. */ + flightState._followTarget = null; /** @type {Cesium.Model|null} Standalone 3D model for the tracked aircraft — NOT a graphic on - * _trackedEntity, so viewer.trackedEntity's follow-camera always has a ready bounding sphere - * (a model graphic reports PENDING until loaded, which freezes the centering on 3D-toggle). */ + * _trackedEntity, which stays a plain, always-ready billboard (a model graphic reports PENDING + * until loaded); the camera follows its own target. */ flightState._trackedModel = null; diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index 475ac410f..bb106c39e 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -1,3 +1,9 @@ +import { cameraOf } from '../../render/cesium/camera.js'; +import { + entityFollowTarget, + onFollowChanged, + stopFollowing, +} from '../../data/followed.js'; import * as Cesium from 'cesium'; import { t } from '../../i18n/index.js'; import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js'; @@ -26,7 +32,7 @@ import { TRACKED_BILLBOARD_SCALE_BY_DISTANCE, } from './policy.js'; import { roundHeadingDegrees } from '../../data/headingDegrees.js'; -import { entityRecord, readEntityRecord } from '../../data/entityRecords.js'; +import { entityRecord } from '../../data/entityRecords.js'; export function createTracking({ flightState, @@ -48,7 +54,7 @@ export function createTracking({ const { createTrail } = services.trails; const { ensureGeoidReady, geoidHeight } = services.geoid; const { clearFocusTarget } = services.focus; - const { applyTrackedCameraFrame } = services.camera; + const { FOLLOW_MIN_RANGE_M } = services.camera; const { resolvePickId, isOwnedByOtherLayer } = services.picking; function _emitAwarenessEvent(type, detail) { @@ -751,7 +757,7 @@ export function createTracking({ * and voice stopTracking. * * @param {boolean} [skipViewerUntrack=false] - ANOTHER layer just grabbed the - * follow-camera: tear down our own state but leave viewer.trackedEntity + * follow-camera: tear down our own state but leave the camera's follow * alone (the new owner controls it). * @param {object} [options] - Clear origin. * @param {boolean} [options.evicted=false] - The contact aged out of the feed @@ -764,8 +770,7 @@ export function createTracking({ skipViewerUntrack = false, { evicted = false, origin = 'programmatic' } = {}, ) { - flightState._trackedCameraFrameStop?.(); - flightState._trackedCameraFrameStop = null; + flightState._followTarget = null; if (!flightState._trackedIcao) { clearFocusTarget('militaryFlights'); return; @@ -778,11 +783,11 @@ export function createTracking({ const trackedCourseDeg = flightState._displayCourse.get(clearedIcao); // Stop tracking and remove the entity. skipViewerUntrack: another layer just grabbed the - // follow-camera, so tear down our state but DON'T clear viewer.trackedEntity (they own it now). + // follow-camera, so tear down our state but DON'T clear the camera's follow (they own it now). // Releasing trackedEntity does NOT move the camera: Cesium resets the lookAt transform in // place, so the view stays where the follow left it and the user can immediately orbit/zoom. if (flightState._viewer && !skipViewerUntrack) { - flightState._viewer.trackedEntity = undefined; + stopFollowing(flightState._viewer); } if (flightState._trackedEntity) { flightState._viewer.entities.remove(flightState._trackedEntity); @@ -891,15 +896,11 @@ export function createTracking({ // resolves into an aircraft. // // The billboard stays SHOWN at all times and is hidden by going TRANSPARENT (alpha 0), not by - // show=false. This is deliberate: viewer.trackedEntity derives the follow-camera framing from - // the entity's bounding sphere, and a 3D model graphic reports a PENDING sphere until its glTF - // finishes loading. If we hid the billboard outright, tracking a plane while already zoomed in - // would stall the centering until the model loaded. A shown-but-transparent billboard always - // supplies a ready sphere, so framing is instant; we only drop its alpha once the model GLB is - // preloaded (_planeModelLoaded), so there's neither a billboard+model double-image nor a gap. + // show=false: it drops its alpha only once the model GLB is preloaded (_planeModelLoaded), so + // there's neither a billboard+model double-image nor a gap. The camera follows its own target + // through the camera port, so nothing here frames the camera. // The tracked entity is a PURE BILLBOARD — no label or model graphic. The tracked plane's 3D - // model is the standalone primitive driven by _updateTrackedModel(); keeping it off the entity is - // what makes the follow-camera's bounding sphere always ready (see _trackedModel). + // model is the standalone primitive driven by _updateTrackedModel(); it stays off the entity (see _trackedModel). // What the tracked billboard is drawn at, kept in its entity record so the // camera guard and screen annotations size it from Core. const sprite = { @@ -914,9 +915,6 @@ export function createTracking({ }; flightState._trackedEntity = flightState._viewer.entities.add({ position: positionProperty, - // Keep Cesium's EntityView in the same ENU frame as the close-range - // camera guard; AUTO may otherwise alternate with a velocity frame. - trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU, billboard: { image: aircraftIcon( parts.rendering._iconKind( @@ -981,7 +979,7 @@ export function createTracking({ // initial frame with surrounding context. (ENU: east=+X, north=+Y, up=+Z.) const altM = info?.altitudeFt ? info.altitudeFt * 0.3048 : 1500; const followRange = Math.min(Math.max(altM * 1.1 + 2500, 3000), 30000); - flightState._trackedEntity.viewFrom = new Cesium.Cartesian3( + const viewFrom = new Cesium.Cartesian3( 0, -followRange * 0.8, followRange * 0.55, @@ -1000,15 +998,18 @@ export function createTracking({ entityRecord(flightState._trackedEntity).visualPosition = parts.motion._trackedVisualCached; refreshTrackedReadout(flightState._trackedEntity); - flightState._viewer.camera.cancelFlight(); - // Camera follows the tracked entity - flightState._viewer.trackedEntity = flightState._trackedEntity; - flightState._trackedCameraFrameStop = - applyTrackedCameraFrame( - flightState._viewer, - flightState._trackedEntity, - flightState._trackedEntity.viewFrom, - ) || null; + // The camera follows through its port: a target framed from viewFrom in + // ENU, never closer than the readable minimum, whose presentation (key, + // label, sprite, display position) reads the tracked entity's record. + const camera = cameraOf(flightState._viewer); + camera.cancelFlight(); + flightState._followTarget = entityFollowTarget(flightState._trackedEntity, { + position: getTrackedPosition, + frame: 'enu', + viewFrom, + minRange: FOLLOW_MIN_RANGE_M, + }); + camera.follow(flightState._followTarget); // Track-history trail (PRD F2): seed from local history + async backfill. // Ground traffic draws NO trail — if the plane takes off while tracked, the @@ -1050,23 +1051,23 @@ export function createTracking({ if (flightState._clickSubscription) return; // already installed // Cross-layer untrack (mirror of flights): if another layer grabs the follow-camera, drop our - // tracking without touching viewer.trackedEntity (the new owner controls it). + // tracking without touching the camera's follow (the new owner controls it). if (!flightState._trackedEntityChangedRemove) { - flightState._trackedEntityChangedRemove = - viewer.trackedEntityChanged.addEventListener(() => { + flightState._trackedEntityChangedRemove = onFollowChanged( + viewer, + (followed) => { if ( flightState._trackedIcao && flightState._viewer && - flightState._viewer.trackedEntity && - flightState._viewer.trackedEntity !== flightState._trackedEntity + followed && + followed !== flightState._followTarget ) { _clearTracking(true, { - origin: - readEntityRecord(flightState._viewer.trackedEntity) - ?.selectionOrigin || 'programmatic', + origin: followed.selectionOrigin || 'programmatic', }); } - }); + }, + ); } flightState._clickSubscription = inputFor(viewer).onClick((event) => { diff --git a/src/layers/satellites/interaction.js b/src/layers/satellites/interaction.js index 40dc7f20c..5c84eb372 100644 --- a/src/layers/satellites/interaction.js +++ b/src/layers/satellites/interaction.js @@ -1,5 +1,5 @@ import { inputFor } from '../../input/dispatcher.js'; -import { readEntityRecord } from '../../data/entityRecords.js'; +import { onFollowChanged } from '../../data/followed.js'; export function createInteraction({ state: layerState, @@ -25,22 +25,22 @@ export function createInteraction({ // new owner controls it). Guarded so our OWN switch (viewer.trackedEntity // briefly undefined mid-_trackSatellite) doesn't self-clear. if (!layerState._trackedEntityChangedRemove) { - layerState._trackedEntityChangedRemove = - viewer.trackedEntityChanged.addEventListener(() => { + layerState._trackedEntityChangedRemove = onFollowChanged( + viewer, + (followed) => { if (!layerState._enabled) return; if ( layerState._trackedNorad && layerState._viewer && - layerState._viewer.trackedEntity && - layerState._viewer.trackedEntity !== layerState._trackedEntity + followed && + followed !== layerState._followTarget ) { parts.tracking._clearTracking(true, { - origin: - readEntityRecord(layerState._viewer.trackedEntity) - ?.selectionOrigin || 'programmatic', + origin: followed.selectionOrigin || 'programmatic', }); } - }); + }, + ); } layerState._clickSubscription = inputFor(viewer).onClick((event) => { diff --git a/src/layers/satellites/state.js b/src/layers/satellites/state.js index c47857fe4..0838377f9 100644 --- a/src/layers/satellites/state.js +++ b/src/layers/satellites/state.js @@ -83,6 +83,8 @@ export function createState({ services }) { }; state._trackedEntity = null; + /** The FollowTarget the camera follows the tracked satellite with. */ + state._followTarget = null; state._clickSubscription = null; diff --git a/src/layers/satellites/tracking.js b/src/layers/satellites/tracking.js index c39ed0aac..b04a59b54 100644 --- a/src/layers/satellites/tracking.js +++ b/src/layers/satellites/tracking.js @@ -1,3 +1,5 @@ +import { cameraOf } from '../../render/cesium/camera.js'; +import { entityFollowTarget, stopFollowing } from '../../data/followed.js'; import * as Cesium from 'cesium'; import { satelliteClassLabel } from '../../data/satelliteClass.js'; import { t } from '../../i18n/index.js'; @@ -113,7 +115,8 @@ export function createTracking({ state: layerState, services, parts, source }) { parts.rendering._hideOrbitPath(layerState._trackedNorad); } if (layerState._viewer && !skipViewerUntrack) - layerState._viewer.trackedEntity = undefined; + stopFollowing(layerState._viewer); + layerState._followTarget = null; if (layerState._trackedEntity) { layerState._viewer.entities.remove(layerState._trackedEntity); layerState._trackedEntity = null; @@ -425,10 +428,14 @@ export function createTracking({ state: layerState, services, parts, source }) { // Tracked entity position propagates per evaluation through the per-frame // cache (WS-D2) — dot, host readout, and camera share one SGP4 epoch per frame. // Falls back to the point primitive's 1s-throttled position if SGP4 fails. - const positionProperty = new Cesium.CallbackProperty(() => { + const trackedPosition = () => { const pos = _getTrackedFramePosition(); return pos ? layerState._trackedFrameCartesian : point.position; - }, false); + }; + const positionProperty = new Cesium.CallbackProperty( + trackedPosition, + false, + ); const name = sat.name.trim(); @@ -473,7 +480,14 @@ export function createTracking({ state: layerState, services, parts, source }) { selectTrackedSubjectContext(_contextSubjectMetadata(noradId, initialPos)); layerState._contextRefreshedAtMs = Date.now(); - layerState._viewer.trackedEntity = layerState._trackedEntity; + // Cesium's own frame for a fast mover (AUTODETECT), as the tracked entity + // had; no close-range rules at orbital distances. + layerState._followTarget = entityFollowTarget(layerState._trackedEntity, { + position: trackedPosition, + frame: 'autodetect', + viewFrom, + }); + cameraOf(layerState._viewer).follow(layerState._followTarget); console.log(`[Data:Satellites] Tracking ${name} (NORAD ${noradId})`); } return { diff --git a/src/navigationPolicy.js b/src/navigationPolicy.js index f69cb54c0..b317d07b5 100644 --- a/src/navigationPolicy.js +++ b/src/navigationPolicy.js @@ -50,8 +50,8 @@ function cockpitRefusal(noun) { /** * Announce that a layer-owned camera flight is taking navigation authority. * - * Aircraft focus reaches the stamp for free: it assigns `viewer.trackedEntity`, - * and the UI stamps on `trackedEntityChanged`. Vessel and installation focus + * Aircraft focus reaches the stamp for free: the camera follows the aircraft, + * and the UI stamps when what it follows changes. Vessel and installation focus * fly the camera WITHOUT ever setting a tracked entity, so they have no such * seam — an earlier deferred geocode would still match the generation it * captured and could resolve on top of the new Context focus. This is that diff --git a/src/render/cesium/follow.js b/src/render/cesium/follow.js index 5d9655c00..e979daf77 100644 --- a/src/render/cesium/follow.js +++ b/src/render/cesium/follow.js @@ -23,6 +23,21 @@ import { trackedEntityViewEntity } from './internals.js'; const MAX_FRAME_ATTEMPTS = 120; const TRANSPARENT = new Cesium.Color(0, 0, 0, 0); const ZOOM_STATES = new WeakMap(); +/** Each helper entity's FollowTarget. */ +const HELPER_TARGETS = new WeakMap(); + +/** + * A pick as the follow's helper entity should not make it: a hit on a helper + * is a hit on its target's drawn `entity`, or no hit when it has none. Any + * other pick comes back as it was. + * @param {object|undefined} picked What `scene.pick` or `drillPick` gave. + * @returns {object|undefined} + */ +export function pickThroughFollowHelper(picked) { + const target = picked?.id && HELPER_TARGETS.get(picked.id); + if (!target) return picked; + return target.entity ? { ...picked, id: target.entity } : undefined; +} /** * Turn zoom coasting off and raise the zoom floor to `minRange` while any @@ -186,14 +201,20 @@ export function createCesiumFollow({ widget, scene, camera, onChanged }) { : target.viewFrom; function helperFor(target) { - return widget.entities.add({ + const helper = 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 }, + // Outline too: Cesium blends an opaque default outline into the edge. + point: { + pixelSize: 1, + color: TRANSPARENT, + outlineColor: TRANSPARENT, + outlineWidth: 0, + }, viewFrom: new Cesium.CallbackProperty((time, result) => { const v = viewFromAt(target, time); return v ? startOffset(target, v, result) : v; @@ -203,6 +224,8 @@ export function createCesiumFollow({ widget, scene, camera, onChanged }) { ? Cesium.TrackingReferenceFrame.AUTODETECT : Cesium.TrackingReferenceFrame.ENU, }); + HELPER_TARGETS.set(helper, target); + return helper; } // preUpdate on purpose: in preRender the target's drawing is already diff --git a/src/render/cesium/follow.test.mjs b/src/render/cesium/follow.test.mjs index c26530294..01c6c9a81 100644 --- a/src/render/cesium/follow.test.mjs +++ b/src/render/cesium/follow.test.mjs @@ -9,7 +9,7 @@ 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'; +import { holdFollowZoom, pickThroughFollowHelper } from './follow.js'; function trackingHost() { const { scene, camera } = headlessCamera(); @@ -252,28 +252,21 @@ test('an autodetected follow is framed once Cesium tracks its helper', () => { h.dispose(); }); -test('a port follow and a layer frame hold one controller together', async () => { - const { applyTrackedCameraFrame } = - await import('../../data/trackedCamera.js'); +test('a pick of the helper is a pick of the target’s drawn entity', () => { 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 }); + const drawn = h.widget.entities.add({ position: AUSTIN }); + h.port.follow(target('a', { entity: drawn })); + const helper = h.widget.entities.values.find((e) => e !== drawn); + const primitive = {}; + assert.deepEqual(pickThroughFollowHelper({ id: helper, primitive }), { + id: drawn, + primitive, + }); + h.port.follow(target('b')); + const bare = h.widget.entities.values.find((e) => e !== drawn); + assert.equal(pickThroughFollowHelper({ id: bare }), undefined); + const other = { id: drawn }; + assert.equal(pickThroughFollowHelper(other), other); + assert.equal(pickThroughFollowHelper(undefined), undefined); h.dispose(); }); diff --git a/src/render/cesiumAllowList.json b/src/render/cesiumAllowList.json index 6e77d949b..0f6e2aee5 100644 --- a/src/render/cesiumAllowList.json +++ b/src/render/cesiumAllowList.json @@ -38,7 +38,6 @@ "src/data/modelVisualAnchor.js", "src/data/motionModel.js", "src/data/osmOverlays.js", - "src/data/trackedCamera.js", "src/data/trailRenderer.js", "src/input/dispatcher.js", "src/input/sensorPick.js", diff --git a/src/testSupport/fakeCameraPort.mjs b/src/testSupport/fakeCameraPort.mjs index e5dcfea69..2524a698a 100644 --- a/src/testSupport/fakeCameraPort.mjs +++ b/src/testSupport/fakeCameraPort.mjs @@ -71,7 +71,9 @@ export function fakeCameraPort({ pose = {}, viewRectangle = null } = {}) { * (`flyTo({destination: Cartesian3, ...})`), while poses are read leniently * from what the fake has (`positionCartographic`, `heading`, `pitch`, * `roll`) and events come from the fake's own `changed`, `moveStart` and - * `moveEnd`. Binding a viewer twice keeps the first. Returns the viewer. + * `moveEnd`. A fake with `trackedEntityChanged` and `entities` follows + * through the port, as the renderer does. Binding a viewer twice keeps the + * first. Returns the viewer. * @template {{camera: object}} V * @param {V} viewer * @returns {V} @@ -119,11 +121,18 @@ export function bindFakeCesiumCamera(viewer) { }); const scene = { postUpdate: noEvent, + preUpdate: viewer.scene?.preUpdate ?? noEvent, + get screenSpaceCameraController() { + return viewer.scene?.screenSpaceCameraController ?? undefined; + }, requestRender: () => viewer.scene?.requestRender?.(), }; + // A fake with Cesium's entity tracking follows as the renderer does. + const tracks = Boolean(viewer.trackedEntityChanged && viewer.entities); const real = createCesiumCamera({ scene, camera, + ...(tracks ? { widget: viewer } : {}), report: (error) => { throw error; }, diff --git a/src/voice/pointerPick.js b/src/voice/pointerPick.js index e74671eda..82679d78b 100644 --- a/src/voice/pointerPick.js +++ b/src/voice/pointerPick.js @@ -1,3 +1,4 @@ +import { pickThroughFollowHelper } from '../render/cesium/follow.js'; import * as Cesium from 'cesium'; import { resolvePickedContext } from '../data/pickedContext.js'; import { cameraPoseKey } from './pointerContext.js'; @@ -34,7 +35,7 @@ export function createPointerPicker({ if (!width || !height) return null; let picked = null; try { - picked = scene.pick(new Cesium.Cartesian2(x, y)); + picked = pickThroughFollowHelper(scene.pick(new Cesium.Cartesian2(x, y))); } catch { picked = null; }