control/FreeNavigation.tstypescript
import type { IControlParams } from "./Control";
import { Control } from "./Control";
import type { IMouseState } from "../renderer/RendererEvents";
import { Navigation } from "./Navigation";
import { Quat } from "../math/Quat";
import type { Vec2 } from "../math/Vec2";
import { Vec3 } from "../math/Vec3";
import { input } from "../input/input";
import { createEvents, type EventsHandler } from "../Events";
import * as math from "../math";
import { RADIANS } from "../math";

export interface IFreeNavigationParams extends IControlParams {
    speed?: number;
    minSpeed?: number;
    maxSpeed?: number;
    speedStep?: number;
    speedFactor?: number;
    lookSensitivity?: number;
    rollSpeed?: number;
    accelerationTime?: number;
    decelerationTime?: number;
    pitchLimit?: number;
    invertY?: boolean;
    pointerLock?: boolean;
    toggleKey?: number;
    showInfo?: boolean;
}

export type FreeNavigationEventsList = ["move", "rotate", "speedchange", "activate", "deactivate"];

const FREE_NAVIGATION_EVENTS: FreeNavigationEventsList = [
    /**
     * Triggered on camera movement.
     * @event og.FreeNavigation#move
     */
    "move",

    /**
     * Triggered on camera rotation.
     * @event og.FreeNavigation#rotate
     */
    "rotate",

    /**
     * Triggered when selected movement speed has been changed.
     * @event og.FreeNavigation#speedchange
     */
    "speedchange",

    /**
     * Triggered on the control activation.
     * @event og.FreeNavigation#activate
     */
    "activate",

    /**
     * Triggered on the control deactivation, i.e. when the pointer lock has been released.
     * @event og.FreeNavigation#deactivate
     */
    "deactivate"
];

// Selected movement speed in meters per second
const DEFAULT_SPEED = 0;
const DEFAULT_MAX_SPEED = 1000000;
const DEFAULT_MIN_SPEED = -DEFAULT_MAX_SPEED;

// Mouse wheel step in meters per second near the zero speed
const DEFAULT_SPEED_STEP = 1;

// Relative speed increment per one mouse wheel step, i.e. 0.45 is 45% of the current speed
const DEFAULT_SPEED_FACTOR = 0.45;

// Camera rotation angle per one mouse move pixel
const DEFAULT_LOOK_SENSITIVITY = 0.08 * RADIANS;

// Q/E roll angular speed in radians per second the roll velocity approaches
const DEFAULT_ROLL_SPEED = 60 * RADIANS;

// Time in seconds the velocity needs to approach the selected speed
const DEFAULT_ACCELERATION_TIME = 0.6;

// Time in seconds the velocity needs to approach zero after keys have been released
const DEFAULT_DECELERATION_TIME = 0.4;

// Time in seconds the roll angular velocity needs to approach the selected roll speed
const ROLL_ACCELERATION_TIME = 0.2;

// Time in seconds the roll angular velocity needs to approach zero after keys have been released
const ROLL_DECELERATION_TIME = 0.15;

// Maximal camera pitch angle above and below the local horizon
const DEFAULT_PITCH_LIMIT = 89.5 * RADIANS;

// Maximal mouse movement of a single locked pointer event in pixels
const MAX_POINTER_MOVEMENT = 400;

const MIN_VELOCITY = 1e-3;
const MIN_ROLL_VELOCITY = 1e-4;
const MAX_FRAME_DELTA_TIME = 0.1;
const FREE_NAVIGATION_PREDRAW_PRIORITY = -10000;

