Source: math/catmull-rom-curve3.js

import * as CatmullRomConstants from '../constants/catmull-rom-curve3.js';
import { Curve3 }               from './curve3.js';
import { Vector3 }              from './vector3.js';

/**
 * Catmull-Rom curve segment data.
 *
 * @typedef {Object} CatmullRomSegment
 * @property {Vector3} previous  - Previous control point.
 * @property {Vector3} start     - Segment start control point.
 * @property {Vector3} end       - Segment end control point.
 * @property {Vector3} next      - Next control point.
 * @property {number}  parameter - Local segment parameter.
 */

/**
 * Catmull-Rom curve in 3D.
 */
export class CatmullRomCurve3 extends Curve3 {

    /**
     * Control points used to build the curve.
     *
     * @type {Vector3[]}
     * @private
     */
    #points;

    /**
     * Whether the curve is closed (wraps around to the first point).
     *
     * @type {boolean}
     * @private
     */
    #closed;

    /**
     * Reusable segment data, avoiding allocations during animation updates.
     *
     * @type {CatmullRomSegment}
     * @private
     */
    #segment;

    /**
     * @param {Vector3[]} points               - Control points.
     * @param {Object} [options]               - Optional options.
     * @param {boolean} [options.closed=false] - Whether the curve is closed.
     * @throws {TypeError}                       When inputs are invalid.
     * @throws {RangeError}                      When points are insufficient.
     */
    constructor(points, options = {}) {
        super();

        if (!Array.isArray(points)) {
            throw new TypeError('`CatmullRomCurve3` expects `points` as an array of `Vector3`.');
        }

        if (points.length < CatmullRomConstants.CATMULL_ROM_DEFAULTS.MIN_CONTROL_POINTS) {
            throw new RangeError('`CatmullRomCurve3` expects at least two control points.');
        }

        for (const point of points) {
            if (!(point instanceof Vector3)) {
                throw new TypeError('`CatmullRomCurve3` expects all points to be `Vector3` instances.');
            }
        }

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

        const { closed = CatmullRomConstants.CATMULL_ROM_DEFAULTS.CLOSED } = options;

        if (typeof closed !== 'boolean') {
            throw new TypeError('`CatmullRomCurve3` option `closed` must be a boolean.');
        }

        this.#points  = points.slice();
        this.#closed  = closed;
        this.#segment = {
            previous  : this.#points[CatmullRomConstants.CATMULL_ROM_VALUES.ZERO],
            start     : this.#points[CatmullRomConstants.CATMULL_ROM_VALUES.ZERO],
            end       : this.#points[CatmullRomConstants.CATMULL_ROM_VALUES.ZERO],
            next      : this.#points[CatmullRomConstants.CATMULL_ROM_VALUES.ZERO],
            parameter : CatmullRomConstants.CATMULL_ROM_VALUES.ZERO
        };
    }

    /**
     * @returns {Vector3[]} - Control points defining the curve.
     */
    get points() {
        return this.#points;
    }

    /**
     * @returns {boolean} - Whether the curve is closed.
     */
    get closed() {
        return this.#closed;
    }

    /**
     * Returns an interpolated point on the curve.
     *
     * @param {number} curveParameter - Parameter in [0..1].
     * @param {Vector3} [out]         - Optional output vector.
     * @returns {Vector3}             - Interpolated point written to `out`.
     * @throws {TypeError}              When inputs are invalid.
     */
    getPoint(curveParameter, out = new Vector3()) {
        if (typeof curveParameter !== 'number' || !Number.isFinite(curveParameter)) {
            throw new TypeError('`CatmullRomCurve3.getPoint` expects `curveParameter` as a finite number.');
        }

        if (!(out instanceof Vector3)) {
            throw new TypeError('`CatmullRomCurve3.getPoint` expects `out` as a Vector3.');
        }

        const { previous, start, end, next, parameter } = this.#resolveSegment(curveParameter);
        const x = CatmullRomCurve3.#interpolateCoordinate(previous.x, start.x, end.x, next.x, parameter);
        const y = CatmullRomCurve3.#interpolateCoordinate(previous.y, start.y, end.y, next.y, parameter);
        const z = CatmullRomCurve3.#interpolateCoordinate(previous.z, start.z, end.z, next.z, parameter);
        return out.set(x, y, z);
    }

    /**
     * Returns a normalized tangent direction at the given curve parameter.
     *
     * @param {number} curveParameter - Parameter in [0..1].
     * @param {Vector3} [out]         - Optional output vector.
     * @returns {Vector3}             - Normalized tangent written to `out`, or a zero vector for a degenerate tangent.
     * @throws {TypeError}              When inputs are invalid.
     */
    getTangent(curveParameter, out = new Vector3()) {
        if (typeof curveParameter !== 'number' || !Number.isFinite(curveParameter)) {
            throw new TypeError('`CatmullRomCurve3.getTangent` expects `curveParameter` as a finite number.');
        }

        if (!(out instanceof Vector3)) {
            throw new TypeError('`CatmullRomCurve3.getTangent` expects `out` as a Vector3.');
        }

        const { previous, start, end, next, parameter } = this.#resolveSegment(curveParameter);
        const x      = CatmullRomCurve3.#calculateTangentCoordinate(previous.x, start.x, end.x, next.x, parameter);
        const y      = CatmullRomCurve3.#calculateTangentCoordinate(previous.y, start.y, end.y, next.y, parameter);
        const z      = CatmullRomCurve3.#calculateTangentCoordinate(previous.z, start.z, end.z, next.z, parameter);
        const length = Math.sqrt((x * x) + (y * y) + (z * z));

        if (length <= CatmullRomConstants.CATMULL_ROM_TANGENT.EPSILON) {
            return out.set(
                CatmullRomConstants.CATMULL_ROM_VALUES.ZERO,
                CatmullRomConstants.CATMULL_ROM_VALUES.ZERO,
                CatmullRomConstants.CATMULL_ROM_VALUES.ZERO
            );
        }

        return out.set(x / length, y / length, z / length);
    }

    /**
     * Resolves the segment and reuses its four control point references.
     *
     * @param {number} curveParameter - Finite curve parameter.
     * @returns {CatmullRomSegment}   - Resolved segment data.
     * @private
     */
    #resolveSegment(curveParameter) {
        // Clamp the curve parameter and convert it to a local segment parameter:
        const clampedCurveParameter = CatmullRomCurve3.#clampCurveParameter(curveParameter);
        const segmentCount          = this.#closed ? this.#points.length : (this.#points.length - CatmullRomConstants.CATMULL_ROM_VALUES.ONE);
        const scaledCurveParameter  = clampedCurveParameter * segmentCount;
        const segmentIndex          = Math.min(Math.floor(scaledCurveParameter), segmentCount - CatmullRomConstants.CATMULL_ROM_VALUES.ONE);
        const parameter             = scaledCurveParameter - segmentIndex;

        const segment     = this.#segment;
        segment.previous  = this.#getPointForSegment(segmentIndex - CatmullRomConstants.CATMULL_ROM_VALUES.ONE);
        segment.start     = this.#getPointForSegment(segmentIndex);
        segment.end       = this.#getPointForSegment(segmentIndex + CatmullRomConstants.CATMULL_ROM_VALUES.ONE);
        segment.next      = this.#getPointForSegment(segmentIndex + CatmullRomConstants.CATMULL_ROM_VALUES.TWO);
        segment.parameter = parameter;
        return segment;
    }

    /**
     * Interpolates one coordinate using the Catmull-Rom polynomial.
     *
     * @param {number} previous  - Previous control point coordinate.
     * @param {number} start     - Segment start coordinate.
     * @param {number} end       - Segment end coordinate.
     * @param {number} next      - Next control point coordinate.
     * @param {number} parameter - Local segment parameter.
     * @returns {number}         - Interpolated coordinate value.
     * @private
     */
    static #interpolateCoordinate(previous, start, end, next, parameter) {
        const polynomial = CatmullRomConstants.CATMULL_ROM_VALUES;
        const squared    = parameter * parameter;
        const cubed      = squared * parameter;

        return polynomial.TENSION * (
            (polynomial.TWO * start)
            + (-previous + end) * parameter
            + (polynomial.TWO * previous - polynomial.FIVE * start + polynomial.FOUR * end - next) * squared
            + (-previous + polynomial.THREE * start - polynomial.THREE * end + next) * cubed
        );
    }

    /**
     * Evaluates the derivative of one Catmull-Rom coordinate.
     *
     * @param {number} previous  - Previous control point coordinate.
     * @param {number} start     - Segment start coordinate.
     * @param {number} end       - Segment end coordinate.
     * @param {number} next      - Next control point coordinate.
     * @param {number} parameter - Local segment parameter.
     * @returns {number}         - Unnormalized tangent coordinate.
     * @private
     */
    static #calculateTangentCoordinate(previous, start, end, next, parameter) {
        const polynomial = CatmullRomConstants.CATMULL_ROM_VALUES;
        const squared    = parameter * parameter;

        return polynomial.TENSION * (
            (-previous + end)
            + (polynomial.TWO * (polynomial.TWO * previous - polynomial.FIVE * start + polynomial.FOUR * end - next)) * parameter
            + (polynomial.THREE * (-previous + polynomial.THREE * start - polynomial.THREE * end + next)) * squared
        );
    }

    /**
     * Resolves a control point index for open or closed curves.
     *
     * @param {number} index - Control point index.
     * @returns {Vector3}    - Control point at the clamped or wrapped index.
     * @private
     */
    #getPointForSegment(index) {
        const pointCount = this.#points.length;

        if (this.#closed) {
            const wrappedIndex = ((index % pointCount) + pointCount) % pointCount;
            return this.#points[wrappedIndex];
        }

        if (index < CatmullRomConstants.CATMULL_ROM_VALUES.ZERO) {
            return this.#points[CatmullRomConstants.CATMULL_ROM_VALUES.ZERO];
        }

        if (index >= pointCount) {
            return this.#points[pointCount - CatmullRomConstants.CATMULL_ROM_VALUES.ONE];
        }

        return this.#points[index];
    }

    /**
     * Clamps a curve parameter to the valid interval.
     *
     * @param {number} curveParameter - Curve parameter.
     * @returns {number}              - Parameter clamped to [0..1].
     * @private
     */
    static #clampCurveParameter(curveParameter) {
        if (curveParameter <= CatmullRomConstants.CATMULL_ROM_VALUES.ZERO) {
            return CatmullRomConstants.CATMULL_ROM_VALUES.ZERO;
        }

        if (curveParameter >= CatmullRomConstants.CATMULL_ROM_VALUES.ONE) {
            return CatmullRomConstants.CATMULL_ROM_VALUES.ONE;
        }

        return curveParameter;
    }
}