import { GeneratedGeometry } from './generated-geometry.js';
import { Vector3 } from '../math/vector3.js';
import { GeometryUtils } from './geometry-utils.js';
import { MATH_COMMON_VALUES, MATH_VECTOR3_INDEXES } from '../constants/math.js';
import { ECMASCRIPT_TYPEOF_RESULTS } from '../constants/ecmascript-types.js';
import {
TUBE_LINE_LIMITS,
TUBE_LINE_DEFAULTS,
TUBE_LINE_CAP_TYPES,
TUBE_LINE_ERRORS,
TUBE_LINE_LAYOUT,
TUBE_LINE_NORMALS
} from '../constants/tube-line-geometry.js';
import {
GEOMETRY_SIZES,
GEOMETRY_LAYOUT,
GEOMETRY_ANGLES,
PRIMITIVE_LINES,
PRIMITIVE_TRIANGLES
} from '../constants/geometry.js';
/**
* 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 GeneratedGeometry {
/**
* @param {TubeLineGeometryOptions} options - Tube geometry options.
* @throws {TypeError} When inputs are invalid.
* @throws {RangeError} When numeric inputs are out of range.
* @returns {GeneratedGeometryData} - Generated CPU buffers.
* @protected
*/
static createGeometryData(options = {}) {
const normalized = TubeLineGeometry.#normalizeOptions(options);
const { positions, radius, width, radialSegments, closed, capType } = normalized;
const resolvedRadius = width !== null ? (width / GEOMETRY_SIZES.HALF_SIZE_DIVISOR) : radius;
const baseVertexCount = positions.length * radialSegments;
const addCaps = capType === TUBE_LINE_CAP_TYPES.FLAT && !closed;
const extraCapVertices = addCaps ? TUBE_LINE_LAYOUT.CAP_CENTER_COUNT : MATH_COMMON_VALUES.ZERO;
const totalVertexCount = baseVertexCount + extraCapVertices;
const positionsBuffer = new Float32Array(totalVertexCount * GEOMETRY_LAYOUT.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 = GeometryUtils.createWireframeIndicesFromSolidIndices(totalVertexCount, indices);
return {
positions : positionsBuffer,
colors : null,
indicesSolid : indices,
indicesWireframe : wireframeIndices,
uvs : null,
normals : null,
primitiveOptions : {
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 = MATH_COMMON_VALUES.ZERO; index < pointCount; index += MATH_COMMON_VALUES.UNIT) {
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 = MATH_COMMON_VALUES.ZERO; segmentIndex < radialSegments; segmentIndex += MATH_COMMON_VALUES.UNIT) {
const angle = GEOMETRY_ANGLES.FULL_TURN * (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 * GEOMETRY_LAYOUT.POSITION_COMPONENT_COUNT;
buffer[baseIndex + MATH_VECTOR3_INDEXES.X] = point.x + offsetX;
buffer[baseIndex + MATH_VECTOR3_INDEXES.Y] = point.y + offsetY;
buffer[baseIndex + MATH_VECTOR3_INDEXES.Z] = 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 * GEOMETRY_LAYOUT.POSITION_COMPONENT_COUNT;
const endBaseIndex = (baseVertexCount + MATH_COMMON_VALUES.UNIT) * GEOMETRY_LAYOUT.POSITION_COMPONENT_COUNT;
const startPoint = positions[MATH_COMMON_VALUES.ZERO];
const endPoint = positions[positions.length - MATH_COMMON_VALUES.UNIT];
buffer[startBaseIndex + MATH_VECTOR3_INDEXES.X] = startPoint.x;
buffer[startBaseIndex + MATH_VECTOR3_INDEXES.Y] = startPoint.y;
buffer[startBaseIndex + MATH_VECTOR3_INDEXES.Z] = startPoint.z;
buffer[endBaseIndex + MATH_VECTOR3_INDEXES.X] = endPoint.x;
buffer[endBaseIndex + MATH_VECTOR3_INDEXES.Y] = endPoint.y;
buffer[endBaseIndex + MATH_VECTOR3_INDEXES.Z] = 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 - MATH_COMMON_VALUES.UNIT);
const indices = [];
for (let segmentIndex = MATH_COMMON_VALUES.ZERO; segmentIndex < segmentCount; segmentIndex += MATH_COMMON_VALUES.UNIT) {
const ringStart = segmentIndex * radialSegments;
const nextRingStart = ((segmentIndex + MATH_COMMON_VALUES.UNIT) % pointCount) * radialSegments;
for (let radialIndex = MATH_COMMON_VALUES.ZERO; radialIndex < radialSegments; radialIndex += MATH_COMMON_VALUES.UNIT) {
const nextRadialIndex = (radialIndex + MATH_COMMON_VALUES.UNIT) % 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 + MATH_COMMON_VALUES.UNIT;
const startRingStart = MATH_COMMON_VALUES.ZERO;
const endRingStart = (pointCount - MATH_COMMON_VALUES.UNIT) * radialSegments;
for (let radialIndex = MATH_COMMON_VALUES.ZERO; radialIndex < radialSegments; radialIndex += MATH_COMMON_VALUES.UNIT) {
const nextRadialIndex = (radialIndex + MATH_COMMON_VALUES.UNIT) % 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 GeometryUtils.createIndexArray(baseVertexCount + (addCaps ? TUBE_LINE_LAYOUT.CAP_CENTER_COUNT : MATH_COMMON_VALUES.ZERO), 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 > MATH_COMMON_VALUES.ZERO) {
return index - MATH_COMMON_VALUES.UNIT;
}
return closed ? (count - MATH_COMMON_VALUES.UNIT) : 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 - MATH_COMMON_VALUES.UNIT) {
return index + MATH_COMMON_VALUES.UNIT;
}
return closed ? MATH_COMMON_VALUES.ZERO : 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 <= TUBE_LINE_LAYOUT.NORMALIZE_EPSILON) {
return new Vector3(
MATH_COMMON_VALUES.ZERO,
MATH_COMMON_VALUES.UNIT,
MATH_COMMON_VALUES.ZERO
);
}
return new Vector3(deltaX / length, deltaY / length, deltaZ / length);
}
/**
* @param {Vector3} tangent - Tangent direction.
* @returns {Vector3}
* @private
*/
static #computeNormal(tangent) {
let normalX = (tangent.y * TUBE_LINE_NORMALS.UP_AXIS_Z) - (tangent.z * TUBE_LINE_NORMALS.UP_AXIS_Y);
let normalY = (tangent.z * TUBE_LINE_NORMALS.UP_AXIS_X) - (tangent.x * TUBE_LINE_NORMALS.UP_AXIS_Z);
let normalZ = (tangent.x * TUBE_LINE_NORMALS.UP_AXIS_Y) - (tangent.y * TUBE_LINE_NORMALS.UP_AXIS_X);
let length = Math.sqrt((normalX * normalX) + (normalY * normalY) + (normalZ * normalZ));
if (length <= TUBE_LINE_LAYOUT.NORMALIZE_EPSILON) {
normalX = (tangent.y * TUBE_LINE_NORMALS.FALLBACK_AXIS_Z) - (tangent.z * TUBE_LINE_NORMALS.FALLBACK_AXIS_Y);
normalY = (tangent.z * TUBE_LINE_NORMALS.FALLBACK_AXIS_X) - (tangent.x * TUBE_LINE_NORMALS.FALLBACK_AXIS_Z);
normalZ = (tangent.x * TUBE_LINE_NORMALS.FALLBACK_AXIS_Y) - (tangent.y * TUBE_LINE_NORMALS.FALLBACK_AXIS_X);
length = Math.sqrt((normalX * normalX) + (normalY * normalY) + (normalZ * normalZ));
}
if (length <= TUBE_LINE_LAYOUT.NORMALIZE_EPSILON) {
normalX = (tangent.y * TUBE_LINE_NORMALS.SECOND_FALLBACK_AXIS_Z) - (tangent.z * TUBE_LINE_NORMALS.SECOND_FALLBACK_AXIS_Y);
normalY = (tangent.z * TUBE_LINE_NORMALS.SECOND_FALLBACK_AXIS_X) - (tangent.x * TUBE_LINE_NORMALS.SECOND_FALLBACK_AXIS_Z);
normalZ = (tangent.x * TUBE_LINE_NORMALS.SECOND_FALLBACK_AXIS_Y) - (tangent.y * TUBE_LINE_NORMALS.SECOND_FALLBACK_AXIS_X);
length = Math.sqrt((normalX * normalX) + (normalY * normalY) + (normalZ * normalZ));
}
if (length <= TUBE_LINE_LAYOUT.NORMALIZE_EPSILON) {
return new Vector3(MATH_COMMON_VALUES.UNIT, MATH_COMMON_VALUES.ZERO, MATH_COMMON_VALUES.ZERO);
}
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 <= TUBE_LINE_LAYOUT.NORMALIZE_EPSILON) {
return new Vector3(
MATH_COMMON_VALUES.ZERO,
MATH_COMMON_VALUES.ZERO,
MATH_COMMON_VALUES.ZERO
);
}
return new Vector3(binormalX / length, binormalY / length, binormalZ / length);
}
/**
* Validates the path and tube options before allocating geometry buffers.
*
* @param {TubeLineGeometryOptions} options - Normalized geometry options.
* @returns {Required<TubeLineGeometryOptions>}
* @private
*/
static #normalizeOptions(options) {
if (options === null || typeof options !== ECMASCRIPT_TYPEOF_RESULTS.OBJECT || Array.isArray(options)) {
throw new TypeError('TubeLineGeometry expects options as a plain object.');
}
const {
positions,
radius = TUBE_LINE_DEFAULTS.RADIUS,
width = TUBE_LINE_DEFAULTS.WIDTH,
radialSegments = TUBE_LINE_DEFAULTS.RADIAL_SEGMENTS,
closed = TUBE_LINE_DEFAULTS.CLOSED,
capType = TUBE_LINE_DEFAULTS.CAP_TYPE
} = options;
if (!Array.isArray(positions)) {
throw new TypeError('TubeLineGeometry expects positions as an array of Vector3.');
}
if (positions.length < TUBE_LINE_LIMITS.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 !== ECMASCRIPT_TYPEOF_RESULTS.NUMBER || !Number.isFinite(radius) || radius <= MATH_COMMON_VALUES.ZERO) {
throw new RangeError('TubeLineGeometry expects radius as a positive number.');
}
if (width !== null && (typeof width !== ECMASCRIPT_TYPEOF_RESULTS.NUMBER || !Number.isFinite(width) || width <= MATH_COMMON_VALUES.ZERO)) {
throw new RangeError('TubeLineGeometry expects width as a positive number or null.');
}
if (!Number.isInteger(radialSegments) || radialSegments < TUBE_LINE_LIMITS.MIN_RADIAL_SEGMENTS) {
throw new RangeError('TubeLineGeometry expects radialSegments as an integer >= 3.');
}
if (typeof closed !== ECMASCRIPT_TYPEOF_RESULTS.BOOLEAN) {
throw new TypeError('TubeLineGeometry expects closed as a boolean.');
}
if (capType !== TUBE_LINE_CAP_TYPES.NONE && capType !== TUBE_LINE_CAP_TYPES.FLAT) {
throw new RangeError(TUBE_LINE_ERRORS.INVALID_CAP_TYPE);
}
return { positions, radius, width, radialSegments, closed, capType };
}
}