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:
parent
204ef670f9
commit
5fbf2b87c1
@ -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) {
|
||||
|
||||
@ -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();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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),
|
||||
position,
|
||||
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"
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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 =====
|
||||
|
||||
/**
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
51
src/gizmos/ResizeGizmo/TODO.md
Normal file
51
src/gizmos/ResizeGizmo/TODO.md
Normal 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`
|
||||
@ -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"
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user