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