From 5fbf2b87c1b7d8c204e52bed465995b9056333cd Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Fri, 14 Nov 2025 07:06:06 -0600 Subject: [PATCH] Implement OBB-based scaling for rotated meshes and simplify gizmo UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major improvements to ResizeGizmo rotation handling and interface: 1. **OBB (Oriented Bounding Box) Implementation** - Replace AABB with true OBB that rotates with mesh - Calculate 8 OBB corners in world space using mesh world matrix - Update bounding box wireframe to use OBB corners - Rewrite all handle generation (corner, edge, face) for OBB positioning - Handle normals now calculated from mesh center to handle position - Result: Bounding box and handles rotate with mesh, scaling follows local axes 2. **Simplify UX - Remove Edge Handles** - Remove TWO_AXIS mode from ResizeGizmoMode enum - Disable edge handles (green, two-axis) to reduce cognitive complexity - Keep only corner handles (blue, uniform) and face handles (red, single-axis) - Updated from 26 total handles to 14 handles (6 face + 8 corner) - All scaling capabilities still available through remaining handle types 3. **Fix Event Leak-Through (Hit Testing)** - Add getUtilityScene() method to ResizeGizmoManager - Configure XR pick predicate to exclude utility layer meshes (primary defense) - Filter utility layer in pointer observable (secondary defense) - Filter utility layer in click handler (tertiary defense) - Prevents gizmo handle events from leaking to main scene 4. **Documentation** - Add TODO.md documenting implementation and decisions - Document OBB implementation and edge handle removal - Track completed features and rationale Files modified: - ResizeGizmoVisuals.ts: OBB wireframe and corner calculation - HandleGeometry.ts: OBB-based handle positioning for all types - ResizeGizmoConfig.ts: Disable edge handles - ResizeGizmoManager.ts: Add utility scene access - ScalingCalculator.ts: Uniform two-axis scaling (distance-ratio) - types.ts: Remove TWO_AXIS mode - diagramMenuManager.ts: XR pick predicate filtering - abstractController.ts: Pointer and click filtering - TODO.md: Documentation of changes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/controllers/abstractController.ts | 25 ++ src/diagram/diagramMenuManager.ts | 23 ++ src/gizmos/ResizeGizmo/HandleGeometry.ts | 367 ++++++++----------- src/gizmos/ResizeGizmo/ResizeGizmoConfig.ts | 4 +- src/gizmos/ResizeGizmo/ResizeGizmoManager.ts | 8 + src/gizmos/ResizeGizmo/ResizeGizmoVisuals.ts | 103 +++--- src/gizmos/ResizeGizmo/ScalingCalculator.ts | 43 +-- src/gizmos/ResizeGizmo/TODO.md | 51 +++ src/gizmos/ResizeGizmo/types.ts | 5 +- 9 files changed, 339 insertions(+), 290 deletions(-) create mode 100644 src/gizmos/ResizeGizmo/TODO.md diff --git a/src/controllers/abstractController.ts b/src/controllers/abstractController.ts index f2f1e16..2c4ce6d 100644 --- a/src/controllers/abstractController.ts +++ b/src/controllers/abstractController.ts @@ -64,6 +64,17 @@ export abstract class AbstractController { this.scene.onPointerObservable.add((pointerInfo) => { if (pointerInfo?.pickInfo?.gripTransform?.id == this.xrInputSource?.grip?.id) { if (pointerInfo.pickInfo.pickedMesh) { + // Filter out utility layer meshes (secondary defense against event leak-through) + const resizeGizmo = this.diagramManager?.diagramMenuManager?.resizeGizmo; + if (resizeGizmo) { + const utilityScene = resizeGizmo.getUtilityScene(); + if (pointerInfo.pickInfo.pickedMesh.getScene() === utilityScene) { + // This is a gizmo handle, ignore it in main scene pointer handling + this._meshUnderPointer = null; + return; + } + } + this._pickPoint.copyFrom(pointerInfo.pickInfo.pickedPoint); this._meshUnderPointer = pointerInfo.pickInfo.pickedMesh; @@ -192,6 +203,20 @@ export abstract class AbstractController { private click() { let mesh = this.xr.pointerSelection.getMeshUnderPointer(this.xrInputSource.uniqueId); + + // Filter out utility layer meshes (tertiary defense against event leak-through) + if (mesh) { + const resizeGizmo = this.diagramManager?.diagramMenuManager?.resizeGizmo; + if (resizeGizmo) { + const utilityScene = resizeGizmo.getUtilityScene(); + if (mesh.getScene() === utilityScene) { + // This is a gizmo handle, ignore click + this._logger.debug("click on utility layer mesh (gizmo), ignoring"); + return; + } + } + } + if (this.diagramManager.isDiagramObject(mesh)) { this._logger.debug("click on " + mesh.id); if (this.diagramManager.diagramMenuManager.connectionPreview) { diff --git a/src/diagram/diagramMenuManager.ts b/src/diagram/diagramMenuManager.ts index e0705a8..40aaffe 100644 --- a/src/diagram/diagramMenuManager.ts +++ b/src/diagram/diagramMenuManager.ts @@ -176,6 +176,29 @@ export class DiagramMenuManager { xr.input.onControllerRemovedObservable.add((controller) => { this.resizeGizmo.unregisterController(controller); }); + + // Configure pointer selection to exclude utility layer meshes (primary defense against event leak-through) + if (xr.pointerSelection) { + const utilityScene = this.resizeGizmo.getUtilityScene(); + + // Wrap or replace the mesh predicate + const originalMeshPredicate = xr.pointerSelection.meshPredicate; + + xr.pointerSelection.meshPredicate = (mesh) => { + // Exclude utility layer meshes (gizmo handles) + if (mesh.getScene() === utilityScene) { + return false; + } + + // Apply original predicate if it exists + if (originalMeshPredicate) { + return originalMeshPredicate(mesh); + } + + // Default: mesh must be pickable, visible, and enabled + return mesh.isPickable && mesh.isVisible && mesh.isEnabled(); + }; + } } /** diff --git a/src/gizmos/ResizeGizmo/HandleGeometry.ts b/src/gizmos/ResizeGizmo/HandleGeometry.ts index 0784695..5d7e89b 100644 --- a/src/gizmos/ResizeGizmo/HandleGeometry.ts +++ b/src/gizmos/ResizeGizmo/HandleGeometry.ts @@ -3,7 +3,7 @@ * Calculates positions for corner, edge, and face handles based on bounding box */ -import { Vector3, BoundingBox } from "@babylonjs/core"; +import { Vector3, BoundingBox, AbstractMesh } from "@babylonjs/core"; import { HandlePosition, HandleType } from "./types"; /** @@ -11,40 +11,79 @@ import { HandlePosition, HandleType } from "./types"; */ export class HandleGeometry { /** - * Generate all corner handle positions (8 handles) - * Corners are at all combinations of min/max X, Y, Z + * Calculate the 8 corners of the oriented bounding box (OBB) in world space */ - static generateCornerHandles(boundingBox: BoundingBox, padding: number = 0): HandlePosition[] { - const min = boundingBox.minimumWorld; - const max = boundingBox.maximumWorld; - const center = boundingBox.centerWorld; + static calculateOBBCorners(mesh: AbstractMesh): Vector3[] { + // Get bounding box in local space + const boundingInfo = mesh.getBoundingInfo(); + const boundingBox = boundingInfo.boundingBox; + const min = boundingBox.minimum; + const max = boundingBox.maximum; - // Apply padding to position handles inward from bounding box edges - const paddedMin = min.add(new Vector3(padding, padding, padding)); - const paddedMax = max.subtract(new Vector3(padding, padding, padding)); - - const corners: HandlePosition[] = []; - const positions = [ - { x: paddedMax.x, y: paddedMax.y, z: paddedMax.z, id: "corner-xyz" }, - { x: paddedMin.x, y: paddedMax.y, z: paddedMax.z, id: "corner-Xyz" }, - { x: paddedMax.x, y: paddedMin.y, z: paddedMax.z, id: "corner-xYz" }, - { x: paddedMin.x, y: paddedMin.y, z: paddedMax.z, id: "corner-XYz" }, - { x: paddedMax.x, y: paddedMax.y, z: paddedMin.z, id: "corner-xyZ" }, - { x: paddedMin.x, y: paddedMax.y, z: paddedMin.z, id: "corner-XyZ" }, - { x: paddedMax.x, y: paddedMin.y, z: paddedMin.z, id: "corner-xYZ" }, - { x: paddedMin.x, y: paddedMin.y, z: paddedMin.z, id: "corner-XYZ" } + // Define 8 corners in local space + const localCorners = [ + new Vector3(min.x, min.y, min.z), // 0: left-bottom-back + new Vector3(max.x, min.y, min.z), // 1: right-bottom-back + new Vector3(max.x, min.y, max.z), // 2: right-bottom-front + new Vector3(min.x, min.y, max.z), // 3: left-bottom-front + new Vector3(min.x, max.y, min.z), // 4: left-top-back + new Vector3(max.x, max.y, min.z), // 5: right-top-back + new Vector3(max.x, max.y, max.z), // 6: right-top-front + new Vector3(min.x, max.y, max.z) // 7: left-top-front ]; - for (const pos of positions) { - const position = new Vector3(pos.x, pos.y, pos.z); - const normal = position.subtract(center).normalize(); + // Transform corners to world space using mesh's world matrix + const worldMatrix = mesh.computeWorldMatrix(true); + const worldCorners = localCorners.map(corner => + Vector3.TransformCoordinates(corner, worldMatrix) + ); + + return worldCorners; + } + /** + * Generate all corner handle positions (8 handles) on the OBB + */ + static generateCornerHandles(mesh: AbstractMesh, paddingFactor: number = 0): HandlePosition[] { + // Get OBB corners in world space + const obbCorners = this.calculateOBBCorners(mesh); + + // Get mesh center (pivot point) + const center = mesh.absolutePosition; + + // Calculate padding in world units + const boundingInfo = mesh.getBoundingInfo(); + const boundingBox = boundingInfo.boundingBox; + const size = boundingBox.extendSize; + const avgSize = (size.x + size.y + size.z) / 3; + const paddingDistance = avgSize * paddingFactor; + + const corners: HandlePosition[] = []; + const cornerIds = [ + "corner-XYZ", // 0: left-bottom-back + "corner-xYZ", // 1: right-bottom-back + "corner-xYz", // 2: right-bottom-front + "corner-XYz", // 3: left-bottom-front + "corner-XyZ", // 4: left-top-back + "corner-xyZ", // 5: right-top-back + "corner-xyz", // 6: right-top-front + "corner-Xyz" // 7: left-top-front + ]; + + for (let i = 0; i < 8; i++) { + const cornerPos = obbCorners[i]; + + // 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)); corners.push({ position, type: HandleType.CORNER, axes: ["X", "Y", "Z"], normal, - id: pos.id + id: cornerIds[i] }); } @@ -52,206 +91,131 @@ export class HandleGeometry { } /** - * Generate all edge handle positions (12 handles) - * Edges are at midpoints of the 12 edges of the bounding box + * Generate all edge handle positions (12 handles) on the OBB + * Edges are at midpoints of the 12 edges of the oriented bounding box */ - static generateEdgeHandles(boundingBox: BoundingBox, padding: number = 0): HandlePosition[] { - const min = boundingBox.minimumWorld; - const max = boundingBox.maximumWorld; - const center = boundingBox.centerWorld; + static generateEdgeHandles(mesh: AbstractMesh, paddingFactor: number = 0): HandlePosition[] { + // Get OBB corners in world space + const c = this.calculateOBBCorners(mesh); - // Apply padding to position handles inward from bounding box edges - const paddedMin = min.add(new Vector3(padding, padding, padding)); - const paddedMax = max.subtract(new Vector3(padding, padding, padding)); + // Get mesh center (pivot point) + const center = mesh.absolutePosition; - // Calculate midpoints - const midX = (paddedMin.x + paddedMax.x) / 2; - const midY = (paddedMin.y + paddedMax.y) / 2; - const midZ = (paddedMin.z + paddedMax.z) / 2; + // Calculate padding distance + const boundingInfo = mesh.getBoundingInfo(); + const boundingBox = boundingInfo.boundingBox; + const size = boundingBox.extendSize; + const avgSize = (size.x + size.y + size.z) / 3; + const paddingDistance = avgSize * paddingFactor; const edges: HandlePosition[] = []; - // 4 edges parallel to X axis (varying Y and Z) - edges.push( - { - position: new Vector3(midX, paddedMax.y, paddedMax.z), - type: HandleType.EDGE, - axes: ["Y", "Z"], - normal: new Vector3(0, 1, 1).normalize(), - id: "edge-x-yz" - }, - { - position: new Vector3(midX, paddedMin.y, paddedMax.z), - type: HandleType.EDGE, - axes: ["Y", "Z"], - normal: new Vector3(0, -1, 1).normalize(), - id: "edge-x-Yz" - }, - { - position: new Vector3(midX, paddedMax.y, paddedMin.z), - type: HandleType.EDGE, - axes: ["Y", "Z"], - normal: new Vector3(0, 1, -1).normalize(), - id: "edge-x-yZ" - }, - { - position: new Vector3(midX, paddedMin.y, paddedMin.z), - type: HandleType.EDGE, - axes: ["Y", "Z"], - normal: new Vector3(0, -1, -1).normalize(), - id: "edge-x-YZ" - } - ); + // Define the 12 edges as pairs of corner indices + // Each edge scales the TWO axes perpendicular to the edge direction + const edgeDefinitions = [ + // 4 edges parallel to X-axis (scale Y and Z - perpendicular axes) + { start: 0, end: 1, axes: ["Y", "Z"], id: "edge-x-YZ" }, // left-bottom-back to right-bottom-back (parallel to X) + { start: 2, end: 3, axes: ["Y", "Z"], id: "edge-x-Yz" }, // right-bottom-front to left-bottom-front (parallel to X) + { start: 4, end: 5, axes: ["Y", "Z"], id: "edge-x-yZ" }, // left-top-back to right-top-back (parallel to X) + { start: 6, end: 7, axes: ["Y", "Z"], id: "edge-x-yz" }, // right-top-front to left-top-front (parallel to X) - // 4 edges parallel to Y axis (varying X and Z) - edges.push( - { - position: new Vector3(paddedMax.x, midY, paddedMax.z), - type: HandleType.EDGE, - axes: ["X", "Z"], - normal: new Vector3(1, 0, 1).normalize(), - id: "edge-y-xz" - }, - { - position: new Vector3(paddedMin.x, midY, paddedMax.z), - type: HandleType.EDGE, - axes: ["X", "Z"], - normal: new Vector3(-1, 0, 1).normalize(), - id: "edge-y-Xz" - }, - { - position: new Vector3(paddedMax.x, midY, paddedMin.z), - type: HandleType.EDGE, - axes: ["X", "Z"], - normal: new Vector3(1, 0, -1).normalize(), - id: "edge-y-xZ" - }, - { - position: new Vector3(paddedMin.x, midY, paddedMin.z), - type: HandleType.EDGE, - axes: ["X", "Z"], - normal: new Vector3(-1, 0, -1).normalize(), - id: "edge-y-XZ" - } - ); + // 4 edges parallel to Z-axis (scale X and Y - perpendicular axes) + { start: 1, end: 2, axes: ["X", "Y"], id: "edge-z-xY" }, // right-bottom-back to right-bottom-front (parallel to Z) + { start: 3, end: 0, axes: ["X", "Y"], id: "edge-z-XY" }, // left-bottom-front to left-bottom-back (parallel to Z) + { start: 5, end: 6, axes: ["X", "Y"], id: "edge-z-xy" }, // right-top-back to right-top-front (parallel to Z) + { start: 7, end: 4, axes: ["X", "Y"], id: "edge-z-Xy" }, // left-top-front to left-top-back (parallel to Z) - // 4 edges parallel to Z axis (varying X and Y) - edges.push( - { - position: new Vector3(paddedMax.x, paddedMax.y, midZ), + // 4 edges parallel to Y-axis (scale X and Z - perpendicular axes) + { start: 0, end: 4, axes: ["X", "Z"], id: "edge-y-XZ" }, // left-bottom-back to left-top-back (parallel to Y) + { start: 1, end: 5, axes: ["X", "Z"], id: "edge-y-xZ" }, // right-bottom-back to right-top-back (parallel to Y) + { start: 2, end: 6, axes: ["X", "Z"], id: "edge-y-xz" }, // right-bottom-front to right-top-front (parallel to Y) + { start: 3, end: 7, axes: ["X", "Z"], id: "edge-y-Xz" } // left-bottom-front to left-top-front (parallel to Y) + ]; + + for (const edge of edgeDefinitions) { + // Calculate midpoint of edge + const midpoint = c[edge.start].add(c[edge.end]).scale(0.5); + + // 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)); + + edges.push({ + position, type: HandleType.EDGE, - axes: ["X", "Y"], - normal: new Vector3(1, 1, 0).normalize(), - id: "edge-z-xy" - }, - { - position: new Vector3(paddedMin.x, paddedMax.y, midZ), - type: HandleType.EDGE, - axes: ["X", "Y"], - normal: new Vector3(-1, 1, 0).normalize(), - id: "edge-z-Xy" - }, - { - position: new Vector3(paddedMax.x, paddedMin.y, midZ), - type: HandleType.EDGE, - axes: ["X", "Y"], - normal: new Vector3(1, -1, 0).normalize(), - id: "edge-z-xY" - }, - { - position: new Vector3(paddedMin.x, paddedMin.y, midZ), - type: HandleType.EDGE, - axes: ["X", "Y"], - normal: new Vector3(-1, -1, 0).normalize(), - id: "edge-z-XY" - } - ); + axes: edge.axes, + normal, + id: edge.id + }); + } return edges; } /** - * Generate all face handle positions (6 handles) - * Faces are at centers of each face of the bounding box + * Generate all face handle positions (6 handles) on the OBB + * Faces are at centers of each face of the oriented bounding box */ - static generateFaceHandles(boundingBox: BoundingBox, padding: number = 0): HandlePosition[] { - const min = boundingBox.minimumWorld; - const max = boundingBox.maximumWorld; + static generateFaceHandles(mesh: AbstractMesh, paddingFactor: number = 0): HandlePosition[] { + // Get OBB corners in world space + const c = this.calculateOBBCorners(mesh); - // Apply padding to position handles inward from bounding box edges - const paddedMin = min.add(new Vector3(padding, padding, padding)); - const paddedMax = max.subtract(new Vector3(padding, padding, padding)); + // Get mesh center (pivot point) + const center = mesh.absolutePosition; - // Calculate midpoints - const midX = (paddedMin.x + paddedMax.x) / 2; - const midY = (paddedMin.y + paddedMax.y) / 2; - const midZ = (paddedMin.z + paddedMax.z) / 2; + // Calculate padding distance + const boundingInfo = mesh.getBoundingInfo(); + const boundingBox = boundingInfo.boundingBox; + const size = boundingBox.extendSize; + const avgSize = (size.x + size.y + size.z) / 3; + const paddingDistance = avgSize * paddingFactor; const faces: HandlePosition[] = []; - // +X face (right) - faces.push({ - position: new Vector3(paddedMax.x, midY, midZ), - type: HandleType.FACE, - axes: ["X"], - normal: new Vector3(1, 0, 0), - id: "face-x" - }); + // Define the 6 faces as sets of 4 corner indices + const faceDefinitions = [ + { corners: [0, 1, 2, 3], axes: ["Y"], id: "face-Y" }, // Bottom face + { corners: [4, 5, 6, 7], axes: ["Y"], id: "face-y" }, // Top face + { corners: [0, 1, 5, 4], axes: ["Z"], id: "face-Z" }, // Back face + { corners: [2, 3, 7, 6], axes: ["Z"], id: "face-z" }, // Front face + { corners: [1, 2, 6, 5], axes: ["X"], id: "face-x" }, // Right face + { corners: [0, 3, 7, 4], axes: ["X"], id: "face-X" } // Left face + ]; - // -X face (left) - faces.push({ - position: new Vector3(paddedMin.x, midY, midZ), - type: HandleType.FACE, - axes: ["X"], - normal: new Vector3(-1, 0, 0), - id: "face-X" - }); + for (const face of faceDefinitions) { + // Calculate center of face (average of 4 corners) + let faceCenter = Vector3.Zero(); + for (const cornerIdx of face.corners) { + faceCenter = faceCenter.add(c[cornerIdx]); + } + faceCenter = faceCenter.scale(0.25); - // +Y face (top) - faces.push({ - position: new Vector3(midX, paddedMax.y, midZ), - type: HandleType.FACE, - axes: ["Y"], - normal: new Vector3(0, 1, 0), - id: "face-y" - }); + // Calculate normal from center to face center + const normal = faceCenter.subtract(center).normalize(); - // -Y face (bottom) - faces.push({ - position: new Vector3(midX, paddedMin.y, midZ), - type: HandleType.FACE, - axes: ["Y"], - normal: new Vector3(0, -1, 0), - id: "face-Y" - }); + // Apply padding by moving inward along the normal + const position = faceCenter.subtract(normal.scale(paddingDistance)); - // +Z face (front) - faces.push({ - position: new Vector3(midX, midY, paddedMax.z), - type: HandleType.FACE, - axes: ["Z"], - normal: new Vector3(0, 0, 1), - id: "face-z" - }); - - // -Z face (back) - faces.push({ - position: new Vector3(midX, midY, paddedMin.z), - type: HandleType.FACE, - axes: ["Z"], - normal: new Vector3(0, 0, -1), - id: "face-Z" - }); + faces.push({ + position, + type: HandleType.FACE, + axes: face.axes, + normal, + id: face.id + }); + } return faces; } /** - * Generate all handles based on mode flags + * Generate all handles based on mode flags (OBB-based) */ static generateHandles( - boundingBox: BoundingBox, - padding: number, + mesh: AbstractMesh, + paddingFactor: number, includeCorners: boolean, includeEdges: boolean, includeFaces: boolean @@ -259,26 +223,17 @@ export class HandleGeometry { const handles: HandlePosition[] = []; if (includeCorners) { - handles.push(...this.generateCornerHandles(boundingBox, padding)); + handles.push(...this.generateCornerHandles(mesh, paddingFactor)); } if (includeEdges) { - handles.push(...this.generateEdgeHandles(boundingBox, padding)); + handles.push(...this.generateEdgeHandles(mesh, paddingFactor)); } if (includeFaces) { - handles.push(...this.generateFaceHandles(boundingBox, padding)); + handles.push(...this.generateFaceHandles(mesh, paddingFactor)); } return handles; } - - /** - * Calculate padding in world units based on bounding box size - */ - static calculatePadding(boundingBox: BoundingBox, paddingFactor: number): number { - const size = boundingBox.extendSizeWorld; - const avgSize = (size.x + size.y + size.z) / 3; - return avgSize * paddingFactor; - } } diff --git a/src/gizmos/ResizeGizmo/ResizeGizmoConfig.ts b/src/gizmos/ResizeGizmo/ResizeGizmoConfig.ts index 8a6d291..008e4f6 100644 --- a/src/gizmos/ResizeGizmo/ResizeGizmoConfig.ts +++ b/src/gizmos/ResizeGizmo/ResizeGizmoConfig.ts @@ -143,10 +143,10 @@ export class ResizeGizmoConfigManager { /** * Check if a mode uses edge handles + * Edge handles are disabled to simplify UX */ usesEdgeHandles(): boolean { - const mode = this._config.mode; - return mode === ResizeGizmoMode.TWO_AXIS || mode === ResizeGizmoMode.ALL; + return false; } /** diff --git a/src/gizmos/ResizeGizmo/ResizeGizmoManager.ts b/src/gizmos/ResizeGizmo/ResizeGizmoManager.ts index 65e912e..4fa5413 100644 --- a/src/gizmos/ResizeGizmo/ResizeGizmoManager.ts +++ b/src/gizmos/ResizeGizmo/ResizeGizmoManager.ts @@ -258,6 +258,14 @@ export class ResizeGizmoManager { return this._interaction.isHoveringHandle(); } + /** + * 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 + */ + getUtilityScene(): Scene { + return this._visuals.getUtilityScene(); + } + // ===== Event System ===== /** diff --git a/src/gizmos/ResizeGizmo/ResizeGizmoVisuals.ts b/src/gizmos/ResizeGizmo/ResizeGizmoVisuals.ts index 58d92c8..aae3e39 100644 --- a/src/gizmos/ResizeGizmo/ResizeGizmoVisuals.ts +++ b/src/gizmos/ResizeGizmo/ResizeGizmoVisuals.ts @@ -94,26 +94,17 @@ export class ResizeGizmoVisuals { } /** - * Generate handle positions based on current config and mesh bounding box + * Generate handle positions based on current config and mesh bounding box (OBB-based) */ private generateHandlePositions(): HandlePosition[] { if (!this._targetMesh) { return []; } - const boundingInfo = this._targetMesh.getBoundingInfo(); - const boundingBox = boundingInfo.boundingBox; - - // Calculate padding - const padding = HandleGeometry.calculatePadding( - boundingBox, - this._config.current.boundingBoxPadding - ); - - // Generate handles based on mode + // Generate handles based on mode (using OBB) return HandleGeometry.generateHandles( - boundingBox, - padding, + this._targetMesh, + this._config.current.boundingBoxPadding, this._config.usesCornerHandles(), this._config.usesEdgeHandles(), this._config.usesFaceHandles() @@ -121,7 +112,42 @@ export class ResizeGizmoVisuals { } /** - * Create bounding box wireframe + * Calculate the 8 corners of the oriented bounding box (OBB) in world space + */ + private calculateOBBCorners(): Vector3[] { + if (!this._targetMesh) { + return []; + } + + // Get bounding box in local space + const boundingInfo = this._targetMesh.getBoundingInfo(); + const boundingBox = boundingInfo.boundingBox; + const min = boundingBox.minimum; + const max = boundingBox.maximum; + + // Define 8 corners in local space + const localCorners = [ + new Vector3(min.x, min.y, min.z), // 0: left-bottom-back + new Vector3(max.x, min.y, min.z), // 1: right-bottom-back + new Vector3(max.x, min.y, max.z), // 2: right-bottom-front + new Vector3(min.x, min.y, max.z), // 3: left-bottom-front + new Vector3(min.x, max.y, min.z), // 4: left-top-back + new Vector3(max.x, max.y, min.z), // 5: right-top-back + new Vector3(max.x, max.y, max.z), // 6: right-top-front + new Vector3(min.x, max.y, max.z) // 7: left-top-front + ]; + + // Transform corners to world space using mesh's world matrix + const worldMatrix = this._targetMesh.computeWorldMatrix(true); + const worldCorners = localCorners.map(corner => + Vector3.TransformCoordinates(corner, worldMatrix) + ); + + return worldCorners; + } + + /** + * Create bounding box wireframe (OBB - oriented bounding box) */ private createBoundingBox(): void { if (!this._targetMesh) { @@ -130,41 +156,32 @@ export class ResizeGizmoVisuals { this.disposeBoundingBox(); - const boundingInfo = this._targetMesh.getBoundingInfo(); - const boundingBox = boundingInfo.boundingBox; - const min = boundingBox.minimumWorld; - const max = boundingBox.maximumWorld; - - // Use original bounding box without padding for wireframe - // (handles are now positioned inside, so box matches actual mesh bounds) - const paddedMin = min; - const paddedMax = max; + // Get OBB corners in world space + const corners = this.calculateOBBCorners(); + if (corners.length !== 8) { + return; + } // Create line points for bounding box edges + // Using corner indices: 0-7 as defined in calculateOBBCorners const points = [ - // Bottom face - [paddedMin, new Vector3(paddedMax.x, paddedMin.y, paddedMin.z)], - [new Vector3(paddedMax.x, paddedMin.y, paddedMin.z), new Vector3(paddedMax.x, paddedMin.y, paddedMax.z)], - [new Vector3(paddedMax.x, paddedMin.y, paddedMax.z), new Vector3(paddedMin.x, paddedMin.y, paddedMax.z)], - [new Vector3(paddedMin.x, paddedMin.y, paddedMax.z), paddedMin], - // Top face - [new Vector3(paddedMin.x, paddedMax.y, paddedMin.z), new Vector3(paddedMax.x, paddedMax.y, paddedMin.z)], - [new Vector3(paddedMax.x, paddedMax.y, paddedMin.z), paddedMax], - [paddedMax, new Vector3(paddedMin.x, paddedMax.y, paddedMax.z)], - [new Vector3(paddedMin.x, paddedMax.y, paddedMax.z), new Vector3(paddedMin.x, paddedMax.y, paddedMin.z)], + // Bottom face (y = min) + [corners[0], corners[1]], // left-back to right-back + [corners[1], corners[2]], // right-back to right-front + [corners[2], corners[3]], // right-front to left-front + [corners[3], corners[0]], // left-front to left-back + // Top face (y = max) + [corners[4], corners[5]], // left-back to right-back + [corners[5], corners[6]], // right-back to right-front + [corners[6], corners[7]], // right-front to left-front + [corners[7], corners[4]], // left-front to left-back // Vertical edges - [paddedMin, new Vector3(paddedMin.x, paddedMax.y, paddedMin.z)], - [new Vector3(paddedMax.x, paddedMin.y, paddedMin.z), new Vector3(paddedMax.x, paddedMax.y, paddedMin.z)], - [new Vector3(paddedMax.x, paddedMin.y, paddedMax.z), paddedMax], - [new Vector3(paddedMin.x, paddedMin.y, paddedMax.z), new Vector3(paddedMin.x, paddedMax.y, paddedMax.z)] + [corners[0], corners[4]], // left-back bottom to top + [corners[1], corners[5]], // right-back bottom to top + [corners[2], corners[6]], // right-front bottom to top + [corners[3], corners[7]] // left-front bottom to top ]; - // Flatten points - const flatPoints: Vector3[] = []; - for (const line of points) { - flatPoints.push(...line); - } - // Create lines mesh this._boundingBoxLines = MeshBuilder.CreateLineSystem( "gizmo-boundingbox", diff --git a/src/gizmos/ResizeGizmo/ScalingCalculator.ts b/src/gizmos/ResizeGizmo/ScalingCalculator.ts index 9d8bc79..7bfa721 100644 --- a/src/gizmos/ResizeGizmo/ScalingCalculator.ts +++ b/src/gizmos/ResizeGizmo/ScalingCalculator.ts @@ -150,44 +150,17 @@ export class ScalingCalculator { return newScale; } - // Calculate vector from pivot to virtual points - const startVector = startVirtualPoint.subtract(boundingBoxCenter); - const currentVector = currentVirtualPoint.subtract(boundingBoxCenter); + // Calculate distance from pivot to virtual points + const startDistance = Vector3.Distance(boundingBoxCenter, startVirtualPoint); + const currentDistance = Vector3.Distance(boundingBoxCenter, currentVirtualPoint); - // Determine which two axes to scale + // Calculate single scale ratio based on distance change + // This ensures both axes scale uniformly (same amount) + const scaleRatio = currentDistance / startDistance; + + // Apply same scale ratio to both axes const axes = handle.axes; - const worldMatrix = mesh.getWorldMatrix(); - - // For each axis involved, calculate scale ratio based on projection for (const axis of axes) { - // Get local axis vector - let localAxisVector: Vector3; - switch (axis) { - case "X": - localAxisVector = Vector3.Right(); - break; - case "Y": - localAxisVector = Vector3.Up(); - break; - case "Z": - localAxisVector = Vector3.Forward(); - break; - } - - // Transform axis to world space - const worldAxisVector = Vector3.TransformNormal(localAxisVector, worldMatrix).normalize(); - - // Project start and current vectors onto this axis - const startProjection = Vector3.Dot(startVector, worldAxisVector); - const currentProjection = Vector3.Dot(currentVector, worldAxisVector); - - // Calculate scale ratio for this axis - // Avoid division by zero - const scaleRatio = Math.abs(startProjection) > 0.001 - ? currentProjection / startProjection - : 1.0; - - // Apply scale to this axis switch (axis) { case "X": newScale.x = startScale.x * scaleRatio; diff --git a/src/gizmos/ResizeGizmo/TODO.md b/src/gizmos/ResizeGizmo/TODO.md new file mode 100644 index 0000000..3e9f234 --- /dev/null +++ b/src/gizmos/ResizeGizmo/TODO.md @@ -0,0 +1,51 @@ +# ResizeGizmo TODO and Known Issues + +## Recently Completed + +### ✅ Remove Edge Handles to Simplify UX (Completed 2025-11-14) +- **Problem:** Edge handles (green, two-axis scaling) added cognitive complexity without unique capabilities +- **User Decision:** Simplify interface by removing edge handles entirely +- **Solution:** + 1. Removed `TWO_AXIS` mode from `ResizeGizmoMode` enum + 2. Updated `usesEdgeHandles()` to always return `false` + 3. Updated mode comments to reflect 14 total handles (6 face + 8 corner) +- **Result:** Simpler, more intuitive interface with only two handle types: + - **Corner handles (blue):** Uniform scaling on all axes + - **Face handles (red):** Single-axis scaling + - All scaling capabilities still available (two-axis can be done sequentially with face handles) +- **Files Modified:** + - `types.ts`: Removed TWO_AXIS mode + - `ResizeGizmoConfig.ts`: Disabled edge handles + - HandleGeometry still contains edge generation code but it's never called + +### ✅ Fix OBB-Based Scaling for Rotated Meshes (Completed 2025-11-14) +- **Problem:** Bounding box wireframe and handles were using AABB (axis-aligned), not rotating with mesh +- **User Requirement:** Scaling should follow mesh's rotated local axes with handles on OBB +- **Solution:** Implemented true OBB (oriented bounding box) system: + 1. Created `calculateOBBCorners()` to transform local corners to world space + 2. Updated bounding box visualization to use OBB corners (lines rotate with mesh) + 3. Rewrote all handle generation (corner, edge, face) to position on OBB + 4. Verified ScalingCalculator correctly transforms local axes to world space +- **Result:** Bounding box and handles now rotate with mesh, scaling follows mesh's local coordinate system +- **Files Modified:** + - `ResizeGizmoVisuals.ts`: OBB wireframe visualization + - `HandleGeometry.ts`: OBB-based handle positioning + - `ScalingCalculator.ts`: Already correct (transforms axes to world space) + +### ✅ Move Handles Inside Bounding Box (Completed 2025-11-13) +- **Problem:** Handles were positioned outside bounding box, causing selection issues +- **Solution:** Reversed padding direction in `HandleGeometry.ts` +- **Result:** Handles now 5% inside edges instead of 5% outside +- **Commit:** `204ef67` + +### ✅ Fix Color Persistence Bug (Completed 2025-11-13) +- **Problem:** Diagram entities losing color when scaled via ResizeGizmo +- **Root Cause:** `DiagramEntityAdapter` was only copying metadata, not extracting color from material +- **Solution:** Use `toDiagramEntity()` converter which properly extracts color from material +- **Commit:** `26b48b2` + +### ✅ Extract DiagramEntityAdapter to Integration Layer (Completed 2025-11-13) +- **Problem:** Adapter was in ResizeGizmo folder, causing tight coupling +- **Solution:** Moved to `src/integration/gizmo/` with dependency injection +- **Result:** ResizeGizmo is now pure and reusable +- **Commit:** `26b48b2` diff --git a/src/gizmos/ResizeGizmo/types.ts b/src/gizmos/ResizeGizmo/types.ts index e6397b8..0f22e9c 100644 --- a/src/gizmos/ResizeGizmo/types.ts +++ b/src/gizmos/ResizeGizmo/types.ts @@ -15,10 +15,7 @@ export enum ResizeGizmoMode { /** Only corner handles (8 handles) - uniform scaling all axes */ UNIFORM = "UNIFORM", - /** Only edge-center handles (12 handles) - scale two axes simultaneously */ - TWO_AXIS = "TWO_AXIS", - - /** All handles enabled (26 total) - behavior depends on grabbed handle */ + /** All handles enabled (14 total: 6 faces + 8 corners) - behavior depends on grabbed handle */ ALL = "ALL" }