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:
parent
43100ad650
commit
c815db4594
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "immersive",
|
||||
"private": true,
|
||||
"version": "0.0.8-23",
|
||||
"version": "0.0.8-24",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user