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:
Michael Mainguy 2025-11-16 05:53:26 -06:00
parent c815db4594
commit 2c3fba31d3
13 changed files with 594 additions and 3833 deletions

View File

@ -1,5 +1,5 @@
import {DiagramEntityType, DiagramEvent, DiagramEventType} from "./types/diagramEntity"; 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 {InputTextView} from "../information/inputTextView";
import {DefaultScene} from "../defaultScene"; import {DefaultScene} from "../defaultScene";
import log from "loglevel"; import log from "loglevel";
@ -7,29 +7,22 @@ import {Toolbox} from "../toolbox/toolbox";
import {ClickMenu} from "../menus/clickMenu"; import {ClickMenu} from "../menus/clickMenu";
import {DiagramEventObserverMask} from "./types/diagramEventObserverMask"; import {DiagramEventObserverMask} from "./types/diagramEventObserverMask";
import {ConnectionPreview} from "../menus/connectionPreview"; import {ConnectionPreview} from "../menus/connectionPreview";
import {ScaleMenu2} from "../menus/ScaleMenu2";
import {viewOnly} from "../util/functions/getPath"; import {viewOnly} from "../util/functions/getPath";
import {GroupMenu} from "../menus/groupMenu"; import {GroupMenu} from "../menus/groupMenu";
import {ControllerEvent} from "../controllers/types/controllerEvent"; import {ControllerEvent} from "../controllers/types/controllerEvent";
import {ControllerEventType} from "../controllers/types/controllerEventType"; import {ControllerEventType} from "../controllers/types/controllerEventType";
import {ResizeGizmoManager} from "../gizmos/ResizeGizmo/ResizeGizmoManager"; import {ResizeGizmo} from "../gizmos/ResizeGizmo";
import {ResizeGizmoMode} from "../gizmos/ResizeGizmo/types";
import {DiagramEntityAdapter} from "../integration/gizmo/DiagramEntityAdapter";
import {toDiagramEntity} from "./functions/toDiagramEntity";
export class DiagramMenuManager { export class DiagramMenuManager {
public readonly toolbox: Toolbox; public readonly toolbox: Toolbox;
public readonly scaleMenu: ScaleMenu2;
public readonly resizeGizmo: ResizeGizmoManager;
private readonly _resizeGizmoAdapter: DiagramEntityAdapter;
private readonly _notifier: Observable<DiagramEvent>; private readonly _notifier: Observable<DiagramEvent>;
private readonly _inputTextView: InputTextView; private readonly _inputTextView: InputTextView;
private _groupMenu: GroupMenu; private _groupMenu: GroupMenu;
private readonly _scene: Scene; private readonly _scene: Scene;
private _logger = log.getLogger('DiagramMenuManager'); private _logger = log.getLogger('DiagramMenuManager');
private _connectionPreview: ConnectionPreview; private _connectionPreview: ConnectionPreview;
private _currentHoveredMesh: AbstractMesh | null = null; private _activeResizeGizmo: ResizeGizmo | null = null;
constructor(notifier: Observable<DiagramEvent>, controllerObservable: Observable<ControllerEvent>, readyObservable: Observable<boolean>) { constructor(notifier: Observable<DiagramEvent>, controllerObservable: Observable<ControllerEvent>, readyObservable: Observable<boolean>) {
this._scene = DefaultScene.Scene; this._scene = DefaultScene.Scene;
@ -46,42 +39,8 @@ export class DiagramMenuManager {
}); });
this.toolbox = new Toolbox(readyObservable); 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()) { if (viewOnly()) {
this.toolbox.handleMesh.setEnabled(false); this.toolbox.handleMesh.setEnabled(false);
this.resizeGizmo.setEnabled(false);
//this.scaleMenu.handleMesh.setEnabled(false)
// this.configMenu.handleTransformNode.setEnabled(false);
} }
controllerObservable.add((event: ControllerEvent) => { controllerObservable.add((event: ControllerEvent) => {
if (event.type == ControllerEventType.B_BUTTON) { if (event.type == ControllerEventType.B_BUTTON) {
@ -126,6 +85,32 @@ export class DiagramMenuManager {
this._inputTextView.show(mesh); 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 { public createClickMenu(mesh: AbstractMesh, input: WebXRInputSource): ClickMenu {
const clickMenu = new ClickMenu(mesh); const clickMenu = new ClickMenu(mesh);
clickMenu.onClickMenuObservable.add((evt: ActionEvent) => { 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); this._connectionPreview = new ConnectionPreview(clickMenu.mesh.id, input, evt.additionalData.pickedPoint, this._notifier);
break; break;
case "size": case "size":
this.scaleMenu.show(clickMenu.mesh); this.activateResizeGizmo(clickMenu.mesh);
break; break;
case "group": case "group":
this._groupMenu = new GroupMenu(clickMenu.mesh); this._groupMenu = new GroupMenu(clickMenu.mesh);
break; break;
case "close": // case "close":
this.scaleMenu.hide(); // // DISCONNECTED - Ready for new scaling implementation
break; // break;
} }
this._logger.debug(evt); this._logger.debug(evt);
@ -167,96 +152,5 @@ export class DiagramMenuManager {
public setXR(xr: WebXRDefaultExperience): void { public setXR(xr: WebXRDefaultExperience): void {
this.toolbox.setXR(xr); 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);
} }
} }

View File

@ -2,14 +2,8 @@
## Overview ## 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 ## Directory Structure
@ -17,56 +11,30 @@ A self-contained, extractable WebXR resize gizmo system for BabylonJS with advan
src/gizmos/ResizeGizmo/ src/gizmos/ResizeGizmo/
├── index.ts # Main exports ├── index.ts # Main exports
├── types.ts # TypeScript type definitions ├── 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 ## Feature Checklist
### Core Features ### Core Features
- [x] Four configurable scaling modes (SINGLE_AXIS, UNIFORM, TWO_AXIS, ALL) * Create a new Gizmo and pass an AbstractMesh in the contructor known as "gizmo target"
- [x] WebXR grip button interaction (hover → hold → drag → release) * Gizmo will create handles in utility layer taking into account scale and rotation of "gizmo target"
- [x] Bounding box visualization with configurable padding * Handles should be large enough to easily grab, but not so large that they overlap
- [x] Handle meshes sized for easy WebXR interaction * Handles should be outside the bounding box of the "gizmo target"
- [x] UtilityLayerRenderer integration (no main scene pollution) * Gizmo will say active until dispose() is called on the gizmo instance.
- [x] Color-coded handles by type (corner, edge, face) * 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 ### Integration
- [x] Event system (Observable-based) - [ ] Self-contained with no hard dependencies
- [x] DiagramEntity adapter for persistence
- [x] Self-contained with no hard dependencies
- [x] Configurable and extensible
## Scaling Modes
### Mode 1: SINGLE_AXIS ### Mode 1: SINGLE_AXIS
**Handles**: 6 face-center handles **Handles**: 6 face-center handles
@ -85,487 +53,7 @@ src/gizmos/ResizeGizmo/
**Handles**: 8 corner handles **Handles**: 8 corner handles
**Behavior**: Scale all axes equally (proportional) **Behavior**: Scale all axes equally (proportional)
**Use Case**: Resizing while maintaining proportions **Use Case**: Resizing while maintaining proportions
**Handle Positions**: All 8 combinations of `(min/max.x, min/max.y, min/max.z)` **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.

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}
}

View File

@ -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();
}
}

View File

@ -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 = [];
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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
);
}
}

