Source: geometry/torus-geometry.js

import { Geometry } from './geometry.js';
import {
    DEFAULT_VERTEX_COLOR,
    createColorsFromSpec,
    createIndexArray,
    createWireframeIndicesFromSolidIndices
} from './geometry-utils.js';

/**
 * Default torus major diameter (center ring diameter without tube).
 *
 * @type {number}
 */
const DEFAULT_MAJOR_DIAMETER = 1.5;

/**
 * Default torus tube diameter.
 *
 * @type {number}
 */
const DEFAULT_TUBE_DIAMETER = 0.5;

/**
 * Default radial segment count (tube segments).
 *
 * @type {number}
 */
const DEFAULT_RADIAL_SEGMENTS = 16;

/**
 * Default tubular segment count (around the ring).
 *
 * @type {number}
 */
const DEFAULT_TUBULAR_SEGMENTS = 32;

/**
 * Minimum segment count for torus grids.
 *
 * @type {number}
 */
const MIN_SEGMENT_COUNT = 3;

/**
 * Divisor used to compute radius from diameter.
 *
 * @type {number}
 */
const HALF_SIZE_DIVISOR = 2.0;

/**
 * Adds one vertex per grid intersection, so vertex count along an axis is `segments + 1`.
 *
 * @type {number}
 */
const VERTICES_PER_SEGMENT_INCREMENT = 1;

/**
 * UV/V coordinate is flipped to keep (0, 0) at top-left.
 *
 * @type {number}
 */
const UV_V_FLIP_BASE = 1.0;

/**
 * Number of float components per `vec3` (position/normal).
 *
 * @type {number}
 */
const VEC3_COMPONENT_COUNT = 3;

/**
 * Number of float components per `vec2` (uv).
 *
 * @type {number}
 */
const VEC2_COMPONENT_COUNT = 2;

/**
 * Constant for `2 * pi`.
 *
 * @type {number}
 */
const TWO_PI = Math.PI * 2.0;

/**
 * Offset to move from a vertex to the next vertex in the same row.
 *
 * @type {number}
 */
const NEXT_VERTEX_OFFSET = 1;

/**
 * Torus geometry options.
 *
 * `width` and `height` are interpreted as:
 * - `width`  = major diameter (center ring diameter)
 * - `height` = tube diameter
 *
 * Segment parameters must be integers `>= 3`.
 *
 * `colors` supports:
 * - Uniform RGB    `length === 3`
 * - Per-vertex RGB `length === vertexCount * 3`
 *
 * @typedef {Object} TorusGeometryOptions
 * @property {number} [width = 1.5]          - Major diameter.
 * @property {number} [height = 0.5]         - Tube diameter.
 * @property {number} [tubularSegments = 32] - Segments around the ring `>= 3`.
 * @property {number} [radialSegments = 16]  - Segments around the tube `>= 3`.
 * @property {Float32Array} [colors]         - Color specification buffer.
 */

/**
 * Internal geometry buffers produced by `TorusGeometry`.
 *
 * @typedef {Object} TorusGeometryData
 * @property {Float32Array} positions                     - Vertex positions (xyz).
 * @property {Float32Array} normals                       - Vertex normals (xyz).
 * @property {Float32Array} uvs                           - Vertex UVs (uv).
 * @property {Float32Array} colors                        - Vertex colors (rgb).
 * @property {Uint16Array | Uint32Array} indicesSolid     - Triangle index buffer.
 * @property {Uint16Array | Uint32Array} indicesWireframe - Line index buffer for wireframe.
 */

/**
 * Segmented torus geometry.
 */
export class TorusGeometry extends Geometry {

    /**
     * @param {WebGL2RenderingContext} webglContext - WebGL2 rendering context.
     * @param {TorusGeometryOptions} [options = {}] - Geometry options.
     */
    constructor(webglContext, options = {}) {
        const normalized = TorusGeometry.#normalizeOptions(options);
        const data       = TorusGeometry.#createGeometryData(normalized);

        super(
            webglContext,
            data.positions,
            data.colors,
            data.indicesSolid,
            data.indicesWireframe,
            data.uvs,
            data.normals
        );
    }

    /**
     * Normalizes constructor input to a `TorusGeometryOptions` object.
     *
     * @param {TorusGeometryOptions} options     - Options object.
     * @returns {Required<TorusGeometryOptions>} - Normalized options.
     * @private
     */
    static #normalizeOptions(options) {
        if (options === null || typeof options !== 'object') {
            throw new TypeError('`TorusGeometry` expects options as an object.');
        }

        const {
            width           = DEFAULT_MAJOR_DIAMETER,
            height          = DEFAULT_TUBE_DIAMETER,
            tubularSegments = DEFAULT_TUBULAR_SEGMENTS,
            radialSegments  = DEFAULT_RADIAL_SEGMENTS,
            colors          = DEFAULT_VERTEX_COLOR
        } = options;

        if (typeof width !== 'number' || typeof height !== 'number') {
            throw new TypeError('`TorusGeometry` expects `width/height` as numbers.');
        }

        if (!Number.isFinite(width) || !Number.isFinite(height)) {
            throw new RangeError('`TorusGeometry` expects finite `width/height`.');
        }

