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>
212 lines
7.6 KiB
TypeScript
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;
|
|
}
|
|
} |