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",
|
"name": "immersive",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.8-19",
|
"version": "0.0.8-22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
AbstractMesh,
|
AbstractMesh,
|
||||||
|
Ray,
|
||||||
Scene,
|
Scene,
|
||||||
Vector3,
|
Vector3,
|
||||||
WebXRControllerComponent,
|
WebXRControllerComponent,
|
||||||
@ -47,6 +48,11 @@ export abstract class AbstractController {
|
|||||||
private _meshUnderPointer: AbstractMesh;
|
private _meshUnderPointer: AbstractMesh;
|
||||||
|
|
||||||
|
|
||||||
|
// Gizmo control state for squeeze button interaction
|
||||||
|
private _activeGizmoAxis: any = null; // IAxisScaleGizmo type from BabylonJS
|
||||||
|
private _draggingGizmo: boolean = false;
|
||||||
|
|
||||||
|
|
||||||
constructor(controller: WebXRInputSource,
|
constructor(controller: WebXRInputSource,
|
||||||
xr: WebXRDefaultExperience,
|
xr: WebXRDefaultExperience,
|
||||||
diagramManager: DiagramManager) {
|
diagramManager: DiagramManager) {
|
||||||
@ -60,8 +66,25 @@ export abstract class AbstractController {
|
|||||||
if (pointerInfo.pickInfo.pickedMesh) {
|
if (pointerInfo.pickInfo.pickedMesh) {
|
||||||
this._pickPoint.copyFrom(pointerInfo.pickInfo.pickedPoint);
|
this._pickPoint.copyFrom(pointerInfo.pickInfo.pickedPoint);
|
||||||
this._meshUnderPointer = pointerInfo.pickInfo.pickedMesh;
|
this._meshUnderPointer = pointerInfo.pickInfo.pickedMesh;
|
||||||
|
|
||||||
|
// Auto-show resize gizmo when hovering diagram object
|
||||||
|
if (this.diagramManager?.isDiagramObject(this._meshUnderPointer)) {
|
||||||
|
this.diagramManager.diagramMenuManager.handleDiagramObjectHover(
|
||||||
|
this._meshUnderPointer,
|
||||||
|
pointerInfo.pickInfo.pickedPoint
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Hovering non-diagram object, pass pointer position to check if still in bounds
|
||||||
|
this.diagramManager.diagramMenuManager.handleDiagramObjectHover(
|
||||||
|
null,
|
||||||
|
pointerInfo.pickInfo.pickedPoint
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this._meshUnderPointer = null;
|
this._meshUnderPointer = null;
|
||||||
|
// No mesh under pointer, use controller pointer position
|
||||||
|
const pointerPos = this.xrInputSource?.pointer?.position;
|
||||||
|
this.diagramManager?.diagramMenuManager.handleDiagramObjectHover(null, pointerPos);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -189,11 +212,51 @@ export abstract class AbstractController {
|
|||||||
grip.onButtonStateChangedObservable.add(() => {
|
grip.onButtonStateChangedObservable.add(() => {
|
||||||
if (grip.changes.pressed) {
|
if (grip.changes.pressed) {
|
||||||
if (grip.pressed) {
|
if (grip.pressed) {
|
||||||
this.grab();
|
this._logger.debug("=== SQUEEZE PRESSED ===");
|
||||||
|
|
||||||
|
// Check if ResizeGizmo will handle the grip (hovering a handle)
|
||||||
|
const resizeGizmo = this.diagramManager.diagramMenuManager.resizeGizmo;
|
||||||
|
if (resizeGizmo.isHoveringHandle()) {
|
||||||
|
// ResizeGizmo will handle grip on its handle, don't interfere
|
||||||
|
this._logger.debug("ResizeGizmo hovering handle, letting it handle grip");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if hovering over old gizmo axis (ScaleMenu2)
|
||||||
|
const gizmoAxis = this.getGizmoAxisUnderPointer();
|
||||||
|
this._logger.debug(`Gizmo axis detected: ${gizmoAxis ? gizmoAxis._rootMesh?.id : 'null'}`);
|
||||||
|
if (gizmoAxis) {
|
||||||
|
// Squeeze on gizmo = start scaling
|
||||||
|
this._logger.debug("Starting gizmo drag");
|
||||||
|
this.startGizmoDrag(gizmoAxis);
|
||||||
} else {
|
} 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();
|
this.drop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -219,4 +282,156 @@ export abstract class AbstractController {
|
|||||||
this.grabbedMeshType = null;
|
this.grabbedMeshType = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the pointer is currently over a gizmo axis and return it
|
||||||
|
* Uses direct ray picking from the utility layer because gizmo meshes
|
||||||
|
* are on utility layer, not included in _meshUnderPointer
|
||||||
|
* @returns The gizmo axis under the pointer, or null
|
||||||
|
*/
|
||||||
|
private getGizmoAxisUnderPointer(): any | null {
|
||||||
|
this._logger.debug("--- getGizmoAxisUnderPointer called ---");
|
||||||
|
|
||||||
|
const scaleMenu = this.diagramManager.diagramMenuManager.scaleMenu;
|
||||||
|
if (!scaleMenu || !scaleMenu.gizmoManager) {
|
||||||
|
this._logger.debug("No scale menu or gizmo manager");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gizmo = scaleMenu.gizmoManager.gizmos.scaleGizmo;
|
||||||
|
if (!gizmo) {
|
||||||
|
this._logger.debug("No scale gizmo");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._logger.debug(`Gizmo attached mesh: ${scaleMenu.gizmoManager.attachedMesh?.id}`);
|
||||||
|
|
||||||
|
// Get the utility layer that contains the gizmo meshes
|
||||||
|
const utilityLayer = gizmo.xGizmo?._rootMesh?.getScene();
|
||||||
|
if (!utilityLayer) {
|
||||||
|
this._logger.debug("No utility layer found");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._logger.debug(`Utility layer found: ${utilityLayer.constructor.name}`);
|
||||||
|
|
||||||
|
// Use controller's pointer ray directly to pick gizmo meshes
|
||||||
|
const pointerRay = this.xrInputSource.pointer.forward;
|
||||||
|
const pointerOrigin = this.xrInputSource.pointer.position;
|
||||||
|
|
||||||
|
this._logger.debug(`Pointer origin: ${pointerOrigin}, direction: ${pointerRay}`);
|
||||||
|
|
||||||
|
const ray = new Ray(pointerOrigin, pointerRay, 1000);
|
||||||
|
|
||||||
|
// Pick from the utility layer scene, not the main scene
|
||||||
|
// Don't filter in predicate - let all meshes be pickable, then check hierarchy after
|
||||||
|
const pickResult = utilityLayer.pickWithRay(ray);
|
||||||
|
|
||||||
|
this._logger.debug(`Pick result: hit=${pickResult?.hit}, pickedMesh=${pickResult?.pickedMesh?.id}`);
|
||||||
|
|
||||||
|
if (pickResult && pickResult.hit && pickResult.pickedMesh) {
|
||||||
|
this._logger.debug(`Checking if picked mesh ${pickResult.pickedMesh.id} is part of gizmo`);
|
||||||
|
|
||||||
|
// Determine which axis was picked by checking hierarchy
|
||||||
|
if (this.isMeshInGizmoHierarchy(pickResult.pickedMesh, gizmo.xGizmo)) {
|
||||||
|
this._logger.debug("Detected X axis");
|
||||||
|
return gizmo.xGizmo;
|
||||||
|
}
|
||||||
|
if (this.isMeshInGizmoHierarchy(pickResult.pickedMesh, gizmo.yGizmo)) {
|
||||||
|
this._logger.debug("Detected Y axis");
|
||||||
|
return gizmo.yGizmo;
|
||||||
|
}
|
||||||
|
if (this.isMeshInGizmoHierarchy(pickResult.pickedMesh, gizmo.zGizmo)) {
|
||||||
|
this._logger.debug("Detected Z axis");
|
||||||
|
return gizmo.zGizmo;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._logger.debug(`Picked mesh ${pickResult.pickedMesh.id} is not part of any gizmo axis`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._logger.debug("No gizmo axis found");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a mesh is part of a gizmo's hierarchy
|
||||||
|
*/
|
||||||
|
private isMeshInGizmoHierarchy(mesh: AbstractMesh, gizmo: any): boolean {
|
||||||
|
if (!gizmo || !gizmo._rootMesh) {
|
||||||
|
this._logger.debug(`isMeshInGizmoHierarchy: no gizmo or rootMesh`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._logger.debug(`Checking if ${mesh.id} is in gizmo ${gizmo._rootMesh.id} hierarchy`);
|
||||||
|
|
||||||
|
// Check if mesh matches gizmo root or is a child
|
||||||
|
let current: any = mesh;
|
||||||
|
let depth = 0;
|
||||||
|
while (current && depth < 10) {
|
||||||
|
this._logger.debug(` Depth ${depth}: checking ${current.id}`);
|
||||||
|
if (current.id === gizmo._rootMesh.id || current === gizmo._rootMesh) {
|
||||||
|
this._logger.debug(` MATCH! Found gizmo root`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Also check if this is a gizmo arrow mesh
|
||||||
|
if (current.id && current.id.includes('arrow')) {
|
||||||
|
const parent = current.parent;
|
||||||
|
this._logger.debug(` Found arrow mesh, parent: ${parent?.id}`);
|
||||||
|
if (parent && parent.id === gizmo._rootMesh.id) {
|
||||||
|
this._logger.debug(` MATCH! Arrow parent is gizmo root`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = current.parent;
|
||||||
|
depth++;
|
||||||
|
}
|
||||||
|
this._logger.debug(` No match after ${depth} iterations`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start dragging a gizmo axis with squeeze button
|
||||||
|
*/
|
||||||
|
private startGizmoDrag(axis: any): void {
|
||||||
|
this._activeGizmoAxis = axis;
|
||||||
|
this._draggingGizmo = true;
|
||||||
|
|
||||||
|
// Enable the drag behavior to start scaling
|
||||||
|
if (axis && axis.dragBehavior) {
|
||||||
|
// Manually enable drag mode for this axis
|
||||||
|
axis.dragBehavior.enabled = true;
|
||||||
|
|
||||||
|
// Get the pointer info for manual drag start
|
||||||
|
const pointerInfo = this.scene.pick(
|
||||||
|
this.scene.pointerX,
|
||||||
|
this.scene.pointerY,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
this.scene.activeCamera
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pointerInfo && pointerInfo.hit) {
|
||||||
|
// Manually trigger the drag start with pointer information
|
||||||
|
// The dragBehavior will handle the actual scaling logic
|
||||||
|
this._logger.debug(`Starting gizmo drag on axis: ${axis._rootMesh?.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End dragging a gizmo axis
|
||||||
|
*/
|
||||||
|
private endGizmoDrag(): void {
|
||||||
|
if (this._activeGizmoAxis) {
|
||||||
|
this._logger.debug(`Ending gizmo drag`);
|
||||||
|
|
||||||
|
// The drag behavior will auto-release, just clean up our state
|
||||||
|
if (this._activeGizmoAxis.dragBehavior) {
|
||||||
|
this._activeGizmoAxis.dragBehavior.enabled = true; // Keep enabled for future use
|
||||||
|
}
|
||||||
|
|
||||||
|
this._activeGizmoAxis = null;
|
||||||
|
this._draggingGizmo = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -12,17 +12,24 @@ import {viewOnly} from "../util/functions/getPath";
|
|||||||
import {GroupMenu} from "../menus/groupMenu";
|
import {GroupMenu} from "../menus/groupMenu";
|
||||||
import {ControllerEvent} from "../controllers/types/controllerEvent";
|
import {ControllerEvent} from "../controllers/types/controllerEvent";
|
||||||
import {ControllerEventType} from "../controllers/types/controllerEventType";
|
import {ControllerEventType} from "../controllers/types/controllerEventType";
|
||||||
|
import {ResizeGizmoManager} from "../gizmos/ResizeGizmo/ResizeGizmoManager";
|
||||||
|
import {ResizeGizmoMode} from "../gizmos/ResizeGizmo/types";
|
||||||
|
import {DiagramEntityAdapter} from "../integration/gizmo/DiagramEntityAdapter";
|
||||||
|
import {toDiagramEntity} from "./functions/toDiagramEntity";
|
||||||
|
|
||||||
|
|
||||||
export class DiagramMenuManager {
|
export class DiagramMenuManager {
|
||||||
public readonly toolbox: Toolbox;
|
public readonly toolbox: Toolbox;
|
||||||
public readonly scaleMenu: ScaleMenu2;
|
public readonly scaleMenu: ScaleMenu2;
|
||||||
|
public readonly resizeGizmo: ResizeGizmoManager;
|
||||||
|
private readonly _resizeGizmoAdapter: DiagramEntityAdapter;
|
||||||
private readonly _notifier: Observable<DiagramEvent>;
|
private readonly _notifier: Observable<DiagramEvent>;
|
||||||
private readonly _inputTextView: InputTextView;
|
private readonly _inputTextView: InputTextView;
|
||||||
private _groupMenu: GroupMenu;
|
private _groupMenu: GroupMenu;
|
||||||
private readonly _scene: Scene;
|
private readonly _scene: Scene;
|
||||||
private _logger = log.getLogger('DiagramMenuManager');
|
private _logger = log.getLogger('DiagramMenuManager');
|
||||||
private _connectionPreview: ConnectionPreview;
|
private _connectionPreview: ConnectionPreview;
|
||||||
|
private _currentHoveredMesh: AbstractMesh | null = null;
|
||||||
|
|
||||||
constructor(notifier: Observable<DiagramEvent>, controllerObservable: Observable<ControllerEvent>, readyObservable: Observable<boolean>) {
|
constructor(notifier: Observable<DiagramEvent>, controllerObservable: Observable<ControllerEvent>, readyObservable: Observable<boolean>) {
|
||||||
this._scene = DefaultScene.Scene;
|
this._scene = DefaultScene.Scene;
|
||||||
@ -41,8 +48,38 @@ export class DiagramMenuManager {
|
|||||||
|
|
||||||
|
|
||||||
this.scaleMenu = new ScaleMenu2(this._notifier);
|
this.scaleMenu = new ScaleMenu2(this._notifier);
|
||||||
|
|
||||||
|
// Initialize ResizeGizmo with auto-show on hover
|
||||||
|
this.resizeGizmo = new ResizeGizmoManager(this._scene, {
|
||||||
|
mode: ResizeGizmoMode.ALL,
|
||||||
|
enableSnapping: true,
|
||||||
|
snapDistanceX: 0.1,
|
||||||
|
snapDistanceY: 0.1,
|
||||||
|
snapDistanceZ: 0.1,
|
||||||
|
showNumericDisplay: true,
|
||||||
|
showGrid: true,
|
||||||
|
showSnapPoints: true,
|
||||||
|
hapticFeedback: true,
|
||||||
|
showBoundingBoxOnHoverOnly: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create adapter for DiagramEntity persistence
|
||||||
|
// Inject toDiagramEntity converter for loose coupling
|
||||||
|
this._resizeGizmoAdapter = new DiagramEntityAdapter(
|
||||||
|
this.resizeGizmo,
|
||||||
|
{ onDiagramEventObservable: this._notifier } as any,
|
||||||
|
toDiagramEntity, // Injected mesh-to-entity converter
|
||||||
|
false // Don't persist on drag, only on scale end
|
||||||
|
);
|
||||||
|
|
||||||
|
// Setup update loop for resize gizmo
|
||||||
|
this._scene.onBeforeRenderObservable.add(() => {
|
||||||
|
this.resizeGizmo.update();
|
||||||
|
});
|
||||||
|
|
||||||
if (viewOnly()) {
|
if (viewOnly()) {
|
||||||
this.toolbox.handleMesh.setEnabled(false);
|
this.toolbox.handleMesh.setEnabled(false);
|
||||||
|
this.resizeGizmo.setEnabled(false);
|
||||||
//this.scaleMenu.handleMesh.setEnabled(false)
|
//this.scaleMenu.handleMesh.setEnabled(false)
|
||||||
// this.configMenu.handleTransformNode.setEnabled(false);
|
// this.configMenu.handleTransformNode.setEnabled(false);
|
||||||
}
|
}
|
||||||
@ -130,5 +167,87 @@ export class DiagramMenuManager {
|
|||||||
|
|
||||||
public setXR(xr: WebXRDefaultExperience): void {
|
public setXR(xr: WebXRDefaultExperience): void {
|
||||||
this.toolbox.setXR(xr);
|
this.toolbox.setXR(xr);
|
||||||
|
|
||||||
|
// Register controllers with resize gizmo when they're added
|
||||||
|
xr.input.onControllerAddedObservable.add((controller) => {
|
||||||
|
this.resizeGizmo.registerController(controller);
|
||||||
|
});
|
||||||
|
|
||||||
|
xr.input.onControllerRemovedObservable.add((controller) => {
|
||||||
|
this.resizeGizmo.unregisterController(controller);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle pointer hovering over a diagram object
|
||||||
|
* Auto-shows resize gizmo
|
||||||
|
*/
|
||||||
|
public handleDiagramObjectHover(mesh: AbstractMesh | null, pointerPosition?: Vector3): void {
|
||||||
|
// If hovering same mesh, do nothing
|
||||||
|
if (mesh === this._currentHoveredMesh) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no longer hovering any mesh, check if we should keep gizmo active
|
||||||
|
if (!mesh) {
|
||||||
|
if (this._currentHoveredMesh) {
|
||||||
|
// Check if pointer is still near the gizmo or within bounding box
|
||||||
|
const shouldKeepActive = this.shouldKeepGizmoActive(pointerPosition);
|
||||||
|
|
||||||
|
if (!shouldKeepActive) {
|
||||||
|
this.resizeGizmo.detachFromMesh();
|
||||||
|
this._currentHoveredMesh = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hovering new mesh, attach gizmo
|
||||||
|
this._currentHoveredMesh = mesh;
|
||||||
|
this.resizeGizmo.attachToMesh(mesh);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if gizmo should remain active based on pointer position
|
||||||
|
*/
|
||||||
|
private shouldKeepGizmoActive(pointerPosition?: Vector3): boolean {
|
||||||
|
if (!this._currentHoveredMesh) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always keep gizmo active if currently scaling
|
||||||
|
if (this.resizeGizmo.isScaling()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep active if pointer is within bounding box area
|
||||||
|
if (!pointerPosition) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the attached mesh's bounding box
|
||||||
|
const boundingInfo = this._currentHoveredMesh.getBoundingInfo();
|
||||||
|
const boundingBox = boundingInfo.boundingBox;
|
||||||
|
|
||||||
|
// Add padding to the bounding box (same as gizmo padding + handle size)
|
||||||
|
const padding = 0.3; // Generous padding to include handles
|
||||||
|
const min = boundingBox.minimumWorld.subtract(new Vector3(padding, padding, padding));
|
||||||
|
const max = boundingBox.maximumWorld.add(new Vector3(padding, padding, padding));
|
||||||
|
|
||||||
|
// Check if pointer is within the padded bounding box
|
||||||
|
const withinBounds =
|
||||||
|
pointerPosition.x >= min.x && pointerPosition.x <= max.x &&
|
||||||
|
pointerPosition.y >= min.y && pointerPosition.y <= max.y &&
|
||||||
|
pointerPosition.z >= min.z && pointerPosition.z <= max.z;
|
||||||
|
|
||||||
|
return withinBounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a controller with the resize gizmo
|
||||||
|
*/
|
||||||
|
public registerControllerWithGizmo(controller: WebXRInputSource): void {
|
||||||
|
this.resizeGizmo.registerController(controller);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -19,6 +19,8 @@ import {v4 as uuidv4} from 'uuid';
|
|||||||
import {xyztovec} from "./vectorConversion";
|
import {xyztovec} from "./vectorConversion";
|
||||||
import {AnimatedLineTexture} from "../../util/animatedLineTexture";
|
import {AnimatedLineTexture} from "../../util/animatedLineTexture";
|
||||||
import {LightmapGenerator} from "../../util/lightmapGenerator";
|
import {LightmapGenerator} from "../../util/lightmapGenerator";
|
||||||
|
import {getToolboxColors} from "../../toolbox/toolbox";
|
||||||
|
import {findClosestColor} from "../../util/functions/findClosestColor";
|
||||||
|
|
||||||
// Material sharing statistics
|
// Material sharing statistics
|
||||||
let materialStats = {
|
let materialStats = {
|
||||||
@ -88,8 +90,29 @@ function createNewInstanceIfNecessary(entity: DiagramEntity, scene: Scene): Abst
|
|||||||
case DiagramTemplates.CONE:
|
case DiagramTemplates.CONE:
|
||||||
case DiagramTemplates.PLANE:
|
case DiagramTemplates.PLANE:
|
||||||
case DiagramTemplates.PERSON:
|
case DiagramTemplates.PERSON:
|
||||||
const toolMeshId = "tool-" + entity.template + "-" + entity.color;
|
// Tool meshes are created with UPPERCASE hex codes (BabylonJS toHexString behavior)
|
||||||
const toolMesh = scene.getMeshById(toolMeshId);
|
let toolMeshId = "tool-" + entity.template + "-" + entity.color?.toUpperCase();
|
||||||
|
let toolMesh = scene.getMeshById(toolMeshId);
|
||||||
|
|
||||||
|
// If exact color match not found, try to find closest color
|
||||||
|
if (!toolMesh && entity.color) {
|
||||||
|
const availableColors = getToolboxColors();
|
||||||
|
const closestColor = findClosestColor(entity.color, availableColors);
|
||||||
|
|
||||||
|
if (closestColor !== entity.color.toLowerCase()) {
|
||||||
|
logger.info(`Color ${entity.color} not found in toolbox, using closest match: ${closestColor}`);
|
||||||
|
// Tool IDs use uppercase hex codes
|
||||||
|
toolMeshId = "tool-" + entity.template + "-" + closestColor.toUpperCase();
|
||||||
|
toolMesh = scene.getMeshById(toolMeshId);
|
||||||
|
|
||||||
|
if (toolMesh) {
|
||||||
|
logger.info(`Successfully found tool mesh with closest color: ${toolMeshId}`);
|
||||||
|
} else {
|
||||||
|
logger.error(`Even with closest color, tool mesh not found: ${toolMeshId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (toolMesh && !oldMesh) {
|
if (toolMesh && !oldMesh) {
|
||||||
// Verify tool mesh has material before creating instance
|
// Verify tool mesh has material before creating instance
|
||||||
if (!toolMesh.material) {
|
if (!toolMesh.material) {
|
||||||
@ -135,6 +158,11 @@ function createNewInstanceIfNecessary(entity: DiagramEntity, scene: Scene): Abst
|
|||||||
newMesh.metadata.tool = false;
|
newMesh.metadata.tool = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store color in metadata so it persists when entity is modified
|
||||||
|
if (entity.color) {
|
||||||
|
newMesh.metadata.color = entity.color;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return newMesh;
|
return newMesh;
|
||||||
|
|||||||
571
src/gizmos/PLAN.md
Normal file
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;
|
return this._gizmoManager.attachedMesh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get gizmoManager() {
|
||||||
|
return this._gizmoManager;
|
||||||
|
}
|
||||||
|
|
||||||
public show(mesh: AbstractMesh) {
|
public show(mesh: AbstractMesh) {
|
||||||
if (mesh.metadata.image) {
|
if (mesh.metadata.image) {
|
||||||
configureImageScale(this._gizmoManager.gizmos.scaleGizmo.yGizmo, true);
|
configureImageScale(this._gizmoManager.gizmos.scaleGizmo.yGizmo, true);
|
||||||
@ -61,6 +65,9 @@ function configureGizmo(gizmo: IAxisScaleGizmo) {
|
|||||||
gizmo.scaleRatio = 3;
|
gizmo.scaleRatio = 3;
|
||||||
gizmo.sensitivity = 3;
|
gizmo.sensitivity = 3;
|
||||||
|
|
||||||
|
// Disable automatic pointer-based drag, we'll control it manually via squeeze button
|
||||||
|
// This prevents conflicts with trigger button and enables squeeze-based manipulation
|
||||||
|
gizmo.dragBehavior.startAndReleaseDragOnPointerEvents = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function configureImageScale(gizmo: IAxisScaleGizmo, enabled: boolean) {
|
function configureImageScale(gizmo: IAxisScaleGizmo, enabled: boolean) {
|
||||||
|
|||||||
@ -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"
|
"#1e90ff", "#98fb98", "#ffe4b5", "#ff69b4"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of available toolbox colors
|
||||||
|
*/
|
||||||
|
export function getToolboxColors(): string[] {
|
||||||
|
return [...colors];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export class Toolbox {
|
export class Toolbox {
|
||||||
public readonly _toolboxBaseNode: TransformNode;
|
public readonly _toolboxBaseNode: TransformNode;
|
||||||
|
|||||||
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