Source: geometry/pyramid-geometry.js

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

/**
 * Default pyramid base width.
 *
 * @type {number}
 */
const DEFAULT_PYRAMID_WIDTH = 1.0;

/**
 * Default pyramid height.
 *
 * @type {number}
 */
const DEFAULT_PYRAMID_HEIGHT = 1.5;

/**
 * Default segment count for base edges.
 *
 * @type {number}
 */
const DEFAULT_BASE_SEGMENT_COUNT = 1;

/**
 * Default segment count along side height.
 *
 * @type {number}
 */
const DEFAULT_HEIGHT_SEGMENT_COUNT = 1;

/**
 * Minimum segment count supported by segmented geometries.
 *
 * @type {number}
 */
const MIN_SEGMENT_COUNT = 1;

/**
 * Divisor used to compute half sizes.
 *
 * @type {number}
 */
const HALF_SIZE_DIVISOR = 2.0;

/**
 * Used to center coordinates around the origin: `(t - 0.5) * size`.
 *
 * @type {number}
 */
const CENTER_T_OFFSET = 0.5;

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

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

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

/**
 * Common numeric constants.
 *
 * @type {number}
 */
const ZERO_VALUE = 0.0;

/**
 * Common numeric constants.
 *
 * @type {number}
 */
const ONE_VALUE = 1.0;

/**
 * Common numeric constants.
 *
 * @type {number}
 */
const NEGATIVE_ONE_VALUE = -1.0;

/**
 * Default UV for apex vertex.
 *
 * @type {number}
 */
const APEX_UV_U = 0.5;

/**
 * Default UV for apex vertex.
 *
 * @type {number}
 */
const APEX_UV_V = 0.0;

/**
 * Outward direction hints used to ensure side face normals point outside.
 *
 * @type {number[]}
 */
const OUTWARD_HINT_FRONT = [0.0, 0.0, 1.0];

/**
 * Outward direction hints used to ensure side face normals point outside.
 *
 * @type {number[]}
 */
const OUTWARD_HINT_RIGHT = [1.0, 0.0, 0.0];

/**
 * Outward direction hints used to ensure side face normals point outside.
 *
 * @type {number[]}
 */
const OUTWARD_HINT_BACK = [0.0, 0.0, -1.0];

/**
 * Outward direction hints used to ensure side face normals point outside.
 *
 * @type {number[]}
 */
const OUTWARD_HINT_LEFT = [-1.0, 0.0, 0.0];

/**
 * Pyramid geometry options.
 *
 * `width` is the base size along X. `depth` (optional) is the base size along Z.
 * If `depth` is not provided, it defaults to `width` (square base).
 *
 * `colors` supports:
 * - Uniform RGB    (`length === 3`)
 * - Per-vertex RGB (`length === vertexCount * 3`)
 *
 * Segment parameters must be integers `>= 1`.
 *
 * @typedef {Object} PyramidGeometryOptions
 * @property {number} [width = 1.0]         - Base width along X.
 * @property {number} [height = 1.5]        - Pyramid height along Y.
 * @property {number} [depth = width]       - Base depth along Z.
 * @property {number} [widthSegments = 1]   - Subdivisions along X (base grid and faces that use X edges).
 * @property {number} [depthSegments = 1]   - Subdivisions along Z (base grid and faces that use Z edges).
 * @property {number} [heightSegments = 1]  - Subdivisions along side height.
 * @property {boolean} [capped = true]      - Whether to generate the bottom face.
 * @property {Float32Array} [colors]        - Color specification buffer.
 */

/**
 * Internal geometry buffers produced by `PyramidGeometry`.
 *
 * @typedef {Object} PyramidGeometryData
 * @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.
 */

/**
 * Small result object returned by base appender.
 *
 * @typedef {Object} PyramidBaseAppendResult
 * @property {number} vertexCount - Number of vertices appended for the base.
 */

