Source: debug/transform-gizmo.js

import { Object3D }           from '../scene/object3d.js';
import { Line }               from '../scene/line.js';
import { Mesh }               from '../scene/mesh.js';
import { Vector3 }            from '../math/vector3.js';
import { PolylineGeometry }   from '../geometry/polyline-geometry.js';
import { BoxGeometry }        from '../geometry/box-geometry.js';
import { SphereGeometry }     from '../geometry/sphere-geometry.js';
import { SolidColorMaterial } from '../material/solid-color-material.js';

/**
 * Default visibility state.
 *
 * @type {boolean}
 */
const DEFAULT_VISIBLE = true;

/**
 * Opacity used, when the gizmo is hidden.
 *
 * @type {number}
 */
const HIDDEN_OPACITY = 0.0;

/**
 * Opacity, used for pick volumes (always hidden).
 *
 * @type {number}
 */
const PICK_OPACITY = 0.0;

/**
 * Opacity, used for idle axes.
 *
 * @type {number}
 */
const AXIS_IDLE_OPACITY = 1.0;

/**
 * Opacity, used for hovered axes.
 *
 * @type {number}
 */
const AXIS_HOVER_OPACITY = 0.8;

/**
 * Opacity, used for active axes.
 *
 * @type {number}
 */
const AXIS_ACTIVE_OPACITY = 1.0;

/**
 * Opacity, used for inactive axes when hover/active is set.
 *
 * @type {number}
 */
const AXIS_INACTIVE_OPACITY = 0.35;

/**
 * Opacity, used to fully hide axes while another axis is active.
 *
 * @type {number}
 */
const AXIS_HIDDEN_OPACITY_WHEN_ACTIVE = 0.0;

/**
 * Axis length for the gizmo.
 *
 * @type {number}
 */
const AXIS_LENGTH = 2.5;

/**
 * Axis arrow head length.
 *
 * @type {number}
 */
const AXIS_HEAD_LENGTH = 0.45;

/**
 * Axis arrow head half-width.
 *
 * @type {number}
 */
const AXIS_HEAD_HALF_WIDTH = 0.25;

/**
 * Axis pick volume thickness.
 *
 * @type {number}
 */
const AXIS_PICK_THICKNESS = 0.35;

/**
 * Axis pick volume length.
 *
 * @type {number}
 */
const AXIS_PICK_LENGTH = AXIS_LENGTH;

/**
 * Divider used to place pick volumes in the middle of the axis.
 *
 * @type {number}
 */
const AXIS_PICK_CENTER_DIVISOR = 2.0;

/**
 * Zero value constant.
 *
 * @type {number}
 */
const ZERO_VALUE = 0.0;

/**
 * Axis identifier for X.
 *
 * @type {string}
 */
const AXIS_X = 'x';

/**
 * Axis identifier for Y.
 *
 * @type {string}
 */
const AXIS_Y = 'y';

/**
 * Axis identifier for Z.
 *
 * @type {string}
 */
const AXIS_Z = 'z';

/**
 * X axis color (RGB).
 *
 * @type {Float32Array}
 */
const AXIS_X_COLOR = new Float32Array([1.0, 0.4, 0.4]);

/**
 * Y axis color (RGB).
 *
 * @type {Float32Array}
 */
const AXIS_Y_COLOR = new Float32Array([0.4, 1.0, 0.6]);

/**
 * Z axis color (RGB).
 *
 * @type {Float32Array}
 */
const AXIS_Z_COLOR = new Float32Array([0.4, 0.6, 1.0]);

/**
 * Center sphere radius.
 *
 * @type {number}
 */
const CENTER_SPHERE_RADIUS = 0.15;

/**
 * Multiplier, used to convert radius to diameter.
 *
 * @type {number}
 */
const CENTER_SPHERE_DIAMETER_MULTIPLIER = 2.0;

/**
 * Center sphere diameter.
 *
 * @type {number}
 */
const CENTER_SPHERE_DIAMETER = CENTER_SPHERE_RADIUS * CENTER_SPHERE_DIAMETER_MULTIPLIER;

