diff --git a/src/gizmos/ResizeGizmo/ResizeGizmo.ts b/src/gizmos/ResizeGizmo/ResizeGizmo.ts index 6bd21fd..6da2ebb 100644 --- a/src/gizmos/ResizeGizmo/ResizeGizmo.ts +++ b/src/gizmos/ResizeGizmo/ResizeGizmo.ts @@ -1,665 +1,439 @@ import { AbstractMesh, - Color3, - Mesh, MeshBuilder, Observable, Observer, - Ray, Scene, + PickingInfo, + Ray, + Scene, StandardMaterial, + Color3, UtilityLayerRenderer, Vector3, WebXRDefaultExperience, - WebXRInputSource, + WebXRInputSource, Color4, } from '@babylonjs/core'; import log from 'loglevel'; -import { HandleType, HandleState } from './enums'; -import { ResizeGizmoEvent, HandleInfo } from './types'; +import { HandleState, CORNER_POSITIONS} from './enums'; +import { ResizeGizmoEvent } from './types'; /** - * ResizeGizmo - Simple gizmo for resizing meshes in WebXR + * ResizeGizmo - Step 1: Corner Handles Only * - * 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) + * Creates 8 cube handles (0.1 size) positioned at bounding box corners */ export class ResizeGizmo { + private _scene: Scene; + private _utilityLayer: UtilityLayerRenderer; private _xr: WebXRDefaultExperience; - private targetMesh: AbstractMesh; - private utilityLayer: UtilityLayerRenderer; - private handles: HandleInfo[] = []; - private logger = log.getLogger('ResizeGizmo'); + private _targetMesh: AbstractMesh; + private _logger = log.getLogger('ResizeGizmo'); - // Materials for different states - private normalMaterial: StandardMaterial; - private hoverMaterial: StandardMaterial; - private activeMaterial: StandardMaterial; + // Handle data + private _handles: AbstractMesh[] = []; + private _handleMaterial: StandardMaterial; + private _hoveredHandle: AbstractMesh | null = null; + private _hoveringController: WebXRInputSource | null = null; - // Interaction state - private activeHandle: HandleInfo | null = null; - private activeController: WebXRInputSource | null = null; + // Scaling state + private _isScaling: boolean = false; + private _activeController: WebXRInputSource | null = null; + private _activeHandle: AbstractMesh | null = null; + private _originalStickLength: number = 0; + private _originalHandleDistance: number = 0; + private _initialScale: Vector3 | 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 + // Frame update observer + private _frameObserver: Observer | null = null; // Observables for events public onScaleDrag: Observable; public onScaleEnd: Observable; - // Frame observers - private beforeRenderObserver: Observer | null = null; + // Static reference to utility layer + public static utilityLayer: UtilityLayerRenderer; // 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; + private static readonly HANDLE_SIZE = 0.05; constructor(targetMesh: AbstractMesh, xr: WebXRDefaultExperience) { this._scene = targetMesh.getScene(); this._xr = xr; - this.targetMesh = targetMesh; + this._targetMesh = targetMesh; + + // Initialize observables this.onScaleDrag = new Observable(); this.onScaleEnd = new Observable(); - this.logger.info(`Creating ResizeGizmo for mesh: ${targetMesh.name} (${targetMesh.id})`); + 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; + this._utilityLayer = new UtilityLayerRenderer(this._scene); + this._utilityLayer.utilityLayerScene.autoClearDepthAndStencil = false; + ResizeGizmo.utilityLayer = this._utilityLayer; - // Create materials - this.createMaterials(); + // Create material + this.createMaterial(); - // Create handles + // Create 8 corner handles this.createHandles(); - this.logger.debug(`ResizeGizmo initialized with ${this.handles.length} handles (6 face + 8 corner)`); - - // Set up XR interaction - this.setupXRInteraction(); + this._logger.debug(`ResizeGizmo initialized with ${this._handles.length} corner handles`); // Set up per-frame updates this.setupFrameUpdates(); + + // Set up XR input handlers + this.setupXRInputHandlers(); } /** - * Create materials for handle states + * Create simple material for handles */ - 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); + private createMaterial(): void { + this._handleMaterial = new StandardMaterial('resizeGizmoMaterial', this._utilityLayer.utilityLayerScene); + this._handleMaterial.diffuseColor = Color3.Yellow(); + this._handleMaterial.emissiveColor = Color3.Yellow().scale(0.3); } /** - * Create all handle meshes (6 face + 8 corner) + * Create 8 corner handles as 0.1 size cubes */ 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)); + // Get bounding box for positioning - // 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)); + const targetBoundingInfo = this._targetMesh.getBoundingInfo(); + const boundingBox = targetBoundingInfo.boundingBox; - // Initial positioning - this.updateHandlePositions(); - } + const innerCorners = boundingBox.vectorsWorld; - /** - * 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; + CORNER_POSITIONS.forEach((cornerDef, index) => { - this.handles.push({ - mesh: handle, - type, - state: HandleState.NORMAL, - material: this.normalMaterial, - localOffset: direction.clone(), + const cornerPos = innerCorners[index]; + const size = cornerPos.subtract(boundingBox.centerWorld).length() * .2; + + + const handleMesh = MeshBuilder.CreateBox( + `resizeHandle_${cornerDef.name}`, + { size: size }, + 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); + handleMesh.position = cornerPos.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} corner handles`); } - /** - * 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(); + this._frameObserver = this._scene.onBeforeRenderObservable.add(() => { + // Check for handle picking with XR controllers + this.checkXRControllerPicking(); + + // Update scaling if active + if (this._isScaling) { + this.updateScaling(); + } }); } /** - * Update each frame + * Check if XR controllers are pointing at any handles */ - 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; - } + private checkXRControllerPicking(): void { + if (!this._xr || !this._xr.input) return; - // Update handle positions (only when not actively scaling) - this.updateHandlePositions(); + let newHoveredHandle: AbstractMesh | null = null; + let newController: WebXRInputSource | null = null; + // Check each controller + for (const controller of this._xr.input.controllers) { + const ray = this.getControllerRay(controller); + if (!ray) continue; - // 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; + const pickInfo = this.pickHandle(ray); + if (pickInfo && pickInfo.pickedMesh) { + newHoveredHandle = pickInfo.pickedMesh as AbstractMesh; + newController = controller; + break; // Use first hit } } - 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); + // Update hover state if changed + if (newHoveredHandle !== this._hoveredHandle) { + // Clear previous hover + if (this._hoveredHandle) { + this._hoveredHandle.disableEdgesRendering(); } - } - // 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); + // Set new hover + this._hoveredHandle = newHoveredHandle; + this._hoveringController = newController; + if (this._hoveredHandle && newController) { + this._hoveredHandle.enableEdgesRendering(); + this._hoveredHandle.edgesWidth = .1; + this._hoveredHandle.edgesColor = Color4.FromColor3(Color3.White()); + + // Pulse only on first hover + newController.motionController.pulse(.2, 100); } } } /** - * Handle grip button pressed + * Set up XR input handlers for grab button */ - private onGripPressed(controller: WebXRInputSource): void { - this.logger.debug('GripPressed'); - if (this.activeHandle) return; // Already gripping + private setupXRInputHandlers(): void { + if (!this._xr || !this._xr.input) return; - // 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; + // Hook up existing controllers + for (const controller of this._xr.input.controllers) { + this.setupControllerInput(controller); } - // 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); + // Listen for new controllers + this._xr.input.onControllerAddedObservable.add((controller) => { + this.setupControllerInput(controller); + }); } /** - * Handle grip button released + * Set up input listeners for a controller */ - 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); + private setupControllerInput(controller: WebXRInputSource): void { + // If motion controller is already initialized, set up immediately + if (controller.motionController) { + this.setupSqueezeButton(controller); + } else { + // Otherwise wait for initialization + controller.onMotionControllerInitObservable.add(() => { + this.setupSqueezeButton(controller); + }); + } } /** - * Update scaling during active grip using Virtual Stick approach + * Set up squeeze button listener for a controller + */ + private setupSqueezeButton(controller: WebXRInputSource): void { + const squeezeComponent = controller.motionController?.getComponentOfType('squeeze'); + if (squeezeComponent) { + squeezeComponent.onButtonStateChangedObservable.add((component) => { + if (component.pressed) { + this.onHandleGrabbed(controller); + } else { + this.onHandleReleased(controller); + } + }); + } + } + + /** + * Called when a controller presses grab while hovering a handle + */ + private onHandleGrabbed(controller: WebXRInputSource): void { + // Only respond if this controller is hovering a handle + if (controller === this._hoveringController && this._hoveredHandle) { + this._logger.info(`Handle grabbed: ${this._hoveredHandle.name} by controller ${controller.uniqueId}`); + + // Pulse feedback + controller.motionController.pulse(0.5, 50); + + // Get controller world position + const pointerWorldMatrix = controller.pointer.getWorldMatrix(); + const controllerWorldPos = Vector3.TransformCoordinates(Vector3.Zero(), pointerWorldMatrix); + + // Get handle center world position + const handleWorldPos = this._hoveredHandle.position; + + // Store original stick length (distance from controller to handle) + this._originalStickLength = Vector3.Distance(controllerWorldPos, handleWorldPos); + + // Get bounding box center world position + const boundingInfo = this._targetMesh.getBoundingInfo(); + const bboxCenterWorld = boundingInfo.boundingBox.centerWorld; + + // Store original handle distance (distance from bbox center to handle) + this._originalHandleDistance = Vector3.Distance(bboxCenterWorld, handleWorldPos); + + // Store initial scale + this._initialScale = this._targetMesh.scaling.clone(); + + // Set scaling state + this._isScaling = true; + this._activeController = controller; + this._activeHandle = this._hoveredHandle; + + // 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}`); + } + } + + /** + * Called when a controller releases grab button + */ + private onHandleReleased(controller: WebXRInputSource): void { + // Only respond if this controller is the active scaling controller + if (controller === this._activeController && this._isScaling && this._initialScale) { + this._logger.info(`Handle released by controller ${controller.uniqueId}`); + + // 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 + ); + + // Apply snapped scale + this._targetMesh.scaling = roundedScale; + + // Change outline back to white + if (this._activeHandle) { + this._activeHandle.edgesColor = Color4.FromColor3(Color3.White()); + } + + // Notify observers + this.onScaleEnd.notifyObservers({ mesh: this._targetMesh }); + + // Clear scaling state + this._isScaling = false; + this._activeController = null; + this._activeHandle = null; + this._initialScale = null; + + // Pulse feedback + controller.motionController.pulse(0.3, 30); + + this._logger.debug(`Scaling ended: final scale=${roundedScale}`); + } + } + + /** + * Update scaling during active grab + * Called every frame while _isScaling is true */ private updateScaling(): void { - if (!this.activeHandle || !this.activeController || !this.initialLocalOffset || !this.initialScale) { - return; - } + if (!this._activeController || !this._initialScale) return; - // 1. Calculate new "end of stick" position in world space - const pointerWorldMatrix = this.activeController.pointer.getWorldMatrix(); + // Get controller world position and forward direction + const pointerWorldMatrix = this._activeController.pointer.getWorldMatrix(); const controllerWorldPos = Vector3.TransformCoordinates(Vector3.Zero(), pointerWorldMatrix); - const controllerWorldForward = Vector3.TransformNormal(Vector3.Forward(), pointerWorldMatrix); + const controllerForward = Vector3.TransformNormal(Vector3.Forward(), pointerWorldMatrix); - // Extend forward by original stick length (fixed length) - const newStickEndWorld = controllerWorldPos.add(controllerWorldForward.scale(this.originalStickLength)); + // Calculate virtual stick end position (fixed length from controller) + const virtualStickEnd = controllerWorldPos.add(controllerForward.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); + // Get bounding box center world position + const boundingInfo = this._targetMesh.getBoundingInfo(); + const bboxCenterWorld = boundingInfo.boundingBox.centerWorld; - // 3. Calculate new distance in local space - const newLocalDistance = newStickEndLocal.length(); + // Calculate new distance from bbox center to virtual stick end + const newDistance = Vector3.Distance(bboxCenterWorld, virtualStickEnd); - // 4. Calculate scale ratio (no rounding during drag for smooth scaling) - const scaleRatio = newLocalDistance / this.initialLocalDistance; + // Calculate scale ratio + const scaleRatio = newDistance / this._originalHandleDistance; - // 5. Apply scaling based on handle type - if (this.activeHandle.type.startsWith('face_')) { - this.applySingleAxisScaling(scaleRatio, newStickEndLocal); - } else { - this.applyUniformScaling(scaleRatio); - } + // Apply uniform scaling (smooth, no snapping yet) + this._targetMesh.scaling = this._initialScale.scale(scaleRatio); - // Fire onScaleDrag event - this.onScaleDrag.notifyObservers({ mesh: this.targetMesh }); + // Notify observers + this.onScaleDrag.notifyObservers({ mesh: this._targetMesh }); } /** - * Apply single-axis scaling from a face handle - * Scales only the appropriate axis based on scale ratio + * Get a ray from an XR controller's pointer + * @param controller - XR input source + * @returns Ray in world space, or null if controller has no pointer */ - private applySingleAxisScaling(scaleRatio: number, newStickEndLocal: Vector3): void { - if (!this.activeHandle || !this.initialScale || !this.initialLocalOffset) return; + private getControllerRay(controller: WebXRInputSource): Ray | null { + if (!controller.pointer) return null; - // Determine which axis to scale based on initial local offset - const offset = this.initialLocalOffset; - let axis: 'x' | 'y' | 'z'; + const pointerWorldMatrix = controller.pointer.getWorldMatrix(); + const origin = Vector3.TransformCoordinates(Vector3.Zero(), pointerWorldMatrix); + const forward = Vector3.TransformNormal(Vector3.Forward(), pointerWorldMatrix); - 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; + return new Ray(origin, forward, 50); } /** - * Apply uniform scaling from a corner handle - * Scales all axes uniformly based on scale ratio + * Get target mesh */ - 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; + public get targetMesh(): AbstractMesh { + return this._targetMesh; } /** - * Apply rounded scale on grip release - * Face handles: round only the scaled axis - * Corner handles: round uniformly on all axes + * Test if a ray intersects any handle in the utility layer + * @param ray - Ray to test (in world space) + * @returns PickingInfo with the closest handle hit, or null if no hit */ - private applyRoundedScale(): void { - if (!this.activeHandle || !this.initialScale) return; + public pickHandle(ray: Ray): PickingInfo | null { + const pickResult = this._utilityLayer.utilityLayerScene.pickWithRay(ray); - 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)}`); + if (pickResult && pickResult.hit && this._handles.includes(pickResult.pickedMesh as AbstractMesh)) { + return pickResult; } - 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; - } + return null; } /** * Dispose of the gizmo and clean up resources */ public dispose(): void { - this.logger.info(`Disposing ResizeGizmo for mesh: ${this.targetMesh.name} (${this.targetMesh.id})`); + this._logger.debug('Disposing ResizeGizmo'); - // Remove observers - if (this.beforeRenderObserver) { - this._scene.onBeforeRenderObservable.remove(this.beforeRenderObserver); - this.beforeRenderObserver = null; + // Remove frame observer + if (this._frameObserver) { + this._scene.onBeforeRenderObservable.remove(this._frameObserver); + this._frameObserver = null; + } + + // Clear hover state + if (this._hoveredHandle) { + this._hoveredHandle.disableEdgesRendering(); + this._hoveredHandle = null; } // Dispose handles - for (const handleInfo of this.handles) { - handleInfo.mesh.dispose(); + for (const mesh of this._handles) { + mesh.dispose(); } - this.handles = []; + this._handles = []; - // Dispose materials - this.normalMaterial.dispose(); - this.hoverMaterial.dispose(); - this.activeMaterial.dispose(); + // Dispose material + if (this._handleMaterial) { + this._handleMaterial.dispose(); + } // Dispose utility layer - this.utilityLayer.dispose(); + if (this._utilityLayer) { + this._utilityLayer.dispose(); + } // Clear observables this.onScaleDrag.clear(); this.onScaleEnd.clear(); - this._xr = null; - this._scene = null; + this._logger.info('ResizeGizmo disposed'); } } diff --git a/src/gizmos/ResizeGizmo/enums.ts b/src/gizmos/ResizeGizmo/enums.ts index 64b6bc8..cb76a6c 100644 --- a/src/gizmos/ResizeGizmo/enums.ts +++ b/src/gizmos/ResizeGizmo/enums.ts @@ -17,7 +17,6 @@ export enum HandleType { CORNER_NNP = 'corner_nnp', // (-X, -Y, +Z) CORNER_NNN = 'corner_nnn', // (-X, -Y, -Z) } - /** * Handle state for visual feedback */ @@ -25,4 +24,62 @@ export enum HandleState { NORMAL = 'normal', HOVER = 'hover', ACTIVE = 'active', + IDLE = 'idle' } + +/** + * Handle position definition with name and normalized coordinates + */ +export interface HandlePositionDef { + name: string; + position: { x: number; y: number; z: number }; + description: string; +} + +/** + * Corner handle positions as static constants + * Index corresponds to BabylonJS boundingBox.vectorsWorld array + * Normalized coordinates are -1 or +1 on each axis (unit cube corners) + */ +export const CORNER_POSITIONS: readonly HandlePositionDef[] = [ + { + name: 'CORNER_NNN', + position: { x: -1, y: -1, z: -1 }, + description: 'Bottom-back-left (-X, -Y, -Z)' + }, + { + name: 'CORNER_PNN', + position: { x: +1, y: -1, z: -1 }, + description: 'Bottom-back-right (+X, -Y, -Z)' + }, + { + name: 'CORNER_PNP', + position: { x: +1, y: -1, z: +1 }, + description: 'Bottom-front-right (+X, -Y, +Z)' + }, + { + name: 'CORNER_NNP', + position: { x: -1, y: -1, z: +1 }, + description: 'Bottom-front-left (-X, -Y, +Z)' + }, + { + name: 'CORNER_NPN', + position: { x: -1, y: +1, z: -1 }, + description: 'Top-back-left (-X, +Y, -Z)' + }, + { + name: 'CORNER_PPN', + position: { x: +1, y: +1, z: -1 }, + description: 'Top-back-right (+X, +Y, -Z)' + }, + { + name: 'CORNER_PPP', + position: { x: +1, y: +1, z: +1 }, + description: 'Top-front-right (+X, +Y, +Z)' + }, + { + name: 'CORNER_NPP', + position: { x: -1, y: +1, z: +1 }, + description: 'Top-front-left (-X, +Y, +Z)' + }, +] as const; diff --git a/src/gizmos/ResizeGizmo/index.ts b/src/gizmos/ResizeGizmo/index.ts index 1423f1c..a908423 100644 --- a/src/gizmos/ResizeGizmo/index.ts +++ b/src/gizmos/ResizeGizmo/index.ts @@ -2,12 +2,12 @@ * ResizeGizmo Module * * A simple WebXR gizmo for resizing meshes with: - * - 6 face handles for single-axis scaling * - 8 corner handles for uniform scaling * - Manual ray casting for utility layer interaction - * - Billboard scaling for constant screen-size handles + * - Normalized position vectors for handle placement */ export { ResizeGizmo } from './ResizeGizmo'; export type { ResizeGizmoEvent, HandleInfo } from './types'; -export { HandleType, HandleState } from './enums'; +export type { HandlePositionDef } from './enums'; +export { HandleType, HandleState, CORNER_POSITIONS } from './enums'; diff --git a/src/gizmos/ResizeGizmo/types.ts b/src/gizmos/ResizeGizmo/types.ts index e21180c..cabbb59 100644 --- a/src/gizmos/ResizeGizmo/types.ts +++ b/src/gizmos/ResizeGizmo/types.ts @@ -1,21 +1,6 @@ -import { AbstractMesh, Mesh, StandardMaterial, Vector3 } from '@babylonjs/core'; -import { HandleType, HandleState } from './enums'; +import { AbstractMesh } from '@babylonjs/core'; + -/** - * 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; -}