Source: scene/object3d.js

import * as MathConstants     from '../constants/math.js';
import * as Object3DConstants from '../constants/object3d.js';
import { Matrix4 }            from '../math/matrix4.js';
import { Vector3 }            from '../math/vector3.js';

/**
 * Base class for scene graph objects.
 *
 * Stores local transformation and parent-child relations.
 */
export class Object3D {
    /**
     * Local position relative to the parent.
     *
     * @type {Vector3}
     */
    #position;

    /**
     * Local Euler rotation relative to the parent, in radians.
     *
     * @type {Vector3}
     */
    #rotation;

    /**
     * Local scale relative to the parent.
     *
     * @type {Vector3}
     */
    #scale;

    /**
     * Parent object in the scene graph.
     *
     * @type {Object3D | null}
     */
    #parent;

    /**
     * Child objects in the scene graph.
     *
     * @type {Object3D[]}
     */
    #children;

    /**
     * Cached local transformation matrix.
     *
     * Rebuilt only when position, rotation or scale changes.
     *
     * @type {Float32Array}
     */
    #localMatrix;

    /**
     * Cached world transformation matrix.
     *
     * Rebuilt when the local transformation, parent relation or an ancestor's world transformation changes.
     *
     * @type {Float32Array}
     */
    #worldMatrix;

    /**
     * Indicates that the cached local matrix must be rebuilt.
     *
     * @type {boolean}
     */
    #isLocalMatrixDirty = true;

    /**
     * Indicates that the cached world matrix must be rebuilt.
     *
     * @type {boolean}
     */
    #isWorldMatrixDirty = true;

    /**
     * Creates an Object3D with the default local transformation.
     */
    constructor() {
        this.#parent      = null;
        this.#children    = [];
        this.#localMatrix = new Float32Array(MathConstants.MATH_LAYOUT.MATRIX_4X4_ELEMENT_COUNT);
        this.#worldMatrix = new Float32Array(MathConstants.MATH_LAYOUT.MATRIX_4X4_ELEMENT_COUNT);

        // Initialize both cached matrices to identity before the first update.
        // This keeps them valid until their first transformation is computed.
        Object3D.#setIdentityMatrix(this.#localMatrix);
        Object3D.#setIdentityMatrix(this.#worldMatrix);

        // Initialize the default local transformation.
        // Changes to these vectors invalidate the cached matrices, so they are recomputed on the next update.
        this.#position = Vector3.createZero(() => this.#markTransformDirty());
        this.#rotation = Vector3.createZero(() => this.#markTransformDirty());
        this.#scale    = Vector3.createUnitScale(() => this.#markTransformDirty());
    }

    /**
     * @returns {Vector3} - Local position relative to the parent.
     */
    get position() {
        return this.#position;
    }

    /**
     * @returns {Vector3} - Local Euler rotation relative to the parent, in radians.
     */
    get rotation() {
        return this.#rotation;
    }

    /**
     * @returns {Vector3} - Local scale relative to the parent.
     */
    get scale() {
        return this.#scale;
    }

    /**
     * @returns {Object3D | null} - Parent object or null for a root object.
     */
    get parent() {
        return this.#parent;
    }

    /**
     * @returns {Object3D[]} - Child objects.
     */
    get children() {
        return this.#children;
    }

    /**
     * @returns {Float32Array} - Cached world transformation matrix.
     */
    get worldMatrix() {
        return this.#worldMatrix;
    }

    /**
     * Attaches a child to this object.
     *
     * Reparents the child when it already belongs to another parent.
     *
     * @param {Object3D} child - Child object to attach.
     * @throws {TypeError}     - If the child is not an Object3D instance.
     */
    add(child) {
        if (!(child instanceof Object3D)) {
            throw new TypeError('Object3D.add expects an Object3D instance.');
        }

        if (child.#parent === this) {
            return;
        }

        if (child.#parent) {
            child.#parent.remove(child);
        }

        child.#parent = this;
        child.#isWorldMatrixDirty = true;
        this.#children.push(child);
    }

    /**
     * Detaches a child from this object.
     *
     * Does nothing when the child is not attached.
     *
     * @param {Object3D} child - Child object to detach.
     * @throws {TypeError}     - If the child is not an Object3D instance.
     */
    remove(child) {
        if (!(child instanceof Object3D)) {
            throw new TypeError('Object3D.remove expects an Object3D instance.');
        }

        const index = this.#children.indexOf(child);

        if (index === Object3DConstants.OBJECT3D_CHILDREN.NOT_FOUND_INDEX) {
            return;
        }

        this.#children.splice(index, Object3DConstants.OBJECT3D_CHILDREN.SINGLE_REMOVE_COUNT);
        child.#parent = null;
        child.#isWorldMatrixDirty = true;
    }

