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) => {
|
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) {
|
||||||
|
|||||||
@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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,
|
||||||
position: new Vector3(midX, midY, paddedMax.z),
|
type: HandleType.FACE,
|
||||||
type: HandleType.FACE,
|
axes: face.axes,
|
||||||
axes: ["Z"],
|
normal,
|
||||||
normal: new Vector3(0, 0, 1),
|
id: face.id
|
||||||
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"
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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 =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
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 */
|
/** 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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user