        if (!(colors instanceof Float32Array)) {
            throw new TypeError('`TorusGeometry` expects colors as a `Float32Array`.');
        }

        return {
            width,
            height,
            tubularSegments : TorusGeometry.#normalizeSegmentCount(tubularSegments, 'tubularSegments'),
            radialSegments  : TorusGeometry.#normalizeSegmentCount(radialSegments,  'radialSegments'),
            colors
        };
    }

    /**
     * Normalizes and validates a segment count parameter.
     *
     * @param {number} value      - Segment count value.
     * @param {string} optionName - Name of the option for error messages.
     * @returns {number}          - Normalized integer `>= 3`.
     * @private
     */
    static #normalizeSegmentCount(value, optionName) {
        if (typeof value !== 'number' || !Number.isFinite(value)) {
            throw new TypeError('`TorusGeometry` expects `{name}` as a finite number.'.replace('{name}', optionName));
        }

        const intValue = Math.floor(value);

        if (intValue < MIN_SEGMENT_COUNT) {
            /* eslint-disable indent */
            throw new RangeError(
                '`TorusGeometry` expects `{name}` to be `>= {min}`.'
                .replace('{name}', optionName)
                .replace('{min}', String(MIN_SEGMENT_COUNT))
            );
            /* eslint-enable indent */
        }

        return intValue;
    }

    /**
     * Creates full geometry data for a torus.
     *
     * @param {Required<TorusGeometryOptions>} options - Normalized options.
     * @returns {TorusGeometryData}                    - Geometry buffers.
     * @private
     */
    static #createGeometryData(options) {
        const majorRadius        = options.width  / HALF_SIZE_DIVISOR;
        const tubeRadius         = options.height / HALF_SIZE_DIVISOR;
        const tubularSegments    = options.tubularSegments;
        const radialSegments     = options.radialSegments;
        const tubularVertexCount = tubularSegments + VERTICES_PER_SEGMENT_INCREMENT;
        const radialVertexCount  = radialSegments  + VERTICES_PER_SEGMENT_INCREMENT;
        const vertexCount        = tubularVertexCount * radialVertexCount;
        const positions          = new Float32Array(vertexCount * VEC3_COMPONENT_COUNT);
        const normals            = new Float32Array(vertexCount * VEC3_COMPONENT_COUNT);
        const uvs                = new Float32Array(vertexCount * VEC2_COMPONENT_COUNT);
        let vertexIndex = 0;

        for (let radialIndex = 0; radialIndex < radialVertexCount; radialIndex += 1) {
            const vNormalized = radialIndex / radialSegments;
            const phi         = vNormalized * TWO_PI;
            const cosPhi      = Math.cos(phi);
            const sinPhi      = Math.sin(phi);

            for (let tubularIndex = 0; tubularIndex < tubularVertexCount; tubularIndex += 1) {
                const uNormalized = tubularIndex / tubularSegments;
                const theta       = uNormalized * TWO_PI;
                const cosTheta    = Math.cos(theta);
                const sinTheta    = Math.sin(theta);
                const ringRadius  = majorRadius + (tubeRadius * cosPhi);

                const positionX = ringRadius * cosTheta;
                const positionY = tubeRadius * sinPhi;
                const positionZ = ringRadius * sinTheta;

                const positionBase = vertexIndex * VEC3_COMPONENT_COUNT;
                positions[positionBase + 0] = positionX;
                positions[positionBase + 1] = positionY;
                positions[positionBase + 2] = positionZ;

                normals[positionBase + 0] = cosTheta * cosPhi;
                normals[positionBase + 1] = sinPhi;
                normals[positionBase + 2] = sinTheta * cosPhi;

                const uvBase = vertexIndex * VEC2_COMPONENT_COUNT;
                uvs[uvBase + 0] = uNormalized;
                uvs[uvBase + 1] = UV_V_FLIP_BASE - vNormalized;
                vertexIndex += 1;
            }
        }

        const indicesSolidList = [];

        for (let radialIndex = 0; radialIndex < radialSegments; radialIndex += 1) {
            for (let tubularIndex = 0; tubularIndex < tubularSegments; tubularIndex += 1) {
                const topLeftVertexIndex     = (radialIndex * tubularVertexCount) + tubularIndex;
                const topRightVertexIndex    = topLeftVertexIndex    + NEXT_VERTEX_OFFSET;
                const bottomLeftVertexIndex  = topLeftVertexIndex    + tubularVertexCount;
                const bottomRightVertexIndex = bottomLeftVertexIndex + NEXT_VERTEX_OFFSET;
                indicesSolidList.push(topLeftVertexIndex, bottomLeftVertexIndex, topRightVertexIndex);
                indicesSolidList.push(topRightVertexIndex, bottomLeftVertexIndex, bottomRightVertexIndex);
            }
        }

        const indicesSolid     = createIndexArray(vertexCount, indicesSolidList);
        const indicesWireframe = createWireframeIndicesFromSolidIndices(vertexCount, indicesSolid);
        const colors           = createColorsFromSpec(vertexCount, options.colors);

        return {
            positions,
            normals,
            uvs,
            colors,
            indicesSolid,
            indicesWireframe
        };
    }
}