    /**
     * Updates world matrices for this object and its descendants.
     *
     * @param {Float32Array | null | Object} inputMatrix            - Parent world matrix or options object.
     * @param {Float32Array | null} [inputMatrix.parentWorldMatrix] - Parent world matrix override, or null for the root.
     * @returns {void}
     * @throws {TypeError} - If the resolved parent world matrix is invalid.
     */
    updateWorldMatrix(inputMatrix) {
        let resolvedParentWorldMatrix = inputMatrix;

        if (inputMatrix !== null && typeof inputMatrix === 'object' && !(inputMatrix instanceof Float32Array)) {
            resolvedParentWorldMatrix = ('parentWorldMatrix' in inputMatrix)
                ? inputMatrix.parentWorldMatrix
                : null;
        }

        if (resolvedParentWorldMatrix !== null && !(resolvedParentWorldMatrix instanceof Float32Array)) {
            throw new TypeError('`Object3D.updateWorldMatrix` expects `Float32Array` or null.');
        }

        this.#updateWorldMatrixRecursive(resolvedParentWorldMatrix, false);
    }

    /**
     * Visits this object and all descendants in depth-first order.
     *
     * @param {function(Object3D): void} callback - Visitor callback.
     * @throws {TypeError}                        - If the callback is not a function.
     */
    traverse(callback) {
        if (typeof callback !== 'function') {
            throw new TypeError('Object3D.traverse expects a function callback.');
        }

        callback(this);

        for (let index = 0; index < this.#children.length; index += 1) {
            this.#children[index].traverse(callback);
        }
    }

    /**
     * Marks the cached local and world matrices for recomputation.
     *
     * @private
     */
    #markTransformDirty() {
        this.#isLocalMatrixDirty = true;
        this.#isWorldMatrixDirty = true;
    }

    /**
     * Updates the cached world matrix and propagates changes to descendants.
     *
     * @param {Float32Array | null} parentWorldMatrix - Parent world matrix, or null for the root.
     * @param {boolean} parentWorldDirty              - Whether the parent world matrix changed in this update pass.
     * @private
     */
    #updateWorldMatrixRecursive(parentWorldMatrix, parentWorldDirty) {
        if (this.#isLocalMatrixDirty) {
            this.#updateLocalMatrix();
            this.#isLocalMatrixDirty = false;
            this.#isWorldMatrixDirty = true;
        }

        const shouldUpdateWorld = this.#isWorldMatrixDirty || parentWorldDirty;

        if (shouldUpdateWorld) {
            if (parentWorldMatrix !== null) {
                Matrix4.multiplyTo(this.#worldMatrix, parentWorldMatrix, this.#localMatrix);
            } else {
                this.#worldMatrix.set(this.#localMatrix);
            }

            this.#isWorldMatrixDirty = false;
        }

        for (let index = 0; index < this.#children.length; index += 1) {
            this.#children[index].#updateWorldMatrixRecursive(this.#worldMatrix, shouldUpdateWorld);
        }
    }

    /**
     * Rebuilds the cached local transformation matrix.
     *
     * @private
     */
    #updateLocalMatrix() {
        Matrix4.writeTransformationTo(
            this.#localMatrix,
            this.#position.x,
            this.#position.y,
            this.#position.z,
            this.#rotation.x,
            this.#rotation.y,
            this.#rotation.z,
            this.#scale.x,
            this.#scale.y,
            this.#scale.z
        );
    }

    /**
     * Writes an identity matrix into an existing buffer.
     *
     * Used to keep matrix caches valid before the first update.
     *
     * @param {Float32Array} out - Output 4x4 matrix buffer.
     * @private
     */
    static #setIdentityMatrix(out) {
        out.fill(MathConstants.MATH_MATRIX_VALUES.ZERO);
        out[0]  = MathConstants.MATH_MATRIX_VALUES.UNIT;
        out[5]  = MathConstants.MATH_MATRIX_VALUES.UNIT;
        out[10] = MathConstants.MATH_MATRIX_VALUES.UNIT;
        out[15] = MathConstants.MATH_MATRIX_VALUES.UNIT;
    }
}