Implementing Custom Editor Tools in Pascal Editor: A Complete Developer Guide
Pascal Editor's modular tool framework lets you add custom drawing utilities by creating React components that subscribe to the grid event bus and interact with the core scene store via hooks like useScene and useViewer.
Pascal Editor is an open-source architectural design application built with React and Three.js. Implementing custom editor tools in Pascal Editor allows developers to extend the editor's functionality—such as adding measurement utilities or specialized wall cutouts—without modifying the core renderer, leveraging a clean separation between UI state and the Zundo-enabled scene graph.
Understanding the Tool Architecture
Pascal Editor's tool system is orchestrated by ToolManager in packages/editor/src/components/tools/tool-manager.tsx. This central dispatcher renders the active tool component based on the current phase (site, structure, furnish) and mode (select, edit, build). Tools are React components that communicate via a global event bus and read or mutate the scene through typed Zustand stores.
Phase and Mode Constraints
Tools are only active for specific phases and modes they are registered under. The editor store tracks this state in packages/editor/src/store/use-editor.tsx:
type Phase = 'site' | 'structure' | 'furnish';
type Mode = 'select' | 'edit' | 'build';
When a user selects a tool, the state updates via useEditor.setState({ tool: 'measure', mode: 'build' }), causing ToolManager to resolve and render the corresponding component from its tools map.
The Store Layer
Custom tools interact with three primary stores:
-
useEditor(packages/editor/src/store/use-editor.tsx): Holds UI state includingphase,mode,movingNode, andeditingHole. Access it to determine the current editing context. -
useScene(packages/core/src/store/use-scene.ts): The authoritative scene graph containingnodes: Record<AnyNodeId, AnyNode>. Use this to read existing geometry or create persistent nodes via core helpers likecreateWallOnCurrentLevel. -
useViewer(packages/viewer/src/store/use-viewer.ts): Exposes selection state includingselectedIds,selection.zoneId, andselection.levelId. Tools use this to identify which node the user is currently targeting.
The Grid Event Bus
Tools subscribe to user input through the global emitter from packages/core/src/events/emitter.ts. The bus publishes UI-level events that tools consume:
grid:move: Fired when the cursor moves across the grid; provides current world position.grid:click: Fired on grid intersection clicks; triggers tool actions.tool:cancel: Fired when the user aborts the current operation (e.g., pressing Escape).
Tools register listeners in a useEffect hook and must clean up on unmount to prevent memory leaks.
ToolManager Dispatch
The ToolManager component maintains a tools map that registers every tool under its phase:
const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
site: { /* ... */ },
structure: {
wall: WallTool,
measure: MeasureTool, // Custom tool registration
},
furnish: { /* ... */ },
};
When the tool state changes, ToolManager resolves tools[phase][tool] and renders the component, which then mounts and activates its event subscriptions.
Step-by-Step: Building the MeasureTool
The following workflow demonstrates implementing a MeasureTool that calculates the distance between two clicked points. This pattern applies to any custom tool requiring draft states and preview geometry.
1. Scaffold the Component
Create the component at packages/editor/src/components/tools/measure/measure-tool.tsx. Import the required utilities:
import { emitter, GridEvent, useScene } from '@pascal-app/core';
import { useViewer } from '@pascal-app/viewer';
import { useEffect, useRef } from 'react';
import { DoubleSide, Group, Line, BufferGeometry, LineBasicMaterial, Vector3 } from 'three';
import { EDITOR_LAYER } from '../../../lib/constants';
import { CursorSphere } from '../shared/cursor-sphere';
2. Manage Draft State with Refs
Use refs to track the tool's internal state without triggering React re-renders during rapid mouse movements:
export const MeasureTool: React.FC = () => {
const lineRef = useRef<Line>(null!);
const start = useRef<Vector3>(new Vector3());
const end = useRef<Vector3>(new Vector3());
const drawing = useRef(false);
const updateLine = () => {
const geometry = new BufferGeometry().setFromPoints([start.current, end.current]);
lineRef.current.geometry.dispose();
lineRef.current.geometry = geometry;
};
3. Subscribe to Grid Events
Register listeners for grid:click and grid:move to handle the two-phase interaction (setting start point, then end point):
useEffect(() => {
const onClick = (e: GridEvent) => {
const pt = new Vector3(e.position[0], e.position[1], e.position[2]);
if (!drawing.current) {
start.current.copy(pt);
end.current.copy(pt);
drawing.current = true;
lineRef.current.visible = true;
} else {
end.current.copy(pt);
drawing.current = false;
console.log('Distance:', start.current.distanceTo(end.current));
}
updateLine();
};
const onMove = (e: GridEvent) => {
if (!drawing.current) return;
end.current.set(e.position[0], e.position[1], e.position[2]);
updateLine();
};
const onCancel = () => {
drawing.current = false;
lineRef.current.visible = false;
};
emitter.on('grid:click', onClick);
emitter.on('grid:move', onMove);
emitter.on('tool:cancel', onCancel);
return () => {
emitter.off('grid:click', onClick);
emitter.off('grid:move', onMove);
emitter.off('tool:cancel', onCancel);
};
}, []);
4. Render Preview Geometry
Return the JSX containing the cursor helper and the line mesh. Set layers={EDITOR_LAYER} and renderOrder={1} to ensure the preview appears above the scene geometry but below UI overlays:
return (
<group>
<CursorSphere />
<line
layers={EDITOR_LAYER}
ref={lineRef}
visible={false}
renderOrder={1}
>
<bufferGeometry />
<lineBasicMaterial color="#fbbf24" linewidth={2} depthTest={false} depthWrite={false} />
</line>
</group>
);
};
5. Register the Tool
Import the component into packages/editor/src/components/tools/tool-manager.tsx and add it to the tools map under the appropriate phase (typically structure for construction tools):
import { MeasureTool } from './measure/measure-tool';
const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
site: { /* ... */ },
structure: {
wall: WallTool,
measure: MeasureTool, // New tool registered
},
furnish: { /* ... */ },
};
Once registered, calling useEditor.setState({ tool: 'measure', mode: 'build' }) activates the tool.
Pattern Variation: Mutating Existing Nodes
Some tools modify existing scene nodes rather than creating new ones. The CutoutTool example demonstrates adding rectangular openings to walls by using a similar event structure but calling a core helper to mutate the target node.
// packages/editor/src/components/tools/wall/cutout-tool.tsx
import { emitter, GridEvent } from '@pascal-app/core';
import { useEffect, useRef } from 'react';
import { Mesh, Shape, ShapeGeometry, Vector3 } from 'three';
import { EDITOR_LAYER } from '../../../lib/constants';
import { CursorSphere } from '../shared/cursor-sphere';
import { createWallCutout } from './wall-cutout-helpers';
export const CutoutTool: React.FC = () => {
const previewRef = useRef<Mesh>(null!);
const start = useRef<Vector3>(new Vector3());
const updatePreview = (end: Vector3) => {
const shape = new Shape();
shape.moveTo(start.current.x, start.current.z);
shape.lineTo(end.x, start.current.z);
shape.lineTo(end.x, end.z);
shape.lineTo(start.current.x, end.z);
shape.closePath();
const geometry = new ShapeGeometry(shape);
previewRef.current.geometry.dispose();
previewRef.current.geometry = geometry;
previewRef.current.visible = true;
};
useEffect(() => {
const onClick = (e: GridEvent) => {
const pt = new Vector3(e.position[0], e.position[1], e.position[2]);
if (!previewRef.current.visible) {
start.current.copy(pt);
previewRef.current.visible = false;
} else {
createWallCutout(start.current, pt);
previewRef.current.visible = false;
}
};
const onMove = (e: GridEvent) => {
if (!previewRef.current.visible) return;
const pt = new Vector3(e.position[0], e.position[1], e.position[2]);
updatePreview(pt);
};
emitter.on('grid:click', onClick);
emitter.on('grid:move', onMove);
return () => {
emitter.off('grid:click', onClick);
emitter.off('grid:move', onMove);
};
}, []);
return (
<group>
<CursorSphere />
<mesh
layers={EDITOR_LAYER}
ref={previewRef}
visible={false}
renderOrder={1}
rotation-x={-Math.PI / 2}
>
<meshBasicMaterial color="#ef4444" opacity={0.3} transparent depthTest={false} />
</mesh>
</group>
);
};
Notice the pattern remains consistent: subscribe to events, manage draft state with refs, update preview geometry on grid:move, and commit changes via a core helper function (createWallCutout) on the second click.
Summary
- Tool Architecture: Pascal Editor uses a phase-based dispatch system where
ToolManagerrenders tool components registered in a map keyed byPhaseandTooltype. - State Management: Tools read from
useEditor(UI state),useViewer(selection), anduseScene(scene graph), mutating the latter via core helpers for undo/redo support. - Event Handling: Tools subscribe to
grid:move,grid:click, andtool:cancelvia the globalemitterfrom@pascal-app/core, cleaning up listeners in the effect cleanup function. - Preview Rendering: Use
EDITOR_LAYERandrenderOrder={1}on Three.js meshes to ensure previews float above the scene geometry without depth testing. - Registration: Import the component into
packages/editor/src/components/tools/tool-manager.tsxand add it to thetoolsrecord under the appropriate phase.
Frequently Asked Questions
How do I access the currently selected node from within my custom tool?
Import useViewer from @pascal-app/viewer and select the selection slice. For example, const selection = useViewer((s) => s.selection) provides selectedIds, zoneId, and levelId, which indicate which node the user has highlighted or selected in the hierarchy.
Can a single tool be used across multiple phases like site and structure?
Yes, but you must register the component in each phase's tool map separately in ToolManager. The tool component itself can inspect useEditor((s) => s.phase) to conditionally alter behavior, though the framework is designed to keep phase-specific logic separate.
What is the correct way to clean up event listeners when a tool unmounts?
Always return a cleanup function from the useEffect that registers listeners. Call emitter.off('grid:click', onClick) and emitter.off('grid:move', onMove) for each subscribed event. Failure to do this causes memory leaks and duplicate event handlers when switching tools.
How do I make my tool's changes undoable?
Mutate the scene only through core store helpers or actions defined in packages/core/src/store/use-scene.ts. The store is wrapped with Zundo middleware; any state change made via the official API automatically creates an undo history entry. Avoid direct mutation of the nodes object outside the store's setter functions.
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 →