immersive2/src/diagram/diagramMenuManager.ts
Michael Mainguy 26b48b26c8 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>
2025-11-13 17:52:23 -06:00

253 lines
9.8 KiB
TypeScript

import {DiagramEntityType, DiagramEvent, DiagramEventType} from "./types/diagramEntity";
import {AbstractMesh, ActionEvent, Observable, Scene, Vector3, WebXRDefaultExperience, WebXRInputSource} from "@babylonjs/core";
import {InputTextView} from "../information/inputTextView";
import {DefaultScene} from "../defaultScene";
import log from "loglevel";
import {Toolbox} from "../toolbox/toolbox";
import {ClickMenu} from "../menus/clickMenu";
import {DiagramEventObserverMask} from "./types/diagramEventObserverMask";
import {ConnectionPreview} from "../menus/connectionPreview";
import {ScaleMenu2} from "../menus/ScaleMenu2";
import {viewOnly} from "../util/functions/getPath";
import {GroupMenu} from "../menus/groupMenu";
import {ControllerEvent} from "../controllers/types/controllerEvent";
import {ControllerEventType} from "../controllers/types/controllerEventType";
import {ResizeGizmoManager} from "../gizmos/ResizeGizmo/ResizeGizmoManager";
import {ResizeGizmoMode} from "../gizmos/ResizeGizmo/types";
import {DiagramEntityAdapter} from "../integration/gizmo/DiagramEntityAdapter";
import {toDiagramEntity} from "./functions/toDiagramEntity";
export class DiagramMenuManager {
public readonly toolbox: Toolbox;
public readonly scaleMenu: ScaleMenu2;
public readonly resizeGizmo: ResizeGizmoManager;
private readonly _resizeGizmoAdapter: DiagramEntityAdapter;
private readonly _notifier: Observable<DiagramEvent>;
private readonly _inputTextView: InputTextView;
private _groupMenu: GroupMenu;
private readonly _scene: Scene;
private _logger = log.getLogger('DiagramMenuManager');
private _connectionPreview: ConnectionPreview;
private _currentHoveredMesh: AbstractMesh | null = null;
constructor(notifier: Observable<DiagramEvent>, controllerObservable: Observable<ControllerEvent>, readyObservable: Observable<boolean>) {
this._scene = DefaultScene.Scene;
this._notifier = notifier;
this._inputTextView = new InputTextView(controllerObservable);
//this.configMenu = new ConfigMenu(config);
this._inputTextView.onTextObservable.add((evt) => {
const event = {
type: DiagramEventType.MODIFY,
entity: {id: evt.id, text: evt.text, type: DiagramEntityType.ENTITY}
}
this._notifier.notifyObservers(event, DiagramEventObserverMask.FROM_DB);
});
this.toolbox = new Toolbox(readyObservable);
this.scaleMenu = new ScaleMenu2(this._notifier);
// Initialize ResizeGizmo with auto-show on hover
this.resizeGizmo = new ResizeGizmoManager(this._scene, {
mode: ResizeGizmoMode.ALL,
enableSnapping: true,
snapDistanceX: 0.1,
snapDistanceY: 0.1,
snapDistanceZ: 0.1,
showNumericDisplay: true,
showGrid: true,
showSnapPoints: true,
hapticFeedback: true,
showBoundingBoxOnHoverOnly: false
});
// Create adapter for DiagramEntity persistence
// Inject toDiagramEntity converter for loose coupling
this._resizeGizmoAdapter = new DiagramEntityAdapter(
this.resizeGizmo,
{ onDiagramEventObservable: this._notifier } as any,
toDiagramEntity, // Injected mesh-to-entity converter
false // Don't persist on drag, only on scale end
);
// Setup update loop for resize gizmo
this._scene.onBeforeRenderObservable.add(() => {
this.resizeGizmo.update();
});
if (viewOnly()) {
this.toolbox.handleMesh.setEnabled(false);
this.resizeGizmo.setEnabled(false);
//this.scaleMenu.handleMesh.setEnabled(false)
// this.configMenu.handleTransformNode.setEnabled(false);
}
controllerObservable.add((event: ControllerEvent) => {
if (event.type == ControllerEventType.B_BUTTON) {
if (event.value > .8) {
const platform = this._scene.getMeshByName("platform");
if (!platform) {
return;
}
const cameraPos = this._scene.activeCamera.globalPosition;
const localCamera = Vector3.TransformCoordinates(cameraPos, platform.getWorldMatrix());
const toolY = this.toolbox.handleMesh.absolutePosition.y;
if (toolY > (cameraPos.y - .2)) {
this.toolbox.handleMesh.position.y = localCamera.y - .2;
}
const inputY = this._inputTextView.handleMesh.absolutePosition.y;
if (inputY > (cameraPos.y - .2)) {
this._inputTextView.handleMesh.position.y = localCamera.y - .2;
}
const configY = this._inputTextView.handleMesh.absolutePosition.y;
/*if (configY > (cameraPos.y - .2)) {
this.configMenu.handleTransformNode.position.y = localCamera.y - .2;
}*/
}
}
});
}
public get connectionPreview(): ConnectionPreview {
return this._connectionPreview;
}
public connect(mesh: AbstractMesh) {
if (this._connectionPreview) {
this._connectionPreview.connect(mesh);
this._connectionPreview = null;
}
}
public editText(mesh: AbstractMesh) {
this._inputTextView.show(mesh);
}
public createClickMenu(mesh: AbstractMesh, input: WebXRInputSource): ClickMenu {
const clickMenu = new ClickMenu(mesh);
clickMenu.onClickMenuObservable.add((evt: ActionEvent) => {
this._logger.debug(evt);
switch (evt.source.id) {
case "remove":
this.notifyAll({
type: DiagramEventType.REMOVE,
entity: {id: clickMenu.mesh.id, type: DiagramEntityType.ENTITY}
});
break;
case "label":
this.editText(clickMenu.mesh);
break;
case "connect":
this._connectionPreview = new ConnectionPreview(clickMenu.mesh.id, input, evt.additionalData.pickedPoint, this._notifier);
break;
case "size":
this.scaleMenu.show(clickMenu.mesh);
break;
case "group":
this._groupMenu = new GroupMenu(clickMenu.mesh);
break;
case "close":
this.scaleMenu.hide();
break;
}
this._logger.debug(evt);
}, -1, false, this, false);
return clickMenu;
}
private notifyAll(event: DiagramEvent) {
this._notifier.notifyObservers(event, DiagramEventObserverMask.ALL);
}
public setXR(xr: WebXRDefaultExperience): void {
this.toolbox.setXR(xr);
// Register controllers with resize gizmo when they're added
xr.input.onControllerAddedObservable.add((controller) => {
this.resizeGizmo.registerController(controller);
});
xr.input.onControllerRemovedObservable.add((controller) => {
this.resizeGizmo.unregisterController(controller);
});
}
/**
* Handle pointer hovering over a diagram object
* Auto-shows resize gizmo
*/
public handleDiagramObjectHover(mesh: AbstractMesh | null, pointerPosition?: Vector3): void {
// If hovering same mesh, do nothing
if (mesh === this._currentHoveredMesh) {
return;
}
// If no longer hovering any mesh, check if we should keep gizmo active
if (!mesh) {
if (this._currentHoveredMesh) {
// Check if pointer is still near the gizmo or within bounding box
const shouldKeepActive = this.shouldKeepGizmoActive(pointerPosition);
if (!shouldKeepActive) {
this.resizeGizmo.detachFromMesh();
this._currentHoveredMesh = null;
}
}
return;
}
// Hovering new mesh, attach gizmo
this._currentHoveredMesh = mesh;
this.resizeGizmo.attachToMesh(mesh);
}
/**
* Check if gizmo should remain active based on pointer position
*/
private shouldKeepGizmoActive(pointerPosition?: Vector3): boolean {
if (!this._currentHoveredMesh) {
return false;
}
// Always keep gizmo active if currently scaling
if (this.resizeGizmo.isScaling()) {
return true;
}
// Keep active if pointer is within bounding box area
if (!pointerPosition) {
return false;
}
// Get the attached mesh's bounding box
const boundingInfo = this._currentHoveredMesh.getBoundingInfo();
const boundingBox = boundingInfo.boundingBox;
// Add padding to the bounding box (same as gizmo padding + handle size)
const padding = 0.3; // Generous padding to include handles
const min = boundingBox.minimumWorld.subtract(new Vector3(padding, padding, padding));
const max = boundingBox.maximumWorld.add(new Vector3(padding, padding, padding));
// Check if pointer is within the padded bounding box
const withinBounds =
pointerPosition.x >= min.x && pointerPosition.x <= max.x &&
pointerPosition.y >= min.y && pointerPosition.y <= max.y &&
pointerPosition.z >= min.z && pointerPosition.z <= max.z;
return withinBounds;
}
/**
* Register a controller with the resize gizmo
*/
public registerControllerWithGizmo(controller: WebXRInputSource): void {
this.resizeGizmo.registerController(controller);
}
}