Source: math/path3d.js

import { Vector3 } from './vector3.js';

/**
 * Minimum point count for a path.
 *
 * @type {number}
 */
const MIN_POINT_COUNT = 2;

/**
 * Default loop flag.
 *
 * @type {boolean}
 */
const DEFAULT_LOOP = false;

/**
 * Start index for segment traversal.
 *
 * @type {number}
 */
const SEGMENT_START_INDEX = 0;

/**
 * Segment index increment.
 *
 * @type {number}
 */
const SEGMENT_INDEX_INCREMENT = 1;

/**
 * Zero value constant.
 *
 * @type {number}
 */
const ZERO_VALUE = 0;

/**
 * One value constant.
 *
 * @type {number}
 */
const ONE_VALUE = 1;

/**
 * Epsilon used for safe normalization.
 *
 * @type {number}
 */
const NORMALIZE_EPSILON = 1e-8;

/**
 * Path built from 3D points.
 * Provides arc-length parameterization with cached segment lengths.
 */
export class Path3D {

    /**
     * Internal copy of path points, used for sampling and interpolation.
     *
     * @type {Vector3[]}
     * @private
     */
    #points;

    /**
     * Cached per-segment lengths (distance between consecutive points).
     *
     * @type {Float32Array}
     * @private
     */
    #segmentLengths;

    /**
     * Cached cumulative segment lengths where index stores the length up to point.
     *
     * @type {Float32Array}
     * @private
     */
    #cumulativeLengths;

    /**
     * Total path length (sum of all segment lengths).
     *
     * @type {number}
     * @private
     */
    #totalLength;

    /**
     * Whether the path is looped (wraps from the last point back to the first).
     *
     * @type {boolean}
     * @private
     */
    #loop;

    /**
     * @param {Vector3[]} points             - Path points.
     * @param {Object} [options]             - Optional options.
     * @param {boolean} [options.loop=false] - Whether the path is looped.
     * @throws {TypeError}  When inputs are invalid.
     * @throws {RangeError} When points are insufficient.
     */
    constructor(points, options = {}) {
        if (!Array.isArray(points)) {
            throw new TypeError('`Path3D` expects `points` as an array of `Vector3`.');
        }

        if (points.length < MIN_POINT_COUNT) {
            throw new RangeError('`Path3D` expects at least the 2 points.');
        }

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

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

        const { loop = DEFAULT_LOOP } = options;

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

        this.#points            = points.slice();
        this.#loop              = loop;
        this.#segmentLengths    = new Float32Array(this.#getSegmentCount());
        this.#cumulativeLengths = new Float32Array(this.#segmentLengths.length + ONE_VALUE);
        this.#totalLength       = ZERO_VALUE;
        this.#recalculateLengths();
    }

    /**
     * @returns {Vector3[]}
     */
    get points() {
        return this.#points;
    }

    /**
     * @returns {boolean}
     */
    get loop() {
        return this.#loop;
    }

    /**
     * @returns {number}
     */
    get totalLength() {
        return this.#totalLength;
    }

    /**
     * Returns a point at normalized position along the path (arc-length parameterization).
     *
     * @param {number} pathParameter - Normalized path parameter in [0..1].
     * @param {Vector3} [out]        - Optional output vector.
     * @returns {Vector3}
     * @throws {TypeError} When inputs are invalid.
     */
    getPointAt(pathParameter, out = new Vector3()) {
        if (typeof pathParameter !== 'number' || !Number.isFinite(pathParameter)) {
            throw new TypeError('`Path3D.getPointAt` expects `pathParameter` as a finite number.');
        }

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

        if (this.#totalLength <= ZERO_VALUE) {
            return out.copyFrom(this.#points[ZERO_VALUE]);
        }

        const normalizedPathParameter = this.#normalizePathParameter(pathParameter);
        const targetLength            = normalizedPathParameter * this.#totalLength;
        const segmentData             = this.#findSegmentAtLength(targetLength);
        const pointA                  = this.#points[segmentData.index];
        const pointB                  = this.#getNextPoint(segmentData.index);

        const segmentParameter = segmentData.length <= ZERO_VALUE
            ? ZERO_VALUE
            : (targetLength - segmentData.cumulativeLength) / segmentData.length;

        const x = pointA.x + (pointB.x - pointA.x) * segmentParameter;
        const y = pointA.y + (pointB.y - pointA.y) * segmentParameter;
        const z = pointA.z + (pointB.z - pointA.z) * segmentParameter;
        return out.set(x, y, z);
    }

    /**
     * Returns a normalized tangent at normalized position along the path (arc-length parameterization).
     *
     * @param {number} pathParameter - Normalized path parameter in [0..1].
     * @param {Vector3} [out]        - Optional output vector.
     * @returns {Vector3}
     * @throws {TypeError} When inputs are invalid.
     */
    getTangentAt(pathParameter, out = new Vector3()) {
        if (typeof pathParameter !== 'number' || !Number.isFinite(pathParameter)) {
            throw new TypeError('`Path3D.getTangentAt` expects `pathParameter` as a finite number.');
        }

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

        // Normalize the path parameter and resolve the corresponding segment by arc-length:
        const normalizedPathParameter = this.#normalizePathParameter(pathParameter);
        const targetLength            = normalizedPathParameter * this.#totalLength;
        const segmentData             = this.#findSegmentAtLength(targetLength);
        const pointA                  = this.#points[segmentData.index];
        const pointB                  = this.#getNextPoint(segmentData.index);

        // Compute the raw segment direction vector as a delta between the segment endpoints:
        const deltaX = pointB.x - pointA.x;
        const deltaY = pointB.y - pointA.y;
        const deltaZ = pointB.z - pointA.z;

        // Normalize the direction vector to produce a unit tangent:
        const tangentLength = Math.sqrt((deltaX * deltaX) + (deltaY * deltaY) + (deltaZ * deltaZ));

        if (tangentLength <= NORMALIZE_EPSILON) {
            return out.set(ZERO_VALUE, ZERO_VALUE, ZERO_VALUE);
        }

        return out.set(deltaX / tangentLength, deltaY / tangentLength, deltaZ / tangentLength);
    }

    /**
     * @returns {number}
     * @private
     */
    #getSegmentCount() {
        return this.#loop ? this.#points.length : (this.#points.length - ONE_VALUE);
    }

