From 1ab3deae92eb06403723774c69a086adb277a096 Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Mon, 17 Nov 2025 17:06:32 -0600 Subject: [PATCH] Add face handles and transform tracking to ResizeGizmo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 6 face handles for single-axis scaling (in addition to 8 corner handles for uniform scaling) - Implement single-axis scaling for face handles vs uniform scaling for corners - Add automatic handle position updates when target mesh moves or rotates - Track mesh transform changes using quaternions for accurate rotation detection - Update handles in real-time during scaling to match new bounding box - Add FACE_POSITIONS constant array to enums.ts - Fix handle sizing to use consistent size calculation for all handles 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/defaultScene.ts | 4 + src/gizmos/ResizeGizmo/ResizeGizmo.ts | 195 +++++++++++++++++++++++--- src/gizmos/ResizeGizmo/enums.ts | 37 +++++ src/gizmos/ResizeGizmo/index.ts | 4 +- src/util/functions/sceneInspector.ts | 28 +++- 5 files changed, 239 insertions(+), 29 deletions(-) diff --git a/src/defaultScene.ts b/src/defaultScene.ts index 2e53d07..a286f25 100644 --- a/src/defaultScene.ts +++ b/src/defaultScene.ts @@ -4,6 +4,10 @@ import log from "loglevel"; export class DefaultScene { private static _Scene: Scene; + private static _UtilityScene: Scene; + public static get UtilityScene(): Scene { + return this._UtilityScene; + } public static get Scene(): Scene { if (!DefaultScene._Scene) { diff --git a/src/gizmos/ResizeGizmo/ResizeGizmo.ts b/src/gizmos/ResizeGizmo/ResizeGizmo.ts index 6da2ebb..45bfaa4 100644 --- a/src/gizmos/ResizeGizmo/ResizeGizmo.ts +++ b/src/gizmos/ResizeGizmo/ResizeGizmo.ts @@ -4,6 +4,7 @@ import { Observable, Observer, PickingInfo, + Quaternion, Ray, Scene, StandardMaterial, @@ -15,7 +16,7 @@ import { } from '@babylonjs/core'; import log from 'loglevel'; -import { HandleState, CORNER_POSITIONS} from './enums'; +import { HandleState, CORNER_POSITIONS, FACE_POSITIONS} from './enums'; import { ResizeGizmoEvent } from './types'; /** @@ -41,10 +42,16 @@ export class ResizeGizmo { private _isScaling: boolean = false; private _activeController: WebXRInputSource | null = null; private _activeHandle: AbstractMesh | null = null; + private _activeHandleType: 'corner' | 'face' = 'corner'; + private _activeAxis: 'x' | 'y' | 'z' | null = null; // Only used for face handles private _originalStickLength: number = 0; private _originalHandleDistance: number = 0; private _initialScale: Vector3 | null = null; + // Track target mesh transform changes + private _lastPosition: Vector3 | null = null; + private _lastRotationQuaternion: Quaternion | null = null; + // Frame update observer private _frameObserver: Observer | null = null; @@ -99,33 +106,33 @@ export class ResizeGizmo { } /** - * Create 8 corner handles as 0.1 size cubes + * Create corner and face handles */ private createHandles(): void { // Get bounding box for positioning - const targetBoundingInfo = this._targetMesh.getBoundingInfo(); const boundingBox = targetBoundingInfo.boundingBox; - + const bboxCenter = boundingBox.centerWorld; + const extents = boundingBox.extendSize; const innerCorners = boundingBox.vectorsWorld; + const worldMatrix = this._targetMesh.getWorldMatrix(); + // Calculate handle size once (based on corner distance) + const handleSize = innerCorners[0].subtract(bboxCenter).length() * .2; + // Create corner handles CORNER_POSITIONS.forEach((cornerDef, index) => { - const cornerPos = innerCorners[index]; - const size = cornerPos.subtract(boundingBox.centerWorld).length() * .2; - const handleMesh = MeshBuilder.CreateBox( `resizeHandle_${cornerDef.name}`, - { size: size }, + { size: handleSize }, this._utilityLayer.utilityLayerScene ); // Position outward from center so handle corner touches bounding box corner - // Cube diagonal = size * sqrt(3), so half diagonal = size * sqrt(3) / 2 - const direction = cornerPos.subtract(boundingBox.centerWorld).normalize(); - const offset = direction.scale(size * Math.sqrt(3) / 2); + const direction = cornerPos.subtract(bboxCenter).normalize(); + const offset = direction.scale(handleSize * Math.sqrt(3) / 2); handleMesh.position = cornerPos.add(offset); handleMesh.rotationQuaternion = this._targetMesh.absoluteRotationQuaternion; handleMesh.material = this._handleMaterial; @@ -134,7 +141,34 @@ export class ResizeGizmo { this._handles.push(handleMesh); }); - this._logger.debug(`Created ${this._handles.length} corner handles`); + // Create face handles + FACE_POSITIONS.forEach((faceDef) => { + // Calculate face center position in world space + const localFacePos = new Vector3( + faceDef.position.x * extents.x, + faceDef.position.y * extents.y, + faceDef.position.z * extents.z + ); + const faceCenterWorld = Vector3.TransformCoordinates(localFacePos, worldMatrix); + + const handleMesh = MeshBuilder.CreateBox( + `resizeHandle_${faceDef.name}`, + { size: handleSize }, + this._utilityLayer.utilityLayerScene + ); + + // Position outward from center so handle touches face center + const direction = faceCenterWorld.subtract(bboxCenter).normalize(); + const offset = direction.scale(handleSize * Math.sqrt(3) / 2); + handleMesh.position = faceCenterWorld.add(offset); + handleMesh.rotationQuaternion = this._targetMesh.absoluteRotationQuaternion; + handleMesh.material = this._handleMaterial; + handleMesh.isPickable = true; + + this._handles.push(handleMesh); + }); + + this._logger.debug(`Created ${this._handles.length} handles (8 corner + 6 face)`); } @@ -142,6 +176,10 @@ export class ResizeGizmo { * Set up per-frame updates */ private setupFrameUpdates(): void { + // Initialize position and rotation tracking + this._lastPosition = this._targetMesh.absolutePosition.clone(); + this._lastRotationQuaternion = this._targetMesh.absoluteRotationQuaternion.clone(); + this._frameObserver = this._scene.onBeforeRenderObservable.add(() => { // Check for handle picking with XR controllers this.checkXRControllerPicking(); @@ -149,6 +187,9 @@ export class ResizeGizmo { // Update scaling if active if (this._isScaling) { this.updateScaling(); + } else { + // Only check for transform changes when not actively scaling + this.checkTransformChanges(); } }); } @@ -275,6 +316,23 @@ export class ResizeGizmo { // Store initial scale this._initialScale = this._targetMesh.scaling.clone(); + // Determine handle type and axis from handle name + const handleName = this._hoveredHandle.name; + if (handleName.includes('FACE_')) { + this._activeHandleType = 'face'; + // Extract axis from face name (FACE_POS_X, FACE_NEG_Y, etc.) + if (handleName.includes('_X')) { + this._activeAxis = 'x'; + } else if (handleName.includes('_Y')) { + this._activeAxis = 'y'; + } else if (handleName.includes('_Z')) { + this._activeAxis = 'z'; + } + } else { + this._activeHandleType = 'corner'; + this._activeAxis = null; + } + // Set scaling state this._isScaling = true; this._activeController = controller; @@ -283,7 +341,7 @@ export class ResizeGizmo { // Change outline to blue to indicate grabbed state this._activeHandle.edgesColor = Color4.FromColor3(Color3.Blue()); - this._logger.debug(`Scaling started: stickLength=${this._originalStickLength}, handleDistance=${this._originalHandleDistance}`); + this._logger.debug(`Scaling started: type=${this._activeHandleType}, axis=${this._activeAxis}, stickLength=${this._originalStickLength}, handleDistance=${this._originalHandleDistance}`); } } @@ -297,11 +355,20 @@ export class ResizeGizmo { // Snap scale to 0.1 increments const currentScale = this._targetMesh.scaling; - const roundedScale = new Vector3( - Math.round(currentScale.x * 10) / 10, - Math.round(currentScale.y * 10) / 10, - Math.round(currentScale.z * 10) / 10 - ); + let roundedScale: Vector3; + + if (this._activeHandleType === 'face' && this._activeAxis) { + // Face handle: only round the active axis + roundedScale = currentScale.clone(); + roundedScale[this._activeAxis] = Math.round(currentScale[this._activeAxis] * 10) / 10; + } else { + // Corner handle: round all axes + roundedScale = new Vector3( + Math.round(currentScale.x * 10) / 10, + Math.round(currentScale.y * 10) / 10, + Math.round(currentScale.z * 10) / 10 + ); + } // Apply snapped scale this._targetMesh.scaling = roundedScale; @@ -352,13 +419,101 @@ export class ResizeGizmo { // Calculate scale ratio const scaleRatio = newDistance / this._originalHandleDistance; - // Apply uniform scaling (smooth, no snapping yet) - this._targetMesh.scaling = this._initialScale.scale(scaleRatio); + // Apply scaling based on handle type + if (this._activeHandleType === 'face' && this._activeAxis) { + // Face handle: scale only on the active axis + const newScale = this._initialScale.clone(); + newScale[this._activeAxis] = this._initialScale[this._activeAxis] * scaleRatio; + this._targetMesh.scaling = newScale; + } else { + // Corner handle: uniform scaling on all axes + this._targetMesh.scaling = this._initialScale.scale(scaleRatio); + } + + // Update handle positions and sizes to match new bounding box + this.updateHandleTransforms(); // Notify observers this.onScaleDrag.notifyObservers({ mesh: this._targetMesh }); } + /** + * Check if target mesh position or rotation has changed, and update handles if needed + */ + private checkTransformChanges(): void { + if (!this._lastPosition || !this._lastRotationQuaternion) return; + + const currentPosition = this._targetMesh.absolutePosition; + const currentRotationQuaternion = this._targetMesh.absoluteRotationQuaternion; + + // Check if position changed (using a small epsilon for floating point comparison) + const positionChanged = !currentPosition.equalsWithEpsilon(this._lastPosition, 0.0001); + + // Check if rotation changed + const rotationChanged = !currentRotationQuaternion.equalsWithEpsilon(this._lastRotationQuaternion, 0.0001); + + if (positionChanged || rotationChanged) { + // Update handles to match new transform + this.updateHandleTransforms(); + + // Update tracked values + this._lastPosition = currentPosition.clone(); + this._lastRotationQuaternion = currentRotationQuaternion.clone(); + } + } + + /** + * Update handle positions and sizes to match current target mesh bounding box + */ + private updateHandleTransforms(): void { + const targetBoundingInfo = this._targetMesh.getBoundingInfo(); + const boundingBox = targetBoundingInfo.boundingBox; + const bboxCenter = boundingBox.centerWorld; + const extents = boundingBox.extendSize; + const innerCorners = boundingBox.vectorsWorld; + const worldMatrix = this._targetMesh.getWorldMatrix(); + + // Recalculate handle size based on new bounding box + const newHandleSize = innerCorners[0].subtract(bboxCenter).length() * .2; + + let handleIndex = 0; + + // Update corner handles (first 8 handles) + for (let i = 0; i < CORNER_POSITIONS.length; i++) { + const handle = this._handles[handleIndex]; + const cornerPos = innerCorners[i]; + + // Update position + const direction = cornerPos.subtract(bboxCenter).normalize(); + const offset = direction.scale(newHandleSize * Math.sqrt(3) / 2); + handle.position = cornerPos.add(offset); + handle.rotationQuaternion = this._targetMesh.absoluteRotationQuaternion; + + handleIndex++; + } + + // Update face handles (next 6 handles) + for (const faceDef of FACE_POSITIONS) { + const handle = this._handles[handleIndex]; + + // Calculate face center position in world space + const localFacePos = new Vector3( + faceDef.position.x * extents.x, + faceDef.position.y * extents.y, + faceDef.position.z * extents.z + ); + const faceCenterWorld = Vector3.TransformCoordinates(localFacePos, worldMatrix); + + // Update position + const direction = faceCenterWorld.subtract(bboxCenter).normalize(); + const offset = direction.scale(newHandleSize * Math.sqrt(3) / 2); + handle.position = faceCenterWorld.add(offset); + handle.rotationQuaternion = this._targetMesh.absoluteRotationQuaternion; + + handleIndex++; + } + } + /** * Get a ray from an XR controller's pointer * @param controller - XR input source diff --git a/src/gizmos/ResizeGizmo/enums.ts b/src/gizmos/ResizeGizmo/enums.ts index cb76a6c..70d110f 100644 --- a/src/gizmos/ResizeGizmo/enums.ts +++ b/src/gizmos/ResizeGizmo/enums.ts @@ -83,3 +83,40 @@ export const CORNER_POSITIONS: readonly HandlePositionDef[] = [ description: 'Top-front-left (-X, +Y, +Z)' }, ] as const; + +/** + * Face handle positions as static constants + * Normalized coordinates have one axis at 0 (face center), others at -1 or +1 + */ +export const FACE_POSITIONS: readonly HandlePositionDef[] = [ + { + name: 'FACE_POS_X', + position: { x: +1, y: 0, z: 0 }, + description: 'Right face (+X)' + }, + { + name: 'FACE_NEG_X', + position: { x: -1, y: 0, z: 0 }, + description: 'Left face (-X)' + }, + { + name: 'FACE_POS_Y', + position: { x: 0, y: +1, z: 0 }, + description: 'Top face (+Y)' + }, + { + name: 'FACE_NEG_Y', + position: { x: 0, y: -1, z: 0 }, + description: 'Bottom face (-Y)' + }, + { + name: 'FACE_POS_Z', + position: { x: 0, y: 0, z: +1 }, + description: 'Front face (+Z)' + }, + { + name: 'FACE_NEG_Z', + position: { x: 0, y: 0, z: -1 }, + description: 'Back face (-Z)' + }, +] as const; diff --git a/src/gizmos/ResizeGizmo/index.ts b/src/gizmos/ResizeGizmo/index.ts index a908423..166f0c7 100644 --- a/src/gizmos/ResizeGizmo/index.ts +++ b/src/gizmos/ResizeGizmo/index.ts @@ -8,6 +8,6 @@ */ export { ResizeGizmo } from './ResizeGizmo'; -export type { ResizeGizmoEvent, HandleInfo } from './types'; +export type { ResizeGizmoEvent } from './types'; export type { HandlePositionDef } from './enums'; -export { HandleType, HandleState, CORNER_POSITIONS } from './enums'; +export { HandleType, HandleState, CORNER_POSITIONS, FACE_POSITIONS } from './enums'; diff --git a/src/util/functions/sceneInspector.ts b/src/util/functions/sceneInspector.ts index 944e2d7..115dc28 100644 --- a/src/util/functions/sceneInspector.ts +++ b/src/util/functions/sceneInspector.ts @@ -1,15 +1,29 @@ import {DefaultScene} from "../../defaultScene"; +import {ResizeGizmo} from "../../gizmos/ResizeGizmo"; export function addSceneInspector() { window.addEventListener("keydown", (ev) => { // Ctrl+Shift+I to open inspector - if (ev.shiftKey && ev.ctrlKey && !ev.altKey && ev.keyCode === 73) { - import ("@babylonjs/inspector").then((inspector) => { - inspector.Inspector.Show(DefaultScene.Scene, { - overlay: true, - showExplorer: true - }); - }); + if (ev.ctrlKey) { + + switch (ev.key) { + case 'I': + import ("@babylonjs/inspector").then((inspector) => { + inspector.Inspector.Show(DefaultScene.Scene, { + overlay: true, + showExplorer: true + }); + }); + break; + case 'U': + import ("@babylonjs/inspector").then((inspector) => { + inspector.Inspector.Show(ResizeGizmo.utilityLayer.utilityLayerScene, { + overlay: true, + showExplorer: true + }); + }); + } + /*import("@babylonjs/core/Debug").then(() => { import("@babylonjs/inspector").then(() => { const web = document.querySelector('#webApp');