/**
 * Center sphere longitudinal segment count.
 *
 * @type {number}
 */
const CENTER_SPHERE_WIDTH_SEGMENTS = 12;

/**
 * Center sphere latitudinal segment count.
 *
 * @type {number}
 */
const CENTER_SPHERE_HEIGHT_SEGMENTS = 8;

/**
 * Center sphere color (RGB).
 *
 * @type {Float32Array}
 */
const CENTER_SPHERE_COLOR = new Float32Array([1.0, 1.0, 1.0]);

/**
 * Center sphere opacity, when visible.
 *
 * @type {number}
 */
const CENTER_SPHERE_OPACITY = 0.8;

/**
 * Error message for invalid WebGL2 context.
 *
 * @type {string}
 */
const ERROR_WEBGL_CONTEXT_TYPE = '`TransformGizmo` expects a `WebGL2RenderingContext`.';

/**
 * Error message for invalid target object.
 *
 * @type {string}
 */
const ERROR_TARGET_TYPE = '`TransformGizmo` expects an `Object3D` target.';

/**
 * Error message for invalid visibility values.
 *
 * @type {string}
 */
const ERROR_VISIBLE_TYPE = '`TransformGizmo.setVisible` expects a boolean.';

/**
 * Error message for invalid axis values.
 *
 * @type {string}
 */
const ERROR_AXIS_TYPE = '`TransformGizmo.setActiveAxis` expects (x, y, z) or null.';

/**
 * Error message for invalid hovered axis values.
 *
 * @type {string}
 */
const ERROR_HOVER_AXIS_TYPE = '`TransformGizmo.setHoveredAxis` expects (x, y, z) or null.';

/**
 * Error message for invalid mesh values.
 *
 * @type {string}
 */
const ERROR_AXIS_MESH_TYPE = '`TransformGizmo.getAxisForMesh` expects a `Mesh` instance.';

/**
 * Error message for invalid options object.
 *
 * @type {string}
 */
const ERROR_OPTIONS_TYPE = '`TransformGizmo` expects `options` as a plain object.';

/**
 * Unit direction for X axis.
 *
 * @type {number[]}
 */
const AXIS_DIR_X = [1.0, 0.0, 0.0];

/**
 * Unit direction for Y axis.
 *
 * @type {number[]}
 */
const AXIS_DIR_Y = [0.0, 1.0, 0.0];

/**
 * Unit direction for Z axis.
 *
 * @type {number[]}
 */
const AXIS_DIR_Z = [0.0, 0.0, 1.0];

/**
 * Ortho direction for X axis arrow head (drawn in XY plane).
 *
 * @type {number[]}
 */
const AXIS_HEAD_ORTHO_X = AXIS_DIR_Y;

/**
 * Ortho direction for Y axis arrow head (drawn in XY plane).
 *
 * @type {number[]}
 */
const AXIS_HEAD_ORTHO_Y = AXIS_DIR_X;

/**
 * Ortho direction for Z axis arrow head (drawn in XZ plane).
 *
 * @type {number[]}
 */
const AXIS_HEAD_ORTHO_Z = AXIS_DIR_X;

/**
 * Axis-aligned transform gizmo with the pickable axis volumes.
 */
export class TransformGizmo extends Object3D {

    /**
     * Target object, that the gizmo follows.
     *
     * @type {Object3D}
     * @private
     */
    #targetObject;

    /**
     * Materials used by the axis lines.
     *
     * @type {Map<string, SolidColorMaterial>}
     * @private
     */
    #axisMaterials = new Map();

    /**
     * Pick volume materials (hidden).
     *
     * @type {SolidColorMaterial}
     * @private
     */
    #pickMaterial;

    /**
     * Center sphere material.
     *
     * @type {SolidColorMaterial}
     * @private
     */
    #centerMaterial;

    /**
     * Center sphere mesh.
     *
     * @type {Mesh}
     * @private
     */
    #centerMesh;

    /**
     * Axis pick meshes mapped to axis identifiers.
     *
     * @type {Map<Mesh, string>}
     * @private
     */
    #pickMeshAxisMap = new Map();

