Extract toolbox buttons into standalone reusable classes

Refactor Toolbox button code into separate, focused classes following Single Responsibility Principle.

New Button Classes:
- ExitXRButton (src/objects/buttons/ExitXRButton.ts)
  * Encapsulates exit XR functionality
  * Takes XR experience, scene, parent, and optional position
  * Handles click events to exit XR session
  * Provides dispose() and transform getter

- ConfigButton (src/objects/buttons/ConfigButton.ts)
  * Encapsulates config panel toggle functionality
  * Uses dependency injection for toggle callback
  * Configurable position relative to parent
  * Provides dispose() and transform getter

- RenderModeButton (src/objects/buttons/RenderModeButton.ts)
  * Encapsulates render mode cycling functionality
  * Internally manages render mode state
  * Automatically recreates button with new label on mode change
  * Cycles through all available rendering modes
  * Provides dispose() and transform getter

Toolbox Changes:
- Removed createRenderModeButton() and updateRenderModeButton() methods
- Simplified setupXRButton() to instantiate button classes
- Reduced button-related code by ~120 lines
- Added button instance properties for cleanup
- Clean, declarative button creation with clear positioning

Benefits:
- Single Responsibility Principle - each button class has one purpose
- Reusability - buttons can be used anywhere in the app
- Testability - each button can be tested independently
- Cleaner code - Toolbox class focuses on core tool management
- Better dependencies - clear interfaces and dependency injection
- Easier maintenance - button logic isolated and self-contained

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-11-19 16:14:01 -06:00
parent e329b95f2f
commit 7849bf4eb2
4 changed files with 278 additions and 136 deletions

View File

@ -0,0 +1,60 @@
import {Scene, TransformNode, Vector3} from "@babylonjs/core";
import {Button} from "../Button";
import log, {Logger} from "loglevel";
/**
* Button that toggles the VR configuration panel
*/
export class ConfigButton {
private _button: Button;
private readonly _logger: Logger = log.getLogger('ConfigButton');
/**
* Creates a Config button
* @param toggleCallback Function to call when button is clicked
* @param scene BabylonJS scene
* @param parent Parent transform node to attach button to
* @param position Position relative to parent (default: bottom-left)
*/
constructor(
private readonly toggleCallback: () => void,
scene: Scene,
parent: TransformNode,
position: Vector3 = new Vector3(0.5, -0.35, 0)
) {
this._button = Button.CreateButton("config", "config", scene, {});
// Position button
this._button.transform.position = position;
this._button.transform.rotation.y = Math.PI; // Flip 180° to face correctly
this._button.transform.scaling = new Vector3(0.2, 0.2, 0.2);
this._button.transform.parent = parent;
// Add click handler
this._button.onPointerObservable.add((evt) => {
if (evt.sourceEvent.type === 'pointerdown') {
this._logger.debug('Config button clicked');
this.toggleCallback();
}
});
this._logger.debug('ConfigButton created');
}
/**
* Get the button transform for external positioning/manipulation
*/
public get transform(): TransformNode {
return this._button.transform;
}
/**
* Dispose of the button and clean up resources
*/
public dispose(): void {
if (this._button) {
this._button.dispose();
}
this._logger.debug('ConfigButton disposed');
}
}

View File