/**
 * Free-flight camera navigation.
 *
 * - W/S — move forward/backward
 * - A/D — strafe left/right
 * - Space/Ctrl — increase/decrease altitude
 * - Shift — hold to move without changing the camera height above the ellipsoid
 * - Q/E — roll
 * - Mouse — look around
 * - Mouse wheel — adjust movement speed
 * - Right mouse button — hold to keep the point under the screen center in the center
 * - F — activate and deactivate the control, see `toggleKey`
 *
 * Yaw follows the local ellipsoid normal, while pitch uses the camera's right vector.
 * The camera preserves its orientation relative to the local horizon while moving.
 *
 * By default, pointer lock allows unrestricted mouse rotation. Pressing Escape releases
 * the pointer and deactivates the control. Set `pointerLock` to `false` to use regular
 * mouse movement instead.
 *
 * The control conflicts with the {@link Navigation} control, so an active {@link Navigation}
 * is deactivated while the free navigation is active, and restored back on deactivation.
 *
 * @class
 * @extends {Control}
 * @param {IFreeNavigationParams} [options] - Free navigation options:
 * @param {number} [options.speed] - Initial selected movement speed in m/s. Default is 0
 * @param {number} [options.minSpeed] - Minimal selected movement speed in m/s. Default is -300
 * @param {number} [options.maxSpeed] - Maximal selected movement speed in m/s. Default is 1000000
 * @param {number} [options.speedStep] - Mouse wheel speed step near the zero speed in m/s. Default is 1
 * @param {number} [options.speedFactor] - Relative speed increment per one mouse wheel step. Default is 0.45
 * @param {number} [options.lookSensitivity] - Camera rotation angle in radians per mouse move pixel
 * @param {number} [options.rollSpeed] - Q/E roll angular speed in radians per second the smoothed
 * roll velocity approaches
 * @param {number} [options.accelerationTime] - Acceleration smoothing time in seconds. Default is 0.6
 * @param {number} [options.decelerationTime] - Deceleration smoothing time in seconds. Default is 0.4
 * @param {number} [options.pitchLimit] - Maximal pitch angle above and below the local horizon in radians
 * @param {boolean} [options.invertY] - Inverts vertical mouse rotation direction. Default is false
 * @param {boolean} [options.pointerLock] - Locks and hides the mouse pointer. Default is true
 * @param {number} [options.toggleKey] - Key code which activates and deactivates the control,
 * it works while the control is inactive as well. Zero disables it. Default is `input.KEY_F`
 * @param {boolean} [options.showInfo] - Shows the movement speed and the key hint. Default is false
 * @fires move
 * @fires rotate
 * @fires speedchange
 * @fires activate
 * @fires deactivate
 */
export class FreeNavigation extends Control {
    public events: EventsHandler<FreeNavigationEventsList>;

    public minSpeed: number;
    public maxSpeed: number;
    public speedStep: number;
    public speedFactor: number;
    public lookSensitivity: number;
    public rollSpeed: number;
    public accelerationTime: number;
    public decelerationTime: number;
    public pitchLimit: number;
    public invertY: boolean;
    public pointerLock: boolean;
    public toggleKey: number;

    /**
     * Current camera velocity in meters per second.
     * @public
     * @type {Vec3}
     */
    public vel: Vec3;

    /**
     * Current camera roll angular velocity in radians per second.
     * @public
     * @type {number}
     */
    public rollVel: number;

    /**
     * Selected movement speed in meters per second.
     * @protected
     * @type {number}
     */
    protected _speed: number;

    protected _moveForward: boolean;
    protected _moveBackward: boolean;
    protected _moveLeft: boolean;
    protected _moveRight: boolean;
    protected _moveUp: boolean;
    protected _moveDown: boolean;

    protected _rollDir: number;

    protected _dx: number;
    protected _dy: number;
    protected _skipPointerMove: boolean;

    protected _targetPoint: Vec3 | null;
    protected _targetRequest: number;

    protected _holdHeight: number | null;

    protected _lastFrameTime: number;
    protected _frameDeltaTime: number;

    protected _infoEl: HTMLElement | null;

    protected _suspendedNavigation: Navigation[];