    /**
     * Current visibility state.
     *
     * @type {boolean}
     * @private
     */
    #visible = DEFAULT_VISIBLE;

    /**
     * Currently active axis (if any).
     *
     * @type {string | null}
     * @private
     */
    #activeAxis = null;

    /**
     * Currently hovered axis (if any).
     *
     * @type {string | null}
     * @private
     */
    #hoveredAxis = null;

    /**
     * Creates a new TransformGizmo instance.
     *
     * @param {WebGL2RenderingContext} webglContext - WebGL2 rendering context.
     * @param {Object3D} targetObject               - Target object to attach the gizmo to.
     * @param {Object} [options]                    - Optional settings (reserved for future use).
     * @throws {TypeError} When inputs are invalid.
     */
    constructor(webglContext, targetObject, options = {}) {
        super();

        if (!(webglContext instanceof WebGL2RenderingContext)) {
            throw new TypeError(ERROR_WEBGL_CONTEXT_TYPE);
        }

        if (!(targetObject instanceof Object3D)) {
            throw new TypeError(ERROR_TARGET_TYPE);
        }

        if (options === null || typeof options !== 'object' || Array.isArray(options)) {
            throw new TypeError(ERROR_OPTIONS_TYPE);
        }

        this.#pickMaterial = new SolidColorMaterial(webglContext, { color: AXIS_X_COLOR });
        this.#pickMaterial.setOpacity(PICK_OPACITY);
        this.#buildAxes(webglContext);
        this.#buildCenterSphere(webglContext);
        this.setTarget(targetObject);
        this.setVisible(DEFAULT_VISIBLE);
    }

    /**
     * Sets gizmo visibility.
     *
     * @param {boolean} visible - Whether the gizmo should be visible.
     * @returns {void}
     * @throws {TypeError} When the visibility flag is invalid.
     */
    setVisible(visible) {
        if (typeof visible !== 'boolean') {
            throw new TypeError(ERROR_VISIBLE_TYPE);
        }

        this.#visible = visible;
        this.#applyAxisVisuals();
    }

    /**
     * Returns current visibility state.
     *
     * @returns {boolean} - The current visibility state of the gizmo.
     */
    isVisible() {
        return this.#visible;
    }

    /**
     * Updates the gizmo target and reparents this object.
     *
     * @param {Object3D} targetObject - Target object to attach the gizmo to.
     * @returns {void}
     * @throws {TypeError} When the target object is invalid.
     */
    setTarget(targetObject) {
        if (!(targetObject instanceof Object3D)) {
            throw new TypeError(ERROR_TARGET_TYPE);
        }

        if (this.parent) {
            this.parent.remove(this);
        }

        this.#targetObject = targetObject;
        this.#targetObject.add(this);
    }

    /**
     * Returns the currently active axis (if set).
     *
     * @returns {string | null} - The currently active axis id (x, y, z) or null, if no axis is active.
     */
    getActiveAxis() {
        return this.#activeAxis;
    }

    /**
     * Sets the active axis identifier.
     *
     * @param {string | null} axis - Axis id (x, y, z) or null.
     * @returns {void}
     * @throws {TypeError} When the axis value is invalid.
     */
    setActiveAxis(axis) {
        if (axis !== null && axis !== AXIS_X && axis !== AXIS_Y && axis !== AXIS_Z) {
            throw new TypeError(ERROR_AXIS_TYPE);
        }

        if (this.#activeAxis !== null && axis !== null && axis !== this.#activeAxis) {
            return;
        }

        this.#activeAxis = axis;
        this.#applyAxisVisuals();
    }

    /**
     * Sets the hovered axis identifier.
     *
     * @param {string | null} axis - Axis id (x, y, z) or null.
     * @returns {void}
     * @throws {TypeError} When the axis value is invalid.
     */
    setHoveredAxis(axis) {
        if (axis !== null && axis !== AXIS_X && axis !== AXIS_Y && axis !== AXIS_Z) {
            throw new TypeError(ERROR_HOVER_AXIS_TYPE);
        }

        if (this.#activeAxis !== null) {
            return;
        }

        this.#hoveredAxis = axis;
        this.#applyAxisVisuals();
    }

