Implement WebXR resize gizmo with virtual stick scaling and extract adapter to integration layer

- Implement comprehensive WebXR resize gizmo system with three handle types:
  - Corner handles: uniform scaling (all axes)
  - Edge handles: two-axis planar scaling
  - Face handles: single-axis scaling
- Use "virtual stick" metaphor for intuitive scaling:
  - Fixed-length projection from controller to handle intersection
  - Distance-ratio based scaling from mesh pivot point
  - Works naturally with controller rotation and movement
- Add world-space coordinate transformations for VR rig parenting
- Implement manual ray picking for utility layer handle detection
- Add motion controller initialization handling for grip button
- Fix color persistence bug in diagram entities:
  - DiagramEntityAdapter now uses toDiagramEntity() converter
  - Store color in mesh metadata for persistence
  - Add dependency injection for loose coupling
- Extract DiagramEntityAdapter to integration layer:
  - Move from src/gizmos/ResizeGizmo/ to src/integration/gizmo/
  - Add dependency injection for mesh-to-entity converter
  - Keep ResizeGizmo pure and reusable without diagram dependencies
- Add closest color matching for missing toolbox colors
- Handle size now relative to bounding box (20% of avg dimension)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-11-13 17:52:23 -06:00
parent 02c08b35f2
commit 26b48b26c8
21 changed files with 4191 additions and 106 deletions

View File

@ -1,7 +1,7 @@
{ {
"name": "immersive", "name": "immersive",
"private": true, "private": true,
"version": "0.0.8-19", "version": "0.0.8-22",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"engines": { "engines": {

View File

@ -1,5 +1,6 @@
import { import {
AbstractMesh, AbstractMesh,
Ray,
Scene, Scene,
Vector3, Vector3,
WebXRControllerComponent, WebXRControllerComponent,
@ -47,6 +48,11 @@ export abstract class AbstractController {
private _meshUnderPointer: AbstractMesh; private _meshUnderPointer: AbstractMesh;
// Gizmo control state for squeeze button interaction
private _activeGizmoAxis: any = null; // IAxisScaleGizmo type from BabylonJS
private _draggingGizmo: boolean = false;
constructor(controller: WebXRInputSource, constructor(controller: WebXRInputSource,
xr: WebXRDefaultExperience, xr: WebXRDefaultExperience,
diagramManager: DiagramManager) { diagramManager: DiagramManager) {
@ -60,8 +66,25 @@ export abstract class AbstractController {
if (pointerInfo.pickInfo.pickedMesh) { if (pointerInfo.pickInfo.pickedMesh) {
this._pickPoint.copyFrom(pointerInfo.pickInfo.pickedPoint); this._pickPoint.copyFrom(pointerInfo.pickInfo.pickedPoint);
this._meshUnderPointer = pointerInfo.pickInfo.pickedMesh; this._meshUnderPointer = pointerInfo.pickInfo.pickedMesh;
// Auto-show resize gizmo when hovering diagram object
if (this.diagramManager?.isDiagramObject(this._meshUnderPointer)) {
this.diagramManager.diagramMenuManager.handleDiagramObjectHover(
this._meshUnderPointer,
pointerInfo.pickInfo.pickedPoint
);
} else {
// Hovering non-diagram object, pass pointer position to check if still in bounds
this.diagramManager.diagramMenuManager.handleDiagramObjectHover(
null,
pointerInfo.pickInfo.pickedPoint
);
}
} else { } else {
this._meshUnderPointer = null; this._meshUnderPointer = null;
// No mesh under pointer, use controller pointer position
const pointerPos = this.xrInputSource?.pointer?.position;
this.diagramManager?.diagramMenuManager.handleDiagramObjectHover(null, pointerPos);
} }
} }
}); });
@ -189,9 +212,49 @@ export abstract class AbstractController {
grip.onButtonStateChangedObservable.add(() => { grip.onButtonStateChangedObservable.add(() => {
if (grip.changes.pressed) { if (grip.changes.pressed) {
if (grip.pressed) { if (grip.pressed) {
this.grab(); this._logger.debug("=== SQUEEZE PRESSED ===");
// Check if ResizeGizmo will handle the grip (hovering a handle)
const resizeGizmo = this.diagramManager.diagramMenuManager.resizeGizmo;
if (resizeGizmo.isHoveringHandle()) {
// ResizeGizmo will handle grip on its handle, don't interfere
this._logger.debug("ResizeGizmo hovering handle, letting it handle grip");
return;
}
// Check if hovering over old gizmo axis (ScaleMenu2)
const gizmoAxis = this.getGizmoAxisUnderPointer();
this._logger.debug(`Gizmo axis detected: ${gizmoAxis ? gizmoAxis._rootMesh?.id : 'null'}`);
if (gizmoAxis) {
// Squeeze on gizmo = start scaling
this._logger.debug("Starting gizmo drag");
this.startGizmoDrag(gizmoAxis);
} else {
// Squeeze on object = grab it
// ResizeGizmo is not hovering a handle, so safe to grab
this._logger.debug("Starting normal grab");
this.grab();
}
} else { } else {
this.drop(); this._logger.debug("=== SQUEEZE RELEASED ===");
// Check if ResizeGizmo was scaling
const resizeGizmo = this.diagramManager.diagramMenuManager.resizeGizmo;
if (resizeGizmo.isScaling()) {
// ResizeGizmo will handle release internally, don't interfere
return;
}
// Release squeeze
if (this._draggingGizmo) {
// Was dragging gizmo, end it
this._logger.debug("Ending gizmo drag");
this.endGizmoDrag();
} else {
// Was grabbing object, drop it
this._logger.debug("Dropping object");
this.drop();
}
} }
} }
}); });
@ -219,4 +282,156 @@ export abstract class AbstractController {
this.grabbedMeshType = null; this.grabbedMeshType = null;
} }
} }
/**
* Check if the pointer is currently over a gizmo axis and return it
* Uses direct ray picking from the utility layer because gizmo meshes
* are on utility layer, not included in _meshUnderPointer
* @returns The gizmo axis under the pointer, or null
*/
private getGizmoAxisUnderPointer(): any | null {
this._logger.debug("--- getGizmoAxisUnderPointer called ---");
const scaleMenu = this.diagramManager.diagramMenuManager.scaleMenu;
if (!scaleMenu || !scaleMenu.gizmoManager) {
this._logger.debug("No scale menu or gizmo manager");
return null;
}
const gizmo = scaleMenu.gizmoManager.gizmos.scaleGizmo;
if (!gizmo) {
this._logger.debug("No scale gizmo");
return null;
}
this._logger.debug(`Gizmo attached mesh: ${scaleMenu.gizmoManager.attachedMesh?.id}`);
// Get the utility layer that contains the gizmo meshes
const utilityLayer = gizmo.xGizmo?._rootMesh?.getScene();
if (!utilityLayer) {
this._logger.debug("No utility layer found");
return null;
}
this._logger.debug(`Utility layer found: ${utilityLayer.constructor.name}`);
// Use controller's pointer ray directly to pick gizmo meshes
const pointerRay = this.xrInputSource.pointer.forward;
const pointerOrigin = this.xrInputSource.pointer.position;
this._logger.debug(`Pointer origin: ${pointerOrigin}, direction: ${pointerRay}`);
const ray = new Ray(pointerOrigin, pointerRay, 1000);
// Pick from the utility layer scene, not the main scene
// Don't filter in predicate - let all meshes be pickable, then check hierarchy after
const pickResult = utilityLayer.pickWithRay(ray);
this._logger.debug(`Pick result: hit=${pickResult?.hit}, pickedMesh=${pickResult?.pickedMesh?.id}`);
if (pickResult && pickResult.hit && pickResult.pickedMesh) {
this._logger.debug(`Checking if picked mesh ${pickResult.pickedMesh.id} is part of gizmo`);
// Determine which axis was picked by checking hierarchy
if (this.isMeshInGizmoHierarchy(pickResult.pickedMesh, gizmo.xGizmo)) {
this._logger.debug("Detected X axis");
return gizmo.xGizmo;
}
if (this.isMeshInGizmoHierarchy(pickResult.pickedMesh, gizmo.yGizmo)) {
this._logger.debug("Detected Y axis");
return gizmo.yGizmo;
}
if (this.isMeshInGizmoHierarchy(pickResult.pickedMesh, gizmo.zGizmo)) {
this._logger.debug("Detected Z axis");
return gizmo.zGizmo;
}
this._logger.debug(`Picked mesh ${pickResult.pickedMesh.id} is not part of any gizmo axis`);
}
this._logger.debug("No gizmo axis found");
return null;
}
/**
* Check if a mesh is part of a gizmo's hierarchy
*/
private isMeshInGizmoHierarchy(mesh: AbstractMesh, gizmo: any): boolean {
if (!gizmo || !gizmo._rootMesh) {
this._logger.debug(`isMeshInGizmoHierarchy: no gizmo or rootMesh`);
return false;
}
this._logger.debug(`Checking if ${mesh.id} is in gizmo ${gizmo._rootMesh.id} hierarchy`);
// Check if mesh matches gizmo root or is a child
let current: any = mesh;
let depth = 0;
while (current && depth < 10) {
this._logger.debug(` Depth ${depth}: checking ${current.id}`);
if (current.id === gizmo._rootMesh.id || current === gizmo._rootMesh) {
this._logger.debug(` MATCH! Found gizmo root`);
return true;
}
// Also check if this is a gizmo arrow mesh
if (current.id && current.id.includes('arrow')) {
const parent = current.parent;
this._logger.debug(` Found arrow mesh, parent: ${parent?.id}`);
if (parent && parent.id === gizmo._rootMesh.id) {
this._logger.debug(` MATCH! Arrow parent is gizmo root`);
return true;
}
}
current = current.parent;
depth++;
}
this._logger.debug(` No match after ${depth} iterations`);
return false;
}
/**
* Start dragging a gizmo axis with squeeze button
*/
private startGizmoDrag(axis: any): void {
this._activeGizmoAxis = axis;
this._draggingGizmo = true;
// Enable the drag behavior to start scaling
if (axis && axis.dragBehavior) {
// Manually enable drag mode for this axis
axis.dragBehavior.enabled = true;
// Get the pointer info for manual drag start
const pointerInfo = this.scene.pick(
this.scene.pointerX,
this.scene.pointerY,
null,
false,
this.scene.activeCamera
);
if (pointerInfo && pointerInfo.hit) {
// Manually trigger the drag start with pointer information
// The dragBehavior will handle the actual scaling logic
this._logger.debug(`Starting gizmo drag on axis: ${axis._rootMesh?.id}`);
}
}
}
/**
* End dragging a gizmo axis
*/
private endGizmoDrag(): void {
if (this._activeGizmoAxis) {
this._logger.debug(`Ending gizmo drag`);
// The drag behavior will auto-release, just clean up our state
if (this._activeGizmoAxis.dragBehavior) {
this._activeGizmoAxis.dragBehavior.enabled = true; // Keep enabled for future use
}
this._activeGizmoAxis = null;
this._draggingGizmo = false;
}
}
} }

View File