    constructor(options: IFreeNavigationParams = {}) {
        super({
            name: "freeNavigation",
            autoActivate: true,
            ...options
        });

        this.events = createEvents<FreeNavigationEventsList>(FREE_NAVIGATION_EVENTS, this);

        this.minSpeed = options.minSpeed ?? DEFAULT_MIN_SPEED;
        this.maxSpeed = options.maxSpeed ?? DEFAULT_MAX_SPEED;
        this.speedStep = options.speedStep ?? DEFAULT_SPEED_STEP;
        this.speedFactor = options.speedFactor ?? DEFAULT_SPEED_FACTOR;
        this.lookSensitivity = options.lookSensitivity ?? DEFAULT_LOOK_SENSITIVITY;
        this.rollSpeed = options.rollSpeed ?? DEFAULT_ROLL_SPEED;
        this.accelerationTime = options.accelerationTime ?? DEFAULT_ACCELERATION_TIME;
        this.decelerationTime = options.decelerationTime ?? DEFAULT_DECELERATION_TIME;
        this.pitchLimit = options.pitchLimit ?? DEFAULT_PITCH_LIMIT;
        this.invertY = options.invertY ?? false;
        this.pointerLock = options.pointerLock ?? true;
        this.toggleKey = options.toggleKey ?? input.KEY_F;

        this._infoEl = options.showInfo ? document.createElement("div") : null;

        this.vel = new Vec3();
        this.rollVel = 0;

        this._speed = math.clamp(options.speed ?? DEFAULT_SPEED, this.minSpeed, this.maxSpeed);

        this._moveForward = false;
        this._moveBackward = false;
        this._moveLeft = false;
        this._moveRight = false;
        this._moveUp = false;
        this._moveDown = false;

        this._rollDir = 0;

        this._dx = 0;
        this._dy = 0;
        this._skipPointerMove = true;

        this._targetPoint = null;
        this._targetRequest = 0;

        this._holdHeight = null;

        this._lastFrameTime = 0;
        this._frameDeltaTime = 0;

        this._suspendedNavigation = [];
    }

    override onadd(): void {
        if (this.planet?.camera) {
            this.planet.camera.events.on("flystart", this._onCameraFly);
        }
    }

    public override oninit() {
        if (this.toggleKey) {
            this.renderer!.events.on("keyfree", this.toggleKey, this._onToggleKey);
        }

        if (this._infoEl) {
            this._infoEl.className = "og-free-navigation-info";
            this.renderer!.getInnerContainer().appendChild(this._infoEl);
            this._updateInfo();
        }
    }

    override onremove(): void {
        if (this.planet?.camera) {
            this.planet.camera.events.off("flystart", this._onCameraFly);
        }

        if (this.toggleKey) {
            this.renderer!.events.off("keyfree", this.toggleKey, this._onToggleKey);
        }

        if (this._infoEl) {
            this._infoEl.remove();
        }
    }

    /**
     * Activates the control when it is inactive and deactivates it otherwise.
     * @public
     */
    public toggle() {
        if (this.isActive()) {
            this.deactivate();
        } else {
            this.activate();
        }
    }

    protected _onToggleKey = () => {
        this.toggle();
    };

    protected _updateInfo() {
        if (!this._infoEl) return;

        this._infoEl.innerText = this.isActive()
            ? `Free flight — W,S,A,D move, Space/Ctrl altitude, Q,E roll, RMB lock target, wheel speed: ` +
              `${this._speed} m/s (${Math.round(this._speed * 3.6)} km/h)`
            : "Press F for the free flight";
    }