    /**
     * Clears hovered/active state and restores the default visuals.
     *
     * @returns {void}
     */
    clearState() {
        this.#hoveredAxis = null;
        this.#activeAxis  = null;
        this.#applyAxisVisuals();
    }

    /**
     * Returns the axis identifier for a pick mesh.
     *
     * @param {Mesh} mesh       - Pick the mesh instance.
     * @returns {string | null} - The axis id (x, y, z) for the provided pick mesh or null, if the mesh is not mapped to an axis.
     * @throws {TypeError} When the mesh input is invalid.
     */
    getAxisForMesh(mesh) {
        if (!(mesh instanceof Mesh)) {
            throw new TypeError(ERROR_AXIS_MESH_TYPE);
        }

        return this.#pickMeshAxisMap.get(mesh) ?? null;
    }

    /**
     * Builds the axes lines and arrow heads.
     *
     * @param {WebGL2RenderingContext} webglContext - WebGL2 rendering context.
     * @returns {void}
     * @private
     */
    #buildAxes(webglContext) {
        this.#buildAxisX(webglContext);
        this.#buildAxisY(webglContext);
        this.#buildAxisZ(webglContext);
    }

    /**
     * Builds the center sphere marker.
     *
     * @param {WebGL2RenderingContext} webglContext - WebGL2 rendering context.
     * @returns {void}
     * @private
     */
    #buildCenterSphere(webglContext) {
        const geometry = new SphereGeometry(webglContext, {
            width          : CENTER_SPHERE_DIAMETER,
            height         : CENTER_SPHERE_DIAMETER,
            depth          : CENTER_SPHERE_DIAMETER,
            widthSegments  : CENTER_SPHERE_WIDTH_SEGMENTS,
            heightSegments : CENTER_SPHERE_HEIGHT_SEGMENTS
        });

        const material = new SolidColorMaterial(webglContext, { color: CENTER_SPHERE_COLOR });
        material.setOpacity(CENTER_SPHERE_OPACITY);

        const mesh           = new Mesh(geometry, material);
        this.#centerMaterial = material;
        this.#centerMesh     = mesh;
        this.add(mesh);
    }

    /**
     * Builds a single axis line, arrow head and pick volume.
     *
     * @param {WebGL2RenderingContext} webglContext - WebGL2 rendering context.
     * @param {string} axis                         - Axis identifier.
     * @param {Float32Array} color                  - Axis color.
     * @param {number[]} dir                        - Unit axis direction (X/Y/Z).
     * @param {number[]} headOrtho                  - Unit orthogonal direction for arrow head width.
     * @returns {void}
     * @private
     */
    #buildAxis(webglContext, axis, color, dir, headOrtho) {
        const material = new SolidColorMaterial(webglContext, { color });
        this.#axisMaterials.set(axis, material);

        // Main axis line:
        const start = new Vector3(ZERO_VALUE, ZERO_VALUE, ZERO_VALUE);
        const end   = new Vector3(
            dir[0] * AXIS_LENGTH,
            dir[1] * AXIS_LENGTH,
            dir[2] * AXIS_LENGTH
        );
        this.add(this.#createLine(webglContext, material, [start, end]));

        // Arrow head (V shape):
        const headBaseLength = AXIS_LENGTH - AXIS_HEAD_LENGTH;

        const headBase = new Vector3(
            dir[0] * headBaseLength,
            dir[1] * headBaseLength,
            dir[2] * headBaseLength
        );

        const headTip = new Vector3(
            dir[0] * AXIS_LENGTH,
            dir[1] * AXIS_LENGTH,
            dir[2] * AXIS_LENGTH
        );

        const headLeft = new Vector3(
            headBase.x + (headOrtho[0] * -AXIS_HEAD_HALF_WIDTH),
            headBase.y + (headOrtho[1] * -AXIS_HEAD_HALF_WIDTH),
            headBase.z + (headOrtho[2] * -AXIS_HEAD_HALF_WIDTH)
        );

        const headRight = new Vector3(
            headBase.x + (headOrtho[0] * AXIS_HEAD_HALF_WIDTH),
            headBase.y + (headOrtho[1] * AXIS_HEAD_HALF_WIDTH),
            headBase.z + (headOrtho[2] * AXIS_HEAD_HALF_WIDTH)
        );

        this.add(this.#createLine(webglContext, material, [headLeft, headTip, headRight]));
        this.#addPickMesh(webglContext, axis);
    }

    #buildAxisX(webglContext) {
        this.#buildAxis(webglContext, AXIS_X, AXIS_X_COLOR, AXIS_DIR_X, AXIS_HEAD_ORTHO_X);
    }

    #buildAxisY(webglContext) {
        this.#buildAxis(webglContext, AXIS_Y, AXIS_Y_COLOR, AXIS_DIR_Y, AXIS_HEAD_ORTHO_Y);
    }

    #buildAxisZ(webglContext) {
        this.#buildAxis(webglContext, AXIS_Z, AXIS_Z_COLOR, AXIS_DIR_Z, AXIS_HEAD_ORTHO_Z);
    }

    /**
     * Adds a pick mesh for the given axis.
     *
     * @param {WebGL2RenderingContext} webglContext - WebGL2 rendering context.
     * @param {string} axis                         - Axis identifier.
     * @returns {void}
     * @private
     */
    #addPickMesh(webglContext, axis) {
        const geometry = new BoxGeometry(webglContext, {
            width  : axis === AXIS_X ? AXIS_PICK_LENGTH : AXIS_PICK_THICKNESS,
            height : axis === AXIS_Y ? AXIS_PICK_LENGTH : AXIS_PICK_THICKNESS,
            depth  : axis === AXIS_Z ? AXIS_PICK_LENGTH : AXIS_PICK_THICKNESS
        });

        const mesh = new Mesh(geometry, this.#pickMaterial, { ownsMaterial: false });

        switch (axis) {
            case AXIS_X:
                mesh.position.x = AXIS_PICK_LENGTH / AXIS_PICK_CENTER_DIVISOR;
                break;
            case AXIS_Y:
                mesh.position.y = AXIS_PICK_LENGTH / AXIS_PICK_CENTER_DIVISOR;
                break;
            case AXIS_Z:
                mesh.position.z = AXIS_PICK_LENGTH / AXIS_PICK_CENTER_DIVISOR;
                break;
            default:
                throw new Error(`Unknown axis: ${axis}`);
        }

        this.add(mesh);
        this.#pickMeshAxisMap.set(mesh, axis);
    }

    /**
     * Creates a line mesh from positions.
     *
     * @param {WebGL2RenderingContext} webglContext - WebGL2 rendering context.
     * @param {SolidColorMaterial} material         - Material to use.
     * @param {Vector3[]} positions                 - Line positions.
     * @returns {Line}                              - A new line object, built from the provided positions and material.
     * @private
     */
    #createLine(webglContext, material, positions) {
        const geometry = new PolylineGeometry(webglContext, { positions });
        return new Line(geometry, material);
    }

    /**
     * Updates the axis materials based on current hover/active state.
     *
     * @returns {void}
     * @private
     */
    #applyAxisVisuals() {
        const isVisible   = this.#visible;
        const activeAxis  = this.#activeAxis;
        const hoveredAxis = this.#hoveredAxis;

        for (const [axis, material] of this.#axisMaterials.entries()) {
            let opacity = HIDDEN_OPACITY;

            if (isVisible) {
                if (activeAxis) {
                    opacity = axis === activeAxis ? AXIS_ACTIVE_OPACITY : AXIS_HIDDEN_OPACITY_WHEN_ACTIVE;
                } else if (hoveredAxis) {
                    opacity = axis === hoveredAxis ? AXIS_HOVER_OPACITY : AXIS_INACTIVE_OPACITY;
                } else {
                    opacity = AXIS_IDLE_OPACITY;
                }
            }

            material.setOpacity(opacity);
        }

        if (this.#centerMesh && this.#centerMaterial) {
            this.#centerMaterial.setOpacity(isVisible ? CENTER_SPHERE_OPACITY : HIDDEN_OPACITY);
        }
    }
}