immersive2/src/objects/handle.ts
Michael Mainguy aa0810be02 Refactor Handle class and fix VR positioning
Major improvements to Handle class architecture:
- Replace positional constructor parameters with options object pattern (HandleOptions interface)
- Add automatic platform parenting - handles now find and parent themselves to platform
- Rename idStored → hasStoredPosition for better clarity
- Remove unused staort() method
- Improve position/rotation persistence with better error handling
- Add comprehensive JSDoc documentation
- Use .parent instead of setParent() for proper local space coordinates

Update all Handle usage sites:
- Toolbox: Use new API with position (-.5, 1.5, .5) and zero rotation
- InputTextView: Use new API with position (0, 1.5, .5) and zero rotation
- VRConfigPanel: Use new API with position (.5, 1.5, .5) and zero rotation
- Remove manual platform parenting logic (61 lines of duplicated code removed)
- Remove local position offsets that were overriding handle positions

Fix VR entry positioning:
- Disable camera-relative positioning in groundMeshObserver
- Handles now use their configured defaults or saved localStorage positions
- Positions are now in platform local space as intended

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 13:28:48 -06:00

212 lines
7.6 KiB
TypeScript

import {
Color3,
DynamicTexture,
ICanvasRenderingContext,
MeshBuilder,
Scene,
StandardMaterial,
TransformNode,
Vector3
} from "@babylonjs/core";
import log, {Logger} from "loglevel";
import {split} from "canvas-hypertxt";
/**
* Options for creating a Handle instance
*/
export interface HandleOptions {
/** The TransformNode that will be parented to this handle */
contentMesh: TransformNode;
/** Display label for the handle (default: 'Handle') */
label?: string;
/** Default position offset if no stored position exists (default: Vector3.Zero()) */
defaultPosition?: Vector3;
/** Default rotation if no stored rotation exists (default: Vector3.Zero()) */
defaultRotation?: Vector3;
/** Whether to automatically parent the handle to the platform (default: true) */
autoParentToPlatform?: boolean;
}
/**
* A grabbable, labeled plane mesh for VR interfaces with automatic position persistence.
*
* Features:
* - Automatically saves and restores position/rotation to localStorage
* - Can automatically parent itself to the platform mesh
* - Creates a labeled visual handle for VR interaction
*
* @example
* ```typescript
* const handle = new Handle({
* contentMesh: myMenu,
* label: 'Configuration',
* defaultPosition: new Vector3(0.5, 1.6, 0.4),
* defaultRotation: new Vector3(0.5, 0.6, 0)
* });
* ```
*/
export class Handle {
/** The TransformNode representing the handle mesh */
public transformNode: TransformNode;
private readonly _contentMesh: TransformNode;
private _hasStoredPosition: boolean = false;
private readonly _defaultPosition: Vector3;
private readonly _defaultRotation: Vector3;
private readonly _label: string;
private readonly _autoParentToPlatform: boolean;
private readonly _logger: Logger = log.getLogger('Handle');
/**
* Creates a new Handle instance
* @param options Configuration options for the handle
*/
constructor(options: HandleOptions) {
this._contentMesh = options.contentMesh;
this._label = options.label ?? 'Handle';
this._defaultPosition = options.defaultPosition ?? Vector3.Zero();
this._defaultRotation = options.defaultRotation ?? Vector3.Zero();
this._autoParentToPlatform = options.autoParentToPlatform ?? true;
this._logger.debug(`Handle created with label: ${this._label}`);
this.buildHandle();
if (this._autoParentToPlatform) {
this.setupPlatformParenting();
}
}
/**
* Returns true if a stored position was found and restored from localStorage
*/
public get hasStoredPosition(): boolean {
return this._hasStoredPosition;
}
/**
* Automatically parents the handle to the platform mesh when it becomes available
* @private
*/
private setupPlatformParenting(): void {
const scene: Scene = this._contentMesh.getScene();
const platform = scene.getMeshByID('platform');
if (platform) {
this._logger.debug(`Platform found, parenting handle to platform`);
this.transformNode.parent = platform;
} else {
this._logger.debug(`Platform not found, waiting for platform creation`);
// Wait for platform to be created
const observer = scene.onNewMeshAddedObservable.add((mesh) => {
if (mesh.id === 'platform') {
this._logger.debug(`Platform created, parenting handle to platform`);
this.transformNode.parent = mesh;
scene.onNewMeshAddedObservable.remove(observer);
}
});
}
}
private buildHandle(): void {
const scene: Scene = this._contentMesh.getScene();
const handle = MeshBuilder.CreatePlane('handle-' + this._contentMesh.id, {width: .4, height: .4 / 8}, scene);
const texture = this.drawText(this._label, Color3.White(), Color3.Black());
const material = new StandardMaterial('handleMaterial', scene);
material.metadata = { isUI: true }; // Mark as UI to prevent rendering mode modifications
material.emissiveTexture = texture;
material.opacityTexture = texture;
material.disableLighting = true;
handle.material = material;
handle.id = 'handle-' + this._contentMesh.id;
handle.metadata = {handle: true};
// Parent content mesh to handle
if (this._contentMesh) {
this._contentMesh.setParent(handle);
}
// Attempt to restore position/rotation from localStorage
this.restorePosition(handle);
this.transformNode = handle;
}
/**
* Restores position and rotation from localStorage, or applies defaults
* @private
*/
private restorePosition(handle: TransformNode): void {
const storageKey = handle.id;
const stored = localStorage.getItem(storageKey);
if (stored) {
this._logger.debug(`Stored location found for ${storageKey}`);
try {
const locationData = JSON.parse(stored);
this._logger.debug('Stored location data:', locationData);
if (locationData.position && locationData.rotation) {
handle.position = new Vector3(
locationData.position.x,
locationData.position.y,
locationData.position.z
);
handle.rotation = new Vector3(
locationData.rotation.x,
locationData.rotation.y,
locationData.rotation.z
);
this._hasStoredPosition = true;
this._logger.debug(`Position restored from storage for ${storageKey}`);
} else {
this._logger.warn(`Invalid stored data format for ${storageKey}, using defaults`);
this.applyDefaultPosition(handle);
}
} catch (e) {
this._logger.error(`Error parsing stored location for ${storageKey}:`, e);
this.applyDefaultPosition(handle);
}
} else {
this._logger.debug(`No stored location found for ${storageKey}, using defaults`);
this.applyDefaultPosition(handle);
}
}
/**
* Applies the default position and rotation to the handle
* @private
*/
private applyDefaultPosition(handle: TransformNode): void {
handle.position = this._defaultPosition;
handle.rotation = this._defaultRotation;
}
/**
* Draws text label onto a dynamic texture for the handle
* @private
*/
private drawText(name: string, foreground: Color3, background: Color3): DynamicTexture {
const texture = new DynamicTexture(`${name}-handle-texture}`, {width: 512, height: 64}, this._contentMesh.getScene());
const ctx: ICanvasRenderingContext = texture.getContext();
const ctx2d: CanvasRenderingContext2D = (ctx.canvas.getContext('2d') as CanvasRenderingContext2D);
const font = `900 24px Arial`;
ctx2d.font = font;
ctx2d.textBaseline = 'middle';
ctx2d.textAlign = 'center';
ctx2d.roundRect(0, 0, 512, 64, 32);
ctx2d.fillStyle = background.toHexString();
ctx2d.fill();
ctx2d.fillStyle = foreground.toHexString();
const lines = split(ctx2d, name, font, 512, true);
const x = 256;
let y = 32;
for (const line of lines) {
ctx2d.fillText(line, x, y);
y += 50;
}
texture.update();
return texture;
}
}