Source: geometry/tube-line-geometry.js

import { Vector3 } from '../math/vector3.js';
import { Geometry, PRIMITIVE_LINES, PRIMITIVE_TRIANGLES }           from './geometry.js';
import { createIndexArray, createWireframeIndicesFromSolidIndices } from './geometry-utils.js';

/**
 * Number of components per position.
 *
 * @type {number}
 */
const POSITION_COMPONENT_COUNT = 3;

/**
 * Offset for X component in position triplet.
 *
 * @type {number}
 */
const POSITION_X_OFFSET = 0;

/**
 * Offset for Y component in position triplet.
 *
 * @type {number}
 */
const POSITION_Y_OFFSET = 1;

/**
 * Offset for Z component in position triplet.
 *
 * @type {number}
 */
const POSITION_Z_OFFSET = 2;

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

/**
 * Default radius for the tube line.
 *
 * @type {number}
 */
const DEFAULT_RADIUS = 0.05;

/**
 * Default tube width (when specified, overrides the radius).
 *
 * @type {null}
 */
const DEFAULT_WIDTH = null;

/**
 * Default radial segments.
 *
 * @type {number}
 */
const DEFAULT_RADIAL_SEGMENTS = 8;

/**
 * Minimum radial segments for a tube.
 *
 * @type {number}
 */
const MIN_RADIAL_SEGMENTS = 3;

/**
 * Default closed flag.
 *
 * @type {boolean}
 */
const DEFAULT_CLOSED = false;

/**
 * Cap type: no caps.
 *
 * @type {string}
 */
const CAP_TYPE_NONE = 'none';

/**
 * Cap type: flat caps.
 *
 * @type {string}
 */
const CAP_TYPE_FLAT = 'flat';

/**
 * Default cap type.
 *
 * @type {string}
 */
const DEFAULT_CAP_TYPE = CAP_TYPE_NONE;

/**
 * Error message for invalid cap type.
 *
 * @type {string}
 */
const ERROR_INVALID_CAP_TYPE = `\`TubeLineGeometry\` expects \`capType\` to be "${CAP_TYPE_NONE}" or "${CAP_TYPE_FLAT}".`;

/**
 * Offset used to compute radius from width.
 *
 * @type {number}
 */
const WIDTH_TO_RADIUS_DIVISOR = 2;

/**
 * Two PI constant.
 *
 * @type {number}
 */
const TWO_PI = Math.PI * 2;

/**
 * Small epsilon used for vector normalization.
 *
 * @type {number}
 */
const NORMALIZE_EPSILON = 1e-8;

/**
 * Up axis used for frame generation.
 *
 * @type {number}
 */
const UP_AXIS_X = 0;

/**
 * Up axis used for frame generation.
 *
 * @type {number}
 */
const UP_AXIS_Y = 1;

/**
 * Up axis used for frame generation.
 *
 * @type {number}
 */
const UP_AXIS_Z = 0;

/**
 * Fallback axis X component used, when tangent is parallel to up axis.
 *
 * @type {number}
 */
const FALLBACK_AXIS_X = 1;

/**
 * Fallback axis Y component used, when tangent is parallel to up axis.
 *
 * @type {number}
 */
const FALLBACK_AXIS_Y = 0;

/**
 * Fallback axis Z component used, when tangent is parallel to up axis.
 *
 * @type {number}
 */
const FALLBACK_AXIS_Z = 0;

/**
 * Secondary fallback axis X component.
 *
 * @type {number}
 */
const SECOND_FALLBACK_AXIS_X = 0;

/**
 * Secondary fallback axis Y component.
 *
 * @type {number}
 */
const SECOND_FALLBACK_AXIS_Y = 0;

/**
 * Secondary fallback axis Z component.
 *
 * @type {number}
 */
const SECOND_FALLBACK_AXIS_Z = 1;

/**
 * Default zero value.
 *
 * @type {number}
 */
const ZERO_VALUE = 0;

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

/**
 * Two value.
 *
 * @type {number}
 */
const TWO_VALUE = 2;

/**
 * Options used by `TubeLineGeometry`.
 *
 * @typedef {Object} TubeLineGeometryOptions
 * @property {Vector3[]} positions          - Path positions.
 * @property {number} [radius = 0.05]       - Tube radius.
 * @property {number | null} [width = null] - Optional tube width (overrides the radius).
 * @property {number} [radialSegments = 8]  - Radial segment count.
 * @property {boolean} [closed = false]     - Whether to close the tube.
 * @property {string} [capType = 'none']    - Cap type (none|flat).
 */

