How the PlayCanvas Supersplat Tool System Architecture Works: ToolManager and Individual Tools
Supersplat's editing UI uses a lightweight event-driven architecture centered on a ToolManager that maintains exclusive tool activation through a central Events bus, with each tool implementing simple activate/deactivate lifecycle methods.
The playcanvas/supersplat repository implements a modular tool system for 3D Gaussian Splat editing that decouples interaction logic from rendering. This architecture relies on a central event bus to coordinate between the ToolManager and individual tools, enabling seamless switching between transform gizmos, selection brushes, and measurement utilities. Understanding this PlayCanvas Supersplat tool system architecture is essential for extending the editor with custom interaction modes.
Core Event-Driven Architecture
At the foundation lies the Events class (src/events.ts), a thin wrapper around PlayCanvas' EventHandler. This class enables any module to fire named events and expose synchronous "functions" that other modules can invoke. Unlike direct method calls, this event bus allows tools to remain completely decoupled from each other and from the UI layer.
The architecture follows a pub-sub pattern where:
- Publishers fire events like
tool.moveorselection.changed - Subscribers listen for these events to update state or trigger actions
- Functions provide synchronous query capabilities such as
tool.activeortool.coordSpace
ToolManager Implementation
The ToolManager class (src/tools/tool-manager.ts) serves as the central coordinator for tool lifecycle management. It maintains an internal registry of tool instances and enforces exclusive activation—only one tool can be active at any time.
Registration and Activation Flow
When a tool registers with the manager, the system wires an event listener of the form tool.<name> that calls ToolManager.activate(name). The activation sequence follows a strict protocol:
- Deactivate the current tool (if any) by calling its
deactivate()method and broadcastingtool.<old>.deactivatedandtool.deactivated - Activate the new tool by storing its name, invoking its
activate()method, and emittingtool.<new>.activatedandtool.activated
This ensures clean state transitions where tools properly release resources (event listeners, SVG overlays, gizmos) before the next tool initializes.
Coordinate Space Management
The manager tracks the current coordinate space ('local' or 'world') and exposes it via the synchronous function tool.coordSpace. Tools can change this setting through tool.setCoordSpace or toggle it with tool.toggleCoordSpace. When the space changes, the manager emits tool.coordSpace, allowing transform tools to reattach their gizmos accordingly.
Individual Tool Implementation
All tools conform to a minimal interface requiring only activate(): void and deactivate(): void methods. They receive the global Events instance during instantiation, typically occurring in src/main.ts.
Transform-Based Tools
The MoveTool, RotateTool, and ScaleTool extend the abstract TransformTool class (src/tools/transform-tool.ts). This base class manages:
- Gizmo creation: Instantiates
TransformGizmoand a hiddenpivotentity that follows the current selection - Event forwarding: Listens to gizmo events (
transform:start,transform:move,transform:end) and forwards them to thePivotsystem - Coordinate space adaptation: Reattaches the gizmo when
tool.coordSpacechanges or when selection changes occur - Screen-space sizing: Adjusts gizmo size on
camera.resizeandcamera.orthoevents
When activated, the tool sets active = true, registers listeners for pivot placement and selection changes, and calls reattach() to display the gizmo. Deactivation reverses these steps, hiding the gizmo and removing listeners.
Selection Tools
Tools like RectSelection, BrushSelection, LassoSelection, FloodSelection, PolygonSelection, SphereSelection, BoxSelection, and EyedropperSelection implement a consistent pattern using SVG overlays or canvas masks:
this.activate = () => {
svg.classList.remove('hidden');
parent.style.display = 'block';
parent.addEventListener('pointerdown', this.handlePointerDown);
// Additional pointer event listeners...
};
this.deactivate = () => {
svg.classList.add('hidden');
parent.style.display = 'none';
parent.removeEventListener('pointerdown', this.handlePointerDown);
// Cleanup...
};
These tools capture pointer events to draw temporary shapes, then fire select.byMask with operations (add, remove, or set) upon completion.
Measure Tool
The MeasureTool (src/tools/measure-tool.ts) demonstrates a complex workflow combining multiple subsystems:
- Visual elements: Creates SVG lines and end-caps for visual feedback
- Gizmo integration: Uses a
TranslateGizmoattached to a temporary pivot entity for dragging measurement points - UI synchronization: Displays length via
LabelandNumericInputcomponents, updating onpivot.movedand UIchangeevents - Lifecycle management: Registers under the key
'measure'and cleans up all visual elements on deactivation
System Integration and Event Flow
The complete integration flow in src/main.ts illustrates how these components connect:
- Initialization: Creates a single
Eventsinstance shared across the application - Manager creation: Instantiates
ToolManagerwith the events bus - Tool registration: Constructs each tool (passing
Eventsand dependencies likeSceneor mask canvas) and registers them viatoolManager.register(name, instance) - UI binding: UI buttons fire
tool.<name>events when clicked - Activation:
ToolManager.activate(name)handles the transition, ensuring exclusive access - Forced deactivation: The
tool.deactivateevent activates "null", cleaning up the previous tool
This design keeps the rendering engine decoupled from interaction logic, allowing tools to inject temporary UI elements (SVG overlays) and 3D gizmos only while active.
Practical Implementation Examples
Creating and Registering the ToolManager
import { Events } from './events';
import { ToolManager } from './tools/tool-manager';
import { MoveTool } from './tools/move-tool';
// Application initialization
const events = new Events();
const toolManager = new ToolManager(events);
// Register tools
toolManager.register('move', new MoveTool(events, scene));
// UI activation trigger
document.getElementById('moveBtn')!.onclick = () => {
events.fire('tool.move');
};
// Global deactivation
events.fire('tool.deactivate');
Source: src/main.ts (lines 23-36) for registration patterns; src/tools/tool-manager.ts for manager implementation.
Minimal Custom Tool Template
import { Events } from '../events';
export class CustomTool {
activate!: () => void;
deactivate!: () => void;
constructor(events: Events) {
this.activate = () => {
console.log('Custom tool activated');
// Setup UI, listeners, gizmos...
};
this.deactivate = () => {
console.log('Custom tool deactivated');
// Cleanup...
};
}
}
// Registration
toolManager.register('custom', new CustomTool(events));
Source pattern: See src/tools/move-tool.ts or src/tools/lasso-selection.ts for complete implementations.
Monitoring Tool State Changes
// Listen for activation events
events.on('tool.activated', (toolName: string) => {
console.log('Active tool:', toolName);
// Update UI state, shortcuts...
});
// Query current tool synchronously
const currentTool = events.function('tool.active');
const coordSpace = events.function('tool.coordSpace');
Source: src/tools/tool-manager.ts (lines 81-83) for event emission logic.
Summary
- Event-Driven Core: The
Eventsclass insrc/events.tsprovides the central bus enabling loose coupling between tools and UI components. - Exclusive Activation:
ToolManagerinsrc/tools/tool-manager.tsenforces single-tool-active semantics with properactivate/deactivatelifecycle management. - Transform Tools: Extend
TransformTool(src/tools/transform-tool.ts) to inherit gizmo management, pivot following, and coordinate space adaptation. - Selection Tools: Implement mask-based interaction using SVG overlays or canvas elements, firing
select.byMaskon completion. - Coordinate Space: The manager tracks
localvsworldspace and notifies tools via thetool.coordSpaceevent. - Easy Extension: New tools require only the two-method interface and registration in
src/main.ts, following patterns established byMeasureToolandLassoSelection.
Frequently Asked Questions
How does ToolManager prevent multiple tools from being active simultaneously?
The ToolManager.activate() method in src/tools/tool-manager.ts explicitly calls deactivate() on the current tool before activating the requested one. It stores the active tool name in an internal variable and broadcasts tool.deactivated before emitting tool.activated, ensuring that only one tool holds active resources (gizmos, event listeners, SVG elements) at any time.
What is the difference between tool events and tool functions in the Events system?
Events (like tool.move or tool.activated) are asynchronous broadcasts that trigger actions, while functions (like tool.active or tool.coordSpace) are synchronous queries that return immediate values. The Events class wraps PlayCanvas' EventHandler to support both patterns, allowing the ToolManager to expose read-only state queries that tools and UI components can invoke directly.
How do transform tools update when the coordinate space changes?
The TransformTool base class listens for the tool.coordSpace event emitted by ToolManager. When this fires, the tool calls its reattach() method, which updates the gizmo's transform to match either local or world space coordinates. This happens automatically without requiring manual reactivation of the tool.
Can I create a tool that doesn't use gizmos or SVG overlays?
Yes. The minimal interface only requires activate() and deactivate() methods. A tool could manipulate the scene directly, modify selection logic, or interact with other subsystems without creating visual overlays. The MeasureTool demonstrates mixed approaches (SVG + gizmo), while selection tools show canvas/SVG patterns, but abstract tools that only process data or modify scene state are equally valid within this architecture.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →