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) => {
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) {

View File

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

View File

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

View File

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

View File

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

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[] {
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",

View File

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

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 */
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"
}