Source: controls/orbit-controls.js

import { Camera }  from '../scene/camera.js';
import { Vector3 } from '../math/vector3.js';

/**
 * Default target X component.
 *
 * @type {number}
 */
const DEFAULT_TARGET_X = 0.0;

/**
 * Default target Y component.
 *
 * @type {number}
 */
const DEFAULT_TARGET_Y = 0.0;

/**
 * Default target Z component.
 *
 * @type {number}
 */
const DEFAULT_TARGET_Z = 0.0;

/**
 * Default orbit distance from the target.
 *
 * @type {number}
 */
const DEFAULT_DISTANCE = 6.0;

/**
 * Default minimum orbit distance.
 *
 * @type {number}
 */
const DEFAULT_MIN_DISTANCE = 0.1;

/**
 * Default maximum orbit distance.
 *
 * @type {number}
 */
const DEFAULT_MAX_DISTANCE = 1000.0;

/**
 * Default azimuth (yaw) angle in radians.
 *
 * @type {number}
 */
const DEFAULT_AZIMUTH_RADIANS = 0.7;

/**
 * Default roll (Z rotation).
 *
 * @type {number}
 */
const DEFAULT_CAMERA_ROLL_RADIANS = 0.0;

/**
 * Default polar (pitch) angle in radians.
 *
 * @type {number}
 */
const DEFAULT_POLAR_RADIANS = -0.6;

/**
 * Minimum allowed polar angle (pitch) in radians.
 * This avoids reaching `+/- 90` degrees, where the controls become unstable.
 *
 * @type {number}
 */
const DEFAULT_MIN_POLAR_RADIANS = -1.5;

/**
 * Maximum allowed polar angle (pitch) in radians.
 * This avoids reaching `+/-` 90 degrees, where the controls become unstable.
 *
 * @type {number}
 */
const DEFAULT_MAX_POLAR_RADIANS = 1.5;

/**
 * Default rotation speed multiplier.
 *
 * @type {number}
 */
const DEFAULT_ROTATION_SPEED = 1.0;

/**
 * Default zoom speed multiplier.
 *
 * @type {number}
 */
const DEFAULT_ZOOM_SPEED = 1.0;

/**
 * Default rotation enabled state.
 *
 * @type {boolean}
 */
const DEFAULT_ROTATION_ENABLED = true;

/**
 * The mouse button id used for rotation.
 *
 * @type {number}
 */
const ROTATE_BUTTON = 0;

/**
 * Radial rotation change per pixel, in radians.
 *
 * @type {number}
 */
const ROTATION_RADIANS_PER_PIXEL = 0.005;

/**
 * Wheel delta multiplier that is applied to the orbit distance.
 *
 * @type {number}
 */
const WHEEL_DISTANCE_MULTIPLIER = 0.01;

/**
 * `wheel` event listener options.
 * Using `passive: false` allows calling `event.preventDefault()` to stop page scrolling.
 *
 * @type {{ passive: boolean }}
 */
const WHEEL_LISTENER_OPTIONS = { passive: false };

/**
 * Pointer capture is used only for a single pointer at a time.
 * This value indicates that no pointer is currently captured.
 *
 * @type {number}
 */
const POINTER_ID_RESET_VALUE = -1;

/**
 * Error message for invalid rotation enabled values.
 *
 * @type {string}
 */
const ERROR_ROTATION_ENABLED_TYPE = '`OrbitControls.setRotationEnabled` expects a boolean.';

/**
 * Orbit controller, that rotates a camera around a target point.
 *
 * Controls:
 * - Mouse left drag: orbit (yaw/pitch).
 * - Mouse wheel: zoom (distance).
 *
 * Notes:
 * - This class modifies camera local `position` and `rotation`.
 * - View matrix is still computed by the camera (inverse of its TRS transform).
 */
export class OrbitControls {

    /**
     * Controlled camera instance.
     *
     * @type {Camera}
     * @private
     */
    #camera;

