Reimplement ResizeGizmo as simplified single-file XR gizmo
Complete rewrite of ResizeGizmo with a much simpler architecture: - Single file implementation (index.ts) replacing multi-file system - 14 handles: 6 face handles for single-axis scaling, 8 corner handles for uniform scaling - XR-only interaction using UtilityLayerRenderer - Billboard scaling for constant screen-size handles - Grip-based interaction with hover/active visual states (gray/white/blue) - Single-axis scaling from opposite face (fixed pivot) - Uniform scaling from center - Integrated with ClickMenu Size button - Observable events (onScaleEnd, onScaleDrag) for future integration Removed old complex implementation files and simplified documentation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
c815db4594
commit
2c3fba31d3
@ -1,5 +1,5 @@
|
||||
import {DiagramEntityType, DiagramEvent, DiagramEventType} from "./types/diagramEntity";
|
||||
import {AbstractMesh, ActionEvent, Observable, Ray, Scene, Vector3, WebXRDefaultExperience, WebXRInputSource} from "@babylonjs/core";
|
||||
import {AbstractMesh, ActionEvent, Observable, Scene, Vector3, WebXRDefaultExperience, WebXRInputSource} from "@babylonjs/core";
|
||||
import {InputTextView} from "../information/inputTextView";
|
||||
import {DefaultScene} from "../defaultScene";
|
||||
import log from "loglevel";
|
||||
@ -7,29 +7,22 @@ import {Toolbox} from "../toolbox/toolbox";
|
||||
import {ClickMenu} from "../menus/clickMenu";
|
||||
import {DiagramEventObserverMask} from "./types/diagramEventObserverMask";
|
||||
import {ConnectionPreview} from "../menus/connectionPreview";
|
||||
import {ScaleMenu2} from "../menus/ScaleMenu2";
|
||||
import {viewOnly} from "../util/functions/getPath";
|
||||
import {GroupMenu} from "../menus/groupMenu";
|
||||
import {ControllerEvent} from "../controllers/types/controllerEvent";
|
||||
import {ControllerEventType} from "../controllers/types/controllerEventType";
|
||||
import {ResizeGizmoManager} from "../gizmos/ResizeGizmo/ResizeGizmoManager";
|
||||
import {ResizeGizmoMode} from "../gizmos/ResizeGizmo/types";
|
||||
import {DiagramEntityAdapter} from "../integration/gizmo/DiagramEntityAdapter";
|
||||
import {toDiagramEntity} from "./functions/toDiagramEntity";
|
||||
import {ResizeGizmo} from "../gizmos/ResizeGizmo";
|
||||
|
||||
|
||||
export class DiagramMenuManager {
|
||||
public readonly toolbox: Toolbox;
|
||||
public readonly scaleMenu: ScaleMenu2;
|
||||
public readonly resizeGizmo: ResizeGizmoManager;
|
||||
private readonly _resizeGizmoAdapter: DiagramEntityAdapter;
|
||||
private readonly _notifier: Observable<DiagramEvent>;
|
||||
private readonly _inputTextView: InputTextView;
|
||||
private _groupMenu: GroupMenu;
|
||||
private readonly _scene: Scene;
|
||||
private _logger = log.getLogger('DiagramMenuManager');
|
||||
private _connectionPreview: ConnectionPreview;
|
||||
private _currentHoveredMesh: AbstractMesh | null = null;
|
||||
private _activeResizeGizmo: ResizeGizmo | null = null;
|
||||
|
||||
constructor(notifier: Observable<DiagramEvent>, controllerObservable: Observable<ControllerEvent>, readyObservable: Observable<boolean>) {
|
||||
this._scene = DefaultScene.Scene;
|
||||
@ -46,42 +39,8 @@ export class DiagramMenuManager {
|
||||
});
|
||||
this.toolbox = new Toolbox(readyObservable);
|
||||
|
||||
|
||||
this.scaleMenu = new ScaleMenu2(this._notifier);
|
||||
|
||||
// Initialize ResizeGizmo with auto-show on hover
|
||||
this.resizeGizmo = new ResizeGizmoManager(this._scene, {
|
||||
mode: ResizeGizmoMode.ALL,
|
||||
enableSnapping: true,
|
||||
snapDistanceX: 0.1,
|
||||
snapDistanceY: 0.1,
|
||||
snapDistanceZ: 0.1,
|
||||
showNumericDisplay: true,
|
||||
showGrid: true,
|
||||
showSnapPoints: true,
|
||||
hapticFeedback: true,
|
||||
showBoundingBoxOnHoverOnly: false
|
||||
});
|
||||
|
||||
// Create adapter for DiagramEntity persistence
|
||||
// Inject toDiagramEntity converter for loose coupling
|
||||
this._resizeGizmoAdapter = new DiagramEntityAdapter(
|
||||
this.resizeGizmo,
|
||||
{ onDiagramEventObservable: this._notifier } as any,
|
||||
toDiagramEntity, // Injected mesh-to-entity converter
|
||||
false // Don't persist on drag, only on scale end
|
||||
);
|
||||
|
||||
// Setup update loop for resize gizmo
|
||||
this._scene.onBeforeRenderObservable.add(() => {
|
||||
this.resizeGizmo.update();
|
||||
});
|
||||
|
||||
if (viewOnly()) {
|
||||
this.toolbox.handleMesh.setEnabled(false);
|
||||
this.resizeGizmo.setEnabled(false);
|
||||
//this.scaleMenu.handleMesh.setEnabled(false)
|
||||
// this.configMenu.handleTransformNode.setEnabled(false);
|
||||
}
|
||||
controllerObservable.add((event: ControllerEvent) => {
|
||||
if (event.type == ControllerEventType.B_BUTTON) {
|
||||
@ -126,6 +85,32 @@ export class DiagramMenuManager {
|
||||
this._inputTextView.show(mesh);
|
||||
}
|
||||
|
||||
public activateResizeGizmo(mesh: AbstractMesh) {
|
||||
// Dispose existing gizmo if any
|
||||
if (this._activeResizeGizmo) {
|
||||
this._activeResizeGizmo.dispose();
|
||||
this._activeResizeGizmo = null;
|
||||
}
|
||||
|
||||
// Create new resize gizmo for the mesh
|
||||
this._activeResizeGizmo = new ResizeGizmo(mesh);
|
||||
|
||||
// Listen for scale end event to notify diagram manager
|
||||
this._activeResizeGizmo.onScaleEnd.add(() => {
|
||||
this.notifyAll({
|
||||
type: DiagramEventType.MODIFY,
|
||||
entity: {id: mesh.id, type: DiagramEntityType.ENTITY}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public disposeResizeGizmo() {
|
||||
if (this._activeResizeGizmo) {
|
||||
this._activeResizeGizmo.dispose();
|
||||
this._activeResizeGizmo = null;
|
||||
}
|
||||
}
|
||||
|
||||
public createClickMenu(mesh: AbstractMesh, input: WebXRInputSource): ClickMenu {
|
||||
const clickMenu = new ClickMenu(mesh);
|
||||
clickMenu.onClickMenuObservable.add((evt: ActionEvent) => {
|
||||
@ -145,14 +130,14 @@ export class DiagramMenuManager {
|
||||
this._connectionPreview = new ConnectionPreview(clickMenu.mesh.id, input, evt.additionalData.pickedPoint, this._notifier);
|
||||
break;
|
||||
case "size":
|
||||
this.scaleMenu.show(clickMenu.mesh);
|
||||
this.activateResizeGizmo(clickMenu.mesh);
|
||||
break;
|
||||
case "group":
|
||||
this._groupMenu = new GroupMenu(clickMenu.mesh);
|
||||
break;
|
||||
case "close":
|
||||
this.scaleMenu.hide();
|
||||
break;
|
||||
// case "close":
|
||||
// // DISCONNECTED - Ready for new scaling implementation
|
||||
// break;
|
||||
}
|
||||
this._logger.debug(evt);
|
||||
|
||||
@ -167,96 +152,5 @@ export class DiagramMenuManager {
|
||||
|
||||
public setXR(xr: WebXRDefaultExperience): void {
|
||||
this.toolbox.setXR(xr);
|
||||
|
||||
// Register controllers with resize gizmo when they're added
|
||||
xr.input.onControllerAddedObservable.add((controller) => {
|
||||
this.resizeGizmo.registerController(controller);
|
||||
});
|
||||
|
||||
xr.input.onControllerRemovedObservable.add((controller) => {
|
||||
this.resizeGizmo.unregisterController(controller);
|
||||
});
|
||||
|
||||
// Configure pointer selection to exclude utility layer meshes (primary defense against event leak-through)
|
||||
if (xr.pointerSelection) {
|
||||
const utilityScene = this.resizeGizmo.getUtilityScene();
|
||||
|
||||
// Wrap or replace the mesh predicate
|
||||
const originalMeshPredicate = xr.pointerSelection.meshPredicate;
|
||||
|
||||
xr.pointerSelection.meshPredicate = (mesh) => {
|
||||
// Exclude utility layer meshes (gizmo handles)
|
||||
if (mesh.getScene() === utilityScene) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Apply original predicate if it exists
|
||||
if (originalMeshPredicate) {
|
||||
return originalMeshPredicate(mesh);
|
||||
}
|
||||
|
||||
// Default: mesh must be pickable, visible, and enabled
|
||||
return mesh.isPickable && mesh.isVisible && mesh.isEnabled();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle pointer hovering over a diagram object
|
||||
* Auto-shows resize gizmo
|
||||
*/
|
||||
public handleDiagramObjectHover(mesh: AbstractMesh | null, pointerPosition?: Vector3): void {
|
||||
// If hovering same mesh, do nothing
|
||||
if (mesh === this._currentHoveredMesh) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If no longer hovering any mesh, check if we should keep gizmo active
|
||||
if (!mesh) {
|
||||
if (this._currentHoveredMesh) {
|
||||
// Check if pointer is still near the gizmo or within bounding box
|
||||
const shouldKeepActive = this.shouldKeepGizmoActive(pointerPosition);
|
||||
|
||||
if (!shouldKeepActive) {
|
||||
this.resizeGizmo.detachFromMesh();
|
||||
this._currentHoveredMesh = null;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Hovering new mesh, attach gizmo
|
||||
this._currentHoveredMesh = mesh;
|
||||
this.resizeGizmo.attachToMesh(mesh);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if gizmo should remain active
|
||||
* Trusts ResizeGizmo's internal state management rather than recalculating
|
||||
*/
|
||||
private shouldKeepGizmoActive(pointerPosition?: Vector3): boolean {
|
||||
if (!this._currentHoveredMesh) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Trust ResizeGizmo's internal state management
|
||||
// ResizeGizmo already tracks hover state correctly with proper controller rays
|
||||
const state = this.resizeGizmo.getInteractionState();
|
||||
|
||||
// Keep active if ResizeGizmo is in any active state:
|
||||
// - ACTIVE_SCALING: User is actively scaling (grip held)
|
||||
// - HOVER_HANDLE: Pointer is hovering a handle (ready to scale)
|
||||
// - HOVER_MESH: Pointer is within handle boundary (grace zone)
|
||||
return state === 'ACTIVE_SCALING' ||
|
||||
state === 'HOVER_HANDLE' ||
|
||||
state === 'HOVER_MESH';
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a controller with the resize gizmo
|
||||
*/
|
||||
public registerControllerWithGizmo(controller: WebXRInputSource): void {
|
||||
this.resizeGizmo.registerController(controller);
|
||||
}
|
||||
}
|
||||
@ -2,14 +2,8 @@
|
||||
|
||||
## Overview
|
||||
|
||||
A self-contained, extractable WebXR resize gizmo system for BabylonJS with advanced features including:
|
||||
A simple, self-contained, extractable WebXR resize gizmo system for BabylonJS with the following features:
|
||||
|
||||
- **4 Configurable Modes**: Single-axis, uniform, two-axis, and all-modes combined
|
||||
- **WebXR Grip Button Control**: Hover handle → hold grip → drag → release workflow
|
||||
- **Visual Feedback**: Numeric displays, alignment grids, snap indicators, color-coded handles
|
||||
- **Snapping System**: Configurable snap points with visual and haptic feedback
|
||||
- **Bounding Box Visualization**: Automatic highlighting with configurable padding
|
||||
- **DiagramEntity Integration**: Optional adapter for persistence systems
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@ -17,56 +11,30 @@ A self-contained, extractable WebXR resize gizmo system for BabylonJS with advan
|
||||
src/gizmos/ResizeGizmo/
|
||||
├── index.ts # Main exports
|
||||
├── types.ts # TypeScript type definitions
|
||||
├── ResizeGizmoManager.ts # Main orchestration class
|
||||
├── ResizeGizmoConfig.ts # Configuration management
|
||||
├── ResizeGizmoVisuals.ts # Bounding box & handle rendering
|
||||
├── ResizeGizmoInteraction.ts # WebXR input handling
|
||||
├── ResizeGizmoSnapping.ts # Snap-to-grid system
|
||||
├── ResizeGizmoFeedback.ts # Visual feedback (numeric, grids, indicators)
|
||||
├── ScalingCalculator.ts # Scaling math for all handle types
|
||||
├── HandleGeometry.ts # Handle position calculations
|
||||
└── DiagramEntityAdapter.ts # Optional DiagramManager integration
|
||||
```
|
||||
|
||||
## Feature Checklist
|
||||
|
||||
### Core Features
|
||||
- [x] Four configurable scaling modes (SINGLE_AXIS, UNIFORM, TWO_AXIS, ALL)
|
||||
- [x] WebXR grip button interaction (hover → hold → drag → release)
|
||||
- [x] Bounding box visualization with configurable padding
|
||||
- [x] Handle meshes sized for easy WebXR interaction
|
||||
- [x] UtilityLayerRenderer integration (no main scene pollution)
|
||||
- [x] Color-coded handles by type (corner, edge, face)
|
||||
* Create a new Gizmo and pass an AbstractMesh in the contructor known as "gizmo target"
|
||||
* Gizmo will create handles in utility layer taking into account scale and rotation of "gizmo target"
|
||||
* Handles should be large enough to easily grab, but not so large that they overlap
|
||||
* Handles should be outside the bounding box of the "gizmo target"
|
||||
* Gizmo will say active until dispose() is called on the gizmo instance.
|
||||
* When xr controller "ray" in utility scene intersects a handle, the handle will change color and get slightly larger
|
||||
* When xr controller "grip" button is pressed while a handle is highlighted, the color of the highlighted handle will change and gizmo will enter "scaling mode"
|
||||
* In "scaling mode", the handle is able to move outward from the center of the "gizmo target" depending on the type of handle selected
|
||||
* In "scaling mode", the gizmo will scale the "gizmo target" in .1 increments with smallest scale being .1 and no upper bound
|
||||
* The math to calculate scaling should take into account rotation and original scale of "gizmo target"
|
||||
* "face handles" will only scale in one axis
|
||||
* "corner handles" will scale every axis
|
||||
* the scaling math should take into account the origin of the handle when gripped in the "gizmo target" local space.
|
||||
|
||||
### Interaction Features
|
||||
- [x] Hover detection for mesh and handles
|
||||
- [x] Handle highlighting on hover
|
||||
- [x] Active state visualization during drag
|
||||
- [x] Scaling calculations for all handle types
|
||||
- [x] Min/max scale constraints
|
||||
- [x] Scale from center option
|
||||
|
||||
### Snapping System
|
||||
- [x] Configurable snap intervals per axis
|
||||
- [x] Visual snap point indicators
|
||||
- [x] Snap proximity calculation
|
||||
- [x] Haptic feedback on snap (WebXR)
|
||||
- [x] Option to disable snapping
|
||||
|
||||
### Visual Feedback
|
||||
- [x] Numeric display (scale values & percentages)
|
||||
- [x] Alignment grids (1D, 2D, 3D based on mode)
|
||||
- [x] Snap point visualization
|
||||
- [x] Color changes (idle/hover/active states)
|
||||
- [x] Billboard text display
|
||||
|
||||
### Integration
|
||||
- [x] Event system (Observable-based)
|
||||
- [x] DiagramEntity adapter for persistence
|
||||
- [x] Self-contained with no hard dependencies
|
||||
- [x] Configurable and extensible
|
||||
- [ ] Self-contained with no hard dependencies
|
||||
|
||||
## Scaling Modes
|
||||
|
||||
### Mode 1: SINGLE_AXIS
|
||||
**Handles**: 6 face-center handles
|
||||
@ -85,487 +53,7 @@ src/gizmos/ResizeGizmo/
|
||||
**Handles**: 8 corner handles
|
||||
**Behavior**: Scale all axes equally (proportional)
|
||||
**Use Case**: Resizing while maintaining proportions
|
||||
|
||||
**Handle Positions**: All 8 combinations of `(min/max.x, min/max.y, min/max.z)`
|
||||
|
||||
### Mode 3: TWO_AXIS
|
||||
**Handles**: 12 edge-center handles
|
||||
**Behavior**: Scale two axes simultaneously
|
||||
**Use Case**: Scaling faces/planes without affecting depth
|
||||
|
||||
**Handle Positions**:
|
||||
- 4 edges parallel to X: `(mid.x, ±Y, ±Z)` → scales Y & Z
|
||||
- 4 edges parallel to Y: `(±X, mid.y, ±Z)` → scales X & Z
|
||||
- 4 edges parallel to Z: `(±X, ±Y, mid.z)` → scales X & Y
|
||||
|
||||
### Mode 4: ALL
|
||||
**Handles**: 26 handles (8 corners + 12 edges + 6 faces)
|
||||
**Behavior**: Handle type determines scaling mode:
|
||||
- Corner → uniform
|
||||
- Edge → two-axis
|
||||
- Face → single-axis
|
||||
|
||||
**Use Case**: Maximum flexibility in single gizmo
|
||||
|
||||
## API Reference
|
||||
|
||||
### ResizeGizmoManager
|
||||
|
||||
Main class for managing the gizmo system.
|
||||
|
||||
#### Constructor
|
||||
```typescript
|
||||
constructor(scene: Scene, config?: Partial<ResizeGizmoConfig>)
|
||||
```
|
||||
|
||||
#### Methods
|
||||
|
||||
**Mesh Attachment**
|
||||
```typescript
|
||||
attachToMesh(mesh: AbstractMesh): void
|
||||
detachFromMesh(): void
|
||||
getAttachedMesh(): AbstractMesh | undefined
|
||||
```
|
||||
|
||||
**Controller Registration**
|
||||
```typescript
|
||||
registerController(controller: WebXRInputSource): void
|
||||
unregisterController(controller: WebXRInputSource): void
|
||||
```
|
||||
|
||||
**Update Loop**
|
||||
```typescript
|
||||
update(): void // Call in scene.onBeforeRenderObservable
|
||||
```
|
||||
|
||||
**Mode Management**
|
||||
```typescript
|
||||
setMode(mode: ResizeGizmoMode): void
|
||||
getMode(): ResizeGizmoMode
|
||||
```
|
||||
|
||||
**Configuration**
|
||||
```typescript
|
||||
updateConfig(updates: Partial<ResizeGizmoConfig>): void
|
||||
getConfig(): Readonly<ResizeGizmoConfig>
|
||||
```
|
||||
|
||||
**Enable/Disable**
|
||||
```typescript
|
||||
setEnabled(enabled: boolean): void
|
||||
isEnabled(): boolean
|
||||
```
|
||||
|
||||
**Event Listeners**
|
||||
```typescript
|
||||
on(eventType: ResizeGizmoEventType, callback: ResizeGizmoEventCallback): void
|
||||
off(eventType: ResizeGizmoEventType, callback: ResizeGizmoEventCallback): void
|
||||
|
||||
// Convenience methods
|
||||
onScaleStart(callback: ResizeGizmoEventCallback): void
|
||||
onScaleDrag(callback: ResizeGizmoEventCallback): void
|
||||
onScaleEnd(callback: ResizeGizmoEventCallback): void
|
||||
onAttached(callback: ResizeGizmoEventCallback): void
|
||||
onDetached(callback: ResizeGizmoEventCallback): void
|
||||
onModeChanged(callback: ResizeGizmoEventCallback): void
|
||||
```
|
||||
|
||||
**Disposal**
|
||||
```typescript
|
||||
dispose(): void
|
||||
```
|
||||
|
||||
### ResizeGizmoConfig
|
||||
|
||||
Configuration interface with the following properties:
|
||||
|
||||
#### Mode Configuration
|
||||
- `mode: ResizeGizmoMode` - Scaling mode (default: `ResizeGizmoMode.ALL`)
|
||||
|
||||
#### Handle Appearance
|
||||
- `handleSize: number` - Size of handle meshes as fraction of bounding box (default: `0.2` = 20% of average dimension)
|
||||
- `cornerHandleColor: Color3` - Corner handle color (default: blue)
|
||||
- `edgeHandleColor: Color3` - Edge handle color (default: green)
|
||||
- `faceHandleColor: Color3` - Face handle color (default: red)
|
||||
- `hoverColor: Color3` - Hover highlight color (default: yellow)
|
||||
- `activeColor: Color3` - Active drag color (default: orange)
|
||||
- `hoverScaleFactor: number` - Scale multiplier on hover (default: `1.3`)
|
||||
|
||||
#### Bounding Box
|
||||
- `boundingBoxPadding: number` - Padding around mesh (default: `0.05` = 5%)
|
||||
- `boundingBoxColor: Color3` - Wireframe color (default: white)
|
||||
- `wireframeAlpha: number` - Wireframe transparency 0-1 (default: `0.3`)
|
||||
- `showBoundingBoxOnHoverOnly: boolean` - Only show on hover (default: `false`)
|
||||
|
||||
#### Snapping
|
||||
- `enableSnapping: boolean` - Enable snap-to-grid (default: `true`)
|
||||
- `snapDistanceX: number` - X-axis snap interval (default: `0.1`)
|
||||
- `snapDistanceY: number` - Y-axis snap interval (default: `0.1`)
|
||||
- `snapDistanceZ: number` - Z-axis snap interval (default: `0.1`)
|
||||
- `showSnapIndicators: boolean` - Show snap point markers (default: `true`)
|
||||
- `hapticFeedback: boolean` - WebXR haptic feedback (default: `true`)
|
||||
|
||||
#### Visual Feedback
|
||||
- `showNumericDisplay: boolean` - Show scale values (default: `true`)
|
||||
- `showGrid: boolean` - Show alignment grid (default: `true`)
|
||||
- `showSnapPoints: boolean` - Show snap points (default: `true`)
|
||||
- `numericDisplayFontSize: number` - Font size for text (default: `24`)
|
||||
|
||||
#### Constraints
|
||||
- `minScale: Vector3` - Minimum scale values (default: `(0.01, 0.01, 0.01)`)
|
||||
- `maxScale?: Vector3` - Maximum scale values (default: `undefined`)
|
||||
- `lockAspectRatio: boolean` - Lock aspect in TWO_AXIS mode (default: `false`)
|
||||
- `scaleFromCenter: boolean` - Scale from center or corner (default: `true`)
|
||||
|
||||
#### Integration
|
||||
- `useDiagramEntity: boolean` - Use DiagramEntity integration (default: `false`)
|
||||
- `diagramManager?: any` - DiagramManager instance
|
||||
- `emitEvents: boolean` - Emit Observable events (default: `true`)
|
||||
|
||||
### Events
|
||||
|
||||
#### ResizeGizmoEventType
|
||||
```typescript
|
||||
enum ResizeGizmoEventType {
|
||||
SCALE_START, // Grip pressed on handle
|
||||
SCALE_DRAG, // During drag
|
||||
SCALE_END, // Grip released
|
||||
ATTACHED, // Gizmo attached to mesh
|
||||
DETACHED, // Gizmo detached
|
||||
MODE_CHANGED // Mode changed
|
||||
}
|
||||
```
|
||||
|
||||
#### ResizeGizmoEvent
|
||||
```typescript
|
||||
interface ResizeGizmoEvent {
|
||||
type: ResizeGizmoEventType;
|
||||
mesh: AbstractMesh;
|
||||
scale: Vector3; // Current scale
|
||||
previousScale?: Vector3; // Previous scale (SCALE_END only)
|
||||
handle?: HandlePosition; // Handle being used
|
||||
timestamp: number;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Standalone Usage
|
||||
|
||||
```typescript
|
||||
import { ResizeGizmoManager, ResizeGizmoMode } from './gizmos/ResizeGizmo';
|
||||
|
||||
// Create gizmo
|
||||
const gizmo = new ResizeGizmoManager(scene, {
|
||||
mode: ResizeGizmoMode.ALL,
|
||||
enableSnapping: true,
|
||||
snapDistanceX: 0.1,
|
||||
snapDistanceY: 0.1,
|
||||
snapDistanceZ: 0.1,
|
||||
showNumericDisplay: true,
|
||||
showGrid: true
|
||||
});
|
||||
|
||||
// Attach to mesh
|
||||
gizmo.attachToMesh(myMesh);
|
||||
|
||||
// Register WebXR controllers
|
||||
xr.input.onControllerAddedObservable.add((controller) => {
|
||||
gizmo.registerController(controller);
|
||||
});
|
||||
|
||||
xr.input.onControllerRemovedObservable.add((controller) => {
|
||||
gizmo.unregisterController(controller);
|
||||
});
|
||||
|
||||
// Update in render loop
|
||||
scene.onBeforeRenderObservable.add(() => {
|
||||
gizmo.update();
|
||||
});
|
||||
|
||||
// Listen to events
|
||||
gizmo.onScaleEnd((event) => {
|
||||
console.log('Scaling finished:', event.scale);
|
||||
console.log('Delta:', event.scale.subtract(event.previousScale));
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
gizmo.dispose();
|
||||
```
|
||||
|
||||
### With DiagramEntity Integration
|
||||
|
||||
```typescript
|
||||
import { createDiagramGizmo, ResizeGizmoMode } from './gizmos/ResizeGizmo';
|
||||
|
||||
// Create gizmo with DiagramManager integration
|
||||
const { gizmo, adapter } = createDiagramGizmo(scene, diagramManager, {
|
||||
mode: ResizeGizmoMode.UNIFORM,
|
||||
enableSnapping: true
|
||||
});
|
||||
|
||||
// Attach to DiagramEntity mesh
|
||||
gizmo.attachToMesh(diagramEntityMesh);
|
||||
|
||||
// Scale changes automatically persist to database via adapter
|
||||
```
|
||||
|
||||
### Mode Switching
|
||||
|
||||
```typescript
|
||||
const gizmo = new ResizeGizmoManager(scene);
|
||||
|
||||
// Start with uniform scaling only
|
||||
gizmo.setMode(ResizeGizmoMode.UNIFORM);
|
||||
|
||||
// Switch to single-axis mode
|
||||
gizmo.setMode(ResizeGizmoMode.SINGLE_AXIS);
|
||||
|
||||
// Enable all modes
|
||||
gizmo.setMode(ResizeGizmoMode.ALL);
|
||||
|
||||
// Listen to mode changes
|
||||
gizmo.onModeChanged((event) => {
|
||||
console.log('Mode changed to:', gizmo.getMode());
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Configuration
|
||||
|
||||
```typescript
|
||||
const gizmo = new ResizeGizmoManager(scene, {
|
||||
mode: ResizeGizmoMode.ALL,
|
||||
|
||||
// Custom handle colors
|
||||
cornerHandleColor: new Color3(1, 0, 0), // Red corners
|
||||
edgeHandleColor: new Color3(0, 1, 0), // Green edges
|
||||
faceHandleColor: new Color3(0, 0, 1), // Blue faces
|
||||
|
||||
// Larger handles for easier interaction
|
||||
handleSize: 0.2,
|
||||
|
||||
// Fine-grained snapping
|
||||
snapDistanceX: 0.05,
|
||||
snapDistanceY: 0.05,
|
||||
snapDistanceZ: 0.05,
|
||||
|
||||
// Scale constraints
|
||||
minScale: new Vector3(0.1, 0.1, 0.1),
|
||||
maxScale: new Vector3(10, 10, 10),
|
||||
|
||||
// Disable some visual feedback
|
||||
showGrid: false,
|
||||
showSnapPoints: false
|
||||
});
|
||||
|
||||
// Update config at runtime
|
||||
gizmo.updateConfig({
|
||||
snapDistanceX: 0.1,
|
||||
showGrid: true
|
||||
});
|
||||
```
|
||||
|
||||
### Advanced: Custom Event Handling
|
||||
|
||||
```typescript
|
||||
const gizmo = new ResizeGizmoManager(scene);
|
||||
|
||||
// Track scaling session
|
||||
let scalingStarted = false;
|
||||
let originalScale: Vector3;
|
||||
|
||||
gizmo.onScaleStart((event) => {
|
||||
scalingStarted = true;
|
||||
originalScale = event.scale.clone();
|
||||
console.log('Started scaling from:', originalScale);
|
||||
});
|
||||
|
||||
gizmo.onScaleDrag((event) => {
|
||||
// Real-time feedback during drag
|
||||
const delta = event.scale.subtract(originalScale);
|
||||
console.log('Scale delta:', delta);
|
||||
});
|
||||
|
||||
gizmo.onScaleEnd((event) => {
|
||||
scalingStarted = false;
|
||||
|
||||
const finalDelta = event.scale.subtract(event.previousScale);
|
||||
console.log('Scaling session completed');
|
||||
console.log('Total change:', finalDelta);
|
||||
|
||||
// Undo support
|
||||
saveToUndoStack({
|
||||
action: 'scale',
|
||||
mesh: event.mesh,
|
||||
before: event.previousScale,
|
||||
after: event.scale
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Integration with Existing Codebase
|
||||
|
||||
### Option 1: Use with DiagramManager (Recommended)
|
||||
|
||||
```typescript
|
||||
import { createDiagramGizmo } from './gizmos/ResizeGizmo';
|
||||
import { diagramManager } from './diagram/diagramManager';
|
||||
import { DefaultScene } from './defaultScene';
|
||||
|
||||
// Create integrated gizmo
|
||||
const { gizmo, adapter } = createDiagramGizmo(
|
||||
DefaultScene.Scene,
|
||||
diagramManager,
|
||||
{
|
||||
mode: ResizeGizmoMode.ALL,
|
||||
snapDistanceX: diagramManager._config.current.createSnap,
|
||||
snapDistanceY: diagramManager._config.current.createSnap,
|
||||
snapDistanceZ: diagramManager._config.current.createSnap
|
||||
}
|
||||
);
|
||||
|
||||
// Register with XR controllers (similar to existing pattern)
|
||||
DefaultScene.Scene.onBeforeRenderObservable.add(() => {
|
||||
gizmo.update();
|
||||
});
|
||||
```
|
||||
|
||||
### Option 2: Standalone in Menu System
|
||||
|
||||
```typescript
|
||||
import { ResizeGizmoManager, ResizeGizmoMode } from './gizmos/ResizeGizmo';
|
||||
|
||||
export class NewScaleMenu {
|
||||
private gizmo: ResizeGizmoManager;
|
||||
|
||||
constructor(scene: Scene) {
|
||||
this.gizmo = new ResizeGizmoManager(scene, {
|
||||
mode: ResizeGizmoMode.ALL
|
||||
});
|
||||
}
|
||||
|
||||
show(mesh: AbstractMesh) {
|
||||
this.gizmo.attachToMesh(mesh);
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.gizmo.detachFromMesh();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Extraction Guide
|
||||
|
||||
To extract this gizmo to another project:
|
||||
|
||||
1. **Copy Directory**: Copy entire `src/gizmos/ResizeGizmo/` folder
|
||||
|
||||
2. **Dependencies**: Ensure BabylonJS packages:
|
||||
```json
|
||||
{
|
||||
"@babylonjs/core": "^8.x.x"
|
||||
}
|
||||
```
|
||||
|
||||
3. **Import**:
|
||||
```typescript
|
||||
import { ResizeGizmoManager } from './path/to/ResizeGizmo';
|
||||
```
|
||||
|
||||
4. **Optional Integration**:
|
||||
- If using DiagramEntity integration, adapt `DiagramEntityAdapter.ts` to your persistence system
|
||||
- If not using, simply don't import the adapter
|
||||
|
||||
5. **No Hard Dependencies**: The gizmo has no hard dependencies on the "immersive" codebase
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Optimization Tips
|
||||
|
||||
1. **Update Frequency**: Only call `update()` when gizmo is attached and enabled
|
||||
2. **Handle Count**: Use specific modes (UNIFORM, SINGLE_AXIS, TWO_AXIS) instead of ALL to reduce handle count
|
||||
3. **Visual Feedback**: Disable expensive features if needed:
|
||||
- `showGrid: false`
|
||||
- `showSnapPoints: false`
|
||||
- `showNumericDisplay: false`
|
||||
4. **Snap Calculation**: Snapping calculations are lightweight, but haptic feedback checks run every frame during drag
|
||||
5. **Event Emission**: Set `emitEvents: false` if not using event listeners
|
||||
|
||||
### Memory Management
|
||||
|
||||
- Always call `dispose()` when done
|
||||
- Detach from mesh before disposing
|
||||
- Unregister controllers explicitly if managing lifecycle
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **Rotation**: Handles are positioned in world space; mesh rotation affects scaling behavior
|
||||
2. **Parenting**: Works best with top-level meshes; parented meshes may have unexpected behavior
|
||||
3. **Non-Uniform Bounds**: Works with any mesh shape, but handles positioned based on AABB
|
||||
4. **WebXR Only**: Grip button interaction designed for WebXR; mouse/touch support would require additional implementation
|
||||
5. **Single Mesh**: One gizmo instance per mesh (no multi-selection scaling currently)
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential features for future implementation:
|
||||
|
||||
- [ ] Mouse/touch interaction support
|
||||
- [ ] Multi-mesh selection and scaling
|
||||
- [ ] Rotation-aware local-space handles
|
||||
- [ ] Custom handle shapes (spheres, cylinders)
|
||||
- [ ] Animation curves for smooth scaling
|
||||
- [ ] Undo/redo integration
|
||||
- [ ] Keyboard modifiers (shift for uniform, ctrl for snap override)
|
||||
- [ ] Handle-specific constraints (lock certain axes)
|
||||
- [ ] Percentage-based scaling input
|
||||
- [ ] Copy scale values between meshes
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Handles not visible
|
||||
- Check that `setEnabled(true)` is called
|
||||
- Verify UtilityLayer is rendering
|
||||
- Ensure handleSize is appropriate for mesh scale
|
||||
|
||||
### Scaling not working
|
||||
- Confirm `update()` is called in render loop
|
||||
- Check that controllers are registered
|
||||
- Verify grip button component exists on controller
|
||||
|
||||
### Snap not working
|
||||
- Confirm `enableSnapping: true`
|
||||
- Check snap distances are > 0
|
||||
- Verify snapping is not disabled in config
|
||||
|
||||
### DiagramEntity not persisting
|
||||
- Ensure `useDiagramEntity: true` and `diagramManager` is provided
|
||||
- Check DiagramEntityAdapter is created
|
||||
- Verify DiagramManager observable is working
|
||||
|
||||
## Version History
|
||||
|
||||
- **v1.0.0** (Initial Implementation)
|
||||
- Four configurable modes
|
||||
- WebXR grip button interaction
|
||||
- Visual feedback system
|
||||
- Snapping with haptic feedback
|
||||
- DiagramEntity integration adapter
|
||||
- Full documentation
|
||||
|
||||
## License
|
||||
|
||||
Part of the "immersive" project. See project LICENSE file.
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions specific to this gizmo:
|
||||
1. Check this PLAN.md documentation
|
||||
2. Review code examples above
|
||||
3. Examine type definitions in `types.ts`
|
||||
4. Test with standalone example before integrating
|
||||
|
||||
---
|
||||
|
||||
**Implementation Status**: ✅ Complete
|
||||
|
||||
All planned features have been implemented and documented.
|
||||
|
||||
@ -1,239 +0,0 @@
|
||||
/**
|
||||
* WebXR Resize Gizmo - Handle Geometry Calculations
|
||||
* Calculates positions for corner, edge, and face handles based on bounding box
|
||||
*/
|
||||
|
||||
import { Vector3, BoundingBox, AbstractMesh } from "@babylonjs/core";
|
||||
import { HandlePosition, HandleType } from "./types";
|
||||
|
||||
/**
|
||||
* Helper class for calculating handle positions from a bounding box
|
||||
*/
|
||||
export class HandleGeometry {
|
||||
/**
|
||||
* Calculate the 8 corners of the oriented bounding box (OBB) in world space
|
||||
*/
|
||||
static calculateOBBCorners(mesh: AbstractMesh): Vector3[] {
|
||||
// Get bounding box in local space
|
||||
const boundingInfo = mesh.getBoundingInfo();
|
||||
const boundingBox = boundingInfo.boundingBox;
|
||||
const min = boundingBox.minimum;
|
||||
const max = boundingBox.maximum;
|
||||
|
||||
// Define 8 corners in local space
|
||||
const localCorners = [
|
||||
new Vector3(min.x, min.y, min.z), // 0: left-bottom-back
|
||||
new Vector3(max.x, min.y, min.z), // 1: right-bottom-back
|
||||
new Vector3(max.x, min.y, max.z), // 2: right-bottom-front
|
||||
new Vector3(min.x, min.y, max.z), // 3: left-bottom-front
|
||||
new Vector3(min.x, max.y, min.z), // 4: left-top-back
|
||||
new Vector3(max.x, max.y, min.z), // 5: right-top-back
|
||||
new Vector3(max.x, max.y, max.z), // 6: right-top-front
|
||||
new Vector3(min.x, max.y, max.z) // 7: left-top-front
|
||||
];
|
||||
|
||||
// Transform corners to world space using mesh's world matrix
|
||||
const worldMatrix = mesh.computeWorldMatrix(true);
|
||||
const worldCorners = localCorners.map(corner =>
|
||||
Vector3.TransformCoordinates(corner, worldMatrix)
|
||||
);
|
||||
|
||||
return worldCorners;
|
||||
}
|
||||
/**
|
||||
* Generate all corner handle positions (8 handles) on the OBB
|
||||
*/
|
||||
static generateCornerHandles(mesh: AbstractMesh, paddingFactor: number = 0): HandlePosition[] {
|
||||
// Get OBB corners in world space
|
||||
const obbCorners = this.calculateOBBCorners(mesh);
|
||||
|
||||
// Get mesh center (pivot point)
|
||||
const center = mesh.absolutePosition;
|
||||
|
||||
// Calculate padding in world units
|
||||
const boundingInfo = mesh.getBoundingInfo();
|
||||
const boundingBox = boundingInfo.boundingBox;
|
||||
const size = boundingBox.extendSize;
|
||||
const avgSize = (size.x + size.y + size.z) / 3;
|
||||
const paddingDistance = avgSize * paddingFactor;
|
||||
|
||||
const corners: HandlePosition[] = [];
|
||||
const cornerIds = [
|
||||
"corner-XYZ", // 0: left-bottom-back
|
||||
"corner-xYZ", // 1: right-bottom-back
|
||||
"corner-xYz", // 2: right-bottom-front
|
||||
"corner-XYz", // 3: left-bottom-front
|
||||
"corner-XyZ", // 4: left-top-back
|
||||
"corner-xyZ", // 5: right-top-back
|
||||
"corner-xyz", // 6: right-top-front
|
||||
"corner-Xyz" // 7: left-top-front
|
||||
];
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const cornerPos = obbCorners[i];
|
||||
|
||||
// Calculate normal from center to corner
|
||||
const normal = cornerPos.subtract(center).normalize();
|
||||
|
||||
// Apply padding by moving corner outward along the normal
|
||||
const position = cornerPos.add(normal.scale(paddingDistance));
|
||||
|
||||
corners.push({
|
||||
position,
|
||||
type: HandleType.CORNER,
|
||||
axes: ["X", "Y", "Z"],
|
||||
normal,
|
||||
id: cornerIds[i]
|
||||
});
|
||||
}
|
||||
|
||||
return corners;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all edge handle positions (12 handles) on the OBB
|
||||
* Edges are at midpoints of the 12 edges of the oriented bounding box
|
||||
*/
|
||||
static generateEdgeHandles(mesh: AbstractMesh, paddingFactor: number = 0): HandlePosition[] {
|
||||
// Get OBB corners in world space
|
||||
const c = this.calculateOBBCorners(mesh);
|
||||
|
||||
// Get mesh center (pivot point)
|
||||
const center = mesh.absolutePosition;
|
||||
|
||||
// Calculate padding distance
|
||||
const boundingInfo = mesh.getBoundingInfo();
|
||||
const boundingBox = boundingInfo.boundingBox;
|
||||
const size = boundingBox.extendSize;
|
||||
const avgSize = (size.x + size.y + size.z) / 3;
|
||||
const paddingDistance = avgSize * paddingFactor;
|
||||
|
||||
const edges: HandlePosition[] = [];
|
||||
|
||||
// Define the 12 edges as pairs of corner indices
|
||||
// Each edge scales the TWO axes perpendicular to the edge direction
|
||||
const edgeDefinitions = [
|
||||
// 4 edges parallel to X-axis (scale Y and Z - perpendicular axes)
|
||||
{ start: 0, end: 1, axes: ["Y", "Z"], id: "edge-x-YZ" }, // left-bottom-back to right-bottom-back (parallel to X)
|
||||
{ start: 2, end: 3, axes: ["Y", "Z"], id: "edge-x-Yz" }, // right-bottom-front to left-bottom-front (parallel to X)
|
||||
{ start: 4, end: 5, axes: ["Y", "Z"], id: "edge-x-yZ" }, // left-top-back to right-top-back (parallel to X)
|
||||
{ start: 6, end: 7, axes: ["Y", "Z"], id: "edge-x-yz" }, // right-top-front to left-top-front (parallel to X)
|
||||
|
||||
// 4 edges parallel to Z-axis (scale X and Y - perpendicular axes)
|
||||
{ start: 1, end: 2, axes: ["X", "Y"], id: "edge-z-xY" }, // right-bottom-back to right-bottom-front (parallel to Z)
|
||||
{ start: 3, end: 0, axes: ["X", "Y"], id: "edge-z-XY" }, // left-bottom-front to left-bottom-back (parallel to Z)
|
||||
{ start: 5, end: 6, axes: ["X", "Y"], id: "edge-z-xy" }, // right-top-back to right-top-front (parallel to Z)
|
||||
{ start: 7, end: 4, axes: ["X", "Y"], id: "edge-z-Xy" }, // left-top-front to left-top-back (parallel to Z)
|
||||
|
||||
// 4 edges parallel to Y-axis (scale X and Z - perpendicular axes)
|
||||
{ start: 0, end: 4, axes: ["X", "Z"], id: "edge-y-XZ" }, // left-bottom-back to left-top-back (parallel to Y)
|
||||
{ start: 1, end: 5, axes: ["X", "Z"], id: "edge-y-xZ" }, // right-bottom-back to right-top-back (parallel to Y)
|
||||
{ start: 2, end: 6, axes: ["X", "Z"], id: "edge-y-xz" }, // right-bottom-front to right-top-front (parallel to Y)
|
||||
{ start: 3, end: 7, axes: ["X", "Z"], id: "edge-y-Xz" } // left-bottom-front to left-top-front (parallel to Y)
|
||||
];
|
||||
|
||||
for (const edge of edgeDefinitions) {
|
||||
// Calculate midpoint of edge
|
||||
const midpoint = c[edge.start].add(c[edge.end]).scale(0.5);
|
||||
|
||||
// Calculate normal from center to midpoint
|
||||
const normal = midpoint.subtract(center).normalize();
|
||||
|
||||
// Apply padding by moving outward along the normal
|
||||
const position = midpoint.add(normal.scale(paddingDistance));
|
||||
|
||||
edges.push({
|
||||
position,
|
||||
type: HandleType.EDGE,
|
||||
axes: edge.axes,
|
||||
normal,
|
||||
id: edge.id
|
||||
});
|
||||
}
|
||||
|
||||
return edges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all face handle positions (6 handles) on the OBB
|
||||
* Faces are at centers of each face of the oriented bounding box
|
||||
*/
|
||||
static generateFaceHandles(mesh: AbstractMesh, paddingFactor: number = 0): HandlePosition[] {
|
||||
// Get OBB corners in world space
|
||||
const c = this.calculateOBBCorners(mesh);
|
||||
|
||||
// Get mesh center (pivot point)
|
||||
const center = mesh.absolutePosition;
|
||||
|
||||
// Calculate padding distance
|
||||
const boundingInfo = mesh.getBoundingInfo();
|
||||
const boundingBox = boundingInfo.boundingBox;
|
||||
const size = boundingBox.extendSize;
|
||||
const avgSize = (size.x + size.y + size.z) / 3;
|
||||
const paddingDistance = avgSize * paddingFactor;
|
||||
|
||||
const faces: HandlePosition[] = [];
|
||||
|
||||
// Define the 6 faces as sets of 4 corner indices
|
||||
const faceDefinitions = [
|
||||
{ corners: [0, 1, 2, 3], axes: ["Y"], id: "face-Y" }, // Bottom face
|
||||
{ corners: [4, 5, 6, 7], axes: ["Y"], id: "face-y" }, // Top face
|
||||
{ corners: [0, 1, 5, 4], axes: ["Z"], id: "face-Z" }, // Back face
|
||||
{ corners: [2, 3, 7, 6], axes: ["Z"], id: "face-z" }, // Front face
|
||||
{ corners: [1, 2, 6, 5], axes: ["X"], id: "face-x" }, // Right face
|
||||
{ corners: [0, 3, 7, 4], axes: ["X"], id: "face-X" } // Left face
|
||||
];
|
||||
|
||||
for (const face of faceDefinitions) {
|
||||
// Calculate center of face (average of 4 corners)
|
||||
let faceCenter = Vector3.Zero();
|
||||
for (const cornerIdx of face.corners) {
|
||||
faceCenter = faceCenter.add(c[cornerIdx]);
|
||||
}
|
||||
faceCenter = faceCenter.scale(0.25);
|
||||
|
||||
// Calculate normal from center to face center
|
||||
const normal = faceCenter.subtract(center).normalize();
|
||||
|
||||
// Apply padding by moving outward along the normal
|
||||
const position = faceCenter.add(normal.scale(paddingDistance));
|
||||
|
||||
faces.push({
|
||||
position,
|
||||
type: HandleType.FACE,
|
||||
axes: face.axes,
|
||||
normal,
|
||||
id: face.id
|
||||
});
|
||||
}
|
||||
|
||||
return faces;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all handles based on mode flags (OBB-based)
|
||||
*/
|
||||
static generateHandles(
|
||||
mesh: AbstractMesh,
|
||||
paddingFactor: number,
|
||||
includeCorners: boolean,
|
||||
includeEdges: boolean,
|
||||
includeFaces: boolean
|
||||
): HandlePosition[] {
|
||||
const handles: HandlePosition[] = [];
|
||||
|
||||
if (includeCorners) {
|
||||
handles.push(...this.generateCornerHandles(mesh, paddingFactor));
|
||||
}
|
||||
|
||||
if (includeEdges) {
|
||||
handles.push(...this.generateEdgeHandles(mesh, paddingFactor));
|
||||
}
|
||||
|
||||
if (includeFaces) {
|
||||
handles.push(...this.generateFaceHandles(mesh, paddingFactor));
|
||||
}
|
||||
|
||||
return handles;
|
||||
}
|
||||
}
|
||||
@ -1,172 +0,0 @@
|
||||
/**
|
||||
* WebXR Resize Gizmo - Configuration Management
|
||||
*/
|
||||
|
||||
import { Vector3 } from "@babylonjs/core";
|
||||
import { ResizeGizmoConfig, DEFAULT_RESIZE_GIZMO_CONFIG, ResizeGizmoMode } from "./types";
|
||||
|
||||
/**
|
||||
* Helper class for managing and validating ResizeGizmo configuration
|
||||
*/
|
||||
export class ResizeGizmoConfigManager {
|
||||
private _config: ResizeGizmoConfig;
|
||||
|
||||
constructor(userConfig?: Partial<ResizeGizmoConfig>) {
|
||||
this._config = this.mergeWithDefaults(userConfig);
|
||||
this.validate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge user config with defaults
|
||||
*/
|
||||
private mergeWithDefaults(userConfig?: Partial<ResizeGizmoConfig>): ResizeGizmoConfig {
|
||||
if (!userConfig) {
|
||||
return { ...DEFAULT_RESIZE_GIZMO_CONFIG };
|
||||
}
|
||||
|
||||
return {
|
||||
...DEFAULT_RESIZE_GIZMO_CONFIG,
|
||||
...userConfig,
|
||||
// Ensure Vector3 objects are properly cloned
|
||||
minScale: userConfig.minScale
|
||||
? userConfig.minScale.clone()
|
||||
: DEFAULT_RESIZE_GIZMO_CONFIG.minScale.clone(),
|
||||
maxScale: userConfig.maxScale
|
||||
? userConfig.maxScale.clone()
|
||||
: DEFAULT_RESIZE_GIZMO_CONFIG.maxScale?.clone()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate configuration values
|
||||
*/
|
||||
private validate(): void {
|
||||
const c = this._config;
|
||||
|
||||
// Validate handle size
|
||||
if (c.handleSize <= 0) {
|
||||
console.warn(`[ResizeGizmo] Invalid handleSize (${c.handleSize}), using default`);
|
||||
c.handleSize = DEFAULT_RESIZE_GIZMO_CONFIG.handleSize;
|
||||
}
|
||||
|
||||
// Validate handle offset
|
||||
if (c.handleOffset < 0) {
|
||||
console.warn(`[ResizeGizmo] Invalid handleOffset (${c.handleOffset}), using 0`);
|
||||
c.handleOffset = 0;
|
||||
}
|
||||
|
||||
// Validate wireframe padding
|
||||
if (c.wireframePadding < 0) {
|
||||
console.warn(`[ResizeGizmo] Invalid wireframePadding (${c.wireframePadding}), using 0`);
|
||||
c.wireframePadding = 0;
|
||||
}
|
||||
|
||||
// Validate wireframe alpha
|
||||
c.wireframeAlpha = Math.max(0, Math.min(1, c.wireframeAlpha));
|
||||
|
||||
// Validate snap distances
|
||||
if (c.snapDistanceX <= 0) c.snapDistanceX = 0.1;
|
||||
if (c.snapDistanceY <= 0) c.snapDistanceY = 0.1;
|
||||
if (c.snapDistanceZ <= 0) c.snapDistanceZ = 0.1;
|
||||
|
||||
// Validate min scale
|
||||
if (c.minScale.x <= 0) c.minScale.x = 0.01;
|
||||
if (c.minScale.y <= 0) c.minScale.y = 0.01;
|
||||
if (c.minScale.z <= 0) c.minScale.z = 0.01;
|
||||
|
||||
// Validate max scale (if set)
|
||||
if (c.maxScale) {
|
||||
if (c.maxScale.x < c.minScale.x) c.maxScale.x = c.minScale.x;
|
||||
if (c.maxScale.y < c.minScale.y) c.maxScale.y = c.minScale.y;
|
||||
if (c.maxScale.z < c.minScale.z) c.maxScale.z = c.minScale.z;
|
||||
}
|
||||
|
||||
// Validate DiagramEntity integration
|
||||
if (c.useDiagramEntity && !c.diagramManager) {
|
||||
console.warn("[ResizeGizmo] useDiagramEntity is true but diagramManager is not provided");
|
||||
}
|
||||
|
||||
// Validate hover scale factor
|
||||
if (c.hoverScaleFactor <= 0) {
|
||||
c.hoverScaleFactor = DEFAULT_RESIZE_GIZMO_CONFIG.hoverScaleFactor;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration (readonly)
|
||||
*/
|
||||
get current(): Readonly<ResizeGizmoConfig> {
|
||||
return this._config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration (partial update)
|
||||
*/
|
||||
update(updates: Partial<ResizeGizmoConfig>): void {
|
||||
this._config = this.mergeWithDefaults({
|
||||
...this._config,
|
||||
...updates
|
||||
});
|
||||
this.validate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set mode
|
||||
*/
|
||||
setMode(mode: ResizeGizmoMode): void {
|
||||
this._config.mode = mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get snap distance for axis
|
||||
*/
|
||||
getSnapDistance(axis: "X" | "Y" | "Z"): number {
|
||||
switch (axis) {
|
||||
case "X": return this._config.snapDistanceX;
|
||||
case "Y": return this._config.snapDistanceY;
|
||||
case "Z": return this._config.snapDistanceZ;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get snap vector
|
||||
*/
|
||||
getSnapVector(): Vector3 {
|
||||
return new Vector3(
|
||||
this._config.snapDistanceX,
|
||||
this._config.snapDistanceY,
|
||||
this._config.snapDistanceZ
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a mode uses corner handles
|
||||
*/
|
||||
usesCornerHandles(): boolean {
|
||||
const mode = this._config.mode;
|
||||
return mode === ResizeGizmoMode.UNIFORM || mode === ResizeGizmoMode.ALL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a mode uses edge handles
|
||||
* Edge handles are disabled to simplify UX
|
||||
*/
|
||||
usesEdgeHandles(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a mode uses face handles
|
||||
*/
|
||||
usesFaceHandles(): boolean {
|
||||
const mode = this._config.mode;
|
||||
return mode === ResizeGizmoMode.SINGLE_AXIS || mode === ResizeGizmoMode.ALL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone configuration
|
||||
*/
|
||||
clone(): ResizeGizmoConfigManager {
|
||||
return new ResizeGizmoConfigManager(this._config);
|
||||
}
|
||||
}
|
||||
@ -1,417 +0,0 @@
|
||||
/**
|
||||
* WebXR Resize Gizmo - Visual Feedback
|
||||
* Handles numeric displays, grids, and snap point visualization
|
||||
*/
|
||||
|
||||
import {
|
||||
Scene,
|
||||
Vector3,
|
||||
AbstractMesh,
|
||||
LinesMesh,
|
||||
MeshBuilder,
|
||||
Color3,
|
||||
DynamicTexture,
|
||||
StandardMaterial,
|
||||
Mesh
|
||||
} from "@babylonjs/core";
|
||||
import { HandlePosition } from "./types";
|
||||
import { ResizeGizmoConfigManager } from "./ResizeGizmoConfig";
|
||||
import { ResizeGizmoSnapping } from "./ResizeGizmoSnapping";
|
||||
|
||||
/**
|
||||
* Manages visual feedback during scaling operations
|
||||
*/
|
||||
export class ResizeGizmoFeedback {
|
||||
private _scene: Scene;
|
||||
private _config: ResizeGizmoConfigManager;
|
||||
private _snapping: ResizeGizmoSnapping;
|
||||
|
||||
// Visual elements
|
||||
private _numericDisplay?: Mesh;
|
||||
private _numericTexture?: DynamicTexture;
|
||||
private _numericMaterial?: StandardMaterial;
|
||||
private _gridLines: LinesMesh[] = [];
|
||||
private _snapIndicators: Mesh[] = [];
|
||||
|
||||
constructor(scene: Scene, config: ResizeGizmoConfigManager, snapping: ResizeGizmoSnapping) {
|
||||
this._scene = scene;
|
||||
this._config = config;
|
||||
this._snapping = snapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show numeric display above mesh
|
||||
*/
|
||||
showNumericDisplay(mesh: AbstractMesh, scale: Vector3, originalScale: Vector3): void {
|
||||
if (!this._config.current.showNumericDisplay) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create or update numeric display
|
||||
if (!this._numericDisplay) {
|
||||
this.createNumericDisplay();
|
||||
}
|
||||
|
||||
if (!this._numericDisplay || !this._numericTexture) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Position above mesh
|
||||
const boundingInfo = mesh.getBoundingInfo();
|
||||
const max = boundingInfo.boundingBox.maximumWorld;
|
||||
this._numericDisplay.position = new Vector3(max.x, max.y + 0.5, max.z);
|
||||
|
||||
// Update text
|
||||
const scalePercent = new Vector3(
|
||||
(scale.x / originalScale.x) * 100,
|
||||
(scale.y / originalScale.y) * 100,
|
||||
(scale.z / originalScale.z) * 100
|
||||
);
|
||||
|
||||
const text = this.formatScaleText(scale, scalePercent);
|
||||
this.updateNumericTexture(text);
|
||||
|
||||
this._numericDisplay.setEnabled(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide numeric display
|
||||
*/
|
||||
hideNumericDisplay(): void {
|
||||
if (this._numericDisplay) {
|
||||
this._numericDisplay.setEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create numeric display mesh
|
||||
*/
|
||||
private createNumericDisplay(): void {
|
||||
const size = 1.0;
|
||||
|
||||
// Create plane for text
|
||||
this._numericDisplay = MeshBuilder.CreatePlane(
|
||||
"gizmo-numeric-display",
|
||||
{ width: size * 2, height: size },
|
||||
this._scene
|
||||
);
|
||||
|
||||
this._numericDisplay.billboardMode = Mesh.BILLBOARDMODE_ALL;
|
||||
this._numericDisplay.isPickable = false;
|
||||
|
||||
// Create dynamic texture
|
||||
const resolution = 512;
|
||||
this._numericTexture = new DynamicTexture(
|
||||
"gizmo-numeric-texture",
|
||||
{ width: resolution * 2, height: resolution },
|
||||
this._scene,
|
||||
false
|
||||
);
|
||||
|
||||
// Create material
|
||||
this._numericMaterial = new StandardMaterial("gizmo-numeric-material", this._scene);
|
||||
this._numericMaterial.diffuseTexture = this._numericTexture;
|
||||
this._numericMaterial.emissiveColor = Color3.White();
|
||||
this._numericMaterial.disableLighting = true;
|
||||
this._numericMaterial.useAlphaFromDiffuseTexture = true;
|
||||
|
||||
this._numericDisplay.material = this._numericMaterial;
|
||||
this._numericDisplay.setEnabled(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format scale text for display
|
||||
*/
|
||||
private formatScaleText(scale: Vector3, scalePercent: Vector3): string {
|
||||
return `X: ${scale.x.toFixed(2)} (${scalePercent.x.toFixed(0)}%)\n` +
|
||||
`Y: ${scale.y.toFixed(2)} (${scalePercent.y.toFixed(0)}%)\n` +
|
||||
`Z: ${scale.z.toFixed(2)} (${scalePercent.z.toFixed(0)}%)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update numeric texture with text
|
||||
*/
|
||||
private updateNumericTexture(text: string): void {
|
||||
if (!this._numericTexture) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = this._numericTexture.getContext();
|
||||
const size = this._numericTexture.getSize();
|
||||
|
||||
// Clear
|
||||
context.clearRect(0, 0, size.width, size.height);
|
||||
|
||||
// Draw background
|
||||
context.fillStyle = "rgba(0, 0, 0, 0.7)";
|
||||
context.fillRect(0, 0, size.width, size.height);
|
||||
|
||||
// Draw text
|
||||
context.fillStyle = "white";
|
||||
context.font = `${this._config.current.numericDisplayFontSize}px monospace`;
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
|
||||
const lines = text.split("\n");
|
||||
const lineHeight = this._config.current.numericDisplayFontSize * 1.2;
|
||||
const startY = (size.height - lineHeight * lines.length) / 2;
|
||||
|
||||
lines.forEach((line, i) => {
|
||||
context.fillText(line, size.width / 2, startY + lineHeight * (i + 0.5));
|
||||
});
|
||||
|
||||
this._numericTexture.update();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show alignment grid during scaling
|
||||
*/
|
||||
showGrid(mesh: AbstractMesh, handle: HandlePosition): void {
|
||||
if (!this._config.current.showGrid) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hideGrid();
|
||||
|
||||
const boundingInfo = mesh.getBoundingInfo();
|
||||
const center = boundingInfo.boundingBox.centerWorld;
|
||||
const size = boundingInfo.boundingBox.extendSizeWorld.scale(2);
|
||||
|
||||
// Create grid lines based on affected axes
|
||||
const axes = handle.axes;
|
||||
|
||||
// Determine grid plane based on axes
|
||||
if (axes.length === 3) {
|
||||
// Uniform - show 3D grid
|
||||
this.create3DGrid(center, size);
|
||||
} else if (axes.length === 2) {
|
||||
// Two-axis - show planar grid
|
||||
this.createPlanarGrid(center, size, axes);
|
||||
} else {
|
||||
// Single-axis - show line grid
|
||||
this.createAxisGrid(center, size, axes[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide alignment grid
|
||||
*/
|
||||
hideGrid(): void {
|
||||
for (const line of this._gridLines) {
|
||||
line.dispose();
|
||||
}
|
||||
this._gridLines = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create 3D grid (for uniform scaling)
|
||||
*/
|
||||
private create3DGrid(center: Vector3, size: Vector3): void {
|
||||
const gridSize = 5;
|
||||
const spacing = 0.5;
|
||||
const color = new Color3(0.5, 0.5, 0.5);
|
||||
|
||||
// XY plane
|
||||
for (let i = -gridSize; i <= gridSize; i++) {
|
||||
// X lines
|
||||
const xLine = MeshBuilder.CreateLines(
|
||||
`grid-x-${i}`,
|
||||
{
|
||||
points: [
|
||||
new Vector3(center.x - gridSize * spacing, center.y + i * spacing, center.z),
|
||||
new Vector3(center.x + gridSize * spacing, center.y + i * spacing, center.z)
|
||||
]
|
||||
},
|
||||
this._scene
|
||||
);
|
||||
xLine.color = color;
|
||||
xLine.alpha = 0.3;
|
||||
xLine.isPickable = false;
|
||||
this._gridLines.push(xLine);
|
||||
|
||||
// Y lines
|
||||
const yLine = MeshBuilder.CreateLines(
|
||||
`grid-y-${i}`,
|
||||
{
|
||||
points: [
|
||||
new Vector3(center.x + i * spacing, center.y - gridSize * spacing, center.z),
|
||||
new Vector3(center.x + i * spacing, center.y + gridSize * spacing, center.z)
|
||||
]
|
||||
},
|
||||
this._scene
|
||||
);
|
||||
yLine.color = color;
|
||||
yLine.alpha = 0.3;
|
||||
yLine.isPickable = false;
|
||||
this._gridLines.push(yLine);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create planar grid (for two-axis scaling)
|
||||
*/
|
||||
private createPlanarGrid(center: Vector3, size: Vector3, axes: ("X" | "Y" | "Z")[]): void {
|
||||
const gridSize = 5;
|
||||
const spacing = 0.5;
|
||||
const color = new Color3(0.5, 0.5, 0.5);
|
||||
|
||||
// Determine which plane
|
||||
const hasX = axes.includes("X");
|
||||
const hasY = axes.includes("Y");
|
||||
const hasZ = axes.includes("Z");
|
||||
|
||||
for (let i = -gridSize; i <= gridSize; i++) {
|
||||
if (hasX && hasY) {
|
||||
// XY plane
|
||||
this.createGridLine(center, i * spacing, "X", color);
|
||||
this.createGridLine(center, i * spacing, "Y", color);
|
||||
} else if (hasX && hasZ) {
|
||||
// XZ plane
|
||||
this.createGridLine(center, i * spacing, "X", color);
|
||||
this.createGridLine(center, i * spacing, "Z", color);
|
||||
} else if (hasY && hasZ) {
|
||||
// YZ plane
|
||||
this.createGridLine(center, i * spacing, "Y", color);
|
||||
this.createGridLine(center, i * spacing, "Z", color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create axis grid (for single-axis scaling)
|
||||
*/
|
||||
private createAxisGrid(center: Vector3, size: Vector3, axis: "X" | "Y" | "Z"): void {
|
||||
const color = axis === "X" ? Color3.Red() : axis === "Y" ? Color3.Green() : Color3.Blue();
|
||||
this.createGridLine(center, 0, axis, color, 1.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single grid line
|
||||
*/
|
||||
private createGridLine(center: Vector3, offset: number, axis: "X" | "Y" | "Z", color: Color3, alpha: number = 0.3): void {
|
||||
const gridSize = 5;
|
||||
let points: Vector3[];
|
||||
|
||||
switch (axis) {
|
||||
case "X":
|
||||
points = [
|
||||
new Vector3(center.x - gridSize, center.y + offset, center.z),
|
||||
new Vector3(center.x + gridSize, center.y + offset, center.z)
|
||||
];
|
||||
break;
|
||||
case "Y":
|
||||
points = [
|
||||
new Vector3(center.x + offset, center.y - gridSize, center.z),
|
||||
new Vector3(center.x + offset, center.y + gridSize, center.z)
|
||||
];
|
||||
break;
|
||||
case "Z":
|
||||
points = [
|
||||
new Vector3(center.x + offset, center.y, center.z - gridSize),
|
||||
new Vector3(center.x + offset, center.y, center.z + gridSize)
|
||||
];
|
||||
break;
|
||||
}
|
||||
|
||||
const line = MeshBuilder.CreateLines(`grid-${axis}-${offset}`, { points }, this._scene);
|
||||
line.color = color;
|
||||
line.alpha = alpha;
|
||||
line.isPickable = false;
|
||||
this._gridLines.push(line);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show snap point indicators
|
||||
*/
|
||||
showSnapIndicators(mesh: AbstractMesh, handle: HandlePosition): void {
|
||||
if (!this._config.current.showSnapPoints || !this._snapping.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hideSnapIndicators();
|
||||
|
||||
const boundingInfo = mesh.getBoundingInfo();
|
||||
const center = boundingInfo.boundingBox.centerWorld;
|
||||
const size = boundingInfo.boundingBox.extendSizeWorld;
|
||||
|
||||
// Create snap indicators along affected axes
|
||||
for (const axis of handle.axes) {
|
||||
const snapDistance = this._config.getSnapDistance(axis);
|
||||
const axisSize = axis === "X" ? size.x : axis === "Y" ? size.y : size.z;
|
||||
|
||||
const snapPoints = this._snapping.getSnapPointsInRange(
|
||||
-axisSize * 2,
|
||||
axisSize * 2,
|
||||
snapDistance
|
||||
);
|
||||
|
||||
for (const snapValue of snapPoints) {
|
||||
let position: Vector3;
|
||||
|
||||
switch (axis) {
|
||||
case "X":
|
||||
position = new Vector3(center.x + snapValue, center.y, center.z);
|
||||
break;
|
||||
case "Y":
|
||||
position = new Vector3(center.x, center.y + snapValue, center.z);
|
||||
break;
|
||||
case "Z":
|
||||
position = new Vector3(center.x, center.y, center.z + snapValue);
|
||||
break;
|
||||
}
|
||||
|
||||
const indicator = MeshBuilder.CreateSphere(
|
||||
`snap-indicator-${axis}-${snapValue}`,
|
||||
{ diameter: 0.05 },
|
||||
this._scene
|
||||
);
|
||||
|
||||
indicator.position = position;
|
||||
indicator.isPickable = false;
|
||||
|
||||
const material = new StandardMaterial(`snap-mat-${axis}-${snapValue}`, this._scene);
|
||||
material.emissiveColor = axis === "X" ? Color3.Red() : axis === "Y" ? Color3.Green() : Color3.Blue();
|
||||
material.alpha = 0.4;
|
||||
material.disableLighting = true;
|
||||
|
||||
indicator.material = material;
|
||||
this._snapIndicators.push(indicator);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide snap indicators
|
||||
*/
|
||||
hideSnapIndicators(): void {
|
||||
for (const indicator of this._snapIndicators) {
|
||||
indicator.dispose();
|
||||
indicator.material?.dispose();
|
||||
}
|
||||
this._snapIndicators = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose all feedback elements
|
||||
*/
|
||||
dispose(): void {
|
||||
this.hideNumericDisplay();
|
||||
this.hideGrid();
|
||||
this.hideSnapIndicators();
|
||||
|
||||
if (this._numericDisplay) {
|
||||
this._numericDisplay.dispose();
|
||||
this._numericDisplay = undefined;
|
||||
}
|
||||
|
||||
if (this._numericTexture) {
|
||||
this._numericTexture.dispose();
|
||||
this._numericTexture = undefined;
|
||||
}
|
||||
|
||||
if (this._numericMaterial) {
|
||||
this._numericMaterial.dispose();
|
||||
this._numericMaterial = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,579 +0,0 @@
|
||||
/**
|
||||
* WebXR Resize Gizmo - Interaction Handling
|
||||
* Manages WebXR pointer detection and grip button interactions
|
||||
*/
|
||||
|
||||
import {
|
||||
Scene,
|
||||
AbstractMesh,
|
||||
Ray,
|
||||
Vector3,
|
||||
Observer,
|
||||
PointerInfo,
|
||||
PointerEventTypes,
|
||||
WebXRInputSource,
|
||||
PickingInfo
|
||||
} from "@babylonjs/core";
|
||||
import {
|
||||
HandlePosition,
|
||||
InteractionState,
|
||||
GizmoInteractionState,
|
||||
ResizeGizmoEvent,
|
||||
ResizeGizmoEventType
|
||||
} from "./types";
|
||||
import { ResizeGizmoConfigManager } from "./ResizeGizmoConfig";
|
||||
import { ResizeGizmoVisuals } from "./ResizeGizmoVisuals";
|
||||
import { ScalingCalculator } from "./ScalingCalculator";
|
||||
import { ResizeGizmoSnapping } from "./ResizeGizmoSnapping";
|
||||
import { ResizeGizmoFeedback } from "./ResizeGizmoFeedback";
|
||||
|
||||
/**
|
||||
* Result of handle detection including pick information
|
||||
*/
|
||||
interface HandlePickResult {
|
||||
handle: HandlePosition;
|
||||
pickInfo: PickingInfo;
|
||||
controller: WebXRInputSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles all WebXR interaction logic for the resize gizmo
|
||||
*/
|
||||
export class ResizeGizmoInteraction {
|
||||
private _scene: Scene;
|
||||
private _config: ResizeGizmoConfigManager;
|
||||
private _visuals: ResizeGizmoVisuals;
|
||||
private _calculator: ScalingCalculator;
|
||||
private _snapping: ResizeGizmoSnapping;
|
||||
private _feedback: ResizeGizmoFeedback;
|
||||
|
||||
// State
|
||||
private _state: GizmoInteractionState = {
|
||||
state: InteractionState.IDLE
|
||||
};
|
||||
|
||||
// Observers
|
||||
private _pointerObserver?: Observer<PointerInfo>;
|
||||
private _xrControllers: Map<string, WebXRInputSource> = new Map();
|
||||
private _gripObservers: Map<string, any> = new Map();
|
||||
|
||||
// Event callback
|
||||
private _onScaleChange?: (event: ResizeGizmoEvent) => void;
|
||||
|
||||
constructor(
|
||||
scene: Scene,
|
||||
config: ResizeGizmoConfigManager,
|
||||
visuals: ResizeGizmoVisuals,
|
||||
calculator: ScalingCalculator,
|
||||
snapping: ResizeGizmoSnapping,
|
||||
feedback: ResizeGizmoFeedback
|
||||
) {
|
||||
this._scene = scene;
|
||||
this._config = config;
|
||||
this._visuals = visuals;
|
||||
this._calculator = calculator;
|
||||
this._snapping = snapping;
|
||||
this._feedback = feedback;
|
||||
|
||||
this.setupPointerObserver();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set callback for scale change events
|
||||
*/
|
||||
setOnScaleChange(callback: (event: ResizeGizmoEvent) => void): void {
|
||||
this._onScaleChange = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register WebXR controller
|
||||
*/
|
||||
registerController(controller: WebXRInputSource): void {
|
||||
const id = controller.uniqueId;
|
||||
|
||||
if (this._xrControllers.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._xrControllers.set(id, controller);
|
||||
|
||||
// Motion controller might not be initialized yet
|
||||
// Listen for motion controller initialization, then register grip handler
|
||||
const setupGripHandler = () => {
|
||||
const gripComponent = controller.motionController?.getComponent("xr-standard-squeeze");
|
||||
|
||||
if (gripComponent) {
|
||||
const observer = gripComponent.onButtonStateChangedObservable.add((component) => {
|
||||
if (component.changes.pressed) {
|
||||
if (component.pressed) {
|
||||
this.onGripPressed(controller);
|
||||
} else {
|
||||
this.onGripReleased(controller);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this._gripObservers.set(id, observer);
|
||||
}
|
||||
};
|
||||
|
||||
// If motion controller already exists, set up handler immediately
|
||||
if (controller.motionController) {
|
||||
setupGripHandler();
|
||||
} else {
|
||||
// Otherwise, wait for motion controller to be initialized
|
||||
controller.onMotionControllerInitObservable.add(() => {
|
||||
setupGripHandler();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister WebXR controller
|
||||
*/
|
||||
unregisterController(controller: WebXRInputSource): void {
|
||||
const id = controller.uniqueId;
|
||||
|
||||
// Remove grip observer
|
||||
const observer = this._gripObservers.get(id);
|
||||
if (observer) {
|
||||
const gripComponent = controller.motionController?.getComponent("xr-standard-squeeze");
|
||||
gripComponent?.onButtonStateChangedObservable.remove(observer);
|
||||
this._gripObservers.delete(id);
|
||||
}
|
||||
|
||||
this._xrControllers.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup pointer observer for hover detection
|
||||
* Note: This only detects main scene meshes, not utility layer meshes
|
||||
*/
|
||||
private setupPointerObserver(): void {
|
||||
this._pointerObserver = this._scene.onPointerObservable.add((pointerInfo) => {
|
||||
if (pointerInfo.type === PointerEventTypes.POINTERMOVE) {
|
||||
this.handlePointerMove(pointerInfo);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle pointer movement (for hover detection)
|
||||
* Only detects target mesh hover - handles are detected via manual ray picking in update()
|
||||
*/
|
||||
private handlePointerMove(pointerInfo: PointerInfo): void {
|
||||
// Only process when not actively scaling
|
||||
if (this._state.state === InteractionState.ACTIVE_SCALING) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for WebXR pointer
|
||||
const pickInfo = pointerInfo.pickInfo;
|
||||
if (!pickInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if hovering over target mesh
|
||||
if (pickInfo.pickedMesh === this._state.targetMesh) {
|
||||
this.onMeshHovered(pickInfo.pickedMesh);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if WebXR pointer is hovering over a handle using manual ray picking
|
||||
* Must use manual picking because handles are in utility layer, not main scene
|
||||
* Returns handle info with pick result for intersection point
|
||||
*/
|
||||
private getHandleUnderPointer(): HandlePickResult | undefined {
|
||||
// Get utility layer scene from visuals
|
||||
const utilityScene = this._visuals.getUtilityScene();
|
||||
|
||||
// Iterate through registered XR controllers
|
||||
for (const controller of this._xrControllers.values()) {
|
||||
if (!controller.pointer) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use getWorldPointerRayToRef to get ray in world space
|
||||
// This is crucial when controllers are parented to a rig
|
||||
const ray = new Ray(Vector3.Zero(), Vector3.Forward(), 1000);
|
||||
controller.getWorldPointerRayToRef(ray);
|
||||
|
||||
// Pick from utility layer scene
|
||||
const pickResult = utilityScene.pickWithRay(ray, (mesh) => {
|
||||
return mesh.id.includes('gizmo-handle');
|
||||
});
|
||||
|
||||
if (pickResult && pickResult.hit && pickResult.pickedMesh) {
|
||||
// Check if picked mesh is one of our handles
|
||||
const handle = this._visuals.getHandleByMesh(pickResult.pickedMesh);
|
||||
if (handle) {
|
||||
return {
|
||||
handle,
|
||||
pickInfo: pickResult,
|
||||
controller
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any XR controller pointer is inside the expanded handle boundary
|
||||
* Used to prevent hover state loss when pointer crosses whitespace between mesh and handles
|
||||
*/
|
||||
private isPointerInsideHandleBoundary(): boolean {
|
||||
// Iterate through registered XR controllers
|
||||
for (const controller of this._xrControllers.values()) {
|
||||
if (!controller.pointer) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get controller ray in world space
|
||||
const ray = new Ray(Vector3.Zero(), Vector3.Forward(), 1000);
|
||||
controller.getWorldPointerRayToRef(ray);
|
||||
|
||||
// Check if this ray intersects the handle boundary
|
||||
if (this._visuals.isPointerInsideHandleBoundary(ray)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mesh hover
|
||||
*/
|
||||
private onMeshHovered(mesh: AbstractMesh): void {
|
||||
if (this._state.state !== InteractionState.HOVER_MESH) {
|
||||
this._state.state = InteractionState.HOVER_MESH;
|
||||
// Visuals already attached via attach() method
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle handle hover
|
||||
*/
|
||||
private onHandleHovered(handlePickResult: HandlePickResult): void {
|
||||
const handle = handlePickResult.handle;
|
||||
|
||||
// Update state
|
||||
if (this._state.hoveredHandle?.id !== handle.id) {
|
||||
// Unhighlight previous handle
|
||||
if (this._state.hoveredHandle) {
|
||||
this._visuals.unhighlightHandle(this._state.hoveredHandle.id);
|
||||
}
|
||||
|
||||
// Highlight new handle
|
||||
this._visuals.highlightHandle(handle.id);
|
||||
this._state.hoveredHandle = handle;
|
||||
this._state.state = InteractionState.HOVER_HANDLE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle hover exit
|
||||
*/
|
||||
private onHoverExit(): void {
|
||||
if (this._state.hoveredHandle) {
|
||||
this._visuals.unhighlightHandle(this._state.hoveredHandle.id);
|
||||
this._state.hoveredHandle = undefined;
|
||||
}
|
||||
|
||||
if (this._state.state !== InteractionState.ACTIVE_SCALING) {
|
||||
this._state.state = InteractionState.IDLE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle grip button press
|
||||
*/
|
||||
private onGripPressed(controller: WebXRInputSource): void {
|
||||
// Only start scaling if hovering over a handle
|
||||
if (this._state.state !== InteractionState.HOVER_HANDLE || !this._state.hoveredHandle || !this._state.targetMesh) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Do a fresh pick to get the intersection point on the handle
|
||||
const utilityScene = this._visuals.getUtilityScene();
|
||||
const ray = new Ray(Vector3.Zero(), Vector3.Forward(), 1000);
|
||||
controller.getWorldPointerRayToRef(ray);
|
||||
|
||||
const pickResult = utilityScene.pickWithRay(ray, (mesh) => {
|
||||
return mesh.id.includes('gizmo-handle');
|
||||
});
|
||||
|
||||
if (!pickResult || !pickResult.hit || !pickResult.pickedPoint) {
|
||||
// Failed to pick handle, abort
|
||||
return;
|
||||
}
|
||||
|
||||
// Get controller position in WORLD SPACE
|
||||
const controllerPosition = controller.pointer.absolutePosition.clone();
|
||||
|
||||
// Get intersection point on handle (world space)
|
||||
const intersectionPoint = pickResult.pickedPoint.clone();
|
||||
|
||||
// Calculate "stick length" - fixed distance from controller to intersection point
|
||||
const stickLength = Vector3.Distance(controllerPosition, intersectionPoint);
|
||||
|
||||
// Get mesh pivot point (scaling center) in world space
|
||||
// Meshes scale from their pivot/position, not from geometric bounding box center
|
||||
const boundingBoxCenter = this._state.targetMesh.absolutePosition.clone();
|
||||
|
||||
// Initialize drag state
|
||||
this._state.state = InteractionState.ACTIVE_SCALING;
|
||||
this._state.activeHandle = this._state.hoveredHandle;
|
||||
this._state.startScale = this._state.targetMesh.scaling.clone();
|
||||
this._state.startPointerPosition = intersectionPoint; // Store intersection point as start
|
||||
this._state.currentPointerPosition = intersectionPoint;
|
||||
this._state.stickLength = stickLength;
|
||||
this._state.boundingBoxCenter = boundingBoxCenter;
|
||||
|
||||
// Update visuals
|
||||
this._visuals.setHandleActive(this._state.activeHandle.id);
|
||||
|
||||
// Show feedback
|
||||
if (this._state.targetMesh) {
|
||||
this._feedback.showGrid(this._state.targetMesh, this._state.activeHandle);
|
||||
this._feedback.showSnapIndicators(this._state.targetMesh, this._state.activeHandle);
|
||||
this._feedback.showNumericDisplay(this._state.targetMesh, this._state.startScale, this._state.startScale);
|
||||
}
|
||||
|
||||
// Emit event
|
||||
this.emitScaleEvent(ResizeGizmoEventType.SCALE_START, this._state.startScale);
|
||||
|
||||
// Apply haptic feedback
|
||||
if (this._config.current.hapticFeedback) {
|
||||
controller.motionController?.pulse(0.5, 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle grip button release
|
||||
*/
|
||||
private onGripReleased(controller: WebXRInputSource): void {
|
||||
if (this._state.state !== InteractionState.ACTIVE_SCALING || !this._state.targetMesh) {
|
||||
return;
|
||||
}
|
||||
|
||||
const finalScale = this._state.targetMesh.scaling.clone();
|
||||
|
||||
// Hide feedback
|
||||
this._feedback.hideGrid();
|
||||
this._feedback.hideSnapIndicators();
|
||||
this._feedback.hideNumericDisplay();
|
||||
|
||||
// Emit event
|
||||
this.emitScaleEvent(ResizeGizmoEventType.SCALE_END, finalScale, this._state.startScale);
|
||||
|
||||
// Reset state
|
||||
this._state.state = InteractionState.HOVER_HANDLE;
|
||||
this._state.activeHandle = undefined;
|
||||
this._state.startScale = undefined;
|
||||
this._state.startPointerPosition = undefined;
|
||||
this._state.currentPointerPosition = undefined;
|
||||
this._state.stickLength = undefined;
|
||||
this._state.boundingBoxCenter = undefined;
|
||||
|
||||
// Apply haptic feedback
|
||||
if (this._config.current.hapticFeedback) {
|
||||
controller.motionController?.pulse(0.3, 50);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update during frame (called every frame)
|
||||
*/
|
||||
update(): void {
|
||||
// Check for handle hover using manual ray picking (only when not actively scaling)
|
||||
if (this._state.state !== InteractionState.ACTIVE_SCALING) {
|
||||
const handlePickResult = this.getHandleUnderPointer();
|
||||
|
||||
if (handlePickResult) {
|
||||
this.onHandleHovered(handlePickResult);
|
||||
} else if (this._state.hoveredHandle) {
|
||||
// Was hovering a handle, but not anymore
|
||||
// Check if still inside handle boundary before exiting hover (prevents loss in whitespace)
|
||||
const stillInsideBoundary = this.isPointerInsideHandleBoundary();
|
||||
|
||||
if (stillInsideBoundary) {
|
||||
// Keep gizmo active but unhighlight the specific handle
|
||||
this._visuals.unhighlightHandle(this._state.hoveredHandle.id);
|
||||
this._state.hoveredHandle = undefined;
|
||||
// Keep state as HOVER_MESH (don't drop to IDLE)
|
||||
this._state.state = InteractionState.HOVER_MESH;
|
||||
} else {
|
||||
// Pointer left the boundary entirely, exit hover completely
|
||||
this.onHoverExit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only process scaling logic during active scaling
|
||||
if (this._state.state !== InteractionState.ACTIVE_SCALING) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._state.targetMesh || !this._state.activeHandle || !this._state.startScale || !this._state.startPointerPosition || !this._state.stickLength || !this._state.boundingBoxCenter) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current virtual point from any active controller using "virtual stick"
|
||||
let currentVirtualPoint: Vector3 | undefined;
|
||||
|
||||
for (const controller of this._xrControllers.values()) {
|
||||
// Check if this controller has grip pressed
|
||||
const gripComponent = controller.motionController?.getComponent("xr-standard-squeeze");
|
||||
if (gripComponent?.pressed) {
|
||||
// Get controller ray in world space
|
||||
const ray = new Ray(Vector3.Zero(), Vector3.Forward(), 1000);
|
||||
controller.getWorldPointerRayToRef(ray);
|
||||
|
||||
// Calculate virtual point = controller origin + (ray direction × stick length)
|
||||
// This is the "end of the stick" that moves/rotates with the controller
|
||||
currentVirtualPoint = ray.origin.add(ray.direction.normalize().scale(this._state.stickLength));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentVirtualPoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._state.currentPointerPosition = currentVirtualPoint;
|
||||
|
||||
// Calculate new scale
|
||||
const newScale = this._calculator.calculateScale(
|
||||
this._state.targetMesh,
|
||||
this._state.activeHandle,
|
||||
this._state.startScale,
|
||||
this._state.startPointerPosition,
|
||||
currentVirtualPoint,
|
||||
this._state.boundingBoxCenter
|
||||
);
|
||||
|
||||
// Apply scale to mesh
|
||||
this._state.targetMesh.scaling = newScale;
|
||||
|
||||
// Update visuals
|
||||
this._visuals.update();
|
||||
this._feedback.showNumericDisplay(this._state.targetMesh, newScale, this._state.startScale);
|
||||
|
||||
// Check for snap proximity (for haptic feedback)
|
||||
if (this._config.current.hapticFeedback && this._snapping.isEnabled()) {
|
||||
// Calculate snap proximity for each affected axis
|
||||
let maxProximity = 0;
|
||||
|
||||
for (const axis of this._state.activeHandle.axes) {
|
||||
const scaleValue = axis === "X" ? newScale.x : axis === "Y" ? newScale.y : newScale.z;
|
||||
const snapDistance = this._config.getSnapDistance(axis);
|
||||
const proximity = this._snapping.calculateSnapProximity(scaleValue, snapDistance);
|
||||
maxProximity = Math.max(maxProximity, proximity);
|
||||
}
|
||||
|
||||
// Trigger haptic pulse if close to snap point
|
||||
if (maxProximity > 0.9) {
|
||||
// Find active controller and pulse
|
||||
for (const controller of this._xrControllers.values()) {
|
||||
const gripComponent = controller.motionController?.getComponent("xr-standard-squeeze");
|
||||
if (gripComponent?.pressed) {
|
||||
controller.motionController?.pulse(0.2, 20);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit event
|
||||
this.emitScaleEvent(ResizeGizmoEventType.SCALE_DRAG, newScale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach to a mesh
|
||||
*/
|
||||
attach(mesh: AbstractMesh): void {
|
||||
this._state.targetMesh = mesh;
|
||||
this._state.state = InteractionState.IDLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach from current mesh
|
||||
*/
|
||||
detach(): void {
|
||||
// Stop any active scaling
|
||||
if (this._state.state === InteractionState.ACTIVE_SCALING) {
|
||||
this._feedback.hideGrid();
|
||||
this._feedback.hideSnapIndicators();
|
||||
this._feedback.hideNumericDisplay();
|
||||
}
|
||||
|
||||
// Reset state
|
||||
this._state = {
|
||||
state: InteractionState.IDLE
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit scale change event
|
||||
*/
|
||||
private emitScaleEvent(type: ResizeGizmoEventType, scale: Vector3, previousScale?: Vector3): void {
|
||||
if (!this._onScaleChange || !this._state.targetMesh) {
|
||||
return;
|
||||
}
|
||||
|
||||
const event: ResizeGizmoEvent = {
|
||||
type,
|
||||
mesh: this._state.targetMesh,
|
||||
scale: scale.clone(),
|
||||
previousScale: previousScale?.clone(),
|
||||
handle: this._state.activeHandle,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
this._onScaleChange(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently scaling
|
||||
*/
|
||||
isScaling(): boolean {
|
||||
return this._state.state === InteractionState.ACTIVE_SCALING;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if hovering over a handle (will handle grip press)
|
||||
*/
|
||||
isHoveringHandle(): boolean {
|
||||
return this._state.state === InteractionState.HOVER_HANDLE && this._state.hoveredHandle != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current interaction state (for external integration)
|
||||
*/
|
||||
getState(): Readonly<GizmoInteractionState> {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose
|
||||
*/
|
||||
dispose(): void {
|
||||
// Remove pointer observer
|
||||
if (this._pointerObserver) {
|
||||
this._scene.onPointerObservable.remove(this._pointerObserver);
|
||||
this._pointerObserver = undefined;
|
||||
}
|
||||
|
||||
// Unregister all controllers
|
||||
for (const controller of this._xrControllers.values()) {
|
||||
this.unregisterController(controller);
|
||||
}
|
||||
|
||||
this._xrControllers.clear();
|
||||
this._gripObservers.clear();
|
||||
}
|
||||
}
|
||||
@ -1,388 +0,0 @@
|
||||
/**
|
||||
* WebXR Resize Gizmo - Manager
|
||||
* Main orchestration class that manages the resize gizmo system
|
||||
*/
|
||||
|
||||
import {
|
||||
Scene,
|
||||
AbstractMesh,
|
||||
Observable,
|
||||
WebXRInputSource,
|
||||
Ray
|
||||
} from "@babylonjs/core";
|
||||
import {
|
||||
ResizeGizmoMode,
|
||||
ResizeGizmoConfig,
|
||||
ResizeGizmoEvent,
|
||||
ResizeGizmoEventType,
|
||||
ResizeGizmoEventCallback,
|
||||
ResizeGizmoObserver
|
||||
} from "./types";
|
||||
import { ResizeGizmoConfigManager } from "./ResizeGizmoConfig";
|
||||
import { ResizeGizmoVisuals } from "./ResizeGizmoVisuals";
|
||||
import { ResizeGizmoInteraction } from "./ResizeGizmoInteraction";
|
||||
import { ScalingCalculator } from "./ScalingCalculator";
|
||||
import { ResizeGizmoSnapping } from "./ResizeGizmoSnapping";
|
||||
import { ResizeGizmoFeedback } from "./ResizeGizmoFeedback";
|
||||
|
||||
/**
|
||||
* Main manager class for the resize gizmo system
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Create gizmo manager
|
||||
* const gizmo = new ResizeGizmoManager(scene, {
|
||||
* mode: ResizeGizmoMode.ALL,
|
||||
* enableSnapping: true,
|
||||
* snapDistanceX: 0.1
|
||||
* });
|
||||
*
|
||||
* // Attach to a mesh
|
||||
* gizmo.attachToMesh(myMesh);
|
||||
*
|
||||
* // Register WebXR controllers
|
||||
* xr.input.onControllerAddedObservable.add((controller) => {
|
||||
* gizmo.registerController(controller);
|
||||
* });
|
||||
*
|
||||
* // Listen to scale events
|
||||
* gizmo.onScaleEnd((event) => {
|
||||
* console.log("Final scale:", event.scale);
|
||||
* });
|
||||
*
|
||||
* // Update in render loop
|
||||
* scene.onBeforeRenderObservable.add(() => {
|
||||
* gizmo.update();
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export class ResizeGizmoManager {
|
||||
private _scene: Scene;
|
||||
private _config: ResizeGizmoConfigManager;
|
||||
|
||||
// Subsystems
|
||||
private _visuals: ResizeGizmoVisuals;
|
||||
private _snapping: ResizeGizmoSnapping;
|
||||
private _calculator: ScalingCalculator;
|
||||
private _feedback: ResizeGizmoFeedback;
|
||||
private _interaction: ResizeGizmoInteraction;
|
||||
|
||||
// Event system
|
||||
private _observable: Observable<ResizeGizmoEvent>;
|
||||
private _observers: ResizeGizmoObserver[] = [];
|
||||
|
||||
// State
|
||||
private _attachedMesh?: AbstractMesh;
|
||||
private _enabled: boolean = true;
|
||||
|
||||
constructor(scene: Scene, config?: Partial<ResizeGizmoConfig>) {
|
||||
this._scene = scene;
|
||||
this._config = new ResizeGizmoConfigManager(config);
|
||||
this._observable = new Observable<ResizeGizmoEvent>();
|
||||
|
||||
// Initialize subsystems
|
||||
this._snapping = new ResizeGizmoSnapping(this._config);
|
||||
this._calculator = new ScalingCalculator(this._config, this._snapping);
|
||||
this._visuals = new ResizeGizmoVisuals(scene, this._config);
|
||||
this._feedback = new ResizeGizmoFeedback(scene, this._config, this._snapping);
|
||||
this._interaction = new ResizeGizmoInteraction(
|
||||
scene,
|
||||
this._config,
|
||||
this._visuals,
|
||||
this._calculator,
|
||||
this._snapping,
|
||||
this._feedback
|
||||
);
|
||||
|
||||
// Wire up interaction events
|
||||
this._interaction.setOnScaleChange((event) => {
|
||||
this.emitEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach gizmo to a mesh
|
||||
*/
|
||||
attachToMesh(mesh: AbstractMesh): void {
|
||||
// Detach from previous mesh
|
||||
if (this._attachedMesh) {
|
||||
this.detachFromMesh();
|
||||
}
|
||||
|
||||
this._attachedMesh = mesh;
|
||||
|
||||
// Attach subsystems
|
||||
this._visuals.attach(mesh);
|
||||
this._interaction.attach(mesh);
|
||||
|
||||
// Emit event
|
||||
this.emitEvent({
|
||||
type: ResizeGizmoEventType.ATTACHED,
|
||||
mesh,
|
||||
scale: mesh.scaling.clone(),
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach from current mesh
|
||||
*/
|
||||
detachFromMesh(): void {
|
||||
if (!this._attachedMesh) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mesh = this._attachedMesh;
|
||||
|
||||
// Detach subsystems
|
||||
this._visuals.detach();
|
||||
this._interaction.detach();
|
||||
|
||||
this._attachedMesh = undefined;
|
||||
|
||||
// Emit event
|
||||
this.emitEvent({
|
||||
type: ResizeGizmoEventType.DETACHED,
|
||||
mesh,
|
||||
scale: mesh.scaling.clone(),
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a WebXR controller
|
||||
*/
|
||||
registerController(controller: WebXRInputSource): void {
|
||||
this._interaction.registerController(controller);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a WebXR controller
|
||||
*/
|
||||
unregisterController(controller: WebXRInputSource): void {
|
||||
this._interaction.unregisterController(controller);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update (call in render loop)
|
||||
*/
|
||||
update(): void {
|
||||
if (!this._enabled || !this._attachedMesh) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._interaction.update();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set gizmo mode
|
||||
*/
|
||||
setMode(mode: ResizeGizmoMode): void {
|
||||
this._config.setMode(mode);
|
||||
|
||||
// Update visuals
|
||||
if (this._attachedMesh) {
|
||||
this._visuals.detach();
|
||||
this._visuals.attach(this._attachedMesh);
|
||||
}
|
||||
|
||||
// Emit event
|
||||
if (this._attachedMesh) {
|
||||
this.emitEvent({
|
||||
type: ResizeGizmoEventType.MODE_CHANGED,
|
||||
mesh: this._attachedMesh,
|
||||
scale: this._attachedMesh.scaling.clone(),
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current mode
|
||||
*/
|
||||
getMode(): ResizeGizmoMode {
|
||||
return this._config.current.mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
*/
|
||||
updateConfig(updates: Partial<ResizeGizmoConfig>): void {
|
||||
this._config.update(updates);
|
||||
|
||||
// Refresh visuals if attached
|
||||
if (this._attachedMesh) {
|
||||
this._visuals.update();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration
|
||||
*/
|
||||
getConfig(): Readonly<ResizeGizmoConfig> {
|
||||
return this._config.current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable gizmo
|
||||
*/
|
||||
setEnabled(enabled: boolean): void {
|
||||
this._enabled = enabled;
|
||||
this._visuals.setVisible(enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if enabled
|
||||
*/
|
||||
isEnabled(): boolean {
|
||||
return this._enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attached mesh
|
||||
*/
|
||||
getAttachedMesh(): AbstractMesh | undefined {
|
||||
return this._attachedMesh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if gizmo is currently being used (scaling in progress)
|
||||
*/
|
||||
isScaling(): boolean {
|
||||
return this._interaction.isScaling();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if hovering over a handle (will handle grip button press)
|
||||
*/
|
||||
isHoveringHandle(): boolean {
|
||||
return this._interaction.isHoveringHandle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current interaction state (for external integration)
|
||||
*/
|
||||
getInteractionState(): string {
|
||||
return this._interaction.getState().state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if pointer ray is inside handle boundary (for external integration)
|
||||
* This is used by DiagramMenuManager to determine if gizmo should stay active
|
||||
*/
|
||||
isPointerInsideHandleBoundary(ray: Ray): boolean {
|
||||
return this._visuals.isPointerInsideHandleBoundary(ray);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the utility layer scene (for filtering picks in main scene)
|
||||
* This is used to prevent pointer events on gizmo handles from leaking to main scene
|
||||
*/
|
||||
getUtilityScene(): Scene {
|
||||
return this._visuals.getUtilityScene();
|
||||
}
|
||||
|
||||
// ===== Event System =====
|
||||
|
||||
/**
|
||||
* Register event listener for specific event type
|
||||
*/
|
||||
on(eventType: ResizeGizmoEventType, callback: ResizeGizmoEventCallback): void {
|
||||
const observer = this._observable.add((event) => {
|
||||
if (event.type === eventType) {
|
||||
callback(event);
|
||||
}
|
||||
});
|
||||
|
||||
this._observers.push({
|
||||
eventType,
|
||||
callback,
|
||||
observer
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove event listener
|
||||
*/
|
||||
off(eventType: ResizeGizmoEventType, callback: ResizeGizmoEventCallback): void {
|
||||
const index = this._observers.findIndex(
|
||||
(o) => o.eventType === eventType && o.callback === callback
|
||||
);
|
||||
|
||||
if (index >= 0) {
|
||||
const observerInfo = this._observers[index];
|
||||
this._observable.remove(observerInfo.observer);
|
||||
this._observers.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen to scale start events
|
||||
*/
|
||||
onScaleStart(callback: ResizeGizmoEventCallback): void {
|
||||
this.on(ResizeGizmoEventType.SCALE_START, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen to scale drag events
|
||||
*/
|
||||
onScaleDrag(callback: ResizeGizmoEventCallback): void {
|
||||
this.on(ResizeGizmoEventType.SCALE_DRAG, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen to scale end events
|
||||
*/
|
||||
onScaleEnd(callback: ResizeGizmoEventCallback): void {
|
||||
this.on(ResizeGizmoEventType.SCALE_END, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen to attach events
|
||||
*/
|
||||
onAttached(callback: ResizeGizmoEventCallback): void {
|
||||
this.on(ResizeGizmoEventType.ATTACHED, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen to detach events
|
||||
*/
|
||||
onDetached(callback: ResizeGizmoEventCallback): void {
|
||||
this.on(ResizeGizmoEventType.DETACHED, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen to mode change events
|
||||
*/
|
||||
onModeChanged(callback: ResizeGizmoEventCallback): void {
|
||||
this.on(ResizeGizmoEventType.MODE_CHANGED, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event
|
||||
*/
|
||||
private emitEvent(event: ResizeGizmoEvent): void {
|
||||
if (this._config.current.emitEvents) {
|
||||
this._observable.notifyObservers(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose all resources
|
||||
*/
|
||||
dispose(): void {
|
||||
// Detach from mesh
|
||||
if (this._attachedMesh) {
|
||||
this.detachFromMesh();
|
||||
}
|
||||
|
||||
// Dispose subsystems
|
||||
this._interaction.dispose();
|
||||
this._feedback.dispose();
|
||||
this._visuals.dispose();
|
||||
|
||||
// Clear observers
|
||||
this._observable.clear();
|
||||
this._observers = [];
|
||||
}
|
||||
}
|
||||
@ -1,135 +0,0 @@
|
||||
/**
|
||||
* WebXR Resize Gizmo - Snapping System
|
||||
* Handles snap-to-grid functionality for scale values
|
||||
*/
|
||||
|
||||
import { Vector3 } from "@babylonjs/core";
|
||||
import { ResizeGizmoConfigManager } from "./ResizeGizmoConfig";
|
||||
|
||||
/**
|
||||
* Snapping utilities for resize gizmo
|
||||
*/
|
||||
export class ResizeGizmoSnapping {
|
||||
private _config: ResizeGizmoConfigManager;
|
||||
|
||||
constructor(config: ResizeGizmoConfigManager) {
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap a single value to nearest snap interval
|
||||
*/
|
||||
private snapValue(value: number, snapInterval: number): number {
|
||||
if (snapInterval <= 0) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return Math.round(value / snapInterval) * snapInterval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap a scale vector to configured snap intervals
|
||||
*/
|
||||
snapScale(scale: Vector3): Vector3 {
|
||||
if (!this._config.current.enableSnapping) {
|
||||
return scale;
|
||||
}
|
||||
|
||||
const config = this._config.current;
|
||||
|
||||
return new Vector3(
|
||||
this.snapValue(scale.x, config.snapDistanceX),
|
||||
this.snapValue(scale.y, config.snapDistanceY),
|
||||
this.snapValue(scale.z, config.snapDistanceZ)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap only specific axes
|
||||
*/
|
||||
snapScaleAxes(scale: Vector3, axes: ("X" | "Y" | "Z")[]): Vector3 {
|
||||
if (!this._config.current.enableSnapping) {
|
||||
return scale;
|
||||
}
|
||||
|
||||
const result = scale.clone();
|
||||
const config = this._config.current;
|
||||
|
||||
for (const axis of axes) {
|
||||
switch (axis) {
|
||||
case "X":
|
||||
result.x = this.snapValue(result.x, config.snapDistanceX);
|
||||
break;
|
||||
case "Y":
|
||||
result.y = this.snapValue(result.y, config.snapDistanceY);
|
||||
break;
|
||||
case "Z":
|
||||
result.z = this.snapValue(result.z, config.snapDistanceZ);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is close to a snap point (for visual feedback)
|
||||
*/
|
||||
isNearSnapPoint(value: number, snapInterval: number, threshold: number = 0.05): boolean {
|
||||
if (snapInterval <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const snapped = this.snapValue(value, snapInterval);
|
||||
return Math.abs(value - snapped) < threshold * snapInterval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nearest snap point for a value
|
||||
*/
|
||||
getNearestSnapPoint(value: number, snapInterval: number): number {
|
||||
return this.snapValue(value, snapInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all snap points in a range
|
||||
*/
|
||||
getSnapPointsInRange(min: number, max: number, snapInterval: number): number[] {
|
||||
if (snapInterval <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const points: number[] = [];
|
||||
const start = Math.ceil(min / snapInterval) * snapInterval;
|
||||
const end = Math.floor(max / snapInterval) * snapInterval;
|
||||
|
||||
for (let value = start; value <= end; value += snapInterval) {
|
||||
points.push(value);
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate haptic feedback intensity based on proximity to snap point
|
||||
* Returns 0-1 value (1 = directly on snap point, 0 = far from snap)
|
||||
*/
|
||||
calculateSnapProximity(value: number, snapInterval: number): number {
|
||||
if (snapInterval <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const snapped = this.snapValue(value, snapInterval);
|
||||
const distance = Math.abs(value - snapped);
|
||||
const maxDistance = snapInterval / 2;
|
||||
|
||||
return Math.max(0, 1 - (distance / maxDistance));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if snapping is enabled
|
||||
*/
|
||||
isEnabled(): boolean {
|
||||
return this._config.current.enableSnapping;
|
||||
}
|
||||
}
|
||||
@ -1,499 +0,0 @@
|
||||
/**
|
||||
* WebXR Resize Gizmo - Visual Rendering
|
||||
* Handles rendering of bounding boxes, handles, and visual feedback
|
||||
*/
|
||||
|
||||
import {
|
||||
Scene,
|
||||
AbstractMesh,
|
||||
Mesh,
|
||||
MeshBuilder,
|
||||
StandardMaterial,
|
||||
Color3,
|
||||
UtilityLayerRenderer,
|
||||
LinesMesh,
|
||||
Vector3,
|
||||
Quaternion,
|
||||
Ray,
|
||||
BoundingBox
|
||||
} from "@babylonjs/core";
|
||||
import { HandlePosition, HandleType } from "./types";
|
||||
import { ResizeGizmoConfigManager } from "./ResizeGizmoConfig";
|
||||
import { HandleGeometry } from "./HandleGeometry";
|
||||
|
||||
/**
|
||||
* Manages all visual elements of the resize gizmo
|
||||
*/
|
||||
export class ResizeGizmoVisuals {
|
||||
private _scene: Scene;
|
||||
private _utilityLayer: UtilityLayerRenderer;
|
||||
private _config: ResizeGizmoConfigManager;
|
||||
|
||||
// Visual elements
|
||||
private _boundingBoxLines?: LinesMesh;
|
||||
private _handleMeshes: Map<string, Mesh> = new Map();
|
||||
private _handleMaterials: Map<string, StandardMaterial> = new Map();
|
||||
|
||||
// Current state
|
||||
private _targetMesh?: AbstractMesh;
|
||||
private _handles: HandlePosition[] = [];
|
||||
private _visible: boolean = false;
|
||||
|
||||
constructor(scene: Scene, config: ResizeGizmoConfigManager) {
|
||||
this._scene = scene;
|
||||
this._config = config;
|
||||
|
||||
// Create utility layer for gizmo rendering
|
||||
this._utilityLayer = new UtilityLayerRenderer(scene);
|
||||
this._utilityLayer.shouldRender = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach gizmo to a mesh and show visuals
|
||||
*/
|
||||
attach(mesh: AbstractMesh): void {
|
||||
this.detach();
|
||||
|
||||
this._targetMesh = mesh;
|
||||
this._visible = true;
|
||||
|
||||
// Generate handle positions
|
||||
this._handles = this.generateHandlePositions();
|
||||
|
||||
// Create visual elements
|
||||
this.createBoundingBox();
|
||||
this.createHandleMeshes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach from current mesh and hide visuals
|
||||
*/
|
||||
detach(): void {
|
||||
this._targetMesh = undefined;
|
||||
this._visible = false;
|
||||
this._handles = [];
|
||||
|
||||
this.disposeBoundingBox();
|
||||
this.disposeHandleMeshes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update visuals (call when mesh transforms or config changes)
|
||||
*/
|
||||
update(): void {
|
||||
if (!this._targetMesh || !this._visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Recompute bounding box
|
||||
this._targetMesh.refreshBoundingInfo();
|
||||
|
||||
// Regenerate handles
|
||||
this._handles = this.generateHandlePositions();
|
||||
|
||||
// Update visuals
|
||||
this.updateBoundingBox();
|
||||
this.updateHandleTransforms();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate handle positions based on current config and mesh bounding box (OBB-based)
|
||||
*/
|
||||
private generateHandlePositions(): HandlePosition[] {
|
||||
if (!this._targetMesh) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Generate handles based on mode (using OBB)
|
||||
return HandleGeometry.generateHandles(
|
||||
this._targetMesh,
|
||||
this._config.current.handleOffset,
|
||||
this._config.usesCornerHandles(),
|
||||
this._config.usesEdgeHandles(),
|
||||
this._config.usesFaceHandles()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the 8 corners of the oriented bounding box (OBB) in world space
|
||||
* @param paddingFactor Optional padding factor to expand corners outward (0.03 = 3%)
|
||||
*/
|
||||
private calculateOBBCorners(paddingFactor: number = 0): Vector3[] {
|
||||
if (!this._targetMesh) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get bounding box in local space
|
||||
const boundingInfo = this._targetMesh.getBoundingInfo();
|
||||
const boundingBox = boundingInfo.boundingBox;
|
||||
const min = boundingBox.minimum;
|
||||
const max = boundingBox.maximum;
|
||||
|
||||
// Define 8 corners in local space
|
||||
const localCorners = [
|
||||
new Vector3(min.x, min.y, min.z), // 0: left-bottom-back
|
||||
new Vector3(max.x, min.y, min.z), // 1: right-bottom-back
|
||||
new Vector3(max.x, min.y, max.z), // 2: right-bottom-front
|
||||
new Vector3(min.x, min.y, max.z), // 3: left-bottom-front
|
||||
new Vector3(min.x, max.y, min.z), // 4: left-top-back
|
||||
new Vector3(max.x, max.y, min.z), // 5: right-top-back
|
||||
new Vector3(max.x, max.y, max.z), // 6: right-top-front
|
||||
new Vector3(min.x, max.y, max.z) // 7: left-top-front
|
||||
];
|
||||
|
||||
// Transform corners to world space using mesh's world matrix
|
||||
const worldMatrix = this._targetMesh.computeWorldMatrix(true);
|
||||
const worldCorners = localCorners.map(corner =>
|
||||
Vector3.TransformCoordinates(corner, worldMatrix)
|
||||
);
|
||||
|
||||
// Apply padding if specified (expand outward from center)
|
||||
if (paddingFactor > 0) {
|
||||
const center = this._targetMesh.absolutePosition;
|
||||
const size = boundingBox.extendSize;
|
||||
const avgSize = (size.x + size.y + size.z) / 3;
|
||||
const paddingDistance = avgSize * paddingFactor;
|
||||
|
||||
return worldCorners.map(corner => {
|
||||
const normal = corner.subtract(center).normalize();
|
||||
return corner.add(normal.scale(paddingDistance));
|
||||
});
|
||||
}
|
||||
|
||||
return worldCorners;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create bounding box wireframe (OBB - oriented bounding box)
|
||||
*/
|
||||
private createBoundingBox(): void {
|
||||
if (!this._targetMesh) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.disposeBoundingBox();
|
||||
|
||||
// Get OBB corners in world space with wireframe padding
|
||||
const corners = this.calculateOBBCorners(this._config.current.wireframePadding);
|
||||
if (corners.length !== 8) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create line points for bounding box edges
|
||||
// Using corner indices: 0-7 as defined in calculateOBBCorners
|
||||
const points = [
|
||||
// Bottom face (y = min)
|
||||
[corners[0], corners[1]], // left-back to right-back
|
||||
[corners[1], corners[2]], // right-back to right-front
|
||||
[corners[2], corners[3]], // right-front to left-front
|
||||
[corners[3], corners[0]], // left-front to left-back
|
||||
// Top face (y = max)
|
||||
[corners[4], corners[5]], // left-back to right-back
|
||||
[corners[5], corners[6]], // right-back to right-front
|
||||
[corners[6], corners[7]], // right-front to left-front
|
||||
[corners[7], corners[4]], // left-front to left-back
|
||||
// Vertical edges
|
||||
[corners[0], corners[4]], // left-back bottom to top
|
||||
[corners[1], corners[5]], // right-back bottom to top
|
||||
[corners[2], corners[6]], // right-front bottom to top
|
||||
[corners[3], corners[7]] // left-front bottom to top
|
||||
];
|
||||
|
||||
// Create lines mesh
|
||||
this._boundingBoxLines = MeshBuilder.CreateLineSystem(
|
||||
"gizmo-boundingbox",
|
||||
{ lines: points },
|
||||
this._utilityLayer.utilityLayerScene
|
||||
);
|
||||
|
||||
this._boundingBoxLines.color = this._config.current.boundingBoxColor;
|
||||
this._boundingBoxLines.alpha = this._config.current.wireframeAlpha;
|
||||
this._boundingBoxLines.isPickable = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update bounding box position/size
|
||||
*/
|
||||
private updateBoundingBox(): void {
|
||||
// Recreate bounding box (simpler than updating)
|
||||
this.createBoundingBox();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose bounding box
|
||||
*/
|
||||
private disposeBoundingBox(): void {
|
||||
if (this._boundingBoxLines) {
|
||||
this._boundingBoxLines.dispose();
|
||||
this._boundingBoxLines = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handle meshes
|
||||
*/
|
||||
private createHandleMeshes(): void {
|
||||
this.disposeHandleMeshes();
|
||||
|
||||
if (!this._targetMesh) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate handle size as percentage of bounding box size
|
||||
const boundingInfo = this._targetMesh.getBoundingInfo();
|
||||
const boundingBox = boundingInfo.boundingBox;
|
||||
const size = boundingBox.extendSizeWorld;
|
||||
const avgSize = (size.x + size.y + size.z) / 3;
|
||||
|
||||
// Handle size is configured percentage of average bounding box dimension
|
||||
// handleSize in config is now a scale factor (e.g., 0.2 = 20% of bounding box)
|
||||
const handleSize = avgSize * this._config.current.handleSize;
|
||||
|
||||
for (const handle of this._handles) {
|
||||
// Create handle mesh (box for now, could be sphere or other shape)
|
||||
const mesh = MeshBuilder.CreateBox(
|
||||
`gizmo-handle-${handle.id}`,
|
||||
{ size: handleSize },
|
||||
this._utilityLayer.utilityLayerScene
|
||||
);
|
||||
|
||||
// Extract and set rotation first (from world matrix)
|
||||
const worldMatrix = this._targetMesh.computeWorldMatrix(true);
|
||||
const rotation = new Quaternion();
|
||||
worldMatrix.decompose(undefined, rotation, undefined);
|
||||
mesh.rotationQuaternion = rotation;
|
||||
|
||||
// Set world-space position (works correctly with rotation)
|
||||
mesh.setAbsolutePosition(handle.position);
|
||||
|
||||
mesh.isPickable = true;
|
||||
|
||||
// Create material
|
||||
const material = new StandardMaterial(
|
||||
`gizmo-handle-mat-${handle.id}`,
|
||||
this._utilityLayer.utilityLayerScene
|
||||
);
|
||||
|
||||
material.emissiveColor = this.getHandleColor(handle.type);
|
||||
material.disableLighting = true;
|
||||
|
||||
mesh.material = material;
|
||||
|
||||
// Store references
|
||||
this._handleMeshes.set(handle.id, mesh);
|
||||
this._handleMaterials.set(handle.id, material);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for handle type
|
||||
*/
|
||||
private getHandleColor(type: HandleType): Color3 {
|
||||
const config = this._config.current;
|
||||
|
||||
switch (type) {
|
||||
case HandleType.CORNER:
|
||||
return config.cornerHandleColor;
|
||||
case HandleType.EDGE:
|
||||
return config.edgeHandleColor;
|
||||
case HandleType.FACE:
|
||||
return config.faceHandleColor;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update handle transforms (position and rotation)
|
||||
*/
|
||||
private updateHandleTransforms(): void {
|
||||
if (!this._targetMesh) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const handle of this._handles) {
|
||||
const mesh = this._handleMeshes.get(handle.id);
|
||||
if (mesh) {
|
||||
// Update rotation to match target mesh (from world matrix)
|
||||
const worldMatrix = this._targetMesh.computeWorldMatrix(true);
|
||||
const rotation = new Quaternion();
|
||||
worldMatrix.decompose(undefined, rotation, undefined);
|
||||
mesh.rotationQuaternion = rotation;
|
||||
|
||||
// Set world-space position (works correctly with rotation)
|
||||
mesh.setAbsolutePosition(handle.position);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose handle meshes
|
||||
*/
|
||||
private disposeHandleMeshes(): void {
|
||||
for (const mesh of this._handleMeshes.values()) {
|
||||
mesh.dispose();
|
||||
}
|
||||
for (const material of this._handleMaterials.values()) {
|
||||
material.dispose();
|
||||
}
|
||||
|
||||
this._handleMeshes.clear();
|
||||
this._handleMaterials.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight a handle (on hover)
|
||||
*/
|
||||
highlightHandle(handleId: string): void {
|
||||
const mesh = this._handleMeshes.get(handleId);
|
||||
const material = this._handleMaterials.get(handleId);
|
||||
|
||||
if (mesh && material) {
|
||||
material.emissiveColor = this._config.current.hoverColor;
|
||||
mesh.scaling = new Vector3(
|
||||
this._config.current.hoverScaleFactor,
|
||||
this._config.current.hoverScaleFactor,
|
||||
this._config.current.hoverScaleFactor
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unhighlight a handle
|
||||
*/
|
||||
unhighlightHandle(handleId: string): void {
|
||||
const handle = this._handles.find(h => h.id === handleId);
|
||||
const mesh = this._handleMeshes.get(handleId);
|
||||
const material = this._handleMaterials.get(handleId);
|
||||
|
||||
if (handle && mesh && material) {
|
||||
material.emissiveColor = this.getHandleColor(handle.type);
|
||||
mesh.scaling = new Vector3(1, 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set handle to active state (during drag)
|
||||
*/
|
||||
setHandleActive(handleId: string): void {
|
||||
const material = this._handleMaterials.get(handleId);
|
||||
if (material) {
|
||||
material.emissiveColor = this._config.current.activeColor;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set visibility
|
||||
*/
|
||||
setVisible(visible: boolean): void {
|
||||
this._visible = visible;
|
||||
|
||||
if (this._boundingBoxLines) {
|
||||
this._boundingBoxLines.setEnabled(visible);
|
||||
}
|
||||
|
||||
for (const mesh of this._handleMeshes.values()) {
|
||||
mesh.setEnabled(visible);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get handle by mesh
|
||||
*/
|
||||
getHandleByMesh(mesh: AbstractMesh): HandlePosition | undefined {
|
||||
for (const handle of this._handles) {
|
||||
const handleMesh = this._handleMeshes.get(handle.id);
|
||||
if (handleMesh === mesh) {
|
||||
return handle;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all handles
|
||||
*/
|
||||
getHandles(): ReadonlyArray<HandlePosition> {
|
||||
return this._handles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get utility layer scene
|
||||
*/
|
||||
getUtilityScene(): Scene {
|
||||
return this._utilityLayer.utilityLayerScene;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a ray intersects the expanded bounding volume that encompasses all handles
|
||||
* This creates a "grace zone" to prevent hover state loss in whitespace between mesh and handles
|
||||
*
|
||||
* Uses local space transformation for accuracy - transforms ray to mesh local space
|
||||
* and performs AABB intersection test with manual slab method
|
||||
*/
|
||||
isPointerInsideHandleBoundary(ray: Ray): boolean {
|
||||
if (!this._targetMesh || !this._config.current.keepHoverInHandleBoundary) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Transform ray from world space to mesh local space
|
||||
const worldMatrix = this._targetMesh.computeWorldMatrix(true);
|
||||
const invWorldMatrix = worldMatrix.clone().invert();
|
||||
|
||||
const localOrigin = Vector3.TransformCoordinates(ray.origin, invWorldMatrix);
|
||||
const localDirection = Vector3.TransformNormal(ray.direction, invWorldMatrix);
|
||||
|
||||
// Get local space bounding box
|
||||
const boundingInfo = this._targetMesh.getBoundingInfo();
|
||||
const boundingBox = boundingInfo.boundingBox;
|
||||
const size = boundingBox.extendSize;
|
||||
const avgSize = (size.x + size.y + size.z) / 3;
|
||||
|
||||
// Calculate expanded padding (handleOffset is a fraction, need to scale by avgSize)
|
||||
const handleSize = avgSize * this._config.current.handleSize;
|
||||
const paddingDistance = avgSize * this._config.current.handleOffset;
|
||||
const totalPadding = paddingDistance + (handleSize / 2);
|
||||
|
||||
// Create expanded AABB in local space
|
||||
const paddingVec = new Vector3(totalPadding, totalPadding, totalPadding);
|
||||
const min = boundingBox.minimum.subtract(paddingVec);
|
||||
const max = boundingBox.maximum.add(paddingVec);
|
||||
|
||||
// Ray-AABB intersection test using slab method
|
||||
// https://tavianator.com/2011/ray_box.html
|
||||
const invDir = new Vector3(
|
||||
1 / localDirection.x,
|
||||
1 / localDirection.y,
|
||||
1 / localDirection.z
|
||||
);
|
||||
|
||||
const t1 = (min.x - localOrigin.x) * invDir.x;
|
||||
const t2 = (max.x - localOrigin.x) * invDir.x;
|
||||
const t3 = (min.y - localOrigin.y) * invDir.y;
|
||||
const t4 = (max.y - localOrigin.y) * invDir.y;
|
||||
const t5 = (min.z - localOrigin.z) * invDir.z;
|
||||
const t6 = (max.z - localOrigin.z) * invDir.z;
|
||||
|
||||
const tmin = Math.max(Math.max(Math.min(t1, t2), Math.min(t3, t4)), Math.min(t5, t6));
|
||||
const tmax = Math.min(Math.min(Math.max(t1, t2), Math.max(t3, t4)), Math.max(t5, t6));
|
||||
|
||||
// If tmax < 0, ray is intersecting AABB but the box is behind the ray
|
||||
if (tmax < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If tmin > tmax, ray doesn't intersect AABB
|
||||
if (tmin > tmax) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ray intersects the expanded bounding box
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose all resources
|
||||
*/
|
||||
dispose(): void {
|
||||
this.detach();
|
||||
this._utilityLayer.dispose();
|
||||
}
|
||||
}
|
||||
@ -1,316 +0,0 @@
|
||||
/**
|
||||
* WebXR Resize Gizmo - Scaling Calculations
|
||||
* Calculates new scale values based on handle type and drag motion
|
||||
*/
|
||||
|
||||
import { Vector3, AbstractMesh } from "@babylonjs/core";
|
||||
import { HandlePosition, HandleType } from "./types";
|
||||
import { ResizeGizmoConfigManager } from "./ResizeGizmoConfig";
|
||||
import { ResizeGizmoSnapping } from "./ResizeGizmoSnapping";
|
||||
|
||||
/**
|
||||
* Handles all scaling calculations for different handle types
|
||||
*/
|
||||
export class ScalingCalculator {
|
||||
private _config: ResizeGizmoConfigManager;
|
||||
private _snapping: ResizeGizmoSnapping;
|
||||
|
||||
constructor(config: ResizeGizmoConfigManager, snapping: ResizeGizmoSnapping) {
|
||||
this._config = config;
|
||||
this._snapping = snapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate new scale based on handle drag
|
||||
*/
|
||||
calculateScale(
|
||||
mesh: AbstractMesh,
|
||||
handle: HandlePosition,
|
||||
startScale: Vector3,
|
||||
startPointerPosition: Vector3,
|
||||
currentPointerPosition: Vector3,
|
||||
boundingBoxCenter?: Vector3
|
||||
): Vector3 {
|
||||
// Calculate drag vector (world space)
|
||||
const dragVector = currentPointerPosition.subtract(startPointerPosition);
|
||||
|
||||
// Calculate scale based on handle type
|
||||
let newScale: Vector3;
|
||||
|
||||
switch (handle.type) {
|
||||
case HandleType.CORNER:
|
||||
newScale = this.calculateUniformScale(mesh, handle, startScale, startPointerPosition, currentPointerPosition, boundingBoxCenter);
|
||||
break;
|
||||
case HandleType.EDGE:
|
||||
newScale = this.calculateTwoAxisScale(mesh, handle, startScale, startPointerPosition, currentPointerPosition, boundingBoxCenter);
|
||||
break;
|
||||
case HandleType.FACE:
|
||||
newScale = this.calculateSingleAxisScale(mesh, handle, startScale, startPointerPosition, currentPointerPosition, boundingBoxCenter);
|
||||
break;
|
||||
}
|
||||
|
||||
// Apply snapping
|
||||
newScale = this._snapping.snapScaleAxes(newScale, handle.axes);
|
||||
|
||||
// Apply constraints
|
||||
newScale = this.applyConstraints(newScale);
|
||||
|
||||
return newScale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate uniform scale (all axes together) using distance-ratio method
|
||||
* Uses "virtual stick" metaphor - scale based on distance from bounding box center
|
||||
*/
|
||||
private calculateUniformScale(
|
||||
mesh: AbstractMesh,
|
||||
handle: HandlePosition,
|
||||
startScale: Vector3,
|
||||
startVirtualPoint: Vector3,
|
||||
currentVirtualPoint: Vector3,
|
||||
boundingBoxCenter?: Vector3
|
||||
): Vector3 {
|
||||
// If no bounding box center provided, fall back to simple drag-based scaling
|
||||
if (!boundingBoxCenter) {
|
||||
const dragVector = currentVirtualPoint.subtract(startVirtualPoint);
|
||||
const worldMatrix = mesh.getWorldMatrix();
|
||||
const worldNormal = Vector3.TransformNormal(handle.normal, worldMatrix).normalize();
|
||||
const dragDistance = Vector3.Dot(dragVector, worldNormal);
|
||||
const boundingInfo = mesh.getBoundingInfo();
|
||||
const boundingSize = boundingInfo.boundingBox.extendSizeWorld;
|
||||
const avgSize = (boundingSize.x + boundingSize.y + boundingSize.z) / 3;
|
||||
const sensitivity = 2.0;
|
||||
const scaleFactor = 1 + (dragDistance / avgSize) * sensitivity;
|
||||
|
||||
return new Vector3(
|
||||
startScale.x * scaleFactor,
|
||||
startScale.y * scaleFactor,
|
||||
startScale.z * scaleFactor
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate distance from bounding box center to start virtual point
|
||||
const startDistance = Vector3.Distance(boundingBoxCenter, startVirtualPoint);
|
||||
|
||||
// Calculate distance from bounding box center to current virtual point
|
||||
const currentDistance = Vector3.Distance(boundingBoxCenter, currentVirtualPoint);
|
||||
|
||||
// Calculate scale ratio based on distance change
|
||||
const scaleRatio = currentDistance / startDistance;
|
||||
|
||||
// Apply uniform scale to all axes
|
||||
return new Vector3(
|
||||
startScale.x * scaleRatio,
|
||||
startScale.y * scaleRatio,
|
||||
startScale.z * scaleRatio
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate two-axis scale (planar) using distance-ratio method
|
||||
* Uses "virtual stick" metaphor - scale based on distance from pivot point
|
||||
*/
|
||||
private calculateTwoAxisScale(
|
||||
mesh: AbstractMesh,
|
||||
handle: HandlePosition,
|
||||
startScale: Vector3,
|
||||
startVirtualPoint: Vector3,
|
||||
currentVirtualPoint: Vector3,
|
||||
boundingBoxCenter?: Vector3
|
||||
): Vector3 {
|
||||
const newScale = startScale.clone();
|
||||
|
||||
// If no bounding box center provided, fall back to old drag-based method
|
||||
if (!boundingBoxCenter) {
|
||||
const dragVector = currentVirtualPoint.subtract(startVirtualPoint);
|
||||
const worldMatrix = mesh.getWorldMatrix();
|
||||
const worldNormal = Vector3.TransformNormal(handle.normal, worldMatrix).normalize();
|
||||
const dragDistance = Vector3.Dot(dragVector, worldNormal);
|
||||
const boundingInfo = mesh.getBoundingInfo();
|
||||
const boundingSize = boundingInfo.boundingBox.extendSizeWorld;
|
||||
const axes = handle.axes;
|
||||
const sensitivity = 2.0;
|
||||
|
||||
if (this._config.current.lockAspectRatio) {
|
||||
const avgSize = (
|
||||
(axes.includes("X") ? boundingSize.x : 0) +
|
||||
(axes.includes("Y") ? boundingSize.y : 0) +
|
||||
(axes.includes("Z") ? boundingSize.z : 0)
|
||||
) / axes.length;
|
||||
const scaleFactor = 1 + (dragDistance / avgSize) * sensitivity;
|
||||
|
||||
for (const axis of axes) {
|
||||
switch (axis) {
|
||||
case "X": newScale.x = startScale.x * scaleFactor; break;
|
||||
case "Y": newScale.y = startScale.y * scaleFactor; break;
|
||||
case "Z": newScale.z = startScale.z * scaleFactor; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return newScale;
|
||||
}
|
||||
|
||||
// Calculate distance from pivot to virtual points
|
||||
const startDistance = Vector3.Distance(boundingBoxCenter, startVirtualPoint);
|
||||
const currentDistance = Vector3.Distance(boundingBoxCenter, currentVirtualPoint);
|
||||
|
||||
// Calculate single scale ratio based on distance change
|
||||
// This ensures both axes scale uniformly (same amount)
|
||||
const scaleRatio = currentDistance / startDistance;
|
||||
|
||||
// Apply same scale ratio to both axes
|
||||
const axes = handle.axes;
|
||||
for (const axis of axes) {
|
||||
switch (axis) {
|
||||
case "X":
|
||||
newScale.x = startScale.x * scaleRatio;
|
||||
break;
|
||||
case "Y":
|
||||
newScale.y = startScale.y * scaleRatio;
|
||||
break;
|
||||
case "Z":
|
||||
newScale.z = startScale.z * scaleRatio;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return newScale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate single-axis scale using distance-ratio method
|
||||
* Uses "virtual stick" metaphor - scale based on distance from pivot point
|
||||
*/
|
||||
private calculateSingleAxisScale(
|
||||
mesh: AbstractMesh,
|
||||
handle: HandlePosition,
|
||||
startScale: Vector3,
|
||||
startVirtualPoint: Vector3,
|
||||
currentVirtualPoint: Vector3,
|
||||
boundingBoxCenter?: Vector3
|
||||
): Vector3 {
|
||||
const newScale = startScale.clone();
|
||||
|
||||
// Get axis direction
|
||||
const axis = handle.axes[0]; // Only one axis for face handles
|
||||
|
||||
// If no bounding box center provided, fall back to old drag-based method
|
||||
if (!boundingBoxCenter) {
|
||||
const dragVector = currentVirtualPoint.subtract(startVirtualPoint);
|
||||
const worldMatrix = mesh.getWorldMatrix();
|
||||
const worldNormal = Vector3.TransformNormal(handle.normal, worldMatrix).normalize();
|
||||
const dragDistance = Vector3.Dot(dragVector, worldNormal);
|
||||
const boundingInfo = mesh.getBoundingInfo();
|
||||
const boundingSize = boundingInfo.boundingBox.extendSizeWorld;
|
||||
let axisSize: number;
|
||||
|
||||
switch (axis) {
|
||||
case "X": axisSize = boundingSize.x; break;
|
||||
case "Y": axisSize = boundingSize.y; break;
|
||||
case "Z": axisSize = boundingSize.z; break;
|
||||
}
|
||||
|
||||
const sensitivity = 2.0;
|
||||
const scaleFactor = 1 + (dragDistance / axisSize) * sensitivity;
|
||||
|
||||
switch (axis) {
|
||||
case "X": newScale.x = startScale.x * scaleFactor; break;
|
||||
case "Y": newScale.y = startScale.y * scaleFactor; break;
|
||||
case "Z": newScale.z = startScale.z * scaleFactor; break;
|
||||
}
|
||||
|
||||
return newScale;
|
||||
}
|
||||
|
||||
// Calculate vector from pivot to virtual points
|
||||
const startVector = startVirtualPoint.subtract(boundingBoxCenter);
|
||||
const currentVector = currentVirtualPoint.subtract(boundingBoxCenter);
|
||||
|
||||
// Get local axis vector
|
||||
let localAxisVector: Vector3;
|
||||
switch (axis) {
|
||||
case "X":
|
||||
localAxisVector = Vector3.Right();
|
||||
break;
|
||||
case "Y":
|
||||
localAxisVector = Vector3.Up();
|
||||
break;
|
||||
case "Z":
|
||||
localAxisVector = Vector3.Forward();
|
||||
break;
|
||||
}
|
||||
|
||||
// Transform axis to world space
|
||||
const worldMatrix = mesh.getWorldMatrix();
|
||||
const worldAxisVector = Vector3.TransformNormal(localAxisVector, worldMatrix).normalize();
|
||||
|
||||
// Project start and current vectors onto this axis
|
||||
const startProjection = Vector3.Dot(startVector, worldAxisVector);
|
||||
const currentProjection = Vector3.Dot(currentVector, worldAxisVector);
|
||||
|
||||
// Calculate scale ratio for this axis
|
||||
// Avoid division by zero
|
||||
const scaleRatio = Math.abs(startProjection) > 0.001
|
||||
? currentProjection / startProjection
|
||||
: 1.0;
|
||||
|
||||
// Apply scale to this axis only
|
||||
switch (axis) {
|
||||
case "X":
|
||||
newScale.x = startScale.x * scaleRatio;
|
||||
break;
|
||||
case "Y":
|
||||
newScale.y = startScale.y * scaleRatio;
|
||||
break;
|
||||
case "Z":
|
||||
newScale.z = startScale.z * scaleRatio;
|
||||
break;
|
||||
}
|
||||
|
||||
return newScale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply min/max constraints to scale
|
||||
*/
|
||||
private applyConstraints(scale: Vector3): Vector3 {
|
||||
const config = this._config.current;
|
||||
const constrained = scale.clone();
|
||||
|
||||
// Apply minimum scale
|
||||
constrained.x = Math.max(constrained.x, config.minScale.x);
|
||||
constrained.y = Math.max(constrained.y, config.minScale.y);
|
||||
constrained.z = Math.max(constrained.z, config.minScale.z);
|
||||
|
||||
// Apply maximum scale (if set)
|
||||
if (config.maxScale) {
|
||||
constrained.x = Math.min(constrained.x, config.maxScale.x);
|
||||
constrained.y = Math.min(constrained.y, config.maxScale.y);
|
||||
constrained.z = Math.min(constrained.z, config.maxScale.z);
|
||||
}
|
||||
|
||||
return constrained;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate scale delta (for display)
|
||||
*/
|
||||
calculateScaleDelta(currentScale: Vector3, originalScale: Vector3): Vector3 {
|
||||
return new Vector3(
|
||||
currentScale.x - originalScale.x,
|
||||
currentScale.y - originalScale.y,
|
||||
currentScale.z - originalScale.z
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate scale percentage (for display)
|
||||
*/
|
||||
calculateScalePercentage(currentScale: Vector3, originalScale: Vector3): Vector3 {
|
||||
return new Vector3(
|
||||
(currentScale.x / originalScale.x) * 100,
|
||||
(currentScale.y / originalScale.y) * 100,
|
||||
(currentScale.z / originalScale.z) * 100
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,51 +1,2 @@
|
||||
# ResizeGizmo TODO and Known Issues
|
||||
|
||||
## Recently Completed
|
||||
|
||||
### ✅ Remove Edge Handles to Simplify UX (Completed 2025-11-14)
|
||||
- **Problem:** Edge handles (green, two-axis scaling) added cognitive complexity without unique capabilities
|
||||
- **User Decision:** Simplify interface by removing edge handles entirely
|
||||
- **Solution:**
|
||||
1. Removed `TWO_AXIS` mode from `ResizeGizmoMode` enum
|
||||
2. Updated `usesEdgeHandles()` to always return `false`
|
||||
3. Updated mode comments to reflect 14 total handles (6 face + 8 corner)
|
||||
- **Result:** Simpler, more intuitive interface with only two handle types:
|
||||
- **Corner handles (blue):** Uniform scaling on all axes
|
||||
- **Face handles (red):** Single-axis scaling
|
||||
- All scaling capabilities still available (two-axis can be done sequentially with face handles)
|
||||
- **Files Modified:**
|
||||
- `types.ts`: Removed TWO_AXIS mode
|
||||
- `ResizeGizmoConfig.ts`: Disabled edge handles
|
||||
- HandleGeometry still contains edge generation code but it's never called
|
||||
|
||||
### ✅ Fix OBB-Based Scaling for Rotated Meshes (Completed 2025-11-14)
|
||||
- **Problem:** Bounding box wireframe and handles were using AABB (axis-aligned), not rotating with mesh
|
||||
- **User Requirement:** Scaling should follow mesh's rotated local axes with handles on OBB
|
||||
- **Solution:** Implemented true OBB (oriented bounding box) system:
|
||||
1. Created `calculateOBBCorners()` to transform local corners to world space
|
||||
2. Updated bounding box visualization to use OBB corners (lines rotate with mesh)
|
||||
3. Rewrote all handle generation (corner, edge, face) to position on OBB
|
||||
4. Verified ScalingCalculator correctly transforms local axes to world space
|
||||
- **Result:** Bounding box and handles now rotate with mesh, scaling follows mesh's local coordinate system
|
||||
- **Files Modified:**
|
||||
- `ResizeGizmoVisuals.ts`: OBB wireframe visualization
|
||||
- `HandleGeometry.ts`: OBB-based handle positioning
|
||||
- `ScalingCalculator.ts`: Already correct (transforms axes to world space)
|
||||
|
||||
### ✅ Move Handles Inside Bounding Box (Completed 2025-11-13)
|
||||
- **Problem:** Handles were positioned outside bounding box, causing selection issues
|
||||
- **Solution:** Reversed padding direction in `HandleGeometry.ts`
|
||||
- **Result:** Handles now 5% inside edges instead of 5% outside
|
||||
- **Commit:** `204ef67`
|
||||
|
||||
### ✅ Fix Color Persistence Bug (Completed 2025-11-13)
|
||||
- **Problem:** Diagram entities losing color when scaled via ResizeGizmo
|
||||
- **Root Cause:** `DiagramEntityAdapter` was only copying metadata, not extracting color from material
|
||||
- **Solution:** Use `toDiagramEntity()` converter which properly extracts color from material
|
||||
- **Commit:** `26b48b2`
|
||||
|
||||
### ✅ Extract DiagramEntityAdapter to Integration Layer (Completed 2025-11-13)
|
||||
- **Problem:** Adapter was in ResizeGizmo folder, causing tight coupling
|
||||
- **Solution:** Moved to `src/integration/gizmo/` with dependency injection
|
||||
- **Result:** ResizeGizmo is now pure and reusable
|
||||
- **Commit:** `26b48b2`
|
||||
|
||||
@ -1,61 +1,552 @@
|
||||
import {
|
||||
AbstractMesh,
|
||||
Color3,
|
||||
Material,
|
||||
Mesh,
|
||||
MeshBuilder,
|
||||
Observable,
|
||||
Observer,
|
||||
StandardMaterial,
|
||||
UtilityLayerRenderer,
|
||||
Vector3,
|
||||
WebXRInputSource,
|
||||
} from '@babylonjs/core';
|
||||
import { DefaultScene } from '../../defaultScene';
|
||||
|
||||
/**
|
||||
* WebXR Resize Gizmo
|
||||
* Self-contained, reusable resize gizmo system for BabylonJS with WebXR support
|
||||
*
|
||||
* @example Basic usage:
|
||||
* ```typescript
|
||||
* import { ResizeGizmoManager, ResizeGizmoMode } from './gizmos/ResizeGizmo';
|
||||
*
|
||||
* const gizmo = new ResizeGizmoManager(scene, {
|
||||
* mode: ResizeGizmoMode.ALL,
|
||||
* enableSnapping: true
|
||||
* });
|
||||
*
|
||||
* gizmo.attachToMesh(myMesh);
|
||||
*
|
||||
* xr.input.onControllerAddedObservable.add((controller) => {
|
||||
* gizmo.registerController(controller);
|
||||
* });
|
||||
*
|
||||
* scene.onBeforeRenderObservable.add(() => {
|
||||
* gizmo.update();
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @example With event callbacks:
|
||||
* ```typescript
|
||||
* gizmo.onScaleEnd((event) => {
|
||||
* console.log('New scale:', event.scale);
|
||||
* // Persist changes, update UI, etc.
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @note For DiagramEntity integration, see src/integration/gizmo/DiagramEntityAdapter
|
||||
* Event emitted during and after scaling operations
|
||||
*/
|
||||
export interface ResizeGizmoEvent {
|
||||
mesh: AbstractMesh;
|
||||
}
|
||||
|
||||
// Main manager
|
||||
export { ResizeGizmoManager } from "./ResizeGizmoManager";
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
// Configuration
|
||||
export { ResizeGizmoConfigManager } from "./ResizeGizmoConfig";
|
||||
/**
|
||||
* Handle state for visual feedback
|
||||
*/
|
||||
enum HandleState {
|
||||
NORMAL = 'normal',
|
||||
HOVER = 'hover',
|
||||
ACTIVE = 'active',
|
||||
}
|
||||
|
||||
// Types
|
||||
export {
|
||||
ResizeGizmoMode,
|
||||
HandleType,
|
||||
InteractionState,
|
||||
ResizeGizmoEventType,
|
||||
ResizeGizmoConfig,
|
||||
ResizeGizmoEvent,
|
||||
ResizeGizmoEventCallback,
|
||||
HandlePosition,
|
||||
DEFAULT_RESIZE_GIZMO_CONFIG
|
||||
} from "./types";
|
||||
/**
|
||||
* Information about a handle
|
||||
*/
|
||||
interface HandleInfo {
|
||||
mesh: Mesh;
|
||||
type: HandleType;
|
||||
state: HandleState;
|
||||
material: StandardMaterial;
|
||||
/** Local space offset from target center for positioning */
|
||||
localOffset: Vector3;
|
||||
}
|
||||
|
||||
// Internal classes (exported for advanced usage)
|
||||
export { ResizeGizmoVisuals } from "./ResizeGizmoVisuals";
|
||||
export { ResizeGizmoInteraction } from "./ResizeGizmoInteraction";
|
||||
export { ResizeGizmoSnapping } from "./ResizeGizmoSnapping";
|
||||
export { ResizeGizmoFeedback } from "./ResizeGizmoFeedback";
|
||||
export { ScalingCalculator } from "./ScalingCalculator";
|
||||
export { HandleGeometry } from "./HandleGeometry";
|
||||
/**
|
||||
* 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 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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,318 +0,0 @@
|
||||
/**
|
||||
* WebXR Resize Gizmo - Type Definitions
|
||||
* Self-contained resize gizmo system for BabylonJS with WebXR support
|
||||
*/
|
||||
|
||||
import { Vector3, Color3, AbstractMesh, Observer } from "@babylonjs/core";
|
||||
|
||||
/**
|
||||
* Scaling mode determines which handles are visible and how scaling behaves
|
||||
*/
|
||||
export enum ResizeGizmoMode {
|
||||
/** Only face-center handles (6 handles) - scale single axis */
|
||||
SINGLE_AXIS = "SINGLE_AXIS",
|
||||
|
||||
/** Only corner handles (8 handles) - uniform scaling all axes */
|
||||
UNIFORM = "UNIFORM",
|
||||
|
||||
/** All handles enabled (14 total: 6 faces + 8 corners) - behavior depends on grabbed handle */
|
||||
ALL = "ALL"
|
||||
}
|
||||
|
||||
/**
|
||||
* Type of handle being interacted with
|
||||
*/
|
||||
export enum HandleType {
|
||||
/** Corner handle - scales uniformly */
|
||||
CORNER = "CORNER",
|
||||
|
||||
/** Edge handle - scales two axes */
|
||||
EDGE = "EDGE",
|
||||
|
||||
/** Face handle - scales single axis */
|
||||
FACE = "FACE"
|
||||
}
|
||||
|
||||
/**
|
||||
* Current state of gizmo interaction
|
||||
*/
|
||||
export enum InteractionState {
|
||||
/** No interaction */
|
||||
IDLE = "IDLE",
|
||||
|
||||
/** Pointer hovering over target mesh */
|
||||
HOVER_MESH = "HOVER_MESH",
|
||||
|
||||
/** Pointer hovering over a handle */
|
||||
HOVER_HANDLE = "HOVER_HANDLE",
|
||||
|
||||
/** Actively scaling (grip button held) */
|
||||
ACTIVE_SCALING = "ACTIVE_SCALING"
|
||||
}
|
||||
|
||||
/**
|
||||
* Events emitted by the resize gizmo
|
||||
*/
|
||||
export enum ResizeGizmoEventType {
|
||||
/** Scaling started (grip pressed on handle) */
|
||||
SCALE_START = "SCALE_START",
|
||||
|
||||
/** Scaling in progress (during drag) */
|
||||
SCALE_DRAG = "SCALE_DRAG",
|
||||
|
||||
/** Scaling ended (grip released) */
|
||||
SCALE_END = "SCALE_END",
|
||||
|
||||
/** Gizmo attached to new mesh */
|
||||
ATTACHED = "ATTACHED",
|
||||
|
||||
/** Gizmo detached from mesh */
|
||||
DETACHED = "DETACHED",
|
||||
|
||||
/** Mode changed */
|
||||
MODE_CHANGED = "MODE_CHANGED"
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle position information
|
||||
*/
|
||||
export interface HandlePosition {
|
||||
/** World position of handle */
|
||||
position: Vector3;
|
||||
|
||||
/** Type of handle */
|
||||
type: HandleType;
|
||||
|
||||
/** Axes affected by this handle (e.g., ["X", "Y", "Z"] for uniform) */
|
||||
axes: ("X" | "Y" | "Z")[];
|
||||
|
||||
/** Normal direction from center (for scaling calculation) */
|
||||
normal: Vector3;
|
||||
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event data for resize gizmo events
|
||||
*/
|
||||
export interface ResizeGizmoEvent {
|
||||
/** Event type */
|
||||
type: ResizeGizmoEventType;
|
||||
|
||||
/** Target mesh being scaled */
|
||||
mesh: AbstractMesh;
|
||||
|
||||
/** Current scale values */
|
||||
scale: Vector3;
|
||||
|
||||
/** Previous scale (for SCALE_END) */
|
||||
previousScale?: Vector3;
|
||||
|
||||
/** Handle being used (if applicable) */
|
||||
handle?: HandlePosition;
|
||||
|
||||
/** Timestamp */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for resize gizmo
|
||||
*/
|
||||
export interface ResizeGizmoConfig {
|
||||
// === Mode Configuration ===
|
||||
/** Scaling mode - determines which handles are shown */
|
||||
mode: ResizeGizmoMode;
|
||||
|
||||
// === Handle Appearance ===
|
||||
/** Size of handle meshes as fraction of bounding box (e.g., 0.2 = 20% of avg bounding box dimension) */
|
||||
handleSize: number;
|
||||
|
||||
/** Color for corner handles */
|
||||
cornerHandleColor: Color3;
|
||||
|
||||
/** Color for edge handles */
|
||||
edgeHandleColor: Color3;
|
||||
|
||||
/** Color for face handles */
|
||||
faceHandleColor: Color3;
|
||||
|
||||
/** Color when handle is hovered */
|
||||
hoverColor: Color3;
|
||||
|
||||
/** Color when handle is being dragged */
|
||||
activeColor: Color3;
|
||||
|
||||
/** Scale factor applied to hovered handle (e.g., 1.2 = 20% larger) */
|
||||
hoverScaleFactor: number;
|
||||
|
||||
// === Bounding Box ===
|
||||
/** Handle offset from bounding box surface (0.05 = 5% outward) */
|
||||
handleOffset: number;
|
||||
|
||||
/** Padding for bounding box wireframe (0.03 = 3% outward breathing room) */
|
||||
wireframePadding: number;
|
||||
|
||||
/** Bounding box wireframe color */
|
||||
boundingBoxColor: Color3;
|
||||
|
||||
/** Bounding box wireframe transparency (0-1) */
|
||||
wireframeAlpha: number;
|
||||
|
||||
/** Show bounding box only on hover */
|
||||
showBoundingBoxOnHoverOnly: boolean;
|
||||
|
||||
/** Keep hover state when pointer is within handle boundary (prevents loss in whitespace) */
|
||||
keepHoverInHandleBoundary: boolean;
|
||||
|
||||
// === Snapping ===
|
||||
/** Enable snap-to-grid during scaling */
|
||||
enableSnapping: boolean;
|
||||
|
||||
/** Snap distance for X axis */
|
||||
snapDistanceX: number;
|
||||
|
||||
/** Snap distance for Y axis */
|
||||
snapDistanceY: number;
|
||||
|
||||
/** Snap distance for Z axis */
|
||||
snapDistanceZ: number;
|
||||
|
||||
/** Show visual snap point indicators */
|
||||
showSnapIndicators: boolean;
|
||||
|
||||
/** Enable haptic feedback on snap (WebXR only) */
|
||||
hapticFeedback: boolean;
|
||||
|
||||
// === Visual Feedback ===
|
||||
/** Show numeric scale/dimension display */
|
||||
showNumericDisplay: boolean;
|
||||
|
||||
/** Show alignment grid during scaling */
|
||||
showGrid: boolean;
|
||||
|
||||
/** Show snap points along axes */
|
||||
showSnapPoints: boolean;
|
||||
|
||||
/** Font size for numeric display */
|
||||
numericDisplayFontSize: number;
|
||||
|
||||
// === Constraints ===
|
||||
/** Minimum scale values */
|
||||
minScale: Vector3;
|
||||
|
||||
/** Maximum scale values (optional) */
|
||||
maxScale?: Vector3;
|
||||
|
||||
/** Lock aspect ratio in TWO_AXIS mode */
|
||||
lockAspectRatio: boolean;
|
||||
|
||||
/** Scale from center (true) or from opposite corner (false) */
|
||||
scaleFromCenter: boolean;
|
||||
|
||||
// === Integration ===
|
||||
/** Use DiagramEntity integration for persistence */
|
||||
useDiagramEntity: boolean;
|
||||
|
||||
/** DiagramManager instance (required if useDiagramEntity is true) */
|
||||
diagramManager?: any;
|
||||
|
||||
/** Emit events on scale changes */
|
||||
emitEvents: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration values
|
||||
*/
|
||||
export const DEFAULT_RESIZE_GIZMO_CONFIG: ResizeGizmoConfig = {
|
||||
// Mode
|
||||
mode: ResizeGizmoMode.ALL,
|
||||
|
||||
// Handle appearance (as fraction of bounding box size, e.g., 0.2 = 20%)
|
||||
handleSize: 0.2,
|
||||
cornerHandleColor: new Color3(0.3, 0.5, 1.0), // Blue
|
||||
edgeHandleColor: new Color3(0.3, 1.0, 0.5), // Green
|
||||
faceHandleColor: new Color3(1.0, 0.3, 0.3), // Red
|
||||
hoverColor: new Color3(1.0, 1.0, 0.3), // Yellow
|
||||
activeColor: new Color3(1.0, 0.6, 0.2), // Orange
|
||||
hoverScaleFactor: 1.3,
|
||||
|
||||
// Bounding box
|
||||
handleOffset: 0.05,
|
||||
wireframePadding: 0.03,
|
||||
boundingBoxColor: new Color3(1.0, 1.0, 1.0), // White
|
||||
wireframeAlpha: 0.3,
|
||||
showBoundingBoxOnHoverOnly: false,
|
||||
keepHoverInHandleBoundary: true,
|
||||
|
||||
// Snapping
|
||||
enableSnapping: true,
|
||||
snapDistanceX: 0.1,
|
||||
snapDistanceY: 0.1,
|
||||
snapDistanceZ: 0.1,
|
||||
showSnapIndicators: true,
|
||||
hapticFeedback: true,
|
||||
|
||||
// Visual feedback
|
||||
showNumericDisplay: true,
|
||||
showGrid: true,
|
||||
showSnapPoints: true,
|
||||
numericDisplayFontSize: 24,
|
||||
|
||||
// Constraints
|
||||
minScale: new Vector3(0.01, 0.01, 0.01),
|
||||
maxScale: undefined,
|
||||
lockAspectRatio: false,
|
||||
scaleFromCenter: true,
|
||||
|
||||
// Integration
|
||||
useDiagramEntity: false,
|
||||
diagramManager: undefined,
|
||||
emitEvents: true
|
||||
};
|
||||
|
||||
/**
|
||||
* Internal state for gizmo interaction
|
||||
*/
|
||||
export interface GizmoInteractionState {
|
||||
/** Current interaction state */
|
||||
state: InteractionState;
|
||||
|
||||
/** Handle currently being hovered (if any) */
|
||||
hoveredHandle?: HandlePosition;
|
||||
|
||||
/** Handle currently being dragged (if any) */
|
||||
activeHandle?: HandlePosition;
|
||||
|
||||
/** Starting scale when drag began */
|
||||
startScale?: Vector3;
|
||||
|
||||
/** Starting pointer position when drag began (world space) */
|
||||
startPointerPosition?: Vector3;
|
||||
|
||||
/** Current pointer position during drag (world space) */
|
||||
currentPointerPosition?: Vector3;
|
||||
|
||||
/** Mesh currently being scaled */
|
||||
targetMesh?: AbstractMesh;
|
||||
|
||||
/** Fixed "stick length" from controller to intersection point at grip press */
|
||||
stickLength?: number;
|
||||
|
||||
/** World-space center of bounding box at drag start */
|
||||
boundingBoxCenter?: Vector3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback type for gizmo events
|
||||
*/
|
||||
export type ResizeGizmoEventCallback = (event: ResizeGizmoEvent) => void;
|
||||
|
||||
/**
|
||||
* Observer info for cleanup
|
||||
*/
|
||||
export interface ResizeGizmoObserver {
|
||||
eventType: ResizeGizmoEventType;
|
||||
callback: ResizeGizmoEventCallback;
|
||||
observer: Observer<ResizeGizmoEvent>;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user