/**
 * Geometry for thick debug lines (tube around the polyline).
 */
export class TubeLineGeometry extends Geometry {

    /**
     * @param {WebGL2RenderingContext} webglContext - WebGL2 rendering context.
     * @param {TubeLineGeometryOptions} options     - Tube geometry options.
     * @throws {TypeError}  When inputs are invalid.
     * @throws {RangeError} When numeric inputs are out of range.
     */
    constructor(webglContext, options = {}) {
        if (options === null || typeof options !== 'object' || Array.isArray(options)) {
            throw new TypeError('`TubeLineGeometry` expects options as a plain object.');
        }

        const {
            positions,
            radius         = DEFAULT_RADIUS,
            width          = DEFAULT_WIDTH,
            radialSegments = DEFAULT_RADIAL_SEGMENTS,
            closed         = DEFAULT_CLOSED,
            capType        = DEFAULT_CAP_TYPE
        } = options;

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

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

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

        if (typeof radius !== 'number' || !Number.isFinite(radius) || radius <= ZERO_VALUE) {
            throw new RangeError('`TubeLineGeometry` expects `radius` as a positive number.');
        }

        if (width !== null && (typeof width !== 'number' || !Number.isFinite(width) || width <= ZERO_VALUE)) {
            throw new RangeError('`TubeLineGeometry` expects `width` as a positive number or null.');
        }

        if (!Number.isInteger(radialSegments) || radialSegments < MIN_RADIAL_SEGMENTS) {
            throw new RangeError('`TubeLineGeometry` expects `radialSegments` as an `integer >= 3`.');
        }

        if (typeof closed !== 'boolean') {
            throw new TypeError('`TubeLineGeometry` expects `closed` as a boolean.');
        }

        if (capType !== CAP_TYPE_NONE && capType !== CAP_TYPE_FLAT) {
            throw new RangeError(ERROR_INVALID_CAP_TYPE);
        }

        const resolvedRadius   = width !== null ? (width / WIDTH_TO_RADIUS_DIVISOR) : radius;
        const baseVertexCount  = positions.length * radialSegments;
        const addCaps          = capType === CAP_TYPE_FLAT && !closed;
        const extraCapVertices = addCaps ? TWO_VALUE : ZERO_VALUE;
        const totalVertexCount = baseVertexCount + extraCapVertices;
        const positionsBuffer  = new Float32Array(totalVertexCount * POSITION_COMPONENT_COUNT);

        TubeLineGeometry.#writeRingPositions(positionsBuffer, positions, radialSegments, resolvedRadius, closed);

        if (addCaps) {
            TubeLineGeometry.#writeCapCenters(positionsBuffer, positions, baseVertexCount);
        }

        const indices          = TubeLineGeometry.#buildIndices(positions.length, radialSegments, closed, addCaps, baseVertexCount);
        const wireframeIndices = createWireframeIndicesFromSolidIndices(totalVertexCount, indices);

        super(
            webglContext,
            positionsBuffer,
            null,
            indices,
            wireframeIndices,
            null,
            null,
            {
                solidPrimitive     : PRIMITIVE_TRIANGLES,
                wireframePrimitive : PRIMITIVE_LINES
            }
        );
    }

    /**
     * @param {Float32Array} buffer   - Output positions buffer.
     * @param {Vector3[]} positions   - Input path positions.
     * @param {number} radialSegments - Radial segment count.
     * @param {number} radius         - Tube radius.
     * @param {boolean} closed        - Whether path is closed.
     * @private
     */
    static #writeRingPositions(buffer, positions, radialSegments, radius, closed) {
        const pointCount = positions.length;