@ -12,17 +12,24 @@ import {viewOnly} from "../util/functions/getPath";
import {GroupMenu} from "../menus/groupMenu"; import {GroupMenu} from "../menus/groupMenu";
import {ControllerEvent} from "../controllers/types/controllerEvent"; import {ControllerEvent} from "../controllers/types/controllerEvent";
import {ControllerEventType} from "../controllers/types/controllerEventType"; import {ControllerEventType} from "../controllers/types/controllerEventType";
import {ResizeGizmoManager} from "../gizmos/ResizeGizmo/ResizeGizmoManager";
import {ResizeGizmoMode} from "../gizmos/ResizeGizmo/types";
import {DiagramEntityAdapter} from "../integration/gizmo/DiagramEntityAdapter";
import {toDiagramEntity} from "./functions/toDiagramEntity";
export class DiagramMenuManager { export class DiagramMenuManager {
public readonly toolbox: Toolbox; public readonly toolbox: Toolbox;
public readonly scaleMenu: ScaleMenu2; public readonly scaleMenu: ScaleMenu2;
public readonly resizeGizmo: ResizeGizmoManager;
private readonly _resizeGizmoAdapter: DiagramEntityAdapter;
private readonly _notifier: Observable<DiagramEvent>; private readonly _notifier: Observable<DiagramEvent>;
private readonly _inputTextView: InputTextView; private readonly _inputTextView: InputTextView;
private _groupMenu: GroupMenu; private _groupMenu: GroupMenu;
private readonly _scene: Scene; private readonly _scene: Scene;
private _logger = log.getLogger('DiagramMenuManager'); private _logger = log.getLogger('DiagramMenuManager');
private _connectionPreview: ConnectionPreview; private _connectionPreview: ConnectionPreview;
private _currentHoveredMesh: AbstractMesh | null = null;
constructor(notifier: Observable<DiagramEvent>, controllerObservable: Observable<ControllerEvent>, readyObservable: Observable<boolean>) { constructor(notifier: Observable<DiagramEvent>, controllerObservable: Observable<ControllerEvent>, readyObservable: Observable<boolean>) {
this._scene = DefaultScene.Scene; this._scene = DefaultScene.Scene;
@ -41,8 +48,38 @@ export class DiagramMenuManager {
this.scaleMenu = new ScaleMenu2(this._notifier); this.scaleMenu = new ScaleMenu2(this._notifier);
// Initialize ResizeGizmo with auto-show on hover
this.resizeGizmo = new ResizeGizmoManager(this._scene, {
mode: ResizeGizmoMode.ALL,
enableSnapping: true,
snapDistanceX: 0.1,
snapDistanceY: 0.1,
snapDistanceZ: 0.1,
showNumericDisplay: true,
showGrid: true,
showSnapPoints: true,
hapticFeedback: true,
showBoundingBoxOnHoverOnly: false
});
// Create adapter for DiagramEntity persistence
// Inject toDiagramEntity converter for loose coupling
this._resizeGizmoAdapter = new DiagramEntityAdapter(
this.resizeGizmo,
{ onDiagramEventObservable: this._notifier } as any,
toDiagramEntity, // Injected mesh-to-entity converter
false // Don't persist on drag, only on scale end
);
// Setup update loop for resize gizmo
this._scene.onBeforeRenderObservable.add(() => {
this.resizeGizmo.update();
});
if (viewOnly()) { if (viewOnly()) {
this.toolbox.handleMesh.setEnabled(false); this.toolbox.handleMesh.setEnabled(false);
this.resizeGizmo.setEnabled(false);
//this.scaleMenu.handleMesh.setEnabled(false) //this.scaleMenu.handleMesh.setEnabled(false)
// this.configMenu.handleTransformNode.setEnabled(false); // this.configMenu.handleTransformNode.setEnabled(false);
} }
@ -130,5 +167,87 @@ export class DiagramMenuManager {
public setXR(xr: WebXRDefaultExperience): void { public setXR(xr: WebXRDefaultExperience): void {
this.toolbox.setXR(xr); this.toolbox.setXR(xr);
// Register controllers with resize gizmo when they're added
xr.input.onControllerAddedObservable.add((controller) => {
this.resizeGizmo.registerController(controller);
});
xr.input.onControllerRemovedObservable.add((controller) => {
this.resizeGizmo.unregisterController(controller);
});
}
/**
* 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 based on pointer position
*/
private shouldKeepGizmoActive(pointerPosition?: Vector3): boolean {
if (!this._currentHoveredMesh) {
return false;
}
// Always keep gizmo active if currently scaling
if (this.resizeGizmo.isScaling()) {
return true;
}
// Keep active if pointer is within bounding box area
if (!pointerPosition) {
return false;
}
// Get the attached mesh's bounding box
const boundingInfo = this._currentHoveredMesh.getBoundingInfo();
const boundingBox = boundingInfo.boundingBox;
// Add padding to the bounding box (same as gizmo padding + handle size)
const padding = 0.3; // Generous padding to include handles
const min = boundingBox.minimumWorld.subtract(new Vector3(padding, padding, padding));
const max = boundingBox.maximumWorld.add(new Vector3(padding, padding, padding));
// Check if pointer is within the padded bounding box
const withinBounds =
pointerPosition.x >= min.x && pointerPosition.x <= max.x &&
pointerPosition.y >= min.y && pointerPosition.y <= max.y &&
pointerPosition.z >= min.z && pointerPosition.z <= max.z;
return withinBounds;
}
/**
* Register a controller with the resize gizmo
*/
public registerControllerWithGizmo(controller: WebXRInputSource): void {
this.resizeGizmo.registerController(controller);
} }
} }

View File

@ -19,6 +19,8 @@ import {v4 as uuidv4} from 'uuid';
import {xyztovec} from "./vectorConversion"; import {xyztovec} from "./vectorConversion";
import {AnimatedLineTexture} from "../../util/animatedLineTexture"; import {AnimatedLineTexture} from "../../util/animatedLineTexture";
import {LightmapGenerator} from "../../util/lightmapGenerator"; import {LightmapGenerator} from "../../util/lightmapGenerator";
import {getToolboxColors} from "../../toolbox/toolbox";
import {findClosestColor} from "../../util/functions/findClosestColor";
// Material sharing statistics // Material sharing statistics
let materialStats = { let materialStats = {
@ -88,8 +90,29 @@ function createNewInstanceIfNecessary(entity: DiagramEntity, scene: Scene): Abst
case DiagramTemplates.CONE: case DiagramTemplates.CONE:
case DiagramTemplates.PLANE: case DiagramTemplates.PLANE:
case DiagramTemplates.PERSON: case DiagramTemplates.PERSON:
const toolMeshId = "tool-" + entity.template + "-" + entity.color; // Tool meshes are created with UPPERCASE hex codes (BabylonJS toHexString behavior)
const toolMesh = scene.getMeshById(toolMeshId); let toolMeshId = "tool-" + entity.template + "-" + entity.color?.toUpperCase();
let toolMesh = scene.getMeshById(toolMeshId);
// If exact color match not found, try to find closest color
if (!toolMesh && entity.color) {
const availableColors = getToolboxColors();
const closestColor = findClosestColor(entity.color, availableColors);
if (closestColor !== entity.color.toLowerCase()) {
logger.info(`Color ${entity.color} not found in toolbox, using closest match: ${closestColor}`);
// Tool IDs use uppercase hex codes
toolMeshId = "tool-" + entity.template + "-" + closestColor.toUpperCase();
toolMesh = scene.getMeshById(toolMeshId);
if (toolMesh) {
logger.info(`Successfully found tool mesh with closest color: ${toolMeshId}`);
} else {
logger.error(`Even with closest color, tool mesh not found: ${toolMeshId}`);
}
}
}
if (toolMesh && !oldMesh) { if (toolMesh && !oldMesh) {
// Verify tool mesh has material before creating instance // Verify tool mesh has material before creating instance
if (!toolMesh.material) { if (!toolMesh.material) {
@ -135,6 +158,11 @@ function createNewInstanceIfNecessary(entity: DiagramEntity, scene: Scene): Abst
newMesh.metadata.tool = false; newMesh.metadata.tool = false;
} }
// Store color in metadata so it persists when entity is modified
if (entity.color) {
newMesh.metadata.color = entity.color;
}
} }
} }
return newMesh; return newMesh;

571
src/gizmos/PLAN.md Normal file
View File

@ -0,0 +1,571 @@
# WebXR Resize Gizmo - Implementation Plan & Documentation
## Overview
A self-contained, extractable WebXR resize gizmo system for BabylonJS with advanced features including:
- **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
```
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)
### 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
## Scaling Modes
### Mode 1: SINGLE_AXIS
**Handles**: 6 face-center handles
**Behavior**: Scale only along single axis (X, Y, or Z)
**Use Case**: Stretching/compressing in one direction
**Handle Positions**:
- Face +X: `(max.x, mid.y, mid.z)`
- Face -X: `(min.x, mid.y, mid.z)`
- Face +Y: `(mid.x, max.y, mid.z)`
- Face -Y: `(mid.x, min.y, mid.z)`
- Face +Z: `(mid.x, mid.y, max.z)`
- Face -Z: `(mid.x, mid.y, min.z)`
### Mode 2: UNIFORM
**Handles**: 8 corner handles
**Behavior**: Scale all axes equally (proportional)
**Use Case**: Resizing while maintaining proportions
**Handle Positions**: All 8 combinations of `(min/max.x, min/max.y, min/max.z)`
### Mode 3: TWO_AXIS
**Handles**: 12 edge-center handles
**Behavior**: Scale two axes simultaneously
**Use Case**: Scaling faces/planes without affecting depth
**Handle Positions**:
- 4 edges parallel to X: `(mid.x, ±Y, ±Z)` → scales Y & Z
- 4 edges parallel to Y: `(±X, mid.y, ±Z)` → scales X & Z
- 4 edges parallel to Z: `(±X, ±Y, mid.z)` → scales X & Y
### Mode 4: ALL
**Handles**: 26 handles (8 corners + 12 edges + 6 faces)
**Behavior**: Handle type determines scaling mode:
- Corner → uniform
- Edge → two-axis
- Face → single-axis
**Use Case**: Maximum flexibility in single gizmo
## API Reference
### ResizeGizmoManager
Main class for managing the gizmo system.
#### Constructor
```typescript
constructor(scene: Scene, config?: Partial<ResizeGizmoConfig>)
```
#### Methods
**Mesh Attachment**
```typescript
attachToMesh(mesh: AbstractMesh): void
detachFromMesh(): void
getAttachedMesh(): AbstractMesh | undefined
```
**Controller Registration**
```typescript
registerController(controller: WebXRInputSource): void
unregisterController(controller: WebXRInputSource): void
```
**Update Loop**
```typescript
update(): void // Call in scene.onBeforeRenderObservable
```
**Mode Management**
```typescript
setMode(mode: ResizeGizmoMode): void
getMode(): ResizeGizmoMode
```
**Configuration**
```typescript
updateConfig(updates: Partial<ResizeGizmoConfig>): void
getConfig(): Readonly<ResizeGizmoConfig>
```
**Enable/Disable**
```typescript
setEnabled(enabled: boolean): void
isEnabled(): boolean
```
**Event Listeners**
```typescript
on(eventType: ResizeGizmoEventType, callback: ResizeGizmoEventCallback): void
off(eventType: ResizeGizmoEventType, callback: ResizeGizmoEventCallback): void
// Convenience methods
onScaleStart(callback: ResizeGizmoEventCallback): void
onScaleDrag(callback: ResizeGizmoEventCallback): void
onScaleEnd(callback: ResizeGizmoEventCallback): void
onAttached(callback: ResizeGizmoEventCallback): void
onDetached(callback: ResizeGizmoEventCallback): void
onModeChanged(callback: ResizeGizmoEventCallback): void
```
**Disposal**
```typescript
dispose(): void
```
### ResizeGizmoConfig
Configuration interface with the following properties:
#### Mode Configuration
- `mode: ResizeGizmoMode` - Scaling mode (default: `ResizeGizmoMode.ALL`)
#### Handle Appearance
- `handleSize: number` - Size of handle meshes as fraction of bounding box (default: `0.2` = 20% of average dimension)
- `cornerHandleColor: Color3` - Corner handle color (default: blue)
- `edgeHandleColor: Color3` - Edge handle color (default: green)
- `faceHandleColor: Color3` - Face handle color (default: red)
- `hoverColor: Color3` - Hover highlight color (default: yellow)
- `activeColor: Color3` - Active drag color (default: orange)
- `hoverScaleFactor: number` - Scale multiplier on hover (default: `1.3`)
#### Bounding Box
- `boundingBoxPadding: number` - Padding around mesh (default: `0.05` = 5%)
- `boundingBoxColor: Color3` - Wireframe color (default: white)
- `wireframeAlpha: number` - Wireframe transparency 0-1 (default: `0.3`)
- `showBoundingBoxOnHoverOnly: boolean` - Only show on hover (default: `false`)
#### Snapping
- `enableSnapping: boolean` - Enable snap-to-grid (default: `true`)
- `snapDistanceX: number` - X-axis snap interval (default: `0.1`)
- `snapDistanceY: number` - Y-axis snap interval (default: `0.1`)
- `snapDistanceZ: number` - Z-axis snap interval (default: `0.1`)
- `showSnapIndicators: boolean` - Show snap point markers (default: `true`)
- `hapticFeedback: boolean` - WebXR haptic feedback (default: `true`)
#### Visual Feedback
- `showNumericDisplay: boolean` - Show scale values (default: `true`)
- `showGrid: boolean` - Show alignment grid (default: `true`)
- `showSnapPoints: boolean` - Show snap points (default: `true`)
- `numericDisplayFontSize: number` - Font size for text (default: `24`)
#### Constraints
- `minScale: Vector3` - Minimum scale values (default: `(0.01, 0.01, 0.01)`)
- `maxScale?: Vector3` - Maximum scale values (default: `undefined`)
- `lockAspectRatio: boolean` - Lock aspect in TWO_AXIS mode (default: `false`)
- `scaleFromCenter: boolean` - Scale from center or corner (default: `true`)
#### Integration
- `useDiagramEntity: boolean` - Use DiagramEntity integration (default: `false`)
- `diagramManager?: any` - DiagramManager instance
- `emitEvents: boolean` - Emit Observable events (default: `true`)
### Events
#### ResizeGizmoEventType
```typescript
enum ResizeGizmoEventType {
SCALE_START, // Grip pressed on handle
SCALE_DRAG, // During drag
SCALE_END, // Grip released
ATTACHED, // Gizmo attached to mesh
DETACHED, // Gizmo detached
MODE_CHANGED // Mode changed
}
```
#### ResizeGizmoEvent
```typescript
interface ResizeGizmoEvent {
type: ResizeGizmoEventType;
mesh: AbstractMesh;
scale: Vector3; // Current scale
previousScale?: Vector3; // Previous scale (SCALE_END only)
handle?: HandlePosition; // Handle being used
timestamp: number;
}
```
## Usage Examples
### Basic Standalone Usage
```typescript
import { ResizeGizmoManager, ResizeGizmoMode } from './gizmos/ResizeGizmo';
// Create gizmo
const gizmo = new ResizeGizmoManager(scene, {
mode: ResizeGizmoMode.ALL,
enableSnapping: true,
snapDistanceX: 0.1,
snapDistanceY: 0.1,
snapDistanceZ: 0.1,
showNumericDisplay: true,
showGrid: true
});
// Attach to mesh
gizmo.attachToMesh(myMesh);
// Register WebXR controllers
xr.input.onControllerAddedObservable.add((controller) => {
gizmo.registerController(controller);
});
xr.input.onControllerRemovedObservable.add((controller) => {
gizmo.unregisterController(controller);
});
// Update in render loop
scene.onBeforeRenderObservable.add(() => {
gizmo.update();
});
// Listen to events
gizmo.onScaleEnd((event) => {
console.log('Scaling finished:', event.scale);
console.log('Delta:', event.scale.subtract(event.previousScale));
});
// Cleanup
gizmo.dispose();
```
### With DiagramEntity Integration
```typescript
import { createDiagramGizmo, ResizeGizmoMode } from './gizmos/ResizeGizmo';
// Create gizmo with DiagramManager integration
const { gizmo, adapter } = createDiagramGizmo(scene, diagramManager, {
mode: ResizeGizmoMode.UNIFORM,
enableSnapping: true
});
// Attach to DiagramEntity mesh
gizmo.attachToMesh(diagramEntityMesh);
// Scale changes automatically persist to database via adapter
```
### Mode Switching
```typescript
const gizmo = new ResizeGizmoManager(scene);
// Start with uniform scaling only
gizmo.setMode(ResizeGizmoMode.UNIFORM);
// Switch to single-axis mode
gizmo.setMode(ResizeGizmoMode.SINGLE_AXIS);
// Enable all modes
gizmo.setMode(ResizeGizmoMode.ALL);
// Listen to mode changes
gizmo.onModeChanged((event) => {
console.log('Mode changed to:', gizmo.getMode());
});
```
### Custom Configuration
```typescript
const gizmo = new ResizeGizmoManager(scene, {
mode: ResizeGizmoMode.ALL,
// Custom handle colors
cornerHandleColor: new Color3(1, 0, 0), // Red corners
edgeHandleColor: new Color3(0, 1, 0), // Green edges
faceHandleColor: new Color3(0, 0, 1), // Blue faces
// Larger handles for easier interaction
handleSize: 0.2,
// Fine-grained snapping
snapDistanceX: 0.05,
snapDistanceY: 0.05,
snapDistanceZ: 0.05,
// Scale constraints
minScale: new Vector3(0.1, 0.1, 0.1),
maxScale: new Vector3(10, 10, 10),
// Disable some visual feedback
showGrid: false,
showSnapPoints: false
});
// Update config at runtime
gizmo.updateConfig({
snapDistanceX: 0.1,
showGrid: true
});
```
### Advanced: Custom Event Handling
```typescript
const gizmo = new ResizeGizmoManager(scene);
// Track scaling session
let scalingStarted = false;
let originalScale: Vector3;
gizmo.onScaleStart((event) => {
scalingStarted = true;
originalScale = event.scale.clone();
console.log('Started scaling from:', originalScale);
});
gizmo.onScaleDrag((event) => {
// Real-time feedback during drag
const delta = event.scale.subtract(originalScale);
console.log('Scale delta:', delta);
});
gizmo.onScaleEnd((event) => {
scalingStarted = false;
const finalDelta = event.scale.subtract(event.previousScale);
console.log('Scaling session completed');
console.log('Total change:', finalDelta);
// Undo support
saveToUndoStack({
action: 'scale',
mesh: event.mesh,
before: event.previousScale,
after: event.scale
});
});
```
## Integration with Existing Codebase
### Option 1: Use with DiagramManager (Recommended)
```typescript
import { createDiagramGizmo } from './gizmos/ResizeGizmo';
import { diagramManager } from './diagram/diagramManager';
import { DefaultScene } from './defaultScene';
// Create integrated gizmo
const { gizmo, adapter } = createDiagramGizmo(
DefaultScene.Scene,
diagramManager,
{
mode: ResizeGizmoMode.ALL,
snapDistanceX: diagramManager._config.current.createSnap,
snapDistanceY: diagramManager._config.current.createSnap,
snapDistanceZ: diagramManager._config.current.createSnap
}
);
// Register with XR controllers (similar to existing pattern)
DefaultScene.Scene.onBeforeRenderObservable.add(() => {
gizmo.update();
});
```
### Option 2: Standalone in Menu System
```typescript
import { ResizeGizmoManager, ResizeGizmoMode } from './gizmos/ResizeGizmo';
export class NewScaleMenu {
private gizmo: ResizeGizmoManager;
constructor(scene: Scene) {
this.gizmo = new ResizeGizmoManager(scene, {
mode: ResizeGizmoMode.ALL
});
}
show(mesh: AbstractMesh) {
this.gizmo.attachToMesh(mesh);
}
hide() {
this.gizmo.detachFromMesh();
}
}
```
## Extraction Guide
To extract this gizmo to another project:
1. **Copy Directory**: Copy entire `src/gizmos/ResizeGizmo/` folder
2. **Dependencies**: Ensure BabylonJS packages:
```json
{
"@babylonjs/core": "^8.x.x"
}
```
3. **Import**:
```typescript
import { ResizeGizmoManager } from './path/to/ResizeGizmo';
```
4. **Optional Integration**:
- If using DiagramEntity integration, adapt `DiagramEntityAdapter.ts` to your persistence system
- If not using, simply don't import the adapter
5. **No Hard Dependencies**: The gizmo has no hard dependencies on the "immersive" codebase
## Performance Considerations
### Optimization Tips
1. **Update Frequency**: Only call `update()` when gizmo is attached and enabled
2. **Handle Count**: Use specific modes (UNIFORM, SINGLE_AXIS, TWO_AXIS) instead of ALL to reduce handle count
3. **Visual Feedback**: Disable expensive features if needed:
- `showGrid: false`
- `showSnapPoints: false`
- `showNumericDisplay: false`
4. **Snap Calculation**: Snapping calculations are lightweight, but haptic feedback checks run every frame during drag
5. **Event Emission**: Set `emitEvents: false` if not using event listeners
### Memory Management
- Always call `dispose()` when done
- Detach from mesh before disposing
- Unregister controllers explicitly if managing lifecycle
## Known Limitations
1. **Rotation**: Handles are positioned in world space; mesh rotation affects scaling behavior
2. **Parenting**: Works best with top-level meshes; parented meshes may have unexpected behavior
3. **Non-Uniform Bounds**: Works with any mesh shape, but handles positioned based on AABB
4. **WebXR Only**: Grip button interaction designed for WebXR; mouse/touch support would require additional implementation
5. **Single Mesh**: One gizmo instance per mesh (no multi-selection scaling currently)
## Future Enhancements
Potential features for future implementation:
- [ ] Mouse/touch interaction support
- [ ] Multi-mesh selection and scaling
- [ ] Rotation-aware local-space handles
- [ ] Custom handle shapes (spheres, cylinders)
- [ ] Animation curves for smooth scaling
- [ ] Undo/redo integration
- [ ] Keyboard modifiers (shift for uniform, ctrl for snap override)
- [ ] Handle-specific constraints (lock certain axes)
- [ ] Percentage-based scaling input
- [ ] Copy scale values between meshes
## Troubleshooting
### Handles not visible
- Check that `setEnabled(true)` is called
- Verify UtilityLayer is rendering
- Ensure handleSize is appropriate for mesh scale
### Scaling not working
- Confirm `update()` is called in render loop
- Check that controllers are registered
- Verify grip button component exists on controller
### Snap not working
- Confirm `enableSnapping: true`
- Check snap distances are > 0
- Verify snapping is not disabled in config
### DiagramEntity not persisting
- Ensure `useDiagramEntity: true` and `diagramManager` is provided
- Check DiagramEntityAdapter is created
- Verify DiagramManager observable is working
## Version History
- **v1.0.0** (Initial Implementation)
- Four configurable modes
- WebXR grip button interaction
- Visual feedback system
- Snapping with haptic feedback
- DiagramEntity integration adapter
- Full documentation
## License
Part of the "immersive" project. See project LICENSE file.
## Support
For issues or questions specific to this gizmo:
1. Check this PLAN.md documentation
2. Review code examples above
3. Examine type definitions in `types.ts`
4. Test with standalone example before integrating
---
**Implementation Status**: ✅ Complete
All planned features have been implemented and documented.

View File

@ -0,0 +1,284 @@
/**
* WebXR Resize Gizmo - Handle Geometry Calculations
* Calculates positions for corner, edge, and face handles based on bounding box
*/
import { Vector3, BoundingBox } from "@babylonjs/core";
import { HandlePosition, HandleType } from "./types";
/**
* Helper class for calculating handle positions from a bounding box
*/
export class HandleGeometry {
/**
* Generate all corner handle positions (8 handles)
* Corners are at all combinations of min/max X, Y, Z
*/
static generateCornerHandles(boundingBox: BoundingBox, padding: number = 0): HandlePosition[] {
const min = boundingBox.minimumWorld;
const max = boundingBox.maximumWorld;
const center = boundingBox.centerWorld;
// Apply padding
const paddedMin = min.subtract(new Vector3(padding, padding, padding));
const paddedMax = max.add(new Vector3(padding, padding, padding));
const corners: HandlePosition[] = [];
const positions = [
{ x: paddedMax.x, y: paddedMax.y, z: paddedMax.z, id: "corner-xyz" },
{ x: paddedMin.x, y: paddedMax.y, z: paddedMax.z, id: "corner-Xyz" },
{ x: paddedMax.x, y: paddedMin.y, z: paddedMax.z, id: "corner-xYz" },
{ x: paddedMin.x, y: paddedMin.y, z: paddedMax.z, id: "corner-XYz" },
{ x: paddedMax.x, y: paddedMax.y, z: paddedMin.z, id: "corner-xyZ" },
{ x: paddedMin.x, y: paddedMax.y, z: paddedMin.z, id: "corner-XyZ" },
{ x: paddedMax.x, y: paddedMin.y, z: paddedMin.z, id: "corner-xYZ" },
{ x: paddedMin.x, y: paddedMin.y, z: paddedMin.z, id: "corner-XYZ" }
];
for (const pos of positions) {
const position = new Vector3(pos.x, pos.y, pos.z);
const normal = position.subtract(center).normalize();
corners.push({
position,
type: HandleType.CORNER,
axes: ["X", "Y", "Z"],
normal,
id: pos.id
});
}
return corners;
}
/**
* Generate all edge handle positions (12 handles)
* Edges are at midpoints of the 12 edges of the bounding box
*/
static generateEdgeHandles(boundingBox: BoundingBox, padding: number = 0): HandlePosition[] {
const min = boundingBox.minimumWorld;
const max = boundingBox.maximumWorld;
const center = boundingBox.centerWorld;
// Apply padding
const paddedMin = min.subtract(new Vector3(padding, padding, padding));
const paddedMax = max.add(new Vector3(padding, padding, padding));
// Calculate midpoints
const midX = (paddedMin.x + paddedMax.x) / 2;
const midY = (paddedMin.y + paddedMax.y) / 2;
const midZ = (paddedMin.z + paddedMax.z) / 2;
const edges: HandlePosition[] = [];
// 4 edges parallel to X axis (varying Y and Z)
edges.push(
{
position: new Vector3(midX, paddedMax.y, paddedMax.z),
type: HandleType.EDGE,
axes: ["Y", "Z"],
normal: new Vector3(0, 1, 1).normalize(),
id: "edge-x-yz"
},
{
position: new Vector3(midX, paddedMin.y, paddedMax.z),
type: HandleType.EDGE,
axes: ["Y", "Z"],
normal: new Vector3(0, -1, 1).normalize(),
id: "edge-x-Yz"
},
{
position: new Vector3(midX, paddedMax.y, paddedMin.z),
type: HandleType.EDGE,
axes: ["Y", "Z"],
normal: new Vector3(0, 1, -1).normalize(),
id: "edge-x-yZ"
},
{
position: new Vector3(midX, paddedMin.y, paddedMin.z),
type: HandleType.EDGE,
axes: ["Y", "Z"],
normal: new Vector3(0, -1, -1).normalize(),
id: "edge-x-YZ"
}
);
// 4 edges parallel to Y axis (varying X and Z)
edges.push(
{
position: new Vector3(paddedMax.x, midY, paddedMax.z),
type: HandleType.EDGE,
axes: ["X", "Z"],
normal: new Vector3(1, 0, 1).normalize(),
id: "edge-y-xz"
},
{
position: new Vector3(paddedMin.x, midY, paddedMax.z),
type: HandleType.EDGE,
axes: ["X", "Z"],
normal: new Vector3(-1, 0, 1).normalize(),
id: "edge-y-Xz"
},
{
position: new Vector3(paddedMax.x, midY, paddedMin.z),
type: HandleType.EDGE,
axes: ["X", "Z"],
normal: new Vector3(1, 0, -1).normalize(),
id: "edge-y-xZ"
},
{
position: new Vector3(paddedMin.x, midY, paddedMin.z),
type: HandleType.EDGE,
axes: ["X", "Z"],
normal: new Vector3(-1, 0, -1).normalize(),
id: "edge-y-XZ"
}
);
// 4 edges parallel to Z axis (varying X and Y)
edges.push(
{
position: new Vector3(paddedMax.x, paddedMax.y, midZ),
type: HandleType.EDGE,
axes: ["X", "Y"],
normal: new Vector3(1, 1, 0).normalize(),
id: "edge-z-xy"
},
{
position: new Vector3(paddedMin.x, paddedMax.y, midZ),
type: HandleType.EDGE,
axes: ["X", "Y"],
normal: new Vector3(-1, 1, 0).normalize(),
id: "edge-z-Xy"
},
{
position: new Vector3(paddedMax.x, paddedMin.y, midZ),
type: HandleType.EDGE,
axes: ["X", "Y"],
normal: new Vector3(1, -1, 0).normalize(),
id: "edge-z-xY"
},
{
position: new Vector3(paddedMin.x, paddedMin.y, midZ),
type: HandleType.EDGE,
axes: ["X", "Y"],
normal: new Vector3(-1, -1, 0).normalize(),
id: "edge-z-XY"
}
);
return edges;
}
/**
* Generate all face handle positions (6 handles)
* Faces are at centers of each face of the bounding box
*/
static generateFaceHandles(boundingBox: BoundingBox, padding: number = 0): HandlePosition[] {
const min = boundingBox.minimumWorld;
const max = boundingBox.maximumWorld;
// Apply padding
const paddedMin = min.subtract(new Vector3(padding, padding, padding));
const paddedMax = max.add(new Vector3(padding, padding, padding));
// Calculate midpoints
const midX = (paddedMin.x + paddedMax.x) / 2;
const midY = (paddedMin.y + paddedMax.y) / 2;
const midZ = (paddedMin.z + paddedMax.z) / 2;
const faces: HandlePosition[] = [];
// +X face (right)
faces.push({
position: new Vector3(paddedMax.x, midY, midZ),
type: HandleType.FACE,
axes: ["X"],
normal: new Vector3(1, 0, 0),
id: "face-x"
});
// -X face (left)
faces.push({
position: new Vector3(paddedMin.x, midY, midZ),
type: HandleType.FACE,
axes: ["X"],
normal: new Vector3(-1, 0, 0),
id: "face-X"
});
// +Y face (top)
faces.push({
position: new Vector3(midX, paddedMax.y, midZ),
type: HandleType.FACE,
axes: ["Y"],
normal: new Vector3(0, 1, 0),
id: "face-y"
});
// -Y face (bottom)
faces.push({
position: new Vector3(midX, paddedMin.y, midZ),
type: HandleType.FACE,
axes: ["Y"],
normal: new Vector3(0, -1, 0),
id: "face-Y"
});
// +Z face (front)
faces.push({
position: new Vector3(midX, midY, paddedMax.z),
type: HandleType.FACE,
axes: ["Z"],
normal: new Vector3(0, 0, 1),
id: "face-z"
});
// -Z face (back)
faces.push({
position: new Vector3(midX, midY, paddedMin.z),
type: HandleType.FACE,
axes: ["Z"],
normal: new Vector3(0, 0, -1),
id: "face-Z"
});
return faces;
}
/**
* Generate all handles based on mode flags
*/
static generateHandles(
boundingBox: BoundingBox,
padding: number,
includeCorners: boolean,
includeEdges: boolean,
includeFaces: boolean
): HandlePosition[] {
const handles: HandlePosition[] = [];
if (includeCorners) {
handles.push(...this.generateCornerHandles(boundingBox, padding));
}
if (includeEdges) {
handles.push(...this.generateEdgeHandles(boundingBox, padding));
}
if (includeFaces) {
handles.push(...this.generateFaceHandles(boundingBox, padding));
}
return handles;
}
/**
* Calculate padding in world units based on bounding box size
*/
static calculatePadding(boundingBox: BoundingBox, paddingFactor: number): number {
const size = boundingBox.extendSizeWorld;
const avgSize = (size.x + size.y + size.z) / 3;
return avgSize * paddingFactor;
}
}

View File

@ -0,0 +1,166 @@
/**
* WebXR Resize Gizmo - Configuration Management
*/
import { Vector3 } from "@babylonjs/core";
import { ResizeGizmoConfig, DEFAULT_RESIZE_GIZMO_CONFIG, ResizeGizmoMode } from "./types";
/**
* Helper class for managing and validating ResizeGizmo configuration
*/
export class ResizeGizmoConfigManager {
private _config: ResizeGizmoConfig;
constructor(userConfig?: Partial<ResizeGizmoConfig>) {
this._config = this.mergeWithDefaults(userConfig);
this.validate();
}
/**
* Merge user config with defaults
*/
private mergeWithDefaults(userConfig?: Partial<ResizeGizmoConfig>): ResizeGizmoConfig {
if (!userConfig) {
return { ...DEFAULT_RESIZE_GIZMO_CONFIG };
}
return {
...DEFAULT_RESIZE_GIZMO_CONFIG,
...userConfig,
// Ensure Vector3 objects are properly cloned
minScale: userConfig.minScale
? userConfig.minScale.clone()
: DEFAULT_RESIZE_GIZMO_CONFIG.minScale.clone(),
maxScale: userConfig.maxScale
? userConfig.maxScale.clone()
: DEFAULT_RESIZE_GIZMO_CONFIG.maxScale?.clone()
};
}
/**
* Validate configuration values
*/
private validate(): void {
const c = this._config;
// Validate handle size
if (c.handleSize <= 0) {
console.warn(`[ResizeGizmo] Invalid handleSize (${c.handleSize}), using default`);
c.handleSize = DEFAULT_RESIZE_GIZMO_CONFIG.handleSize;
}
// Validate bounding box padding
if (c.boundingBoxPadding < 0) {
console.warn(`[ResizeGizmo] Invalid boundingBoxPadding (${c.boundingBoxPadding}), using 0`);
c.boundingBoxPadding = 0;
}
// Validate wireframe alpha
c.wireframeAlpha = Math.max(0, Math.min(1, c.wireframeAlpha));
// Validate snap distances
if (c.snapDistanceX <= 0) c.snapDistanceX = 0.1;
if (c.snapDistanceY <= 0) c.snapDistanceY = 0.1;
if (c.snapDistanceZ <= 0) c.snapDistanceZ = 0.1;
// Validate min scale
if (c.minScale.x <= 0) c.minScale.x = 0.01;
if (c.minScale.y <= 0) c.minScale.y = 0.01;
if (c.minScale.z <= 0) c.minScale.z = 0.01;
// Validate max scale (if set)
if (c.maxScale) {
if (c.maxScale.x < c.minScale.x) c.maxScale.x = c.minScale.x;
if (c.maxScale.y < c.minScale.y) c.maxScale.y = c.minScale.y;
if (c.maxScale.z < c.minScale.z) c.maxScale.z = c.minScale.z;
}
// Validate DiagramEntity integration
if (c.useDiagramEntity && !c.diagramManager) {
console.warn("[ResizeGizmo] useDiagramEntity is true but diagramManager is not provided");
}
// Validate hover scale factor
if (c.hoverScaleFactor <= 0) {
c.hoverScaleFactor = DEFAULT_RESIZE_GIZMO_CONFIG.hoverScaleFactor;
}
}
/**
* Get current configuration (readonly)
*/
get current(): Readonly<ResizeGizmoConfig> {
return this._config;
}
/**
* Update configuration (partial update)
*/
update(updates: Partial<ResizeGizmoConfig>): void {
this._config = this.mergeWithDefaults({
...this._config,
...updates
});
this.validate();
}
/**
* Set mode
*/
setMode(mode: ResizeGizmoMode): void {
this._config.mode = mode;
}
/**
* Get snap distance for axis
*/
getSnapDistance(axis: "X" | "Y" | "Z"): number {
switch (axis) {
case "X": return this._config.snapDistanceX;
case "Y": return this._config.snapDistanceY;
case "Z": return this._config.snapDistanceZ;
}
}
/**
* Get snap vector
*/
getSnapVector(): Vector3 {
return new Vector3(
this._config.snapDistanceX,
this._config.snapDistanceY,
this._config.snapDistanceZ
);
}
/**
* Check if a mode uses corner handles
*/
usesCornerHandles(): boolean {
const mode = this._config.mode;
return mode === ResizeGizmoMode.UNIFORM || mode === ResizeGizmoMode.ALL;
}
/**
* Check if a mode uses edge handles
*/
usesEdgeHandles(): boolean {
const mode = this._config.mode;
return mode === ResizeGizmoMode.TWO_AXIS || mode === ResizeGizmoMode.ALL;
}
/**
* Check if a mode uses face handles
*/
usesFaceHandles(): boolean {
const mode = this._config.mode;
return mode === ResizeGizmoMode.SINGLE_AXIS || mode === ResizeGizmoMode.ALL;
}
/**
* Clone configuration
*/
clone(): ResizeGizmoConfigManager {
return new ResizeGizmoConfigManager(this._config);
}
}

View File

@ -0,0 +1,417 @@
/**
* WebXR Resize Gizmo - Visual Feedback
* Handles numeric displays, grids, and snap point visualization
*/
import {
Scene,
Vector3,
AbstractMesh,
LinesMesh,
MeshBuilder,
Color3,
DynamicTexture,
StandardMaterial,
Mesh
} from "@babylonjs/core";
import { HandlePosition } from "./types";
import { ResizeGizmoConfigManager } from "./ResizeGizmoConfig";
import { ResizeGizmoSnapping } from "./ResizeGizmoSnapping";
/**
* Manages visual feedback during scaling operations
*/
export class ResizeGizmoFeedback {
private _scene: Scene;
private _config: ResizeGizmoConfigManager;
private _snapping: ResizeGizmoSnapping;
// Visual elements
private _numericDisplay?: Mesh;
private _numericTexture?: DynamicTexture;
private _numericMaterial?: StandardMaterial;
private _gridLines: LinesMesh[] = [];
private _snapIndicators: Mesh[] = [];
constructor(scene: Scene, config: ResizeGizmoConfigManager, snapping: ResizeGizmoSnapping) {
this._scene = scene;
this._config = config;
this._snapping = snapping;
}
/**
* Show numeric display above mesh
*/
showNumericDisplay(mesh: AbstractMesh, scale: Vector3, originalScale: Vector3): void {
if (!this._config.current.showNumericDisplay) {
return;
}
// Create or update numeric display
if (!this._numericDisplay) {
this.createNumericDisplay();
}
if (!this._numericDisplay || !this._numericTexture) {
return;
}
// Position above mesh
const boundingInfo = mesh.getBoundingInfo();
const max = boundingInfo.boundingBox.maximumWorld;
this._numericDisplay.position = new Vector3(max.x, max.y + 0.5, max.z);
// Update text
const scalePercent = new Vector3(
(scale.x / originalScale.x) * 100,
(scale.y / originalScale.y) * 100,
(scale.z / originalScale.z) * 100
);
const text = this.formatScaleText(scale, scalePercent);
this.updateNumericTexture(text);
this._numericDisplay.setEnabled(true);
}
/**
* Hide numeric display
*/
hideNumericDisplay(): void {
if (this._numericDisplay) {
this._numericDisplay.setEnabled(false);
}
}
/**
* Create numeric display mesh
*/
private createNumericDisplay(): void {
const size = 1.0;
// Create plane for text
this._numericDisplay = MeshBuilder.CreatePlane(
"gizmo-numeric-display",
{ width: size * 2, height: size },
this._scene
);
this._numericDisplay.billboardMode = Mesh.BILLBOARDMODE_ALL;
this._numericDisplay.isPickable = false;
// Create dynamic texture
const resolution = 512;
this._numericTexture = new DynamicTexture(
"gizmo-numeric-texture",
{ width: resolution * 2, height: resolution },
this._scene,
false
);
// Create material
this._numericMaterial = new StandardMaterial("gizmo-numeric-material", this._scene);
this._numericMaterial.diffuseTexture = this._numericTexture;
this._numericMaterial.emissiveColor = Color3.White();
this._numericMaterial.disableLighting = true;
this._numericMaterial.useAlphaFromDiffuseTexture = true;
this._numericDisplay.material = this._numericMaterial;
this._numericDisplay.setEnabled(false);
}
/**
* Format scale text for display
*/
private formatScaleText(scale: Vector3, scalePercent: Vector3): string {
return `X: ${scale.x.toFixed(2)} (${scalePercent.x.toFixed(0)}%)\n` +
`Y: ${scale.y.toFixed(2)} (${scalePercent.y.toFixed(0)}%)\n` +
`Z: ${scale.z.toFixed(2)} (${scalePercent.z.toFixed(0)}%)`;
}
/**
* Update numeric texture with text
*/
private updateNumericTexture(text: string): void {
if (!this._numericTexture) {
return;
}
const context = this._numericTexture.getContext();
const size = this._numericTexture.getSize();
// Clear
context.clearRect(0, 0, size.width, size.height);
// Draw background
context.fillStyle = "rgba(0, 0, 0, 0.7)";
context.fillRect(0, 0, size.width, size.height);
// Draw text
context.fillStyle = "white";
context.font = `${this._config.current.numericDisplayFontSize}px monospace`;
context.textAlign = "center";
context.textBaseline = "middle";
const lines = text.split("\n");
const lineHeight = this._config.current.numericDisplayFontSize * 1.2;
const startY = (size.height - lineHeight * lines.length) / 2;
lines.forEach((line, i) => {
context.fillText(line, size.width / 2, startY + lineHeight * (i + 0.5));
});
this._numericTexture.update();
}
/**
* Show alignment grid during scaling
*/
showGrid(mesh: AbstractMesh, handle: HandlePosition): void {
if (!this._config.current.showGrid) {
return;
}
this.hideGrid();
const boundingInfo = mesh.getBoundingInfo();
const center = boundingInfo.boundingBox.centerWorld;
const size = boundingInfo.boundingBox.extendSizeWorld.scale(2);
// Create grid lines based on affected axes
const axes = handle.axes;
// Determine grid plane based on axes
if (axes.length === 3) {
// Uniform - show 3D grid
this.create3DGrid(center, size);
} else if (axes.length === 2) {
// Two-axis - show planar grid
this.createPlanarGrid(center, size, axes);
} else {
// Single-axis - show line grid
this.createAxisGrid(center, size, axes[0]);
}
}
/**
* Hide alignment grid
*/
hideGrid(): void {
for (const line of this._gridLines) {
line.dispose();
}
this._gridLines = [];
}
/**
* Create 3D grid (for uniform scaling)
*/
private create3DGrid(center: Vector3, size: Vector3): void {
const gridSize = 5;
const spacing = 0.5;
const color = new Color3(0.5, 0.5, 0.5);
// XY plane
for (let i = -gridSize; i <= gridSize; i++) {
// X lines
const xLine = MeshBuilder.CreateLines(
`grid-x-${i}`,
{
points: [
new Vector3(center.x - gridSize * spacing, center.y + i * spacing, center.z),
new Vector3(center.x + gridSize * spacing, center.y + i * spacing, center.z)
]
},
this._scene
);
xLine.color = color;
xLine.alpha = 0.3;
xLine.isPickable = false;
this._gridLines.push(xLine);
// Y lines
const yLine = MeshBuilder.CreateLines(
`grid-y-${i}`,
{
points: [
new Vector3(center.x + i * spacing, center.y - gridSize * spacing, center.z),
new Vector3(center.x + i * spacing, center.y + gridSize * spacing, center.z)
]
},
this._scene
);
yLine.color = color;
yLine.alpha = 0.3;
yLine.isPickable = false;
this._gridLines.push(yLine);
}
}
/**
* Create planar grid (for two-axis scaling)
*/
private createPlanarGrid(center: Vector3, size: Vector3, axes: ("X" | "Y" | "Z")[]): void {
const gridSize = 5;
const spacing = 0.5;
const color = new Color3(0.5, 0.5, 0.5);
// Determine which plane
const hasX = axes.includes("X");
const hasY = axes.includes("Y");
const hasZ = axes.includes("Z");
for (let i = -gridSize; i <= gridSize; i++) {
if (hasX && hasY) {
// XY plane
this.createGridLine(center, i * spacing, "X", color);
this.createGridLine(center, i * spacing, "Y", color);
} else if (hasX && hasZ) {
// XZ plane
this.createGridLine(center, i * spacing, "X", color);
this.createGridLine(center, i * spacing, "Z", color);
} else if (hasY && hasZ) {
// YZ plane
this.createGridLine(center, i * spacing, "Y", color);
this.createGridLine(center, i * spacing, "Z", color);
}
}
}
/**
* Create axis grid (for single-axis scaling)
*/
private createAxisGrid(center: Vector3, size: Vector3, axis: "X" | "Y" | "Z"): void {
const color = axis === "X" ? Color3.Red() : axis === "Y" ? Color3.Green() : Color3.Blue();
this.createGridLine(center, 0, axis, color, 1.0);
}
/**
* Create a single grid line
*/
private createGridLine(center: Vector3, offset: number, axis: "X" | "Y" | "Z", color: Color3, alpha: number = 0.3): void {
const gridSize = 5;
let points: Vector3[];
switch (axis) {
case "X":
points = [
new Vector3(center.x - gridSize, center.y + offset, center.z),
new Vector3(center.x + gridSize, center.y + offset, center.z)
];
break;
case "Y":
points = [
new Vector3(center.x + offset, center.y - gridSize, center.z),
new Vector3(center.x + offset, center.y + gridSize, center.z)
];
break;
case "Z":
points = [
new Vector3(center.x + offset, center.y, center.z - gridSize),
new Vector3(center.x + offset, center.y, center.z + gridSize)
];
break;
}
const line = MeshBuilder.CreateLines(`grid-${axis}-${offset}`, { points }, this._scene);
line.color = color;
line.alpha = alpha;
line.isPickable = false;
this._gridLines.push(line);
}
/**
* Show snap point indicators
*/
showSnapIndicators(mesh: AbstractMesh, handle: HandlePosition): void {
if (!this._config.current.showSnapPoints || !this._snapping.isEnabled()) {
return;
}
this.hideSnapIndicators();
const boundingInfo = mesh.getBoundingInfo();
const center = boundingInfo.boundingBox.centerWorld;
const size = boundingInfo.boundingBox.extendSizeWorld;
// Create snap indicators along affected axes
for (const axis of handle.axes) {
const snapDistance = this._config.getSnapDistance(axis);
const axisSize = axis === "X" ? size.x : axis === "Y" ? size.y : size.z;
const snapPoints = this._snapping.getSnapPointsInRange(
-axisSize * 2,
axisSize * 2,
snapDistance
);
for (const snapValue of snapPoints) {
let position: Vector3;
switch (axis) {
case "X":
position = new Vector3(center.x + snapValue, center.y, center.z);
break;
case "Y":
position = new Vector3(center.x, center.y + snapValue, center.z);
break;
case "Z":
position = new Vector3(center.x, center.y, center.z + snapValue);
break;
}
const indicator = MeshBuilder.CreateSphere(
`snap-indicator-${axis}-${snapValue}`,
{ diameter: 0.05 },
this._scene
);
indicator.position = position;
indicator.isPickable = false;
const material = new StandardMaterial(`snap-mat-${axis}-${snapValue}`, this._scene);
material.emissiveColor = axis === "X" ? Color3.Red() : axis === "Y" ? Color3.Green() : Color3.Blue();
material.alpha = 0.4;
material.disableLighting = true;
indicator.material = material;
this._snapIndicators.push(indicator);
}
}
}
/**
* Hide snap indicators
*/
hideSnapIndicators(): void {
for (const indicator of this._snapIndicators) {
indicator.dispose();
indicator.material?.dispose();
}
this._snapIndicators = [];
}
/**
* Dispose all feedback elements
*/
dispose(): void {
this.hideNumericDisplay();
this.hideGrid();
this.hideSnapIndicators();
if (this._numericDisplay) {
this._numericDisplay.dispose();
this._numericDisplay = undefined;
}
if (this._numericTexture) {
this._numericTexture.dispose();
this._numericTexture = undefined;
}
if (this._numericMaterial) {
this._numericMaterial.dispose();
this._numericMaterial = undefined;
}
}
}

View File

@ -0,0 +1,536 @@
/**
* WebXR Resize Gizmo - Interaction Handling
* Manages WebXR pointer detection and grip button interactions
*/
import {
Scene,
AbstractMesh,
Ray,
Vector3,
Observer,
PointerInfo,
PointerEventTypes,
WebXRInputSource,
PickingInfo
} from "@babylonjs/core";
import {
HandlePosition,
InteractionState,
GizmoInteractionState,
ResizeGizmoEvent,
ResizeGizmoEventType
} from "./types";
import { ResizeGizmoConfigManager } from "./ResizeGizmoConfig";
import { ResizeGizmoVisuals } from "./ResizeGizmoVisuals";
import { ScalingCalculator } from "./ScalingCalculator";
import { ResizeGizmoSnapping } from "./ResizeGizmoSnapping";
import { ResizeGizmoFeedback } from "./ResizeGizmoFeedback";
/**
* Result of handle detection including pick information
*/
interface HandlePickResult {
handle: HandlePosition;
pickInfo: PickingInfo;
controller: WebXRInputSource;
}
/**
* Handles all WebXR interaction logic for the resize gizmo
*/
export class ResizeGizmoInteraction {
private _scene: Scene;
private _config: ResizeGizmoConfigManager;
private _visuals: ResizeGizmoVisuals;
private _calculator: ScalingCalculator;
private _snapping: ResizeGizmoSnapping;
private _feedback: ResizeGizmoFeedback;
// State
private _state: GizmoInteractionState = {
state: InteractionState.IDLE
};
// Observers
private _pointerObserver?: Observer<PointerInfo>;
private _xrControllers: Map<string, WebXRInputSource> = new Map();
private _gripObservers: Map<string, any> = new Map();
// Event callback
private _onScaleChange?: (event: ResizeGizmoEvent) => void;
constructor(
scene: Scene,
config: ResizeGizmoConfigManager,
visuals: ResizeGizmoVisuals,
calculator: ScalingCalculator,
snapping: ResizeGizmoSnapping,
feedback: ResizeGizmoFeedback
) {
this._scene = scene;
this._config = config;
this._visuals = visuals;
this._calculator = calculator;
this._snapping = snapping;
this._feedback = feedback;
this.setupPointerObserver();
}
/**
* Set callback for scale change events
*/
setOnScaleChange(callback: (event: ResizeGizmoEvent) => void): void {
this._onScaleChange = callback;
}
/**
* Register WebXR controller
*/
registerController(controller: WebXRInputSource): void {
const id = controller.uniqueId;
if (this._xrControllers.has(id)) {
return;
}
this._xrControllers.set(id, controller);
// Motion controller might not be initialized yet
// Listen for motion controller initialization, then register grip handler
const setupGripHandler = () => {
const gripComponent = controller.motionController?.getComponent("xr-standard-squeeze");
if (gripComponent) {
const observer = gripComponent.onButtonStateChangedObservable.add((component) => {
if (component.changes.pressed) {
if (component.pressed) {
this.onGripPressed(controller);
} else {
this.onGripReleased(controller);
}
}
});
this._gripObservers.set(id, observer);
}
};
// If motion controller already exists, set up handler immediately
if (controller.motionController) {
setupGripHandler();
} else {
// Otherwise, wait for motion controller to be initialized
controller.onMotionControllerInitObservable.add(() => {
setupGripHandler();
});
}
}
/**
* Unregister WebXR controller
*/
unregisterController(controller: WebXRInputSource): void {
const id = controller.uniqueId;
// Remove grip observer
const observer = this._gripObservers.get(id);
if (observer) {
const gripComponent = controller.motionController?.getComponent("xr-standard-squeeze");
gripComponent?.onButtonStateChangedObservable.remove(observer);
this._gripObservers.delete(id);
}
this._xrControllers.delete(id);
}
/**
* Setup pointer observer for hover detection
* Note: This only detects main scene meshes, not utility layer meshes
*/
private setupPointerObserver(): void {
this._pointerObserver = this._scene.onPointerObservable.add((pointerInfo) => {
if (pointerInfo.type === PointerEventTypes.POINTERMOVE) {
this.handlePointerMove(pointerInfo);
}
});
}
/**
* Handle pointer movement (for hover detection)
* Only detects target mesh hover - handles are detected via manual ray picking in update()
*/
private handlePointerMove(pointerInfo: PointerInfo): void {
// Only process when not actively scaling
if (this._state.state === InteractionState.ACTIVE_SCALING) {
return;
}
// Check for WebXR pointer
const pickInfo = pointerInfo.pickInfo;
if (!pickInfo) {
return;
}
// Check if hovering over target mesh
if (pickInfo.pickedMesh === this._state.targetMesh) {
this.onMeshHovered(pickInfo.pickedMesh);
}
}
/**
* Check if WebXR pointer is hovering over a handle using manual ray picking
* Must use manual picking because handles are in utility layer, not main scene
* Returns handle info with pick result for intersection point
*/
private getHandleUnderPointer(): HandlePickResult | undefined {
// Get utility layer scene from visuals
const utilityScene = this._visuals.getUtilityScene();
// Iterate through registered XR controllers
for (const controller of this._xrControllers.values()) {
if (!controller.pointer) {
continue;
}
// Use getWorldPointerRayToRef to get ray in world space
// This is crucial when controllers are parented to a rig
const ray = new Ray(Vector3.Zero(), Vector3.Forward(), 1000);
controller.getWorldPointerRayToRef(ray);
// Pick from utility layer scene
const pickResult = utilityScene.pickWithRay(ray, (mesh) => {
return mesh.id.includes('gizmo-handle');
});
if (pickResult && pickResult.hit && pickResult.pickedMesh) {
// Check if picked mesh is one of our handles
const handle = this._visuals.getHandleByMesh(pickResult.pickedMesh);
if (handle) {
return {
handle,
pickInfo: pickResult,
controller
};
}
}
}
return undefined;
}
/**
* 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
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;
}
/**
* Dispose
*/
dispose(): void {
// Remove pointer observer
if (this._pointerObserver) {
this._scene.onPointerObservable.remove(this._pointerObserver);
this._pointerObserver = undefined;
}
// Unregister all controllers
for (const controller of this._xrControllers.values()) {
this.unregisterController(controller);
}
this._xrControllers.clear();
this._gripObservers.clear();
}
}

View File

@ -0,0 +1,364 @@
/**
* WebXR Resize Gizmo - Manager
* Main orchestration class that manages the resize gizmo system
*/
import {
Scene,
AbstractMesh,
Observable,
WebXRInputSource
} from "@babylonjs/core";
import {
ResizeGizmoMode,
ResizeGizmoConfig,
ResizeGizmoEvent,
ResizeGizmoEventType,
ResizeGizmoEventCallback,
ResizeGizmoObserver
} from "./types";
import { ResizeGizmoConfigManager } from "./ResizeGizmoConfig";
import { ResizeGizmoVisuals } from "./ResizeGizmoVisuals";
import { ResizeGizmoInteraction } from "./ResizeGizmoInteraction";
import { ScalingCalculator } from "./ScalingCalculator";
import { ResizeGizmoSnapping } from "./ResizeGizmoSnapping";
import { ResizeGizmoFeedback } from "./ResizeGizmoFeedback";
/**
* Main manager class for the resize gizmo system
*
* @example
* ```typescript
* // Create gizmo manager
* const gizmo = new ResizeGizmoManager(scene, {
* mode: ResizeGizmoMode.ALL,
* enableSnapping: true,
* snapDistanceX: 0.1
* });
*
* // Attach to a mesh
* gizmo.attachToMesh(myMesh);
*
* // Register WebXR controllers
* xr.input.onControllerAddedObservable.add((controller) => {
* gizmo.registerController(controller);
* });
*
* // Listen to scale events
* gizmo.onScaleEnd((event) => {
* console.log("Final scale:", event.scale);
* });
*
* // Update in render loop
* scene.onBeforeRenderObservable.add(() => {
* gizmo.update();
* });
* ```
*/
export class ResizeGizmoManager {
private _scene: Scene;
private _config: ResizeGizmoConfigManager;
// Subsystems
private _visuals: ResizeGizmoVisuals;
private _snapping: ResizeGizmoSnapping;
private _calculator: ScalingCalculator;
private _feedback: ResizeGizmoFeedback;
private _interaction: ResizeGizmoInteraction;
// Event system
private _observable: Observable<ResizeGizmoEvent>;
private _observers: ResizeGizmoObserver[] = [];
// State
private _attachedMesh?: AbstractMesh;
private _enabled: boolean = true;
constructor(scene: Scene, config?: Partial<ResizeGizmoConfig>) {
this._scene = scene;
this._config = new ResizeGizmoConfigManager(config);
this._observable = new Observable<ResizeGizmoEvent>();
// Initialize subsystems
this._snapping = new ResizeGizmoSnapping(this._config);
this._calculator = new ScalingCalculator(this._config, this._snapping);
this._visuals = new ResizeGizmoVisuals(scene, this._config);
this._feedback = new ResizeGizmoFeedback(scene, this._config, this._snapping);
this._interaction = new ResizeGizmoInteraction(
scene,
this._config,
this._visuals,
this._calculator,
this._snapping,
this._feedback
);
// Wire up interaction events
this._interaction.setOnScaleChange((event) => {
this.emitEvent(event);
});
}
/**
* Attach gizmo to a mesh
*/
attachToMesh(mesh: AbstractMesh): void {
// Detach from previous mesh
if (this._attachedMesh) {
this.detachFromMesh();
}
this._attachedMesh = mesh;
// Attach subsystems
this._visuals.attach(mesh);
this._interaction.attach(mesh);
// Emit event
this.emitEvent({
type: ResizeGizmoEventType.ATTACHED,
mesh,
scale: mesh.scaling.clone(),
timestamp: Date.now()
});
}
/**
* Detach from current mesh
*/
detachFromMesh(): void {
if (!this._attachedMesh) {
return;
}
const mesh = this._attachedMesh;
// Detach subsystems
this._visuals.detach();
this._interaction.detach();
this._attachedMesh = undefined;
// Emit event
this.emitEvent({
type: ResizeGizmoEventType.DETACHED,
mesh,
scale: mesh.scaling.clone(),
timestamp: Date.now()
});
}
/**
* Register a WebXR controller
*/
registerController(controller: WebXRInputSource): void {
this._interaction.registerController(controller);
}
/**
* Unregister a WebXR controller
*/
unregisterController(controller: WebXRInputSource): void {
this._interaction.unregisterController(controller);
}
/**
* Update (call in render loop)
*/
update(): void {
if (!this._enabled || !this._attachedMesh) {
return;
}
this._interaction.update();
}
/**
* Set gizmo mode
*/
setMode(mode: ResizeGizmoMode): void {
this._config.setMode(mode);
// Update visuals
if (this._attachedMesh) {
this._visuals.detach();
this._visuals.attach(this._attachedMesh);
}
// Emit event
if (this._attachedMesh) {
this.emitEvent({
type: ResizeGizmoEventType.MODE_CHANGED,
mesh: this._attachedMesh,
scale: this._attachedMesh.scaling.clone(),
timestamp: Date.now()
});
}
}
/**
* Get current mode
*/
getMode(): ResizeGizmoMode {
return this._config.current.mode;
}
/**
* Update configuration
*/
updateConfig(updates: Partial<ResizeGizmoConfig>): void {
this._config.update(updates);
// Refresh visuals if attached
if (this._attachedMesh) {
this._visuals.update();
}
}
/**
* Get current configuration
*/
getConfig(): Readonly<ResizeGizmoConfig> {
return this._config.current;
}
/**
* Enable/disable gizmo
*/
setEnabled(enabled: boolean): void {
this._enabled = enabled;
this._visuals.setVisible(enabled);
}
/**
* Check if enabled
*/
isEnabled(): boolean {
return this._enabled;
}
/**
* Get attached mesh
*/
getAttachedMesh(): AbstractMesh | undefined {
return this._attachedMesh;
}
/**
* Check if gizmo is currently being used (scaling in progress)
*/
isScaling(): boolean {
return this._interaction.isScaling();
}
/**
* Check if hovering over a handle (will handle grip button press)
*/
isHoveringHandle(): boolean {
return this._interaction.isHoveringHandle();
}
// ===== Event System =====
/**
* Register event listener for specific event type
*/
on(eventType: ResizeGizmoEventType, callback: ResizeGizmoEventCallback): void {
const observer = this._observable.add((event) => {
if (event.type === eventType) {
callback(event);
}
});
this._observers.push({
eventType,
callback,
observer
});
}
/**
* Remove event listener
*/
off(eventType: ResizeGizmoEventType, callback: ResizeGizmoEventCallback): void {
const index = this._observers.findIndex(
(o) => o.eventType === eventType && o.callback === callback
);
if (index >= 0) {
const observerInfo = this._observers[index];
this._observable.remove(observerInfo.observer);
this._observers.splice(index, 1);
}
}
/**
* Listen to scale start events
*/
onScaleStart(callback: ResizeGizmoEventCallback): void {
this.on(ResizeGizmoEventType.SCALE_START, callback);
}
/**
* Listen to scale drag events
*/
onScaleDrag(callback: ResizeGizmoEventCallback): void {
this.on(ResizeGizmoEventType.SCALE_DRAG, callback);
}
/**
* Listen to scale end events
*/
onScaleEnd(callback: ResizeGizmoEventCallback): void {
this.on(ResizeGizmoEventType.SCALE_END, callback);
}
/**
* Listen to attach events
*/
onAttached(callback: ResizeGizmoEventCallback): void {
this.on(ResizeGizmoEventType.ATTACHED, callback);
}
/**
* Listen to detach events
*/
onDetached(callback: ResizeGizmoEventCallback): void {
this.on(ResizeGizmoEventType.DETACHED, callback);
}
/**
* Listen to mode change events
*/
onModeChanged(callback: ResizeGizmoEventCallback): void {
this.on(ResizeGizmoEventType.MODE_CHANGED, callback);
}
/**
* Emit an event
*/
private emitEvent(event: ResizeGizmoEvent): void {
if (this._config.current.emitEvents) {
this._observable.notifyObservers(event);
}
}
/**
* Dispose all resources
*/
dispose(): void {
// Detach from mesh
if (this._attachedMesh) {
this.detachFromMesh();
}
// Dispose subsystems
this._interaction.dispose();
this._feedback.dispose();
this._visuals.dispose();
// Clear observers
this._observable.clear();
this._observers = [];
}
}

View File

@ -0,0 +1,135 @@
/**
* WebXR Resize Gizmo - Snapping System
* Handles snap-to-grid functionality for scale values
*/
import { Vector3 } from "@babylonjs/core";
import { ResizeGizmoConfigManager } from "./ResizeGizmoConfig";
/**
* Snapping utilities for resize gizmo
*/
export class ResizeGizmoSnapping {
private _config: ResizeGizmoConfigManager;
constructor(config: ResizeGizmoConfigManager) {
this._config = config;
}
/**
* Snap a single value to nearest snap interval
*/
private snapValue(value: number, snapInterval: number): number {
if (snapInterval <= 0) {
return value;
}
return Math.round(value / snapInterval) * snapInterval;
}
/**
* Snap a scale vector to configured snap intervals
*/
snapScale(scale: Vector3): Vector3 {
if (!this._config.current.enableSnapping) {
return scale;
}
const config = this._config.current;
return new Vector3(
this.snapValue(scale.x, config.snapDistanceX),
this.snapValue(scale.y, config.snapDistanceY),
this.snapValue(scale.z, config.snapDistanceZ)
);
}
/**
* Snap only specific axes
*/
snapScaleAxes(scale: Vector3, axes: ("X" | "Y" | "Z")[]): Vector3 {
if (!this._config.current.enableSnapping) {
return scale;
}
const result = scale.clone();
const config = this._config.current;
for (const axis of axes) {
switch (axis) {
case "X":
result.x = this.snapValue(result.x, config.snapDistanceX);
break;
case "Y":
result.y = this.snapValue(result.y, config.snapDistanceY);
break;
case "Z":
result.z = this.snapValue(result.z, config.snapDistanceZ);
break;
}
}
return result;
}
/**
* Check if a value is close to a snap point (for visual feedback)
*/
isNearSnapPoint(value: number, snapInterval: number, threshold: number = 0.05): boolean {
if (snapInterval <= 0) {
return false;
}
const snapped = this.snapValue(value, snapInterval);
return Math.abs(value - snapped) < threshold * snapInterval;
}
/**
* Get nearest snap point for a value
*/
getNearestSnapPoint(value: number, snapInterval: number): number {
return this.snapValue(value, snapInterval);
}
/**
* Get all snap points in a range
*/
getSnapPointsInRange(min: number, max: number, snapInterval: number): number[] {
if (snapInterval <= 0) {
return [];
}
const points: number[] = [];
const start = Math.ceil(min / snapInterval) * snapInterval;
const end = Math.floor(max / snapInterval) * snapInterval;
for (let value = start; value <= end; value += snapInterval) {
points.push(value);
}
return points;
}
/**
* Calculate haptic feedback intensity based on proximity to snap point
* Returns 0-1 value (1 = directly on snap point, 0 = far from snap)
*/
calculateSnapProximity(value: number, snapInterval: number): number {
if (snapInterval <= 0) {
return 0;
}
const snapped = this.snapValue(value, snapInterval);
const distance = Math.abs(value - snapped);
const maxDistance = snapInterval / 2;
return Math.max(0, 1 - (distance / maxDistance));
}
/**
* Check if snapping is enabled
*/
isEnabled(): boolean {
return this._config.current.enableSnapping;
}
}

View File

@ -0,0 +1,383 @@
/**
* WebXR Resize Gizmo - Visual Rendering
* Handles rendering of bounding boxes, handles, and visual feedback
*/
import {
Scene,
AbstractMesh,
Mesh,
MeshBuilder,
StandardMaterial,
Color3,
UtilityLayerRenderer,
LinesMesh,
Vector3
} from "@babylonjs/core";
import { HandlePosition, HandleType } from "./types";
import { ResizeGizmoConfigManager } from "./ResizeGizmoConfig";
import { HandleGeometry } from "./HandleGeometry";
/**
* Manages all visual elements of the resize gizmo
*/
export class ResizeGizmoVisuals {
private _scene: Scene;
private _utilityLayer: UtilityLayerRenderer;
private _config: ResizeGizmoConfigManager;
// Visual elements
private _boundingBoxLines?: LinesMesh;
private _handleMeshes: Map<string, Mesh> = new Map();
private _handleMaterials: Map<string, StandardMaterial> = new Map();
// Current state
private _targetMesh?: AbstractMesh;
private _handles: HandlePosition[] = [];
private _visible: boolean = false;
constructor(scene: Scene, config: ResizeGizmoConfigManager) {
this._scene = scene;
this._config = config;
// Create utility layer for gizmo rendering
this._utilityLayer = new UtilityLayerRenderer(scene);
this._utilityLayer.shouldRender = true;
}
/**
* Attach gizmo to a mesh and show visuals
*/
attach(mesh: AbstractMesh): void {
this.detach();
this._targetMesh = mesh;
this._visible = true;
// Generate handle positions
this._handles = this.generateHandlePositions();
// Create visual elements
this.createBoundingBox();
this.createHandleMeshes();
}
/**
* Detach from current mesh and hide visuals
*/
detach(): void {
this._targetMesh = undefined;
this._visible = false;
this._handles = [];
this.disposeBoundingBox();
this.disposeHandleMeshes();
}
/**
* Update visuals (call when mesh transforms or config changes)
*/
update(): void {
if (!this._targetMesh || !this._visible) {
return;
}
// Recompute bounding box
this._targetMesh.refreshBoundingInfo();
// Regenerate handles
this._handles = this.generateHandlePositions();
// Update visuals
this.updateBoundingBox();
this.updateHandlePositions();
}
/**
* Generate handle positions based on current config and mesh bounding box
*/
private generateHandlePositions(): HandlePosition[] {
if (!this._targetMesh) {
return [];
}
const boundingInfo = this._targetMesh.getBoundingInfo();
const boundingBox = boundingInfo.boundingBox;
// Calculate padding
const padding = HandleGeometry.calculatePadding(
boundingBox,
this._config.current.boundingBoxPadding
);
// Generate handles based on mode
return HandleGeometry.generateHandles(
boundingBox,
padding,
this._config.usesCornerHandles(),
this._config.usesEdgeHandles(),
this._config.usesFaceHandles()
);
}
/**
* Create bounding box wireframe
*/
private createBoundingBox(): void {
if (!this._targetMesh) {
return;
}
this.disposeBoundingBox();
const boundingInfo = this._targetMesh.getBoundingInfo();
const boundingBox = boundingInfo.boundingBox;
const min = boundingBox.minimumWorld;
const max = boundingBox.maximumWorld;
// Calculate padding
const padding = HandleGeometry.calculatePadding(
boundingBox,
this._config.current.boundingBoxPadding
);
const paddedMin = min.subtract(new Vector3(padding, padding, padding));
const paddedMax = max.add(new Vector3(padding, padding, padding));
// Create line points for bounding box edges
const points = [
// Bottom face
[paddedMin, new Vector3(paddedMax.x, paddedMin.y, paddedMin.z)],
[new Vector3(paddedMax.x, paddedMin.y, paddedMin.z), new Vector3(paddedMax.x, paddedMin.y, paddedMax.z)],
[new Vector3(paddedMax.x, paddedMin.y, paddedMax.z), new Vector3(paddedMin.x, paddedMin.y, paddedMax.z)],
[new Vector3(paddedMin.x, paddedMin.y, paddedMax.z), paddedMin],
// Top face
[new Vector3(paddedMin.x, paddedMax.y, paddedMin.z), new Vector3(paddedMax.x, paddedMax.y, paddedMin.z)],
[new Vector3(paddedMax.x, paddedMax.y, paddedMin.z), paddedMax],
[paddedMax, new Vector3(paddedMin.x, paddedMax.y, paddedMax.z)],
[new Vector3(paddedMin.x, paddedMax.y, paddedMax.z), new Vector3(paddedMin.x, paddedMax.y, paddedMin.z)],
// Vertical edges
[paddedMin, new Vector3(paddedMin.x, paddedMax.y, paddedMin.z)],
[new Vector3(paddedMax.x, paddedMin.y, paddedMin.z), new Vector3(paddedMax.x, paddedMax.y, paddedMin.z)],
[new Vector3(paddedMax.x, paddedMin.y, paddedMax.z), paddedMax],
[new Vector3(paddedMin.x, paddedMin.y, paddedMax.z), new Vector3(paddedMin.x, paddedMax.y, paddedMax.z)]
];
// Flatten points
const flatPoints: Vector3[] = [];
for (const line of points) {
flatPoints.push(...line);
}
// 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
);
mesh.position = handle.position.clone();
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 positions
*/
private updateHandlePositions(): void {
for (const handle of this._handles) {
const mesh = this._handleMeshes.get(handle.id);
if (mesh) {
mesh.position = handle.position.clone();
}
}
}
/**
* Dispose handle meshes
*/
private disposeHandleMeshes(): void {
for (const mesh of this._handleMeshes.values()) {
mesh.dispose();
}
for (const material of this._handleMaterials.values()) {
material.dispose();
}
this._handleMeshes.clear();
this._handleMaterials.clear();
}
/**
* Highlight a handle (on hover)
*/
highlightHandle(handleId: string): void {
const mesh = this._handleMeshes.get(handleId);
const material = this._handleMaterials.get(handleId);
if (mesh && material) {
material.emissiveColor = this._config.current.hoverColor;
mesh.scaling = new Vector3(
this._config.current.hoverScaleFactor,
this._config.current.hoverScaleFactor,
this._config.current.hoverScaleFactor
);
}
}
/**
* Unhighlight a handle
*/
unhighlightHandle(handleId: string): void {
const handle = this._handles.find(h => h.id === handleId);
const mesh = this._handleMeshes.get(handleId);
const material = this._handleMaterials.get(handleId);
if (handle && mesh && material) {
material.emissiveColor = this.getHandleColor(handle.type);
mesh.scaling = new Vector3(1, 1, 1);
}
}
/**
* Set handle to active state (during drag)
*/
setHandleActive(handleId: string): void {
const material = this._handleMaterials.get(handleId);
if (material) {
material.emissiveColor = this._config.current.activeColor;
}
}
/**
* Set visibility
*/
setVisible(visible: boolean): void {
this._visible = visible;
if (this._boundingBoxLines) {
this._boundingBoxLines.setEnabled(visible);
}
for (const mesh of this._handleMeshes.values()) {
mesh.setEnabled(visible);
}
}
/**
* Get handle by mesh
*/
getHandleByMesh(mesh: AbstractMesh): HandlePosition | undefined {
for (const handle of this._handles) {
const handleMesh = this._handleMeshes.get(handle.id);
if (handleMesh === mesh) {
return handle;
}
}
return undefined;
}
/**
* Get all handles
*/
getHandles(): ReadonlyArray<HandlePosition> {
return this._handles;
}
/**
* Get utility layer scene
*/
getUtilityScene(): Scene {
return this._utilityLayer.utilityLayerScene;
}
/**
* Dispose all resources
*/
dispose(): void {
this.detach();
this._utilityLayer.dispose();
}
}

View File

@ -0,0 +1,343 @@
/**
* 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 vector from pivot to virtual points
const startVector = startVirtualPoint.subtract(boundingBoxCenter);
const currentVector = currentVirtualPoint.subtract(boundingBoxCenter);
// Determine which two axes to scale
const axes = handle.axes;
const worldMatrix = mesh.getWorldMatrix();
// For each axis involved, calculate scale ratio based on projection
for (const axis of axes) {
// 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 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
switch (axis) {
case "X":
newScale.x = startScale.x * scaleRatio;
break;
case "Y":
newScale.y = startScale.y * scaleRatio;
break;
case "Z":
newScale.z = startScale.z * scaleRatio;
break;
}
}
return newScale;
}
/**
* Calculate single-axis scale using distance-ratio method
* Uses "virtual stick" metaphor - scale based on distance from pivot point
*/
private calculateSingleAxisScale(
mesh: AbstractMesh,
handle: HandlePosition,
startScale: Vector3,
startVirtualPoint: Vector3,
currentVirtualPoint: Vector3,
boundingBoxCenter?: Vector3
): Vector3 {
const newScale = startScale.clone();
// Get axis direction
const axis = handle.axes[0]; // Only one axis for face handles
// If no bounding box center provided, fall back to old drag-based method
if (!boundingBoxCenter) {
const dragVector = currentVirtualPoint.subtract(startVirtualPoint);
const worldMatrix = mesh.getWorldMatrix();
const worldNormal = Vector3.TransformNormal(handle.normal, worldMatrix).normalize();
const dragDistance = Vector3.Dot(dragVector, worldNormal);
const boundingInfo = mesh.getBoundingInfo();
const boundingSize = boundingInfo.boundingBox.extendSizeWorld;
let axisSize: number;
switch (axis) {
case "X": axisSize = boundingSize.x; break;
case "Y": axisSize = boundingSize.y; break;
case "Z": axisSize = boundingSize.z; break;
}
const sensitivity = 2.0;
const scaleFactor = 1 + (dragDistance / axisSize) * sensitivity;
switch (axis) {
case "X": newScale.x = startScale.x * scaleFactor; break;
case "Y": newScale.y = startScale.y * scaleFactor; break;
case "Z": newScale.z = startScale.z * scaleFactor; break;
}
return newScale;
}
// Calculate vector from pivot to virtual points
const startVector = startVirtualPoint.subtract(boundingBoxCenter);
const currentVector = currentVirtualPoint.subtract(boundingBoxCenter);
// Get local axis vector
let localAxisVector: Vector3;
switch (axis) {
case "X":
localAxisVector = Vector3.Right();
break;
case "Y":
localAxisVector = Vector3.Up();
break;
case "Z":
localAxisVector = Vector3.Forward();
break;
}
// Transform axis to world space
const worldMatrix = mesh.getWorldMatrix();
const worldAxisVector = Vector3.TransformNormal(localAxisVector, worldMatrix).normalize();
// Project start and current vectors onto this axis
const startProjection = Vector3.Dot(startVector, worldAxisVector);
const currentProjection = Vector3.Dot(currentVector, worldAxisVector);
// Calculate scale ratio for this axis
// Avoid division by zero
const scaleRatio = Math.abs(startProjection) > 0.001
? currentProjection / startProjection
: 1.0;
// Apply scale to this axis only
switch (axis) {
case "X":
newScale.x = startScale.x * scaleRatio;
break;
case "Y":
newScale.y = startScale.y * scaleRatio;
break;
case "Z":
newScale.z = startScale.z * scaleRatio;
break;
}
return newScale;
}
/**
* Apply min/max constraints to scale
*/
private applyConstraints(scale: Vector3): Vector3 {
const config = this._config.current;
const constrained = scale.clone();
// Apply minimum scale
constrained.x = Math.max(constrained.x, config.minScale.x);
constrained.y = Math.max(constrained.y, config.minScale.y);
constrained.z = Math.max(constrained.z, config.minScale.z);
// Apply maximum scale (if set)
if (config.maxScale) {
constrained.x = Math.min(constrained.x, config.maxScale.x);
constrained.y = Math.min(constrained.y, config.maxScale.y);
constrained.z = Math.min(constrained.z, config.maxScale.z);
}
return constrained;
}
/**
* Calculate scale delta (for display)
*/
calculateScaleDelta(currentScale: Vector3, originalScale: Vector3): Vector3 {
return new Vector3(
currentScale.x - originalScale.x,
currentScale.y - originalScale.y,
currentScale.z - originalScale.z
);
}
/**
* Calculate scale percentage (for display)
*/
calculateScalePercentage(currentScale: Vector3, originalScale: Vector3): Vector3 {
return new Vector3(
(currentScale.x / originalScale.x) * 100,
(currentScale.y / originalScale.y) * 100,
(currentScale.z / originalScale.z) * 100
);
}
}

View File

@ -0,0 +1,61 @@
/**
* 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
*/
// Main manager
export { ResizeGizmoManager } from "./ResizeGizmoManager";
// Configuration
export { ResizeGizmoConfigManager } from "./ResizeGizmoConfig";
// Types
export {
ResizeGizmoMode,
HandleType,
InteractionState,
ResizeGizmoEventType,
ResizeGizmoConfig,
ResizeGizmoEvent,
ResizeGizmoEventCallback,
HandlePosition,
DEFAULT_RESIZE_GIZMO_CONFIG
} from "./types";
// 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";

View File

@ -0,0 +1,313 @@
/**
* 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",
/** Only edge-center handles (12 handles) - scale two axes simultaneously */
TWO_AXIS = "TWO_AXIS",
/** All handles enabled (26 total) - 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 ===
/** Padding around mesh bounding box (0.05 = 5% padding) */
boundingBoxPadding: number;
/** Bounding box wireframe color */
boundingBoxColor: Color3;
/** Bounding box wireframe transparency (0-1) */
wireframeAlpha: number;
/** Show bounding box only on hover */
showBoundingBoxOnHoverOnly: 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
boundingBoxPadding: 0.05,
boundingBoxColor: new Color3(1.0, 1.0, 1.0), // White
wireframeAlpha: 0.3,
showBoundingBoxOnHoverOnly: false,
// Snapping
enableSnapping: true,
snapDistanceX: 0.1,
snapDistanceY: 0.1,
snapDistanceZ: 0.1,
showSnapIndicators: true,
hapticFeedback: true,
// Visual feedback
showNumericDisplay: true,
showGrid: true,
showSnapPoints: true,
numericDisplayFontSize: 24,
// Constraints
minScale: new Vector3(0.01, 0.01, 0.01),
maxScale: undefined,
lockAspectRatio: false,
scaleFromCenter: true,
// Integration
useDiagramEntity: false,
diagramManager: undefined,
emitEvents: true
};
/**
* Internal state for gizmo interaction
*/
export interface GizmoInteractionState {
/** Current interaction state */
state: InteractionState;
/** Handle currently being hovered (if any) */
hoveredHandle?: HandlePosition;
/** Handle currently being dragged (if any) */
activeHandle?: HandlePosition;
/** Starting scale when drag began */
startScale?: Vector3;
/** Starting pointer position when drag began (world space) */
startPointerPosition?: Vector3;
/** Current pointer position during drag (world space) */
currentPointerPosition?: Vector3;
/** Mesh currently being scaled */
targetMesh?: AbstractMesh;
/** Fixed "stick length" from controller to intersection point at grip press */
stickLength?: number;
/** World-space center of bounding box at drag start */
boundingBoxCenter?: Vector3;
}
/**
* Callback type for gizmo events
*/
export type ResizeGizmoEventCallback = (event: ResizeGizmoEvent) => void;
/**
* Observer info for cleanup
*/
export interface ResizeGizmoObserver {
eventType: ResizeGizmoEventType;
callback: ResizeGizmoEventCallback;
observer: Observer<ResizeGizmoEvent>;
}

View File

@ -0,0 +1,166 @@
/**
* DiagramEntity Integration Adapter for ResizeGizmo
* Bridges ResizeGizmo events to DiagramManager's persistence system
*
* This adapter lives in the integration layer to keep the ResizeGizmo
* system pure and reusable without diagram-specific dependencies.
*/
import { AbstractMesh } from "@babylonjs/core";
import { ResizeGizmoManager } from "../../gizmos/ResizeGizmo";
import { ResizeGizmoEvent } from "../../gizmos/ResizeGizmo";
/**
* Type definitions for DiagramManager integration (loosely coupled)
* These match the actual types in the codebase without importing them
*/
interface DiagramEntity {
id?: string;
template?: string;
position?: { x: number; y: number; z: number };
rotation?: { x: number; y: number; z: number };
scale?: { x: number; y: number; z: number };
[key: string]: any;
}
enum DiagramEventType {
MODIFY = "MODIFY"
}
interface DiagramEvent {
type: DiagramEventType;
entity: DiagramEntity;
}
enum DiagramEventObserverMask {
TO_DB = 2,
ALL = -1
}
interface DiagramEventNotifier {
notifyObservers(event: DiagramEvent, mask?: number): void;
}
interface DiagramManager {
onDiagramEventObservable: DiagramEventNotifier;
}
/**
* Converter function type for transforming BabylonJS meshes to DiagramEntities
*/
export type MeshToEntityConverter = (mesh: AbstractMesh) => DiagramEntity;
/**
* Adapter that connects ResizeGizmo to DiagramManager for persistence
* Uses dependency injection to remain loosely coupled from diagram internals
*
* @example
* ```typescript
* import { DiagramEntityAdapter } from './integration/gizmo';
* import { toDiagramEntity } from './diagram/functions/toDiagramEntity';
*
* // Create resize gizmo
* const gizmo = new ResizeGizmoManager(scene, {
* mode: ResizeGizmoMode.ALL
* });
*
* // Create adapter with injected converter
* const adapter = new DiagramEntityAdapter(
* gizmo,
* diagramManager,
* toDiagramEntity, // Injected dependency
* false // Don't persist on drag
* );
*
* // Now scale changes will automatically persist to database
* gizmo.attachToMesh(myDiagramMesh);
* ```
*/
export class DiagramEntityAdapter {
private _gizmo: ResizeGizmoManager;
private _diagramManager: DiagramManager;
private _meshConverter: MeshToEntityConverter;
private _persistOnDrag: boolean;
/**
* Create adapter
* @param gizmo ResizeGizmoManager instance
* @param diagramManager DiagramManager instance (or object with onDiagramEventObservable)
* @param meshConverter Function to convert BabylonJS mesh to DiagramEntity (injected dependency)
* @param persistOnDrag If true, persist on every drag update (can be expensive). If false, only persist on scale end.
*/
constructor(
gizmo: ResizeGizmoManager,
diagramManager: DiagramManager,
meshConverter: MeshToEntityConverter,
persistOnDrag: boolean = false
) {
this._gizmo = gizmo;
this._diagramManager = diagramManager;
this._meshConverter = meshConverter;
this._persistOnDrag = persistOnDrag;
this.setupEventListeners();
}
/**
* Setup event listeners
*/
private setupEventListeners(): void {
// Persist on scale end (always)
this._gizmo.onScaleEnd((event) => {
this.persistScaleChange(event);
});
// Optionally persist on drag
if (this._persistOnDrag) {
this._gizmo.onScaleDrag((event) => {
this.persistScaleChange(event);
});
}
}
/**
* Persist scale change to DiagramManager
*/
private persistScaleChange(event: ResizeGizmoEvent): void {
const mesh = event.mesh;
// Convert mesh to DiagramEntity using injected converter
// This properly extracts color from material and all other properties
const entity = this._meshConverter(mesh);
// Notify DiagramManager
this._diagramManager.onDiagramEventObservable.notifyObservers(
{
type: DiagramEventType.MODIFY,
entity
},
DiagramEventObserverMask.TO_DB
);
}
/**
* Enable/disable drag persistence
*/
setPersistOnDrag(enabled: boolean): void {
if (this._persistOnDrag === enabled) {
return;
}
this._persistOnDrag = enabled;
// Re-setup listeners
// Note: In a production implementation, you'd want to properly remove/add observers
// For now, this is a simplified version
console.warn("[DiagramEntityAdapter] Changing persistOnDrag at runtime may cause duplicate events");
}
/**
* Get persist on drag setting
*/
getPersistOnDrag(): boolean {
return this._persistOnDrag;
}
}

View File

@ -0,0 +1,6 @@
/**
* Gizmo Integration Layer
* Adapters for integrating gizmo systems with diagram persistence
*/
export { DiagramEntityAdapter, type MeshToEntityConverter } from './DiagramEntityAdapter';

View File

@ -40,6 +40,10 @@ export class ScaleMenu2 {
return this._gizmoManager.attachedMesh; return this._gizmoManager.attachedMesh;
} }
public get gizmoManager() {
return this._gizmoManager;
}
public show(mesh: AbstractMesh) { public show(mesh: AbstractMesh) {
if (mesh.metadata.image) { if (mesh.metadata.image) {
configureImageScale(this._gizmoManager.gizmos.scaleGizmo.yGizmo, true); configureImageScale(this._gizmoManager.gizmos.scaleGizmo.yGizmo, true);
@ -61,6 +65,9 @@ function configureGizmo(gizmo: IAxisScaleGizmo) {
gizmo.scaleRatio = 3; gizmo.scaleRatio = 3;
gizmo.sensitivity = 3; gizmo.sensitivity = 3;
// Disable automatic pointer-based drag, we'll control it manually via squeeze button
// This prevents conflicts with trigger button and enables squeeze-based manipulation
gizmo.dragBehavior.startAndReleaseDragOnPointerEvents = false;
} }
function configureImageScale(gizmo: IAxisScaleGizmo, enabled: boolean) { function configureImageScale(gizmo: IAxisScaleGizmo, enabled: boolean) {

View File

@ -1,101 +0,0 @@
import {DefaultScene} from "../defaultScene";
import {AbstractMesh, Observable, TransformNode, Vector3} from "@babylonjs/core";
import {Button} from "../objects/Button";
export class ScaleMenu {
private static Sizes = [
.025, .05, .1, .25, .5, 1.0, 2.0, 3.0, 4.0, 5.0
]
public readonly onScaleChangeObservable: Observable<AbstractMesh> = new Observable<AbstractMesh>();
private readonly transform;
private _mesh: AbstractMesh;
constructor() {
this.transform = new TransformNode("scaleMenu", DefaultScene.Scene);
this.transform.scaling = new Vector3(.5, .5, .5);
this.build();
}
private async build() {
let x = .12;
const xParent = new TransformNode("xParent", DefaultScene.Scene);
xParent.parent = this.transform;
const yParent = new TransformNode("yParent", DefaultScene.Scene);
yParent.parent = this.transform;
const zParent = new TransformNode("zParent", DefaultScene.Scene);
zParent.parent = this.transform;
xParent.rotation.x = Math.PI / 2;
yParent.rotation.z = Math.PI / 2;
yParent.billboardMode = TransformNode.BILLBOARDMODE_Y;
zParent.rotation.y = Math.PI / 2;
zParent.rotation.x = Math.PI / 2;
for (const size of ScaleMenu.Sizes) {
const xbutton = this.makeButton(size.toString(), x, 0, xParent);
xbutton.onPointerObservable.add((eventData) => {
if (eventData.sourceEvent.type == "pointerup") {
this.scaleX(size)
}
}, -1, false, this, false);
const ybutton = this.makeButton(size.toString(), x, Math.PI / 2, yParent);
ybutton.onPointerObservable.add((eventData) => {
if (eventData.sourceEvent.type == "pointerup") {
this.scaleY(size)
}
}, -1, false, this, false);
const zbutton = this.makeButton(size.toString(), x, -Math.PI / 2, zParent);
zbutton.onPointerObservable.add((eventData) => {
if (eventData.sourceEvent.type == "pointerup") {
this.scaleZ(size)
}
}, -1, false, this, false);
x += .11;
}
// const labelX = await this.createLabel('X Size', .3);
// const labelY = await this.createLabel('Y Size', .2);
// const labelZ = await this.createLabel('Z Size', .1);
this.transform.position.y = 1;
this.transform.rotation.y = Math.PI;
this.transform.setEnabled(false);
}
private makeButton(name: string, x: number, y: number, parent: TransformNode = null) {
const button = new Button(name, name, DefaultScene.Scene);
button.transform.parent = parent;
button.transform.position.x = x;
//button.transform.position.y = y;
button.transform.rotation.z = y;
button.transform.rotation.y = Math.PI;
return button;
}
private scaleX(size: number) {
if (this._mesh) {
this._mesh.scaling.x = size;
this.scaleChanged();
}
}
private scaleY(size: number) {
if (this._mesh) {
this._mesh.scaling.y = size;
this.scaleChanged();
}
}
private scaleZ(size: number) {
if (this._mesh) {
this._mesh.scaling.z = size;
this.scaleChanged();
}
}
private scaleChanged() {
if (this._mesh) {
this.onScaleChangeObservable.notifyObservers(this._mesh);
}
}
}

View File

@ -14,6 +14,13 @@ const colors: string[] = [
"#1e90ff", "#98fb98", "#ffe4b5", "#ff69b4" "#1e90ff", "#98fb98", "#ffe4b5", "#ff69b4"
] ]
/**
* Get the list of available toolbox colors
*/
export function getToolboxColors(): string[] {
return [...colors];
}
export class Toolbox { export class Toolbox {
public readonly _toolboxBaseNode: TransformNode; public readonly _toolboxBaseNode: TransformNode;

View File

@ -0,0 +1,65 @@
/**
* Find the closest color from a list of available colors
* Uses Euclidean distance in RGB color space
*/
import { Color3 } from "@babylonjs/core";
/**
* Calculate the Euclidean distance between two colors in RGB space
*/
function colorDistance(color1: Color3, color2: Color3): number {
const rDiff = color1.r - color2.r;
const gDiff = color1.g - color2.g;
const bDiff = color1.b - color2.b;
return Math.sqrt(rDiff * rDiff + gDiff * gDiff + bDiff * bDiff);
}
/**
* Find the closest color from a list of available colors
* @param targetColor The color to match (hex string like "#FFFFFF")
* @param availableColors Array of available colors (hex strings)
* @returns The closest matching color from the available list
*/
export function findClosestColor(targetColor: string, availableColors: string[]): string {
if (!targetColor || !availableColors || availableColors.length === 0) {
return targetColor;
}
// Check if exact match exists
const exactMatch = availableColors.find(c => c.toLowerCase() === targetColor.toLowerCase());
if (exactMatch) {
return exactMatch;
}
// Convert target color to Color3
let targetColor3: Color3;
try {
targetColor3 = Color3.FromHexString(targetColor);
} catch (e) {
// If target color is invalid, return first available color
console.warn(`Invalid target color ${targetColor}, using first available color`);
return availableColors[0];
}
// Find closest color by distance
let closestColor = availableColors[0];
let minDistance = Number.MAX_VALUE;
for (const availableColor of availableColors) {
try {
const availableColor3 = Color3.FromHexString(availableColor);
const distance = colorDistance(targetColor3, availableColor3);
if (distance < minDistance) {
minDistance = distance;
closestColor = availableColor;
}
} catch (e) {
console.warn(`Invalid available color ${availableColor}, skipping`);
}
}
return closestColor;
}