    public override onactivate() {
        super.onactivate();

        this._suspendNavigation();

        this._lastFrameTime = 0;

        let r = this.renderer!;
        r.events.on("mousewheel", this._onMouseWheel);
        r.events.on("mousemove", this._onMouseMove);
        r.events.on("mouseleave", this._onMouseLeave);
        r.events.on("ldown", this._onLDown);
        r.events.on("rdown", this._onRDown);
        r.events.on("rup", this._onRUp);
        r.events.on("keyfree", input.KEY_ESC, this._onKeyEscape);
        r.events.on("keypress", input.KEY_W, this._onKeyForward);
        r.events.on("keypress", input.KEY_S, this._onKeyBackward);
        r.events.on("keypress", input.KEY_A, this._onKeyLeft);
        r.events.on("keypress", input.KEY_D, this._onKeyRight);
        r.events.on("keypress", input.KEY_SPACE, this._onKeyUp);
        r.events.on("keypress", input.KEY_CTRL, this._onKeyDown);
        r.events.on("keypress", input.KEY_Q, this._onKeyRollLeft);
        r.events.on("keypress", input.KEY_E, this._onKeyRollRight);
        r.events.on("predraw", this.onPreDraw, this, FREE_NAVIGATION_PREDRAW_PRIORITY);

        document.addEventListener("pointerlockchange", this._onPointerLockChange);
        document.addEventListener("mousemove", this._onLockedMouseMove);

        this.requestPointerLock();

        this._updateInfo();

        this.events.dispatch(this.events.activate, this);
    }

    public override ondeactivate() {
        super.ondeactivate();

        let r = this.renderer!;
        r.events.off("mousewheel", this._onMouseWheel);
        r.events.off("mousemove", this._onMouseMove);
        r.events.off("mouseleave", this._onMouseLeave);
        r.events.off("ldown", this._onLDown);
        r.events.off("rdown", this._onRDown);
        r.events.off("rup", this._onRUp);
        r.events.off("keyfree", input.KEY_ESC, this._onKeyEscape);
        r.events.off("keypress", input.KEY_W, this._onKeyForward);
        r.events.off("keypress", input.KEY_S, this._onKeyBackward);
        r.events.off("keypress", input.KEY_A, this._onKeyLeft);
        r.events.off("keypress", input.KEY_D, this._onKeyRight);
        r.events.off("keypress", input.KEY_SPACE, this._onKeyUp);
        r.events.off("keypress", input.KEY_CTRL, this._onKeyDown);
        r.events.off("keypress", input.KEY_Q, this._onKeyRollLeft);
        r.events.off("keypress", input.KEY_E, this._onKeyRollRight);
        r.events.off("predraw", this.onPreDraw);

        document.removeEventListener("pointerlockchange", this._onPointerLockChange);
        document.removeEventListener("mousemove", this._onLockedMouseMove);

        this.exitPointerLock();

        this.stop();

        this._restoreNavigation();

        this._updateInfo();

        this.events.dispatch(this.events.deactivate, this);
    }

    /**
     * Returns selected movement speed in meters per second.
     * @public
     * @return {number} -
     */
    public get speed(): number {
        return this._speed;
    }

    /**
     * Sets selected movement speed in meters per second.
     * @public
     * @param {number} speed - Speed in m/s.
     */
    public set speed(speed: number) {
        this.setSpeed(speed);
    }

    /**
     * Sets selected movement speed in meters per second, clamped to the min and max speed.
     * @public
     * @param {number} speed - Speed in m/s.
     */
    public setSpeed(speed: number) {
        let s = math.clamp(speed, this.minSpeed, this.maxSpeed);
        if (s !== this._speed) {
            this._speed = s;
            this._updateInfo();
            this.events.dispatch(this.events.speedchange, this);
        }
    }

