// 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; /** * 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; /** 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((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 { 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): 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 { 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 { 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 { 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(); } } }