@ -0,0 +1,60 @@
import {Scene, TransformNode, Vector3, WebXRDefaultExperience} from "@babylonjs/core";
import {Button} from "../Button";
import log, {Logger} from "loglevel";
/**
* Button that exits the XR session when clicked
*/
export class ExitXRButton {
private _button: Button;
private readonly _logger: Logger = log.getLogger('ExitXRButton');
/**
* Creates an Exit XR button
* @param xr WebXR experience to exit from
* @param scene BabylonJS scene
* @param parent Parent transform node to attach button to
* @param position Position relative to parent (default: bottom-right)
*/
constructor(
private readonly xr: WebXRDefaultExperience,
scene: Scene,
parent: TransformNode,
position: Vector3 = new Vector3(-0.5, -0.35, 0)
) {
this._button = Button.CreateButton("exitXr", "exitXr", scene, {});
// Position button
this._button.transform.position = position;
this._button.transform.rotation.y = Math.PI; // Flip 180° to face correctly
this._button.transform.scaling = new Vector3(0.2, 0.2, 0.2);
this._button.transform.parent = parent;
// Add click handler
this._button.onPointerObservable.add((evt) => {
if (evt.sourceEvent.type === 'pointerdown') {
this._logger.debug('Exit XR button clicked');
this.xr.baseExperience.exitXRAsync();
}
});
this._logger.debug('ExitXRButton created');
}
/**
* Get the button transform for external positioning/manipulation
*/
public get transform(): TransformNode {
return this._button.transform;
}
/**
* Dispose of the button and clean up resources
*/
public dispose(): void {
if (this._button) {
this._button.dispose();
}
this._logger.debug('ExitXRButton disposed');
}
}

View File

@ -0,0 +1,129 @@
import {Color3, Scene, TransformNode, Vector3} from "@babylonjs/core";
import {Button} from "../Button";
import {LightmapGenerator} from "../../util/lightmapGenerator";
import {RenderingMode, RenderingModeLabels} from "../../util/renderingMode";
import log, {Logger} from "loglevel";
/**
* Button that cycles through rendering modes
*/
export class RenderModeButton {
private _button: Button;
private readonly _logger: Logger = log.getLogger('RenderModeButton');
private readonly _scene: Scene;
private readonly _parent: TransformNode;
private readonly _position: Vector3;
private readonly _scaling: Vector3;
private readonly _modes: RenderingMode[] = [
RenderingMode.LIGHTMAP_WITH_LIGHTING,
RenderingMode.UNLIT_WITH_EMISSIVE_TEXTURE,
RenderingMode.FLAT_EMISSIVE,
RenderingMode.DIFFUSE_WITH_LIGHTS
];
/**
* Creates a Render Mode button
* @param scene BabylonJS scene
* @param parent Parent transform node to attach button to
* @param position Position relative to parent (default: center below)
* @param scaling Scaling for the button (default: 0.4, 0.4, 0.4)
*/
constructor(
scene: Scene,
parent: TransformNode,
position: Vector3 = new Vector3(0, -0.2, 0),
scaling: Vector3 = new Vector3(0.4, 0.4, 0.4)
) {
this._scene = scene;
this._parent = parent;
this._position = position;
this._scaling = scaling;
this.createButton();
this._logger.debug('RenderModeButton created');
}
/**
* Create or recreate the button with current mode label
* @private
*/
private createButton(): void {
const currentMode = LightmapGenerator.getRenderingMode();
this._button = Button.CreateButton(
`Mode: ${RenderingModeLabels[currentMode]}`,
'renderModeButton',
this._scene,
{
width: 0.5,
height: 0.2,
background: Color3.FromHexString("#333333"),
color: Color3.White(),
fontSize: 240
}
);
// Position button
this._button.transform.position = this._position;
this._button.transform.rotation.y = Math.PI;
this._button.transform.scaling = this._scaling;
this._button.transform.parent = this._parent;
// Add click handler to cycle through modes
this._button.onPointerObservable.add((evt) => {
if (evt.sourceEvent.type === 'pointerdown') {
this.cycleRenderMode();
}
});
}
/**
* Cycle to the next rendering mode
* @private
*/
private cycleRenderMode(): void {
const currentMode = LightmapGenerator.getRenderingMode();
const currentIndex = this._modes.indexOf(currentMode);
const nextIndex = (currentIndex + 1) % this._modes.length;
const nextMode = this._modes[nextIndex];
this._logger.info(`Cycling to rendering mode: ${nextMode}`);
LightmapGenerator.updateAllMaterials(this._scene, nextMode);
// Recreate button with new label
this.updateButton(nextMode);
}
/**
* Update button with new rendering mode label
* @param mode New rendering mode
* @private
*/
private updateButton(mode: RenderingMode): void {
// Dispose old button
if (this._button) {
this._button.dispose();
}
// Create new button with updated text
this.createButton();
}
/**
* Get the button transform for external positioning/manipulation
*/
public get transform(): TransformNode {
return this._button.transform;
}
/**
* Dispose of the button and clean up resources
*/
public dispose(): void {
if (this._button) {
this._button.dispose();
}
this._logger.debug('RenderModeButton disposed');
}
}