    /**
     * Changes the movement speed by the given number of wheel steps.
     *
     * The speed step increases with the current speed. Changes are reversible,
     * and zero speed is always reachable.
     * @public
     * @param {number} steps - Number of the wheel steps, negative decreases the speed.
     */
    public stepSpeed(steps: number) {
        if (steps === 0) return;

        if (this.speedFactor <= 0) {
            this.setSpeed(this._speed + steps * this.speedStep);
            return;
        }

        let zeroStep = this.speedStep / this.speedFactor;
        let s = this._speed;
        let scaled = Math.sign(s) * Math.log(1.0 + Math.abs(s) / zeroStep) + steps * this.speedFactor;

        let speed = Math.sign(scaled) * zeroStep * Math.expm1(Math.abs(scaled));
        speed = Math.round(speed);

        if (speed === this._speed) {
            speed = this._speed + Math.sign(steps);
        }

        if (this._speed !== 0 && Math.sign(speed) !== Math.sign(this._speed)) {
            speed = 0;
        }

        this.setSpeed(speed);
    }

    /**
     * True when the mouse pointer is locked by the control.
     * @public
     * @return {boolean} -
     */
    public isPointerLocked(): boolean {
        let canvas = this.renderer?.handler.canvas;
        return !!canvas && document.pointerLockElement === canvas;
    }

    /**
     * Locks and hides the mouse pointer over the canvas.
     * @public
     */
    public requestPointerLock() {
        if (!this.pointerLock || this.isPointerLocked()) return;

        let canvas = this.renderer?.handler.canvas;
        if (!canvas) return;

        let lock = canvas.requestPointerLock as (options?: any) => Promise<void> | undefined;

        // Fixed mouse jump.
        let request = lock.call(canvas, { unadjustedMovement: true });

        if (request && typeof request.catch === "function") {
            request.catch(() => {
                let fallback = lock.call(canvas);
                if (fallback && typeof fallback.catch === "function") {
                    fallback.catch(() => {});
                }
            });
        }
    }

    /**
     * Releases the mouse pointer.
     * @public
     */
    public exitPointerLock() {
        if (this.isPointerLocked()) {
            document.exitPointerLock();
        }
    }

    /**
     * Locked target point in the cartesian coordinates, or null when no target is locked.
     * @public
     * @return {Vec3 | null} -
     */
    public get targetPoint(): Vec3 | null {
        return this._targetPoint;
    }

    /**
     * Locks the target point, so the camera keeps looking at it wherever it moves,
     * @public
     * @param {Vec3} [point] - Target point in the cartesian coordinates.
     */
    public lockTarget(point?: Vec3 | null) {
        this._targetRequest++;

        if (point) {
            this._targetPoint = point;
            return;
        }

        let px = this._getTargetPixel();
        let request = this._targetRequest;

        this._targetPoint = this._pickTargetPoint(px);

        this.renderer!.getCartesianFromPixelAsync(px).then((picked?: Vec3) => {
            if (picked && request === this._targetRequest) {
                this._targetPoint = picked;
            }
        });
    }

    /**
     * Releases the locked target point.
     * @public
     */
    public unlockTarget() {
        this._targetRequest++;
        this._targetPoint = null;
    }

    /**
     * Stops the camera movement and releases the locked target point.
     * @public
     */
    public stop() {
        this.vel.set(0, 0, 0);
        this.rollVel = 0;
        this._holdHeight = null;
        this.unlockTarget();
        this._resetInput();
    }

    protected onPreDraw() {
        if (!this.planet) return;

        this._updateFrameDeltaTime();

        if (this._hasInput()) {
            this.planet.stopFlying();
            this.renderer!.requestRedraw();
        }

        this._handleRotation();
        this._handleRoll();
        this._handleMove();
        this._handleHeightHold();
        this._handleTargetLock();

        this._resetInput();
    }

    protected _hasInput(): boolean {
        return (
            this._dx !== 0 ||
            this._dy !== 0 ||
            this._rollDir !== 0 ||
            this._moveForward ||
            this._moveBackward ||
            this._moveLeft ||
            this._moveRight ||
            this._moveUp ||
            this._moveDown
        );
    }