/**
 * Segmented pyramid geometry with a rectangular base and 4 planar side faces.
 * Side faces use flat normals (sharp edges).
 */
export class PyramidGeometry extends Geometry {

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

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

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

        const {
            width          = DEFAULT_PYRAMID_WIDTH,
            height         = DEFAULT_PYRAMID_HEIGHT,
            depth          = width,
            widthSegments  = DEFAULT_BASE_SEGMENT_COUNT,
            depthSegments  = widthSegments,
            heightSegments = DEFAULT_HEIGHT_SEGMENT_COUNT,
            capped         = true,
            colors         = DEFAULT_VERTEX_COLOR
        } = options;

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

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

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

        return {
            width,
            height,
            depth,
            widthSegments  : PyramidGeometry.#normalizeSegmentCount(widthSegments,  'widthSegments',  MIN_SEGMENT_COUNT),
            depthSegments  : PyramidGeometry.#normalizeSegmentCount(depthSegments,  'depthSegments',  MIN_SEGMENT_COUNT),
            heightSegments : PyramidGeometry.#normalizeSegmentCount(heightSegments, 'heightSegments', MIN_SEGMENT_COUNT),
            capped         : Boolean(capped),
            colors
        };
    }

    /**
     * Normalizes and validates a segment count parameter.
     *
     * @param {number} value      - Segment count.
     * @param {string} optionName - Option name.
     * @param {number} minValue   - Minimal allowed value.
     * @returns {number}          - Integer segment count.
     * @private
     */
    static #normalizeSegmentCount(value, optionName, minValue) {
        if (typeof value !== 'number' || !Number.isFinite(value)) {
            throw new TypeError('`PyramidGeometry` expects `{name}` as a finite number.'.replace('{name}', optionName));
        }

        const intValue = Math.floor(value);

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

        return intValue;
    }

    /**
     * Creates full geometry data for a segmented pyramid.
     *
     * @param {Required<PyramidGeometryOptions>} options - Normalized options.
     * @returns {PyramidGeometryData}                    - Geometry buffers.
     * @private
     */
    static #createGeometryData(options) {
        const halfWidth  = options.width  / HALF_SIZE_DIVISOR;
        const halfDepth  = options.depth  / HALF_SIZE_DIVISOR;
        const halfHeight = options.height / HALF_SIZE_DIVISOR;

        const apexPoint        = [ZERO_VALUE, halfHeight, ZERO_VALUE];
        const positions        = [];
        const normals          = [];
        const uvs              = [];
        const indicesSolidList = [];
        let vertexOffset = 0;

        // Base (optional):
        if (options.capped) {
            const baseAppendResult = PyramidGeometry.#appendBase(
                positions,
                normals,
                uvs,
                indicesSolidList,
                vertexOffset,
                halfWidth,
                halfDepth,
                halfHeight,
                options.widthSegments,
                options.depthSegments
            );

            vertexOffset += baseAppendResult.vertexCount;
        }

        // Side faces:
        const baseY   = -halfHeight;
        const corners = {
            frontLeft  : [-halfWidth,  baseY,  halfDepth],
            frontRight : [ halfWidth,  baseY,  halfDepth],
            backRight  : [ halfWidth,  baseY, -halfDepth],
            backLeft   : [-halfWidth,  baseY, -halfDepth]
        };

        // Front face (+Z):
        vertexOffset += PyramidGeometry.#appendSideFace(
            positions,
            normals,
            uvs,
            indicesSolidList,
            vertexOffset,
            corners.frontLeft,
            corners.frontRight,
            apexPoint,
            options.widthSegments,
            options.heightSegments,
            OUTWARD_HINT_FRONT
        );

        // Right face (+X):
        vertexOffset += PyramidGeometry.#appendSideFace(
            positions,
            normals,
            uvs,
            indicesSolidList,
            vertexOffset,
            corners.frontRight,
            corners.backRight,
            apexPoint,
            options.depthSegments,
            options.heightSegments,
            OUTWARD_HINT_RIGHT
        );

        // Back face (-Z):
        vertexOffset += PyramidGeometry.#appendSideFace(
            positions,
            normals,
            uvs,
            indicesSolidList,
            vertexOffset,
            corners.backRight,
            corners.backLeft,
            apexPoint,
            options.widthSegments,
            options.heightSegments,
            OUTWARD_HINT_BACK
        );

        // Left face (-X):
        vertexOffset += PyramidGeometry.#appendSideFace(
            positions,
            normals,
            uvs,
            indicesSolidList,
            vertexOffset,
            corners.backLeft,
            corners.frontLeft,
            apexPoint,
            options.depthSegments,
            options.heightSegments,
            OUTWARD_HINT_LEFT
        );

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

        return {
            positions : new Float32Array(positions),
            normals   : new Float32Array(normals),
            uvs       : new Float32Array(uvs),
            colors,
            indicesSolid,
            indicesWireframe
        };
    }

    /**
     * Appends a bottom base grid `XZ plane` with a `-Y` normal.
     *
     * @param {number[]} positions        - Output positions (flat vec3).
     * @param {number[]} normals          - Output normals (flat vec3).
     * @param {number[]} uvs              - Output UVs (flat vec2).
     * @param {number[]} indicesSolid     - Output solid indices.
     * @param {number} vertexOffset       - Starting vertex index.
     * @param {number} halfWidth          - Half base width.
     * @param {number} halfDepth          - Half base depth.
     * @param {number} halfHeight         - Half pyramid height.
     * @param {number} widthSegments      - Base subdivisions along X.
     * @param {number} depthSegments      - Base subdivisions along Z.
     * @returns {PyramidBaseAppendResult} - Base append result.
     * @private
     */
    static #appendBase(
        positions,
        normals,
        uvs,
        indicesSolid,
        vertexOffset,
        halfWidth,
        halfDepth,
        halfHeight,
        widthSegments,
        depthSegments
    ) {
        const xSegments    = widthSegments;
        const zSegments    = depthSegments;
        const xVertexCount = xSegments + VERTICES_PER_SEGMENT_INCREMENT;
        const zVertexCount = zSegments + VERTICES_PER_SEGMENT_INCREMENT;
        const baseY        = -halfHeight;
        const fullWidth    = halfWidth * HALF_SIZE_DIVISOR;
        const fullDepth    = halfDepth * HALF_SIZE_DIVISOR;

        for (let zIndex = 0; zIndex < zVertexCount; zIndex += 1) {
            const vNormalized = zIndex / zSegments;
            const positionZ   = (vNormalized - CENTER_T_OFFSET) * fullDepth;

            for (let xIndex = 0; xIndex < xVertexCount; xIndex += 1) {
                const uNormalized = xIndex / xSegments;
                const positionX   = (uNormalized - CENTER_T_OFFSET) * fullWidth;
                positions.push(positionX, baseY, positionZ);
                normals.push(ZERO_VALUE, NEGATIVE_ONE_VALUE, ZERO_VALUE);
                uvs.push(uNormalized, UV_V_FLIP_BASE - vNormalized);
            }
        }

        for (let zIndex = 0; zIndex < zSegments; zIndex += 1) {
            for (let xIndex = 0; xIndex < xSegments; xIndex += 1) {
                const topLeftVertexIndex     = vertexOffset + (zIndex * xVertexCount) + xIndex;
                const topRightVertexIndex    = topLeftVertexIndex + NEXT_VERTEX_OFFSET;
                const bottomLeftVertexIndex  = topLeftVertexIndex + xVertexCount;
                const bottomRightVertexIndex = bottomLeftVertexIndex + NEXT_VERTEX_OFFSET;
                indicesSolid.push(topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex);
                indicesSolid.push(topRightVertexIndex, bottomRightVertexIndex, bottomLeftVertexIndex);
            }
        }

        return { vertexCount: xVertexCount * zVertexCount };
    }

    /**
     * Appends a single planar side face subdivided into a grid.
     * The face uses a flat normal (sharp edges).
     *
     * @param {number[]} positions    - Output positions.
     * @param {number[]} normals      - Output normals.
     * @param {number[]} uvs          - Output UVs.
     * @param {number[]} indicesSolid - Output solid indices.
     * @param {number} vertexOffset   - Starting vertex index.
     * @param {number[]} baseStart    - Base edge start point [x, y, z].
     * @param {number[]} baseEnd      - Base edge end point [x, y, z].
     * @param {number[]} apex         - Apex point [x, y, z].
     * @param {number} edgeSegments   - Subdivisions along the base edge.
     * @param {number} heightSegments - Subdivisions along the face height.
     * @param {number[]} outwardHint  - Expected outward direction hint.
     * @returns {number}              - Number of vertices appended.
     * @private
     */
    static #appendSideFace(
        positions,
        normals,
        uvs,
        indicesSolid,
        vertexOffset,
        baseStart,
        baseEnd,
        apex,
        edgeSegments,
        heightSegments,
        outwardHint
    ) {
        let edgeStart  = baseStart;
        let edgeEnd    = baseEnd;
        let faceNormal = PyramidGeometry.#computeFaceNormal(edgeStart, edgeEnd, apex);

        if (PyramidGeometry.#dot(faceNormal, outwardHint) < ZERO_VALUE) {
            edgeStart  = baseEnd;
            edgeEnd    = baseStart;
            faceNormal = PyramidGeometry.#computeFaceNormal(edgeStart, edgeEnd, apex);
        }

        const edgeVertexCount = edgeSegments + VERTICES_PER_SEGMENT_INCREMENT;
        const ringCount       = heightSegments;
        const faceVertexCount = (ringCount * edgeVertexCount) + VERTICES_PER_SEGMENT_INCREMENT;

        for (let ringIndex = 0; ringIndex < ringCount; ringIndex += 1) {
            const heightNormalized = ringIndex / heightSegments;
            const rowStart         = PyramidGeometry.#lerp3(edgeStart, apex, heightNormalized);
            const rowEnd           = PyramidGeometry.#lerp3(edgeEnd, apex, heightNormalized);

            for (let edgeIndex = 0; edgeIndex < edgeVertexCount; edgeIndex += 1) {
                const edgeNormalized = edgeIndex / edgeSegments;
                const point          = PyramidGeometry.#lerp3(rowStart, rowEnd, edgeNormalized);
                positions.push(point[0], point[1], point[2]);
                normals.push(faceNormal[0], faceNormal[1], faceNormal[2]);
                uvs.push(edgeNormalized, UV_V_FLIP_BASE - heightNormalized);
            }
        }

        positions.push(apex[0], apex[1], apex[2]);
        normals.push(faceNormal[0], faceNormal[1], faceNormal[2]);
        uvs.push(APEX_UV_U, APEX_UV_V);

        const apexVertexIndex = vertexOffset + faceVertexCount - VERTICES_PER_SEGMENT_INCREMENT;

        for (let ringIndex = 0; ringIndex < (ringCount - VERTICES_PER_SEGMENT_INCREMENT); ringIndex += 1) {
            const ringStartVertexIndex = vertexOffset + (ringIndex * edgeVertexCount);
            const nextRingVertexIndex  = vertexOffset + ((ringIndex + VERTICES_PER_SEGMENT_INCREMENT) * edgeVertexCount);

            for (let edgeIndex = 0; edgeIndex < edgeSegments; edgeIndex += 1) {
                const topLeftVertexIndex     = ringStartVertexIndex + edgeIndex;
                const topRightVertexIndex    = topLeftVertexIndex + NEXT_VERTEX_OFFSET;
                const bottomLeftVertexIndex  = nextRingVertexIndex + edgeIndex;
                const bottomRightVertexIndex = bottomLeftVertexIndex + NEXT_VERTEX_OFFSET;
                indicesSolid.push(topLeftVertexIndex, bottomLeftVertexIndex, topRightVertexIndex);
                indicesSolid.push(topRightVertexIndex, bottomLeftVertexIndex, bottomRightVertexIndex);
            }
        }

        const topRingStartVertexIndex = vertexOffset + ((ringCount - VERTICES_PER_SEGMENT_INCREMENT) * edgeVertexCount);

        for (let edgeIndex = 0; edgeIndex < edgeSegments; edgeIndex += 1) {
            const topLeftVertexIndex  = topRingStartVertexIndex + edgeIndex;
            const topRightVertexIndex = topLeftVertexIndex + NEXT_VERTEX_OFFSET;
            indicesSolid.push(topLeftVertexIndex, apexVertexIndex, topRightVertexIndex);
        }

        return faceVertexCount;
    }

    /**
     * Computes a normalized face normal from 3 points.
     *
     * @param {number[]} pointA - Point A [x, y, z].
     * @param {number[]} pointB - Point B [x, y, z].
     * @param {number[]} pointC - Point C [x, y, z].
     * @returns {number[]}      - Normalized normal vector [x, y, z].
     * @private
     */
    static #computeFaceNormal(pointA, pointB, pointC) {
        const vectorAB = [
            pointB[0] - pointA[0],
            pointB[1] - pointA[1],
            pointB[2] - pointA[2]
        ];

        const vectorAC = [
            pointC[0] - pointA[0],
            pointC[1] - pointA[1],
            pointC[2] - pointA[2]
        ];

        const normalX0 = (vectorAB[1] * vectorAC[2]) - (vectorAB[2] * vectorAC[1]);
        const normalY0 = (vectorAB[2] * vectorAC[0]) - (vectorAB[0] * vectorAC[2]);
        const normalZ0 = (vectorAB[0] * vectorAC[1]) - (vectorAB[1] * vectorAC[0]);
        const inverseNormalLength = PyramidGeometry.#inverseLength(normalX0, normalY0, normalZ0);
        return [normalX0 * inverseNormalLength, normalY0 * inverseNormalLength, normalZ0 * inverseNormalLength];
    }

    /**
     * Linear interpolation between points A and B.
     *
     * @param {number[]} pointA            - Point A [x, y, z].
     * @param {number[]} pointB            - Point B [x, y, z].
     * @param {number} interpolationFactor - Interpolation factor.
     * @returns {number[]}                 - Interpolated point [x, y, z].
     * @private
     */
    static #lerp3(pointA, pointB, interpolationFactor) {
        return [
            pointA[0] + ((pointB[0] - pointA[0]) * interpolationFactor),
            pointA[1] + ((pointB[1] - pointA[1]) * interpolationFactor),
            pointA[2] + ((pointB[2] - pointA[2]) * interpolationFactor)
        ];
    }

    /**
     * Dot product of two `vec3` arrays.
     *
     * @param {number[]} vectorA - Vector A.
     * @param {number[]} vectorB - Vector B.
     * @returns {number}         - Dot product.
     * @private
     */
    static #dot(vectorA, vectorB) {
        return (vectorA[0] * vectorB[0]) + (vectorA[1] * vectorB[1]) + (vectorA[2] * vectorB[2]);
    }

    /**
     * Computes inverse vector length `(1 / sqrt(x ^ 2 + y ^ 2 + z ^ 2))`.
     * Returns 0 when the input vector is zero-length.
     *
     * @param {number} x - X component.
     * @param {number} y - Y component.
     * @param {number} z - Z component.
     * @returns {number} - Inverse length.
     * @private
     */
    static #inverseLength(x, y, z) {
        const length = Math.sqrt((x * x) + (y * y) + (z * z));

        if (length === ZERO_VALUE) {
            return ZERO_VALUE;
        }

        return ONE_VALUE / length;
    }
}