        for (let index = ZERO_VALUE; index < pointCount; index += ONE_VALUE) {
            const previousIndex = TubeLineGeometry.#getPreviousIndex(index, pointCount, closed);
            const nextIndex     = TubeLineGeometry.#getNextIndex(index, pointCount, closed);
            const tangent       = TubeLineGeometry.#computeTangent(positions[previousIndex], positions[nextIndex]);
            const normal        = TubeLineGeometry.#computeNormal(tangent);
            const binormal      = TubeLineGeometry.#computeBinormal(tangent, normal);
            const ringBase      = index * radialSegments;
            const point         = positions[index];

            for (let segmentIndex = ZERO_VALUE; segmentIndex < radialSegments; segmentIndex += ONE_VALUE) {
                const angle       = TWO_PI * (segmentIndex / radialSegments);
                const cosAngle    = Math.cos(angle);
                const sinAngle    = Math.sin(angle);
                const offsetX     = (normal.x * cosAngle + binormal.x * sinAngle) * radius;
                const offsetY     = (normal.y * cosAngle + binormal.y * sinAngle) * radius;
                const offsetZ     = (normal.z * cosAngle + binormal.z * sinAngle) * radius;
                const vertexIndex = ringBase + segmentIndex;
                const baseIndex   = vertexIndex * POSITION_COMPONENT_COUNT;
                buffer[baseIndex + POSITION_X_OFFSET] = point.x + offsetX;
                buffer[baseIndex + POSITION_Y_OFFSET] = point.y + offsetY;
                buffer[baseIndex + POSITION_Z_OFFSET] = point.z + offsetZ;
            }
        }
    }

    /**
     * @param {Float32Array} buffer    - Output positions buffer.
     * @param {Vector3[]} positions    - Input path positions.
     * @param {number} baseVertexCount - Base vertex count before caps.
     * @private
     */
    static #writeCapCenters(buffer, positions, baseVertexCount) {
        const startBaseIndex = baseVertexCount * POSITION_COMPONENT_COUNT;
        const endBaseIndex   = (baseVertexCount + ONE_VALUE) * POSITION_COMPONENT_COUNT;
        const startPoint     = positions[ZERO_VALUE];
        const endPoint       = positions[positions.length - ONE_VALUE];

        buffer[startBaseIndex + POSITION_X_OFFSET] = startPoint.x;
        buffer[startBaseIndex + POSITION_Y_OFFSET] = startPoint.y;
        buffer[startBaseIndex + POSITION_Z_OFFSET] = startPoint.z;
        buffer[endBaseIndex + POSITION_X_OFFSET]   = endPoint.x;
        buffer[endBaseIndex + POSITION_Y_OFFSET]   = endPoint.y;
        buffer[endBaseIndex + POSITION_Z_OFFSET]   = endPoint.z;
    }

    /**
     * @param {number} pointCount      - Number of path points.
     * @param {number} radialSegments  - Radial segment count.
     * @param {boolean} closed         - Whether path is closed.
     * @param {boolean} addCaps        - Whether caps are added.
     * @param {number} baseVertexCount - Base vertex count before caps.
     * @returns {Uint16Array | Uint32Array}
     * @private
     */
    static #buildIndices(pointCount, radialSegments, closed, addCaps, baseVertexCount) {
        const segmentCount = closed ? pointCount : (pointCount - ONE_VALUE);
        const indices      = [];

        for (let segmentIndex = ZERO_VALUE; segmentIndex < segmentCount; segmentIndex += ONE_VALUE) {
            const ringStart     = segmentIndex * radialSegments;
            const nextRingStart = ((segmentIndex + ONE_VALUE) % pointCount) * radialSegments;

            for (let radialIndex = ZERO_VALUE; radialIndex < radialSegments; radialIndex += ONE_VALUE) {
                const nextRadialIndex = (radialIndex + ONE_VALUE) % radialSegments;
                const groupA          = ringStart + radialIndex;
                const groupB          = ringStart + nextRadialIndex;
                const groupC          = nextRingStart + radialIndex;
                const groupD          = nextRingStart + nextRadialIndex;
                indices.push(groupA, groupC, groupB);
                indices.push(groupB, groupC, groupD);
            }
        }

        if (addCaps) {
            const startCenterIndex = baseVertexCount;
            const endCenterIndex   = baseVertexCount + ONE_VALUE;
            const startRingStart   = ZERO_VALUE;
            const endRingStart     = (pointCount - ONE_VALUE) * radialSegments;

            for (let radialIndex = ZERO_VALUE; radialIndex < radialSegments; radialIndex += ONE_VALUE) {
                const nextRadialIndex = (radialIndex + ONE_VALUE) % radialSegments;
                const startA          = startRingStart + radialIndex;
                const startB          = startRingStart + nextRadialIndex;
                indices.push(startCenterIndex, startB, startA);

                const endA = endRingStart + radialIndex;
                const endB = endRingStart + nextRadialIndex;
                indices.push(endCenterIndex, endA, endB);
            }
        }

        return createIndexArray(baseVertexCount + (addCaps ? TWO_VALUE : ZERO_VALUE), indices);
    }

    /**
     * @param {number} index   - Current index.
     * @param {number} count   - Total count.
     * @param {boolean} closed - Whether path is closed.
     * @returns {number}
     * @private
     */
    static #getPreviousIndex(index, count, closed) {
        if (index > ZERO_VALUE) {
            return index - ONE_VALUE;
        }

        return closed ? (count - ONE_VALUE) : index;
    }

    /**
     * @param {number} index   - Current index.
     * @param {number} count   - Total count.
     * @param {boolean} closed - Whether path is closed.
     * @returns {number}
     * @private
     */
    static #getNextIndex(index, count, closed) {
        if (index < count - ONE_VALUE) {
            return index + ONE_VALUE;
        }

        return closed ? ZERO_VALUE : index;
    }

    /**
     * @param {Vector3} pointA - Start point.
     * @param {Vector3} pointB - End point.
     * @returns {Vector3}
     * @private
     */
    static #computeTangent(pointA, pointB) {
        const deltaX = pointB.x - pointA.x;
        const deltaY = pointB.y - pointA.y;
        const deltaZ = pointB.z - pointA.z;
        const length = Math.sqrt((deltaX * deltaX) + (deltaY * deltaY) + (deltaZ * deltaZ));

        if (length <= NORMALIZE_EPSILON) {
            return new Vector3(ZERO_VALUE, ONE_VALUE, ZERO_VALUE);
        }

        return new Vector3(deltaX / length, deltaY / length, deltaZ / length);
    }

    /**
     * @param {Vector3} tangent - Tangent direction.
     * @returns {Vector3}
     * @private
     */
    static #computeNormal(tangent) {
        let normalX = (tangent.y * UP_AXIS_Z) - (tangent.z * UP_AXIS_Y);
        let normalY = (tangent.z * UP_AXIS_X) - (tangent.x * UP_AXIS_Z);
        let normalZ = (tangent.x * UP_AXIS_Y) - (tangent.y * UP_AXIS_X);
        let length = Math.sqrt((normalX * normalX) + (normalY * normalY) + (normalZ * normalZ));

        if (length <= NORMALIZE_EPSILON) {
            normalX = (tangent.y * FALLBACK_AXIS_Z) - (tangent.z * FALLBACK_AXIS_Y);
            normalY = (tangent.z * FALLBACK_AXIS_X) - (tangent.x * FALLBACK_AXIS_Z);
            normalZ = (tangent.x * FALLBACK_AXIS_Y) - (tangent.y * FALLBACK_AXIS_X);
            length  = Math.sqrt((normalX * normalX) + (normalY * normalY) + (normalZ * normalZ));
        }

        if (length <= NORMALIZE_EPSILON) {
            normalX = (tangent.y * SECOND_FALLBACK_AXIS_Z) - (tangent.z * SECOND_FALLBACK_AXIS_Y);
            normalY = (tangent.z * SECOND_FALLBACK_AXIS_X) - (tangent.x * SECOND_FALLBACK_AXIS_Z);
            normalZ = (tangent.x * SECOND_FALLBACK_AXIS_Y) - (tangent.y * SECOND_FALLBACK_AXIS_X);
            length  = Math.sqrt((normalX * normalX) + (normalY * normalY) + (normalZ * normalZ));
        }

        if (length <= NORMALIZE_EPSILON) {
            return new Vector3(ONE_VALUE, ZERO_VALUE, ZERO_VALUE);
        }

        return new Vector3(normalX / length, normalY / length, normalZ / length);
    }

    /**
     * @param {Vector3} tangent - Tangent direction.
     * @param {Vector3} normal  - Normal vector.
     * @returns {Vector3}
     * @private
     */
    static #computeBinormal(tangent, normal) {
        const binormalX = (tangent.y * normal.z) - (tangent.z * normal.y);
        const binormalY = (tangent.z * normal.x) - (tangent.x * normal.z);
        const binormalZ = (tangent.x * normal.y) - (tangent.y * normal.x);
        const length    = Math.sqrt((binormalX * binormalX) + (binormalY * binormalY) + (binormalZ * binormalZ));

        if (length <= NORMALIZE_EPSILON) {
            return new Vector3(ZERO_VALUE, ZERO_VALUE, ZERO_VALUE);
        }

        return new Vector3(binormalX / length, binormalY / length, binormalZ / length);
    }
}