    protected _resetInput() {
        this._dx = 0;
        this._dy = 0;
        this._rollDir = 0;
        this._moveForward = false;
        this._moveBackward = false;
        this._moveLeft = false;
        this._moveRight = false;
        this._moveUp = false;
        this._moveDown = false;
    }

    private _onCameraFly = () => {
        this.stop();
    };

    protected _onMouseMove = (e: IMouseState) => {
        if (this.isPointerLocked()) return;

        this._dx += e.x - e.prev_x;
        this._dy += e.y - e.prev_y;
    };

    protected _onLockedMouseMove = (e: MouseEvent) => {
        if (!this.isPointerLocked()) return;

        if (this._skipPointerMove) {
            this._skipPointerMove = false;
            return;
        }

        if (Math.abs(e.movementX) > MAX_POINTER_MOVEMENT || Math.abs(e.movementY) > MAX_POINTER_MOVEMENT) {
            return;
        }

        let pixelRatio = this.renderer!.handler.pixelRatio;

        this._dx += e.movementX * pixelRatio;
        this._dy += e.movementY * pixelRatio;
    };

    protected _onPointerLockChange = () => {
        this._dx = 0;
        this._dy = 0;
        this._skipPointerMove = true;

        if (this._active && this.pointerLock && !this.isPointerLocked()) {
            this.deactivate();
        }
    };

    protected _onLDown = () => {
        this.requestPointerLock();
    };

    protected _onRDown = () => {
        this.planet?.stopFlying();

        this.lockTarget();
    };

    protected _onRUp = () => {
        this.unlockTarget();
    };

    protected _onKeyEscape = () => {
        this.deactivate();
    };

    protected _onMouseLeave = () => {
        this._dx = 0;
        this._dy = 0;
    };

    protected _onMouseWheel = (e: IMouseState) => {
        this.stepSpeed(Math.sign(e.wheelDelta));
    };

    protected _onKeyForward = () => {
        this._moveForward = true;
    };

    protected _onKeyBackward = () => {
        this._moveBackward = true;
    };

    protected _onKeyLeft = () => {
        this._moveLeft = true;
    };

    protected _onKeyRight = () => {
        this._moveRight = true;
    };

    protected _onKeyUp = () => {
        this._moveUp = true;
    };

    protected _onKeyDown = () => {
        this._moveDown = true;
    };

    protected _onKeyRollLeft = () => {
        this._rollDir -= 1;
    };

    protected _onKeyRollRight = () => {
        this._rollDir += 1;
    };

    protected _getLocalUp(eye: Vec3): Vec3 {
        return this.planet!.ellipsoid.getSurfaceNormal3v(eye);
    }

    protected _getTargetPixel(): Vec2 {
        let r = this.renderer!;
        return this.isPointerLocked() ? r.handler.getCenter() : r.events.mouseState.pos.clone();
    }

    protected _pickTargetPoint(px: Vec2): Vec3 | null {
        return this.planet?.getCartesianFromPixelTerrain(px) || null;
    }

    protected _handleTargetLock() {
        if (!this._targetPoint) return;

        let cam = this.planet!.camera;
        let dir = this._targetPoint.sub(cam.eye);

        if (dir.length2() === 0) {
            return;
        }

        dir.normalize();

        let localUp = this._getLocalUp(cam.eye);
        let rotated = false;

        let forwardProj = Vec3.proj_b_to_plane(cam.getForward(), localUp);
        let dirProj = Vec3.proj_b_to_plane(dir, localUp);

        if (forwardProj.length2() > 0 && dirProj.length2() > 0) {
            forwardProj.normalize();
            dirProj.normalize();

            let yaw = Math.atan2(forwardProj.cross(dirProj).dot(localUp), forwardProj.dot(dirProj));

            if (yaw !== 0) {
                cam.rotate(Quat.axisAngleToQuat(localUp, yaw));
                rotated = true;
            }
        }

        // Pitch around the horizontal axis, which is the camera right vector when there is no roll
        let forward = cam.getForward();
        let axis = forward.cross(localUp);
        axis = axis.length2() > 0 ? axis.normalize() : cam.getRight();

        let pitch = Math.atan2(forward.cross(dir).dot(axis), forward.dot(dir));

        if (pitch !== 0) {
            cam.rotate(Quat.axisAngleToQuat(axis, pitch));
            rotated = true;
        }

        if (!rotated) return;

        this._orthonormalizeCamera();

        this.events.dispatch(this.events.rotate, this);
    }

