Add face handles and transform tracking to ResizeGizmo
- Add 6 face handles for single-axis scaling (in addition to 8 corner handles for uniform scaling) - Implement single-axis scaling for face handles vs uniform scaling for corners - Add automatic handle position updates when target mesh moves or rotates - Track mesh transform changes using quaternions for accurate rotation detection - Update handles in real-time during scaling to match new bounding box - Add FACE_POSITIONS constant array to enums.ts - Fix handle sizing to use consistent size calculation for all handles 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
016b1fe6e2
commit
1ab3deae92
@ -4,6 +4,10 @@ import log from "loglevel";
|
||||
|
||||
export class DefaultScene {
|
||||
private static _Scene: Scene;
|
||||
private static _UtilityScene: Scene;
|
||||
public static get UtilityScene(): Scene {
|
||||
return this._UtilityScene;
|
||||
}
|
||||
|
||||
public static get Scene(): Scene {
|
||||
if (!DefaultScene._Scene) {
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
Observable,
|
||||
Observer,
|
||||
PickingInfo,
|
||||
Quaternion,
|
||||
Ray,
|
||||
Scene,
|
||||
StandardMaterial,
|
||||
@ -15,7 +16,7 @@ import {
|
||||
} from '@babylonjs/core';
|
||||
|
||||
import log from 'loglevel';
|
||||
import { HandleState, CORNER_POSITIONS} from './enums';
|
||||
import { HandleState, CORNER_POSITIONS, FACE_POSITIONS} from './enums';
|
||||
import { ResizeGizmoEvent } from './types';
|
||||
|
||||
/**
|
||||
@ -41,10 +42,16 @@ export class ResizeGizmo {
|
||||
private _isScaling: boolean = false;
|
||||
private _activeController: WebXRInputSource | null = null;
|
||||
private _activeHandle: AbstractMesh | null = null;
|
||||
private _activeHandleType: 'corner' | 'face' = 'corner';
|
||||
private _activeAxis: 'x' | 'y' | 'z' | null = null; // Only used for face handles
|
||||
private _originalStickLength: number = 0;
|
||||
private _originalHandleDistance: number = 0;
|
||||
private _initialScale: Vector3 | null = null;
|
||||
|
||||
// Track target mesh transform changes
|
||||
private _lastPosition: Vector3 | null = null;
|
||||
private _lastRotationQuaternion: Quaternion | null = null;
|
||||
|
||||
// Frame update observer
|
||||
private _frameObserver: Observer<Scene> | null = null;
|
||||
|
||||
@ -99,33 +106,33 @@ export class ResizeGizmo {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create 8 corner handles as 0.1 size cubes
|
||||
* Create corner and face handles
|
||||
*/
|
||||
private createHandles(): void {
|
||||
// Get bounding box for positioning
|
||||
|
||||
const targetBoundingInfo = this._targetMesh.getBoundingInfo();
|
||||
const boundingBox = targetBoundingInfo.boundingBox;
|
||||
|
||||
const bboxCenter = boundingBox.centerWorld;
|
||||
const extents = boundingBox.extendSize;
|
||||
const innerCorners = boundingBox.vectorsWorld;
|
||||
const worldMatrix = this._targetMesh.getWorldMatrix();
|
||||
|
||||
// Calculate handle size once (based on corner distance)
|
||||
const handleSize = innerCorners[0].subtract(bboxCenter).length() * .2;
|
||||
|
||||
// Create corner handles
|
||||
CORNER_POSITIONS.forEach((cornerDef, index) => {
|
||||
|
||||
const cornerPos = innerCorners[index];
|
||||
const size = cornerPos.subtract(boundingBox.centerWorld).length() * .2;
|
||||
|
||||
|
||||
const handleMesh = MeshBuilder.CreateBox(
|
||||
`resizeHandle_${cornerDef.name}`,
|
||||
{ size: size },
|
||||
{ size: handleSize },
|
||||
this._utilityLayer.utilityLayerScene
|
||||
);
|
||||
|
||||
// Position outward from center so handle corner touches bounding box corner
|
||||
// Cube diagonal = size * sqrt(3), so half diagonal = size * sqrt(3) / 2
|
||||
const direction = cornerPos.subtract(boundingBox.centerWorld).normalize();
|
||||
const offset = direction.scale(size * Math.sqrt(3) / 2);
|
||||
const direction = cornerPos.subtract(bboxCenter).normalize();
|
||||
const offset = direction.scale(handleSize * Math.sqrt(3) / 2);
|
||||
handleMesh.position = cornerPos.add(offset);
|
||||
handleMesh.rotationQuaternion = this._targetMesh.absoluteRotationQuaternion;
|
||||
handleMesh.material = this._handleMaterial;
|
||||
@ -134,7 +141,34 @@ export class ResizeGizmo {
|
||||
this._handles.push(handleMesh);
|
||||
});
|
||||
|
||||
this._logger.debug(`Created ${this._handles.length} corner handles`);
|
||||
// Create face handles
|
||||
FACE_POSITIONS.forEach((faceDef) => {
|
||||
// Calculate face center position in world space
|
||||
const localFacePos = new Vector3(
|
||||
faceDef.position.x * extents.x,
|
||||
faceDef.position.y * extents.y,
|
||||
faceDef.position.z * extents.z
|
||||
);
|
||||
const faceCenterWorld = Vector3.TransformCoordinates(localFacePos, worldMatrix);
|
||||
|
||||
const handleMesh = MeshBuilder.CreateBox(
|
||||
`resizeHandle_${faceDef.name}`,
|
||||
{ size: handleSize },
|
||||
this._utilityLayer.utilityLayerScene
|
||||
);
|
||||
|
||||
// Position outward from center so handle touches face center
|
||||
const direction = faceCenterWorld.subtract(bboxCenter).normalize();
|
||||
const offset = direction.scale(handleSize * Math.sqrt(3) / 2);
|
||||
handleMesh.position = faceCenterWorld.add(offset);
|
||||
handleMesh.rotationQuaternion = this._targetMesh.absoluteRotationQuaternion;
|
||||
handleMesh.material = this._handleMaterial;
|
||||
handleMesh.isPickable = true;
|
||||
|
||||
this._handles.push(handleMesh);
|
||||
});
|
||||
|
||||
this._logger.debug(`Created ${this._handles.length} handles (8 corner + 6 face)`);
|
||||
}
|
||||
|
||||
|
||||
@ -142,6 +176,10 @@ export class ResizeGizmo {
|
||||
* Set up per-frame updates
|
||||
*/
|
||||
private setupFrameUpdates(): void {
|
||||
// Initialize position and rotation tracking
|
||||
this._lastPosition = this._targetMesh.absolutePosition.clone();
|
||||
this._lastRotationQuaternion = this._targetMesh.absoluteRotationQuaternion.clone();
|
||||
|
||||
this._frameObserver = this._scene.onBeforeRenderObservable.add(() => {
|
||||
// Check for handle picking with XR controllers
|
||||
this.checkXRControllerPicking();
|
||||
@ -149,6 +187,9 @@ export class ResizeGizmo {
|
||||
// Update scaling if active
|
||||
if (this._isScaling) {
|
||||
this.updateScaling();
|
||||
} else {
|
||||
// Only check for transform changes when not actively scaling
|
||||
this.checkTransformChanges();
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -275,6 +316,23 @@ export class ResizeGizmo {
|
||||
// Store initial scale
|
||||
this._initialScale = this._targetMesh.scaling.clone();
|
||||
|
||||
// Determine handle type and axis from handle name
|
||||
const handleName = this._hoveredHandle.name;
|
||||
if (handleName.includes('FACE_')) {
|
||||
this._activeHandleType = 'face';
|
||||
// Extract axis from face name (FACE_POS_X, FACE_NEG_Y, etc.)
|
||||
if (handleName.includes('_X')) {
|
||||
this._activeAxis = 'x';
|
||||
} else if (handleName.includes('_Y')) {
|
||||
this._activeAxis = 'y';
|
||||
} else if (handleName.includes('_Z')) {
|
||||
this._activeAxis = 'z';
|
||||
}
|
||||
} else {
|
||||
this._activeHandleType = 'corner';
|
||||
this._activeAxis = null;
|
||||
}
|
||||
|
||||
// Set scaling state
|
||||
this._isScaling = true;
|
||||
this._activeController = controller;
|
||||
@ -283,7 +341,7 @@ export class ResizeGizmo {
|
||||
// Change outline to blue to indicate grabbed state
|
||||
this._activeHandle.edgesColor = Color4.FromColor3(Color3.Blue());
|
||||
|
||||
this._logger.debug(`Scaling started: stickLength=${this._originalStickLength}, handleDistance=${this._originalHandleDistance}`);
|
||||
this._logger.debug(`Scaling started: type=${this._activeHandleType}, axis=${this._activeAxis}, stickLength=${this._originalStickLength}, handleDistance=${this._originalHandleDistance}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -297,11 +355,20 @@ export class ResizeGizmo {
|
||||
|
||||
// Snap scale to 0.1 increments
|
||||
const currentScale = this._targetMesh.scaling;
|
||||
const roundedScale = new Vector3(
|
||||
let roundedScale: Vector3;
|
||||
|
||||
if (this._activeHandleType === 'face' && this._activeAxis) {
|
||||
// Face handle: only round the active axis
|
||||
roundedScale = currentScale.clone();
|
||||
roundedScale[this._activeAxis] = Math.round(currentScale[this._activeAxis] * 10) / 10;
|
||||
} else {
|
||||
// Corner handle: round all axes
|
||||
roundedScale = new Vector3(
|
||||
Math.round(currentScale.x * 10) / 10,
|
||||
Math.round(currentScale.y * 10) / 10,
|
||||
Math.round(currentScale.z * 10) / 10
|
||||
);
|
||||
}
|
||||
|
||||
// Apply snapped scale
|
||||
this._targetMesh.scaling = roundedScale;
|
||||
@ -352,13 +419,101 @@ export class ResizeGizmo {
|
||||
// Calculate scale ratio
|
||||
const scaleRatio = newDistance / this._originalHandleDistance;
|
||||
|
||||
// Apply uniform scaling (smooth, no snapping yet)
|
||||
// Apply scaling based on handle type
|
||||
if (this._activeHandleType === 'face' && this._activeAxis) {
|
||||
// Face handle: scale only on the active axis
|
||||
const newScale = this._initialScale.clone();
|
||||
newScale[this._activeAxis] = this._initialScale[this._activeAxis] * scaleRatio;
|
||||
this._targetMesh.scaling = newScale;
|
||||
} else {
|
||||
// Corner handle: uniform scaling on all axes
|
||||
this._targetMesh.scaling = this._initialScale.scale(scaleRatio);
|
||||
}
|
||||
|
||||
// Update handle positions and sizes to match new bounding box
|
||||
this.updateHandleTransforms();
|
||||
|
||||
// Notify observers
|
||||
this.onScaleDrag.notifyObservers({ mesh: this._targetMesh });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if target mesh position or rotation has changed, and update handles if needed
|
||||
*/
|
||||
private checkTransformChanges(): void {
|
||||
if (!this._lastPosition || !this._lastRotationQuaternion) return;
|
||||
|
||||
const currentPosition = this._targetMesh.absolutePosition;
|
||||
const currentRotationQuaternion = this._targetMesh.absoluteRotationQuaternion;
|
||||
|
||||
// Check if position changed (using a small epsilon for floating point comparison)
|
||||
const positionChanged = !currentPosition.equalsWithEpsilon(this._lastPosition, 0.0001);
|
||||
|
||||
// Check if rotation changed
|
||||
const rotationChanged = !currentRotationQuaternion.equalsWithEpsilon(this._lastRotationQuaternion, 0.0001);
|
||||
|
||||
if (positionChanged || rotationChanged) {
|
||||
// Update handles to match new transform
|
||||
this.updateHandleTransforms();
|
||||
|
||||
// Update tracked values
|
||||
this._lastPosition = currentPosition.clone();
|
||||
this._lastRotationQuaternion = currentRotationQuaternion.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update handle positions and sizes to match current target mesh bounding box
|
||||
*/
|
||||
private updateHandleTransforms(): void {
|
||||
const targetBoundingInfo = this._targetMesh.getBoundingInfo();
|
||||
const boundingBox = targetBoundingInfo.boundingBox;
|
||||
const bboxCenter = boundingBox.centerWorld;
|
||||
const extents = boundingBox.extendSize;
|
||||
const innerCorners = boundingBox.vectorsWorld;
|
||||
const worldMatrix = this._targetMesh.getWorldMatrix();
|
||||
|
||||
// Recalculate handle size based on new bounding box
|
||||
const newHandleSize = innerCorners[0].subtract(bboxCenter).length() * .2;
|
||||
|
||||
let handleIndex = 0;
|
||||
|
||||
// Update corner handles (first 8 handles)
|
||||
for (let i = 0; i < CORNER_POSITIONS.length; i++) {
|
||||
const handle = this._handles[handleIndex];
|
||||
const cornerPos = innerCorners[i];
|
||||
|
||||
// Update position
|
||||
const direction = cornerPos.subtract(bboxCenter).normalize();
|
||||
const offset = direction.scale(newHandleSize * Math.sqrt(3) / 2);
|
||||
handle.position = cornerPos.add(offset);
|
||||
handle.rotationQuaternion = this._targetMesh.absoluteRotationQuaternion;
|
||||
|
||||
handleIndex++;
|
||||
}
|
||||
|
||||
// Update face handles (next 6 handles)
|
||||
for (const faceDef of FACE_POSITIONS) {
|
||||
const handle = this._handles[handleIndex];
|
||||
|
||||
// Calculate face center position in world space
|
||||
const localFacePos = new Vector3(
|
||||
faceDef.position.x * extents.x,
|
||||
faceDef.position.y * extents.y,
|
||||
faceDef.position.z * extents.z
|
||||
);
|
||||
const faceCenterWorld = Vector3.TransformCoordinates(localFacePos, worldMatrix);
|
||||
|
||||
// Update position
|
||||
const direction = faceCenterWorld.subtract(bboxCenter).normalize();
|
||||
const offset = direction.scale(newHandleSize * Math.sqrt(3) / 2);
|
||||
handle.position = faceCenterWorld.add(offset);
|
||||
handle.rotationQuaternion = this._targetMesh.absoluteRotationQuaternion;
|
||||
|
||||
handleIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a ray from an XR controller's pointer
|
||||
* @param controller - XR input source
|
||||
|
||||
@ -83,3 +83,40 @@ export const CORNER_POSITIONS: readonly HandlePositionDef[] = [
|
||||
description: 'Top-front-left (-X, +Y, +Z)'
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Face handle positions as static constants
|
||||
* Normalized coordinates have one axis at 0 (face center), others at -1 or +1
|
||||
*/
|
||||
export const FACE_POSITIONS: readonly HandlePositionDef[] = [
|
||||
{
|
||||
name: 'FACE_POS_X',
|
||||
position: { x: +1, y: 0, z: 0 },
|
||||
description: 'Right face (+X)'
|
||||
},
|
||||
{
|
||||
name: 'FACE_NEG_X',
|
||||
position: { x: -1, y: 0, z: 0 },
|
||||
description: 'Left face (-X)'
|
||||
},
|
||||
{
|
||||
name: 'FACE_POS_Y',
|
||||
position: { x: 0, y: +1, z: 0 },
|
||||
description: 'Top face (+Y)'
|
||||
},
|
||||
{
|
||||
name: 'FACE_NEG_Y',
|
||||
position: { x: 0, y: -1, z: 0 },
|
||||
description: 'Bottom face (-Y)'
|
||||
},
|
||||
{
|
||||
name: 'FACE_POS_Z',
|
||||
position: { x: 0, y: 0, z: +1 },
|
||||
description: 'Front face (+Z)'
|
||||
},
|
||||
{
|
||||
name: 'FACE_NEG_Z',
|
||||
position: { x: 0, y: 0, z: -1 },
|
||||
description: 'Back face (-Z)'
|
||||
},
|
||||
] as const;
|
||||
|
||||
@ -8,6 +8,6 @@
|
||||
*/
|
||||
|
||||
export { ResizeGizmo } from './ResizeGizmo';
|
||||
export type { ResizeGizmoEvent, HandleInfo } from './types';
|
||||
export type { ResizeGizmoEvent } from './types';
|
||||
export type { HandlePositionDef } from './enums';
|
||||
export { HandleType, HandleState, CORNER_POSITIONS } from './enums';
|
||||
export { HandleType, HandleState, CORNER_POSITIONS, FACE_POSITIONS } from './enums';
|
||||
|
||||
@ -1,15 +1,29 @@
|
||||
import {DefaultScene} from "../../defaultScene";
|
||||
import {ResizeGizmo} from "../../gizmos/ResizeGizmo";
|
||||
|
||||
export function addSceneInspector() {
|
||||
window.addEventListener("keydown", (ev) => {
|
||||
// Ctrl+Shift+I to open inspector
|
||||
if (ev.shiftKey && ev.ctrlKey && !ev.altKey && ev.keyCode === 73) {
|
||||
if (ev.ctrlKey) {
|
||||
|
||||
switch (ev.key) {
|
||||
case 'I':
|
||||
import ("@babylonjs/inspector").then((inspector) => {
|
||||
inspector.Inspector.Show(DefaultScene.Scene, {
|
||||
overlay: true,
|
||||
showExplorer: true
|
||||
});
|
||||
});
|
||||
break;
|
||||
case 'U':
|
||||
import ("@babylonjs/inspector").then((inspector) => {
|
||||
inspector.Inspector.Show(ResizeGizmo.utilityLayer.utilityLayerScene, {
|
||||
overlay: true,
|
||||
showExplorer: true
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/*import("@babylonjs/core/Debug").then(() => {
|
||||
import("@babylonjs/inspector").then(() => {
|
||||
const web = document.querySelector('#webApp');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user