immersive2/src/menus/vrConfigPanel.ts
Michael Mainguy e329b95f2f Fix AppConfig persistence and consolidate handle storage
AppConfig Persistence Fixes:
- Fix constructor to properly handle null localStorage values
- Add null check before JSON.parse to prevent errors
- Create fresh config copies with spread operator to avoid reference issues
- Add better error handling and logging for config loading
- Initialize handles array properly

React ConfigModal Improvements:
- Fix config initialization to get fresh values on render instead of stale module-level values
- Separate useEffect hooks for each config property (prevents unnecessary updates)
- Fix SegmentedControl string-to-number conversion (locationSnaps now use "0.01", "0.1" format)
- Enable/disable logic now properly sets values to 0 when disabled

Handle Storage Consolidation:
- Create dynamic HandleConfig type with Vec3 for serializable position/rotation/scale
- Add handles array to AppConfigType for flexible handle storage
- Replace individual localStorage keys with centralized AppConfig storage
- Add handle management methods: getHandleConfig, setHandleConfig, removeHandleConfig, getAllHandleConfigs
- Update Handle class to read from AppConfig instead of direct localStorage
- Update dropMesh to save handles via AppConfig using Vec3 serialization
- Convert between BabylonJS Vector3 and serializable Vec3 at conversion points

Benefits:
- Single source of truth for all configuration
- Proper localStorage persistence across page reloads
- Dynamic handle creation without code changes
- Type-safe configuration with proper JSON serialization
- Consolidated storage (one appConfig key instead of multiple handle-* keys)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 15:34:40 -06:00