    protected _handleRotation() {
        if (this._targetPoint) {
            return;
        }

        if (this._dx === 0 && this._dy === 0) {
            return;
        }

        let cam = this.planet!.camera;
        let localUp = this._getLocalUp(cam.eye);

        let yaw = -this._dx * this.lookSensitivity;
        let pitch = (this.invertY ? this._dy : -this._dy) * this.lookSensitivity;

        if (yaw !== 0) {
            cam.rotate(Quat.axisAngleToQuat(localUp, yaw));
        }

        if (pitch !== 0) {
            pitch = this._limitPitch(pitch, cam.getForward(), cam.getRight(), localUp);
            if (pitch !== 0) {
                cam.rotate(Quat.axisAngleToQuat(cam.getRight(), pitch));
            }
        }

        this._orthonormalizeCamera();

        this.events.dispatch(this.events.rotate, this);
    }

    /**
     * Clamps the pitch rotation angle.
     * @protected
     * @param {number} angle - Pitch angle in radians.
     * @param {Vec3} forward - Camera forward vector.
     * @param {Vec3} right - Camera right vector.
     * @param {Vec3} localUp - Local reference frame up direction.
     * @return {number} - Applicable pitch angle in radians.
     */
    protected _limitPitch(angle: number, forward: Vec3, right: Vec3, localUp: Vec3): number {
        let pitchSin = forward.dot(localUp);
        let radius = Math.hypot(pitchSin, right.cross(forward).dot(localUp));
        let limitSin = Math.sin(this.pitchLimit);

        if (radius <= limitSin) {
            return angle;
        }

        let dir = Math.sign(angle);
        let pos = Math.atan2(pitchSin, right.cross(forward).dot(localUp));

        let halfArc = Math.asin(limitSin / radius);
        let center: number;

        if (Math.abs(pitchSin) <= limitSin) {
            center = Math.abs(pos) <= math.PI_TWO ? 0 : Math.sign(pos) * Math.PI;
        } else {
            let vertical = Math.sign(pos) * math.PI_TWO;
            let away = Math.sign(pos - vertical);
            if (away !== 0 && dir !== away) {
                return 0;
            }
            center = vertical + dir * math.PI_TWO;
        }

        return dir * Math.min(Math.abs(angle), Math.abs(center + dir * halfArc - pos));
    }

    /**
     * Rollls the camera around its forward axis and keeps its angular velocity smoothed.
     * @protected
     */
    protected _handleRoll() {
        let dt = this.dt;
        let targetVel = Math.sign(this._rollDir) * this.rollSpeed;

        let time = targetVel !== 0 ? ROLL_ACCELERATION_TIME : ROLL_DECELERATION_TIME;
        let k = time > 0 ? 1.0 - Math.exp(-dt / time) : 1.0;
        this.rollVel += (targetVel - this.rollVel) * k;

        if (Math.abs(this.rollVel) < MIN_ROLL_VELOCITY) {
            this.rollVel = 0;
            return;
        }

        let cam = this.planet!.camera;
        cam.rotate(Quat.axisAngleToQuat(cam.getForward(), this.rollVel * dt));

        this._orthonormalizeCamera();

        this.events.dispatch(this.events.rotate, this);
    }

    protected _orthonormalizeCamera() {
        let cam = this.planet!.camera;
        cam._b.normalize();
        cam._r.copy(cam._u.cross(cam._b).normalize());
        cam._u.copy(cam._b.cross(cam._r).normalize());
        cam._f.set(-cam._b.x, -cam._b.y, -cam._b.z);
    }

