diff --git a/VRCONFIGPLAN.md b/VRCONFIGPLAN.md new file mode 100644 index 0000000..52b4425 --- /dev/null +++ b/VRCONFIGPLAN.md @@ -0,0 +1,297 @@ +# VR Configuration Panel Implementation Plan + +## Overview + +Create an immersive WebXR configuration panel that mirrors the 2D ConfigModal functionality using BabylonJS AdvancedDynamicTexture (ADT). The panel will allow users to adjust all application settings directly in VR. + +## Recommended Approach: AdvancedDynamicTexture (ADT) + +**Why ADT?** +- Most common approach for WebXR UI in BabylonJS +- Existing pattern in codebase (see `src/menus/configMenu.ts`) +- Good balance of simplicity and functionality +- Native support for text, buttons, sliders, and dropdowns +- Easy integration with existing Handle pattern + +**Estimated Effort**: 150-200 lines of code, 4-8 hours implementation time + +## File Structure + +``` +src/menus/ +├── vrConfigPanel.ts (NEW - main implementation) +└── configMenu.ts (REFERENCE - existing VR config example) + +src/diagram/ +└── diagramMenuManager.ts (MODIFY - add toolbox button) + +src/util/ +└── appConfig.ts (USE - singleton for config management) +``` + +## Implementation Phases + +### Phase 1: Core Panel Setup +- [ ] Create `src/menus/vrConfigPanel.ts` file +- [ ] Implement class structure following Handle pattern: + ```typescript + export class VRConfigPanel { + private _scene: Scene; + private _handleMesh: Mesh; + private _advancedTexture: AdvancedDynamicTexture; + private _configObserver: Observer; + + constructor(scene: Scene) { + // Initialize panel + } + + public get handleMesh(): Mesh { + return this._handleMesh; + } + + public show(): void { + this._handleMesh.setEnabled(true); + } + + public hide(): void { + this._handleMesh.setEnabled(false); + } + + public dispose(): void { + // Cleanup + } + } + ``` +- [ ] Create base mesh (plane) for panel backing +- [ ] Set up AdvancedDynamicTexture with appropriate resolution (1024x1024 or 2048x2048) +- [ ] Position panel at comfortable viewing distance (0.5-0.7m from camera) +- [ ] Make panel grabbable via Handle pattern + +**Reference Files**: +- `src/menus/inputTextView.ts` - Handle pattern implementation +- `src/menus/configMenu.ts` - ADT usage example + +### Phase 2: UI Layout Structure +- [ ] Create main container (StackPanel for vertical layout) +- [ ] Add title text at top ("Configuration") +- [ ] Create 5 section containers (one for each config group): + 1. Location Snap + 2. Rotation Snap + 3. Fly Mode + 4. Snap Turn + 5. Label Rendering Mode +- [ ] Style containers with padding and spacing +- [ ] Add visual separators between sections + +**ADT Components to Use**: +- `StackPanel` - Main vertical container +- `TextBlock` - Labels and section titles +- `Rectangle` - Containers and separators + +**Reference**: `src/menus/configMenu.ts:44-89` for existing layout patterns + +### Phase 3: Location Snap Section +- [ ] Add "Location Snap" label +- [ ] Create enable/disable toggle button + - Shows "Enabled" or "Disabled" + - Updates `appConfigInstance` on click +- [ ] Add RadioGroup for snap values: + - Options: 1cm (.01), 5cm (.05), 10cm (.1), 50cm (.5), 1m (1) + - Default: 10cm (.1) + - Disable when snap is off +- [ ] Wire up to `appConfigInstance.setGridSnap(value)` +- [ ] Subscribe to config changes to update UI + +**ADT Components**: +- `Button` - Toggle switch +- `RadioButton` + `TextBlock` - Value selection +- Color coding: enabled (green/myColor), disabled (gray) + +**Reference ConfigModal**: `src/react/pages/configModal.tsx:83-94` + +### Phase 4: Rotation Snap Section +- [ ] Add "Rotation Snap" label +- [ ] Create enable/disable toggle button +- [ ] Add RadioGroup for rotation values: + - Options: 22.5°, 45°, 90°, 180°, 360° + - Default: 90° + - Disable when snap is off +- [ ] Wire up to `appConfigInstance.setRotateSnap(value)` +- [ ] Subscribe to config changes to update UI + +**Reference ConfigModal**: `src/react/pages/configModal.tsx:96-108` + +### Phase 5: Fly Mode Section +- [ ] Add "Fly Mode" label +- [ ] Create toggle button + - Shows "Fly Mode Enabled" or "Fly Mode Disabled" +- [ ] Wire up to `appConfigInstance.setFlyMode(value)` +- [ ] Subscribe to config changes to update UI + +**Reference ConfigModal**: `src/react/pages/configModal.tsx:109-112` + +### Phase 6: Snap Turn Section +- [ ] Add "Snap Turn" label +- [ ] Create enable/disable toggle button +- [ ] Add RadioGroup for snap turn angles: + - Options: 22.5°, 45°, 90°, 180°, 360° + - Default: 45° + - Disable when snap is off +- [ ] Wire up to `appConfigInstance.setTurnSnap(value)` +- [ ] Subscribe to config changes to update UI + +**Reference ConfigModal**: `src/react/pages/configModal.tsx:113-125` + +### Phase 7: Label Rendering Mode Section +- [ ] Add "Label Rendering Mode" label +- [ ] Create RadioGroup for rendering modes: + - Fixed + - Billboard (Always Face Camera) + - Dynamic (Coming Soon) - disabled + - Distance-based (Coming Soon) - disabled +- [ ] Wire up to `appConfigInstance.setLabelRenderingMode(value)` +- [ ] Subscribe to config changes to update UI +- [ ] Style disabled options with gray text + +**Reference ConfigModal**: `src/react/pages/configModal.tsx:126-135` + +### Phase 8: Integration with Toolbox +- [ ] Modify `src/diagram/diagramMenuManager.ts` to instantiate VRConfigPanel +- [ ] Add "Config" button to toolbox (similar to "Exit VR" button pattern) +- [ ] Wire up button click to show/hide panel +- [ ] Position panel relative to camera when shown (see `positionComponentsRelativeToCamera`) +- [ ] Add parent relationship to platform for movement tracking + +**Reference**: +- `src/diagram/diagramMenuManager.ts:85-97` - Exit button creation +- `src/util/functions/groundMeshObserver.ts:127-222` - Component positioning + +### Phase 9: Observable Integration +- [ ] Subscribe to `appConfigInstance.onConfigChangedObservable` in constructor +- [ ] Update all UI elements when config changes externally +- [ ] Ensure Observable cleanup in dispose() method +- [ ] Test config changes from both VR panel and 2D ConfigModal + +**Pattern**: +```typescript +this._configObserver = appConfigInstance.onConfigChangedObservable.add((config) => { + // Update UI elements to reflect new config + this.updateLocationSnapUI(config.locationSnap); + this.updateRotationSnapUI(config.rotateSnap); + // ... etc +}); +``` + +### Phase 10: Testing & Polish +- [ ] Test all toggle switches update config correctly +- [ ] Test all radio button selections update config correctly +- [ ] Verify config changes propagate to DiagramObjects (label mode, snap behavior) +- [ ] Test panel positioning in VR (comfortable viewing distance) +- [ ] Test panel grabbability via Handle +- [ ] Verify panel follows platform movement +- [ ] Test config persistence (localStorage) +- [ ] Test config synchronization between VR panel and 2D ConfigModal +- [ ] Add visual feedback for button clicks (color changes, animations) +- [ ] Ensure proper cleanup on panel disposal +- [ ] Test in both WebXR and desktop modes + +## Code Patterns to Follow + +### 1. Toggle Button Pattern +```typescript +const toggleButton = Button.CreateSimpleButton("toggle", "Enabled"); +toggleButton.width = "200px"; +toggleButton.height = "40px"; +toggleButton.color = "white"; +toggleButton.background = "green"; +toggleButton.onPointerClickObservable.add(() => { + const newValue = !currentValue; + toggleButton.textBlock.text = newValue ? "Enabled" : "Disabled"; + toggleButton.background = newValue ? "green" : "gray"; + appConfigInstance.setSomeSetting(newValue); +}); +``` + +### 2. RadioGroup Pattern +```typescript +const radioGroup = new SelectionPanel("snapValues"); +const options = [ + { value: 0.01, label: "1cm" }, + { value: 0.1, label: "10cm" }, + // ... more options +]; + +options.forEach(option => { + const radio = new RadioButton(); + radio.width = "20px"; + radio.height = "20px"; + radio.isChecked = (option.value === currentValue); + radio.onIsCheckedChangedObservable.add((checked) => { + if (checked) { + appConfigInstance.setGridSnap(option.value); + } + }); + // Add label next to radio button +}); +``` + +### 3. Config Observer Pattern +```typescript +this._configObserver = appConfigInstance.onConfigChangedObservable.add((config) => { + this.updateUIFromConfig(config); +}); + +// In dispose(): +if (this._configObserver) { + appConfigInstance.onConfigChangedObservable.remove(this._configObserver); +} +``` + +## Key Integration Points + +### AppConfig Singleton +- Import: `import {appConfigInstance} from "../util/appConfig";` +- Read: `appConfigInstance.current.locationSnap` +- Write: `appConfigInstance.setGridSnap(0.1)` +- Subscribe: `appConfigInstance.onConfigChangedObservable.add(callback)` + +### DiagramMenuManager +- Instantiate panel: `this._vrConfigPanel = new VRConfigPanel(this._scene);` +- Add button to toolbox: Follow exit button pattern in `setupExitButton()` +- Show panel: `this._vrConfigPanel.show();` +- Position panel: Follow pattern in `groundMeshObserver.ts:127-222` + +### Handle Pattern +- Make panel grabbable by controllers +- Parent to platform for world movement +- Use `_handleMesh` as root for entire panel UI + +## Reference Files + +1. **src/menus/configMenu.ts** - Existing VR config implementation with ADT +2. **src/menus/inputTextView.ts** - Handle pattern and ADT setup +3. **src/react/pages/configModal.tsx** - UI structure and config sections +4. **src/util/appConfig.ts** - Config singleton and setter methods +5. **src/diagram/diagramMenuManager.ts** - Toolbox button creation +6. **src/util/functions/groundMeshObserver.ts** - Component positioning + +## Success Criteria + +- [ ] All 5 config sections implemented and functional +- [ ] Config changes in VR panel update appConfigInstance +- [ ] Config changes propagate to all DiagramObjects +- [ ] Panel is grabbable and repositionable +- [ ] Panel follows platform movement +- [ ] Config persists to localStorage +- [ ] Synchronized with 2D ConfigModal +- [ ] Comfortable viewing experience in VR +- [ ] No memory leaks (proper Observable cleanup) + +## Notes + +- Start hidden (only show when user clicks toolbox button) +- Position at ~0.5m in front of camera when opened +- Use Y-axis billboard mode to keep panel upright but allow rotation +- Consider adding "Close" button at bottom of panel +- Match color scheme with existing UI (myColor theme) +- Test with both left and right controller grabbing diff --git a/src/menus/vrConfigPanel.ts b/src/menus/vrConfigPanel.ts new file mode 100644 index 0000000..4a7d36e --- /dev/null +++ b/src/menus/vrConfigPanel.ts @@ -0,0 +1,234 @@ +import { + AdvancedDynamicTexture, + 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; + private _mainContainer: StackPanel; + + 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 + this._advancedTexture = AdvancedDynamicTexture.CreateForMesh( + this._panelMesh, + 2048, // Width in pixels + 2048 // Height in pixels (square for now, will adjust if needed) + ); + + // 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); + + // Parent handle to platform when available + this.setupPlatformParenting(); + + this._logger.debug('VR config panel built successfully'); + } + + /** + * 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); + + // UI update logic will be implemented in subsequent phases + // For now, just log the config change + + // Phase 3-7 will add: + // - Location snap UI update + // - Rotation snap UI update + // - Fly mode UI update + // - Snap turn UI update + // - Label rendering mode UI update + } +}