838 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, LabelRenderingMode} 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();
// Rotation Snap UI controls
private _rotationSnapEnabled: boolean = true;
private _rotationSnapToggle: Button;
private _rotationSnapButtons: Map<number, Button> = new Map();
// Fly Mode UI controls
private _flyModeToggle: Button;
// Snap Turn UI controls
private _snapTurnEnabled: boolean = true;
private _snapTurnToggle: Button;
private _snapTurnButtons: Map<number, Button> = new Map();
// Label Rendering Mode UI controls
private _labelModeButtons: Map<LabelRenderingMode, 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);
// Scale down to match toolbox compact size (makes 2m×1.5m panel → 1.2m×0.9m)
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({
contentMesh: this._baseTransform,
label: 'Configuration',
defaultPosition: new Vector3(.95, .6, .3), // Default position relative to platform
defaultRotation: new Vector3(.47, .87, 0) // Default rotation
});
this._baseTransform.position.y = .3
this._baseTransform.scaling = new Vector3(.3, .3, .3);
// 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 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);
material.metadata = { isUI: true }; // Mark as UI to prevent rendering mode modifications
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();
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.buildRotationSnapControls();
this.addSeparator();
// Section 3: Fly Mode
this._flyModeContent = this.createSectionContainer("Fly Mode");
this.buildFlyModeControls();
this.addSeparator();
// Section 4: Snap Turn
this._snapTurnContent = this.createSectionContainer("Snap Turn");
this.buildSnapTurnControls();
this.addSeparator();
// Section 5: Label Rendering Mode
this._labelModeContent = this.createSectionContainer("Label Rendering Mode");
this.buildLabelModeControls();
}
/**
* 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) => {
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) {
this._logger.debug(snap.value);
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;
});
}
/**
* Build Rotation Snap controls
*/
private buildRotationSnapControls(): void {
const currentSnap = appConfigInstance.current.rotateSnap;
this._rotationSnapEnabled = currentSnap > 0;
// Create horizontal container for toggle button
const toggleContainer = new StackPanel("rotationSnapToggleContainer");
toggleContainer.isVertical = false;
toggleContainer.width = "100%";
toggleContainer.height = "80px";
toggleContainer.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT;
toggleContainer.paddingBottom = "20px";
this._rotationSnapContent.addControl(toggleContainer);
// Create toggle button
this._rotationSnapToggle = Button.CreateSimpleButton(
"rotationSnapToggle",
this._rotationSnapEnabled ? "Enabled" : "Disabled"
);
this._rotationSnapToggle.width = "300px";
this._rotationSnapToggle.height = "70px";
this._rotationSnapToggle.fontSize = 48;
this._rotationSnapToggle.color = "white";
this._rotationSnapToggle.background = this._rotationSnapEnabled ? "#4A9EFF" : "#666666";
this._rotationSnapToggle.thickness = 0;
this._rotationSnapToggle.cornerRadius = 10;
toggleContainer.addControl(this._rotationSnapToggle);
// Toggle button click handler
this._rotationSnapToggle.onPointerClickObservable.add(() => {
this._rotationSnapEnabled = !this._rotationSnapEnabled;
this._rotationSnapToggle.textBlock.text = this._rotationSnapEnabled ? "Enabled" : "Disabled";
this._rotationSnapToggle.background = this._rotationSnapEnabled ? "#4A9EFF" : "#666666";
if (this._rotationSnapEnabled) {
// Re-enable with last selected value or default
const lastValue = appConfigInstance.current.rotateSnap || 90;
appConfigInstance.setRotateSnap(lastValue > 0 ? lastValue : 90);
this.updateRotationSnapButtonStates(lastValue);
} else {
// Disable by setting to 0
appConfigInstance.setRotateSnap(0);
this.updateRotationSnapButtonStates(0);
}
});
// Create horizontal container for snap value buttons
const valuesContainer = new StackPanel("rotationSnapValuesContainer");
valuesContainer.isVertical = false;
valuesContainer.width = "100%";
valuesContainer.height = "80px";
valuesContainer.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT;
this._rotationSnapContent.addControl(valuesContainer);
// Define rotation snap values
const snapValues = [
{ value: 22.5, label: "22.5°" },
{ value: 45, label: "45°" },
{ value: 90, label: "90°" },
{ value: 180, label: "180°" },
{ value: 360, label: "360°" }
];
// Create button for each snap value
snapValues.forEach((snap) => {
const isSelected = this._rotationSnapEnabled && Math.abs(currentSnap - snap.value) < 0.001;
const btn = Button.CreateSimpleButton(`rotationSnap_${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._rotationSnapEnabled ? "#333333" : "#222222";
btn.alpha = this._rotationSnapEnabled ? 1.0 : 0.5;
}
// Click handler
btn.onPointerClickObservable.add(() => {
if (this._rotationSnapEnabled) {
appConfigInstance.setRotateSnap(snap.value);
this.updateRotationSnapButtonStates(snap.value);
}
});
this._rotationSnapButtons.set(snap.value, btn);
valuesContainer.addControl(btn);
});
}
/**
* Update Rotation Snap button visual states
*/
private updateRotationSnapButtonStates(selectedValue: number): void {
this._rotationSnapButtons.forEach((btn, value) => {
const isSelected = this._rotationSnapEnabled && Math.abs(selectedValue - value) < 0.001;
if (isSelected) {
btn.background = "#4A9EFF";
btn.fontWeight = "bold";
} else {
btn.background = this._rotationSnapEnabled ? "#333333" : "#222222";
btn.fontWeight = "normal";
}
btn.alpha = this._rotationSnapEnabled ? 1.0 : 0.5;
});
}
/**
* Build Fly Mode controls
*/
private buildFlyModeControls(): void {
const flyModeEnabled = appConfigInstance.current.flyMode;
// Create horizontal container for toggle button
const toggleContainer = new StackPanel("flyModeToggleContainer");
toggleContainer.isVertical = false;
toggleContainer.width = "100%";
toggleContainer.height = "80px";
toggleContainer.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT;
this._flyModeContent.addControl(toggleContainer);
// Create toggle button
this._flyModeToggle = Button.CreateSimpleButton(
"flyModeToggle",
flyModeEnabled ? "Fly Mode Enabled" : "Fly Mode Disabled"
);
this._flyModeToggle.width = "400px";
this._flyModeToggle.height = "70px";
this._flyModeToggle.fontSize = 48;
this._flyModeToggle.color = "white";
this._flyModeToggle.background = flyModeEnabled ? "#4A9EFF" : "#666666";
this._flyModeToggle.thickness = 0;
this._flyModeToggle.cornerRadius = 10;
toggleContainer.addControl(this._flyModeToggle);
// Toggle button click handler
this._flyModeToggle.onPointerClickObservable.add(() => {
const newValue = !appConfigInstance.current.flyMode;
appConfigInstance.setFlyMode(newValue);
this._flyModeToggle.textBlock.text = newValue ? "Fly Mode Enabled" : "Fly Mode Disabled";
this._flyModeToggle.background = newValue ? "#4A9EFF" : "#666666";
});
}
/**
* Build Snap Turn controls
*/
private buildSnapTurnControls(): void {
const currentSnap = appConfigInstance.current.turnSnap;
this._snapTurnEnabled = currentSnap > 0;
// Create horizontal container for toggle button
const toggleContainer = new StackPanel("snapTurnToggleContainer");
toggleContainer.isVertical = false;
toggleContainer.width = "100%";
toggleContainer.height = "80px";
toggleContainer.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT;
toggleContainer.paddingBottom = "20px";
this._snapTurnContent.addControl(toggleContainer);
// Create toggle button
this._snapTurnToggle = Button.CreateSimpleButton(
"snapTurnToggle",
this._snapTurnEnabled ? "Enabled" : "Disabled"
);
this._snapTurnToggle.width = "300px";
this._snapTurnToggle.height = "70px";
this._snapTurnToggle.fontSize = 48;
this._snapTurnToggle.color = "white";
this._snapTurnToggle.background = this._snapTurnEnabled ? "#4A9EFF" : "#666666";
this._snapTurnToggle.thickness = 0;
this._snapTurnToggle.cornerRadius = 10;
toggleContainer.addControl(this._snapTurnToggle);
// Toggle button click handler
this._snapTurnToggle.onPointerClickObservable.add(() => {
this._snapTurnEnabled = !this._snapTurnEnabled;
this._snapTurnToggle.textBlock.text = this._snapTurnEnabled ? "Enabled" : "Disabled";
this._snapTurnToggle.background = this._snapTurnEnabled ? "#4A9EFF" : "#666666";
if (this._snapTurnEnabled) {
// Re-enable with last selected value or default
const lastValue = appConfigInstance.current.turnSnap || 45;
appConfigInstance.setTurnSnap(lastValue > 0 ? lastValue : 45);
this.updateSnapTurnButtonStates(lastValue);
} else {
// Disable by setting to 0
appConfigInstance.setTurnSnap(0);
this.updateSnapTurnButtonStates(0);
}
});
// Create horizontal container for snap value buttons
const valuesContainer = new StackPanel("snapTurnValuesContainer");
valuesContainer.isVertical = false;
valuesContainer.width = "100%";
valuesContainer.height = "80px";
valuesContainer.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT;
this._snapTurnContent.addControl(valuesContainer);
// Define snap turn values (same as rotation snap)
const snapValues = [
{ value: 22.5, label: "22.5°" },
{ value: 45, label: "45°" },
{ value: 90, label: "90°" },
{ value: 180, label: "180°" },
{ value: 360, label: "360°" }
];
// Create button for each snap value
snapValues.forEach((snap) => {
const isSelected = this._snapTurnEnabled && Math.abs(currentSnap - snap.value) < 0.001;
const btn = Button.CreateSimpleButton(`snapTurn_${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._snapTurnEnabled ? "#333333" : "#222222";
btn.alpha = this._snapTurnEnabled ? 1.0 : 0.5;
}
// Click handler
btn.onPointerClickObservable.add(() => {
if (this._snapTurnEnabled) {
appConfigInstance.setTurnSnap(snap.value);
this.updateSnapTurnButtonStates(snap.value);
}
});
this._snapTurnButtons.set(snap.value, btn);
valuesContainer.addControl(btn);
});
}
/**
* Update Snap Turn button visual states
*/
private updateSnapTurnButtonStates(selectedValue: number): void {
this._snapTurnButtons.forEach((btn, value) => {
const isSelected = this._snapTurnEnabled && Math.abs(selectedValue - value) < 0.001;
if (isSelected) {
btn.background = "#4A9EFF";
btn.fontWeight = "bold";
} else {
btn.background = this._snapTurnEnabled ? "#333333" : "#222222";
btn.fontWeight = "normal";
}
btn.alpha = this._snapTurnEnabled ? 1.0 : 0.5;
});
}
/**
* Build Label Rendering Mode controls
*/
private buildLabelModeControls(): void {
const currentMode = appConfigInstance.current.labelRenderingMode || 'billboard';
// Create vertical container for mode buttons
const modesContainer = new StackPanel("labelModesContainer");
modesContainer.isVertical = true;
modesContainer.width = "100%";
modesContainer.adaptHeightToChildren = true;
this._labelModeContent.addControl(modesContainer);
// Define label rendering modes
const modes: Array<{ value: LabelRenderingMode, label: string, disabled: boolean }> = [
{ value: 'fixed', label: 'Fixed', disabled: false },
{ value: 'billboard', label: 'Billboard (Always Face Camera)', disabled: false },
{ value: 'dynamic', label: 'Dynamic (Coming Soon)', disabled: true },
{ value: 'distance', label: 'Distance-based (Coming Soon)', disabled: true }
];
// Create button for each mode
modes.forEach((mode) => {
const isSelected = currentMode === mode.value;
const btn = Button.CreateSimpleButton(`labelMode_${mode.value}`, mode.label);
btn.width = "100%";
btn.height = "70px";
btn.fontSize = 42;
btn.color = "white";
btn.paddingBottom = "10px";
btn.thickness = 0;
btn.cornerRadius = 8;
// Set initial appearance
if (mode.disabled) {
btn.background = "#222222";
btn.alpha = 0.5;
btn.color = "#888888";
} else if (isSelected) {
btn.background = "#4A9EFF";
btn.fontWeight = "bold";
} else {
btn.background = "#333333";
}
// Click handler (only for enabled modes)
if (!mode.disabled) {
btn.onPointerClickObservable.add(() => {
appConfigInstance.setLabelRenderingMode(mode.value);
this.updateLabelModeButtonStates(mode.value);
});
}
this._labelModeButtons.set(mode.value, btn);
modesContainer.addControl(btn);
});
}
/**
* Update Label Rendering Mode button visual states
*/
private updateLabelModeButtonStates(selectedValue: LabelRenderingMode): void {
this._labelModeButtons.forEach((btn, value) => {
const isSelected = selectedValue === value;
const isDisabled = value === 'dynamic' || value === 'distance';
if (isDisabled) {
// Keep disabled appearance
btn.background = "#222222";
btn.alpha = 0.5;
btn.color = "#888888";
btn.fontWeight = "normal";
} else if (isSelected) {
btn.background = "#4A9EFF";
btn.fontWeight = "bold";
btn.color = "white";
} else {
btn.background = "#333333";
btn.fontWeight = "normal";
btn.color = "white";
}
});
}
/**
* 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);
}
// Update Rotation Snap UI
if (this._rotationSnapToggle && this._rotationSnapButtons.size > 0) {
this._rotationSnapEnabled = config.rotateSnap > 0;
this._rotationSnapToggle.textBlock.text = this._rotationSnapEnabled ? "Enabled" : "Disabled";
this._rotationSnapToggle.background = this._rotationSnapEnabled ? "#4A9EFF" : "#666666";
this.updateRotationSnapButtonStates(config.rotateSnap);
}
// Update Fly Mode UI
if (this._flyModeToggle) {
this._flyModeToggle.textBlock.text = config.flyMode ? "Fly Mode Enabled" : "Fly Mode Disabled";
this._flyModeToggle.background = config.flyMode ? "#4A9EFF" : "#666666";
}
// Update Snap Turn UI
if (this._snapTurnToggle && this._snapTurnButtons.size > 0) {
this._snapTurnEnabled = config.turnSnap > 0;
this._snapTurnToggle.textBlock.text = this._snapTurnEnabled ? "Enabled" : "Disabled";
this._snapTurnToggle.background = this._snapTurnEnabled ? "#4A9EFF" : "#666666";
this.updateSnapTurnButtonStates(config.turnSnap);
}
// Update Label Rendering Mode UI
if (this._labelModeButtons.size > 0 && config.labelRenderingMode) {
this.updateLabelModeButtonStates(config.labelRenderingMode);
}
}
}