    /**
     * @param {number} length - Target length along the path.
     * @returns {{index: number, length: number, cumulativeLength: number}}
     * @private
     */
    #findSegmentAtLength(length) {
        const segmentCount = this.#segmentLengths.length;

        for (let index = SEGMENT_START_INDEX; index < segmentCount; index += SEGMENT_INDEX_INCREMENT) {
            const segmentLength    = this.#segmentLengths[index];
            const cumulativeLength = this.#cumulativeLengths[index];

            if (length <= cumulativeLength + segmentLength) {
                return {
                    index,
                    length : segmentLength,
                    cumulativeLength
                };
            }
        }

        const lastIndex = segmentCount - ONE_VALUE;

        return {
            index            : lastIndex,
            length           : this.#segmentLengths[lastIndex],
            cumulativeLength : this.#cumulativeLengths[lastIndex]
        };
    }

    /**
     * @param {number} index - Segment start index.
     * @returns {Vector3}
     * @private
     */
    #getNextPoint(index) {
        if (this.#loop) {
            const nextIndex = (index + ONE_VALUE) % this.#points.length;
            return this.#points[nextIndex];
        }

        return this.#points[Math.min(index + ONE_VALUE, this.#points.length - ONE_VALUE)];
    }

    /**
     * @param {number} pathParameter - Path parameter to normalize.
     * @returns {number}
     * @private
     */
    #normalizePathParameter(pathParameter) {
        if (this.#loop) {
            const wrapped = pathParameter - Math.floor(pathParameter);
            return wrapped < ZERO_VALUE ? wrapped + ONE_VALUE : wrapped;
        }

        if (pathParameter <= ZERO_VALUE) {
            return ZERO_VALUE;
        }

        if (pathParameter >= ONE_VALUE) {
            return ONE_VALUE;
        }

        return pathParameter;
    }

    /**
     * @private
     */
    #recalculateLengths() {
        const segmentCount                  = this.#segmentLengths.length;
        let cumulativeLength                = ZERO_VALUE;
        this.#cumulativeLengths[ZERO_VALUE] = cumulativeLength;

        for (let index = SEGMENT_START_INDEX; index < segmentCount; index += SEGMENT_INDEX_INCREMENT) {
            const pointA = this.#points[index];
            const pointB = this.#getNextPoint(index);
            const length = Path3D.#distance(pointA, pointB);

            this.#segmentLengths[index] = length;
            cumulativeLength += length;
            this.#cumulativeLengths[index + ONE_VALUE] = cumulativeLength;
        }

        this.#totalLength = cumulativeLength;
    }

    /**
     * @param {Vector3} pointA - First point.
     * @param {Vector3} pointB - Second point.
     * @returns {number}
     * @private
     */
    static #distance(pointA, pointB) {
        const deltaX = pointB.x - pointA.x;
        const deltaY = pointB.y - pointA.y;
        const deltaZ = pointB.z - pointA.z;
        return Math.sqrt((deltaX * deltaX) + (deltaY * deltaY) + (deltaZ * deltaZ));
    }
}