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:
parent
02c08b35f2
commit
26b48b26c8
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "immersive",
|
||||
"private": true,
|
||||
"version": "0.0.8-19",
|
||||
"version": "0.0.8-22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import {
|
||||
AbstractMesh,
|
||||
Ray,
|
||||
Scene,
|
||||
Vector3,
|
||||
WebXRControllerComponent,
|
||||
@ -47,6 +48,11 @@ export abstract class AbstractController {
|
||||
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,
|
||||
xr: WebXRDefaultExperience,
|
||||
diagramManager: DiagramManager) {
|
||||
@ -60,8 +66,25 @@ export abstract class AbstractController {
|
||||
if (pointerInfo.pickInfo.pickedMesh) {
|
||||
this._pickPoint.copyFrom(pointerInfo.pickInfo.pickedPoint);
|
||||
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 {
|
||||
this._meshUnderPointer = null;
|
||||
// No mesh under pointer, use controller pointer position
|
||||
const pointerPos = this.xrInputSource?.pointer?.position;
|
||||
this.diagramManager?.diagramMenuManager.handleDiagramObjectHover(null, pointerPos);
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -189,11 +212,51 @@ export abstract class AbstractController {
|
||||
grip.onButtonStateChangedObservable.add(() => {
|
||||
if (grip.changes.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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -12,17 +12,24 @@ import {viewOnly} from "../util/functions/getPath";
|
||||
import {GroupMenu} from "../menus/groupMenu";
|
||||
import {ControllerEvent} from "../controllers/types/controllerEvent";
|
||||
import {ControllerEventType} from "../controllers/types/controllerEventType";
|
||||
import {ResizeGizmoManager} from "../gizmos/ResizeGizmo/ResizeGizmoManager";
|
||||
import {ResizeGizmoMode} from "../gizmos/ResizeGizmo/types";
|
||||
import {DiagramEntityAdapter} from "../integration/gizmo/DiagramEntityAdapter";
|
||||
import {toDiagramEntity} from "./functions/toDiagramEntity";
|
||||
|
||||
|
||||
export class DiagramMenuManager {
|
||||
public readonly toolbox: Toolbox;
|
||||
public readonly scaleMenu: ScaleMenu2;
|
||||
public readonly resizeGizmo: ResizeGizmoManager;
|
||||
private readonly _resizeGizmoAdapter: DiagramEntityAdapter;
|
||||
private readonly _notifier: Observable<DiagramEvent>;
|
||||
private readonly _inputTextView: InputTextView;
|
||||
private _groupMenu: GroupMenu;
|
||||
private readonly _scene: Scene;
|
||||
private _logger = log.getLogger('DiagramMenuManager');
|
||||
private _connectionPreview: ConnectionPreview;
|
||||
private _currentHoveredMesh: AbstractMesh | null = null;
|
||||
|
||||
constructor(notifier: Observable<DiagramEvent>, controllerObservable: Observable<ControllerEvent>, readyObservable: Observable<boolean>) {
|
||||
this._scene = DefaultScene.Scene;
|
||||
@ -41,8 +48,38 @@ export class DiagramMenuManager {
|
||||
|
||||
|
||||
this.scaleMenu = new ScaleMenu2(this._notifier);
|
||||
|
||||
// Initialize ResizeGizmo with auto-show on hover
|
||||
this.resizeGizmo = new ResizeGizmoManager(this._scene, {
|
||||
mode: ResizeGizmoMode.ALL,
|
||||
enableSnapping: true,
|
||||
snapDistanceX: 0.1,
|
||||
snapDistanceY: 0.1,
|
||||
snapDistanceZ: 0.1,
|
||||
showNumericDisplay: true,
|
||||
showGrid: true,
|
||||
showSnapPoints: true,
|
||||
hapticFeedback: true,
|
||||
showBoundingBoxOnHoverOnly: false
|
||||
});
|
||||
|
||||
// Create adapter for DiagramEntity persistence
|
||||
// Inject toDiagramEntity converter for loose coupling
|
||||
this._resizeGizmoAdapter = new DiagramEntityAdapter(
|
||||
this.resizeGizmo,
|
||||
{ onDiagramEventObservable: this._notifier } as any,
|
||||
toDiagramEntity, // Injected mesh-to-entity converter
|
||||
false // Don't persist on drag, only on scale end
|
||||
);
|
||||
|
||||
// Setup update loop for resize gizmo
|
||||
this._scene.onBeforeRenderObservable.add(() => {
|
||||
this.resizeGizmo.update();
|
||||
});
|
||||
|
||||
if (viewOnly()) {
|
||||
this.toolbox.handleMesh.setEnabled(false);
|
||||
this.resizeGizmo.setEnabled(false);
|
||||
//this.scaleMenu.handleMesh.setEnabled(false)
|
||||
// this.configMenu.handleTransformNode.setEnabled(false);
|
||||
}
|
||||
@ -130,5 +167,87 @@ export class DiagramMenuManager {
|
||||
|
||||
public setXR(xr: WebXRDefaultExperience): void {
|
||||
this.toolbox.setXR(xr);
|
||||
|
||||
// Register controllers with resize gizmo when they're added
|
||||
xr.input.onControllerAddedObservable.add((controller) => {
|
||||
this.resizeGizmo.registerController(controller);
|
||||
});
|
||||
|
||||
xr.input.onControllerRemovedObservable.add((controller) => {
|
||||
this.resizeGizmo.unregisterController(controller);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@ -19,6 +19,8 @@ import {v4 as uuidv4} from 'uuid';
|
||||
import {xyztovec} from "./vectorConversion";
|
||||
import {AnimatedLineTexture} from "../../util/animatedLineTexture";
|
||||
import {LightmapGenerator} from "../../util/lightmapGenerator";
|
||||
import {getToolboxColors} from "../../toolbox/toolbox";
|
||||
import {findClosestColor} from "../../util/functions/findClosestColor";
|
||||
|
||||
// Material sharing statistics
|
||||
let materialStats = {
|
||||
@ -88,8 +90,29 @@ function createNewInstanceIfNecessary(entity: DiagramEntity, scene: Scene): Abst
|
||||
case DiagramTemplates.CONE:
|
||||
case DiagramTemplates.PLANE:
|
||||
case DiagramTemplates.PERSON:
|
||||
const toolMeshId = "tool-" + entity.template + "-" + entity.color;
|
||||
const toolMesh = scene.getMeshById(toolMeshId);
|
||||
// Tool meshes are created with UPPERCASE hex codes (BabylonJS toHexString behavior)
|
||||
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) {
|
||||
// Verify tool mesh has material before creating instance
|
||||
if (!toolMesh.material) {
|
||||
@ -135,6 +158,11 @@ function createNewInstanceIfNecessary(entity: DiagramEntity, scene: Scene): Abst
|
||||
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;
|
||||
|
||||
571
src/gizmos/PLAN.md
Normal file
571
src/gizmos/PLAN.md
Normal 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.
|
||||
284
src/gizmos/ResizeGizmo/HandleGeometry.ts
Normal file
284
src/gizmos/ResizeGizmo/HandleGeometry.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
166
src/gizmos/ResizeGizmo/ResizeGizmoConfig.ts
Normal file
166
src/gizmos/ResizeGizmo/ResizeGizmoConfig.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
417
src/gizmos/ResizeGizmo/ResizeGizmoFeedback.ts
Normal file
417
src/gizmos/ResizeGizmo/ResizeGizmoFeedback.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
536
src/gizmos/ResizeGizmo/ResizeGizmoInteraction.ts
Normal file
536
src/gizmos/ResizeGizmo/ResizeGizmoInteraction.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
364
src/gizmos/ResizeGizmo/ResizeGizmoManager.ts
Normal file
364
src/gizmos/ResizeGizmo/ResizeGizmoManager.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
135
src/gizmos/ResizeGizmo/ResizeGizmoSnapping.ts
Normal file
135
src/gizmos/ResizeGizmo/ResizeGizmoSnapping.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
383
src/gizmos/ResizeGizmo/ResizeGizmoVisuals.ts
Normal file
383
src/gizmos/ResizeGizmo/ResizeGizmoVisuals.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
343
src/gizmos/ResizeGizmo/ScalingCalculator.ts
Normal file
343
src/gizmos/ResizeGizmo/ScalingCalculator.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
61
src/gizmos/ResizeGizmo/index.ts
Normal file
61
src/gizmos/ResizeGizmo/index.ts
Normal 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";
|
||||
313
src/gizmos/ResizeGizmo/types.ts
Normal file
313
src/gizmos/ResizeGizmo/types.ts
Normal 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>;
|
||||
}
|
||||
166
src/integration/gizmo/DiagramEntityAdapter.ts
Normal file
166
src/integration/gizmo/DiagramEntityAdapter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
6
src/integration/gizmo/index.ts
Normal file
6
src/integration/gizmo/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Gizmo Integration Layer
|
||||
* Adapters for integrating gizmo systems with diagram persistence
|
||||
*/
|
||||
|
||||
export { DiagramEntityAdapter, type MeshToEntityConverter } from './DiagramEntityAdapter';
|
||||
@ -40,6 +40,10 @@ export class ScaleMenu2 {
|
||||
return this._gizmoManager.attachedMesh;
|
||||
}
|
||||
|
||||
public get gizmoManager() {
|
||||
return this._gizmoManager;
|
||||
}
|
||||
|
||||
public show(mesh: AbstractMesh) {
|
||||
if (mesh.metadata.image) {
|
||||
configureImageScale(this._gizmoManager.gizmos.scaleGizmo.yGizmo, true);
|
||||
@ -61,6 +65,9 @@ function configureGizmo(gizmo: IAxisScaleGizmo) {
|
||||
gizmo.scaleRatio = 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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,13 @@ const colors: string[] = [
|
||||
"#1e90ff", "#98fb98", "#ffe4b5", "#ff69b4"
|
||||
]
|
||||
|
||||
/**
|
||||
* Get the list of available toolbox colors
|
||||
*/
|
||||
export function getToolboxColors(): string[] {
|
||||
return [...colors];
|
||||
}
|
||||
|
||||
|
||||
export class Toolbox {
|
||||
public readonly _toolboxBaseNode: TransformNode;
|
||||
|
||||
65
src/util/functions/findClosestColor.ts
Normal file
65
src/util/functions/findClosestColor.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user