mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-07 11:54:02 +00:00
Follow aircraft and satellites through the camera port
Flights and military follow their tracked aircraft with an entityFollowTarget: framed in ENU from viewFrom, never closer than 150 m, presentation read from the drawn entity's record. Satellites follow in Cesium's autodetect frame. The layers' own framing (applyTrackedCameraFrame) goes; the port's follow has the same close-range rules, and trackedCamera.js no longer imports Cesium. The port's helper entity draws nothing: its point's outline is transparent too, and click and voice picks of it count as picks of the target's drawn entity. The fake camera binder follows when the fake has Cesium's entity tracking.
This commit is contained in:
1 parent
e5f8e5753a
commit
c6f043d3fd
22 files changed
+291
-332
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
+5
-143
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user