orbit-studio / src /modules /SkyView.ts
moncefem's picture
Deploy Orbit Studio propagator
9f21d0a
Raw
History Blame Contribute Delete
31.8 kB
// The sky view: the app's own camera, parked on the ground at the observer and
// aimed upward, so the satellites the globe already draws are seen from below.
//
// This module owns the camera and nothing else. It takes an observer and an aim
// and produces a camera basis; resolving who the observer is, and stopping the
// other things that want to drive the camera, belong to the caller
// (src/modules/sceneSync.ts). Rationale: docs/adr/0003-sky-view.md.
//
// Entering and leaving are flights rather than cuts, which is why the pose the
// camera is given each frame is not always the pose the aim asks for — see
// `#apply`, and src/modules/skyFlight.ts for the interpolation itself.
//
// Two Cesium behaviours shape the implementation:
//
// - `camera.setView` cannot express this. It converts direction/up back into
// heading/pitch/roll, and `getHeading` switches formula within EPSILON3 of
// straight up — above about 87.4° of elevation the roll comes back wrong by
// up to 180°, which mirrors the whole sky. So the basis is assigned
// directly and Cesium is kept out of Euler angles entirely. The same
// applies to `camera.flyTo`, which is why the flight is ours.
// - `ScreenSpaceCameraController` runs its collision detection *outside* the
// `enableInputs` check, so clearing that flag alone still leaves
// `adjustHeightForTerrain` free to lift the camera off the observer on any
// frame it thinks it moved. Both flags come off.
import { Cartesian3, Cartographic, Math as CesiumMath, Matrix3, Matrix4, PerspectiveFrustum, type Scene, SceneMode, Transforms } from "@cesium/engine";
import { flightDuration, type FlightPath, flightPose, newPose, type Pose } from "./skyFlight";
import { type Aim, enuDirection, type Observer, type ObserverFrame, observerFrame, rollBasis } from "./skyGeometry";
export type { Aim, Observer } from "./skyGeometry";
/** An orthonormal camera basis in east-north-up components. */
export interface Basis {
direction: Cartesian3;
up: Cartesian3;
right: Cartesian3;
}
/**
* How far the eye may be above the ground under the observer.
*
* The floor is standing height, so the view cannot be walked under the surface
* it is standing on. The ceiling is where "looking up from a point on the
* ground" stops being a fair description of what is on screen: 5 km clears every
* building and most of the relief anyone stands on — the ground height carries
* the mountain itself — while still leaving the observer inside the weather.
*/
export const MIN_EYE_HEIGHT = 2;
export const MAX_EYE_HEIGHT = 5000;
/**
* How often the ground under a walking observer is measured. Five metres of
* base-speed walking, forty at a sprint — closer than terrain relief changes
* over, and far cheaper than the per-frame request the honest answer would be.
*/
const WALK_MEASURE_MS = 250;
/**
* The range a ground elevation can credibly fall in — roughly the Dead Sea shore
* to rather above Everest, with room to spare at both ends.
*/
const MIN_GROUND_HEIGHT = -500;
const MAX_GROUND_HEIGHT = 9000;
/**
* Whether a surface height can be believed, wherever it came from.
*
* It has to be asked of `globe.getHeight`, because the honest answer for "no tile
* loaded here" is not `undefined`: with the default `EllipsoidTerrainProvider`,
* where the surface is the ellipsoid and the answer is exactly 0, it has been
* observed returning -36990. Taking that at face value puts the camera 37 km
* underground, which stops the tiles under the observer from rendering at all,
* which keeps the answer garbage — the view never recovers on its own.
*
* It is worth asking of a surface model's clamp too, for a different reason: that
* clamps to whatever scene geometry is above the point, and a satellite's own 3D
* model passing overhead is scene geometry.
*/
export const isPlausibleGroundHeight = (height: number | undefined): height is number =>
height !== undefined && Number.isFinite(height) && height >= MIN_GROUND_HEIGHT && height <= MAX_GROUND_HEIGHT;
/**
* Where the ground under the observer comes from when the globe cannot say.
*
* The globe is the default and needs no source: `getHeight` answers from tiles
* that are already loaded, every frame, for free. A surface model is neither —
* measuring it is a request, and with the photorealistic mesh the globe is not
* even being drawn, so `getHeight` has nothing to answer from and the eye would
* sit at ellipsoid height, hundreds of metres inside the mesh.
*
* Async, and asked once per observer rather than per frame: the answer needs the
* tiles at that spot loaded, which is a network round trip, and standing still is
* what the sky view does.
*/
export type GroundHeightSource = (observer: Observer) => Promise<number | undefined>;
/**
* Defaults chosen so the first frame is legible rather than empty sky. The
* horizon is on screen because `pitch < fovy / 2`; that invariant is the whole
* guarantee, which is why there is no per-orientation arithmetic here.
*/
export const DEFAULT_FOVY = 75;
export const DEFAULT_PITCH = 30;
/**
* How far the view may zoom, stated as vertical field of view.
*
* 10° at the narrow end is roughly 7.5x magnification, which is what it takes to
* separate two satellites sharing the reticle at the default zoom; below about 5°
* hand tremor under device aiming dominates and it stops being precision. 100° at
* the wide end is as much sky as the perspective will take — on a 21:9 window it
* derives a horizontal `fov` of 141°, and the stretching at the edges is already
* severe there.
*
* Note this deliberately lets the user break `pitch < fovy/2`, which is a
* statement about the defaults on entry and not a standing invariant — zooming in
* on something high up is *supposed* to take the horizon off screen.
*/
export const MIN_FOVY = 10;
export const MAX_FOVY = 100;
/** North is the emptiest direction to open on: passes culminate toward the equator. */
export const defaultAzimuth = (observer: Observer): number => (observer.lat >= 0 ? 180 : 0);
/**
* The camera basis for an aim, in east-north-up components.
*
* Exported because this is the part worth testing: `up` and `right` are derived
* from the aim angles rather than from a cross product against world up, so
* there is no singularity at the zenith and no discontinuity crossing it.
*/
export function skyBasis(aim: Aim): Basis {
return { direction: enuDirection(aim.azimuth, aim.pitch), ...rollBasis(aim.azimuth, aim.pitch, aim.roll) };
}
/**
* The horizontal angle the view actually spans, at any aspect ratio.
*
* Distinct from `fovFromFovy` below, which answers the narrower question of what
* to hand Cesium: on a portrait viewport Cesium's `fov` *is* the vertical angle,
* so it is not the horizontal span and cannot be used as one.
*/
export function fovxFromFovy(fovyRadians: number, aspectRatio: number): number {
if (!Number.isFinite(aspectRatio) || aspectRatio <= 0) {
return fovyRadians;
}
return 2 * Math.atan(Math.tan(fovyRadians * 0.5) * aspectRatio);
}
/**
* Cesium's `fov` is the horizontal angle on a landscape viewport and the
* vertical one otherwise, so it means different things on a phone held two
* ways. Everything here is specified vertically; this converts.
*/
export function fovFromFovy(fovyRadians: number, aspectRatio: number): number {
if (!Number.isFinite(aspectRatio) || aspectRatio <= 1) {
return fovyRadians;
}
return fovxFromFovy(fovyRadians, aspectRatio);
}
/**
* The vertical angle behind a Cesium `fov`, and the exact inverse of
* `fovFromFovy`. Needed on the way in: a flight starts at whatever the globe
* camera's frustum was set to, and the flight interpolates vertical angles.
*/
export function fovyFromFov(fovRadians: number, aspectRatio: number): number {
if (!Number.isFinite(aspectRatio) || aspectRatio <= 1) {
return fovRadians;
}
return 2 * Math.atan(Math.tan(fovRadians * 0.5) / aspectRatio);
}
interface SavedState {
pose: Pose;
/** Cesium's own `fov`, put back verbatim; NaN when the frustum had none. */
fov: number;
requestRenderMode: boolean;
enableInputs: boolean;
enableCollisionDetection: boolean;
depthTestAgainstTerrain: boolean;
}
/**
* Where the view is between the globe and the ground.
*
* `active` covers all three of the non-off states, because the sky view owns the
* camera for the whole of them. `settled` is the narrower question the HUD and
* the crosshair have to ask instead: is the camera where the aim says it is, or
* is it still on its way there?
*/
type Phase = "off" | "entering" | "live" | "leaving";
interface Flight {
/**
* The globe end, the sky end and the straight-down attitude between them. The
* same three objects for as long as the view is up: the sky end and the
* attitude are rewritten under the flight each frame, because the ground under
* the observer is only known once its tiles are in and the aim can still move
* while the camera is on its way.
*/
path: FlightPath;
/** Leaving is the flight in played backwards, so it is one path and a sign. */
reverse: boolean;
startedAt: number;
durationMs: number;
finished: Promise<void>;
/** Resolves `finished`, on landing or on another flight taking over. */
finish: () => void;
}
function beginFlight(path: FlightPath, reverse: boolean, durationMs: number, elapsedMs: number): Flight {
let finish = (): void => {};
const finished = new Promise<void>((resolve) => {
finish = () => resolve();
});
return { path, reverse, startedAt: performance.now() - elapsedMs, durationMs, finished, finish };
}
export class SkyView {
#scene: Scene;
#phase: Phase = "off";
#flight: Flight | undefined;
// Present exactly while the view is active, and the record of what has to be
// put back. Restoring only what was actually changed is what lets the sky
// view coexist with `?bg=false`, which has already destroyed the sky objects
// a blanket restore would try to bring back.
#saved: SavedState | undefined;
// The pose the aim asks for, the same pose tipped all the way down, and the
// pose actually given to the camera. Separate objects because during a flight
// all three differ; the first two are the flight's destination and the
// attitude it aims with, rewritten in place each frame so the descent keeps
// re-aiming at ground that only shows up as tiles load.
#sky: Pose = newPose();
#over: Pose = newPose();
#blended: Pose = newPose();
#observer: Observer | undefined;
// The observer's coordinates, kept in the form `globe.getHeight` wants so the
// per-frame ground lookup allocates nothing.
#observerCartographic = new Cartographic();
// Sea level until a tile says otherwise, which is the exact answer for the
// default terrain provider and a safe one for every other.
#groundHeight = 0;
// A surface model's answer, once it has one, and the flag that stops the globe
// being consulted as well. Both matter: with OSM Buildings the globe is still
// there and still has an opinion, and the two would fight every frame.
#groundSource: GroundHeightSource | undefined;
#groundMeasured = false;
/** Which observer the outstanding measurement is about. */
#groundGeneration = 0;
/** When the ground was last asked about, for the walk's throttle. */
#measuredAt = Number.NEGATIVE_INFINITY;
#aim: Aim = { azimuth: 0, pitch: DEFAULT_PITCH, roll: 0 };
#eyeHeight: number = MIN_EYE_HEIGHT;
#fovy: number = DEFAULT_FOVY;
// Rebuilt only when the observer or the ground under it moves, which is rare;
// everything that reads it wants it every frame.
#frame: ObserverFrame | undefined;
#removePreRender: (() => void) | undefined;
constructor(scene: Scene) {
this.#scene = scene;
}
/** Whether the sky view owns the camera — true throughout both flights. */
get active(): boolean {
return this.#phase !== "off";
}
/**
* Whether the camera has arrived. Anything that reads the aim to say something
* about the picture — the HUD's tapes, the crosshair — has to wait for this:
* during a flight the aim is the destination, not where the camera is looking.
*/
get settled(): boolean {
return this.#phase === "live";
}
get observer(): Observer | undefined {
return this.#observer;
}
get aim(): Readonly<Aim> {
return this.#aim;
}
/** The observer's local frame, for anything measuring angles against it. */
get frame(): ObserverFrame | undefined {
return this.#frame;
}
get fovy(): number {
return this.#fovy;
}
/** Clamped here rather than at each caller: it is a property of the view. */
set fovy(degrees: number) {
this.#fovy = CesiumMath.clamp(degrees, MIN_FOVY, MAX_FOVY);
this.#apply();
}
/** How far the eye is above the ground under the observer, in metres. */
get eyeHeight(): number {
return this.#eyeHeight;
}
/**
* Lift the eye off the ground, or set it back down. Clamped here rather than
* at the caller, for the same reason `fovy` is: it is a property of the view.
*/
set eyeHeight(metres: number) {
const height = CesiumMath.clamp(metres, MIN_EYE_HEIGHT, MAX_EYE_HEIGHT);
if (height === this.#eyeHeight) {
return;
}
this.#eyeHeight = height;
// The frame is built at eye level, and every angle the HUD and the crosshair
// measure is taken against it, so rising is a new frame rather than the same
// one moved.
this.#frame = undefined;
this.#apply();
}
/** Point somewhere else. Omitted angles keep their current value. */
look(aim: Partial<Aim>): void {
this.#aim = { ...this.#aim, ...aim };
this.#apply();
}
/**
* Walk the observer to a nearby point, measuring the ground as it goes.
*
* Distinct from `enter`, which is what a station drag or an arriving fix goes
* through: those are one move each and can afford a measurement outright. This
* one runs every frame for as long as a key is held, so the measurement is
* throttled — and throttled is the whole design, because neither alternative
* works. Per frame is a request per frame. Not at all leaves the eye at the
* height of wherever the walk began, which is underground the moment it heads
* uphill.
*
* What it must not do is fall back to `globe.getHeight` for the walk. That is
* free and follows the terrain, which is why it is the fallback of last resort
* in `#skyPose` — but it answers about the globe, and under a surface model the
* globe is not what is being stood on. With the photorealistic mesh it is not
* even drawn, and its ellipsoid answers 0 plausibly enough to pass the guard,
* which drops the eye through the mesh (docs/manual-verification.md).
*/
moveObserver(observer: Observer): void {
if (!this.#observer) {
return;
}
this.#observer = observer;
Cartographic.fromDegrees(observer.lon, observer.lat, 0, this.#observerCartographic);
this.#frame = undefined;
if (performance.now() - this.#measuredAt >= WALK_MEASURE_MS) {
this.#measureGround();
}
this.#apply();
}
/**
* Take the ground under the observer from somewhere other than the globe, or
* pass `undefined` to go back to the globe.
*
* Re-measures immediately, because this is called when the thing being stood on
* has changed — a surface model appearing or going away — and the height already
* in hand was about the old one.
*/
setGroundHeightSource(source: GroundHeightSource | undefined): void {
this.#groundSource = source;
this.#measureGround();
}
/**
* Ask again what the observer is standing on.
*
* Either because it changed under them — a terrain swapped, a surface model
* arriving or going away — or because they walked off it: `moveObserver`
* measures on a throttle, and the walk ends with the one measurement that is
* not on it.
*/
remeasureGround(): void {
this.#measureGround();
}
/**
* Stand on a height somebody else measured, now.
*
* For the one case an asynchronous source cannot cover: the terrain under the
* observer is being *replaced*, and the height has to change in the same breath as
* the ground does. Left to arrive on its own it lands a beat late, and for that
* beat the eye is under the new surface — 570 m under it in Munich, switching from
* the ellipsoid to World Terrain — which does not read as a lag. It reads as the
* world flipping inside out.
*/
setGroundHeight(height: number): void {
if (!isPlausibleGroundHeight(height)) {
return;
}
// Counted as a measurement so the per-frame globe reads stay out of it: those
// are what turn one honest move into a stagger of them as tiles refine.
this.#groundGeneration += 1;
this.#groundMeasured = true;
if (height !== this.#groundHeight) {
this.#groundHeight = height;
this.#frame = undefined;
}
this.#apply();
this.#scene.requestRender();
}
/**
* Stand at the observer and look up, arriving by flight rather than by cut.
*
* The promise resolves when the camera has landed, or when another flight
* takes over from this one. That is what lets the caller hold the interaction
* back until the aim and the picture agree — see src/modules/sceneSync.ts.
*/
enter(observer: Observer): Promise<void> {
if (this.#phase === "entering" || this.#phase === "live") {
// Re-entering with a different observer is a move, not a second entry: the
// saved globe state is the one from the original entry, and a move that
// flew would turn dragging a ground station marker into a slideshow.
this.#setObserver(observer);
this.#apply();
return this.#flight?.finished ?? Promise.resolve();
}
if (this.#phase === "leaving") {
// Turned around rather than started afresh: the camera is mid-air, and
// `#saved` is still the globe this flight was on its way back to.
this.#reset(observer);
const arrival = this.#fly("entering");
this.#apply();
return arrival;
}
// The sky view is 3D, so entering from 2D or Columbus has to morph first —
// instantly, because the camera is about to be assigned outright and an
// animated morph would spend two seconds fighting it. Without this the basis
// lands in an orthographic projection where it means nothing, and the frustum
// is not a PerspectiveFrustum so there is no `fov` to save or to put back.
if (this.#scene.mode !== SceneMode.SCENE3D) {
this.#scene.morphTo3D(0);
}
const { camera, globe, screenSpaceCameraController: controller } = this.#scene;
this.#saved = {
pose: this.#cameraPose(),
fov: (camera.frustum instanceof PerspectiveFrustum ? camera.frustum.fov : undefined) ?? Number.NaN,
requestRenderMode: this.#scene.requestRenderMode,
enableInputs: controller.enableInputs,
enableCollisionDetection: controller.enableCollisionDetection,
depthTestAgainstTerrain: globe.depthTestAgainstTerrain,
};
this.#reset(observer);
// A leftover reference frame — from `jumpTo`, or from tracking — would
// reinterpret every vector assigned below.
camera.lookAtTransform(Matrix4.IDENTITY);
// Off for the flight as well as for the view: the descent is not something
// to wrestle with, and collision detection would fight it all the way down.
controller.enableInputs = false;
controller.enableCollisionDetection = false;
// The camera is driven from outside Cesium's own input handling, so there
// is nothing for request-render mode to notice.
this.#scene.requestRenderMode = false;
// Let the ground hide what is behind it. Cesium's default clears the globe's
// depth and occludes against an ellipsoid quad, which carries no relief.
globe.depthTestAgainstTerrain = true;
const arrival = this.#fly("entering");
// Re-asserted every frame rather than set once: the ground height under the
// observer is only known after a render, the viewport aspect can change at
// any time, and anything else that grabs the camera loses on the next frame.
this.#removePreRender = this.#scene.preRender.addEventListener(() => this.#apply());
this.#apply();
return arrival;
}
/**
* Fly back to the globe the camera was taken from, and hand it back.
*
* The promise resolves once the globe state is restored — the caller must wait
* for it before morphing the projection or releasing the camera mode, because
* until then this view is still flying the camera.
*/
exit(): Promise<void> {
if (this.#phase === "off") {
return Promise.resolve();
}
if (this.#phase === "leaving") {
return this.#flight?.finished ?? Promise.resolve();
}
if (!this.#saved) {
// Unreachable: every non-off phase has a saved globe to go back to.
this.#restore();
return Promise.resolve();
}
return this.#fly("leaving");
}
#reset(observer: Observer): void {
this.#setObserver(observer);
this.#aim = { azimuth: defaultAzimuth(observer), pitch: DEFAULT_PITCH, roll: 0 };
this.#eyeHeight = MIN_EYE_HEIGHT;
this.#fovy = DEFAULT_FOVY;
}
#setObserver(observer: Observer): void {
this.#observer = observer;
Cartographic.fromDegrees(observer.lon, observer.lat, 0, this.#observerCartographic);
// A different place has a different ground under it, and a different frame — but
// the height it had is kept until the new one is measured, rather than reset to
// sea level. Resetting looks harmless and is not: the observer moves while the
// view is up (dragging a station, or a geolocation fix arriving), and anywhere
// above sea level the eye would spend the measurement underneath the ground.
// From under a surface you see its underside, textured with the same imagery,
// which does not read as a wrong height. It reads as the world inverted.
this.#frame = undefined;
this.#measureGround();
}
/**
* Ask the ground height source about the observer, if there is one.
*
* The generation is what makes a late answer harmless: the observer can move —
* dragging a ground station does exactly that — while a measurement is in
* flight, and that answer is about a place the view has left.
*/
#measureGround(): void {
const generation = ++this.#groundGeneration;
// Stamped here rather than at the walk's own call, so every measurement — the
// walk's, the settle's, a terrain swap — counts against the walk's throttle.
this.#measuredAt = performance.now();
// `#groundMeasured` deliberately survives this. A height measured a moment ago,
// even somewhere slightly else, beats what the globe can offer while tiles are
// still arriving — which is a coarse approximation, then a better one, then a
// better one, each of which would move the camera.
const source = this.#groundSource;
const observer = this.#observer;
if (!source || !observer) {
return;
}
void source(observer).then((height) => {
if (generation !== this.#groundGeneration || !isPlausibleGroundHeight(height)) {
return;
}
this.#groundMeasured = true;
if (height !== this.#groundHeight) {
this.#groundHeight = height;
this.#frame = undefined;
}
// The camera may already be standing at the old height, and nothing else
// will come along to move it: a settled sky view renders on demand.
this.#apply();
this.#scene.requestRender();
});
}
/** Fly the one path, forwards to enter and backwards to leave. */
#fly(phase: "entering" | "leaving"): Promise<void> {
const previous = this.#flight;
this.#phase = phase;
const durationMs = flightDuration();
if (durationMs <= 0) {
// Reduced motion asked for the cut this replaced, so give exactly that
// rather than a brisk version of the flight.
this.#flight = undefined;
previous?.finish();
if (phase === "leaving") {
this.#restore();
} else {
this.#phase = "live";
}
return Promise.resolve();
}
// Turning around resumes the progress already made rather than starting
// over: the path is the one thing both directions share, so playing it the
// other way from here is exactly retracing the trip, and the camera carries
// on from where it is instead of snapping to an end it is nowhere near.
const covered = previous ? CesiumMath.clamp((performance.now() - previous.startedAt) / previous.durationMs, 0, 1) : 1;
const path: FlightPath = previous?.path ?? { from: this.#savedPose(), to: this.#sky, over: this.#over };
this.#flight = beginFlight(path, phase === "leaving", durationMs, durationMs * (1 - covered));
// After the new flight is in place: whoever was awaiting the old one checks
// where things stand the moment this resolves.
previous?.finish();
return this.#flight.finished;
}
/** The globe pose to return to. Only ever called with `#saved` present. */
#savedPose(): Pose {
return this.#saved?.pose ?? this.#cameraPose();
}
#land(): void {
const flight = this.#flight;
this.#flight = undefined;
if (this.#phase === "leaving") {
this.#restore();
} else {
this.#phase = "live";
}
flight?.finish();
}
/** Put the globe back exactly as it was found, and stop touching the camera. */
#restore(): void {
const saved = this.#saved;
// Cesium's Event defers removals raised from inside a dispatch, so this is
// safe even though the landing frame is itself a preRender callback.
this.#removePreRender?.();
this.#removePreRender = undefined;
this.#flight = undefined;
this.#saved = undefined;
this.#observer = undefined;
this.#frame = undefined;
this.#phase = "off";
if (!saved) {
return;
}
const { camera, screenSpaceCameraController: controller } = this.#scene;
camera.lookAtTransform(Matrix4.IDENTITY);
Cartesian3.clone(saved.pose.position, camera.position);
Cartesian3.clone(saved.pose.direction, camera.direction);
Cartesian3.clone(saved.pose.up, camera.up);
Cartesian3.clone(saved.pose.right, camera.right);
// The saved `fov` rather than the pose's vertical angle: this is the number
// that was taken, and putting it back is not a question of aspect ratio.
if (camera.frustum instanceof PerspectiveFrustum && !Number.isNaN(saved.fov)) {
camera.frustum.fov = saved.fov;
}
controller.enableInputs = saved.enableInputs;
controller.enableCollisionDetection = saved.enableCollisionDetection;
this.#scene.requestRenderMode = saved.requestRenderMode;
this.#scene.globe.depthTestAgainstTerrain = saved.depthTestAgainstTerrain;
}
#aspectRatio(): number {
const { clientWidth, clientHeight } = this.#scene.canvas;
return clientHeight > 0 ? clientWidth / clientHeight : 1;
}
/** Where the camera is right now, as a flight endpoint. */
#cameraPose(): Pose {
const { camera } = this.#scene;
const fov = (camera.frustum instanceof PerspectiveFrustum ? camera.frustum.fov : undefined) ?? Number.NaN;
return {
position: Cartesian3.clone(camera.position, new Cartesian3()),
direction: Cartesian3.clone(camera.direction, new Cartesian3()),
up: Cartesian3.clone(camera.up, new Cartesian3()),
right: Cartesian3.clone(camera.right, new Cartesian3()),
// A frustum with no `fov` gives the flight nothing to interpolate, so it
// starts at the angle it will end on and only the pose moves.
fovy: Number.isNaN(fov) ? this.#fovy : CesiumMath.toDegrees(fovyFromFov(fov, this.#aspectRatio())),
};
}
/**
* The pose the aim asks for, written into `#sky`, and the same aim tipped all
* the way down, written into `#over`. Refreshes the ground and the frame.
*/
#skyPose(observer: Observer): Pose {
const pose = this.#sky;
// Stand on the ground rather than on the ellipsoid, which is hundreds of
// metres out in the mountains. The last believable answer is kept, so an
// implausible one — which is how a missing tile reports itself — leaves the
// camera where it was instead of dropping it through the surface.
//
// Skipped once a surface model has answered: that model is what is being
// stood on, and the globe underneath it — still loaded and still opinionated
// under OSM Buildings — would pull the eye back down to the street every frame.
if (!this.#groundMeasured) {
const measured = this.#scene.globe.getHeight(this.#observerCartographic);
if (isPlausibleGroundHeight(measured) && measured !== this.#groundHeight) {
this.#groundHeight = measured;
this.#frame = undefined;
}
}
Cartesian3.fromDegrees(observer.lon, observer.lat, this.#groundHeight + this.#eyeHeight, undefined, pose.position);
// Built from where the observer stands, never from `camera.position`, which
// during a flight is somewhere over the ocean on the way here.
this.#frame ??= observerFrame(pose.position);
const enu = Transforms.eastNorthUpToFixedFrame(pose.position, undefined, new Matrix4());
const rotation = Matrix4.getMatrix3(enu, new Matrix3());
this.#orient(rotation, this.#aim, pose);
pose.fovy = this.#fovy;
// Straight down at the observer's feet, on the same azimuth and roll — the
// attitude the descent aims with and the one the rise starts from. Built
// through `skyBasis` like every other attitude here rather than as some
// convenient nadir, because that is what makes the rise a pitch sweep from
// -90° and nothing else: no roll creeps in, and `skyBasis` is continuous
// through straight down, so -90° is an aim like any other.
Cartesian3.clone(pose.position, this.#over.position);
this.#orient(rotation, { ...this.#aim, pitch: -90 }, this.#over);
return pose;
}
/** An east-north-up aim written into a pose, in world coordinates. */
#orient(enuToFixed: Matrix3, aim: Aim, into: Pose): void {
const { direction, up, right } = skyBasis(aim);
Matrix3.multiplyByVector(enuToFixed, direction, into.direction);
Matrix3.multiplyByVector(enuToFixed, up, into.up);
Matrix3.multiplyByVector(enuToFixed, right, into.right);
}
#assign(pose: Pose): void {
const { camera } = this.#scene;
Cartesian3.clone(pose.position, camera.position);
Cartesian3.clone(pose.direction, camera.direction);
Cartesian3.clone(pose.up, camera.up);
Cartesian3.clone(pose.right, camera.right);
if (camera.frustum instanceof PerspectiveFrustum) {
camera.frustum.fov = fovFromFovy(CesiumMath.toRadians(pose.fovy), this.#aspectRatio());
}
}
#apply(): void {
const observer = this.#observer;
if (!observer || this.#phase === "off") {
return;
}
// Computed even while leaving, and even though the camera is elsewhere: it
// is what keeps `frame` answerable for as long as the view is active.
const sky = this.#skyPose(observer);
const flight = this.#flight;
if (!flight) {
this.#assign(sky);
return;
}
const progress = (performance.now() - flight.startedAt) / flight.durationMs;
// Leaving runs the same path from the far end, so the trip out retraces the
// trip in exactly: look down at your feet, take off, and swing away.
this.#assign(flightPose(flight.path, flight.reverse ? 1 - progress : progress, this.#blended));
if (progress >= 1) {
this.#land();
}
}
}