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:

  1. useEditor (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): 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): 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. 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 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 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. 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →