Source: scene/object3d.js

import { Matrix4 } from '../math/matrix4.js';
import { Vector3 } from '../math/vector3.js';

/** @type {number} */
const CHILD_NOT_FOUND_INDEX = -1;

/** @type {number} */
const SINGLE_CHILD_REMOVE_COUNT = 1;

/** @type {number} */
const MATRIX_4x4_ELEMENT_COUNT = 16;

/**
 * Base class for all objects that live in a scene graph.
 * Stores position, rotation, scale and parent/children relations.
 */
export class Object3D {
    /** @type {Vector3} */
    #position;

    /** @type {Vector3} */
    #rotation;

    /** @type {Vector3} */
    #scale;

    /** @type {Object3D | null} */
    #parent;

    /** @type {Object3D[]} */
    #children;

    /** @type {Float32Array} */
    #localMatrix;

    /** @type {Float32Array} */
    #worldMatrix;

    /** @type {boolean} */
    #isLocalMatrixDirty = true;

    /** @type {boolean} */
    #isWorldMatrixDirty = true;

    constructor() {
        this.#parent   = null;
        this.#children = [];

        this.#localMatrix = new Float32Array(MATRIX_4x4_ELEMENT_COUNT);
        this.#worldMatrix = new Float32Array(MATRIX_4x4_ELEMENT_COUNT);
        Object3D.#setIdentityMatrix(this.#localMatrix);
        Object3D.#setIdentityMatrix(this.#worldMatrix);

        this.#position = Vector3.createZero(() => this.#markTransformDirty());
        this.#rotation = Vector3.createZero(() => this.#markTransformDirty());
        this.#scale    = Vector3.createUnitScale(() => this.#markTransformDirty());
    }

    /** @returns {Vector3} */
    get position() {
        return this.#position;
    }

    /** @returns {Vector3} */
    get rotation() {
        return this.#rotation;
    }

    /** @returns {Vector3} */
    get scale() {
        return this.#scale;
    }

    /** @returns {Object3D | null} */
    get parent() {
        return this.#parent;
    }

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

    /** @returns {Float32Array} */
    get worldMatrix() {
        return this.#worldMatrix;
    }

    /**
     * @param {Object3D} child - Child node to attach to this object (reparented, if it already has a parent).
     */
    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);
    }

    /**
     * @param {Object3D} child - Child node to detach from this object (no-op if the child is not attached here).
     */
    remove(child) {
        if (!(child instanceof Object3D)) {
            throw new TypeError('Object3D.remove expects an Object3D instance.');
        }

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

        if (index === CHILD_NOT_FOUND_INDEX) {
            return;
        }

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

    /**
     * Updates world matrices.
     *
     * @param {Float32Array | null | Object} inputMatrix            - Parent world matrix or options object.
     * @param {Float32Array | null} [inputMatrix.parentWorldMatrix] - Parent world matrix override (root, when null).
     * @returns {void}
     * @throws {TypeError} When inputs are 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);
    }

    /**
     * @param {function(Object3D): void} callback - Visitor function called for this object and all descendants (depth-first).
     */
    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);
        }
    }

    /** @private */
    #markTransformDirty() {
        this.#isLocalMatrixDirty = true;
        this.#isWorldMatrixDirty = true;
    }

    /**
     * @param {Float32Array | null} parentWorldMatrix - Parent world matrix, or null for the root.
     * @param {boolean} parentWorldDirty              - Whether the parent world matrix was recomputed 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);
        }
    }

    /**
     * Recomputes local matrix into existing buffer (no allocations).
     *
     * @private
     */
    #updateLocalMatrix() {
        const positionX = this.#position.x;
        const positionY = this.#position.y;
        const positionZ = this.#position.z;

        const rotationX = this.#rotation.x;
        const rotationY = this.#rotation.y;
        const rotationZ = this.#rotation.z;

        const scaleX = this.#scale.x;
        const scaleY = this.#scale.y;
        const scaleZ = this.#scale.z;

        const cosX = Math.cos(rotationX);
        const sinX = Math.sin(rotationX);
        const cosY = Math.cos(rotationY);
        const sinY = Math.sin(rotationY);
        const cosZ = Math.cos(rotationZ);
        const sinZ = Math.sin(rotationZ);

        // Rotation matrix, R = Rz * Ry * Rx:
        const rot00 = cosZ * cosY;
        const rot01 = (cosZ * sinY * sinX) - (sinZ * cosX);
        const rot02 = (cosZ * sinY * cosX) + (sinZ * sinX);

        const rot10 = sinZ * cosY;
        const rot11 = (sinZ * sinY * sinX) + (cosZ * cosX);
        const rot12 = (sinZ * sinY * cosX) - (cosZ * sinX);

        const rot20 = -sinY;
        const rot21 = cosY * sinX;
        const rot22 = cosY * cosX;
        const out   = this.#localMatrix;

        // X axis:
        out[0] = rot00 * scaleX;
        out[1] = rot10 * scaleX;
        out[2] = rot20 * scaleX;
        out[3] = 0;

        // Y axis:
        out[4] = rot01 * scaleY;
        out[5] = rot11 * scaleY;
        out[6] = rot21 * scaleY;
        out[7] = 0;

        // Z axis:
        out[8]  = rot02 * scaleZ;
        out[9]  = rot12 * scaleZ;
        out[10] = rot22 * scaleZ;
        out[11] = 0;

        // Translation:
        out[12] = positionX;
        out[13] = positionY;
        out[14] = positionZ;
        out[15] = 1;
    }

    /**
     * @param {Float32Array} out - Output 4x4 matrix buffer that will be overwritten with the identity matrix.
     * @private
     */
    static #setIdentityMatrix(out) {
        for (let index = 0; index < MATRIX_4x4_ELEMENT_COUNT; index += 1) {
            out[index] = 0;
        }

        out[0]  = 1;
        out[5]  = 1;
        out[10] = 1;
        out[15] = 1;
    }
}