# Implementing Custom Editor Tools in Pascal Editor: A Complete Developer Guide

> Learn to implement custom editor tools in Pascal Editor using React components and the grid event bus. Enhance your development workflow with custom drawing utilities.

- Repository: [Pascal/editor](https://github.com/pascalorg/editor)
- Tags: how-to-guide
- Published: 2026-03-25

---

**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`](https://github.com/pascalorg/editor/blob/main/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`](https://github.com/pascalorg/editor/blob/main/packages/editor/src/store/use-editor.tsx):

```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:

1. **`useEditor`** ([`packages/editor/src/store/use-editor.tsx`](https://github.com/pascalorg/editor/blob/main/packages/editor/src/store/use-editor.tsx)): Holds UI state including `phase`, `mode`, `movingNode`, and `editingHole`. Access it to determine the current editing context.

2. **`useScene`** ([`packages/core/src/store/use-scene.ts`](https://github.com/pascalorg/editor/blob/main/packages/core/src/store/use-scene.ts)): The authoritative scene graph containing `nodes: Record<AnyNodeId, AnyNode>`. Use this to read existing geometry or create persistent nodes via core helpers like `createWallOnCurrentLevel`.

3. **`useViewer`** ([`packages/viewer/src/store/use-viewer.ts`](https://github.com/pascalorg/editor/blob/main/packages/viewer/src/store/use-viewer.ts)): Exposes selection state including `selectedIds`, `selection.zoneId`, and `selection.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`](https://github.com/pascalorg/editor/blob/main/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:

```tsx
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`](https://github.com/pascalorg/editor/blob/main/packages/editor/src/components/tools/measure/measure-tool.tsx). Import the required utilities:

```tsx
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:

```tsx
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):

```tsx
  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:

```tsx
  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`](https://github.com/pascalorg/editor/blob/main/packages/editor/src/components/tools/tool-manager.tsx) and add it to the `tools` map under the appropriate phase (typically `structure` for construction tools):

```tsx
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.

```tsx
// 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 `ToolManager` renders tool components registered in a map keyed by `Phase` and `Tool` type.
- **State Management**: Tools read from `useEditor` (UI state), `useViewer` (selection), and `useScene` (scene graph), mutating the latter via core helpers for undo/redo support.
- **Event Handling**: Tools subscribe to `grid:move`, `grid:click`, and `tool:cancel` via the global `emitter` from `@pascal-app/core`, cleaning up listeners in the effect cleanup function.
- **Preview Rendering**: Use `EDITOR_LAYER` and `renderOrder={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.tsx`](https://github.com/pascalorg/editor/blob/main/packages/editor/src/components/tools/tool-manager.tsx) and add it to the `tools` record 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`](https://github.com/pascalorg/editor/blob/main/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.