diff --git a/src/controllers/webController.ts b/src/controllers/webController.ts index 43e7f79..cc77a96 100644 --- a/src/controllers/webController.ts +++ b/src/controllers/webController.ts @@ -4,6 +4,9 @@ import {Rigplatform} from "./rigplatform"; import {DiagramManager} from "../diagram/diagramManager"; import {wheelHandler} from "./functions/wheelHandler"; import log, {Logger} from "loglevel"; +import {DiagramEntityType, DiagramEventType, DiagramTemplates} from "../diagram/types/diagramEntity"; +import {DiagramEventObserverMask} from "../diagram/types/diagramEventObserverMask"; +import {getToolboxColors} from "../toolbox/toolbox"; export class WebController { private readonly scene: Scene; @@ -94,6 +97,18 @@ export class WebController { */ break; + case "T": + // Ctrl+Shift+T: Create test entities (sphere and box) + if (kbInfo.event.ctrlKey && kbInfo.event.shiftKey) { + this.createTestEntities(); + } + break; + case "X": + // Ctrl+Shift+X: Clear all entities from diagram + if (kbInfo.event.ctrlKey && kbInfo.event.shiftKey) { + this.clearAllEntities(); + } + break; default: this.logger.debug(kbInfo.event); @@ -240,4 +255,57 @@ export class WebController { } this._mesh = mesh; } + + /** + * Create test entities for testing ResizeGizmo + * Creates a sphere at (-0.25, 1.5, 4) and a box at (0.25, 1.5, 4) + */ + private createTestEntities(): void { + this.logger.info('Creating test entities (Ctrl+Shift+T)'); + + // Get first color from toolbox colors array + const firstColor = getToolboxColors()[0]; + const colorHex = firstColor.replace('#', ''); + + // Create sphere + this.diagramManager.onDiagramEventObservable.notifyObservers({ + type: DiagramEventType.ADD, + entity: { + id: `test-sphere-${colorHex}`, + type: DiagramEntityType.ENTITY, + template: DiagramTemplates.SPHERE, + position: { x: -0.25, y: 1.5, z: 4 }, + scale: { x: 0.1, y: 0.1, z: 0.1 }, + color: firstColor + } + }, DiagramEventObserverMask.ALL); + + // Create box + this.diagramManager.onDiagramEventObservable.notifyObservers({ + type: DiagramEventType.ADD, + entity: { + id: `test-box-${colorHex}`, + type: DiagramEntityType.ENTITY, + template: DiagramTemplates.BOX, + position: { x: 0.25, y: 1.5, z: 4 }, + scale: { x: 0.1, y: 0.1, z: 0.1 }, + color: firstColor + } + }, DiagramEventObserverMask.ALL); + + this.logger.info(`Test entities created with color ${firstColor}: test-sphere-${colorHex} at (-0.25, 1.5, 4) and test-box-${colorHex} at (0.25, 1.5, 4)`); + } + + /** + * Clear all entities from the diagram + */ + private clearAllEntities(): void { + this.logger.info('Clearing all entities from diagram (Ctrl+Shift+X)'); + + this.diagramManager.onDiagramEventObservable.notifyObservers({ + type: DiagramEventType.CLEAR + }, DiagramEventObserverMask.TO_DB); + + this.logger.info('All entities cleared from diagram'); + } } \ No newline at end of file diff --git a/src/diagram/diagramManager.ts b/src/diagram/diagramManager.ts index 7cbb393..10cbd79 100644 --- a/src/diagram/diagramManager.ts +++ b/src/diagram/diagramManager.ts @@ -111,10 +111,6 @@ export class DiagramManager { return this._diagramEntityActionManager; } - public get diagramMenuManager(): DiagramMenuManager { - return this._diagramMenuManager; - } - public getDiagramObject(id: string) { return this._diagramObjects.get(id); } @@ -147,6 +143,7 @@ export class DiagramManager { switch (event.type) { case DiagramEventType.CLEAR: this._diagramObjects.forEach((value) => { + value.dispose(); }); this._diagramObjects.clear(); diff --git a/src/diagram/diagramMenuManager.ts b/src/diagram/diagramMenuManager.ts index b11263a..c0a7d08 100644 --- a/src/diagram/diagramMenuManager.ts +++ b/src/diagram/diagramMenuManager.ts @@ -23,6 +23,7 @@ export class DiagramMenuManager { private _logger = log.getLogger('DiagramMenuManager'); private _connectionPreview: ConnectionPreview; private _activeResizeGizmo: ResizeGizmo | null = null; + private _xr: WebXRDefaultExperience | null = null; constructor(notifier: Observable, controllerObservable: Observable, readyObservable: Observable) { this._scene = DefaultScene.Scene; @@ -92,8 +93,14 @@ export class DiagramMenuManager { this._activeResizeGizmo = null; } + // XR must be available to create resize gizmo + if (!this._xr) { + this._logger.warn('Cannot activate resize gizmo: XR not initialized'); + return; + } + // Create new resize gizmo for the mesh - this._activeResizeGizmo = new ResizeGizmo(mesh); + this._activeResizeGizmo = new ResizeGizmo(mesh, this._xr); // Listen for scale end event to notify diagram manager this._activeResizeGizmo.onScaleEnd.add(() => { @@ -135,9 +142,9 @@ export class DiagramMenuManager { case "group": this._groupMenu = new GroupMenu(clickMenu.mesh); break; - // case "close": - // // DISCONNECTED - Ready for new scaling implementation - // break; + case "close": + this.disposeResizeGizmo(); + break; } this._logger.debug(evt); @@ -151,6 +158,7 @@ export class DiagramMenuManager { } public setXR(xr: WebXRDefaultExperience): void { + this._xr = xr; this.toolbox.setXR(xr); } } \ No newline at end of file diff --git a/src/gizmos/ResizeGizmo/ResizeGizmo.ts b/src/gizmos/ResizeGizmo/ResizeGizmo.ts new file mode 100644 index 0000000..6bd21fd --- /dev/null +++ b/src/gizmos/ResizeGizmo/ResizeGizmo.ts @@ -0,0 +1,665 @@ +import { + AbstractMesh, + Color3, + Mesh, + MeshBuilder, + Observable, + Observer, + Ray, Scene, + StandardMaterial, + UtilityLayerRenderer, + Vector3, + WebXRDefaultExperience, + WebXRInputSource, +} from '@babylonjs/core'; + +import log from 'loglevel'; +import { HandleType, HandleState } from './enums'; +import { ResizeGizmoEvent, HandleInfo } from './types'; + +/** + * ResizeGizmo - Simple gizmo for resizing meshes in WebXR + * + * Features: + * - 6 face handles for single-axis scaling + * - 8 corner handles for uniform scaling + * - XR controller grip interaction + * - Billboard scaling for constant screen-size handles + * - Renders in utility layer (separate from main scene) + */ +export class ResizeGizmo { + private _scene: Scene; + private _xr: WebXRDefaultExperience; + private targetMesh: AbstractMesh; + private utilityLayer: UtilityLayerRenderer; + private handles: HandleInfo[] = []; + private logger = log.getLogger('ResizeGizmo'); + + // Materials for different states + private normalMaterial: StandardMaterial; + private hoverMaterial: StandardMaterial; + private activeMaterial: StandardMaterial; + + // Interaction state + private activeHandle: HandleInfo | null = null; + private activeController: WebXRInputSource | null = null; + + // Virtual Stick state + private originalStickLength: number = 0; // World-space distance from controller to handle at grip time + private initialLocalOffset: Vector3 | null = null; // Local-space offset from mesh center to handle center + private initialLocalDistance: number = 0; // Length of initial local offset + private initialScale: Vector3 | null = null; // Mesh scale at grip time + + // Observables for events + public onScaleDrag: Observable; + public onScaleEnd: Observable; + + // Frame observers + private beforeRenderObserver: Observer | null = null; + + // Constants + private static readonly HANDLE_SIZE = 0.1; + private static readonly HANDLE_OFFSET = 0.05; + private static readonly BILLBOARD_SCALE_DISTANCE = 10; // Reference distance for billboard scaling + private static readonly SCALE_INCREMENT = 0.1; + private static readonly MIN_SCALE = 0.1; + + constructor(targetMesh: AbstractMesh, xr: WebXRDefaultExperience) { + this._scene = targetMesh.getScene(); + this._xr = xr; + this.targetMesh = targetMesh; + this.onScaleDrag = new Observable(); + this.onScaleEnd = new Observable(); + + this.logger.info(`Creating ResizeGizmo for mesh: ${targetMesh.name} (${targetMesh.id})`); + + // Create utility layer for rendering handles + this.utilityLayer = new UtilityLayerRenderer(this._scene); + this.utilityLayer.utilityLayerScene.autoClearDepthAndStencil = false; + + // Create materials + this.createMaterials(); + + // Create handles + this.createHandles(); + + this.logger.debug(`ResizeGizmo initialized with ${this.handles.length} handles (6 face + 8 corner)`); + + // Set up XR interaction + this.setupXRInteraction(); + + // Set up per-frame updates + this.setupFrameUpdates(); + } + + /** + * Create materials for handle states + */ + private createMaterials(): void { + // Normal state - Gray + this.normalMaterial = new StandardMaterial('resizeGizmo_normal', this.utilityLayer.utilityLayerScene); + this.normalMaterial.diffuseColor = new Color3(0.5, 0.5, 0.5); + this.normalMaterial.specularColor = new Color3(0.2, 0.2, 0.2); + + // Hover state - White + this.hoverMaterial = new StandardMaterial('resizeGizmo_hover', this.utilityLayer.utilityLayerScene); + this.hoverMaterial.diffuseColor = new Color3(1, 1, 1); + this.hoverMaterial.specularColor = new Color3(0.3, 0.3, 0.3); + this.hoverMaterial.emissiveColor = new Color3(0.2, 0.2, 0.2); + + // Active state - Blue + this.activeMaterial = new StandardMaterial('resizeGizmo_active', this.utilityLayer.utilityLayerScene); + this.activeMaterial.diffuseColor = new Color3(0.2, 0.5, 1); + this.activeMaterial.specularColor = new Color3(0.5, 0.7, 1); + this.activeMaterial.emissiveColor = new Color3(0.1, 0.3, 0.6); + } + + /** + * Create all handle meshes (6 face + 8 corner) + */ + private createHandles(): void { + // Face handles (single-axis scaling) + this.createFaceHandle(HandleType.FACE_POS_X, new Vector3(1, 0, 0)); + this.createFaceHandle(HandleType.FACE_NEG_X, new Vector3(-1, 0, 0)); + this.createFaceHandle(HandleType.FACE_POS_Y, new Vector3(0, 1, 0)); + this.createFaceHandle(HandleType.FACE_NEG_Y, new Vector3(0, -1, 0)); + this.createFaceHandle(HandleType.FACE_POS_Z, new Vector3(0, 0, 1)); + this.createFaceHandle(HandleType.FACE_NEG_Z, new Vector3(0, 0, -1)); + + // Corner handles (uniform scaling) + this.createCornerHandle(HandleType.CORNER_PPP, new Vector3(1, 1, 1)); + this.createCornerHandle(HandleType.CORNER_PPN, new Vector3(1, 1, -1)); + this.createCornerHandle(HandleType.CORNER_PNP, new Vector3(1, -1, 1)); + this.createCornerHandle(HandleType.CORNER_PNN, new Vector3(1, -1, -1)); + this.createCornerHandle(HandleType.CORNER_NPP, new Vector3(-1, 1, 1)); + this.createCornerHandle(HandleType.CORNER_NPN, new Vector3(-1, 1, -1)); + this.createCornerHandle(HandleType.CORNER_NNP, new Vector3(-1, -1, 1)); + this.createCornerHandle(HandleType.CORNER_NNN, new Vector3(-1, -1, -1)); + + // Initial positioning + this.updateHandlePositions(); + } + + /** + * Create a face handle at the specified local offset direction + */ + private createFaceHandle(type: HandleType, direction: Vector3): void { + const handle = MeshBuilder.CreateBox( + `resizeHandle_${type}`, + { size: ResizeGizmo.HANDLE_SIZE }, + this.utilityLayer.utilityLayerScene + ); + + handle.material = this.normalMaterial; + + this.handles.push({ + mesh: handle, + type, + state: HandleState.NORMAL, + material: this.normalMaterial, + localOffset: direction.clone(), + }); + } + + /** + * Create a corner handle at the specified local offset direction + */ + private createCornerHandle(type: HandleType, direction: Vector3): void { + const handle = MeshBuilder.CreateBox( + `resizeHandle_${type}`, + { size: ResizeGizmo.HANDLE_SIZE }, + this.utilityLayer.utilityLayerScene + ); + + handle.material = this.normalMaterial; + + this.handles.push({ + mesh: handle, + type, + state: HandleState.NORMAL, + material: this.normalMaterial, + localOffset: direction.clone().normalize(), + }); + } + + /** + * Update handle positions based on target mesh bounding box + */ + private updateHandlePositions(): void { + const boundingInfo = this.targetMesh.getBoundingInfo(); + const boundingBox = boundingInfo.boundingBox; + + // Get bounding box extents in local space + const extents = boundingBox.extendSize; + + // Get target mesh world matrix and rotation + const worldMatrix = this.targetMesh.getWorldMatrix(); + + // Extract rotation from world matrix to handle all rotation types + const targetRotation = this.targetMesh.absoluteRotationQuaternion; + + for (const handleInfo of this.handles) { + // Calculate position based on handle type + let localPos: Vector3; + + if (handleInfo.type.startsWith('face_')) { + // Face handles: positioned at face centers + localPos = new Vector3( + handleInfo.localOffset.x * extents.x, + handleInfo.localOffset.y * extents.y, + handleInfo.localOffset.z * extents.z + ); + } else { + // Corner handles: positioned at corners + localPos = new Vector3( + handleInfo.localOffset.x * extents.x, + handleInfo.localOffset.y * extents.y, + handleInfo.localOffset.z * extents.z + ); + } + + // Add offset to move handle outside bounding box + const offsetDir = handleInfo.localOffset.clone().normalize(); + localPos.addInPlace(offsetDir.scale(ResizeGizmo.HANDLE_SIZE / 2 + ResizeGizmo.HANDLE_OFFSET)); + + // Transform to world space + const worldPos = Vector3.TransformCoordinates(localPos, worldMatrix); + handleInfo.mesh.position = worldPos; + + // Apply rotation to match target mesh orientation + handleInfo.mesh.rotationQuaternion = targetRotation.clone(); + + // Apply billboard scaling + this.applyBillboardScale(handleInfo.mesh); + } + } + + /** + * Apply billboard scaling to maintain constant screen size + */ + private applyBillboardScale(handleMesh: Mesh): void { + const camera = this.utilityLayer.utilityLayerScene.activeCamera; + if (!camera) return; + + const distance = Vector3.Distance(camera.position, handleMesh.position); + const scaleFactor = distance / ResizeGizmo.BILLBOARD_SCALE_DISTANCE; + + handleMesh.scaling = new Vector3(scaleFactor, scaleFactor, scaleFactor); + } + + /** + * Set up XR controller interaction + */ + private setupXRInteraction(): void { + if (!this._xr) { + this.logger.error('No XR present'); + return; + } + const controllers = this._xr.input?.controllers?.values(); + if (controllers) { + for (const controller of controllers) { + const motionController = controller.motionController; + const gripComponent = motionController.getComponent('xr-standard-squeeze'); + if (gripComponent) { + this.logger.debug('Grip Component loaded'); + gripComponent.onButtonStateChangedObservable.add((component) => { + if (component.pressed) { + this.onGripPressed(controller); + } else { + this.onGripReleased(controller); + } + }); + } + } + } else { + this._xr.input.onControllerAddedObservable.add((controller) => { + const motionController = controller.motionController; + if (!motionController) return; + + // Listen for grip button + const gripComponent = motionController.getComponent('squeeze'); + if (gripComponent) { + gripComponent.onButtonStateChangedObservable.add((component) => { + if (component.pressed) { + this.onGripPressed(controller); + } else { + this.onGripReleased(controller); + } + }); + } + }); + } + // Listen for controller added + + } + + /** + * Set up per-frame updates + */ + private setupFrameUpdates(): void { + this.beforeRenderObserver = this._scene.onBeforeRenderObservable.add(() => { + this.updateFrame(); + }); + } + + /** + * Update each frame + */ + private updateFrame(): void { + // Update active scaling first + if (this.activeHandle && this.activeController) { + this.updateScaling(); + // Don't update handle positions during active scaling to prevent feedback loop + return; + } + + // Update handle positions (only when not actively scaling) + this.updateHandlePositions(); + + // Check for hover states + this.updateHoverStates(); + } + + /** + * Manually perform ray casting against utility layer handles + * Returns the closest handle hit by the controller's ray, or null + */ + private getHandleUnderPointer(controller: WebXRInputSource): HandleInfo | null { + // Get controller pointer and transform to world coordinates + const pointerWorldMatrix = controller.pointer.getWorldMatrix(); + const pointerPos = Vector3.TransformCoordinates(Vector3.Zero(), pointerWorldMatrix); + const pointerForward = Vector3.TransformNormal(Vector3.Forward(), pointerWorldMatrix); + + // Create ray from controller pointer in world space + const ray = new Ray(pointerPos, pointerForward, 50); + + let closestHandle: HandleInfo | null = null; + let closestDistance = Infinity; + + // Test ray against each handle mesh in utility layer + for (const handleInfo of this.handles) { + const pickInfo = ray.intersectsMesh(handleInfo.mesh); + + if (pickInfo.hit && pickInfo.distance < closestDistance) { + closestDistance = pickInfo.distance; + closestHandle = handleInfo; + + } + } + + return closestHandle; + } + + /** + * Check which handle (if any) is being pointed at by XR controllers + */ + private updateHoverStates(): void { + if (!this._xr || this.activeHandle) return; // Don't update hover during active scaling + + // Reset all handles to normal + for (const handleInfo of this.handles) { + if (handleInfo.state === HandleState.HOVER) { + this.setHandleState(handleInfo, HandleState.NORMAL); + } + } + + // Check each controller with manual ray casting + for (const controller of this._xr.input.controllers.values()) { + const handleInfo = this.getHandleUnderPointer(controller); + if (handleInfo) { + //this.logger.debug(`Handle hover detected: ${handleInfo.type} by controller ${controller.uniqueId}`); + this.setHandleState(handleInfo, HandleState.HOVER); + } + } + } + + /** + * Handle grip button pressed + */ + private onGripPressed(controller: WebXRInputSource): void { + this.logger.debug('GripPressed'); + if (this.activeHandle) return; // Already gripping + + // Use manual ray casting to check for handle under pointer + const handleInfo = this.getHandleUnderPointer(controller); + if (!handleInfo) { + this.logger.debug(`Grip pressed but no handle under pointer (controller ${controller.uniqueId})`); + return; + } + + // Calculate Virtual Stick state at grip time + // 1. Get controller world position + const pointerWorldMatrix = controller.pointer.getWorldMatrix(); + const controllerWorldPos = Vector3.TransformCoordinates(Vector3.Zero(), pointerWorldMatrix); + + // 2. Get handle center world position (original "end of stick") + const handleWorldPos = handleInfo.mesh.position.clone(); + + // 3. Calculate original stick length in world space + this.originalStickLength = Vector3.Distance(controllerWorldPos, handleWorldPos); + + // 4. Get target mesh center in world space + const meshWorldMatrix = this.targetMesh.getWorldMatrix(); + const meshWorldCenter = Vector3.TransformCoordinates(Vector3.Zero(), meshWorldMatrix); + + // 5. Calculate initial offset in local space (from mesh center to handle center) + const meshInverseMatrix = meshWorldMatrix.clone().invert(); + const handleLocalPos = Vector3.TransformCoordinates(handleWorldPos, meshInverseMatrix); + this.initialLocalOffset = handleLocalPos.clone(); + this.initialLocalDistance = handleLocalPos.length(); + + // 6. Store initial scale + this.initialScale = this.targetMesh.scaling.clone(); + + // Set active state + this.activeHandle = handleInfo; + this.activeController = controller; + + this.logger.info(`Grip started on handle: ${handleInfo.type}`); + this.logger.debug(` Original stick length (world): ${this.originalStickLength.toFixed(3)}`); + this.logger.debug(` Initial local offset: ${this.initialLocalOffset.toString()}`); + this.logger.debug(` Initial local distance: ${this.initialLocalDistance.toFixed(3)}`); + this.logger.debug(` Initial scale: ${this.initialScale.toString()}`); + + this.setHandleState(handleInfo, HandleState.ACTIVE); + + // Haptic feedback + controller.motionController?.pulse(0.5, 100); + } + + /** + * Handle grip button released + */ + private onGripReleased(controller: WebXRInputSource): void { + if (!this.activeHandle || this.activeController !== controller) return; + + const handleType = this.activeHandle.type; + + // Round scale to nearest 0.1 increment on release + this.applyRoundedScale(); + + const finalScale = this.targetMesh.scaling.clone(); + + this.logger.info(`Grip released on handle: ${handleType}`); + this.logger.debug(` Final scale (after rounding): ${finalScale.toString()}`); + this.logger.debug(` Scale change: x=${(finalScale.x / this.initialScale!.x).toFixed(2)}, y=${(finalScale.y / this.initialScale!.y).toFixed(2)}, z=${(finalScale.z / this.initialScale!.z).toFixed(2)}`); + + // End gripping + this.setHandleState(this.activeHandle, HandleState.NORMAL); + this.activeHandle = null; + this.activeController = null; + + // Clear Virtual Stick state + this.originalStickLength = 0; + this.initialLocalOffset = null; + this.initialLocalDistance = 0; + this.initialScale = null; + + // Fire onScaleEnd event + this.onScaleEnd.notifyObservers({ mesh: this.targetMesh }); + + // Haptic feedback + controller.motionController?.pulse(0.3, 50); + } + + /** + * Update scaling during active grip using Virtual Stick approach + */ + private updateScaling(): void { + if (!this.activeHandle || !this.activeController || !this.initialLocalOffset || !this.initialScale) { + return; + } + + // 1. Calculate new "end of stick" position in world space + const pointerWorldMatrix = this.activeController.pointer.getWorldMatrix(); + const controllerWorldPos = Vector3.TransformCoordinates(Vector3.Zero(), pointerWorldMatrix); + const controllerWorldForward = Vector3.TransformNormal(Vector3.Forward(), pointerWorldMatrix); + + // Extend forward by original stick length (fixed length) + const newStickEndWorld = controllerWorldPos.add(controllerWorldForward.scale(this.originalStickLength)); + + // 2. Transform new stick-end position to target mesh's local space + const meshWorldMatrix = this.targetMesh.getWorldMatrix(); + const meshInverseMatrix = meshWorldMatrix.clone().invert(); + const newStickEndLocal = Vector3.TransformCoordinates(newStickEndWorld, meshInverseMatrix); + + // 3. Calculate new distance in local space + const newLocalDistance = newStickEndLocal.length(); + + // 4. Calculate scale ratio (no rounding during drag for smooth scaling) + const scaleRatio = newLocalDistance / this.initialLocalDistance; + + // 5. Apply scaling based on handle type + if (this.activeHandle.type.startsWith('face_')) { + this.applySingleAxisScaling(scaleRatio, newStickEndLocal); + } else { + this.applyUniformScaling(scaleRatio); + } + + // Fire onScaleDrag event + this.onScaleDrag.notifyObservers({ mesh: this.targetMesh }); + } + + /** + * Apply single-axis scaling from a face handle + * Scales only the appropriate axis based on scale ratio + */ + private applySingleAxisScaling(scaleRatio: number, newStickEndLocal: Vector3): void { + if (!this.activeHandle || !this.initialScale || !this.initialLocalOffset) return; + + // Determine which axis to scale based on initial local offset + const offset = this.initialLocalOffset; + let axis: 'x' | 'y' | 'z'; + + if (Math.abs(offset.x) > Math.abs(offset.y) && Math.abs(offset.x) > Math.abs(offset.z)) { + axis = 'x'; + } else if (Math.abs(offset.y) > Math.abs(offset.z)) { + axis = 'y'; + } else { + axis = 'z'; + } + + // Apply scale ratio to the appropriate axis + const newScale = this.initialScale.clone(); + newScale[axis] = Math.max(ResizeGizmo.MIN_SCALE, this.initialScale[axis] * scaleRatio); + + this.logger.debug(`Single-axis scaling: axis=${axis.toUpperCase()}, ratio=${scaleRatio.toFixed(2)}, new scale=${newScale[axis].toFixed(2)}`); + + this.targetMesh.scaling = newScale; + } + + /** + * Apply uniform scaling from a corner handle + * Scales all axes uniformly based on scale ratio + */ + private applyUniformScaling(scaleRatio: number): void { + if (!this.initialScale) return; + + // Apply scale ratio uniformly to all axes + const newScale = this.initialScale.clone().scale(scaleRatio); + + // Clamp to minimum + newScale.x = Math.max(ResizeGizmo.MIN_SCALE, newScale.x); + newScale.y = Math.max(ResizeGizmo.MIN_SCALE, newScale.y); + newScale.z = Math.max(ResizeGizmo.MIN_SCALE, newScale.z); + + this.logger.debug(`Uniform scaling: ratio=${scaleRatio.toFixed(2)}, new scale=(${newScale.x.toFixed(2)}, ${newScale.y.toFixed(2)}, ${newScale.z.toFixed(2)})`); + + this.targetMesh.scaling = newScale; + } + + /** + * Apply rounded scale on grip release + * Face handles: round only the scaled axis + * Corner handles: round uniformly on all axes + */ + private applyRoundedScale(): void { + if (!this.activeHandle || !this.initialScale) return; + + const currentScale = this.targetMesh.scaling.clone(); + const newScale = this.initialScale.clone(); + + if (this.activeHandle.type.startsWith('face_')) { + // Face handle: round only the affected axis + const offset = this.initialLocalOffset!; + let axis: 'x' | 'y' | 'z'; + + // Determine which axis was scaled + if (Math.abs(offset.x) > Math.abs(offset.y) && Math.abs(offset.x) > Math.abs(offset.z)) { + axis = 'x'; + } else if (Math.abs(offset.y) > Math.abs(offset.z)) { + axis = 'y'; + } else { + axis = 'z'; + } + + // Calculate and round the ratio for this axis + const ratio = currentScale[axis] / this.initialScale[axis]; + const roundedRatio = Math.round(ratio * 10) / 10; + + // Apply rounded ratio + newScale[axis] = Math.max(ResizeGizmo.MIN_SCALE, this.initialScale[axis] * roundedRatio); + + // Keep other axes unchanged + const otherAxes = ['x', 'y', 'z'].filter(a => a !== axis) as ('x' | 'y' | 'z')[]; + otherAxes.forEach(a => newScale[a] = currentScale[a]); + + this.logger.debug(`Rounding face handle: axis=${axis}, ratio=${ratio.toFixed(3)} → ${roundedRatio.toFixed(1)}`); + + } else { + // Corner handle: round uniformly + // Use average ratio across all axes + const avgRatio = ( + (currentScale.x / this.initialScale.x) + + (currentScale.y / this.initialScale.y) + + (currentScale.z / this.initialScale.z) + ) / 3; + + const roundedRatio = Math.round(avgRatio * 10) / 10; + + // Apply same rounded ratio to all axes + newScale.x = Math.max(ResizeGizmo.MIN_SCALE, this.initialScale.x * roundedRatio); + newScale.y = Math.max(ResizeGizmo.MIN_SCALE, this.initialScale.y * roundedRatio); + newScale.z = Math.max(ResizeGizmo.MIN_SCALE, this.initialScale.z * roundedRatio); + + this.logger.debug(`Rounding corner handle: ratio=${avgRatio.toFixed(3)} → ${roundedRatio.toFixed(1)}`); + } + + this.targetMesh.scaling = newScale; + } + + /** + * Set handle state and update visual appearance + */ + private setHandleState(handleInfo: HandleInfo, state: HandleState): void { + handleInfo.state = state; + + switch (state) { + case HandleState.NORMAL: + handleInfo.mesh.material = this.normalMaterial; + handleInfo.mesh.scaling = handleInfo.mesh.scaling.scale(1 / 1.2); // Reset scale + break; + case HandleState.HOVER: + handleInfo.mesh.material = this.hoverMaterial; + handleInfo.mesh.scaling = handleInfo.mesh.scaling.scale(1.2); // Slightly larger + break; + case HandleState.ACTIVE: + handleInfo.mesh.material = this.activeMaterial; + break; + } + } + + /** + * Dispose of the gizmo and clean up resources + */ + public dispose(): void { + this.logger.info(`Disposing ResizeGizmo for mesh: ${this.targetMesh.name} (${this.targetMesh.id})`); + + // Remove observers + if (this.beforeRenderObserver) { + this._scene.onBeforeRenderObservable.remove(this.beforeRenderObserver); + this.beforeRenderObserver = null; + } + + // Dispose handles + for (const handleInfo of this.handles) { + handleInfo.mesh.dispose(); + } + this.handles = []; + + // Dispose materials + this.normalMaterial.dispose(); + this.hoverMaterial.dispose(); + this.activeMaterial.dispose(); + + // Dispose utility layer + this.utilityLayer.dispose(); + + // Clear observables + this.onScaleDrag.clear(); + this.onScaleEnd.clear(); + + this._xr = null; + this._scene = null; + } +} diff --git a/src/gizmos/ResizeGizmo/enums.ts b/src/gizmos/ResizeGizmo/enums.ts new file mode 100644 index 0000000..64b6bc8 --- /dev/null +++ b/src/gizmos/ResizeGizmo/enums.ts @@ -0,0 +1,28 @@ +/** + * Handle types for the resize gizmo + */ +export enum HandleType { + FACE_POS_X = 'face_pos_x', + FACE_NEG_X = 'face_neg_x', + FACE_POS_Y = 'face_pos_y', + FACE_NEG_Y = 'face_neg_y', + FACE_POS_Z = 'face_pos_z', + FACE_NEG_Z = 'face_neg_z', + CORNER_PPP = 'corner_ppp', // (+X, +Y, +Z) + CORNER_PPN = 'corner_ppn', // (+X, +Y, -Z) + CORNER_PNP = 'corner_pnp', // (+X, -Y, +Z) + CORNER_PNN = 'corner_pnn', // (+X, -Y, -Z) + CORNER_NPP = 'corner_npp', // (-X, +Y, +Z) + CORNER_NPN = 'corner_npn', // (-X, +Y, -Z) + CORNER_NNP = 'corner_nnp', // (-X, -Y, +Z) + CORNER_NNN = 'corner_nnn', // (-X, -Y, -Z) +} + +/** + * Handle state for visual feedback + */ +export enum HandleState { + NORMAL = 'normal', + HOVER = 'hover', + ACTIVE = 'active', +} diff --git a/src/gizmos/ResizeGizmo/index.ts b/src/gizmos/ResizeGizmo/index.ts index 9d04d09..1423f1c 100644 --- a/src/gizmos/ResizeGizmo/index.ts +++ b/src/gizmos/ResizeGizmo/index.ts @@ -1,552 +1,13 @@ -import { - AbstractMesh, - Color3, - Material, - Mesh, - MeshBuilder, - Observable, - Observer, - StandardMaterial, - UtilityLayerRenderer, - Vector3, - WebXRInputSource, -} from '@babylonjs/core'; -import { DefaultScene } from '../../defaultScene'; - /** - * Event emitted during and after scaling operations - */ -export interface ResizeGizmoEvent { - mesh: AbstractMesh; -} - -/** - * Handle types for the resize gizmo - */ -enum HandleType { - FACE_POS_X = 'face_pos_x', - FACE_NEG_X = 'face_neg_x', - FACE_POS_Y = 'face_pos_y', - FACE_NEG_Y = 'face_neg_y', - FACE_POS_Z = 'face_pos_z', - FACE_NEG_Z = 'face_neg_z', - CORNER_PPP = 'corner_ppp', // (+X, +Y, +Z) - CORNER_PPN = 'corner_ppn', // (+X, +Y, -Z) - CORNER_PNP = 'corner_pnp', // (+X, -Y, +Z) - CORNER_PNN = 'corner_pnn', // (+X, -Y, -Z) - CORNER_NPP = 'corner_npp', // (-X, +Y, +Z) - CORNER_NPN = 'corner_npn', // (-X, +Y, -Z) - CORNER_NNP = 'corner_nnp', // (-X, -Y, +Z) - CORNER_NNN = 'corner_nnn', // (-X, -Y, -Z) -} - -/** - * Handle state for visual feedback - */ -enum HandleState { - NORMAL = 'normal', - HOVER = 'hover', - ACTIVE = 'active', -} - -/** - * Information about a handle - */ -interface HandleInfo { - mesh: Mesh; - type: HandleType; - state: HandleState; - material: StandardMaterial; - /** Local space offset from target center for positioning */ - localOffset: Vector3; -} - -/** - * ResizeGizmo - Simple gizmo for resizing meshes in WebXR + * ResizeGizmo Module * - * Features: + * A simple WebXR gizmo for resizing meshes with: * - 6 face handles for single-axis scaling * - 8 corner handles for uniform scaling - * - XR controller grip interaction + * - Manual ray casting for utility layer interaction * - Billboard scaling for constant screen-size handles - * - Renders in utility layer (separate from main scene) */ -export class ResizeGizmo { - private targetMesh: AbstractMesh; - private utilityLayer: UtilityLayerRenderer; - private handles: HandleInfo[] = []; - // Materials for different states - private normalMaterial: StandardMaterial; - private hoverMaterial: StandardMaterial; - private activeMaterial: StandardMaterial; - - // Interaction state - private activeHandle: HandleInfo | null = null; - private gripStartPosition: Vector3 | null = null; - private initialScale: Vector3 | null = null; - private activeController: WebXRInputSource | null = null; - - // Observables for events - public onScaleDrag: Observable; - public onScaleEnd: Observable; - - // Frame observers - private beforeRenderObserver: Observer | null = null; - - // Constants - private static readonly HANDLE_SIZE = 0.1; - private static readonly HANDLE_OFFSET = 0.05; - private static readonly BILLBOARD_SCALE_DISTANCE = 10; // Reference distance for billboard scaling - private static readonly SCALE_INCREMENT = 0.1; - private static readonly MIN_SCALE = 0.1; - - constructor(targetMesh: AbstractMesh) { - this.targetMesh = targetMesh; - this.onScaleDrag = new Observable(); - this.onScaleEnd = new Observable(); - - // Create utility layer for rendering handles - this.utilityLayer = new UtilityLayerRenderer(DefaultScene.Scene); - this.utilityLayer.utilityLayerScene.autoClearDepthAndStencil = false; - - // Create materials - this.createMaterials(); - - // Create handles - this.createHandles(); - - // Set up XR interaction - this.setupXRInteraction(); - - // Set up per-frame updates - this.setupFrameUpdates(); - } - - /** - * Create materials for handle states - */ - private createMaterials(): void { - // Normal state - Gray - this.normalMaterial = new StandardMaterial('resizeGizmo_normal', this.utilityLayer.utilityLayerScene); - this.normalMaterial.diffuseColor = new Color3(0.5, 0.5, 0.5); - this.normalMaterial.specularColor = new Color3(0.2, 0.2, 0.2); - - // Hover state - White - this.hoverMaterial = new StandardMaterial('resizeGizmo_hover', this.utilityLayer.utilityLayerScene); - this.hoverMaterial.diffuseColor = new Color3(1, 1, 1); - this.hoverMaterial.specularColor = new Color3(0.3, 0.3, 0.3); - this.hoverMaterial.emissiveColor = new Color3(0.2, 0.2, 0.2); - - // Active state - Blue - this.activeMaterial = new StandardMaterial('resizeGizmo_active', this.utilityLayer.utilityLayerScene); - this.activeMaterial.diffuseColor = new Color3(0.2, 0.5, 1); - this.activeMaterial.specularColor = new Color3(0.5, 0.7, 1); - this.activeMaterial.emissiveColor = new Color3(0.1, 0.3, 0.6); - } - - /** - * Create all handle meshes (6 face + 8 corner) - */ - private createHandles(): void { - // Face handles (single-axis scaling) - this.createFaceHandle(HandleType.FACE_POS_X, new Vector3(1, 0, 0)); - this.createFaceHandle(HandleType.FACE_NEG_X, new Vector3(-1, 0, 0)); - this.createFaceHandle(HandleType.FACE_POS_Y, new Vector3(0, 1, 0)); - this.createFaceHandle(HandleType.FACE_NEG_Y, new Vector3(0, -1, 0)); - this.createFaceHandle(HandleType.FACE_POS_Z, new Vector3(0, 0, 1)); - this.createFaceHandle(HandleType.FACE_NEG_Z, new Vector3(0, 0, -1)); - - // Corner handles (uniform scaling) - this.createCornerHandle(HandleType.CORNER_PPP, new Vector3(1, 1, 1)); - this.createCornerHandle(HandleType.CORNER_PPN, new Vector3(1, 1, -1)); - this.createCornerHandle(HandleType.CORNER_PNP, new Vector3(1, -1, 1)); - this.createCornerHandle(HandleType.CORNER_PNN, new Vector3(1, -1, -1)); - this.createCornerHandle(HandleType.CORNER_NPP, new Vector3(-1, 1, 1)); - this.createCornerHandle(HandleType.CORNER_NPN, new Vector3(-1, 1, -1)); - this.createCornerHandle(HandleType.CORNER_NNP, new Vector3(-1, -1, 1)); - this.createCornerHandle(HandleType.CORNER_NNN, new Vector3(-1, -1, -1)); - - // Initial positioning - this.updateHandlePositions(); - } - - /** - * Create a face handle at the specified local offset direction - */ - private createFaceHandle(type: HandleType, direction: Vector3): void { - const handle = MeshBuilder.CreateBox( - `resizeHandle_${type}`, - { size: ResizeGizmo.HANDLE_SIZE }, - this.utilityLayer.utilityLayerScene - ); - - handle.material = this.normalMaterial; - - this.handles.push({ - mesh: handle, - type, - state: HandleState.NORMAL, - material: this.normalMaterial, - localOffset: direction.clone(), - }); - } - - /** - * Create a corner handle at the specified local offset direction - */ - private createCornerHandle(type: HandleType, direction: Vector3): void { - const handle = MeshBuilder.CreateBox( - `resizeHandle_${type}`, - { size: ResizeGizmo.HANDLE_SIZE }, - this.utilityLayer.utilityLayerScene - ); - - handle.material = this.normalMaterial; - - this.handles.push({ - mesh: handle, - type, - state: HandleState.NORMAL, - material: this.normalMaterial, - localOffset: direction.clone().normalize(), - }); - } - - /** - * Update handle positions based on target mesh bounding box - */ - private updateHandlePositions(): void { - const boundingInfo = this.targetMesh.getBoundingInfo(); - const boundingBox = boundingInfo.boundingBox; - - // Get bounding box extents in local space - const extents = boundingBox.extendSize; - - // Get target mesh world matrix and position - const worldMatrix = this.targetMesh.getWorldMatrix(); - const targetPosition = this.targetMesh.getAbsolutePosition(); - const targetRotation = this.targetMesh.rotationQuaternion || this.targetMesh.rotation.toQuaternion(); - - for (const handleInfo of this.handles) { - // Calculate position based on handle type - let localPos: Vector3; - - if (handleInfo.type.startsWith('face_')) { - // Face handles: positioned at face centers - localPos = new Vector3( - handleInfo.localOffset.x * extents.x, - handleInfo.localOffset.y * extents.y, - handleInfo.localOffset.z * extents.z - ); - } else { - // Corner handles: positioned at corners - localPos = new Vector3( - handleInfo.localOffset.x * extents.x, - handleInfo.localOffset.y * extents.y, - handleInfo.localOffset.z * extents.z - ); - } - - // Add offset to move handle outside bounding box - const offsetDir = handleInfo.localOffset.clone().normalize(); - localPos.addInPlace(offsetDir.scale(ResizeGizmo.HANDLE_SIZE / 2 + ResizeGizmo.HANDLE_OFFSET)); - - // Transform to world space - const worldPos = Vector3.TransformCoordinates(localPos, worldMatrix); - handleInfo.mesh.position = worldPos; - - // Apply rotation to match target mesh orientation - handleInfo.mesh.rotationQuaternion = targetRotation.clone(); - - // Apply billboard scaling - this.applyBillboardScale(handleInfo.mesh); - } - } - - /** - * Apply billboard scaling to maintain constant screen size - */ - private applyBillboardScale(handleMesh: Mesh): void { - const camera = this.utilityLayer.utilityLayerScene.activeCamera; - if (!camera) return; - - const distance = Vector3.Distance(camera.position, handleMesh.position); - const scaleFactor = distance / ResizeGizmo.BILLBOARD_SCALE_DISTANCE; - - handleMesh.scaling = new Vector3(scaleFactor, scaleFactor, scaleFactor); - } - - /** - * Set up XR controller interaction - */ - private setupXRInteraction(): void { - const xr = DefaultScene.Scene.xr; - if (!xr) return; - - // Listen for controller added - xr.input.onControllerAddedObservable.add((controller) => { - const motionController = controller.motionController; - if (!motionController) return; - - // Listen for grip button - const gripComponent = motionController.getComponent('squeeze'); - if (gripComponent) { - gripComponent.onButtonStateChangedObservable.add((component) => { - if (component.pressed) { - this.onGripPressed(controller); - } else { - this.onGripReleased(controller); - } - }); - } - }); - } - - /** - * Set up per-frame updates - */ - private setupFrameUpdates(): void { - this.beforeRenderObserver = DefaultScene.Scene.onBeforeRenderObservable.add(() => { - this.updateFrame(); - }); - } - - /** - * Update each frame - */ - private updateFrame(): void { - // Update handle positions - this.updateHandlePositions(); - - // Check for hover states - this.updateHoverStates(); - - // Update active scaling - if (this.activeHandle && this.activeController) { - this.updateScaling(); - } - } - - /** - * Check which handle (if any) is being pointed at by XR controllers - */ - private updateHoverStates(): void { - const xr = DefaultScene.Scene.xr; - if (!xr || this.activeHandle) return; // Don't update hover during active scaling - - // Reset all handles to normal - for (const handleInfo of this.handles) { - if (handleInfo.state === HandleState.HOVER) { - this.setHandleState(handleInfo, HandleState.NORMAL); - } - } - - // Check each controller - for (const controllerId of xr.input.controllers.keys()) { - const pickedMesh = xr.pointerSelection.getMeshUnderPointer(controllerId); - if (!pickedMesh) continue; - - // Check if picked mesh is one of our handles - const handleInfo = this.handles.find(h => h.mesh === pickedMesh); - if (handleInfo) { - this.setHandleState(handleInfo, HandleState.HOVER); - } - } - } - - /** - * Handle grip button pressed - */ - private onGripPressed(controller: WebXRInputSource): void { - if (this.activeHandle) return; // Already gripping - - // Check if controller is pointing at a handle - const pickedMesh = DefaultScene.Scene.xr?.pointerSelection.getMeshUnderPointer(controller.uniqueId); - if (!pickedMesh) return; - - const handleInfo = this.handles.find(h => h.mesh === pickedMesh); - if (!handleInfo) return; - - // Start gripping - this.activeHandle = handleInfo; - this.activeController = controller; - this.gripStartPosition = controller.pointer.position.clone(); - this.initialScale = this.targetMesh.scaling.clone(); - - this.setHandleState(handleInfo, HandleState.ACTIVE); - - // Haptic feedback - controller.motionController?.pulse(0.5, 100); - } - - /** - * Handle grip button released - */ - private onGripReleased(controller: WebXRInputSource): void { - if (!this.activeHandle || this.activeController !== controller) return; - - // End gripping - this.setHandleState(this.activeHandle, HandleState.NORMAL); - this.activeHandle = null; - this.activeController = null; - this.gripStartPosition = null; - this.initialScale = null; - - // Fire onScaleEnd event - this.onScaleEnd.notifyObservers({ mesh: this.targetMesh }); - - // Haptic feedback - controller.motionController?.pulse(0.3, 50); - } - - /** - * Update scaling during active grip - */ - private updateScaling(): void { - if (!this.activeHandle || !this.activeController || !this.gripStartPosition || !this.initialScale) { - return; - } - - const currentPosition = this.activeController.pointer.position; - const movement = currentPosition.subtract(this.gripStartPosition); - - // Determine scaling based on handle type - if (this.activeHandle.type.startsWith('face_')) { - this.applySingleAxisScaling(movement); - } else { - this.applyUniformScaling(movement); - } - - // Fire onScaleDrag event - this.onScaleDrag.notifyObservers({ mesh: this.targetMesh }); - } - - /** - * Apply single-axis scaling from a face handle - * Scales from opposite face (fixed pivot) - */ - private applySingleAxisScaling(movement: Vector3): void { - if (!this.activeHandle || !this.initialScale) return; - - // Determine which axis to scale - const offset = this.activeHandle.localOffset; - let axis: 'x' | 'y' | 'z'; - let direction: number; - - if (Math.abs(offset.x) > 0.5) { - axis = 'x'; - direction = Math.sign(offset.x); - } else if (Math.abs(offset.y) > 0.5) { - axis = 'y'; - direction = Math.sign(offset.y); - } else { - axis = 'z'; - direction = Math.sign(offset.z); - } - - // Calculate movement along the axis in world space - const worldAxis = this.activeHandle.localOffset.clone().normalize(); - const movementAlongAxis = Vector3.Dot(movement, worldAxis); - - // Convert movement to scale delta (in increments of 0.1) - const scaleDelta = Math.round(movementAlongAxis / ResizeGizmo.SCALE_INCREMENT) * ResizeGizmo.SCALE_INCREMENT; - - // Apply scale - const newScale = this.initialScale.clone(); - newScale[axis] = Math.max(ResizeGizmo.MIN_SCALE, this.initialScale[axis] + scaleDelta * direction); - - // Calculate position adjustment to keep opposite face fixed - const boundingInfo = this.targetMesh.getBoundingInfo(); - const extents = boundingInfo.boundingBox.extendSize; - const scaleRatio = newScale[axis] / this.initialScale[axis]; - - // Calculate offset in local space - const localOffset = new Vector3(0, 0, 0); - localOffset[axis] = extents[axis] * (scaleRatio - 1) * direction; - - // Transform to world space and adjust position - const worldMatrix = this.targetMesh.getWorldMatrix(); - const rotation = this.targetMesh.rotationQuaternion || this.targetMesh.rotation.toQuaternion(); - const worldOffset = localOffset.applyRotationQuaternion(rotation); - - this.targetMesh.scaling = newScale; - this.targetMesh.position.addInPlace(worldOffset); - } - - /** - * Apply uniform scaling from a corner handle - * Scales from center - */ - private applyUniformScaling(movement: Vector3): void { - if (!this.activeHandle || !this.initialScale) return; - - // Calculate movement along the diagonal direction - const diagonal = this.activeHandle.localOffset.clone().normalize(); - const movementAlongDiagonal = Vector3.Dot(movement, diagonal); - - // Convert movement to scale delta - const scaleDelta = Math.round(movementAlongDiagonal / ResizeGizmo.SCALE_INCREMENT) * ResizeGizmo.SCALE_INCREMENT; - - // Apply uniform scale - const scaleMultiplier = Math.max(ResizeGizmo.MIN_SCALE, 1 + scaleDelta); - const newScale = this.initialScale.clone().scale(scaleMultiplier); - - // Clamp to minimum - newScale.x = Math.max(ResizeGizmo.MIN_SCALE, newScale.x); - newScale.y = Math.max(ResizeGizmo.MIN_SCALE, newScale.y); - newScale.z = Math.max(ResizeGizmo.MIN_SCALE, newScale.z); - - this.targetMesh.scaling = newScale; - } - - /** - * Set handle state and update visual appearance - */ - private setHandleState(handleInfo: HandleInfo, state: HandleState): void { - handleInfo.state = state; - - switch (state) { - case HandleState.NORMAL: - handleInfo.mesh.material = this.normalMaterial; - handleInfo.mesh.scaling = handleInfo.mesh.scaling.scale(1 / 1.2); // Reset scale - break; - case HandleState.HOVER: - handleInfo.mesh.material = this.hoverMaterial; - handleInfo.mesh.scaling = handleInfo.mesh.scaling.scale(1.2); // Slightly larger - break; - case HandleState.ACTIVE: - handleInfo.mesh.material = this.activeMaterial; - break; - } - } - - /** - * Dispose of the gizmo and clean up resources - */ - public dispose(): void { - // Remove observers - if (this.beforeRenderObserver) { - DefaultScene.Scene.onBeforeRenderObservable.remove(this.beforeRenderObserver); - this.beforeRenderObserver = null; - } - - // Dispose handles - for (const handleInfo of this.handles) { - handleInfo.mesh.dispose(); - } - this.handles = []; - - // Dispose materials - this.normalMaterial.dispose(); - this.hoverMaterial.dispose(); - this.activeMaterial.dispose(); - - // Dispose utility layer - this.utilityLayer.dispose(); - - // Clear observables - this.onScaleDrag.clear(); - this.onScaleEnd.clear(); - } -} +export { ResizeGizmo } from './ResizeGizmo'; +export type { ResizeGizmoEvent, HandleInfo } from './types'; +export { HandleType, HandleState } from './enums'; diff --git a/src/gizmos/ResizeGizmo/types.ts b/src/gizmos/ResizeGizmo/types.ts new file mode 100644 index 0000000..e21180c --- /dev/null +++ b/src/gizmos/ResizeGizmo/types.ts @@ -0,0 +1,21 @@ +import { AbstractMesh, Mesh, StandardMaterial, Vector3 } from '@babylonjs/core'; +import { HandleType, HandleState } from './enums'; + +/** + * Event emitted during and after scaling operations + */ +export interface ResizeGizmoEvent { + mesh: AbstractMesh; +} + +/** + * Information about a handle + */ +export interface HandleInfo { + mesh: Mesh; + type: HandleType; + state: HandleState; + material: StandardMaterial; + /** Local space offset from target center for positioning */ + localOffset: Vector3; +}