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>
This commit is contained in:
Michael Mainguy 2025-11-19 13:28:48 -06:00
parent 15c6617151
commit aa0810be02
5 changed files with 186 additions and 148 deletions

View File

@ -28,9 +28,13 @@ export class InputTextView {
this.controllerObservable = controllerObservable; this.controllerObservable = controllerObservable;
this.scene = DefaultScene.Scene; this.scene = DefaultScene.Scene;
this.inputMesh = MeshBuilder.CreatePlane("input", {width: 1, height: .5}, this.scene); this.inputMesh = MeshBuilder.CreatePlane("input", {width: 1, height: .5}, this.scene);
this.handle = new Handle(this.inputMesh, 'Input'); this.handle = new Handle({
this.inputMesh.position.y = .06; contentMesh: this.inputMesh,
this.inputMesh.position.z = .02; label: 'Input',
defaultPosition: new Vector3(0, 1.5, .5),
defaultRotation: new Vector3(0, 0, 0)
});
// Position is now controlled by Handle class
this.createKeyboard(); this.createKeyboard();
} }
@ -52,36 +56,6 @@ export class InputTextView {
} }
public createKeyboard() { public createKeyboard() {
const platform = this.scene.getMeshById('platform');
const position = new Vector3(0, 1.66, .53);
const rotation = new Vector3(.9, 0, 0);
const handle = this.handle;
/*if (handle.mesh.position.x != 0 && handle.mesh.position.y != 0 && handle.mesh.position.z != 0) {
position = handle.mesh.position;
}
if (handle.mesh.rotation.x != 0 && handle.mesh.rotation.y != 0 && handle.mesh.rotation.z != 0) {
rotation = handle.mesh.rotation;
}*/
if (!platform) {
this.scene.onNewMeshAddedObservable.add((mesh) => {
if (mesh.id == 'platform') {
this.logger.debug("platform added");
handle.transformNode.parent = mesh;
if (!handle.idStored) {
handle.transformNode.position = position;
handle.transformNode.rotation = rotation;
}
}
}, -1, false, this, false);
} else {
handle.transformNode.setParent(platform);
if (!handle.idStored) {
handle.transformNode.position = position;
handle.transformNode.rotation = rotation;
}
}
//setMenuPosition(handle.mesh, this.scene, new Vector3(0, .4, 0));
const advancedTexture = AdvancedDynamicTexture.CreateForMesh(this.inputMesh, 2048, 1024, false); const advancedTexture = AdvancedDynamicTexture.CreateForMesh(this.inputMesh, 2048, 1024, false);
const input = new InputText(); const input = new InputText();

View File

@ -83,12 +83,12 @@ export class VRConfigPanel {
this._baseTransform.scaling = new Vector3(0.6, 0.6, 0.6); this._baseTransform.scaling = new Vector3(0.6, 0.6, 0.6);
// Create handle for grabbing (Handle will become parent of baseTransform) // Create handle for grabbing (Handle will become parent of baseTransform)
this._handle = new Handle( this._handle = new Handle({
this._baseTransform, contentMesh: this._baseTransform,
'Configuration', label: 'Configuration',
new Vector3(0.5, 1.6, 0.4), // Default position relative to platform defaultPosition: new Vector3(.5, 1.5, .5), // Default position relative to platform
new Vector3(0.5, 0.6, 0) // Default rotation defaultRotation: new Vector3(0, 0, 0) // Default rotation
); });
// Build the panel mesh and UI // Build the panel mesh and UI
this.buildPanel(); this.buildPanel();
@ -185,14 +185,8 @@ export class VRConfigPanel {
// Parent to base transform // Parent to base transform
this._panelMesh.parent = this._baseTransform; this._panelMesh.parent = this._baseTransform;
// Calculate position to place panel bottom just above handle // Position is now controlled by Handle class
// Panel is 1.5m tall, so center needs to be at half-height + small gap above handle // Panel is positioned at origin relative to baseTransform
const panelHeight = 1.5;
const gapAboveHandle = 0.05; // 5cm gap above handle for spacing
const panelCenterY = (panelHeight / 2) + gapAboveHandle; // 0.75 + 0.05 = 0.8m
// Position panel so bottom edge sits just above handle, matching toolbox appearance
this._panelMesh.position = new Vector3(0, panelCenterY, 0);
// Create material for panel backing // Create material for panel backing
const material = new StandardMaterial("vrConfigPanelMaterial", this._scene); const material = new StandardMaterial("vrConfigPanelMaterial", this._scene);
@ -233,9 +227,6 @@ export class VRConfigPanel {
// Build configuration sections // Build configuration sections
this.buildConfigSections(); this.buildConfigSections();
// Parent handle to platform when available
this.setupPlatformParenting();
this._logger.debug('VR config panel built successfully'); this._logger.debug('VR config panel built successfully');
} }
@ -797,25 +788,6 @@ export class VRConfigPanel {
}); });
} }
/**
* Set up parenting to platform for world movement tracking
*/
private setupPlatformParenting(): void {
const platform = this._scene.getMeshById('platform');
if (platform) {
this._handle.transformNode.parent = platform;
this._logger.debug('VRConfigPanel parented to existing platform');
} else {
// Wait for platform to be added
const handler = this._scene.onNewMeshAddedObservable.add((mesh) => {
if (mesh && mesh.id === 'platform') {
this._handle.transformNode.parent = mesh;
this._logger.debug('VRConfigPanel parented to newly added platform');
this._scene.onNewMeshAddedObservable.remove(handler);
}
});
}
}
/** /**
* Update all UI elements to reflect current config * Update all UI elements to reflect current config

View File

@ -11,38 +11,106 @@ import {
import log, {Logger} from "loglevel"; import log, {Logger} from "loglevel";
import {split} from "canvas-hypertxt"; 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 { export class Handle {
/** The TransformNode representing the handle mesh */
public transformNode: TransformNode; public transformNode: TransformNode;
private readonly _menuItem: TransformNode;
private _isStored: boolean = false; private readonly _contentMesh: TransformNode;
private _offset: Vector3; private _hasStoredPosition: boolean = false;
private _rotation: Vector3; private readonly _defaultPosition: Vector3;
private readonly _defaultRotation: Vector3;
private readonly _label: string; private readonly _label: string;
private readonly _autoParentToPlatform: boolean;
private readonly _logger: Logger = log.getLogger('Handle'); private readonly _logger: Logger = log.getLogger('Handle');
constructor(mesh: TransformNode, label: string = 'Handle', offset: Vector3 = Vector3.Zero(), rotation: Vector3 = Vector3.Zero()) { /**
this._menuItem = mesh; * Creates a new Handle instance
this._offset = offset; * @param options Configuration options for the handle
this._rotation = rotation; */
this._label = label; constructor(options: HandleOptions) {
this._logger.debug('Handle created with label ' + label); 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(); this.buildHandle();
if (this._autoParentToPlatform) {
this.setupPlatformParenting();
}
} }
public get idStored() { /**
return this._isStored; * Returns true if a stored position was found and restored from localStorage
*/
public get hasStoredPosition(): boolean {
return this._hasStoredPosition;
} }
public staort() { /**
* 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() { private buildHandle(): void {
const scene: Scene = this._menuItem.getScene(); const scene: Scene = this._contentMesh.getScene();
const handle = MeshBuilder.CreatePlane('handle-' + this._contentMesh.id, {width: .4, height: .4 / 8}, scene);
const handle = MeshBuilder.CreatePlane('handle-' + this._menuItem.id, {width: .4, height: .4 / 8}, scene);
//button.transform.scaling.set(.1,.1,.1);
const texture = this.drawText(this._label, Color3.White(), Color3.Black()); const texture = this.drawText(this._label, Color3.White(), Color3.Black());
const material = new StandardMaterial('handleMaterial', scene); const material = new StandardMaterial('handleMaterial', scene);
material.metadata = { isUI: true }; // Mark as UI to prevent rendering mode modifications material.metadata = { isUI: true }; // Mark as UI to prevent rendering mode modifications
@ -50,38 +118,77 @@ export class Handle {
material.opacityTexture = texture; material.opacityTexture = texture;
material.disableLighting = true; material.disableLighting = true;
handle.material = material; handle.material = material;
//handle.rotate(Vector3.Up(), Math.PI);
handle.id = 'handle-' + this._menuItem.id;
if (this._menuItem) {
this._menuItem.setParent(handle);
}
const stored = localStorage.getItem(handle.id); handle.id = 'handle-' + this._contentMesh.id;
if (stored) {
this._logger.debug('Stored location found for ' + handle.id);
try {
const locationdata = JSON.parse(stored);
this._logger.debug('Stored location data found ', locationdata);
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._isStored = true;
} catch (e) {
this._logger.error(e);
handle.position = Vector3.Zero();
}
} else {
this._logger.debug('No stored location found for ' + handle.id + ', using defaults');
handle.position = this._offset;
handle.rotation = this._rotation;
}
handle.metadata = {handle: true}; handle.metadata = {handle: true};
this.transformNode = handle;
// 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 { private drawText(name: string, foreground: Color3, background: Color3): DynamicTexture {
const texture = new DynamicTexture('handleTexture', {width: 512, height: 64}, this._menuItem.getScene()); const texture = new DynamicTexture(`${name}-handle-texture}`, {width: 512, height: 64}, this._contentMesh.getScene());
const ctx: ICanvasRenderingContext = texture.getContext(); const ctx: ICanvasRenderingContext = texture.getContext();
const ctx2d: CanvasRenderingContext2D = (ctx.canvas.getContext('2d') as CanvasRenderingContext2D); const ctx2d: CanvasRenderingContext2D = (ctx.canvas.getContext('2d') as CanvasRenderingContext2D);
const font = `900 24px Arial`; const font = `900 24px Arial`;
@ -102,6 +209,4 @@ export class Handle {
texture.update(); texture.update();
return texture; return texture;
} }
} }

View File

@ -36,10 +36,15 @@ export class Toolbox {
constructor(readyObservable: Observable<boolean>) { constructor(readyObservable: Observable<boolean>) {
this._scene = DefaultScene.Scene; this._scene = DefaultScene.Scene;
this._toolboxBaseNode = new TransformNode("toolbox", this._scene); this._toolboxBaseNode = new TransformNode("toolbox", this._scene);
this._handle = new Handle(this._toolboxBaseNode, 'Toolbox'); this._handle = new Handle({
this._toolboxBaseNode.position.y = .2; contentMesh: this._toolboxBaseNode,
label: 'Toolbox',
defaultPosition: new Vector3(-.5, 1.5, .5),
defaultRotation: new Vector3(0, 0, 0)
});
// Position is now controlled by Handle class
this._toolboxBaseNode.scaling = new Vector3(0.6, 0.6, 0.6); this._toolboxBaseNode.scaling = new Vector3(0.6, 0.6, 0.6);
this._toolboxBaseNode.position.y = .2;
// Preload lightmaps for all toolbox colors for better first-render performance // Preload lightmaps for all toolbox colors for better first-render performance
LightmapGenerator.preloadLightmaps(colors, this._scene); LightmapGenerator.preloadLightmaps(colors, this._scene);
@ -79,19 +84,6 @@ export class Toolbox {
private async buildToolbox() { private async buildToolbox() {
this.setupPointerObservable(); this.setupPointerObservable();
await this.buildColorPicker(); await this.buildColorPicker();
if (this._toolboxBaseNode.parent) {
const platform = this._scene.getMeshById("platform");
if (platform) {
this.assignHandleParentAndStore(platform);
} else {
const observer = this._scene.onNewMeshAddedObservable.add((mesh: AbstractMesh) => {
if (mesh && mesh.id == "platform") {
this.assignHandleParentAndStore(mesh);
this._scene.onNewMeshAddedObservable.remove(observer);
}
}, -1, false, this, false);
}
}
} }
private setupPointerObservable() { private setupPointerObservable() {
@ -143,18 +135,6 @@ export class Toolbox {
} }
} }
private assignHandleParentAndStore(mesh: TransformNode) {
const offset = new Vector3(-.50, 1.6, .38);
const rotation = new Vector3(.5, -.6, .18);
const handle = this._handle;
handle.transformNode.parent = mesh;
if (!handle.idStored) {
handle.transformNode.position = offset;
handle.transformNode.rotation = rotation;
}
}
private setupXRButton() { private setupXRButton() {
if (!this._xr) { if (!this._xr) {

View File

@ -157,16 +157,18 @@ function positionComponentsRelativeToCamera(scene: Scene, diagramManager: Diagra
logger.info('Horizontal left:', horizontalLeft); logger.info('Horizontal left:', horizontalLeft);
logger.info('Platform world position:', platform.getAbsolutePosition()); logger.info('Platform world position:', platform.getAbsolutePosition());
// Position toolbox: On left side following VR best practices // Position toolbox: Camera-relative positioning disabled to respect default/saved positions
// Meta guidelines: 45-60 degrees to the side, comfortable arm's reach (~0.4-0.5m) // Handles now use their configured defaults or saved localStorage positions
const toolbox = diagramManager.diagramMenuManager.toolbox; const toolbox = diagramManager.diagramMenuManager.toolbox;
if (toolbox && toolbox.handleMesh) { if (toolbox && toolbox.handleMesh) {
logger.info('Toolbox handleMesh BEFORE positioning:', { logger.info('Toolbox handleMesh using default/saved position:', {
position: toolbox.handleMesh.position.clone(), position: toolbox.handleMesh.position.clone(),
absolutePosition: toolbox.handleMesh.getAbsolutePosition().clone(), absolutePosition: toolbox.handleMesh.getAbsolutePosition().clone(),
rotation: toolbox.handleMesh.rotation.clone() rotation: toolbox.handleMesh.rotation.clone()
}); });
// Camera-relative positioning commented out - handles use their own defaults
/*
// Position at 45 degrees to the left, 0.45m away, slightly below eye level // Position at 45 degrees to the left, 0.45m away, slightly below eye level
// NOTE: User faces -Z direction by design, so negate forward offset // NOTE: User faces -Z direction by design, so negate forward offset
const forwardOffset = horizontalForward.scale(-0.3); const forwardOffset = horizontalForward.scale(-0.3);
@ -193,16 +195,20 @@ function positionComponentsRelativeToCamera(scene: Scene, diagramManager: Diagra
absolutePosition: toolbox.handleMesh.getAbsolutePosition().clone(), absolutePosition: toolbox.handleMesh.getAbsolutePosition().clone(),
rotation: toolbox.handleMesh.rotation.clone() rotation: toolbox.handleMesh.rotation.clone()
}); });
*/
} }
// Position input text view: Centered in front, slightly below eye level // Position input text view: Camera-relative positioning disabled to respect default/saved positions
// Handles now use their configured defaults or saved localStorage positions
const inputTextView = diagramManager.diagramMenuManager['_inputTextView']; const inputTextView = diagramManager.diagramMenuManager['_inputTextView'];
if (inputTextView && inputTextView.handleMesh) { if (inputTextView && inputTextView.handleMesh) {
logger.info('InputTextView handleMesh BEFORE positioning:', { logger.info('InputTextView handleMesh using default/saved position:', {
position: inputTextView.handleMesh.position.clone(), position: inputTextView.handleMesh.position.clone(),
absolutePosition: inputTextView.handleMesh.getAbsolutePosition().clone() absolutePosition: inputTextView.handleMesh.getAbsolutePosition().clone()
}); });
// Camera-relative positioning commented out - handles use their own defaults
/*
// NOTE: User faces -Z direction by design, so negate forward offset // NOTE: User faces -Z direction by design, so negate forward offset
const inputWorldPos = cameraWorldPos.add(horizontalForward.scale(-0.5)); const inputWorldPos = cameraWorldPos.add(horizontalForward.scale(-0.5));
inputWorldPos.y = cameraWorldPos.y - 0.4; // Below eye level inputWorldPos.y = cameraWorldPos.y - 0.4; // Below eye level
@ -218,5 +224,6 @@ function positionComponentsRelativeToCamera(scene: Scene, diagramManager: Diagra
position: inputTextView.handleMesh.position.clone(), position: inputTextView.handleMesh.position.clone(),
absolutePosition: inputTextView.handleMesh.getAbsolutePosition().clone() absolutePosition: inputTextView.handleMesh.getAbsolutePosition().clone()
}); });
*/
} }
} }