From 2c3fba31d32a174fdda660585b4c348b115e0af0 Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Sun, 16 Nov 2025 05:53:26 -0600 Subject: [PATCH] Reimplement ResizeGizmo as simplified single-file XR gizmo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/diagram/diagramMenuManager.ts | 172 +---- src/gizmos/PLAN.md | 542 +--------------- src/gizmos/ResizeGizmo/HandleGeometry.ts | 239 ------- src/gizmos/ResizeGizmo/ResizeGizmoConfig.ts | 172 ----- src/gizmos/ResizeGizmo/ResizeGizmoFeedback.ts | 417 ------------ .../ResizeGizmo/ResizeGizmoInteraction.ts | 579 ----------------- src/gizmos/ResizeGizmo/ResizeGizmoManager.ts | 388 ----------- src/gizmos/ResizeGizmo/ResizeGizmoSnapping.ts | 135 ---- src/gizmos/ResizeGizmo/ResizeGizmoVisuals.ts | 499 --------------- src/gizmos/ResizeGizmo/ScalingCalculator.ts | 316 --------- src/gizmos/ResizeGizmo/TODO.md | 49 -- src/gizmos/ResizeGizmo/index.ts | 601 ++++++++++++++++-- src/gizmos/ResizeGizmo/types.ts | 318 --------- 13 files changed, 594 insertions(+), 3833 deletions(-) delete mode 100644 src/gizmos/ResizeGizmo/HandleGeometry.ts delete mode 100644 src/gizmos/ResizeGizmo/ResizeGizmoConfig.ts delete mode 100644 src/gizmos/ResizeGizmo/ResizeGizmoFeedback.ts delete mode 100644 src/gizmos/ResizeGizmo/ResizeGizmoInteraction.ts delete mode 100644 src/gizmos/ResizeGizmo/ResizeGizmoManager.ts delete mode 100644 src/gizmos/ResizeGizmo/ResizeGizmoSnapping.ts delete mode 100644 src/gizmos/ResizeGizmo/ResizeGizmoVisuals.ts delete mode 100644 src/gizmos/ResizeGizmo/ScalingCalculator.ts delete mode 100644 src/gizmos/ResizeGizmo/types.ts diff --git a/src/diagram/diagramMenuManager.ts b/src/diagram/diagramMenuManager.ts index ec1aa9b..b11263a 100644 --- a/src/diagram/diagramMenuManager.ts +++ b/src/diagram/diagramMenuManager.ts @@ -1,5 +1,5 @@ import {DiagramEntityType, DiagramEvent, DiagramEventType} from "./types/diagramEntity"; -import {AbstractMesh, ActionEvent, Observable, Ray, Scene, Vector3, WebXRDefaultExperience, WebXRInputSource} from "@babylonjs/core"; +import {AbstractMesh, ActionEvent, Observable, Scene, Vector3, WebXRDefaultExperience, WebXRInputSource} from "@babylonjs/core"; import {InputTextView} from "../information/inputTextView"; import {DefaultScene} from "../defaultScene"; import log from "loglevel"; @@ -7,29 +7,22 @@ import {Toolbox} from "../toolbox/toolbox"; import {ClickMenu} from "../menus/clickMenu"; import {DiagramEventObserverMask} from "./types/diagramEventObserverMask"; import {ConnectionPreview} from "../menus/connectionPreview"; -import {ScaleMenu2} from "../menus/ScaleMenu2"; import {viewOnly} from "../util/functions/getPath"; import {GroupMenu} from "../menus/groupMenu"; import {ControllerEvent} from "../controllers/types/controllerEvent"; import {ControllerEventType} from "../controllers/types/controllerEventType"; -import {ResizeGizmoManager} from "../gizmos/ResizeGizmo/ResizeGizmoManager"; -import {ResizeGizmoMode} from "../gizmos/ResizeGizmo/types"; -import {DiagramEntityAdapter} from "../integration/gizmo/DiagramEntityAdapter"; -import {toDiagramEntity} from "./functions/toDiagramEntity"; +import {ResizeGizmo} from "../gizmos/ResizeGizmo"; export class DiagramMenuManager { public readonly toolbox: Toolbox; - public readonly scaleMenu: ScaleMenu2; - public readonly resizeGizmo: ResizeGizmoManager; - private readonly _resizeGizmoAdapter: DiagramEntityAdapter; private readonly _notifier: Observable; private readonly _inputTextView: InputTextView; private _groupMenu: GroupMenu; private readonly _scene: Scene; private _logger = log.getLogger('DiagramMenuManager'); private _connectionPreview: ConnectionPreview; - private _currentHoveredMesh: AbstractMesh | null = null; + private _activeResizeGizmo: ResizeGizmo | null = null; constructor(notifier: Observable, controllerObservable: Observable, readyObservable: Observable) { this._scene = DefaultScene.Scene; @@ -46,42 +39,8 @@ export class DiagramMenuManager { }); this.toolbox = new Toolbox(readyObservable); - - this.scaleMenu = new ScaleMenu2(this._notifier); - - // Initialize ResizeGizmo with auto-show on hover - this.resizeGizmo = new ResizeGizmoManager(this._scene, { - mode: ResizeGizmoMode.ALL, - enableSnapping: true, - snapDistanceX: 0.1, - snapDistanceY: 0.1, - snapDistanceZ: 0.1, - showNumericDisplay: true, - showGrid: true, - showSnapPoints: true, - hapticFeedback: true, - showBoundingBoxOnHoverOnly: false - }); - - // Create adapter for DiagramEntity persistence - // Inject toDiagramEntity converter for loose coupling - this._resizeGizmoAdapter = new DiagramEntityAdapter( - this.resizeGizmo, - { onDiagramEventObservable: this._notifier } as any, - toDiagramEntity, // Injected mesh-to-entity converter - false // Don't persist on drag, only on scale end - ); - - // Setup update loop for resize gizmo - this._scene.onBeforeRenderObservable.add(() => { - this.resizeGizmo.update(); - }); - if (viewOnly()) { this.toolbox.handleMesh.setEnabled(false); - this.resizeGizmo.setEnabled(false); - //this.scaleMenu.handleMesh.setEnabled(false) - // this.configMenu.handleTransformNode.setEnabled(false); } controllerObservable.add((event: ControllerEvent) => { if (event.type == ControllerEventType.B_BUTTON) { @@ -126,6 +85,32 @@ export class DiagramMenuManager { this._inputTextView.show(mesh); } + public activateResizeGizmo(mesh: AbstractMesh) { + // Dispose existing gizmo if any + if (this._activeResizeGizmo) { + this._activeResizeGizmo.dispose(); + this._activeResizeGizmo = null; + } + + // Create new resize gizmo for the mesh + this._activeResizeGizmo = new ResizeGizmo(mesh); + + // Listen for scale end event to notify diagram manager + this._activeResizeGizmo.onScaleEnd.add(() => { + this.notifyAll({ + type: DiagramEventType.MODIFY, + entity: {id: mesh.id, type: DiagramEntityType.ENTITY} + }); + }); + } + + public disposeResizeGizmo() { + if (this._activeResizeGizmo) { + this._activeResizeGizmo.dispose(); + this._activeResizeGizmo = null; + } + } + public createClickMenu(mesh: AbstractMesh, input: WebXRInputSource): ClickMenu { const clickMenu = new ClickMenu(mesh); clickMenu.onClickMenuObservable.add((evt: ActionEvent) => { @@ -145,14 +130,14 @@ export class DiagramMenuManager { this._connectionPreview = new ConnectionPreview(clickMenu.mesh.id, input, evt.additionalData.pickedPoint, this._notifier); break; case "size": - this.scaleMenu.show(clickMenu.mesh); + this.activateResizeGizmo(clickMenu.mesh); break; case "group": this._groupMenu = new GroupMenu(clickMenu.mesh); break; - case "close": - this.scaleMenu.hide(); - break; + // case "close": + // // DISCONNECTED - Ready for new scaling implementation + // break; } this._logger.debug(evt); @@ -167,96 +152,5 @@ export class DiagramMenuManager { public setXR(xr: WebXRDefaultExperience): void { this.toolbox.setXR(xr); - - // Register controllers with resize gizmo when they're added - xr.input.onControllerAddedObservable.add((controller) => { - this.resizeGizmo.registerController(controller); - }); - - xr.input.onControllerRemovedObservable.add((controller) => { - this.resizeGizmo.unregisterController(controller); - }); - - // Configure pointer selection to exclude utility layer meshes (primary defense against event leak-through) - if (xr.pointerSelection) { - const utilityScene = this.resizeGizmo.getUtilityScene(); - - // Wrap or replace the mesh predicate - const originalMeshPredicate = xr.pointerSelection.meshPredicate; - - xr.pointerSelection.meshPredicate = (mesh) => { - // Exclude utility layer meshes (gizmo handles) - if (mesh.getScene() === utilityScene) { - return false; - } - - // Apply original predicate if it exists - if (originalMeshPredicate) { - return originalMeshPredicate(mesh); - } - - // Default: mesh must be pickable, visible, and enabled - return mesh.isPickable && mesh.isVisible && mesh.isEnabled(); - }; - } - } - - /** - * Handle pointer hovering over a diagram object - * Auto-shows resize gizmo - */ - public handleDiagramObjectHover(mesh: AbstractMesh | null, pointerPosition?: Vector3): void { - // If hovering same mesh, do nothing - if (mesh === this._currentHoveredMesh) { - return; - } - - // If no longer hovering any mesh, check if we should keep gizmo active - if (!mesh) { - if (this._currentHoveredMesh) { - // Check if pointer is still near the gizmo or within bounding box - const shouldKeepActive = this.shouldKeepGizmoActive(pointerPosition); - - if (!shouldKeepActive) { - this.resizeGizmo.detachFromMesh(); - this._currentHoveredMesh = null; - } - } - return; - } - - // Hovering new mesh, attach gizmo - this._currentHoveredMesh = mesh; - this.resizeGizmo.attachToMesh(mesh); - - } - - /** - * Check if gizmo should remain active - * Trusts ResizeGizmo's internal state management rather than recalculating - */ - private shouldKeepGizmoActive(pointerPosition?: Vector3): boolean { - if (!this._currentHoveredMesh) { - return false; - } - - // Trust ResizeGizmo's internal state management - // ResizeGizmo already tracks hover state correctly with proper controller rays - const state = this.resizeGizmo.getInteractionState(); - - // Keep active if ResizeGizmo is in any active state: - // - ACTIVE_SCALING: User is actively scaling (grip held) - // - HOVER_HANDLE: Pointer is hovering a handle (ready to scale) - // - HOVER_MESH: Pointer is within handle boundary (grace zone) - return state === 'ACTIVE_SCALING' || - state === 'HOVER_HANDLE' || - state === 'HOVER_MESH'; - } - - /** - * Register a controller with the resize gizmo - */ - public registerControllerWithGizmo(controller: WebXRInputSource): void { - this.resizeGizmo.registerController(controller); } } \ No newline at end of file diff --git a/src/gizmos/PLAN.md b/src/gizmos/PLAN.md index a9febbb..0d3a13e 100644 --- a/src/gizmos/PLAN.md +++ b/src/gizmos/PLAN.md @@ -2,14 +2,8 @@ ## Overview -A self-contained, extractable WebXR resize gizmo system for BabylonJS with advanced features including: +A simple, self-contained, extractable WebXR resize gizmo system for BabylonJS with the following features: -- **4 Configurable Modes**: Single-axis, uniform, two-axis, and all-modes combined -- **WebXR Grip Button Control**: Hover handle → hold grip → drag → release workflow -- **Visual Feedback**: Numeric displays, alignment grids, snap indicators, color-coded handles -- **Snapping System**: Configurable snap points with visual and haptic feedback -- **Bounding Box Visualization**: Automatic highlighting with configurable padding -- **DiagramEntity Integration**: Optional adapter for persistence systems ## Directory Structure @@ -17,56 +11,30 @@ A self-contained, extractable WebXR resize gizmo system for BabylonJS with advan src/gizmos/ResizeGizmo/ ├── index.ts # Main exports ├── types.ts # TypeScript type definitions -├── ResizeGizmoManager.ts # Main orchestration class -├── ResizeGizmoConfig.ts # Configuration management -├── ResizeGizmoVisuals.ts # Bounding box & handle rendering -├── ResizeGizmoInteraction.ts # WebXR input handling -├── ResizeGizmoSnapping.ts # Snap-to-grid system -├── ResizeGizmoFeedback.ts # Visual feedback (numeric, grids, indicators) -├── ScalingCalculator.ts # Scaling math for all handle types -├── HandleGeometry.ts # Handle position calculations -└── DiagramEntityAdapter.ts # Optional DiagramManager integration ``` ## Feature Checklist ### Core Features -- [x] Four configurable scaling modes (SINGLE_AXIS, UNIFORM, TWO_AXIS, ALL) -- [x] WebXR grip button interaction (hover → hold → drag → release) -- [x] Bounding box visualization with configurable padding -- [x] Handle meshes sized for easy WebXR interaction -- [x] UtilityLayerRenderer integration (no main scene pollution) -- [x] Color-coded handles by type (corner, edge, face) +* Create a new Gizmo and pass an AbstractMesh in the contructor known as "gizmo target" +* Gizmo will create handles in utility layer taking into account scale and rotation of "gizmo target" +* Handles should be large enough to easily grab, but not so large that they overlap +* Handles should be outside the bounding box of the "gizmo target" +* Gizmo will say active until dispose() is called on the gizmo instance. +* When xr controller "ray" in utility scene intersects a handle, the handle will change color and get slightly larger +* When xr controller "grip" button is pressed while a handle is highlighted, the color of the highlighted handle will change and gizmo will enter "scaling mode" +* In "scaling mode", the handle is able to move outward from the center of the "gizmo target" depending on the type of handle selected +* In "scaling mode", the gizmo will scale the "gizmo target" in .1 increments with smallest scale being .1 and no upper bound +* The math to calculate scaling should take into account rotation and original scale of "gizmo target" +* "face handles" will only scale in one axis +* "corner handles" will scale every axis +* the scaling math should take into account the origin of the handle when gripped in the "gizmo target" local space. -### Interaction Features -- [x] Hover detection for mesh and handles -- [x] Handle highlighting on hover -- [x] Active state visualization during drag -- [x] Scaling calculations for all handle types -- [x] Min/max scale constraints -- [x] Scale from center option -### Snapping System -- [x] Configurable snap intervals per axis -- [x] Visual snap point indicators -- [x] Snap proximity calculation -- [x] Haptic feedback on snap (WebXR) -- [x] Option to disable snapping - -### Visual Feedback -- [x] Numeric display (scale values & percentages) -- [x] Alignment grids (1D, 2D, 3D based on mode) -- [x] Snap point visualization -- [x] Color changes (idle/hover/active states) -- [x] Billboard text display ### Integration -- [x] Event system (Observable-based) -- [x] DiagramEntity adapter for persistence -- [x] Self-contained with no hard dependencies -- [x] Configurable and extensible +- [ ] Self-contained with no hard dependencies -## Scaling Modes ### Mode 1: SINGLE_AXIS **Handles**: 6 face-center handles @@ -85,487 +53,7 @@ src/gizmos/ResizeGizmo/ **Handles**: 8 corner handles **Behavior**: Scale all axes equally (proportional) **Use Case**: Resizing while maintaining proportions - **Handle Positions**: All 8 combinations of `(min/max.x, min/max.y, min/max.z)` -### Mode 3: TWO_AXIS -**Handles**: 12 edge-center handles -**Behavior**: Scale two axes simultaneously -**Use Case**: Scaling faces/planes without affecting depth -**Handle Positions**: -- 4 edges parallel to X: `(mid.x, ±Y, ±Z)` → scales Y & Z -- 4 edges parallel to Y: `(±X, mid.y, ±Z)` → scales X & Z -- 4 edges parallel to Z: `(±X, ±Y, mid.z)` → scales X & Y -### Mode 4: ALL -**Handles**: 26 handles (8 corners + 12 edges + 6 faces) -**Behavior**: Handle type determines scaling mode: -- Corner → uniform -- Edge → two-axis -- Face → single-axis - -**Use Case**: Maximum flexibility in single gizmo - -## API Reference - -### ResizeGizmoManager - -Main class for managing the gizmo system. - -#### Constructor -```typescript -constructor(scene: Scene, config?: Partial) -``` - -#### 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): void -getConfig(): Readonly -``` - -**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. diff --git a/src/gizmos/ResizeGizmo/HandleGeometry.ts b/src/gizmos/ResizeGizmo/HandleGeometry.ts deleted file mode 100644 index e38c28c..0000000 --- a/src/gizmos/ResizeGizmo/HandleGeometry.ts +++ /dev/null @@ -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; - } -} diff --git a/src/gizmos/ResizeGizmo/ResizeGizmoConfig.ts b/src/gizmos/ResizeGizmo/ResizeGizmoConfig.ts deleted file mode 100644 index 8291b72..0000000 --- a/src/gizmos/ResizeGizmo/ResizeGizmoConfig.ts +++ /dev/null @@ -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) { - this._config = this.mergeWithDefaults(userConfig); - this.validate(); - } - - /** - * Merge user config with defaults - */ - private mergeWithDefaults(userConfig?: Partial): 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 { - return this._config; - } - - /** - * Update configuration (partial update) - */ - update(updates: Partial): 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); - } -} diff --git a/src/gizmos/ResizeGizmo/ResizeGizmoFeedback.ts b/src/gizmos/ResizeGizmo/ResizeGizmoFeedback.ts deleted file mode 100644 index 9ee958d..0000000 --- a/src/gizmos/ResizeGizmo/ResizeGizmoFeedback.ts +++ /dev/null @@ -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; - } - } -} diff --git a/src/gizmos/ResizeGizmo/ResizeGizmoInteraction.ts b/src/gizmos/ResizeGizmo/ResizeGizmoInteraction.ts deleted file mode 100644 index 374c89a..0000000 --- a/src/gizmos/ResizeGizmo/ResizeGizmoInteraction.ts +++ /dev/null @@ -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; - private _xrControllers: Map = new Map(); - private _gripObservers: Map = 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 { - 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(); - } -} diff --git a/src/gizmos/ResizeGizmo/ResizeGizmoManager.ts b/src/gizmos/ResizeGizmo/ResizeGizmoManager.ts deleted file mode 100644 index 94a3b85..0000000 --- a/src/gizmos/ResizeGizmo/ResizeGizmoManager.ts +++ /dev/null @@ -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; - private _observers: ResizeGizmoObserver[] = []; - - // State - private _attachedMesh?: AbstractMesh; - private _enabled: boolean = true; - - constructor(scene: Scene, config?: Partial) { - this._scene = scene; - this._config = new ResizeGizmoConfigManager(config); - this._observable = new Observable(); - - // 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): void { - this._config.update(updates); - - // Refresh visuals if attached - if (this._attachedMesh) { - this._visuals.update(); - } - } - - /** - * Get current configuration - */ - getConfig(): Readonly { - 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 = []; - } -} diff --git a/src/gizmos/ResizeGizmo/ResizeGizmoSnapping.ts b/src/gizmos/ResizeGizmo/ResizeGizmoSnapping.ts deleted file mode 100644 index cfee637..0000000 --- a/src/gizmos/ResizeGizmo/ResizeGizmoSnapping.ts +++ /dev/null @@ -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; - } -} diff --git a/src/gizmos/ResizeGizmo/ResizeGizmoVisuals.ts b/src/gizmos/ResizeGizmo/ResizeGizmoVisuals.ts deleted file mode 100644 index b660c2b..0000000 --- a/src/gizmos/ResizeGizmo/ResizeGizmoVisuals.ts +++ /dev/null @@ -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 = new Map(); - private _handleMaterials: Map = 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 { - 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(); - } -} diff --git a/src/gizmos/ResizeGizmo/ScalingCalculator.ts b/src/gizmos/ResizeGizmo/ScalingCalculator.ts deleted file mode 100644 index 7bfa721..0000000 --- a/src/gizmos/ResizeGizmo/ScalingCalculator.ts +++ /dev/null @@ -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 - ); - } -} diff --git a/src/gizmos/ResizeGizmo/TODO.md b/src/gizmos/ResizeGizmo/TODO.md index 3e9f234..b2f661a 100644 --- a/src/gizmos/ResizeGizmo/TODO.md +++ b/src/gizmos/ResizeGizmo/TODO.md @@ -1,51 +1,2 @@ # ResizeGizmo TODO and Known Issues -## Recently Completed - -### ✅ Remove Edge Handles to Simplify UX (Completed 2025-11-14) -- **Problem:** Edge handles (green, two-axis scaling) added cognitive complexity without unique capabilities -- **User Decision:** Simplify interface by removing edge handles entirely -- **Solution:** - 1. Removed `TWO_AXIS` mode from `ResizeGizmoMode` enum - 2. Updated `usesEdgeHandles()` to always return `false` - 3. Updated mode comments to reflect 14 total handles (6 face + 8 corner) -- **Result:** Simpler, more intuitive interface with only two handle types: - - **Corner handles (blue):** Uniform scaling on all axes - - **Face handles (red):** Single-axis scaling - - All scaling capabilities still available (two-axis can be done sequentially with face handles) -- **Files Modified:** - - `types.ts`: Removed TWO_AXIS mode - - `ResizeGizmoConfig.ts`: Disabled edge handles - - HandleGeometry still contains edge generation code but it's never called - -### ✅ Fix OBB-Based Scaling for Rotated Meshes (Completed 2025-11-14) -- **Problem:** Bounding box wireframe and handles were using AABB (axis-aligned), not rotating with mesh -- **User Requirement:** Scaling should follow mesh's rotated local axes with handles on OBB -- **Solution:** Implemented true OBB (oriented bounding box) system: - 1. Created `calculateOBBCorners()` to transform local corners to world space - 2. Updated bounding box visualization to use OBB corners (lines rotate with mesh) - 3. Rewrote all handle generation (corner, edge, face) to position on OBB - 4. Verified ScalingCalculator correctly transforms local axes to world space -- **Result:** Bounding box and handles now rotate with mesh, scaling follows mesh's local coordinate system -- **Files Modified:** - - `ResizeGizmoVisuals.ts`: OBB wireframe visualization - - `HandleGeometry.ts`: OBB-based handle positioning - - `ScalingCalculator.ts`: Already correct (transforms axes to world space) - -### ✅ Move Handles Inside Bounding Box (Completed 2025-11-13) -- **Problem:** Handles were positioned outside bounding box, causing selection issues -- **Solution:** Reversed padding direction in `HandleGeometry.ts` -- **Result:** Handles now 5% inside edges instead of 5% outside -- **Commit:** `204ef67` - -### ✅ Fix Color Persistence Bug (Completed 2025-11-13) -- **Problem:** Diagram entities losing color when scaled via ResizeGizmo -- **Root Cause:** `DiagramEntityAdapter` was only copying metadata, not extracting color from material -- **Solution:** Use `toDiagramEntity()` converter which properly extracts color from material -- **Commit:** `26b48b2` - -### ✅ Extract DiagramEntityAdapter to Integration Layer (Completed 2025-11-13) -- **Problem:** Adapter was in ResizeGizmo folder, causing tight coupling -- **Solution:** Moved to `src/integration/gizmo/` with dependency injection -- **Result:** ResizeGizmo is now pure and reusable -- **Commit:** `26b48b2` diff --git a/src/gizmos/ResizeGizmo/index.ts b/src/gizmos/ResizeGizmo/index.ts index dc5303c..9d04d09 100644 --- a/src/gizmos/ResizeGizmo/index.ts +++ b/src/gizmos/ResizeGizmo/index.ts @@ -1,61 +1,552 @@ +import { + AbstractMesh, + Color3, + Material, + Mesh, + MeshBuilder, + Observable, + Observer, + StandardMaterial, + UtilityLayerRenderer, + Vector3, + WebXRInputSource, +} from '@babylonjs/core'; +import { DefaultScene } from '../../defaultScene'; + /** - * WebXR Resize Gizmo - * Self-contained, reusable resize gizmo system for BabylonJS with WebXR support - * - * @example Basic usage: - * ```typescript - * import { ResizeGizmoManager, ResizeGizmoMode } from './gizmos/ResizeGizmo'; - * - * const gizmo = new ResizeGizmoManager(scene, { - * mode: ResizeGizmoMode.ALL, - * enableSnapping: true - * }); - * - * gizmo.attachToMesh(myMesh); - * - * xr.input.onControllerAddedObservable.add((controller) => { - * gizmo.registerController(controller); - * }); - * - * scene.onBeforeRenderObservable.add(() => { - * gizmo.update(); - * }); - * ``` - * - * @example With event callbacks: - * ```typescript - * gizmo.onScaleEnd((event) => { - * console.log('New scale:', event.scale); - * // Persist changes, update UI, etc. - * }); - * ``` - * - * @note For DiagramEntity integration, see src/integration/gizmo/DiagramEntityAdapter + * Event emitted during and after scaling operations */ +export interface ResizeGizmoEvent { + mesh: AbstractMesh; +} -// Main manager -export { ResizeGizmoManager } from "./ResizeGizmoManager"; +/** + * Handle types for the resize gizmo + */ +enum HandleType { + FACE_POS_X = 'face_pos_x', + FACE_NEG_X = 'face_neg_x', + FACE_POS_Y = 'face_pos_y', + FACE_NEG_Y = 'face_neg_y', + FACE_POS_Z = 'face_pos_z', + FACE_NEG_Z = 'face_neg_z', + CORNER_PPP = 'corner_ppp', // (+X, +Y, +Z) + CORNER_PPN = 'corner_ppn', // (+X, +Y, -Z) + CORNER_PNP = 'corner_pnp', // (+X, -Y, +Z) + CORNER_PNN = 'corner_pnn', // (+X, -Y, -Z) + CORNER_NPP = 'corner_npp', // (-X, +Y, +Z) + CORNER_NPN = 'corner_npn', // (-X, +Y, -Z) + CORNER_NNP = 'corner_nnp', // (-X, -Y, +Z) + CORNER_NNN = 'corner_nnn', // (-X, -Y, -Z) +} -// Configuration -export { ResizeGizmoConfigManager } from "./ResizeGizmoConfig"; +/** + * Handle state for visual feedback + */ +enum HandleState { + NORMAL = 'normal', + HOVER = 'hover', + ACTIVE = 'active', +} -// Types -export { - ResizeGizmoMode, - HandleType, - InteractionState, - ResizeGizmoEventType, - ResizeGizmoConfig, - ResizeGizmoEvent, - ResizeGizmoEventCallback, - HandlePosition, - DEFAULT_RESIZE_GIZMO_CONFIG -} from "./types"; +/** + * Information about a handle + */ +interface HandleInfo { + mesh: Mesh; + type: HandleType; + state: HandleState; + material: StandardMaterial; + /** Local space offset from target center for positioning */ + localOffset: Vector3; +} -// Internal classes (exported for advanced usage) -export { ResizeGizmoVisuals } from "./ResizeGizmoVisuals"; -export { ResizeGizmoInteraction } from "./ResizeGizmoInteraction"; -export { ResizeGizmoSnapping } from "./ResizeGizmoSnapping"; -export { ResizeGizmoFeedback } from "./ResizeGizmoFeedback"; -export { ScalingCalculator } from "./ScalingCalculator"; -export { HandleGeometry } from "./HandleGeometry"; +/** + * ResizeGizmo - Simple gizmo for resizing meshes in WebXR + * + * Features: + * - 6 face handles for single-axis scaling + * - 8 corner handles for uniform scaling + * - XR controller grip interaction + * - Billboard scaling for constant screen-size handles + * - Renders in utility layer (separate from main scene) + */ +export class ResizeGizmo { + private targetMesh: AbstractMesh; + private utilityLayer: UtilityLayerRenderer; + private handles: HandleInfo[] = []; + + // Materials for different states + private normalMaterial: StandardMaterial; + private hoverMaterial: StandardMaterial; + private activeMaterial: StandardMaterial; + + // Interaction state + private activeHandle: HandleInfo | null = null; + private gripStartPosition: Vector3 | null = null; + private initialScale: Vector3 | null = null; + private activeController: WebXRInputSource | null = null; + + // Observables for events + public onScaleDrag: Observable; + public onScaleEnd: Observable; + + // Frame observers + private beforeRenderObserver: Observer | 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(); + this.onScaleEnd = new Observable(); + + // 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(); + } +} diff --git a/src/gizmos/ResizeGizmo/types.ts b/src/gizmos/ResizeGizmo/types.ts deleted file mode 100644 index 566633e..0000000 --- a/src/gizmos/ResizeGizmo/types.ts +++ /dev/null @@ -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; -}