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>
838 lines
32 KiB
TypeScript
838 lines
32 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, 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);
|
||
}
|
||
}
|
||
}
|