    /**
     * Moves the camera and keeps its orientation in the local reference frame.
     * @protected
     */
    protected _handleMove() {
        let cam = this.planet!.camera;
        let dt = this.dt;
        let localUp = this._getLocalUp(cam.eye);

        let dir = new Vec3();
        if (this._moveForward) {
            dir.addA(cam.getForward());
        }
        if (this._moveBackward) {
            dir.subA(cam.getForward());
        }
        if (this._moveRight) {
            dir.addA(cam.getRight());
        }
        if (this._moveLeft) {
            dir.subA(cam.getRight());
        }

        if (this._isHeightHold()) {
            dir.copy(Vec3.proj_b_to_plane(dir, localUp));
        }

        if (this._moveUp) {
            dir.addA(localUp);
        }

        if (this._moveDown) {
            dir.subA(localUp);
        }

        // Diagonal movement must not be faster than a straight one
        if (dir.length2() > 0) {
            dir.normalize();
        }

        // Negative speed reverses the movement direction, zero speed keeps the camera in place
        let targetVel = dir.scale(this._speed);

        // Frame rate independent velocity smoothing
        let time = targetVel.length2() > 0 ? this.accelerationTime : this.decelerationTime;
        let k = time > 0 ? 1.0 - Math.exp(-dt / time) : 1.0;
        this.vel.addA(targetVel.subA(this.vel).scale(k));

        if (this.vel.length() < MIN_VELOCITY) {
            this.vel.set(0, 0, 0);
            return;
        }

        cam.eye.addA(this.vel.scaleTo(dt));

        // Follow the local surface normal without adding roll.
        let rot = Quat.getRotationBetweenVectors(localUp, this._getLocalUp(cam.eye));
        cam.rotate(rot);
        this.vel.copy(rot.mulVec3(this.vel));

        this._orthonormalizeCamera();

        this.events.dispatch(this.events.move, this);
    }

    protected _isHeightHold(): boolean {
        return this.renderer!.events.isKeyPressed(input.KEY_SHIFT);
    }

    protected _handleHeightHold() {
        if (!this._isHeightHold()) {
            this._holdHeight = null;
            return;
        }

        let cam = this.planet!.camera;
        let ellipsoid = this.planet!.ellipsoid;
        let lonLat = ellipsoid.cartesianToLonLat(cam.eye);

        if (this._holdHeight === null) {
            this._holdHeight = lonLat.height;
            return;
        }

        this._holdHeight += this.vel.dot(this._getLocalUp(cam.eye)) * this.dt;

        // Straight line movement above the curved surface changes the height as well
        if (lonLat.height !== this._holdHeight) {
            lonLat.height = this._holdHeight;
            cam.eye.copy(ellipsoid.lonLatToCartesian(lonLat));
        }
    }

    protected _suspendNavigation() {
        this._suspendedNavigation = [];

        let controls = this.renderer?.controls;
        if (!controls) return;

        for (let name in controls) {
            let c = controls[name];
            if (c instanceof Navigation && c.isActive()) {
                c.stop();
                c.deactivate();
                this._suspendedNavigation.push(c);
            }
        }
    }

    protected _restoreNavigation() {
        for (let i = 0; i < this._suspendedNavigation.length; i++) {
            this._suspendedNavigation[i].activate();
        }
        this._suspendedNavigation = [];
    }

    protected _updateFrameDeltaTime() {
        let now = window.performance.now();
        this._frameDeltaTime =
            this._lastFrameTime > 0 ? math.clamp(0.001 * (now - this._lastFrameTime), 0, MAX_FRAME_DELTA_TIME) : 0;
        this._lastFrameTime = now;
    }

    protected get dt(): number {
        return this._frameDeltaTime;
    }
}