Implement Virtual Stick scaling with modular ResizeGizmo architecture
Refactored ResizeGizmo into modular structure: - ResizeGizmo.ts: Main implementation with Virtual Stick scaling - enums.ts: HandleType and HandleState enums - types.ts: TypeScript interfaces - index.ts: Barrel exports Implemented Virtual Stick scaling approach: - Fixed-length virtual stick extends from controller forward - Scaling based on distance ratio in mesh local space - World-to-local coordinate transforms for proper rotation handling - Smooth continuous scaling during drag (no rounding) - Snap to 0.1 increments on grip release - Face handles: round only scaled axis - Corner handles: round uniformly on all axes Fixed scaling oscillation issues: - Freeze handle position updates during active scaling - Prevents feedback loop between scaling and handle positioning - Use absoluteRotationQuaternion for proper handle rotation Added WebXRDefaultExperience parameter to constructor for proper controller integration with manual ray casting in world space. Added test shortcuts: - Ctrl+Shift+T: Create test entities (sphere and box) - Ctrl+Shift+X: Clear all entities Wired Close button to dispose active ResizeGizmo. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
0712abe729
commit
ebad30ce4d
@ -4,6 +4,9 @@ import {Rigplatform} from "./rigplatform";
|
||||
import {DiagramManager} from "../diagram/diagramManager";
|
||||
import {wheelHandler} from "./functions/wheelHandler";
|
||||
import log, {Logger} from "loglevel";
|
||||
import {DiagramEntityType, DiagramEventType, DiagramTemplates} from "../diagram/types/diagramEntity";
|
||||
import {DiagramEventObserverMask} from "../diagram/types/diagramEventObserverMask";
|
||||
import {getToolboxColors} from "../toolbox/toolbox";
|
||||
|
||||
export class WebController {
|
||||
private readonly scene: Scene;
|
||||
@ -94,6 +97,18 @@ export class WebController {
|
||||
|
||||
*/
|
||||
break;
|
||||
case "T":
|
||||
// Ctrl+Shift+T: Create test entities (sphere and box)
|
||||
if (kbInfo.event.ctrlKey && kbInfo.event.shiftKey) {
|
||||
this.createTestEntities();
|
||||
}
|
||||
break;
|
||||
case "X":
|
||||
// Ctrl+Shift+X: Clear all entities from diagram
|
||||
if (kbInfo.event.ctrlKey && kbInfo.event.shiftKey) {
|
||||
this.clearAllEntities();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
|
||||
this.logger.debug(kbInfo.event);
|
||||
@ -240,4 +255,57 @@ export class WebController {
|
||||
}
|
||||
this._mesh = mesh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test entities for testing ResizeGizmo
|
||||
* Creates a sphere at (-0.25, 1.5, 4) and a box at (0.25, 1.5, 4)
|
||||
*/
|
||||
private createTestEntities(): void {
|
||||
this.logger.info('Creating test entities (Ctrl+Shift+T)');
|
||||
|
||||
// Get first color from toolbox colors array
|
||||
const firstColor = getToolboxColors()[0];
|
||||
const colorHex = firstColor.replace('#', '');
|
||||
|
||||
// Create sphere
|
||||
this.diagramManager.onDiagramEventObservable.notifyObservers({
|
||||
type: DiagramEventType.ADD,
|
||||
entity: {
|
||||
id: `test-sphere-${colorHex}`,
|
||||
type: DiagramEntityType.ENTITY,
|
||||
template: DiagramTemplates.SPHERE,
|
||||
position: { x: -0.25, y: 1.5, z: 4 },
|
||||
scale: { x: 0.1, y: 0.1, z: 0.1 },
|
||||
color: firstColor
|
||||
}
|
||||
}, DiagramEventObserverMask.ALL);
|
||||
|
||||
// Create box
|
||||
this.diagramManager.onDiagramEventObservable.notifyObservers({
|
||||
type: DiagramEventType.ADD,
|
||||
entity: {
|
||||
id: `test-box-${colorHex}`,
|
||||
type: DiagramEntityType.ENTITY,
|
||||
template: DiagramTemplates.BOX,
|
||||
position: { x: 0.25, y: 1.5, z: 4 },
|
||||
scale: { x: 0.1, y: 0.1, z: 0.1 },
|
||||
color: firstColor
|
||||
}
|
||||
}, DiagramEventObserverMask.ALL);
|
||||
|
||||
this.logger.info(`Test entities created with color ${firstColor}: test-sphere-${colorHex} at (-0.25, 1.5, 4) and test-box-${colorHex} at (0.25, 1.5, 4)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all entities from the diagram
|
||||
*/
|
||||
private clearAllEntities(): void {
|
||||
this.logger.info('Clearing all entities from diagram (Ctrl+Shift+X)');
|
||||
|
||||
this.diagramManager.onDiagramEventObservable.notifyObservers({
|
||||
type: DiagramEventType.CLEAR
|
||||
}, DiagramEventObserverMask.TO_DB);
|
||||
|
||||
this.logger.info('All entities cleared from diagram');
|
||||
}
|
||||
}
|
||||
@ -111,10 +111,6 @@ export class DiagramManager {
|
||||
return this._diagramEntityActionManager;
|
||||
}
|
||||
|
||||
public get diagramMenuManager(): DiagramMenuManager {
|
||||
return this._diagramMenuManager;
|
||||
}
|
||||
|
||||
public getDiagramObject(id: string) {
|
||||
return this._diagramObjects.get(id);
|
||||
}
|
||||
@ -147,6 +143,7 @@ export class DiagramManager {
|
||||
switch (event.type) {
|
||||
case DiagramEventType.CLEAR:
|
||||
this._diagramObjects.forEach((value) => {
|
||||
|
||||
value.dispose();
|
||||
});
|
||||
this._diagramObjects.clear();
|
||||
|
||||
@ -23,6 +23,7 @@ export class DiagramMenuManager {
|
||||
private _logger = log.getLogger('DiagramMenuManager');
|
||||
private _connectionPreview: ConnectionPreview;
|
||||
private _activeResizeGizmo: ResizeGizmo | null = null;
|
||||
private _xr: WebXRDefaultExperience | null = null;
|
||||
|
||||
constructor(notifier: Observable<DiagramEvent>, controllerObservable: Observable<ControllerEvent>, readyObservable: Observable<boolean>) {
|
||||
this._scene = DefaultScene.Scene;
|
||||
@ -92,8 +93,14 @@ export class DiagramMenuManager {
|
||||
this._activeResizeGizmo = null;
|
||||
}
|
||||
|
||||
// XR must be available to create resize gizmo
|
||||
if (!this._xr) {
|
||||
this._logger.warn('Cannot activate resize gizmo: XR not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new resize gizmo for the mesh
|
||||
this._activeResizeGizmo = new ResizeGizmo(mesh);
|
||||
this._activeResizeGizmo = new ResizeGizmo(mesh, this._xr);
|
||||
|
||||
// Listen for scale end event to notify diagram manager
|
||||
this._activeResizeGizmo.onScaleEnd.add(() => {
|
||||
@ -135,9 +142,9 @@ export class DiagramMenuManager {
|
||||
case "group":
|
||||
this._groupMenu = new GroupMenu(clickMenu.mesh);
|
||||
break;
|
||||
// case "close":
|
||||
// // DISCONNECTED - Ready for new scaling implementation
|
||||
// break;
|
||||
case "close":
|
||||
this.disposeResizeGizmo();
|
||||
break;
|
||||
}
|
||||
this._logger.debug(evt);
|
||||
|
||||
@ -151,6 +158,7 @@ export class DiagramMenuManager {
|
||||
}
|
||||
|
||||
public setXR(xr: WebXRDefaultExperience): void {
|
||||
this._xr = xr;
|
||||
this.toolbox.setXR(xr);
|
||||
}
|
||||
}
|
||||
665
src/gizmos/ResizeGizmo/ResizeGizmo.ts
Normal file
665
src/gizmos/ResizeGizmo/ResizeGizmo.ts
Normal file
@ -0,0 +1,665 @@
|
||||
import {
|
||||
AbstractMesh,
|
||||
Color3,
|
||||
Mesh,
|
||||
MeshBuilder,
|
||||
Observable,
|
||||
Observer,
|
||||
Ray, Scene,
|
||||
StandardMaterial,
|
||||
UtilityLayerRenderer,
|
||||
Vector3,
|
||||
WebXRDefaultExperience,
|
||||
WebXRInputSource,
|
||||
} from '@babylonjs/core';
|
||||
|
||||
import log from 'loglevel';
|
||||
import { HandleType, HandleState } from './enums';
|
||||
import { ResizeGizmoEvent, HandleInfo } from './types';
|
||||
|
||||
/**
|
||||
* ResizeGizmo - Simple gizmo for resizing meshes in WebXR
|
||||
*
|
||||
* Features:
|
||||
* - 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 {
|
||||
private _scene: Scene;
|
||||
private _xr: WebXRDefaultExperience;
|
||||
private targetMesh: AbstractMesh;
|
||||
private utilityLayer: UtilityLayerRenderer;
|
||||
private handles: HandleInfo[] = [];
|
||||
private logger = log.getLogger('ResizeGizmo');
|
||||
|
||||
// Materials for different states
|
||||
private normalMaterial: StandardMaterial;
|
||||
private hoverMaterial: StandardMaterial;
|
||||
private activeMaterial: StandardMaterial;
|
||||
|
||||
// Interaction state
|
||||
private activeHandle: HandleInfo | null = null;
|
||||
private activeController: WebXRInputSource | null = null;
|
||||
|
||||
// Virtual Stick state
|
||||
private originalStickLength: number = 0; // World-space distance from controller to handle at grip time
|
||||
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
|
||||
public onScaleDrag: Observable<ResizeGizmoEvent>;
|
||||
public onScaleEnd: Observable<ResizeGizmoEvent>;
|
||||
|
||||
// Frame observers
|
||||
private beforeRenderObserver: Observer<any> | null = null;
|
||||
|
||||
// Constants
|
||||
private static readonly HANDLE_SIZE = 0.1;
|
||||
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) {
|
||||
this._scene = targetMesh.getScene();
|
||||
this._xr = xr;
|
||||
this.targetMesh = targetMesh;
|
||||
this.onScaleDrag = new Observable<ResizeGizmoEvent>();
|
||||
this.onScaleEnd = new Observable<ResizeGizmoEvent>();
|
||||
|
||||
this.logger.info(`Creating ResizeGizmo for mesh: ${targetMesh.name} (${targetMesh.id})`);
|
||||
|
||||
// Create utility layer for rendering handles
|
||||
this.utilityLayer = new UtilityLayerRenderer(this._scene);
|
||||
this.utilityLayer.utilityLayerScene.autoClearDepthAndStencil = false;
|
||||
|
||||
// Create materials
|
||||
this.createMaterials();
|
||||
|
||||
// Create handles
|
||||
this.createHandles();
|
||||
|
||||
this.logger.debug(`ResizeGizmo initialized with ${this.handles.length} handles (6 face + 8 corner)`);
|
||||
|
||||
// Set up XR interaction
|
||||
this.setupXRInteraction();
|
||||
|
||||
// Set up per-frame updates
|
||||
this.setupFrameUpdates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create materials for handle states
|
||||
*/
|
||||
private createMaterials(): void {
|
||||
// Normal state - Gray
|
||||
this.normalMaterial = new StandardMaterial('resizeGizmo_normal', this.utilityLayer.utilityLayerScene);
|
||||
this.normalMaterial.diffuseColor = new Color3(0.5, 0.5, 0.5);
|
||||
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)
|
||||
*/
|
||||
private createHandles(): void {
|
||||
// Face handles (single-axis scaling)
|
||||
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)
|
||||
this.createCornerHandle(HandleType.CORNER_PPP, new Vector3(1, 1, 1));
|
||||
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
|
||||
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;
|
||||
|
||||
this.handles.push({
|
||||
mesh: handle,
|
||||
type,
|
||||
state: HandleState.NORMAL,
|
||||
material: this.normalMaterial,
|
||||
localOffset: direction.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
private setupFrameUpdates(): void {
|
||||
this.beforeRenderObserver = this._scene.onBeforeRenderObservable.add(() => {
|
||||
this.updateFrame();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update each frame
|
||||
*/
|
||||
private updateFrame(): void {
|
||||
// Update active scaling first
|
||||
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)
|
||||
this.updateHandlePositions();
|
||||
|
||||
// Check for hover states
|
||||
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;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return closestHandle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check which handle (if any) is being pointed at by XR controllers
|
||||
*/
|
||||
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
|
||||
for (const controller of this._xr.input.controllers.values()) {
|
||||
const handleInfo = this.getHandleUnderPointer(controller);
|
||||
if (handleInfo) {
|
||||
//this.logger.debug(`Handle hover detected: ${handleInfo.type} by controller ${controller.uniqueId}`);
|
||||
this.setHandleState(handleInfo, HandleState.HOVER);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle grip button pressed
|
||||
*/
|
||||
private onGripPressed(controller: WebXRInputSource): void {
|
||||
this.logger.debug('GripPressed');
|
||||
if (this.activeHandle) return; // Already gripping
|
||||
|
||||
// Use manual ray casting to check for handle under pointer
|
||||
const handleInfo = this.getHandleUnderPointer(controller);
|
||||
if (!handleInfo) {
|
||||
this.logger.debug(`Grip pressed but no handle under pointer (controller ${controller.uniqueId})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate Virtual Stick state at grip time
|
||||
// 1. Get controller world position
|
||||
const pointerWorldMatrix = controller.pointer.getWorldMatrix();
|
||||
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
|
||||
*/
|
||||
private onGripReleased(controller: WebXRInputSource): void {
|
||||
if (!this.activeHandle || this.activeController !== controller) return;
|
||||
|
||||
const handleType = this.activeHandle.type;
|
||||
|
||||
// Round scale to nearest 0.1 increment on release
|
||||
this.applyRoundedScale();
|
||||
|
||||
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
|
||||
*/
|
||||
private updateScaling(): void {
|
||||
if (!this.activeHandle || !this.activeController || !this.initialLocalOffset || !this.initialScale) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Calculate new "end of stick" position in world space
|
||||
const pointerWorldMatrix = this.activeController.pointer.getWorldMatrix();
|
||||
const controllerWorldPos = Vector3.TransformCoordinates(Vector3.Zero(), pointerWorldMatrix);
|
||||
const controllerWorldForward = Vector3.TransformNormal(Vector3.Forward(), pointerWorldMatrix);
|
||||
|
||||
// Extend forward by original stick length (fixed length)
|
||||
const newStickEndWorld = controllerWorldPos.add(controllerWorldForward.scale(this.originalStickLength));
|
||||
|
||||
// 2. Transform new stick-end position to target mesh's local space
|
||||
const meshWorldMatrix = this.targetMesh.getWorldMatrix();
|
||||
const meshInverseMatrix = meshWorldMatrix.clone().invert();
|
||||
const newStickEndLocal = Vector3.TransformCoordinates(newStickEndWorld, meshInverseMatrix);
|
||||
|
||||
// 3. Calculate new distance in local space
|
||||
const newLocalDistance = newStickEndLocal.length();
|
||||
|
||||
// 4. Calculate scale ratio (no rounding during drag for smooth scaling)
|
||||
const scaleRatio = newLocalDistance / this.initialLocalDistance;
|
||||
|
||||
// 5. Apply scaling based on handle type
|
||||
if (this.activeHandle.type.startsWith('face_')) {
|
||||
this.applySingleAxisScaling(scaleRatio, newStickEndLocal);
|
||||
} else {
|
||||
this.applyUniformScaling(scaleRatio);
|
||||
}
|
||||
|
||||
// Fire onScaleDrag event
|
||||
this.onScaleDrag.notifyObservers({ mesh: this.targetMesh });
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply single-axis scaling from a face handle
|
||||
* Scales only the appropriate axis based on scale ratio
|
||||
*/
|
||||
private applySingleAxisScaling(scaleRatio: number, newStickEndLocal: Vector3): void {
|
||||
if (!this.activeHandle || !this.initialScale || !this.initialLocalOffset) return;
|
||||
|
||||
// Determine which axis to scale based on initial local offset
|
||||
const offset = this.initialLocalOffset;
|
||||
let axis: 'x' | 'y' | 'z';
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
// 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
|
||||
* Scales all axes uniformly based on scale ratio
|
||||
*/
|
||||
private applyUniformScaling(scaleRatio: number): void {
|
||||
if (!this.initialScale) return;
|
||||
|
||||
// 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
|
||||
* Face handles: round only the scaled axis
|
||||
* Corner handles: round uniformly on all axes
|
||||
*/
|
||||
private applyRoundedScale(): void {
|
||||
if (!this.activeHandle || !this.initialScale) return;
|
||||
|
||||
const currentScale = this.targetMesh.scaling.clone();
|
||||
const newScale = this.initialScale.clone();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
public dispose(): void {
|
||||
this.logger.info(`Disposing ResizeGizmo for mesh: ${this.targetMesh.name} (${this.targetMesh.id})`);
|
||||
|
||||
// Remove observers
|
||||
if (this.beforeRenderObserver) {
|
||||
this._scene.onBeforeRenderObservable.remove(this.beforeRenderObserver);
|
||||
this.beforeRenderObserver = null;
|
||||
}
|
||||
|
||||
// Dispose handles
|
||||
for (const handleInfo of this.handles) {
|
||||
handleInfo.mesh.dispose();
|
||||
}
|
||||
this.handles = [];
|
||||
|
||||
// Dispose materials
|
||||
this.normalMaterial.dispose();
|
||||
this.hoverMaterial.dispose();
|
||||
this.activeMaterial.dispose();
|
||||
|
||||
// Dispose utility layer
|
||||
this.utilityLayer.dispose();
|
||||
|
||||
// Clear observables
|
||||
this.onScaleDrag.clear();
|
||||
this.onScaleEnd.clear();
|
||||
|
||||
this._xr = null;
|
||||
this._scene = null;
|
||||
}
|
||||
}
|
||||
28
src/gizmos/ResizeGizmo/enums.ts
Normal file
28
src/gizmos/ResizeGizmo/enums.ts
Normal file
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Handle types for the resize gizmo
|
||||
*/
|
||||
export enum HandleType {
|
||||
FACE_POS_X = 'face_pos_x',
|
||||
FACE_NEG_X = 'face_neg_x',
|
||||
FACE_POS_Y = 'face_pos_y',
|
||||
FACE_NEG_Y = 'face_neg_y',
|
||||
FACE_POS_Z = 'face_pos_z',
|
||||
FACE_NEG_Z = 'face_neg_z',
|
||||
CORNER_PPP = 'corner_ppp', // (+X, +Y, +Z)
|
||||
CORNER_PPN = 'corner_ppn', // (+X, +Y, -Z)
|
||||
CORNER_PNP = 'corner_pnp', // (+X, -Y, +Z)
|
||||
CORNER_PNN = 'corner_pnn', // (+X, -Y, -Z)
|
||||
CORNER_NPP = 'corner_npp', // (-X, +Y, +Z)
|
||||
CORNER_NPN = 'corner_npn', // (-X, +Y, -Z)
|
||||
CORNER_NNP = 'corner_nnp', // (-X, -Y, +Z)
|
||||
CORNER_NNN = 'corner_nnn', // (-X, -Y, -Z)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle state for visual feedback
|
||||
*/
|
||||
export enum HandleState {
|
||||
NORMAL = 'normal',
|
||||
HOVER = 'hover',
|
||||
ACTIVE = 'active',
|
||||
}
|
||||
@ -1,552 +1,13 @@
|
||||
import {
|
||||
AbstractMesh,
|
||||
Color3,
|
||||
Material,
|
||||
Mesh,
|
||||
MeshBuilder,
|
||||
Observable,
|
||||
Observer,
|
||||
StandardMaterial,
|
||||
UtilityLayerRenderer,
|
||||
Vector3,
|
||||
WebXRInputSource,
|
||||
} from '@babylonjs/core';
|
||||
import { DefaultScene } from '../../defaultScene';
|
||||
|
||||
/**
|
||||
* Event emitted during and after scaling operations
|
||||
*/
|
||||
export interface ResizeGizmoEvent {
|
||||
mesh: AbstractMesh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle types for the resize gizmo
|
||||
*/
|
||||
enum HandleType {
|
||||
FACE_POS_X = 'face_pos_x',
|
||||
FACE_NEG_X = 'face_neg_x',
|
||||
FACE_POS_Y = 'face_pos_y',
|
||||
FACE_NEG_Y = 'face_neg_y',
|
||||
FACE_POS_Z = 'face_pos_z',
|
||||
FACE_NEG_Z = 'face_neg_z',
|
||||
CORNER_PPP = 'corner_ppp', // (+X, +Y, +Z)
|
||||
CORNER_PPN = 'corner_ppn', // (+X, +Y, -Z)
|
||||
CORNER_PNP = 'corner_pnp', // (+X, -Y, +Z)
|
||||
CORNER_PNN = 'corner_pnn', // (+X, -Y, -Z)
|
||||
CORNER_NPP = 'corner_npp', // (-X, +Y, +Z)
|
||||
CORNER_NPN = 'corner_npn', // (-X, +Y, -Z)
|
||||
CORNER_NNP = 'corner_nnp', // (-X, -Y, +Z)
|
||||
CORNER_NNN = 'corner_nnn', // (-X, -Y, -Z)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle state for visual feedback
|
||||
*/
|
||||
enum HandleState {
|
||||
NORMAL = 'normal',
|
||||
HOVER = 'hover',
|
||||
ACTIVE = 'active',
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about a handle
|
||||
*/
|
||||
interface HandleInfo {
|
||||
mesh: Mesh;
|
||||
type: HandleType;
|
||||
state: HandleState;
|
||||
material: StandardMaterial;
|
||||
/** Local space offset from target center for positioning */
|
||||
localOffset: Vector3;
|
||||
}
|
||||
|
||||
/**
|
||||
* ResizeGizmo - Simple gizmo for resizing meshes in WebXR
|
||||
* ResizeGizmo Module
|
||||
*
|
||||
* Features:
|
||||
* A simple WebXR gizmo for resizing meshes with:
|
||||
* - 6 face handles for single-axis scaling
|
||||
* - 8 corner handles for uniform scaling
|
||||
* - XR controller grip interaction
|
||||
* - Manual ray casting for utility layer interaction
|
||||
* - Billboard scaling for constant screen-size handles
|
||||
* - Renders in utility layer (separate from main scene)
|
||||
*/
|
||||
export class ResizeGizmo {
|
||||
private targetMesh: AbstractMesh;
|
||||
private utilityLayer: UtilityLayerRenderer;
|
||||
private handles: HandleInfo[] = [];
|
||||
|
||||
// Materials for different states
|
||||
private normalMaterial: StandardMaterial;
|
||||
private hoverMaterial: StandardMaterial;
|
||||
private activeMaterial: StandardMaterial;
|
||||
|
||||
// Interaction state
|
||||
private activeHandle: HandleInfo | null = null;
|
||||
private gripStartPosition: Vector3 | null = null;
|
||||
private initialScale: Vector3 | null = null;
|
||||
private activeController: WebXRInputSource | null = null;
|
||||
|
||||
// Observables for events
|
||||
public onScaleDrag: Observable<ResizeGizmoEvent>;
|
||||
public onScaleEnd: Observable<ResizeGizmoEvent>;
|
||||
|
||||
// Frame observers
|
||||
private beforeRenderObserver: Observer<any> | null = null;
|
||||
|
||||
// Constants
|
||||
private static readonly HANDLE_SIZE = 0.1;
|
||||
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) {
|
||||
this.targetMesh = targetMesh;
|
||||
this.onScaleDrag = new Observable<ResizeGizmoEvent>();
|
||||
this.onScaleEnd = new Observable<ResizeGizmoEvent>();
|
||||
|
||||
// Create utility layer for rendering handles
|
||||
this.utilityLayer = new UtilityLayerRenderer(DefaultScene.Scene);
|
||||
this.utilityLayer.utilityLayerScene.autoClearDepthAndStencil = false;
|
||||
|
||||
// Create materials
|
||||
this.createMaterials();
|
||||
|
||||
// Create handles
|
||||
this.createHandles();
|
||||
|
||||
// Set up XR interaction
|
||||
this.setupXRInteraction();
|
||||
|
||||
// Set up per-frame updates
|
||||
this.setupFrameUpdates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create materials for handle states
|
||||
*/
|
||||
private createMaterials(): void {
|
||||
// Normal state - Gray
|
||||
this.normalMaterial = new StandardMaterial('resizeGizmo_normal', this.utilityLayer.utilityLayerScene);
|
||||
this.normalMaterial.diffuseColor = new Color3(0.5, 0.5, 0.5);
|
||||
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)
|
||||
*/
|
||||
private createHandles(): void {
|
||||
// Face handles (single-axis scaling)
|
||||
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)
|
||||
this.createCornerHandle(HandleType.CORNER_PPP, new Vector3(1, 1, 1));
|
||||
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
|
||||
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;
|
||||
|
||||
this.handles.push({
|
||||
mesh: handle,
|
||||
type,
|
||||
state: HandleState.NORMAL,
|
||||
material: this.normalMaterial,
|
||||
localOffset: direction.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 position
|
||||
const worldMatrix = this.targetMesh.getWorldMatrix();
|
||||
const targetPosition = this.targetMesh.getAbsolutePosition();
|
||||
const targetRotation = this.targetMesh.rotationQuaternion || this.targetMesh.rotation.toQuaternion();
|
||||
|
||||
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 {
|
||||
const xr = DefaultScene.Scene.xr;
|
||||
if (!xr) return;
|
||||
|
||||
// Listen for controller added
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up per-frame updates
|
||||
*/
|
||||
private setupFrameUpdates(): void {
|
||||
this.beforeRenderObserver = DefaultScene.Scene.onBeforeRenderObservable.add(() => {
|
||||
this.updateFrame();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update each frame
|
||||
*/
|
||||
private updateFrame(): void {
|
||||
// Update handle positions
|
||||
this.updateHandlePositions();
|
||||
|
||||
// Check for hover states
|
||||
this.updateHoverStates();
|
||||
|
||||
// Update active scaling
|
||||
if (this.activeHandle && this.activeController) {
|
||||
this.updateScaling();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check which handle (if any) is being pointed at by XR controllers
|
||||
*/
|
||||
private updateHoverStates(): void {
|
||||
const xr = DefaultScene.Scene.xr;
|
||||
if (!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
|
||||
for (const controllerId of xr.input.controllers.keys()) {
|
||||
const pickedMesh = xr.pointerSelection.getMeshUnderPointer(controllerId);
|
||||
if (!pickedMesh) continue;
|
||||
|
||||
// Check if picked mesh is one of our handles
|
||||
const handleInfo = this.handles.find(h => h.mesh === pickedMesh);
|
||||
if (handleInfo) {
|
||||
this.setHandleState(handleInfo, HandleState.HOVER);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle grip button pressed
|
||||
*/
|
||||
private onGripPressed(controller: WebXRInputSource): void {
|
||||
if (this.activeHandle) return; // Already gripping
|
||||
|
||||
// Check if controller is pointing at a handle
|
||||
const pickedMesh = DefaultScene.Scene.xr?.pointerSelection.getMeshUnderPointer(controller.uniqueId);
|
||||
if (!pickedMesh) return;
|
||||
|
||||
const handleInfo = this.handles.find(h => h.mesh === pickedMesh);
|
||||
if (!handleInfo) return;
|
||||
|
||||
// Start gripping
|
||||
this.activeHandle = handleInfo;
|
||||
this.activeController = controller;
|
||||
this.gripStartPosition = controller.pointer.position.clone();
|
||||
this.initialScale = this.targetMesh.scaling.clone();
|
||||
|
||||
this.setHandleState(handleInfo, HandleState.ACTIVE);
|
||||
|
||||
// Haptic feedback
|
||||
controller.motionController?.pulse(0.5, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle grip button released
|
||||
*/
|
||||
private onGripReleased(controller: WebXRInputSource): void {
|
||||
if (!this.activeHandle || this.activeController !== controller) return;
|
||||
|
||||
// End gripping
|
||||
this.setHandleState(this.activeHandle, HandleState.NORMAL);
|
||||
this.activeHandle = null;
|
||||
this.activeController = null;
|
||||
this.gripStartPosition = null;
|
||||
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
|
||||
*/
|
||||
private updateScaling(): void {
|
||||
if (!this.activeHandle || !this.activeController || !this.gripStartPosition || !this.initialScale) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPosition = this.activeController.pointer.position;
|
||||
const movement = currentPosition.subtract(this.gripStartPosition);
|
||||
|
||||
// Determine scaling based on handle type
|
||||
if (this.activeHandle.type.startsWith('face_')) {
|
||||
this.applySingleAxisScaling(movement);
|
||||
} else {
|
||||
this.applyUniformScaling(movement);
|
||||
}
|
||||
|
||||
// Fire onScaleDrag event
|
||||
this.onScaleDrag.notifyObservers({ mesh: this.targetMesh });
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply single-axis scaling from a face handle
|
||||
* Scales from opposite face (fixed pivot)
|
||||
*/
|
||||
private applySingleAxisScaling(movement: Vector3): void {
|
||||
if (!this.activeHandle || !this.initialScale) return;
|
||||
|
||||
// Determine which axis to scale
|
||||
const offset = this.activeHandle.localOffset;
|
||||
let axis: 'x' | 'y' | 'z';
|
||||
let direction: number;
|
||||
|
||||
if (Math.abs(offset.x) > 0.5) {
|
||||
axis = 'x';
|
||||
direction = Math.sign(offset.x);
|
||||
} else if (Math.abs(offset.y) > 0.5) {
|
||||
axis = 'y';
|
||||
direction = Math.sign(offset.y);
|
||||
} else {
|
||||
axis = 'z';
|
||||
direction = Math.sign(offset.z);
|
||||
}
|
||||
|
||||
// Calculate movement along the axis in world space
|
||||
const worldAxis = this.activeHandle.localOffset.clone().normalize();
|
||||
const movementAlongAxis = Vector3.Dot(movement, worldAxis);
|
||||
|
||||
// Convert movement to scale delta (in increments of 0.1)
|
||||
const scaleDelta = Math.round(movementAlongAxis / ResizeGizmo.SCALE_INCREMENT) * ResizeGizmo.SCALE_INCREMENT;
|
||||
|
||||
// Apply scale
|
||||
const newScale = this.initialScale.clone();
|
||||
newScale[axis] = Math.max(ResizeGizmo.MIN_SCALE, this.initialScale[axis] + scaleDelta * direction);
|
||||
|
||||
// Calculate position adjustment to keep opposite face fixed
|
||||
const boundingInfo = this.targetMesh.getBoundingInfo();
|
||||
const extents = boundingInfo.boundingBox.extendSize;
|
||||
const scaleRatio = newScale[axis] / this.initialScale[axis];
|
||||
|
||||
// Calculate offset in local space
|
||||
const localOffset = new Vector3(0, 0, 0);
|
||||
localOffset[axis] = extents[axis] * (scaleRatio - 1) * direction;
|
||||
|
||||
// Transform to world space and adjust position
|
||||
const worldMatrix = this.targetMesh.getWorldMatrix();
|
||||
const rotation = this.targetMesh.rotationQuaternion || this.targetMesh.rotation.toQuaternion();
|
||||
const worldOffset = localOffset.applyRotationQuaternion(rotation);
|
||||
|
||||
this.targetMesh.scaling = newScale;
|
||||
this.targetMesh.position.addInPlace(worldOffset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply uniform scaling from a corner handle
|
||||
* Scales from center
|
||||
*/
|
||||
private applyUniformScaling(movement: Vector3): void {
|
||||
if (!this.activeHandle || !this.initialScale) return;
|
||||
|
||||
// Calculate movement along the diagonal direction
|
||||
const diagonal = this.activeHandle.localOffset.clone().normalize();
|
||||
const movementAlongDiagonal = Vector3.Dot(movement, diagonal);
|
||||
|
||||
// Convert movement to scale delta
|
||||
const scaleDelta = Math.round(movementAlongDiagonal / ResizeGizmo.SCALE_INCREMENT) * ResizeGizmo.SCALE_INCREMENT;
|
||||
|
||||
// Apply uniform scale
|
||||
const scaleMultiplier = Math.max(ResizeGizmo.MIN_SCALE, 1 + scaleDelta);
|
||||
const newScale = this.initialScale.clone().scale(scaleMultiplier);
|
||||
|
||||
// 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.targetMesh.scaling = newScale;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
public dispose(): void {
|
||||
// Remove observers
|
||||
if (this.beforeRenderObserver) {
|
||||
DefaultScene.Scene.onBeforeRenderObservable.remove(this.beforeRenderObserver);
|
||||
this.beforeRenderObserver = null;
|
||||
}
|
||||
|
||||
// Dispose handles
|
||||
for (const handleInfo of this.handles) {
|
||||
handleInfo.mesh.dispose();
|
||||
}
|
||||
this.handles = [];
|
||||
|
||||
// Dispose materials
|
||||
this.normalMaterial.dispose();
|
||||
this.hoverMaterial.dispose();
|
||||
this.activeMaterial.dispose();
|
||||
|
||||
// Dispose utility layer
|
||||
this.utilityLayer.dispose();
|
||||
|
||||
// Clear observables
|
||||
this.onScaleDrag.clear();
|
||||
this.onScaleEnd.clear();
|
||||
}
|
||||
}
|
||||
export { ResizeGizmo } from './ResizeGizmo';
|
||||
export type { ResizeGizmoEvent, HandleInfo } from './types';
|
||||
export { HandleType, HandleState } from './enums';
|
||||
|
||||
21
src/gizmos/ResizeGizmo/types.ts
Normal file
21
src/gizmos/ResizeGizmo/types.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { AbstractMesh, Mesh, StandardMaterial, Vector3 } from '@babylonjs/core';
|
||||
import { HandleType, HandleState } from './enums';
|
||||
|
||||
/**
|
||||
* Event emitted during and after scaling operations
|
||||
*/
|
||||
export interface ResizeGizmoEvent {
|
||||
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