    /**
     * DOM element that receives pointer/wheel input (usually the canvas).
     *
     * @type {HTMLElement}
     * @private
     */
    #element;

    /**
     * Orbit target (point in world space that the camera looks at).
     *
     * Changing the returned vector via `.target.x = ...` will automatically mark controls as dirty.
     *
     * @type {Vector3}
     * @private
     */
    #target;

    /**
     * Orbit distance from the target.
     *
     * @type {number}
     * @private
     */
    #distance;

    /**
     * Minimum orbit distance.
     *
     * @type {number}
     * @private
     */
    #minDistance;

    /**
     * Maximum orbit distance.
     *
     * @type {number}
     * @private
     */
    #maxDistance;

    /**
     * Azimuth angle (yaw) in radians.
     *
     * @type {number}
     * @private
     */
    #azimuthRadians;

    /**
     * Polar angle (pitch) in radians.
     *
     * @type {number}
     * @private
     */
    #polarRadians;

    /**
     * Minimum allowed polar angle (pitch) in radians.
     *
     * @type {number}
     * @private
     */
    #minPolarRadians;

    /**
     * Maximum allowed polar angle (pitch) in radians.
     *
     * @type {number}
     * @private
     */
    #maxPolarRadians;

    /**
     * Rotation speed multiplier.
     *
     * @type {number}
     * @private
     */
    #rotationSpeed;

    /**
     * Zoom speed multiplier.
     *
     * @type {number}
     * @private
     */
    #zoomSpeed;

    /**
     * Flag controlling whether rotation input is enabled.
     *
     * @type {boolean}
     * @private
     */
    #rotationEnabled = DEFAULT_ROTATION_ENABLED;

    /**
     * True when controls need to recompute camera transform.
     *
     * @type {boolean}
     * @private
     */
    #isDirty = true;

    /**
     * Captured pointer id used during dragging.
     *
     * @type {number}
     * @private
     */
    #capturedPointerId = POINTER_ID_RESET_VALUE;

    /**
     * Previous pointer X position in client pixels.
     *
     * @type {number}
     * @private
     */
    #previousPointerX = 0;

    /**
     * Previous pointer Y position in client pixels.
     *
     * @type {number}
     * @private
     */
    #previousPointerY = 0;

    /**
     * Cached pointerdown handler reference used for `removeEventListener`.
     *
     * @type {function(PointerEvent): void}
     * @private
     */
    #onPointerDown;

    /**
     * Cached pointermove handler reference used for `removeEventListener`.
     *
     * @type {function(PointerEvent): void}
     * @private
     */
    #onPointerMove;

    /**
     * Cached pointerup handler reference used for `removeEventListener`.
     *
     * @type {function(PointerEvent): void}
     * @private
     */
    #onPointerUp;

    /**
     * Cached wheel handler reference used for `removeEventListener`.
     *
     * @type {function(WheelEvent): void}
     * @private
     */
    #onWheel;

    /**
     * Cached contextmenu handler reference used for `removeEventListener`.
     *
     * @type {function(MouseEvent): void}
     * @private
     */
    #onContextMenu;

