Improve ResizeGizmo hover state and handle interaction

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 <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-11-15 13:55:18 -06:00
parent 43100ad650
commit c815db4594
9 changed files with 206 additions and 48 deletions

View File

@ -1,7 +1,7 @@
{
"name": "immersive",
"private": true,
"version": "0.0.8-23",
"version": "0.0.8-24",
"type": "module",
"license": "MIT",
"engines": {

View File

@ -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,

View File

@ -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';
}
/**

View File

@ -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,

View File

@ -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

View File

@ -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<GizmoInteractionState> {
return this._state;
}
/**
* Dispose
*/

View File

@ -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

View File

@ -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
*/

View File

@ -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,