import { MathUtils, Mesh, MeshBasicMaterial, Object3D } from "three"; import { TransformControls } from "three/examples/jsm/controls/TransformControls.js"; import * as params from "../engine/engine_default_parameters.js"; import { serializable } from "../engine/engine_serialization_decorator.js"; import { Behaviour, GameObject } from "./Component.js"; import { OrbitControls } from "./OrbitControls.js"; import { SyncedTransform } from "./SyncedTransform.js"; /** * TransformGizmo displays manipulation controls for translating, rotating, and scaling objects in the scene. * It wraps three.js {@link TransformControls} and provides keyboard shortcuts for changing modes and settings. * @category Helpers * @group Components */ export class TransformGizmo extends Behaviour { /** * When true, this is considered a helper gizmo and will only be shown if showGizmos is enabled in engine parameters. */ @serializable() public isGizmo: boolean = false; /** * Specifies the translation grid snap value in world units. * Applied when holding Shift while translating an object. */ @serializable() public translationSnap: number = 1; /** * Specifies the rotation snap angle in degrees. * Applied when holding Shift while rotating an object. */ @serializable() public rotationSnapAngle: number = 15; /** * Specifies the scale snapping value. * Applied when holding Shift while scaling an object. */ @serializable() public scaleSnap: number = .25; /** * Gets the underlying three.js {@link TransformControls} instance. * @returns The TransformControls instance or undefined if not initialized. */ get control() { return this._control; } private _control?: TransformControls; private orbit?: OrbitControls; /** @internal */ onEnable() { if (this.isGizmo && !params.showGizmos) return; if (!this.context.mainCamera) return; if (!this._control) { this._control = new TransformControls(this.context.mainCamera, this.context.renderer.domElement); this._control.enabled = true; this._control.getRaycaster().layers.set(2); this._control.size = 1; const obj = ("_root" in this._control ? this._control._root : this._control) as Object3D; obj.traverse(x => { const mesh = x as Mesh; mesh.layers.set(2); if (mesh) { const gizmoMat = mesh.material as MeshBasicMaterial; if (gizmoMat) { gizmoMat.opacity = 0.3; } } }); this.orbit = GameObject.getComponentInParent(this.context.mainCamera, OrbitControls) ?? undefined; } if (this._control) { const obj = this._control.getHelper(); this.context.scene.add(obj); this._control.attach(this.gameObject); this._control?.addEventListener('dragging-changed', this.onControlChangedEvent); window.addEventListener('keydown', this.windowKeyDownListener); window.addEventListener('keyup', this.windowKeyUpListener); } } /** @internal */ onDisable() { this._control?.getHelper()?.removeFromParent(); this._control?.removeEventListener('dragging-changed', this.onControlChangedEvent); window.removeEventListener('keydown', this.windowKeyDownListener); window.removeEventListener('keyup', this.windowKeyUpListener); } /** * Enables grid snapping for transform operations according to set snap values. * This applies the translationSnap, rotationSnapAngle, and scaleSnap properties to the controls. */ enableSnapping() { if (this._control) { this._control.setTranslationSnap(this.translationSnap); this._control.setRotationSnap(MathUtils.degToRad(this.rotationSnapAngle)); this._control.setScaleSnap(this.scaleSnap); } } /** * Disables grid snapping for transform operations. * Removes all snapping constraints from the transform controls. */ disableSnapping() { if (this._control) { this._control.setTranslationSnap(null); this._control.setRotationSnap(null); this._control.setScaleSnap(null); } } /** * Event handler for when dragging state changes. * Disables orbit controls during dragging and requests ownership of the transform if it's synchronized. * @param event The drag change event */ private onControlChangedEvent = (event) => { const orbit = this.orbit; if (orbit) orbit.enabled = !event.value; if (event.value) { // request ownership on drag start const sync = GameObject.getComponentInParent(this.gameObject, SyncedTransform); if (sync) { sync.requestOwnership(); } } } /** * Handles keyboard shortcuts for transform operations: * - Q: Toggle local/world space * - W: Translation mode * - E: Rotation mode * - R: Scale mode * - Shift: Enable snapping (while held) * - +/-: Adjust gizmo size * - X/Y/Z: Toggle visibility of respective axis * - Spacebar: Toggle controls enabled state * @param event The keyboard event */ private windowKeyDownListener = (event) => { if (!this.enabled) return; if (!this._control) return; switch (event.keyCode) { case 81: // Q this._control.setSpace(this._control.space === 'local' ? 'world' : 'local'); break; case 16: // Shift this.enableSnapping(); break; case 87: // W this._control.setMode('translate'); break; case 69: // E this._control.setMode('rotate'); break; case 82: // R this._control.setMode('scale'); break; case 187: case 107: // +, =, num+ this._control.setSize(this._control.size + 0.1); break; case 189: case 109: // -, _, num- this._control.setSize(Math.max(this._control.size - 0.1, 0.1)); break; case 88: // X this._control.showX = !this._control.showX; break; case 89: // Y this._control.showY = !this._control.showY; break; case 90: // Z this._control.showZ = !this._control.showZ; break; case 32: // Spacebar this._control.enabled = !this._control.enabled; break; } } /** * Handles keyboard key release events. * Currently only handles releasing Shift key to disable snapping. * @param event The keyboard event */ private windowKeyUpListener = (event) => { if (!this.enabled) return; switch (event.keyCode) { case 16: // Shift this.disableSnapping(); break; } } }