immersive2/src/gizmos/ResizeGizmo/ResizeGizmoVisuals.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

384 lines
11 KiB
TypeScript

/**
* 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();
}
}