diff --git a/src/information/inputTextView.ts b/src/information/inputTextView.ts index 6729dd3..b2c75e3 100644 --- a/src/information/inputTextView.ts +++ b/src/information/inputTextView.ts @@ -28,9 +28,13 @@ export class InputTextView { this.controllerObservable = controllerObservable; this.scene = DefaultScene.Scene; this.inputMesh = MeshBuilder.CreatePlane("input", {width: 1, height: .5}, this.scene); - this.handle = new Handle(this.inputMesh, 'Input'); - this.inputMesh.position.y = .06; - this.inputMesh.position.z = .02; + this.handle = new Handle({ + contentMesh: this.inputMesh, + label: 'Input', + defaultPosition: new Vector3(0, 1.5, .5), + defaultRotation: new Vector3(0, 0, 0) + }); + // Position is now controlled by Handle class this.createKeyboard(); } @@ -52,36 +56,6 @@ export class InputTextView { } 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 input = new InputText(); diff --git a/src/menus/vrConfigPanel.ts b/src/menus/vrConfigPanel.ts index 8a4f39c..2313837 100644 --- a/src/menus/vrConfigPanel.ts +++ b/src/menus/vrConfigPanel.ts @@ -83,12 +83,12 @@ export class VRConfigPanel { this._baseTransform.scaling = new Vector3(0.6, 0.6, 0.6); // Create handle for grabbing (Handle will become parent of baseTransform) - this._handle = new Handle( - this._baseTransform, - 'Configuration', - new Vector3(0.5, 1.6, 0.4), // Default position relative to platform - new Vector3(0.5, 0.6, 0) // Default rotation - ); + this._handle = new Handle({ + contentMesh: this._baseTransform, + label: 'Configuration', + defaultPosition: new Vector3(.5, 1.5, .5), // Default position relative to platform + defaultRotation: new Vector3(0, 0, 0) // Default rotation + }); // Build the panel mesh and UI this.buildPanel(); @@ -185,14 +185,8 @@ export class VRConfigPanel { // Parent to base transform this._panelMesh.parent = this._baseTransform; - // Calculate position to place panel bottom just above handle - // Panel is 1.5m tall, so center needs to be at half-height + small gap above handle - 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); + // Position is now controlled by Handle class + // Panel is positioned at origin relative to baseTransform // Create material for panel backing const material = new StandardMaterial("vrConfigPanelMaterial", this._scene); @@ -233,9 +227,6 @@ export class VRConfigPanel { // Build configuration sections this.buildConfigSections(); - // Parent handle to platform when available - this.setupPlatformParenting(); - 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 diff --git a/src/objects/handle.ts b/src/objects/handle.ts index c15420d..cdbe79f 100644 --- a/src/objects/handle.ts +++ b/src/objects/handle.ts @@ -11,38 +11,106 @@ import { 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 _menuItem: TransformNode; - private _isStored: boolean = false; - private _offset: Vector3; - private _rotation: Vector3; + + 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'); - constructor(mesh: TransformNode, label: string = 'Handle', offset: Vector3 = Vector3.Zero(), rotation: Vector3 = Vector3.Zero()) { - this._menuItem = mesh; - this._offset = offset; - this._rotation = rotation; - this._label = label; - this._logger.debug('Handle created with label ' + label); + /** + * 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(); + } } - 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() { - const scene: Scene = this._menuItem.getScene(); + private buildHandle(): void { + const scene: Scene = this._contentMesh.getScene(); - - const handle = MeshBuilder.CreatePlane('handle-' + this._menuItem.id, {width: .4, height: .4 / 8}, scene); - //button.transform.scaling.set(.1,.1,.1); + 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 @@ -50,38 +118,77 @@ export class Handle { material.opacityTexture = texture; material.disableLighting = true; 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); - 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.id = 'handle-' + this._contentMesh.id; 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 { - 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 ctx2d: CanvasRenderingContext2D = (ctx.canvas.getContext('2d') as CanvasRenderingContext2D); const font = `900 24px Arial`; @@ -102,6 +209,4 @@ export class Handle { texture.update(); return texture; } - - } \ No newline at end of file diff --git a/src/toolbox/toolbox.ts b/src/toolbox/toolbox.ts index 7e705e9..ed9b5de 100644 --- a/src/toolbox/toolbox.ts +++ b/src/toolbox/toolbox.ts @@ -36,10 +36,15 @@ export class Toolbox { constructor(readyObservable: Observable) { this._scene = DefaultScene.Scene; this._toolboxBaseNode = new TransformNode("toolbox", this._scene); - this._handle = new Handle(this._toolboxBaseNode, 'Toolbox'); - this._toolboxBaseNode.position.y = .2; + this._handle = new Handle({ + 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.position.y = .2; // Preload lightmaps for all toolbox colors for better first-render performance LightmapGenerator.preloadLightmaps(colors, this._scene); @@ -79,19 +84,6 @@ export class Toolbox { private async buildToolbox() { this.setupPointerObservable(); 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() { @@ -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() { if (!this._xr) { diff --git a/src/util/functions/groundMeshObserver.ts b/src/util/functions/groundMeshObserver.ts index 327b9b7..3bf7b8d 100644 --- a/src/util/functions/groundMeshObserver.ts +++ b/src/util/functions/groundMeshObserver.ts @@ -157,16 +157,18 @@ function positionComponentsRelativeToCamera(scene: Scene, diagramManager: Diagra logger.info('Horizontal left:', horizontalLeft); logger.info('Platform world position:', platform.getAbsolutePosition()); - // Position toolbox: On left side following VR best practices - // Meta guidelines: 45-60 degrees to the side, comfortable arm's reach (~0.4-0.5m) + // Position toolbox: Camera-relative positioning disabled to respect default/saved positions + // Handles now use their configured defaults or saved localStorage positions const toolbox = diagramManager.diagramMenuManager.toolbox; if (toolbox && toolbox.handleMesh) { - logger.info('Toolbox handleMesh BEFORE positioning:', { + logger.info('Toolbox handleMesh using default/saved position:', { position: toolbox.handleMesh.position.clone(), absolutePosition: toolbox.handleMesh.getAbsolutePosition().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 // NOTE: User faces -Z direction by design, so negate forward offset const forwardOffset = horizontalForward.scale(-0.3); @@ -193,16 +195,20 @@ function positionComponentsRelativeToCamera(scene: Scene, diagramManager: Diagra absolutePosition: toolbox.handleMesh.getAbsolutePosition().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']; if (inputTextView && inputTextView.handleMesh) { - logger.info('InputTextView handleMesh BEFORE positioning:', { + logger.info('InputTextView handleMesh using default/saved position:', { position: inputTextView.handleMesh.position.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 const inputWorldPos = cameraWorldPos.add(horizontalForward.scale(-0.5)); inputWorldPos.y = cameraWorldPos.y - 0.4; // Below eye level @@ -218,5 +224,6 @@ function positionComponentsRelativeToCamera(scene: Scene, diagramManager: Diagra position: inputTextView.handleMesh.position.clone(), absolutePosition: inputTextView.handleMesh.getAbsolutePosition().clone() }); + */ } } \ No newline at end of file