View File

@ -3,10 +3,11 @@ import {buildColor} from "./functions/buildColor";
import log from "loglevel";
import {Handle} from "../objects/handle";
import {DefaultScene} from "../defaultScene";
import {Button} from "../objects/Button";
import {LightmapGenerator} from "../util/lightmapGenerator";
import {RenderingMode, RenderingModeLabels} from "../util/renderingMode";
import {AnimatedLineTexture} from "../util/animatedLineTexture";
import {ExitXRButton} from "../objects/buttons/ExitXRButton";
import {ConfigButton} from "../objects/buttons/ConfigButton";
import {RenderModeButton} from "../objects/buttons/RenderModeButton";
import {LightmapGenerator} from "../util/lightmapGenerator";
const colors: string[] = [
"#222222", "#8b4513", "#006400", "#778899",
@ -30,9 +31,13 @@ export class Toolbox {
private readonly _handle: Handle;
private readonly _scene: Scene;
private _xr?: WebXRDefaultExperience;
private _renderModeDisplay?: Button;
private _diagramMenuManager?: any; // Import would create circular dependency
// Button instances
private _exitXRButton?: ExitXRButton;
private _configButton?: ConfigButton;
private _renderModeButton?: RenderModeButton;
constructor(readyObservable: Observable<boolean>) {
this._scene = DefaultScene.Scene;
this._toolboxBaseNode = new TransformNode("toolbox", this._scene);
@ -145,145 +150,33 @@ export class Toolbox {
this._xr.baseExperience.onStateChangedObservable.add((state) => {
if (state == 2) { // WebXRState.IN_XR
// Create exit XR button
const exitButton = Button.CreateButton("exitXr", "exitXr", this._scene, {});
this._exitXRButton = new ExitXRButton(
this._xr,
this._scene,
this._toolboxBaseNode,
new Vector3(-0.5, -0.35, 0) // Bottom-right
);
// Position button at bottom-right of toolbox, matching handle size and orientation
exitButton.transform.position.x = -0.5; // Right side
exitButton.transform.position.y = -0.35; // Below color grid
exitButton.transform.position.z = 0; // Coplanar with toolbox
exitButton.transform.rotation.y = Math.PI; // Flip 180° on local x-axis to face correctly
exitButton.transform.scaling = new Vector3(.2, .2, .2); // Match handle height
exitButton.transform.parent = this._toolboxBaseNode;
exitButton.onPointerObservable.add((evt) => {
this._logger.debug(evt);
if (evt.sourceEvent.type == 'pointerdown') {
this._xr.baseExperience.exitXRAsync();
}
});
// Create config button next to exit button
// Create config button if diagram menu manager is available
if (this._diagramMenuManager) {
const configButton = Button.CreateButton("config", "config", this._scene, {});
// Position button at bottom-left of toolbox, opposite the exit button
configButton.transform.position.x = 0.5; // Left side
configButton.transform.position.y = -0.35; // Below color grid (same as exit)
configButton.transform.position.z = 0; // Coplanar with toolbox
configButton.transform.rotation.y = Math.PI; // Flip 180° to face correctly
configButton.transform.scaling = new Vector3(.2, .2, .2); // Match exit button size
configButton.transform.parent = this._toolboxBaseNode;
configButton.onPointerObservable.add((evt) => {
this._logger.debug('Config button clicked', evt);
if (evt.sourceEvent.type == 'pointerdown') {
this._diagramMenuManager.toggleVRConfigPanel();
}
});
this._configButton = new ConfigButton(
() => this._diagramMenuManager.toggleVRConfigPanel(),
this._scene,
this._toolboxBaseNode,
new Vector3(0.5, -0.35, 0) // Bottom-left
);
}
// Create rendering mode button that cycles through modes
this.createRenderModeButton();
// Create rendering mode button
this._renderModeButton = new RenderModeButton(
this._scene,
this._toolboxBaseNode,
new Vector3(0, -0.2, 0), // Center below grid
new Vector3(0.4, 0.4, 0.4)
);
}
});
}
private createRenderModeButton() {
const modes = [
RenderingMode.LIGHTMAP_WITH_LIGHTING,
RenderingMode.UNLIT_WITH_EMISSIVE_TEXTURE,
RenderingMode.FLAT_EMISSIVE,
RenderingMode.DIFFUSE_WITH_LIGHTS
];
const currentMode = LightmapGenerator.getRenderingMode();
this._renderModeDisplay = Button.CreateButton(
`Mode: ${RenderingModeLabels[currentMode]}`,
`renderModeButton`,
this._scene,
{
width: 0.5,
height: 0.2,
background: Color3.FromHexString("#333333"),
color: Color3.White(),
fontSize: 240
}
);
// Position below the color grid
this._renderModeDisplay.transform.position.x = 0;
this._renderModeDisplay.transform.position.y = -.2;
this._renderModeDisplay.transform.position.z = 0;
this._renderModeDisplay.transform.rotation.y = Math.PI;
this._renderModeDisplay.transform.scaling = new Vector3(.4, .4, .4);
this._renderModeDisplay.transform.parent = this._toolboxBaseNode;
// Add click handler to cycle through modes
this._renderModeDisplay.onPointerObservable.add((evt) => {
if (evt.sourceEvent.type == 'pointerdown') {
const currentMode = LightmapGenerator.getRenderingMode();
const currentIndex = modes.indexOf(currentMode);
const nextIndex = (currentIndex + 1) % modes.length;
const nextMode = modes[nextIndex];
this._logger.info(`Cycling to rendering mode: ${nextMode}`);
LightmapGenerator.updateAllMaterials(this._scene, nextMode);
// Update button text
this.updateRenderModeButton(nextMode);
}
});
}
private updateRenderModeButton(mode: RenderingMode) {
if (this._renderModeDisplay) {
// Dispose old button and create new one with updated text
this._renderModeDisplay.dispose();
this._renderModeDisplay = Button.CreateButton(
`Mode: ${RenderingModeLabels[mode]}`,
`renderModeButton`,
this._scene,
{
width: 0.5,
height: 0.2,
background: Color3.FromHexString("#333333"),
color: Color3.White(),
fontSize: 240
}
);
this._renderModeDisplay.transform.position.x = 0;
this._renderModeDisplay.transform.position.y = -.2;
this._renderModeDisplay.transform.position.z = 0;
this._renderModeDisplay.transform.rotation.y = Math.PI;
this._renderModeDisplay.transform.scaling = new Vector3(.15, .15, .15);
this._renderModeDisplay.transform.parent = this._toolboxBaseNode;
// Re-attach the click handler
this._renderModeDisplay.onPointerObservable.add((evt) => {
if (evt.sourceEvent.type == 'pointerdown') {
const modes = [
RenderingMode.LIGHTMAP_WITH_LIGHTING,
RenderingMode.UNLIT_WITH_EMISSIVE_TEXTURE,
RenderingMode.FLAT_EMISSIVE,
RenderingMode.DIFFUSE_WITH_LIGHTS
];
const currentMode = LightmapGenerator.getRenderingMode();
const currentIndex = modes.indexOf(currentMode);
const nextIndex = (currentIndex + 1) % modes.length;
const nextMode = modes[nextIndex];
this._logger.info(`Cycling to rendering mode: ${nextMode}`);
LightmapGenerator.updateAllMaterials(this._scene, nextMode);
// Update button text
this.updateRenderModeButton(nextMode);
}
});
}
}
}