immersive2/src/menus/vrConfigPanel.ts
Michael Mainguy 5889a1ed79 Implement Phase 3: Location Snap controls for VR config panel
Add fully functional Location Snap controls:
- Toggle button (Enabled/Disabled) with blue/gray color coding
- 5 snap value buttons: 1cm, 5cm, 10cm, 50cm, 1m
- Selected button highlighted in blue with bold text
- Disabled appearance when snap is off (50% opacity)
- Wire up to appConfigInstance.setGridSnap()
- Update UI from config observable changes

Fix layout issues:
- Change texture aspect ratio from 2048x2048 to 2048x1536 (4:3) to match plane dimensions
- Add adaptHeightToChildren to section containers for proper auto-sizing
- Add horizontal alignment to button containers

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 12:13:22 -06:00

452 lines
16 KiB
TypeScript

import {
AdvancedDynamicTexture,
Button,
Control,
Rectangle,
StackPanel,
TextBlock
} from "@babylonjs/gui";
import {
Color3,
Mesh,
MeshBuilder,
Observer,
Scene,
StandardMaterial,
TransformNode,
Vector3
} from "@babylonjs/core";
import {appConfigInstance} from "../util/appConfig";
import {AppConfigType} from "../util/appConfigType";
import log from "loglevel";
import {DefaultScene} from "../defaultScene";
import {Handle} from "../objects/handle";
/**
* VRConfigPanel - Immersive WebXR configuration panel using AdvancedDynamicTexture
*
* Provides a VR-native interface for adjusting application settings including:
* - Location snap settings
* - Rotation snap settings
* - Fly mode toggle
* - Snap turn settings
* - Label rendering mode
*
* The panel is grabbable via the Handle pattern and integrates with the AppConfig singleton.
*/
export class VRConfigPanel {
private readonly _logger = log.getLogger('VRConfigPanel');
private readonly _scene: Scene;
private readonly _baseTransform: TransformNode;
private _handle: Handle;
private _panelMesh: Mesh;
private _advancedTexture: AdvancedDynamicTexture;
private _configObserver: Observer<AppConfigType>;
private _mainContainer: StackPanel;
// Section content containers (filled in Phases 3-7)
private _locationSnapContent: StackPanel;
private _rotationSnapContent: StackPanel;
private _flyModeContent: StackPanel;
private _snapTurnContent: StackPanel;
private _labelModeContent: StackPanel;
// Location Snap UI controls
private _locationSnapEnabled: boolean = true;
private _locationSnapToggle: Button;
private _locationSnapButtons: Map<number, Button> = new Map();
constructor(scene: Scene) {
this._scene = scene || DefaultScene.Scene;
this._logger.debug('VRConfigPanel constructor called');
// Create base transform for the entire panel hierarchy
this._baseTransform = new TransformNode("vrConfigPanelBase", this._scene);
// 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
);
// Build the panel mesh and UI
this.buildPanel();
// Subscribe to config changes
this._configObserver = appConfigInstance.onConfigChangedObservable.add((config) => {
this.updateUIFromConfig(config);
});
// Start hidden - will be shown when user clicks toolbox button
this.hide();
this._logger.debug('VRConfigPanel initialized');
}
/**
* Get the handle's transform node for external positioning
*/
public get handleMesh(): TransformNode {
return this._handle.transformNode;
}
/**
* Show the configuration panel
*/
public show(): void {
if (this._handle && this._handle.transformNode) {
this._handle.transformNode.setEnabled(true);
this._logger.debug('VRConfigPanel shown');
}
}
/**
* Hide the configuration panel
*/
public hide(): void {
if (this._handle && this._handle.transformNode) {
this._handle.transformNode.setEnabled(false);
this._logger.debug('VRConfigPanel hidden');
}
}
/**
* Dispose of all resources
*/
public dispose(): void {
this._logger.debug('Disposing VRConfigPanel');
// Remove config observer
if (this._configObserver) {
appConfigInstance.onConfigChangedObservable.remove(this._configObserver);
this._configObserver = null;
}
// Dispose of ADT
if (this._advancedTexture) {
this._advancedTexture.dispose();
this._advancedTexture = null;
}
// Dispose of mesh
if (this._panelMesh) {
this._panelMesh.dispose();
this._panelMesh = null;
}
// Dispose of base transform
if (this._baseTransform) {
this._baseTransform.dispose();
}
// Handle will be disposed via its parent
this._handle = null;
this._logger.debug('VRConfigPanel disposed');
}
/**
* Build the panel mesh and initialize AdvancedDynamicTexture
*/
private buildPanel(): void {
this._logger.debug('Building VR config panel');
// Create panel plane mesh (2m wide x 1.5m tall for comfortable viewing in VR)
this._panelMesh = MeshBuilder.CreatePlane(
"vrConfigPanelPlane",
{
width: 2.0,
height: 1.5
},
this._scene
);
// Parent to base transform
this._panelMesh.parent = this._baseTransform;
// Position slightly forward and up from handle
this._panelMesh.position = new Vector3(0, 0.2, 0);
// Create material for panel backing
const material = new StandardMaterial("vrConfigPanelMaterial", this._scene);
material.diffuseColor = new Color3(0.1, 0.1, 0.15); // Dark blue-gray
material.specularColor = new Color3(0.1, 0.1, 0.1);
this._panelMesh.material = material;
// Create AdvancedDynamicTexture with high resolution for crisp text in VR
// Match aspect ratio of plane (2m x 1.5m = 4:3)
this._advancedTexture = AdvancedDynamicTexture.CreateForMesh(
this._panelMesh,
2048, // Width in pixels
1536 // Height in pixels (4:3 aspect ratio)
);
// Create main container (vertical stack)
this._mainContainer = new StackPanel("vrConfigMainContainer");
this._mainContainer.isVertical = true;
this._mainContainer.width = "100%";
this._mainContainer.height = "100%";
this._mainContainer.paddingTop = "40px";
this._mainContainer.paddingBottom = "40px";
this._mainContainer.paddingLeft = "60px";
this._mainContainer.paddingRight = "60px";
this._advancedTexture.addControl(this._mainContainer);
// Add title
const title = new TextBlock("vrConfigTitle", "Configuration");
title.height = "120px";
title.fontSize = 80;
title.color = "white";
title.textHorizontalAlignment = TextBlock.HORIZONTAL_ALIGNMENT_CENTER;
title.textVerticalAlignment = TextBlock.VERTICAL_ALIGNMENT_TOP;
title.paddingBottom = "40px";
this._mainContainer.addControl(title);
// Build configuration sections
this.buildConfigSections();
// Parent handle to platform when available
this.setupPlatformParenting();
this._logger.debug('VR config panel built successfully');
}
/**
* Build all configuration sections with layout structure
*/
private buildConfigSections(): void {
// Section 1: Location Snap
this._locationSnapContent = this.createSectionContainer("Location Snap");
this.buildLocationSnapControls();
this.addSeparator();
// Section 2: Rotation Snap
this._rotationSnapContent = this.createSectionContainer("Rotation Snap");
this.addSeparator();
// Section 3: Fly Mode
this._flyModeContent = this.createSectionContainer("Fly Mode");
this.addSeparator();
// Section 4: Snap Turn
this._snapTurnContent = this.createSectionContainer("Snap Turn");
this.addSeparator();
// Section 5: Label Rendering Mode
this._labelModeContent = this.createSectionContainer("Label Rendering Mode");
}
/**
* Create a section container with title
*/
private createSectionContainer(sectionTitle: string): StackPanel {
// Create section container
const section = new StackPanel(`section_${sectionTitle.replace(/\s+/g, '_')}`);
section.isVertical = true;
section.width = "100%";
section.adaptHeightToChildren = true;
section.paddingTop = "20px";
section.paddingBottom = "20px";
this._mainContainer.addControl(section);
// Add section title
const titleText = new TextBlock(`title_${sectionTitle.replace(/\s+/g, '_')}`, sectionTitle);
titleText.height = "60px";
titleText.fontSize = 60;
titleText.color = "#4A9EFF"; // Bright blue for section titles
titleText.textHorizontalAlignment = TextBlock.HORIZONTAL_ALIGNMENT_LEFT;
titleText.paddingLeft = "20px";
titleText.paddingBottom = "10px";
section.addControl(titleText);
// Create content container for controls (to be filled in subsequent phases)
const contentContainer = new StackPanel(`content_${sectionTitle.replace(/\s+/g, '_')}`);
contentContainer.isVertical = true;
contentContainer.width = "100%";
contentContainer.adaptHeightToChildren = true;
contentContainer.paddingLeft = "40px";
contentContainer.paddingRight = "40px";
section.addControl(contentContainer);
return contentContainer;
}
/**
* Add a visual separator line between sections
*/
private addSeparator(): void {
const separator = new Rectangle(`separator_${Date.now()}`);
separator.height = "2px";
separator.width = "90%";
separator.thickness = 0;
separator.background = "#444444"; // Dark gray separator
separator.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER;
separator.paddingTop = "10px";
separator.paddingBottom = "10px";
this._mainContainer.addControl(separator);
}
/**
* Build Location Snap controls
*/
private buildLocationSnapControls(): void {
const currentSnap = appConfigInstance.current.locationSnap;
this._locationSnapEnabled = currentSnap > 0;
// Create horizontal container for toggle button
const toggleContainer = new StackPanel("locationSnapToggleContainer");
toggleContainer.isVertical = false;
toggleContainer.width = "100%";
toggleContainer.height = "80px";
toggleContainer.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT;
toggleContainer.paddingBottom = "20px";
this._locationSnapContent.addControl(toggleContainer);
// Create toggle button
this._locationSnapToggle = Button.CreateSimpleButton(
"locationSnapToggle",
this._locationSnapEnabled ? "Enabled" : "Disabled"
);
this._locationSnapToggle.width = "300px";
this._locationSnapToggle.height = "70px";
this._locationSnapToggle.fontSize = 48;
this._locationSnapToggle.color = "white";
this._locationSnapToggle.background = this._locationSnapEnabled ? "#4A9EFF" : "#666666";
this._locationSnapToggle.thickness = 0;
this._locationSnapToggle.cornerRadius = 10;
toggleContainer.addControl(this._locationSnapToggle);
// Toggle button click handler
this._locationSnapToggle.onPointerClickObservable.add(() => {
this._locationSnapEnabled = !this._locationSnapEnabled;
this._locationSnapToggle.textBlock.text = this._locationSnapEnabled ? "Enabled" : "Disabled";
this._locationSnapToggle.background = this._locationSnapEnabled ? "#4A9EFF" : "#666666";
if (this._locationSnapEnabled) {
// Re-enable with last selected value or default
const lastValue = appConfigInstance.current.locationSnap || 0.1;
appConfigInstance.setGridSnap(lastValue > 0 ? lastValue : 0.1);
this.updateLocationSnapButtonStates(lastValue);
} else {
// Disable by setting to 0
appConfigInstance.setGridSnap(0);
this.updateLocationSnapButtonStates(0);
}
});
// Create horizontal container for snap value buttons
const valuesContainer = new StackPanel("locationSnapValuesContainer");
valuesContainer.isVertical = false;
valuesContainer.width = "100%";
valuesContainer.height = "80px";
valuesContainer.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT;
this._locationSnapContent.addControl(valuesContainer);
// Define snap values
const snapValues = [
{ value: 0.01, label: "1cm" },
{ value: 0.05, label: "5cm" },
{ value: 0.1, label: "10cm" },
{ value: 0.5, label: "50cm" },
{ value: 1.0, label: "1m" }
];
// Create button for each snap value
snapValues.forEach((snap, index) => {
const isSelected = this._locationSnapEnabled && Math.abs(currentSnap - snap.value) < 0.001;
const btn = Button.CreateSimpleButton(`locationSnap_${snap.value}`, snap.label);
btn.width = "120px";
btn.height = "70px";
btn.fontSize = 42;
btn.color = "white";
btn.paddingRight = "10px";
btn.thickness = 0;
btn.cornerRadius = 8;
// Set initial appearance
if (isSelected) {
btn.background = "#4A9EFF";
btn.fontWeight = "bold";
} else {
btn.background = this._locationSnapEnabled ? "#333333" : "#222222";
btn.alpha = this._locationSnapEnabled ? 1.0 : 0.5;
}
// Click handler
btn.onPointerClickObservable.add(() => {
if (this._locationSnapEnabled) {
appConfigInstance.setGridSnap(snap.value);
this.updateLocationSnapButtonStates(snap.value);
}
});
this._locationSnapButtons.set(snap.value, btn);
valuesContainer.addControl(btn);
});
}
/**
* Update Location Snap button visual states
*/
private updateLocationSnapButtonStates(selectedValue: number): void {
this._locationSnapButtons.forEach((btn, value) => {
const isSelected = this._locationSnapEnabled && Math.abs(selectedValue - value) < 0.001;
if (isSelected) {
btn.background = "#4A9EFF";
btn.fontWeight = "bold";
} else {
btn.background = this._locationSnapEnabled ? "#333333" : "#222222";
btn.fontWeight = "normal";
}
btn.alpha = this._locationSnapEnabled ? 1.0 : 0.5;
});
}
/**
* 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
* Called when config changes externally
*/
private updateUIFromConfig(config: AppConfigType): void {
this._logger.debug('Updating UI from config', config);
// Update Location Snap UI
if (this._locationSnapToggle && this._locationSnapButtons.size > 0) {
this._locationSnapEnabled = config.locationSnap > 0;
this._locationSnapToggle.textBlock.text = this._locationSnapEnabled ? "Enabled" : "Disabled";
this._locationSnapToggle.background = this._locationSnapEnabled ? "#4A9EFF" : "#666666";
this.updateLocationSnapButtonStates(config.locationSnap);
}
// Phase 4-7 will add:
// - Rotation snap UI update
// - Fly mode UI update
// - Snap turn UI update
// - Label rendering mode UI update
}
}