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:
parent
15c6617151
commit
aa0810be02
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -36,10 +36,15 @@ export class Toolbox {
|
||||
constructor(readyObservable: Observable<boolean>) {
|
||||
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) {
|
||||
|
||||
@ -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()
|
||||
});
|
||||
*/
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user