import { GeometryUtils } from './geometry-utils.js';
import { GeneratedGeometry } from './generated-geometry.js';
import { MATH_COMMON_VALUES, MATH_VECTOR3_INDEXES } from '../constants/math.js';
import { ECMASCRIPT_TYPEOF_RESULTS } from '../constants/ecmascript-types.js';
import {
GEOMETRY_DEFAULTS,
DEFAULT_VERTEX_COLOR,
GEOMETRY_SIZES,
GEOMETRY_GRID,
GEOMETRY_UV
} from '../constants/geometry.js';
import {
PYRAMID_DEFAULTS,
PYRAMID_LIMITS,
PYRAMID_LAYOUT,
PYRAMID_DIRECTIONS
} from '../constants/pyramid-geometry.js';
/**
* 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 = GEOMETRY_DEFAULTS.CAPPED] - 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 GeneratedGeometry {
/**
* 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 !== ECMASCRIPT_TYPEOF_RESULTS.OBJECT) {
throw new TypeError('PyramidGeometry expects options as an object.');
}
const {
width = PYRAMID_DEFAULTS.WIDTH,
height = PYRAMID_DEFAULTS.HEIGHT,
depth = width,
widthSegments = PYRAMID_DEFAULTS.BASE_SEGMENT_COUNT,
depthSegments = widthSegments,
heightSegments = PYRAMID_DEFAULTS.HEIGHT_SEGMENT_COUNT,
capped = GEOMETRY_DEFAULTS.CAPPED,
colors = DEFAULT_VERTEX_COLOR
} = options;
if (typeof width !== ECMASCRIPT_TYPEOF_RESULTS.NUMBER ||
typeof height !== ECMASCRIPT_TYPEOF_RESULTS.NUMBER ||
typeof depth !== ECMASCRIPT_TYPEOF_RESULTS.NUMBER) {
throw new TypeError('PyramidGeometry expects width, height or depth as numbers.');
}
if (!Number.isFinite(width) || !Number.isFinite(height) || !Number.isFinite(depth)) {
throw new RangeError('PyramidGeometry expects finite width, height or depth.');
}
if (!(colors instanceof Float32Array)) {
throw new TypeError('PyramidGeometry expects colors as a Float32Array.');
}
return {
width,
height,
depth,
widthSegments : GeometryUtils.normalizeSegmentCount(widthSegments, 'widthSegments', PYRAMID_LIMITS.MIN_SEGMENT_COUNT, 'PyramidGeometry'),
depthSegments : GeometryUtils.normalizeSegmentCount(depthSegments, 'depthSegments', PYRAMID_LIMITS.MIN_SEGMENT_COUNT, 'PyramidGeometry'),
heightSegments : GeometryUtils.normalizeSegmentCount(heightSegments, 'heightSegments', PYRAMID_LIMITS.MIN_SEGMENT_COUNT, 'PyramidGeometry'),
capped : Boolean(capped),
colors
};
}
/**
* Generates vertex and index buffers from construction options.
*
* @param {PyramidGeometryOptions} [options] - Geometry options.
* @returns {PyramidGeometryData} - Generated CPU buffers.
* @protected
*/
static createGeometryData(options = {}) {
const normalized = PyramidGeometry.#normalizeOptions(options);
const buffers = { positions: [], normals: [], uvs: [], indicesSolid: [] };
let vertexOffset = MATH_COMMON_VALUES.ZERO;
if (normalized.capped) {
vertexOffset = PyramidGeometry.#appendBottom(buffers, normalized);
}
const vertexCount = PyramidGeometry.#appendSides(buffers, normalized, vertexOffset);
const indicesSolid = GeometryUtils.createIndexArray(vertexCount, buffers.indicesSolid);
const indicesWireframe = GeometryUtils.createWireframeIndicesFromSolidIndices(vertexCount, indicesSolid);
const colors = GeometryUtils.createColorsFromSpec(vertexCount, normalized.colors);
return {
positions : new Float32Array(buffers.positions),
normals : new Float32Array(buffers.normals),
uvs : new Float32Array(buffers.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 centerOffset = GEOMETRY_GRID.CENTER_OFFSET;
const zeroValue = MATH_COMMON_VALUES.ZERO;
const xSegments = widthSegments;
const zSegments = depthSegments;
const xVertexCount = xSegments + GEOMETRY_GRID.VERTEX_INCREMENT;
const zVertexCount = zSegments + GEOMETRY_GRID.VERTEX_INCREMENT;
const baseY = -halfHeight;
const fullWidth = halfWidth * GEOMETRY_SIZES.HALF_SIZE_DIVISOR;
const fullDepth = halfDepth * GEOMETRY_SIZES.HALF_SIZE_DIVISOR;
for (let zIndex = MATH_COMMON_VALUES.ZERO; zIndex < zVertexCount; zIndex += MATH_COMMON_VALUES.UNIT) {
const vNormalized = zIndex / zSegments;
const positionZ = (vNormalized - centerOffset) * fullDepth;
for (let xIndex = MATH_COMMON_VALUES.ZERO; xIndex < xVertexCount; xIndex += MATH_COMMON_VALUES.UNIT) {
const uNormalized = xIndex / xSegments;
const positionX = (uNormalized - centerOffset) * fullWidth;
positions.push(positionX, baseY, positionZ);
normals.push(zeroValue, PYRAMID_LAYOUT.NEGATIVE_ONE_VALUE, zeroValue);
uvs.push(uNormalized, GEOMETRY_UV.V_FLIP_BASE - vNormalized);
}
}
GeometryUtils.appendGridTriangleIndices(indicesSolid, xSegments, zSegments, vertexOffset, true);
return { vertexCount: xVertexCount * zVertexCount };
}
/**
* Appends a single planar side face subdivided into a grid.
* The face uses a flat normal (sharp edges).
*
* @param {Object} buffers - Output vertex and index lists.
* @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(buffers, vertexOffset, baseStart, baseEnd, apex, edgeSegments, heightSegments, outwardHint) {
const { positions, normals, uvs, indicesSolid } = buffers;
const vertexIncrement = GEOMETRY_GRID.VERTEX_INCREMENT;
const nextVertexOffset = GEOMETRY_GRID.NEXT_VERTEX_OFFSET;
let edgeStart = baseStart;
let edgeEnd = baseEnd;
let faceNormal = PyramidGeometry.#computeFaceNormal(edgeStart, edgeEnd, apex);
if (PyramidGeometry.#dot(faceNormal, outwardHint) < MATH_COMMON_VALUES.ZERO) {
edgeStart = baseEnd;
edgeEnd = baseStart;
faceNormal = PyramidGeometry.#computeFaceNormal(edgeStart, edgeEnd, apex);
}
const edgeVertexCount = edgeSegments + vertexIncrement;
const ringCount = heightSegments;
const faceVertexCount = (ringCount * edgeVertexCount) + vertexIncrement;
for (let ringIndex = MATH_COMMON_VALUES.ZERO; ringIndex < ringCount; ringIndex += MATH_COMMON_VALUES.UNIT) {
const heightNormalized = ringIndex / heightSegments;
const rowStart = PyramidGeometry.#lerp3(edgeStart, apex, heightNormalized);
const rowEnd = PyramidGeometry.#lerp3(edgeEnd, apex, heightNormalized);
for (let edgeIndex = MATH_COMMON_VALUES.ZERO; edgeIndex < edgeVertexCount; edgeIndex += MATH_COMMON_VALUES.UNIT) {
const edgeNormalized = edgeIndex / edgeSegments;
const point = PyramidGeometry.#lerp3(rowStart, rowEnd, edgeNormalized);
positions.push(point[MATH_VECTOR3_INDEXES.X], point[MATH_VECTOR3_INDEXES.Y], point[MATH_VECTOR3_INDEXES.Z]);
normals.push(faceNormal[MATH_VECTOR3_INDEXES.X], faceNormal[MATH_VECTOR3_INDEXES.Y], faceNormal[MATH_VECTOR3_INDEXES.Z]);
uvs.push(edgeNormalized, GEOMETRY_UV.V_FLIP_BASE - heightNormalized);
}
}
positions.push(apex[MATH_VECTOR3_INDEXES.X], apex[MATH_VECTOR3_INDEXES.Y], apex[MATH_VECTOR3_INDEXES.Z]);
normals.push(faceNormal[MATH_VECTOR3_INDEXES.X], faceNormal[MATH_VECTOR3_INDEXES.Y], faceNormal[MATH_VECTOR3_INDEXES.Z]);
uvs.push(PYRAMID_LAYOUT.APEX_UV_U, PYRAMID_LAYOUT.APEX_UV_V);
const apexVertexIndex = vertexOffset + faceVertexCount - vertexIncrement;
GeometryUtils.appendGridTriangleIndices(indicesSolid, edgeSegments, ringCount - vertexIncrement, vertexOffset);
const topRingStartVertexIndex = vertexOffset + ((ringCount - vertexIncrement) * edgeVertexCount);
for (let edgeIndex = MATH_COMMON_VALUES.ZERO; edgeIndex < edgeSegments; edgeIndex += MATH_COMMON_VALUES.UNIT) {
const topLeftVertexIndex = topRingStartVertexIndex + edgeIndex;
const topRightVertexIndex = topLeftVertexIndex + nextVertexOffset;
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[MATH_VECTOR3_INDEXES.X] - pointA[MATH_VECTOR3_INDEXES.X],
pointB[MATH_VECTOR3_INDEXES.Y] - pointA[MATH_VECTOR3_INDEXES.Y],
pointB[MATH_VECTOR3_INDEXES.Z] - pointA[MATH_VECTOR3_INDEXES.Z]
];
const vectorAC = [
pointC[MATH_VECTOR3_INDEXES.X] - pointA[MATH_VECTOR3_INDEXES.X],
pointC[MATH_VECTOR3_INDEXES.Y] - pointA[MATH_VECTOR3_INDEXES.Y],
pointC[MATH_VECTOR3_INDEXES.Z] - pointA[MATH_VECTOR3_INDEXES.Z]
];
const normalX0 = (vectorAB[MATH_VECTOR3_INDEXES.Y] * vectorAC[MATH_VECTOR3_INDEXES.Z]) - (vectorAB[MATH_VECTOR3_INDEXES.Z] * vectorAC[MATH_VECTOR3_INDEXES.Y]);
const normalY0 = (vectorAB[MATH_VECTOR3_INDEXES.Z] * vectorAC[MATH_VECTOR3_INDEXES.X]) - (vectorAB[MATH_VECTOR3_INDEXES.X] * vectorAC[MATH_VECTOR3_INDEXES.Z]);
const normalZ0 = (vectorAB[MATH_VECTOR3_INDEXES.X] * vectorAC[MATH_VECTOR3_INDEXES.Y]) - (vectorAB[MATH_VECTOR3_INDEXES.Y] * vectorAC[MATH_VECTOR3_INDEXES.X]);
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[MATH_VECTOR3_INDEXES.X] + ((pointB[MATH_VECTOR3_INDEXES.X] - pointA[MATH_VECTOR3_INDEXES.X]) * interpolationFactor),
pointA[MATH_VECTOR3_INDEXES.Y] + ((pointB[MATH_VECTOR3_INDEXES.Y] - pointA[MATH_VECTOR3_INDEXES.Y]) * interpolationFactor),
pointA[MATH_VECTOR3_INDEXES.Z] + ((pointB[MATH_VECTOR3_INDEXES.Z] - pointA[MATH_VECTOR3_INDEXES.Z]) * 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[MATH_VECTOR3_INDEXES.X] * vectorB[MATH_VECTOR3_INDEXES.X]) +
(vectorA[MATH_VECTOR3_INDEXES.Y] * vectorB[MATH_VECTOR3_INDEXES.Y]) +
(vectorA[MATH_VECTOR3_INDEXES.Z] * vectorB[MATH_VECTOR3_INDEXES.Z])
);
}
/**
* 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 === MATH_COMMON_VALUES.ZERO) {
return MATH_COMMON_VALUES.ZERO;
}
return MATH_COMMON_VALUES.UNIT / length;
}
/**
* Appends the optional bottom grid before the side faces.
*
* @param {Object} buffers - Output vertex and index lists.
* @param {Required<PyramidGeometryOptions>} options - Normalized geometry options.
* @returns {number} - Number of base vertices.
* @private
*/
static #appendBottom(buffers, options) {
const divisor = GEOMETRY_SIZES.HALF_SIZE_DIVISOR;
const result = PyramidGeometry.#appendBase(
buffers.positions,
buffers.normals,
buffers.uvs,
buffers.indicesSolid,
MATH_COMMON_VALUES.ZERO,
options.width / divisor,
options.depth / divisor,
options.height / divisor,
options.widthSegments,
options.depthSegments
);
return result.vertexCount;
}
/**
* Appends the front, right, back and left faces in their existing order.
*
* @param {Object} buffers - Output vertex and index lists.
* @param {Required<PyramidGeometryOptions>} options - Normalized geometry options.
* @param {number} vertexOffset - First side vertex.
* @returns {number} - Total vertex count after appending all sides.
* @private
*/
static #appendSides(buffers, options, vertexOffset) {
const divisor = GEOMETRY_SIZES.HALF_SIZE_DIVISOR;
const halfWidth = options.width / divisor;
const halfDepth = options.depth / divisor;
const halfHeight = options.height / divisor;
const apex = [MATH_COMMON_VALUES.ZERO, halfHeight, MATH_COMMON_VALUES.ZERO];
const frontLeft = [-halfWidth, -halfHeight, halfDepth];
const frontRight = [ halfWidth, -halfHeight, halfDepth];
const backRight = [ halfWidth, -halfHeight, -halfDepth];
const backLeft = [-halfWidth, -halfHeight, -halfDepth];
const heightSegments = options.heightSegments;
const appendFace = (firstCorner, secondCorner, segmentCount, direction) => {
vertexOffset += PyramidGeometry.#appendSideFace(
buffers,
vertexOffset,
firstCorner,
secondCorner,
apex,
segmentCount,
heightSegments,
direction
);
};
appendFace(frontLeft, frontRight, options.widthSegments, PYRAMID_DIRECTIONS.FRONT);
appendFace(frontRight, backRight, options.depthSegments, PYRAMID_DIRECTIONS.RIGHT);
appendFace(backRight, backLeft, options.widthSegments, PYRAMID_DIRECTIONS.BACK);
appendFace(backLeft, frontLeft, options.depthSegments, PYRAMID_DIRECTIONS.LEFT);
return vertexOffset;
}
}