From c815db45941706d1756c501b05a350110233cf35 Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Sat, 15 Nov 2025 13:55:18 -0600 Subject: [PATCH] Improve ResizeGizmo hover state and handle interaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 & 2: Handle positioning and wireframe improvements - Move handles 5% outward from bounding box (was inward) - Rename boundingBoxPadding → handleOffset for clarity - Add wireframePadding (3% breathing room around mesh) Hover boundary detection (prevent loss in whitespace): - Add isPointerInsideHandleBoundary() with ray-AABB intersection - Use local space transformation for accurate OBB handling - Keep HOVER_MESH state when pointer in handle boundary - Fix: Trust ResizeGizmo state instead of recreating with fake rays Prevent main scene mesh grab during handle interaction: - Add ResizeGizmo state check in pointer observable - Add defense-in-depth guard in grab() method - Prevents controller from grabbing diagram mesh when hovering handle - Two-level protection against race conditions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- package.json | 2 +- src/controllers/abstractController.ts | 16 ++++ src/diagram/diagramMenuManager.ts | 40 +++----- src/gizmos/ResizeGizmo/HandleGeometry.ts | 12 +-- src/gizmos/ResizeGizmo/ResizeGizmoConfig.ts | 14 ++- .../ResizeGizmo/ResizeGizmoInteraction.ts | 45 ++++++++- src/gizmos/ResizeGizmo/ResizeGizmoManager.ts | 18 +++- src/gizmos/ResizeGizmo/ResizeGizmoVisuals.ts | 93 ++++++++++++++++++- src/gizmos/ResizeGizmo/types.ts | 14 ++- 9 files changed, 206 insertions(+), 48 deletions(-) diff --git a/package.json b/package.json index 74951e8..fadc72c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "immersive", "private": true, - "version": "0.0.8-23", + "version": "0.0.8-24", "type": "module", "license": "MIT", "engines": { diff --git a/src/controllers/abstractController.ts b/src/controllers/abstractController.ts index 2c4ce6d..14c7434 100644 --- a/src/controllers/abstractController.ts +++ b/src/controllers/abstractController.ts @@ -73,6 +73,13 @@ export abstract class AbstractController { this._meshUnderPointer = null; return; } + + // Don't set _meshUnderPointer if ResizeGizmo is active + // Prevents main scene mesh grab from conflicting with handle interaction + if (resizeGizmo.isHoveringHandle() || resizeGizmo.isScaling()) { + this._meshUnderPointer = null; + return; + } } this._pickPoint.copyFrom(pointerInfo.pickInfo.pickedPoint); @@ -289,6 +296,15 @@ export abstract class AbstractController { if (viewOnly() || this._meshUnderPointer == null) { return; } + + // Defense in depth: Verify ResizeGizmo isn't active + // Prevents race conditions where grip press happens during state transitions + const resizeGizmo = this.diagramManager?.diagramMenuManager?.resizeGizmo; + if (resizeGizmo && (resizeGizmo.isHoveringHandle() || resizeGizmo.isScaling())) { + this._logger.debug("ResizeGizmo is active, aborting grab"); + return; + } + const { grabbedMesh, grabbedObject, diff --git a/src/diagram/diagramMenuManager.ts b/src/diagram/diagramMenuManager.ts index 40aaffe..ec1aa9b 100644 --- a/src/diagram/diagramMenuManager.ts +++ b/src/diagram/diagramMenuManager.ts @@ -1,5 +1,5 @@ import {DiagramEntityType, DiagramEvent, DiagramEventType} from "./types/diagramEntity"; -import {AbstractMesh, ActionEvent, Observable, Scene, Vector3, WebXRDefaultExperience, WebXRInputSource} from "@babylonjs/core"; +import {AbstractMesh, ActionEvent, Observable, Ray, Scene, Vector3, WebXRDefaultExperience, WebXRInputSource} from "@babylonjs/core"; import {InputTextView} from "../information/inputTextView"; import {DefaultScene} from "../defaultScene"; import log from "loglevel"; @@ -232,39 +232,25 @@ export class DiagramMenuManager { } /** - * Check if gizmo should remain active based on pointer position + * Check if gizmo should remain active + * Trusts ResizeGizmo's internal state management rather than recalculating */ private shouldKeepGizmoActive(pointerPosition?: Vector3): boolean { if (!this._currentHoveredMesh) { return false; } - // Always keep gizmo active if currently scaling - if (this.resizeGizmo.isScaling()) { - return true; - } + // Trust ResizeGizmo's internal state management + // ResizeGizmo already tracks hover state correctly with proper controller rays + const state = this.resizeGizmo.getInteractionState(); - // Keep active if pointer is within bounding box area - if (!pointerPosition) { - return false; - } - - // Get the attached mesh's bounding box - const boundingInfo = this._currentHoveredMesh.getBoundingInfo(); - const boundingBox = boundingInfo.boundingBox; - - // Add padding to the bounding box (same as gizmo padding + handle size) - const padding = 0.3; // Generous padding to include handles - const min = boundingBox.minimumWorld.subtract(new Vector3(padding, padding, padding)); - const max = boundingBox.maximumWorld.add(new Vector3(padding, padding, padding)); - - // Check if pointer is within the padded bounding box - const withinBounds = - pointerPosition.x >= min.x && pointerPosition.x <= max.x && - pointerPosition.y >= min.y && pointerPosition.y <= max.y && - pointerPosition.z >= min.z && pointerPosition.z <= max.z; - - return withinBounds; + // Keep active if ResizeGizmo is in any active state: + // - ACTIVE_SCALING: User is actively scaling (grip held) + // - HOVER_HANDLE: Pointer is hovering a handle (ready to scale) + // - HOVER_MESH: Pointer is within handle boundary (grace zone) + return state === 'ACTIVE_SCALING' || + state === 'HOVER_HANDLE' || + state === 'HOVER_MESH'; } /** diff --git a/src/gizmos/ResizeGizmo/HandleGeometry.ts b/src/gizmos/ResizeGizmo/HandleGeometry.ts index 5d7e89b..e38c28c 100644 --- a/src/gizmos/ResizeGizmo/HandleGeometry.ts +++ b/src/gizmos/ResizeGizmo/HandleGeometry.ts @@ -75,8 +75,8 @@ export class HandleGeometry { // Calculate normal from center to corner const normal = cornerPos.subtract(center).normalize(); - // Apply padding by moving corner inward along the normal - const position = cornerPos.subtract(normal.scale(paddingDistance)); + // Apply padding by moving corner outward along the normal + const position = cornerPos.add(normal.scale(paddingDistance)); corners.push({ position, @@ -139,8 +139,8 @@ export class HandleGeometry { // Calculate normal from center to midpoint const normal = midpoint.subtract(center).normalize(); - // Apply padding by moving inward along the normal - const position = midpoint.subtract(normal.scale(paddingDistance)); + // Apply padding by moving outward along the normal + const position = midpoint.add(normal.scale(paddingDistance)); edges.push({ position, @@ -195,8 +195,8 @@ export class HandleGeometry { // Calculate normal from center to face center const normal = faceCenter.subtract(center).normalize(); - // Apply padding by moving inward along the normal - const position = faceCenter.subtract(normal.scale(paddingDistance)); + // Apply padding by moving outward along the normal + const position = faceCenter.add(normal.scale(paddingDistance)); faces.push({ position, diff --git a/src/gizmos/ResizeGizmo/ResizeGizmoConfig.ts b/src/gizmos/ResizeGizmo/ResizeGizmoConfig.ts index 008e4f6..8291b72 100644 --- a/src/gizmos/ResizeGizmo/ResizeGizmoConfig.ts +++ b/src/gizmos/ResizeGizmo/ResizeGizmoConfig.ts @@ -49,10 +49,16 @@ export class ResizeGizmoConfigManager { c.handleSize = DEFAULT_RESIZE_GIZMO_CONFIG.handleSize; } - // Validate bounding box padding - if (c.boundingBoxPadding < 0) { - console.warn(`[ResizeGizmo] Invalid boundingBoxPadding (${c.boundingBoxPadding}), using 0`); - c.boundingBoxPadding = 0; + // Validate handle offset + if (c.handleOffset < 0) { + console.warn(`[ResizeGizmo] Invalid handleOffset (${c.handleOffset}), using 0`); + c.handleOffset = 0; + } + + // Validate wireframe padding + if (c.wireframePadding < 0) { + console.warn(`[ResizeGizmo] Invalid wireframePadding (${c.wireframePadding}), using 0`); + c.wireframePadding = 0; } // Validate wireframe alpha diff --git a/src/gizmos/ResizeGizmo/ResizeGizmoInteraction.ts b/src/gizmos/ResizeGizmo/ResizeGizmoInteraction.ts index ca8742b..374c89a 100644 --- a/src/gizmos/ResizeGizmo/ResizeGizmoInteraction.ts +++ b/src/gizmos/ResizeGizmo/ResizeGizmoInteraction.ts @@ -220,6 +220,30 @@ export class ResizeGizmoInteraction { return undefined; } + /** + * Check if any XR controller pointer is inside the expanded handle boundary + * Used to prevent hover state loss when pointer crosses whitespace between mesh and handles + */ + private isPointerInsideHandleBoundary(): boolean { + // Iterate through registered XR controllers + for (const controller of this._xrControllers.values()) { + if (!controller.pointer) { + continue; + } + + // Get controller ray in world space + const ray = new Ray(Vector3.Zero(), Vector3.Forward(), 1000); + controller.getWorldPointerRayToRef(ray); + + // Check if this ray intersects the handle boundary + if (this._visuals.isPointerInsideHandleBoundary(ray)) { + return true; + } + } + + return false; + } + /** * Handle mesh hover */ @@ -373,7 +397,19 @@ export class ResizeGizmoInteraction { this.onHandleHovered(handlePickResult); } else if (this._state.hoveredHandle) { // Was hovering a handle, but not anymore - this.onHoverExit(); + // Check if still inside handle boundary before exiting hover (prevents loss in whitespace) + const stillInsideBoundary = this.isPointerInsideHandleBoundary(); + + if (stillInsideBoundary) { + // Keep gizmo active but unhighlight the specific handle + this._visuals.unhighlightHandle(this._state.hoveredHandle.id); + this._state.hoveredHandle = undefined; + // Keep state as HOVER_MESH (don't drop to IDLE) + this._state.state = InteractionState.HOVER_MESH; + } else { + // Pointer left the boundary entirely, exit hover completely + this.onHoverExit(); + } } } @@ -515,6 +551,13 @@ export class ResizeGizmoInteraction { return this._state.state === InteractionState.HOVER_HANDLE && this._state.hoveredHandle != null; } + /** + * Get current interaction state (for external integration) + */ + getState(): Readonly { + return this._state; + } + /** * Dispose */ diff --git a/src/gizmos/ResizeGizmo/ResizeGizmoManager.ts b/src/gizmos/ResizeGizmo/ResizeGizmoManager.ts index 4fa5413..94a3b85 100644 --- a/src/gizmos/ResizeGizmo/ResizeGizmoManager.ts +++ b/src/gizmos/ResizeGizmo/ResizeGizmoManager.ts @@ -7,7 +7,8 @@ import { Scene, AbstractMesh, Observable, - WebXRInputSource + WebXRInputSource, + Ray } from "@babylonjs/core"; import { ResizeGizmoMode, @@ -258,6 +259,21 @@ export class ResizeGizmoManager { return this._interaction.isHoveringHandle(); } + /** + * Get current interaction state (for external integration) + */ + getInteractionState(): string { + return this._interaction.getState().state; + } + + /** + * Check if pointer ray is inside handle boundary (for external integration) + * This is used by DiagramMenuManager to determine if gizmo should stay active + */ + isPointerInsideHandleBoundary(ray: Ray): boolean { + return this._visuals.isPointerInsideHandleBoundary(ray); + } + /** * Get the utility layer scene (for filtering picks in main scene) * This is used to prevent pointer events on gizmo handles from leaking to main scene diff --git a/src/gizmos/ResizeGizmo/ResizeGizmoVisuals.ts b/src/gizmos/ResizeGizmo/ResizeGizmoVisuals.ts index 83cf59a..b660c2b 100644 --- a/src/gizmos/ResizeGizmo/ResizeGizmoVisuals.ts +++ b/src/gizmos/ResizeGizmo/ResizeGizmoVisuals.ts @@ -13,7 +13,9 @@ import { UtilityLayerRenderer, LinesMesh, Vector3, - Quaternion + Quaternion, + Ray, + BoundingBox } from "@babylonjs/core"; import { HandlePosition, HandleType } from "./types"; import { ResizeGizmoConfigManager } from "./ResizeGizmoConfig"; @@ -105,7 +107,7 @@ export class ResizeGizmoVisuals { // Generate handles based on mode (using OBB) return HandleGeometry.generateHandles( this._targetMesh, - this._config.current.boundingBoxPadding, + this._config.current.handleOffset, this._config.usesCornerHandles(), this._config.usesEdgeHandles(), this._config.usesFaceHandles() @@ -114,8 +116,9 @@ export class ResizeGizmoVisuals { /** * Calculate the 8 corners of the oriented bounding box (OBB) in world space + * @param paddingFactor Optional padding factor to expand corners outward (0.03 = 3%) */ - private calculateOBBCorners(): Vector3[] { + private calculateOBBCorners(paddingFactor: number = 0): Vector3[] { if (!this._targetMesh) { return []; } @@ -144,6 +147,19 @@ export class ResizeGizmoVisuals { Vector3.TransformCoordinates(corner, worldMatrix) ); + // Apply padding if specified (expand outward from center) + if (paddingFactor > 0) { + const center = this._targetMesh.absolutePosition; + const size = boundingBox.extendSize; + const avgSize = (size.x + size.y + size.z) / 3; + const paddingDistance = avgSize * paddingFactor; + + return worldCorners.map(corner => { + const normal = corner.subtract(center).normalize(); + return corner.add(normal.scale(paddingDistance)); + }); + } + return worldCorners; } @@ -157,8 +173,8 @@ export class ResizeGizmoVisuals { this.disposeBoundingBox(); - // Get OBB corners in world space - const corners = this.calculateOBBCorners(); + // Get OBB corners in world space with wireframe padding + const corners = this.calculateOBBCorners(this._config.current.wireframePadding); if (corners.length !== 8) { return; } @@ -406,6 +422,73 @@ export class ResizeGizmoVisuals { return this._utilityLayer.utilityLayerScene; } + /** + * Check if a ray intersects the expanded bounding volume that encompasses all handles + * This creates a "grace zone" to prevent hover state loss in whitespace between mesh and handles + * + * Uses local space transformation for accuracy - transforms ray to mesh local space + * and performs AABB intersection test with manual slab method + */ + isPointerInsideHandleBoundary(ray: Ray): boolean { + if (!this._targetMesh || !this._config.current.keepHoverInHandleBoundary) { + return false; + } + + // Transform ray from world space to mesh local space + const worldMatrix = this._targetMesh.computeWorldMatrix(true); + const invWorldMatrix = worldMatrix.clone().invert(); + + const localOrigin = Vector3.TransformCoordinates(ray.origin, invWorldMatrix); + const localDirection = Vector3.TransformNormal(ray.direction, invWorldMatrix); + + // Get local space bounding box + const boundingInfo = this._targetMesh.getBoundingInfo(); + const boundingBox = boundingInfo.boundingBox; + const size = boundingBox.extendSize; + const avgSize = (size.x + size.y + size.z) / 3; + + // Calculate expanded padding (handleOffset is a fraction, need to scale by avgSize) + const handleSize = avgSize * this._config.current.handleSize; + const paddingDistance = avgSize * this._config.current.handleOffset; + const totalPadding = paddingDistance + (handleSize / 2); + + // Create expanded AABB in local space + const paddingVec = new Vector3(totalPadding, totalPadding, totalPadding); + const min = boundingBox.minimum.subtract(paddingVec); + const max = boundingBox.maximum.add(paddingVec); + + // Ray-AABB intersection test using slab method + // https://tavianator.com/2011/ray_box.html + const invDir = new Vector3( + 1 / localDirection.x, + 1 / localDirection.y, + 1 / localDirection.z + ); + + const t1 = (min.x - localOrigin.x) * invDir.x; + const t2 = (max.x - localOrigin.x) * invDir.x; + const t3 = (min.y - localOrigin.y) * invDir.y; + const t4 = (max.y - localOrigin.y) * invDir.y; + const t5 = (min.z - localOrigin.z) * invDir.z; + const t6 = (max.z - localOrigin.z) * invDir.z; + + const tmin = Math.max(Math.max(Math.min(t1, t2), Math.min(t3, t4)), Math.min(t5, t6)); + const tmax = Math.min(Math.min(Math.max(t1, t2), Math.max(t3, t4)), Math.max(t5, t6)); + + // If tmax < 0, ray is intersecting AABB but the box is behind the ray + if (tmax < 0) { + return false; + } + + // If tmin > tmax, ray doesn't intersect AABB + if (tmin > tmax) { + return false; + } + + // Ray intersects the expanded bounding box + return true; + } + /** * Dispose all resources */ diff --git a/src/gizmos/ResizeGizmo/types.ts b/src/gizmos/ResizeGizmo/types.ts index 0f22e9c..566633e 100644 --- a/src/gizmos/ResizeGizmo/types.ts +++ b/src/gizmos/ResizeGizmo/types.ts @@ -147,8 +147,11 @@ export interface ResizeGizmoConfig { hoverScaleFactor: number; // === Bounding Box === - /** Padding around mesh bounding box (0.05 = 5% padding) */ - boundingBoxPadding: number; + /** Handle offset from bounding box surface (0.05 = 5% outward) */ + handleOffset: number; + + /** Padding for bounding box wireframe (0.03 = 3% outward breathing room) */ + wireframePadding: number; /** Bounding box wireframe color */ boundingBoxColor: Color3; @@ -159,6 +162,9 @@ export interface ResizeGizmoConfig { /** Show bounding box only on hover */ showBoundingBoxOnHoverOnly: boolean; + /** Keep hover state when pointer is within handle boundary (prevents loss in whitespace) */ + keepHoverInHandleBoundary: boolean; + // === Snapping === /** Enable snap-to-grid during scaling */ enableSnapping: boolean; @@ -232,10 +238,12 @@ export const DEFAULT_RESIZE_GIZMO_CONFIG: ResizeGizmoConfig = { hoverScaleFactor: 1.3, // Bounding box - boundingBoxPadding: 0.05, + handleOffset: 0.05, + wireframePadding: 0.03, boundingBoxColor: new Color3(1.0, 1.0, 1.0), // White wireframeAlpha: 0.3, showBoundingBoxOnHoverOnly: false, + keepHoverInHandleBoundary: true, // Snapping enableSnapping: true,