View File

@ -1,51 +1,2 @@
# ResizeGizmo TODO and Known Issues # 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`

View File

@ -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 * Event emitted during and after scaling operations
* 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
*/ */
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 { * Information about a handle
ResizeGizmoMode, */
HandleType, interface HandleInfo {
InteractionState, mesh: Mesh;
ResizeGizmoEventType, type: HandleType;
ResizeGizmoConfig, state: HandleState;
ResizeGizmoEvent, material: StandardMaterial;
ResizeGizmoEventCallback, /** Local space offset from target center for positioning */
HandlePosition, localOffset: Vector3;
DEFAULT_RESIZE_GIZMO_CONFIG }
} from "./types";
// Internal classes (exported for advanced usage) /**
export { ResizeGizmoVisuals } from "./ResizeGizmoVisuals"; * ResizeGizmo - Simple gizmo for resizing meshes in WebXR
export { ResizeGizmoInteraction } from "./ResizeGizmoInteraction"; *
export { ResizeGizmoSnapping } from "./ResizeGizmoSnapping"; * Features:
export { ResizeGizmoFeedback } from "./ResizeGizmoFeedback"; * - 6 face handles for single-axis scaling
export { ScalingCalculator } from "./ScalingCalculator"; * - 8 corner handles for uniform scaling
export { HandleGeometry } from "./HandleGeometry"; * - 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();
}
}

View File

@ -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>;
}