Rebuild ResizeGizmo from scratch with simplified corner-only approach
Completely rewrote ResizeGizmo to be methodical and debuggable: - Created CORNER_POSITIONS static array with normalized coordinates - 8 corner handles only (removed face handles for simplicity) - Handle sizing based on bbox distance (20% of corner-to-center) - Handle positioning uses vectorsWorld directly - XR controller ray picking in utility layer - Edge rendering for hover (white) and grab (blue) states - Virtual stick scaling: fixed-length ray from controller - Uniform scaling based on distance ratio - Snap to 0.1 increments on release only - Proper XR input setup for existing and new controllers Key improvements: - Uses BabylonJS vectorsWorld instead of manual calculations - Cleaner separation of concerns (picking, input, scaling) - All private fields use underscore prefix convention - Better haptic feedback (hover pulse, grab pulse, release pulse) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
ebad30ce4d
commit
016b1fe6e2
@ -1,665 +1,439 @@
|
|||||||
import {
|
import {
|
||||||
AbstractMesh,
|
AbstractMesh,
|
||||||
Color3,
|
|
||||||
Mesh,
|
|
||||||
MeshBuilder,
|
MeshBuilder,
|
||||||
Observable,
|
Observable,
|
||||||
Observer,
|
Observer,
|
||||||
Ray, Scene,
|
PickingInfo,
|
||||||
|
Ray,
|
||||||
|
Scene,
|
||||||
StandardMaterial,
|
StandardMaterial,
|
||||||
|
Color3,
|
||||||
UtilityLayerRenderer,
|
UtilityLayerRenderer,
|
||||||
Vector3,
|
Vector3,
|
||||||
WebXRDefaultExperience,
|
WebXRDefaultExperience,
|
||||||
WebXRInputSource,
|
WebXRInputSource, Color4,
|
||||||
} from '@babylonjs/core';
|
} from '@babylonjs/core';
|
||||||
|
|
||||||
import log from 'loglevel';
|
import log from 'loglevel';
|
||||||
import { HandleType, HandleState } from './enums';
|
import { HandleState, CORNER_POSITIONS} from './enums';
|
||||||
import { ResizeGizmoEvent, HandleInfo } from './types';
|
import { ResizeGizmoEvent } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ResizeGizmo - Simple gizmo for resizing meshes in WebXR
|
* ResizeGizmo - Step 1: Corner Handles Only
|
||||||
*
|
*
|
||||||
* Features:
|
* Creates 8 cube handles (0.1 size) positioned at bounding box corners
|
||||||
* - 6 face handles for single-axis scaling
|
|
||||||
* - 8 corner handles for uniform scaling
|
|
||||||
* - XR controller grip interaction
|
|
||||||
* - Billboard scaling for constant screen-size handles
|
|
||||||
* - Renders in utility layer (separate from main scene)
|
|
||||||
*/
|
*/
|
||||||
export class ResizeGizmo {
|
export class ResizeGizmo {
|
||||||
|
|
||||||
private _scene: Scene;
|
private _scene: Scene;
|
||||||
|
private _utilityLayer: UtilityLayerRenderer;
|
||||||
private _xr: WebXRDefaultExperience;
|
private _xr: WebXRDefaultExperience;
|
||||||
private targetMesh: AbstractMesh;
|
private _targetMesh: AbstractMesh;
|
||||||
private utilityLayer: UtilityLayerRenderer;
|
private _logger = log.getLogger('ResizeGizmo');
|
||||||
private handles: HandleInfo[] = [];
|
|
||||||
private logger = log.getLogger('ResizeGizmo');
|
|
||||||
|
|
||||||
// Materials for different states
|
// Handle data
|
||||||
private normalMaterial: StandardMaterial;
|
private _handles: AbstractMesh[] = [];
|
||||||
private hoverMaterial: StandardMaterial;
|
private _handleMaterial: StandardMaterial;
|
||||||
private activeMaterial: StandardMaterial;
|
private _hoveredHandle: AbstractMesh | null = null;
|
||||||
|
private _hoveringController: WebXRInputSource | null = null;
|
||||||
|
|
||||||
// Interaction state
|
// Scaling state
|
||||||
private activeHandle: HandleInfo | null = null;
|
private _isScaling: boolean = false;
|
||||||
private activeController: WebXRInputSource | null = null;
|
private _activeController: WebXRInputSource | null = null;
|
||||||
|
private _activeHandle: AbstractMesh | null = null;
|
||||||
|
private _originalStickLength: number = 0;
|
||||||
|
private _originalHandleDistance: number = 0;
|
||||||
|
private _initialScale: Vector3 | null = null;
|
||||||
|
|
||||||
// Virtual Stick state
|
// Frame update observer
|
||||||
private originalStickLength: number = 0; // World-space distance from controller to handle at grip time
|
private _frameObserver: Observer<Scene> | null = null;
|
||||||
private initialLocalOffset: Vector3 | null = null; // Local-space offset from mesh center to handle center
|
|
||||||
private initialLocalDistance: number = 0; // Length of initial local offset
|
|
||||||
private initialScale: Vector3 | null = null; // Mesh scale at grip time
|
|
||||||
|
|
||||||
// Observables for events
|
// Observables for events
|
||||||
public onScaleDrag: Observable<ResizeGizmoEvent>;
|
public onScaleDrag: Observable<ResizeGizmoEvent>;
|
||||||
public onScaleEnd: Observable<ResizeGizmoEvent>;
|
public onScaleEnd: Observable<ResizeGizmoEvent>;
|
||||||
|
|
||||||
// Frame observers
|
// Static reference to utility layer
|
||||||
private beforeRenderObserver: Observer<any> | null = null;
|
public static utilityLayer: UtilityLayerRenderer;
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
private static readonly HANDLE_SIZE = 0.1;
|
private static readonly HANDLE_SIZE = 0.05;
|
||||||
private static readonly HANDLE_OFFSET = 0.05;
|
|
||||||
private static readonly BILLBOARD_SCALE_DISTANCE = 10; // Reference distance for billboard scaling
|
|
||||||
private static readonly SCALE_INCREMENT = 0.1;
|
|
||||||
private static readonly MIN_SCALE = 0.1;
|
|
||||||
|
|
||||||
constructor(targetMesh: AbstractMesh, xr: WebXRDefaultExperience) {
|
constructor(targetMesh: AbstractMesh, xr: WebXRDefaultExperience) {
|
||||||
this._scene = targetMesh.getScene();
|
this._scene = targetMesh.getScene();
|
||||||
this._xr = xr;
|
this._xr = xr;
|
||||||
this.targetMesh = targetMesh;
|
this._targetMesh = targetMesh;
|
||||||
|
|
||||||
|
// Initialize observables
|
||||||
this.onScaleDrag = new Observable<ResizeGizmoEvent>();
|
this.onScaleDrag = new Observable<ResizeGizmoEvent>();
|
||||||
this.onScaleEnd = new Observable<ResizeGizmoEvent>();
|
this.onScaleEnd = new Observable<ResizeGizmoEvent>();
|
||||||
|
|
||||||
this.logger.info(`Creating ResizeGizmo for mesh: ${targetMesh.name} (${targetMesh.id})`);
|
this._logger.info(`Creating ResizeGizmo for mesh: ${targetMesh.name} (${targetMesh.id})`);
|
||||||
|
|
||||||
// Create utility layer for rendering handles
|
// Create utility layer for rendering handles
|
||||||
this.utilityLayer = new UtilityLayerRenderer(this._scene);
|
this._utilityLayer = new UtilityLayerRenderer(this._scene);
|
||||||
this.utilityLayer.utilityLayerScene.autoClearDepthAndStencil = false;
|
this._utilityLayer.utilityLayerScene.autoClearDepthAndStencil = false;
|
||||||
|
ResizeGizmo.utilityLayer = this._utilityLayer;
|
||||||
|
|
||||||
// Create materials
|
// Create material
|
||||||
this.createMaterials();
|
this.createMaterial();
|
||||||
|
|
||||||
// Create handles
|
// Create 8 corner handles
|
||||||
this.createHandles();
|
this.createHandles();
|
||||||
|
|
||||||
this.logger.debug(`ResizeGizmo initialized with ${this.handles.length} handles (6 face + 8 corner)`);
|
this._logger.debug(`ResizeGizmo initialized with ${this._handles.length} corner handles`);
|
||||||
|
|
||||||
// Set up XR interaction
|
|
||||||
this.setupXRInteraction();
|
|
||||||
|
|
||||||
// Set up per-frame updates
|
// Set up per-frame updates
|
||||||
this.setupFrameUpdates();
|
this.setupFrameUpdates();
|
||||||
|
|
||||||
|
// Set up XR input handlers
|
||||||
|
this.setupXRInputHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create materials for handle states
|
* Create simple material for handles
|
||||||
*/
|
*/
|
||||||
private createMaterials(): void {
|
private createMaterial(): void {
|
||||||
// Normal state - Gray
|
this._handleMaterial = new StandardMaterial('resizeGizmoMaterial', this._utilityLayer.utilityLayerScene);
|
||||||
this.normalMaterial = new StandardMaterial('resizeGizmo_normal', this.utilityLayer.utilityLayerScene);
|
this._handleMaterial.diffuseColor = Color3.Yellow();
|
||||||
this.normalMaterial.diffuseColor = new Color3(0.5, 0.5, 0.5);
|
this._handleMaterial.emissiveColor = Color3.Yellow().scale(0.3);
|
||||||
this.normalMaterial.specularColor = new Color3(0.2, 0.2, 0.2);
|
|
||||||
|
|
||||||
// Hover state - White
|
|
||||||
this.hoverMaterial = new StandardMaterial('resizeGizmo_hover', this.utilityLayer.utilityLayerScene);
|
|
||||||
this.hoverMaterial.diffuseColor = new Color3(1, 1, 1);
|
|
||||||
this.hoverMaterial.specularColor = new Color3(0.3, 0.3, 0.3);
|
|
||||||
this.hoverMaterial.emissiveColor = new Color3(0.2, 0.2, 0.2);
|
|
||||||
|
|
||||||
// Active state - Blue
|
|
||||||
this.activeMaterial = new StandardMaterial('resizeGizmo_active', this.utilityLayer.utilityLayerScene);
|
|
||||||
this.activeMaterial.diffuseColor = new Color3(0.2, 0.5, 1);
|
|
||||||
this.activeMaterial.specularColor = new Color3(0.5, 0.7, 1);
|
|
||||||
this.activeMaterial.emissiveColor = new Color3(0.1, 0.3, 0.6);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create all handle meshes (6 face + 8 corner)
|
* Create 8 corner handles as 0.1 size cubes
|
||||||
*/
|
*/
|
||||||
private createHandles(): void {
|
private createHandles(): void {
|
||||||
// Face handles (single-axis scaling)
|
// Get bounding box for positioning
|
||||||
this.createFaceHandle(HandleType.FACE_POS_X, new Vector3(1, 0, 0));
|
|
||||||
this.createFaceHandle(HandleType.FACE_NEG_X, new Vector3(-1, 0, 0));
|
|
||||||
this.createFaceHandle(HandleType.FACE_POS_Y, new Vector3(0, 1, 0));
|
|
||||||
this.createFaceHandle(HandleType.FACE_NEG_Y, new Vector3(0, -1, 0));
|
|
||||||
this.createFaceHandle(HandleType.FACE_POS_Z, new Vector3(0, 0, 1));
|
|
||||||
this.createFaceHandle(HandleType.FACE_NEG_Z, new Vector3(0, 0, -1));
|
|
||||||
|
|
||||||
// Corner handles (uniform scaling)
|
const targetBoundingInfo = this._targetMesh.getBoundingInfo();
|
||||||
this.createCornerHandle(HandleType.CORNER_PPP, new Vector3(1, 1, 1));
|
const boundingBox = targetBoundingInfo.boundingBox;
|
||||||
this.createCornerHandle(HandleType.CORNER_PPN, new Vector3(1, 1, -1));
|
|
||||||
this.createCornerHandle(HandleType.CORNER_PNP, new Vector3(1, -1, 1));
|
|
||||||
this.createCornerHandle(HandleType.CORNER_PNN, new Vector3(1, -1, -1));
|
|
||||||
this.createCornerHandle(HandleType.CORNER_NPP, new Vector3(-1, 1, 1));
|
|
||||||
this.createCornerHandle(HandleType.CORNER_NPN, new Vector3(-1, 1, -1));
|
|
||||||
this.createCornerHandle(HandleType.CORNER_NNP, new Vector3(-1, -1, 1));
|
|
||||||
this.createCornerHandle(HandleType.CORNER_NNN, new Vector3(-1, -1, -1));
|
|
||||||
|
|
||||||
// Initial positioning
|
const innerCorners = boundingBox.vectorsWorld;
|
||||||
this.updateHandlePositions();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a face handle at the specified local offset direction
|
|
||||||
*/
|
|
||||||
private createFaceHandle(type: HandleType, direction: Vector3): void {
|
|
||||||
const handle = MeshBuilder.CreateBox(
|
|
||||||
`resizeHandle_${type}`,
|
|
||||||
{ size: ResizeGizmo.HANDLE_SIZE },
|
|
||||||
this.utilityLayer.utilityLayerScene
|
|
||||||
);
|
|
||||||
|
|
||||||
handle.material = this.normalMaterial;
|
CORNER_POSITIONS.forEach((cornerDef, index) => {
|
||||||
|
|
||||||
this.handles.push({
|
const cornerPos = innerCorners[index];
|
||||||
mesh: handle,
|
const size = cornerPos.subtract(boundingBox.centerWorld).length() * .2;
|
||||||
type,
|
|
||||||
state: HandleState.NORMAL,
|
|
||||||
material: this.normalMaterial,
|
const handleMesh = MeshBuilder.CreateBox(
|
||||||
localOffset: direction.clone(),
|
`resizeHandle_${cornerDef.name}`,
|
||||||
|
{ size: size },
|
||||||
|
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);
|
||||||
|
handleMesh.position = cornerPos.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} corner handles`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a corner handle at the specified local offset direction
|
|
||||||
*/
|
|
||||||
private createCornerHandle(type: HandleType, direction: Vector3): void {
|
|
||||||
const handle = MeshBuilder.CreateBox(
|
|
||||||
`resizeHandle_${type}`,
|
|
||||||
{ size: ResizeGizmo.HANDLE_SIZE },
|
|
||||||
this.utilityLayer.utilityLayerScene
|
|
||||||
);
|
|
||||||
|
|
||||||
handle.material = this.normalMaterial;
|
|
||||||
|
|
||||||
this.handles.push({
|
|
||||||
mesh: handle,
|
|
||||||
type,
|
|
||||||
state: HandleState.NORMAL,
|
|
||||||
material: this.normalMaterial,
|
|
||||||
localOffset: direction.clone().normalize(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update handle positions based on target mesh bounding box
|
|
||||||
*/
|
|
||||||
private updateHandlePositions(): void {
|
|
||||||
const boundingInfo = this.targetMesh.getBoundingInfo();
|
|
||||||
const boundingBox = boundingInfo.boundingBox;
|
|
||||||
|
|
||||||
// Get bounding box extents in local space
|
|
||||||
const extents = boundingBox.extendSize;
|
|
||||||
|
|
||||||
// Get target mesh world matrix and rotation
|
|
||||||
const worldMatrix = this.targetMesh.getWorldMatrix();
|
|
||||||
|
|
||||||
// Extract rotation from world matrix to handle all rotation types
|
|
||||||
const targetRotation = this.targetMesh.absoluteRotationQuaternion;
|
|
||||||
|
|
||||||
for (const handleInfo of this.handles) {
|
|
||||||
// Calculate position based on handle type
|
|
||||||
let localPos: Vector3;
|
|
||||||
|
|
||||||
if (handleInfo.type.startsWith('face_')) {
|
|
||||||
// Face handles: positioned at face centers
|
|
||||||
localPos = new Vector3(
|
|
||||||
handleInfo.localOffset.x * extents.x,
|
|
||||||
handleInfo.localOffset.y * extents.y,
|
|
||||||
handleInfo.localOffset.z * extents.z
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Corner handles: positioned at corners
|
|
||||||
localPos = new Vector3(
|
|
||||||
handleInfo.localOffset.x * extents.x,
|
|
||||||
handleInfo.localOffset.y * extents.y,
|
|
||||||
handleInfo.localOffset.z * extents.z
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add offset to move handle outside bounding box
|
|
||||||
const offsetDir = handleInfo.localOffset.clone().normalize();
|
|
||||||
localPos.addInPlace(offsetDir.scale(ResizeGizmo.HANDLE_SIZE / 2 + ResizeGizmo.HANDLE_OFFSET));
|
|
||||||
|
|
||||||
// Transform to world space
|
|
||||||
const worldPos = Vector3.TransformCoordinates(localPos, worldMatrix);
|
|
||||||
handleInfo.mesh.position = worldPos;
|
|
||||||
|
|
||||||
// Apply rotation to match target mesh orientation
|
|
||||||
handleInfo.mesh.rotationQuaternion = targetRotation.clone();
|
|
||||||
|
|
||||||
// Apply billboard scaling
|
|
||||||
this.applyBillboardScale(handleInfo.mesh);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply billboard scaling to maintain constant screen size
|
|
||||||
*/
|
|
||||||
private applyBillboardScale(handleMesh: Mesh): void {
|
|
||||||
const camera = this.utilityLayer.utilityLayerScene.activeCamera;
|
|
||||||
if (!camera) return;
|
|
||||||
|
|
||||||
const distance = Vector3.Distance(camera.position, handleMesh.position);
|
|
||||||
const scaleFactor = distance / ResizeGizmo.BILLBOARD_SCALE_DISTANCE;
|
|
||||||
|
|
||||||
handleMesh.scaling = new Vector3(scaleFactor, scaleFactor, scaleFactor);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set up XR controller interaction
|
|
||||||
*/
|
|
||||||
private setupXRInteraction(): void {
|
|
||||||
if (!this._xr) {
|
|
||||||
this.logger.error('No XR present');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const controllers = this._xr.input?.controllers?.values();
|
|
||||||
if (controllers) {
|
|
||||||
for (const controller of controllers) {
|
|
||||||
const motionController = controller.motionController;
|
|
||||||
const gripComponent = motionController.getComponent('xr-standard-squeeze');
|
|
||||||
if (gripComponent) {
|
|
||||||
this.logger.debug('Grip Component loaded');
|
|
||||||
gripComponent.onButtonStateChangedObservable.add((component) => {
|
|
||||||
if (component.pressed) {
|
|
||||||
this.onGripPressed(controller);
|
|
||||||
} else {
|
|
||||||
this.onGripReleased(controller);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this._xr.input.onControllerAddedObservable.add((controller) => {
|
|
||||||
const motionController = controller.motionController;
|
|
||||||
if (!motionController) return;
|
|
||||||
|
|
||||||
// Listen for grip button
|
|
||||||
const gripComponent = motionController.getComponent('squeeze');
|
|
||||||
if (gripComponent) {
|
|
||||||
gripComponent.onButtonStateChangedObservable.add((component) => {
|
|
||||||
if (component.pressed) {
|
|
||||||
this.onGripPressed(controller);
|
|
||||||
} else {
|
|
||||||
this.onGripReleased(controller);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Listen for controller added
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up per-frame updates
|
* Set up per-frame updates
|
||||||
*/
|
*/
|
||||||
private setupFrameUpdates(): void {
|
private setupFrameUpdates(): void {
|
||||||
this.beforeRenderObserver = this._scene.onBeforeRenderObservable.add(() => {
|
this._frameObserver = this._scene.onBeforeRenderObservable.add(() => {
|
||||||
this.updateFrame();
|
// Check for handle picking with XR controllers
|
||||||
|
this.checkXRControllerPicking();
|
||||||
|
|
||||||
|
// Update scaling if active
|
||||||
|
if (this._isScaling) {
|
||||||
|
this.updateScaling();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update each frame
|
* Check if XR controllers are pointing at any handles
|
||||||
*/
|
*/
|
||||||
private updateFrame(): void {
|
private checkXRControllerPicking(): void {
|
||||||
// Update active scaling first
|
if (!this._xr || !this._xr.input) return;
|
||||||
if (this.activeHandle && this.activeController) {
|
|
||||||
this.updateScaling();
|
|
||||||
// Don't update handle positions during active scaling to prevent feedback loop
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update handle positions (only when not actively scaling)
|
let newHoveredHandle: AbstractMesh | null = null;
|
||||||
this.updateHandlePositions();
|
let newController: WebXRInputSource | null = null;
|
||||||
|
// Check each controller
|
||||||
|
for (const controller of this._xr.input.controllers) {
|
||||||
|
const ray = this.getControllerRay(controller);
|
||||||
|
if (!ray) continue;
|
||||||
|
|
||||||
// Check for hover states
|
const pickInfo = this.pickHandle(ray);
|
||||||
this.updateHoverStates();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manually perform ray casting against utility layer handles
|
|
||||||
* Returns the closest handle hit by the controller's ray, or null
|
|
||||||
*/
|
|
||||||
private getHandleUnderPointer(controller: WebXRInputSource): HandleInfo | null {
|
|
||||||
// Get controller pointer and transform to world coordinates
|
|
||||||
const pointerWorldMatrix = controller.pointer.getWorldMatrix();
|
|
||||||
const pointerPos = Vector3.TransformCoordinates(Vector3.Zero(), pointerWorldMatrix);
|
|
||||||
const pointerForward = Vector3.TransformNormal(Vector3.Forward(), pointerWorldMatrix);
|
|
||||||
|
|
||||||
// Create ray from controller pointer in world space
|
|
||||||
const ray = new Ray(pointerPos, pointerForward, 50);
|
|
||||||
|
|
||||||
let closestHandle: HandleInfo | null = null;
|
|
||||||
let closestDistance = Infinity;
|
|
||||||
|
|
||||||
// Test ray against each handle mesh in utility layer
|
|
||||||
for (const handleInfo of this.handles) {
|
|
||||||
const pickInfo = ray.intersectsMesh(handleInfo.mesh);
|
|
||||||
|
|
||||||
if (pickInfo.hit && pickInfo.distance < closestDistance) {
|
|
||||||
closestDistance = pickInfo.distance;
|
|
||||||
closestHandle = handleInfo;
|
|
||||||
|
|
||||||
|
if (pickInfo && pickInfo.pickedMesh) {
|
||||||
|
newHoveredHandle = pickInfo.pickedMesh as AbstractMesh;
|
||||||
|
newController = controller;
|
||||||
|
break; // Use first hit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return closestHandle;
|
// Update hover state if changed
|
||||||
}
|
if (newHoveredHandle !== this._hoveredHandle) {
|
||||||
|
// Clear previous hover
|
||||||
/**
|
if (this._hoveredHandle) {
|
||||||
* Check which handle (if any) is being pointed at by XR controllers
|
this._hoveredHandle.disableEdgesRendering();
|
||||||
*/
|
|
||||||
private updateHoverStates(): void {
|
|
||||||
if (!this._xr || this.activeHandle) return; // Don't update hover during active scaling
|
|
||||||
|
|
||||||
// Reset all handles to normal
|
|
||||||
for (const handleInfo of this.handles) {
|
|
||||||
if (handleInfo.state === HandleState.HOVER) {
|
|
||||||
this.setHandleState(handleInfo, HandleState.NORMAL);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Check each controller with manual ray casting
|
// Set new hover
|
||||||
for (const controller of this._xr.input.controllers.values()) {
|
this._hoveredHandle = newHoveredHandle;
|
||||||
const handleInfo = this.getHandleUnderPointer(controller);
|
this._hoveringController = newController;
|
||||||
if (handleInfo) {
|
if (this._hoveredHandle && newController) {
|
||||||
//this.logger.debug(`Handle hover detected: ${handleInfo.type} by controller ${controller.uniqueId}`);
|
this._hoveredHandle.enableEdgesRendering();
|
||||||
this.setHandleState(handleInfo, HandleState.HOVER);
|
this._hoveredHandle.edgesWidth = .1;
|
||||||
|
this._hoveredHandle.edgesColor = Color4.FromColor3(Color3.White());
|
||||||
|
|
||||||
|
// Pulse only on first hover
|
||||||
|
newController.motionController.pulse(.2, 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle grip button pressed
|
* Set up XR input handlers for grab button
|
||||||
*/
|
*/
|
||||||
private onGripPressed(controller: WebXRInputSource): void {
|
private setupXRInputHandlers(): void {
|
||||||
this.logger.debug('GripPressed');
|
if (!this._xr || !this._xr.input) return;
|
||||||
if (this.activeHandle) return; // Already gripping
|
|
||||||
|
|
||||||
// Use manual ray casting to check for handle under pointer
|
// Hook up existing controllers
|
||||||
const handleInfo = this.getHandleUnderPointer(controller);
|
for (const controller of this._xr.input.controllers) {
|
||||||
if (!handleInfo) {
|
this.setupControllerInput(controller);
|
||||||
this.logger.debug(`Grip pressed but no handle under pointer (controller ${controller.uniqueId})`);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate Virtual Stick state at grip time
|
// Listen for new controllers
|
||||||
// 1. Get controller world position
|
this._xr.input.onControllerAddedObservable.add((controller) => {
|
||||||
const pointerWorldMatrix = controller.pointer.getWorldMatrix();
|
this.setupControllerInput(controller);
|
||||||
const controllerWorldPos = Vector3.TransformCoordinates(Vector3.Zero(), pointerWorldMatrix);
|
});
|
||||||
|
|
||||||
// 2. Get handle center world position (original "end of stick")
|
|
||||||
const handleWorldPos = handleInfo.mesh.position.clone();
|
|
||||||
|
|
||||||
// 3. Calculate original stick length in world space
|
|
||||||
this.originalStickLength = Vector3.Distance(controllerWorldPos, handleWorldPos);
|
|
||||||
|
|
||||||
// 4. Get target mesh center in world space
|
|
||||||
const meshWorldMatrix = this.targetMesh.getWorldMatrix();
|
|
||||||
const meshWorldCenter = Vector3.TransformCoordinates(Vector3.Zero(), meshWorldMatrix);
|
|
||||||
|
|
||||||
// 5. Calculate initial offset in local space (from mesh center to handle center)
|
|
||||||
const meshInverseMatrix = meshWorldMatrix.clone().invert();
|
|
||||||
const handleLocalPos = Vector3.TransformCoordinates(handleWorldPos, meshInverseMatrix);
|
|
||||||
this.initialLocalOffset = handleLocalPos.clone();
|
|
||||||
this.initialLocalDistance = handleLocalPos.length();
|
|
||||||
|
|
||||||
// 6. Store initial scale
|
|
||||||
this.initialScale = this.targetMesh.scaling.clone();
|
|
||||||
|
|
||||||
// Set active state
|
|
||||||
this.activeHandle = handleInfo;
|
|
||||||
this.activeController = controller;
|
|
||||||
|
|
||||||
this.logger.info(`Grip started on handle: ${handleInfo.type}`);
|
|
||||||
this.logger.debug(` Original stick length (world): ${this.originalStickLength.toFixed(3)}`);
|
|
||||||
this.logger.debug(` Initial local offset: ${this.initialLocalOffset.toString()}`);
|
|
||||||
this.logger.debug(` Initial local distance: ${this.initialLocalDistance.toFixed(3)}`);
|
|
||||||
this.logger.debug(` Initial scale: ${this.initialScale.toString()}`);
|
|
||||||
|
|
||||||
this.setHandleState(handleInfo, HandleState.ACTIVE);
|
|
||||||
|
|
||||||
// Haptic feedback
|
|
||||||
controller.motionController?.pulse(0.5, 100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle grip button released
|
* Set up input listeners for a controller
|
||||||
*/
|
*/
|
||||||
private onGripReleased(controller: WebXRInputSource): void {
|
private setupControllerInput(controller: WebXRInputSource): void {
|
||||||
if (!this.activeHandle || this.activeController !== controller) return;
|
// If motion controller is already initialized, set up immediately
|
||||||
|
if (controller.motionController) {
|
||||||
const handleType = this.activeHandle.type;
|
this.setupSqueezeButton(controller);
|
||||||
|
} else {
|
||||||
// Round scale to nearest 0.1 increment on release
|
// Otherwise wait for initialization
|
||||||
this.applyRoundedScale();
|
controller.onMotionControllerInitObservable.add(() => {
|
||||||
|
this.setupSqueezeButton(controller);
|
||||||
const finalScale = this.targetMesh.scaling.clone();
|
});
|
||||||
|
}
|
||||||
this.logger.info(`Grip released on handle: ${handleType}`);
|
|
||||||
this.logger.debug(` Final scale (after rounding): ${finalScale.toString()}`);
|
|
||||||
this.logger.debug(` Scale change: x=${(finalScale.x / this.initialScale!.x).toFixed(2)}, y=${(finalScale.y / this.initialScale!.y).toFixed(2)}, z=${(finalScale.z / this.initialScale!.z).toFixed(2)}`);
|
|
||||||
|
|
||||||
// End gripping
|
|
||||||
this.setHandleState(this.activeHandle, HandleState.NORMAL);
|
|
||||||
this.activeHandle = null;
|
|
||||||
this.activeController = null;
|
|
||||||
|
|
||||||
// Clear Virtual Stick state
|
|
||||||
this.originalStickLength = 0;
|
|
||||||
this.initialLocalOffset = null;
|
|
||||||
this.initialLocalDistance = 0;
|
|
||||||
this.initialScale = null;
|
|
||||||
|
|
||||||
// Fire onScaleEnd event
|
|
||||||
this.onScaleEnd.notifyObservers({ mesh: this.targetMesh });
|
|
||||||
|
|
||||||
// Haptic feedback
|
|
||||||
controller.motionController?.pulse(0.3, 50);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update scaling during active grip using Virtual Stick approach
|
* Set up squeeze button listener for a controller
|
||||||
|
*/
|
||||||
|
private setupSqueezeButton(controller: WebXRInputSource): void {
|
||||||
|
const squeezeComponent = controller.motionController?.getComponentOfType('squeeze');
|
||||||
|
if (squeezeComponent) {
|
||||||
|
squeezeComponent.onButtonStateChangedObservable.add((component) => {
|
||||||
|
if (component.pressed) {
|
||||||
|
this.onHandleGrabbed(controller);
|
||||||
|
} else {
|
||||||
|
this.onHandleReleased(controller);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a controller presses grab while hovering a handle
|
||||||
|
*/
|
||||||
|
private onHandleGrabbed(controller: WebXRInputSource): void {
|
||||||
|
// Only respond if this controller is hovering a handle
|
||||||
|
if (controller === this._hoveringController && this._hoveredHandle) {
|
||||||
|
this._logger.info(`Handle grabbed: ${this._hoveredHandle.name} by controller ${controller.uniqueId}`);
|
||||||
|
|
||||||
|
// Pulse feedback
|
||||||
|
controller.motionController.pulse(0.5, 50);
|
||||||
|
|
||||||
|
// Get controller world position
|
||||||
|
const pointerWorldMatrix = controller.pointer.getWorldMatrix();
|
||||||
|
const controllerWorldPos = Vector3.TransformCoordinates(Vector3.Zero(), pointerWorldMatrix);
|
||||||
|
|
||||||
|
// Get handle center world position
|
||||||
|
const handleWorldPos = this._hoveredHandle.position;
|
||||||
|
|
||||||
|
// Store original stick length (distance from controller to handle)
|
||||||
|
this._originalStickLength = Vector3.Distance(controllerWorldPos, handleWorldPos);
|
||||||
|
|
||||||
|
// Get bounding box center world position
|
||||||
|
const boundingInfo = this._targetMesh.getBoundingInfo();
|
||||||
|
const bboxCenterWorld = boundingInfo.boundingBox.centerWorld;
|
||||||
|
|
||||||
|
// Store original handle distance (distance from bbox center to handle)
|
||||||
|
this._originalHandleDistance = Vector3.Distance(bboxCenterWorld, handleWorldPos);
|
||||||
|
|
||||||
|
// Store initial scale
|
||||||
|
this._initialScale = this._targetMesh.scaling.clone();
|
||||||
|
|
||||||
|
// Set scaling state
|
||||||
|
this._isScaling = true;
|
||||||
|
this._activeController = controller;
|
||||||
|
this._activeHandle = this._hoveredHandle;
|
||||||
|
|
||||||
|
// 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a controller releases grab button
|
||||||
|
*/
|
||||||
|
private onHandleReleased(controller: WebXRInputSource): void {
|
||||||
|
// Only respond if this controller is the active scaling controller
|
||||||
|
if (controller === this._activeController && this._isScaling && this._initialScale) {
|
||||||
|
this._logger.info(`Handle released by controller ${controller.uniqueId}`);
|
||||||
|
|
||||||
|
// Snap scale to 0.1 increments
|
||||||
|
const currentScale = this._targetMesh.scaling;
|
||||||
|
const 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;
|
||||||
|
|
||||||
|
// Change outline back to white
|
||||||
|
if (this._activeHandle) {
|
||||||
|
this._activeHandle.edgesColor = Color4.FromColor3(Color3.White());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify observers
|
||||||
|
this.onScaleEnd.notifyObservers({ mesh: this._targetMesh });
|
||||||
|
|
||||||
|
// Clear scaling state
|
||||||
|
this._isScaling = false;
|
||||||
|
this._activeController = null;
|
||||||
|
this._activeHandle = null;
|
||||||
|
this._initialScale = null;
|
||||||
|
|
||||||
|
// Pulse feedback
|
||||||
|
controller.motionController.pulse(0.3, 30);
|
||||||
|
|
||||||
|
this._logger.debug(`Scaling ended: final scale=${roundedScale}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update scaling during active grab
|
||||||
|
* Called every frame while _isScaling is true
|
||||||
*/
|
*/
|
||||||
private updateScaling(): void {
|
private updateScaling(): void {
|
||||||
if (!this.activeHandle || !this.activeController || !this.initialLocalOffset || !this.initialScale) {
|
if (!this._activeController || !this._initialScale) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Calculate new "end of stick" position in world space
|
// Get controller world position and forward direction
|
||||||
const pointerWorldMatrix = this.activeController.pointer.getWorldMatrix();
|
const pointerWorldMatrix = this._activeController.pointer.getWorldMatrix();
|
||||||
const controllerWorldPos = Vector3.TransformCoordinates(Vector3.Zero(), pointerWorldMatrix);
|
const controllerWorldPos = Vector3.TransformCoordinates(Vector3.Zero(), pointerWorldMatrix);
|
||||||
const controllerWorldForward = Vector3.TransformNormal(Vector3.Forward(), pointerWorldMatrix);
|
const controllerForward = Vector3.TransformNormal(Vector3.Forward(), pointerWorldMatrix);
|
||||||
|
|
||||||
// Extend forward by original stick length (fixed length)
|
// Calculate virtual stick end position (fixed length from controller)
|
||||||
const newStickEndWorld = controllerWorldPos.add(controllerWorldForward.scale(this.originalStickLength));
|
const virtualStickEnd = controllerWorldPos.add(controllerForward.scale(this._originalStickLength));
|
||||||
|
|
||||||
// 2. Transform new stick-end position to target mesh's local space
|
// Get bounding box center world position
|
||||||
const meshWorldMatrix = this.targetMesh.getWorldMatrix();
|
const boundingInfo = this._targetMesh.getBoundingInfo();
|
||||||
const meshInverseMatrix = meshWorldMatrix.clone().invert();
|
const bboxCenterWorld = boundingInfo.boundingBox.centerWorld;
|
||||||
const newStickEndLocal = Vector3.TransformCoordinates(newStickEndWorld, meshInverseMatrix);
|
|
||||||
|
|
||||||
// 3. Calculate new distance in local space
|
// Calculate new distance from bbox center to virtual stick end
|
||||||
const newLocalDistance = newStickEndLocal.length();
|
const newDistance = Vector3.Distance(bboxCenterWorld, virtualStickEnd);
|
||||||
|
|
||||||
// 4. Calculate scale ratio (no rounding during drag for smooth scaling)
|
// Calculate scale ratio
|
||||||
const scaleRatio = newLocalDistance / this.initialLocalDistance;
|
const scaleRatio = newDistance / this._originalHandleDistance;
|
||||||
|
|
||||||
// 5. Apply scaling based on handle type
|
// Apply uniform scaling (smooth, no snapping yet)
|
||||||
if (this.activeHandle.type.startsWith('face_')) {
|
this._targetMesh.scaling = this._initialScale.scale(scaleRatio);
|
||||||
this.applySingleAxisScaling(scaleRatio, newStickEndLocal);
|
|
||||||
} else {
|
|
||||||
this.applyUniformScaling(scaleRatio);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fire onScaleDrag event
|
// Notify observers
|
||||||
this.onScaleDrag.notifyObservers({ mesh: this.targetMesh });
|
this.onScaleDrag.notifyObservers({ mesh: this._targetMesh });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply single-axis scaling from a face handle
|
* Get a ray from an XR controller's pointer
|
||||||
* Scales only the appropriate axis based on scale ratio
|
* @param controller - XR input source
|
||||||
|
* @returns Ray in world space, or null if controller has no pointer
|
||||||
*/
|
*/
|
||||||
private applySingleAxisScaling(scaleRatio: number, newStickEndLocal: Vector3): void {
|
private getControllerRay(controller: WebXRInputSource): Ray | null {
|
||||||
if (!this.activeHandle || !this.initialScale || !this.initialLocalOffset) return;
|
if (!controller.pointer) return null;
|
||||||
|
|
||||||
// Determine which axis to scale based on initial local offset
|
const pointerWorldMatrix = controller.pointer.getWorldMatrix();
|
||||||
const offset = this.initialLocalOffset;
|
const origin = Vector3.TransformCoordinates(Vector3.Zero(), pointerWorldMatrix);
|
||||||
let axis: 'x' | 'y' | 'z';
|
const forward = Vector3.TransformNormal(Vector3.Forward(), pointerWorldMatrix);
|
||||||
|
|
||||||
if (Math.abs(offset.x) > Math.abs(offset.y) && Math.abs(offset.x) > Math.abs(offset.z)) {
|
return new Ray(origin, forward, 50);
|
||||||
axis = 'x';
|
|
||||||
} else if (Math.abs(offset.y) > Math.abs(offset.z)) {
|
|
||||||
axis = 'y';
|
|
||||||
} else {
|
|
||||||
axis = 'z';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply scale ratio to the appropriate axis
|
|
||||||
const newScale = this.initialScale.clone();
|
|
||||||
newScale[axis] = Math.max(ResizeGizmo.MIN_SCALE, this.initialScale[axis] * scaleRatio);
|
|
||||||
|
|
||||||
this.logger.debug(`Single-axis scaling: axis=${axis.toUpperCase()}, ratio=${scaleRatio.toFixed(2)}, new scale=${newScale[axis].toFixed(2)}`);
|
|
||||||
|
|
||||||
this.targetMesh.scaling = newScale;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply uniform scaling from a corner handle
|
* Get target mesh
|
||||||
* Scales all axes uniformly based on scale ratio
|
|
||||||
*/
|
*/
|
||||||
private applyUniformScaling(scaleRatio: number): void {
|
public get targetMesh(): AbstractMesh {
|
||||||
if (!this.initialScale) return;
|
return this._targetMesh;
|
||||||
|
|
||||||
// Apply scale ratio uniformly to all axes
|
|
||||||
const newScale = this.initialScale.clone().scale(scaleRatio);
|
|
||||||
|
|
||||||
// Clamp to minimum
|
|
||||||
newScale.x = Math.max(ResizeGizmo.MIN_SCALE, newScale.x);
|
|
||||||
newScale.y = Math.max(ResizeGizmo.MIN_SCALE, newScale.y);
|
|
||||||
newScale.z = Math.max(ResizeGizmo.MIN_SCALE, newScale.z);
|
|
||||||
|
|
||||||
this.logger.debug(`Uniform scaling: ratio=${scaleRatio.toFixed(2)}, new scale=(${newScale.x.toFixed(2)}, ${newScale.y.toFixed(2)}, ${newScale.z.toFixed(2)})`);
|
|
||||||
|
|
||||||
this.targetMesh.scaling = newScale;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply rounded scale on grip release
|
* Test if a ray intersects any handle in the utility layer
|
||||||
* Face handles: round only the scaled axis
|
* @param ray - Ray to test (in world space)
|
||||||
* Corner handles: round uniformly on all axes
|
* @returns PickingInfo with the closest handle hit, or null if no hit
|
||||||
*/
|
*/
|
||||||
private applyRoundedScale(): void {
|
public pickHandle(ray: Ray): PickingInfo | null {
|
||||||
if (!this.activeHandle || !this.initialScale) return;
|
const pickResult = this._utilityLayer.utilityLayerScene.pickWithRay(ray);
|
||||||
|
|
||||||
const currentScale = this.targetMesh.scaling.clone();
|
if (pickResult && pickResult.hit && this._handles.includes(pickResult.pickedMesh as AbstractMesh)) {
|
||||||
const newScale = this.initialScale.clone();
|
return pickResult;
|
||||||
|
|
||||||
if (this.activeHandle.type.startsWith('face_')) {
|
|
||||||
// Face handle: round only the affected axis
|
|
||||||
const offset = this.initialLocalOffset!;
|
|
||||||
let axis: 'x' | 'y' | 'z';
|
|
||||||
|
|
||||||
// Determine which axis was scaled
|
|
||||||
if (Math.abs(offset.x) > Math.abs(offset.y) && Math.abs(offset.x) > Math.abs(offset.z)) {
|
|
||||||
axis = 'x';
|
|
||||||
} else if (Math.abs(offset.y) > Math.abs(offset.z)) {
|
|
||||||
axis = 'y';
|
|
||||||
} else {
|
|
||||||
axis = 'z';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate and round the ratio for this axis
|
|
||||||
const ratio = currentScale[axis] / this.initialScale[axis];
|
|
||||||
const roundedRatio = Math.round(ratio * 10) / 10;
|
|
||||||
|
|
||||||
// Apply rounded ratio
|
|
||||||
newScale[axis] = Math.max(ResizeGizmo.MIN_SCALE, this.initialScale[axis] * roundedRatio);
|
|
||||||
|
|
||||||
// Keep other axes unchanged
|
|
||||||
const otherAxes = ['x', 'y', 'z'].filter(a => a !== axis) as ('x' | 'y' | 'z')[];
|
|
||||||
otherAxes.forEach(a => newScale[a] = currentScale[a]);
|
|
||||||
|
|
||||||
this.logger.debug(`Rounding face handle: axis=${axis}, ratio=${ratio.toFixed(3)} → ${roundedRatio.toFixed(1)}`);
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// Corner handle: round uniformly
|
|
||||||
// Use average ratio across all axes
|
|
||||||
const avgRatio = (
|
|
||||||
(currentScale.x / this.initialScale.x) +
|
|
||||||
(currentScale.y / this.initialScale.y) +
|
|
||||||
(currentScale.z / this.initialScale.z)
|
|
||||||
) / 3;
|
|
||||||
|
|
||||||
const roundedRatio = Math.round(avgRatio * 10) / 10;
|
|
||||||
|
|
||||||
// Apply same rounded ratio to all axes
|
|
||||||
newScale.x = Math.max(ResizeGizmo.MIN_SCALE, this.initialScale.x * roundedRatio);
|
|
||||||
newScale.y = Math.max(ResizeGizmo.MIN_SCALE, this.initialScale.y * roundedRatio);
|
|
||||||
newScale.z = Math.max(ResizeGizmo.MIN_SCALE, this.initialScale.z * roundedRatio);
|
|
||||||
|
|
||||||
this.logger.debug(`Rounding corner handle: ratio=${avgRatio.toFixed(3)} → ${roundedRatio.toFixed(1)}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.targetMesh.scaling = newScale;
|
return null;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set handle state and update visual appearance
|
|
||||||
*/
|
|
||||||
private setHandleState(handleInfo: HandleInfo, state: HandleState): void {
|
|
||||||
handleInfo.state = state;
|
|
||||||
|
|
||||||
switch (state) {
|
|
||||||
case HandleState.NORMAL:
|
|
||||||
handleInfo.mesh.material = this.normalMaterial;
|
|
||||||
handleInfo.mesh.scaling = handleInfo.mesh.scaling.scale(1 / 1.2); // Reset scale
|
|
||||||
break;
|
|
||||||
case HandleState.HOVER:
|
|
||||||
handleInfo.mesh.material = this.hoverMaterial;
|
|
||||||
handleInfo.mesh.scaling = handleInfo.mesh.scaling.scale(1.2); // Slightly larger
|
|
||||||
break;
|
|
||||||
case HandleState.ACTIVE:
|
|
||||||
handleInfo.mesh.material = this.activeMaterial;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispose of the gizmo and clean up resources
|
* Dispose of the gizmo and clean up resources
|
||||||
*/
|
*/
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
this.logger.info(`Disposing ResizeGizmo for mesh: ${this.targetMesh.name} (${this.targetMesh.id})`);
|
this._logger.debug('Disposing ResizeGizmo');
|
||||||
|
|
||||||
// Remove observers
|
// Remove frame observer
|
||||||
if (this.beforeRenderObserver) {
|
if (this._frameObserver) {
|
||||||
this._scene.onBeforeRenderObservable.remove(this.beforeRenderObserver);
|
this._scene.onBeforeRenderObservable.remove(this._frameObserver);
|
||||||
this.beforeRenderObserver = null;
|
this._frameObserver = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear hover state
|
||||||
|
if (this._hoveredHandle) {
|
||||||
|
this._hoveredHandle.disableEdgesRendering();
|
||||||
|
this._hoveredHandle = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispose handles
|
// Dispose handles
|
||||||
for (const handleInfo of this.handles) {
|
for (const mesh of this._handles) {
|
||||||
handleInfo.mesh.dispose();
|
mesh.dispose();
|
||||||
}
|
}
|
||||||
this.handles = [];
|
this._handles = [];
|
||||||
|
|
||||||
// Dispose materials
|
// Dispose material
|
||||||
this.normalMaterial.dispose();
|
if (this._handleMaterial) {
|
||||||
this.hoverMaterial.dispose();
|
this._handleMaterial.dispose();
|
||||||
this.activeMaterial.dispose();
|
}
|
||||||
|
|
||||||
// Dispose utility layer
|
// Dispose utility layer
|
||||||
this.utilityLayer.dispose();
|
if (this._utilityLayer) {
|
||||||
|
this._utilityLayer.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
// Clear observables
|
// Clear observables
|
||||||
this.onScaleDrag.clear();
|
this.onScaleDrag.clear();
|
||||||
this.onScaleEnd.clear();
|
this.onScaleEnd.clear();
|
||||||
|
|
||||||
this._xr = null;
|
this._logger.info('ResizeGizmo disposed');
|
||||||
this._scene = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,6 @@ export enum HandleType {
|
|||||||
CORNER_NNP = 'corner_nnp', // (-X, -Y, +Z)
|
CORNER_NNP = 'corner_nnp', // (-X, -Y, +Z)
|
||||||
CORNER_NNN = 'corner_nnn', // (-X, -Y, -Z)
|
CORNER_NNN = 'corner_nnn', // (-X, -Y, -Z)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle state for visual feedback
|
* Handle state for visual feedback
|
||||||
*/
|
*/
|
||||||
@ -25,4 +24,62 @@ export enum HandleState {
|
|||||||
NORMAL = 'normal',
|
NORMAL = 'normal',
|
||||||
HOVER = 'hover',
|
HOVER = 'hover',
|
||||||
ACTIVE = 'active',
|
ACTIVE = 'active',
|
||||||
|
IDLE = 'idle'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle position definition with name and normalized coordinates
|
||||||
|
*/
|
||||||
|
export interface HandlePositionDef {
|
||||||
|
name: string;
|
||||||
|
position: { x: number; y: number; z: number };
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corner handle positions as static constants
|
||||||
|
* Index corresponds to BabylonJS boundingBox.vectorsWorld array
|
||||||
|
* Normalized coordinates are -1 or +1 on each axis (unit cube corners)
|
||||||
|
*/
|
||||||
|
export const CORNER_POSITIONS: readonly HandlePositionDef[] = [
|
||||||
|
{
|
||||||
|
name: 'CORNER_NNN',
|
||||||
|
position: { x: -1, y: -1, z: -1 },
|
||||||
|
description: 'Bottom-back-left (-X, -Y, -Z)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CORNER_PNN',
|
||||||
|
position: { x: +1, y: -1, z: -1 },
|
||||||
|
description: 'Bottom-back-right (+X, -Y, -Z)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CORNER_PNP',
|
||||||
|
position: { x: +1, y: -1, z: +1 },
|
||||||
|
description: 'Bottom-front-right (+X, -Y, +Z)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CORNER_NNP',
|
||||||
|
position: { x: -1, y: -1, z: +1 },
|
||||||
|
description: 'Bottom-front-left (-X, -Y, +Z)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CORNER_NPN',
|
||||||
|
position: { x: -1, y: +1, z: -1 },
|
||||||
|
description: 'Top-back-left (-X, +Y, -Z)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CORNER_PPN',
|
||||||
|
position: { x: +1, y: +1, z: -1 },
|
||||||
|
description: 'Top-back-right (+X, +Y, -Z)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CORNER_PPP',
|
||||||
|
position: { x: +1, y: +1, z: +1 },
|
||||||
|
description: 'Top-front-right (+X, +Y, +Z)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CORNER_NPP',
|
||||||
|
position: { x: -1, y: +1, z: +1 },
|
||||||
|
description: 'Top-front-left (-X, +Y, +Z)'
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|||||||
@ -2,12 +2,12 @@
|
|||||||
* ResizeGizmo Module
|
* ResizeGizmo Module
|
||||||
*
|
*
|
||||||
* A simple WebXR gizmo for resizing meshes with:
|
* A simple WebXR gizmo for resizing meshes with:
|
||||||
* - 6 face handles for single-axis scaling
|
|
||||||
* - 8 corner handles for uniform scaling
|
* - 8 corner handles for uniform scaling
|
||||||
* - Manual ray casting for utility layer interaction
|
* - Manual ray casting for utility layer interaction
|
||||||
* - Billboard scaling for constant screen-size handles
|
* - Normalized position vectors for handle placement
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { ResizeGizmo } from './ResizeGizmo';
|
export { ResizeGizmo } from './ResizeGizmo';
|
||||||
export type { ResizeGizmoEvent, HandleInfo } from './types';
|
export type { ResizeGizmoEvent, HandleInfo } from './types';
|
||||||
export { HandleType, HandleState } from './enums';
|
export type { HandlePositionDef } from './enums';
|
||||||
|
export { HandleType, HandleState, CORNER_POSITIONS } from './enums';
|
||||||
|
|||||||
@ -1,21 +1,6 @@
|
|||||||
import { AbstractMesh, Mesh, StandardMaterial, Vector3 } from '@babylonjs/core';
|
import { AbstractMesh } from '@babylonjs/core';
|
||||||
import { HandleType, HandleState } from './enums';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Event emitted during and after scaling operations
|
|
||||||
*/
|
|
||||||
export interface ResizeGizmoEvent {
|
export interface ResizeGizmoEvent {
|
||||||
mesh: AbstractMesh;
|
mesh: AbstractMesh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Information about a handle
|
|
||||||
*/
|
|
||||||
export interface HandleInfo {
|
|
||||||
mesh: Mesh;
|
|
||||||
type: HandleType;
|
|
||||||
state: HandleState;
|
|
||||||
material: StandardMaterial;
|
|
||||||
/** Local space offset from target center for positioning */
|
|
||||||
localOffset: Vector3;
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user