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>
452 lines
16 KiB
TypeScript
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
|
|
}
|
|
}
|