Implement OBB-based scaling for rotated meshes and simplify gizmo UX

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 <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-11-14 07:06:06 -06:00
parent 204ef670f9
commit 5fbf2b87c1
9 changed files with 339 additions and 290 deletions

View File

@ -64,6 +64,17 @@ export abstract class AbstractController {
this.scene.onPointerObservable.add((pointerInfo) => { this.scene.onPointerObservable.add((pointerInfo) => {
if (pointerInfo?.pickInfo?.gripTransform?.id == this.xrInputSource?.grip?.id) { if (pointerInfo?.pickInfo?.gripTransform?.id == this.xrInputSource?.grip?.id) {
if (pointerInfo.pickInfo.pickedMesh) { 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._pickPoint.copyFrom(pointerInfo.pickInfo.pickedPoint);
this._meshUnderPointer = pointerInfo.pickInfo.pickedMesh; this._meshUnderPointer = pointerInfo.pickInfo.pickedMesh;
@ -192,6 +203,20 @@ export abstract class AbstractController {
private click() { private click() {
let mesh = this.xr.pointerSelection.getMeshUnderPointer(this.xrInputSource.uniqueId); 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)) { if (this.diagramManager.isDiagramObject(mesh)) {
this._logger.debug("click on " + mesh.id); this._logger.debug("click on " + mesh.id);
if (this.diagramManager.diagramMenuManager.connectionPreview) { if (this.diagramManager.diagramMenuManager.connectionPreview) {

View File

@ -176,6 +176,29 @@ export class DiagramMenuManager {
xr.input.onControllerRemovedObservable.add((controller) => { xr.input.onControllerRemovedObservable.add((controller) => {
this.resizeGizmo.unregisterController(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();
};
}
} }
/** /**

View File

@ -3,7 +3,7 @@
* Calculates positions for corner, edge, and face handles based on bounding box * 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"; import { HandlePosition, HandleType } from "./types";
/** /**
@ -11,40 +11,79 @@ import { HandlePosition, HandleType } from "./types";
*/ */
export class HandleGeometry { export class HandleGeometry {
/** /**
* Generate all corner handle positions (8 handles) * Calculate the 8 corners of the oriented bounding box (OBB) in world space
* Corners are at all combinations of min/max X, Y, Z
*/ */
static generateCornerHandles(boundingBox: BoundingBox, padding: number = 0): HandlePosition[] { static calculateOBBCorners(mesh: AbstractMesh): Vector3[] {
const min = boundingBox.minimumWorld; // Get bounding box in local space
const max = boundingBox.maximumWorld; const boundingInfo = mesh.getBoundingInfo();
const center = boundingBox.centerWorld; const boundingBox = boundingInfo.boundingBox;
const min = boundingBox.minimum;
const max = boundingBox.maximum;
// Apply padding to position handles inward from bounding box edges // Define 8 corners in local space
const paddedMin = min.add(new Vector3(padding, padding, padding)); const localCorners = [
const paddedMax = max.subtract(new Vector3(padding, padding, padding)); new Vector3(min.x, min.y, min.z), // 0: left-bottom-back
new Vector3(max.x, min.y, min.z), // 1: right-bottom-back
const corners: HandlePosition[] = []; new Vector3(max.x, min.y, max.z), // 2: right-bottom-front
const positions = [ new Vector3(min.x, min.y, max.z), // 3: left-bottom-front
{ x: paddedMax.x, y: paddedMax.y, z: paddedMax.z, id: "corner-xyz" }, new Vector3(min.x, max.y, min.z), // 4: left-top-back
{ x: paddedMin.x, y: paddedMax.y, z: paddedMax.z, id: "corner-Xyz" }, new Vector3(max.x, max.y, min.z), // 5: right-top-back
{ x: paddedMax.x, y: paddedMin.y, z: paddedMax.z, id: "corner-xYz" }, new Vector3(max.x, max.y, max.z), // 6: right-top-front
{ x: paddedMin.x, y: paddedMin.y, z: paddedMax.z, id: "corner-XYz" }, new Vector3(min.x, max.y, max.z) // 7: left-top-front
{ 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" }
]; ];
for (const pos of positions) { // Transform corners to world space using mesh's world matrix
const position = new Vector3(pos.x, pos.y, pos.z); const worldMatrix = mesh.computeWorldMatrix(true);
const normal = position.subtract(center).normalize(); 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({ corners.push({
position, position,
type: HandleType.CORNER, type: HandleType.CORNER,
axes: ["X", "Y", "Z"], axes: ["X", "Y", "Z"],
normal, normal,
id: pos.id id: cornerIds[i]
}); });
} }
@ -52,206 +91,131 @@ export class HandleGeometry {
} }
/** /**
* Generate all edge handle positions (12 handles) * Generate all edge handle positions (12 handles) on the OBB
* Edges are at midpoints of the 12 edges of the bounding box * Edges are at midpoints of the 12 edges of the oriented bounding box
*/ */
static generateEdgeHandles(boundingBox: BoundingBox, padding: number = 0): HandlePosition[] { static generateEdgeHandles(mesh: AbstractMesh, paddingFactor: number = 0): HandlePosition[] {
const min = boundingBox.minimumWorld; // Get OBB corners in world space
const max = boundingBox.maximumWorld; const c = this.calculateOBBCorners(mesh);
const center = boundingBox.centerWorld;
// Apply padding to position handles inward from bounding box edges // Get mesh center (pivot point)
const paddedMin = min.add(new Vector3(padding, padding, padding)); const center = mesh.absolutePosition;
const paddedMax = max.subtract(new Vector3(padding, padding, padding));
// Calculate midpoints // Calculate padding distance
const midX = (paddedMin.x + paddedMax.x) / 2; const boundingInfo = mesh.getBoundingInfo();
const midY = (paddedMin.y + paddedMax.y) / 2; const boundingBox = boundingInfo.boundingBox;
const midZ = (paddedMin.z + paddedMax.z) / 2; const size = boundingBox.extendSize;
const avgSize = (size.x + size.y + size.z) / 3;
const paddingDistance = avgSize * paddingFactor;
const edges: HandlePosition[] = []; const edges: HandlePosition[] = [];
// 4 edges parallel to X axis (varying Y and Z) // Define the 12 edges as pairs of corner indices
edges.push( // Each edge scales the TWO axes perpendicular to the edge direction
{ const edgeDefinitions = [
position: new Vector3(midX, paddedMax.y, paddedMax.z), // 4 edges parallel to X-axis (scale Y and Z - perpendicular axes)
type: HandleType.EDGE, { start: 0, end: 1, axes: ["Y", "Z"], id: "edge-x-YZ" }, // left-bottom-back to right-bottom-back (parallel to X)
axes: ["Y", "Z"], { start: 2, end: 3, axes: ["Y", "Z"], id: "edge-x-Yz" }, // right-bottom-front to left-bottom-front (parallel to X)
normal: new Vector3(0, 1, 1).normalize(), { start: 4, end: 5, axes: ["Y", "Z"], id: "edge-x-yZ" }, // left-top-back to right-top-back (parallel to X)
id: "edge-x-yz" { start: 6, end: 7, axes: ["Y", "Z"], id: "edge-x-yz" }, // right-top-front to left-top-front (parallel to X)
},
{
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"
}
);
// 4 edges parallel to Y axis (varying X and Z) // 4 edges parallel to Z-axis (scale X and Y - perpendicular axes)
edges.push( { 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)
position: new Vector3(paddedMax.x, midY, paddedMax.z), { start: 5, end: 6, axes: ["X", "Y"], id: "edge-z-xy" }, // right-top-back to right-top-front (parallel to Z)
type: HandleType.EDGE, { start: 7, end: 4, axes: ["X", "Y"], id: "edge-z-Xy" }, // left-top-front to left-top-back (parallel to Z)
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 (varying X and Y) // 4 edges parallel to Y-axis (scale X and Z - perpendicular axes)
edges.push( { 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)
position: new Vector3(paddedMax.x, paddedMax.y, midZ), { 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, type: HandleType.EDGE,
axes: ["X", "Y"], axes: edge.axes,
normal: new Vector3(1, 1, 0).normalize(), normal,
id: "edge-z-xy" id: edge.id
}, });
{
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"
} }
);
return edges; return edges;
} }
/** /**
* Generate all face handle positions (6 handles) * Generate all face handle positions (6 handles) on the OBB
* Faces are at centers of each face of the bounding box * Faces are at centers of each face of the oriented bounding box
*/ */
static generateFaceHandles(boundingBox: BoundingBox, padding: number = 0): HandlePosition[] { static generateFaceHandles(mesh: AbstractMesh, paddingFactor: number = 0): HandlePosition[] {
const min = boundingBox.minimumWorld; // Get OBB corners in world space
const max = boundingBox.maximumWorld; const c = this.calculateOBBCorners(mesh);
// Apply padding to position handles inward from bounding box edges // Get mesh center (pivot point)
const paddedMin = min.add(new Vector3(padding, padding, padding)); const center = mesh.absolutePosition;
const paddedMax = max.subtract(new Vector3(padding, padding, padding));
// Calculate midpoints // Calculate padding distance
const midX = (paddedMin.x + paddedMax.x) / 2; const boundingInfo = mesh.getBoundingInfo();
const midY = (paddedMin.y + paddedMax.y) / 2; const boundingBox = boundingInfo.boundingBox;
const midZ = (paddedMin.z + paddedMax.z) / 2; const size = boundingBox.extendSize;
const avgSize = (size.x + size.y + size.z) / 3;
const paddingDistance = avgSize * paddingFactor;
const faces: HandlePosition[] = []; const faces: HandlePosition[] = [];
// +X face (right) // Define the 6 faces as sets of 4 corner indices
faces.push({ const faceDefinitions = [
position: new Vector3(paddedMax.x, midY, midZ), { corners: [0, 1, 2, 3], axes: ["Y"], id: "face-Y" }, // Bottom face
type: HandleType.FACE, { corners: [4, 5, 6, 7], axes: ["Y"], id: "face-y" }, // Top face
axes: ["X"], { corners: [0, 1, 5, 4], axes: ["Z"], id: "face-Z" }, // Back face
normal: new Vector3(1, 0, 0), { corners: [2, 3, 7, 6], axes: ["Z"], id: "face-z" }, // Front face
id: "face-x" { 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) for (const face of faceDefinitions) {
faces.push({ // Calculate center of face (average of 4 corners)
position: new Vector3(paddedMin.x, midY, midZ), let faceCenter = Vector3.Zero();
type: HandleType.FACE, for (const cornerIdx of face.corners) {
axes: ["X"], faceCenter = faceCenter.add(c[cornerIdx]);
normal: new Vector3(-1, 0, 0), }
id: "face-X" faceCenter = faceCenter.scale(0.25);
});
// +Y face (top) // Calculate normal from center to face center
faces.push({ const normal = faceCenter.subtract(center).normalize();
position: new Vector3(midX, paddedMax.y, midZ),
type: HandleType.FACE,
axes: ["Y"],
normal: new Vector3(0, 1, 0),
id: "face-y"
});
// -Y face (bottom) // Apply padding by moving inward along the normal
faces.push({ const position = faceCenter.subtract(normal.scale(paddingDistance));
position: new Vector3(midX, paddedMin.y, midZ),
type: HandleType.FACE,
axes: ["Y"],
normal: new Vector3(0, -1, 0),
id: "face-Y"
});
// +Z face (front)
faces.push({ faces.push({
position: new Vector3(midX, midY, paddedMax.z), position,
type: HandleType.FACE, type: HandleType.FACE,
axes: ["Z"], axes: face.axes,
normal: new Vector3(0, 0, 1), normal,
id: "face-z" id: face.id
});
// -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"
}); });
}
return faces; return faces;
} }
/** /**
* Generate all handles based on mode flags * Generate all handles based on mode flags (OBB-based)
*/ */
static generateHandles( static generateHandles(
boundingBox: BoundingBox, mesh: AbstractMesh,
padding: number, paddingFactor: number,
includeCorners: boolean, includeCorners: boolean,
includeEdges: boolean, includeEdges: boolean,
includeFaces: boolean includeFaces: boolean
@ -259,26 +223,17 @@ export class HandleGeometry {
const handles: HandlePosition[] = []; const handles: HandlePosition[] = [];
if (includeCorners) { if (includeCorners) {
handles.push(...this.generateCornerHandles(boundingBox, padding)); handles.push(...this.generateCornerHandles(mesh, paddingFactor));
} }
if (includeEdges) { if (includeEdges) {
handles.push(...this.generateEdgeHandles(boundingBox, padding)); handles.push(...this.generateEdgeHandles(mesh, paddingFactor));
} }
if (includeFaces) { if (includeFaces) {
handles.push(...this.generateFaceHandles(boundingBox, padding)); handles.push(...this.generateFaceHandles(mesh, paddingFactor));
} }
return handles; 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;
}
} }

View File

@ -143,10 +143,10 @@ export class ResizeGizmoConfigManager {
/** /**
* Check if a mode uses edge handles * Check if a mode uses edge handles
* Edge handles are disabled to simplify UX
*/ */
usesEdgeHandles(): boolean { usesEdgeHandles(): boolean {
const mode = this._config.mode; return false;
return mode === ResizeGizmoMode.TWO_AXIS || mode === ResizeGizmoMode.ALL;
} }
/** /**

View File

@ -258,6 +258,14 @@ export class ResizeGizmoManager {
return this._interaction.isHoveringHandle(); 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 ===== // ===== Event System =====
/** /**

View File

@ -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[] { private generateHandlePositions(): HandlePosition[] {
if (!this._targetMesh) { if (!this._targetMesh) {
return []; return [];
} }
const boundingInfo = this._targetMesh.getBoundingInfo(); // Generate handles based on mode (using OBB)
const boundingBox = boundingInfo.boundingBox;
// Calculate padding
const padding = HandleGeometry.calculatePadding(
boundingBox,
this._config.current.boundingBoxPadding
);
// Generate handles based on mode
return HandleGeometry.generateHandles( return HandleGeometry.generateHandles(
boundingBox, this._targetMesh,
padding, this._config.current.boundingBoxPadding,
this._config.usesCornerHandles(), this._config.usesCornerHandles(),
this._config.usesEdgeHandles(), this._config.usesEdgeHandles(),
this._config.usesFaceHandles() 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 { private createBoundingBox(): void {
if (!this._targetMesh) { if (!this._targetMesh) {
@ -130,41 +156,32 @@ export class ResizeGizmoVisuals {
this.disposeBoundingBox(); this.disposeBoundingBox();
const boundingInfo = this._targetMesh.getBoundingInfo(); // Get OBB corners in world space
const boundingBox = boundingInfo.boundingBox; const corners = this.calculateOBBCorners();
const min = boundingBox.minimumWorld; if (corners.length !== 8) {
const max = boundingBox.maximumWorld; return;
}
// 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;
// Create line points for bounding box edges // Create line points for bounding box edges
// Using corner indices: 0-7 as defined in calculateOBBCorners
const points = [ const points = [
// Bottom face // Bottom face (y = min)
[paddedMin, new Vector3(paddedMax.x, paddedMin.y, paddedMin.z)], [corners[0], corners[1]], // left-back to right-back
[new Vector3(paddedMax.x, paddedMin.y, paddedMin.z), new Vector3(paddedMax.x, paddedMin.y, paddedMax.z)], [corners[1], corners[2]], // right-back to right-front
[new Vector3(paddedMax.x, paddedMin.y, paddedMax.z), new Vector3(paddedMin.x, paddedMin.y, paddedMax.z)], [corners[2], corners[3]], // right-front to left-front
[new Vector3(paddedMin.x, paddedMin.y, paddedMax.z), paddedMin], [corners[3], corners[0]], // left-front to left-back
// Top face // Top face (y = max)
[new Vector3(paddedMin.x, paddedMax.y, paddedMin.z), new Vector3(paddedMax.x, paddedMax.y, paddedMin.z)], [corners[4], corners[5]], // left-back to right-back
[new Vector3(paddedMax.x, paddedMax.y, paddedMin.z), paddedMax], [corners[5], corners[6]], // right-back to right-front
[paddedMax, new Vector3(paddedMin.x, paddedMax.y, paddedMax.z)], [corners[6], corners[7]], // right-front to left-front
[new Vector3(paddedMin.x, paddedMax.y, paddedMax.z), new Vector3(paddedMin.x, paddedMax.y, paddedMin.z)], [corners[7], corners[4]], // left-front to left-back
// Vertical edges // Vertical edges
[paddedMin, new Vector3(paddedMin.x, paddedMax.y, paddedMin.z)], [corners[0], corners[4]], // left-back bottom to top
[new Vector3(paddedMax.x, paddedMin.y, paddedMin.z), new Vector3(paddedMax.x, paddedMax.y, paddedMin.z)], [corners[1], corners[5]], // right-back bottom to top
[new Vector3(paddedMax.x, paddedMin.y, paddedMax.z), paddedMax], [corners[2], corners[6]], // right-front bottom to top
[new Vector3(paddedMin.x, paddedMin.y, paddedMax.z), new Vector3(paddedMin.x, paddedMax.y, paddedMax.z)] [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 // Create lines mesh
this._boundingBoxLines = MeshBuilder.CreateLineSystem( this._boundingBoxLines = MeshBuilder.CreateLineSystem(
"gizmo-boundingbox", "gizmo-boundingbox",

View File

@ -150,44 +150,17 @@ export class ScalingCalculator {
return newScale; return newScale;
} }
// Calculate vector from pivot to virtual points // Calculate distance from pivot to virtual points
const startVector = startVirtualPoint.subtract(boundingBoxCenter); const startDistance = Vector3.Distance(boundingBoxCenter, startVirtualPoint);
const currentVector = currentVirtualPoint.subtract(boundingBoxCenter); 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 axes = handle.axes;
const worldMatrix = mesh.getWorldMatrix();
// For each axis involved, calculate scale ratio based on projection
for (const axis of axes) { 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) { switch (axis) {
case "X": case "X":
newScale.x = startScale.x * scaleRatio; newScale.x = startScale.x * scaleRatio;

View File

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

View File

@ -15,10 +15,7 @@ export enum ResizeGizmoMode {
/** Only corner handles (8 handles) - uniform scaling all axes */ /** Only corner handles (8 handles) - uniform scaling all axes */
UNIFORM = "UNIFORM", UNIFORM = "UNIFORM",
/** Only edge-center handles (12 handles) - scale two axes simultaneously */ /** All handles enabled (14 total: 6 faces + 8 corners) - behavior depends on grabbed handle */
TWO_AXIS = "TWO_AXIS",
/** All handles enabled (26 total) - behavior depends on grabbed handle */
ALL = "ALL" ALL = "ALL"
} }