    /**
     * @param {Camera}      camera                         - Controlled camera instance.
     * @param {HTMLElement} element                        - DOM element that receives input (usually the canvas).
     * @param {Object}      [options]                      - Orbit options (plain object).
     * @param {number}      [options.targetX=0]            - Orbit target X component.
     * @param {number}      [options.targetY=0]            - Orbit target Y component.
     * @param {number}      [options.targetZ=0]            - Orbit target Z component.
     * @param {number}      [options.distance=6]           - Orbit distance from the target.
     * @param {number}      [options.minDistance=0.1]      - Minimum orbit distance.
     * @param {number}      [options.maxDistance=1000]     - Maximum orbit distance.
     * @param {number}      [options.azimuthRadians=0.7]   - Initial yaw angle in radians.
     * @param {number}      [options.polarRadians=-0.6]    - Initial pitch angle in radians.
     * @param {number}      [options.minPolarRadians=-1.5] - Minimum pitch angle in radians.
     * @param {number}      [options.maxPolarRadians=1.5]  - Maximum pitch angle in radians.
     * @param {number}      [options.rotationSpeed=1.0]    - Rotation speed multiplier.
     * @param {number}      [options.zoomSpeed=1.0]        - Zoom speed multiplier.
     */
    constructor(camera, element, options = {}) {
        if (!(camera instanceof Camera)) {
            throw new TypeError('`OrbitControls` expects `camera` as a `Camera` derived-instance.');
        }

        if (!(element instanceof HTMLElement)) {
            throw new TypeError('`OrbitControls` expects `element` as an `HTMLElement`.');
        }

        if (options === null || typeof options !== 'object' || Array.isArray(options)) {
            throw new TypeError('`OrbitControls` expects `options` as a plain object.');
        }

        const {
            targetX         = DEFAULT_TARGET_X,
            targetY         = DEFAULT_TARGET_Y,
            targetZ         = DEFAULT_TARGET_Z,
            distance        = DEFAULT_DISTANCE,
            minDistance     = DEFAULT_MIN_DISTANCE,
            maxDistance     = DEFAULT_MAX_DISTANCE,
            azimuthRadians  = DEFAULT_AZIMUTH_RADIANS,
            polarRadians    = DEFAULT_POLAR_RADIANS,
            minPolarRadians = DEFAULT_MIN_POLAR_RADIANS,
            maxPolarRadians = DEFAULT_MAX_POLAR_RADIANS,
            rotationSpeed   = DEFAULT_ROTATION_SPEED,
            zoomSpeed       = DEFAULT_ZOOM_SPEED
        } = options;

        if (   typeof targetX !== 'number'
            || typeof targetY !== 'number'
            || typeof targetZ !== 'number') {
            throw new TypeError('`OrbitControls` options: `targetX/Y/Z` must be numbers.');
        }

        if (typeof distance       !== 'number'
            || typeof minDistance !== 'number'
            || typeof maxDistance !== 'number') {
            throw new TypeError('`OrbitControls` options: `distance/minDistance/maxDistance` must be numbers.');
        }

        if (distance <= 0 || minDistance <= 0 || maxDistance <= 0) {
            throw new RangeError('`OrbitControls` options: `distance/minDistance/maxDistance` must be positive numbers.');
        }

        if (minDistance > maxDistance) {
            throw new RangeError('`OrbitControls` options: `minDistance` must be less or equal to `maxDistance`.');
        }

        if (typeof azimuthRadians     !== 'number'
            || typeof polarRadians    !== 'number'
            || typeof minPolarRadians !== 'number'
            || typeof maxPolarRadians !== 'number') {
            throw new TypeError('`OrbitControls` options: angle values must be numbers.');
        }

        if (minPolarRadians > maxPolarRadians) {
            throw new RangeError('`OrbitControls` options: `minPolarRadians` must be less or equal to `maxPolarRadians`.');
        }

        if (typeof rotationSpeed !== 'number' || rotationSpeed <= 0) {
            throw new RangeError('`OrbitControls` options: `rotationSpeed` must be a positive number.');
        }

        if (typeof zoomSpeed !== 'number' || zoomSpeed <= 0) {
            throw new RangeError('`OrbitControls` options: `zoomSpeed` must be a positive number.');
        }

        this.#camera          = camera;
        this.#element         = element;
        this.#target          = new Vector3(targetX, targetY, targetZ, () => this.#markDirty());
        this.#distance        = OrbitControls.#clamp(distance, minDistance, maxDistance);
        this.#minDistance     = minDistance;
        this.#maxDistance     = maxDistance;
        this.#azimuthRadians  = azimuthRadians;
        this.#polarRadians    = OrbitControls.#clamp(polarRadians, minPolarRadians, maxPolarRadians);
        this.#minPolarRadians = minPolarRadians;
        this.#maxPolarRadians = maxPolarRadians;
        this.#rotationSpeed   = rotationSpeed;
        this.#zoomSpeed       = zoomSpeed;

        // Disable browser gestures on touch devices for the given element:
        this.#element.style.touchAction = 'none';

        this.#onPointerDown  = (event) => this.#handlePointerDown(event);
        this.#onPointerMove  = (event) => this.#handlePointerMove(event);
        this.#onPointerUp    = (event) => this.#handlePointerUp(event);
        this.#onWheel        = (event) => this.#handleWheel(event);
        this.#onContextMenu  = (event) => event.preventDefault();

        this.#element.addEventListener('pointerdown' , this.#onPointerDown);
        window.addEventListener('pointermove'        , this.#onPointerMove);
        window.addEventListener('pointerup'          , this.#onPointerUp);
        this.#element.addEventListener('wheel'       , this.#onWheel, WHEEL_LISTENER_OPTIONS);
        this.#element.addEventListener('contextmenu' , this.#onContextMenu);
    }

    /**
     * Orbit target (the point camera looks at).
     *
     * @returns {Vector3} - Mutable target vector (changes mark controls as dirty).
     */
    get target() {
        return this.#target;
    }

    /**
     * Current orbit distance.
     *
     * @returns {number} - Distance value in world units.
     */
    get distance() {
        return this.#distance;
    }

    /**
     * Sets orbit target components.
     *
     * @param {number} x - Target X component.
     * @param {number} y - Target Y component.
     * @param {number} z - Target Z component.
     */
    setTarget(x, y, z) {
        if (typeof x !== 'number' || typeof y !== 'number' || typeof z !== 'number') {
            throw new TypeError('`OrbitControls.setTarget` expects numeric `x/y/z` components.');
        }

        this.#target.set(x, y, z);
        this.#markDirty();
    }

    /**
     * Replaces the controlled camera.
     *
     * @param {Camera} camera - New controlled camera instance.
     */
    setCamera(camera) {
        if (!(camera instanceof Camera)) {
            throw new TypeError('`OrbitControls.setCamera` expects a `Camera` derived-instance.');
        }

        this.#camera = camera;
        this.#markDirty();
    }

    /**
     * Applies the current orbit state to the camera (position + rotation).
     */
    update() {
        if (this.#isDirty !== true) {
            return;
        }

        const target     = this.#target;
        const distance   = this.#distance;
        const azimuth    = this.#azimuthRadians;
        const polar      = this.#polarRadians;
        const cosPolar   = Math.cos(polar);
        const sinPolar   = Math.sin(polar);
        const sinAzimuth = Math.sin(azimuth);
        const cosAzimuth = Math.cos(azimuth);
        const cameraX    = target.x + (sinAzimuth * cosPolar * distance);
        const cameraY    = target.y - (sinPolar * distance);
        const cameraZ    = target.z + (cosAzimuth * cosPolar * distance);
        const camera     = this.#camera;

        camera.position.set(cameraX, cameraY, cameraZ);
        camera.rotation.set(polar, azimuth, DEFAULT_CAMERA_ROLL_RADIANS);
        this.#isDirty = false;
    }

    /**
     * Sets orbit distance (useful for UI sliders).
     *
     * @param {number} distance - New distance value.
     */
    setDistance(distance) {
        if (!Number.isFinite(distance)) {
            return;
        }

        this.#distance = OrbitControls.#clamp(distance, this.#minDistance, this.#maxDistance);
        this.#markDirty();
    }

    /**
     * Enables or disables the pointer-driven rotation.
     *
     * @param {boolean} enabled - Whether the rotation input is enabled.
     * @returns {void}
     * @throws {TypeError} When the enabled flag is invalid.
     */
    setRotationEnabled(enabled) {
        if (typeof enabled !== 'boolean') {
            throw new TypeError(ERROR_ROTATION_ENABLED_TYPE);
        }

        this.#rotationEnabled = enabled;

        if (!enabled && this.#capturedPointerId !== POINTER_ID_RESET_VALUE) {
            this.#element.releasePointerCapture(this.#capturedPointerId);
            this.#capturedPointerId = POINTER_ID_RESET_VALUE;
        }
    }

    /**
     * Returns the current state of the pointer-driven camera rotation input.
     *
     * @returns {boolean} - True if the rotation input is enabled, otherwise false.
     */
    isRotationEnabled() {
        return this.#rotationEnabled;
    }

    /**
     * Disposes the controller by removing all event listeners.
     */
    dispose() {
        this.#element.removeEventListener('pointerdown' , this.#onPointerDown);
        window.removeEventListener('pointermove'        , this.#onPointerMove);
        window.removeEventListener('pointerup'          , this.#onPointerUp);
        this.#element.removeEventListener('wheel'       , this.#onWheel, WHEEL_LISTENER_OPTIONS);
        this.#element.removeEventListener('contextmenu' , this.#onContextMenu);
        this.#capturedPointerId = POINTER_ID_RESET_VALUE;
    }

    /**
     * @private
     */
    #markDirty() {
        this.#isDirty = true;
    }

    /**
     * @param {PointerEvent} event - Pointer event.
     * @private
     */
    #handlePointerDown(event) {
        if (!this.#rotationEnabled) {
            return;
        }

        if (event.button !== ROTATE_BUTTON) {
            return;
        }

        if (this.#capturedPointerId !== POINTER_ID_RESET_VALUE) {
            return;
        }

        this.#capturedPointerId = event.pointerId;
        this.#previousPointerX  = event.clientX;
        this.#previousPointerY  = event.clientY;
        this.#element.setPointerCapture(event.pointerId);
        event.preventDefault();
    }

    /**
     * @param {PointerEvent} event - Pointer event.
     * @private
     */
    #handlePointerMove(event) {
        if (!this.#rotationEnabled) {
            return;
        }

        if (event.pointerId !== this.#capturedPointerId) {
            return;
        }

        const deltaX = event.clientX - this.#previousPointerX;
        const deltaY = event.clientY - this.#previousPointerY;
        this.#previousPointerX = event.clientX;
        this.#previousPointerY = event.clientY;

        const rotationStep    = ROTATION_RADIANS_PER_PIXEL * this.#rotationSpeed;
        this.#azimuthRadians -= deltaX * rotationStep;
        this.#polarRadians   -= deltaY * rotationStep;
        this.#polarRadians    = OrbitControls.#clamp(
            this.#polarRadians,
            this.#minPolarRadians,
            this.#maxPolarRadians
        );

        this.#markDirty();
        event.preventDefault();
    }

    /**
     * @param {PointerEvent} event - Pointer event.
     * @private
     */
    #handlePointerUp(event) {
        if (event.pointerId !== this.#capturedPointerId) {
            return;
        }

        this.#capturedPointerId = POINTER_ID_RESET_VALUE;
        event.preventDefault();
    }

    /**
     * @param {WheelEvent} event - Wheel event.
     * @private
     */
    #handleWheel(event) {
        const delta = event.deltaY;

        if (typeof delta !== 'number') {
            return;
        }

        const distanceDelta = delta * this.#zoomSpeed * WHEEL_DISTANCE_MULTIPLIER;
        const nextDistance  = this.#distance + distanceDelta;
        this.#distance = OrbitControls.#clamp(nextDistance, this.#minDistance, this.#maxDistance);
        this.#markDirty();
        event.preventDefault();
    }

    /**
     * @param {number} value - Value to clamp.
     * @param {number} min   - Inclusive lower bound.
     * @param {number} max   - Inclusive upper bound.
     * @returns {number}     - Clamped value.
     * @private
     */
    static #clamp(value, min, max) {
        if (value < min) {
            return min;
        }

        if (value > max) {
            return max;
        }

        return value;
    }
}