Files
editor/wiki/architecture/tools.md
T
Wassim SAMADandClaude Opus 4.7 c3e255b58a wiki(tools): clarify useLiveTransforms.position semantics for polygon kinds
Two regressions in the Stage D port surfaced the same gap: the wiki
already said "the mesh offset must mirror the useLiveTransforms entry"
but didn't spell out that for polygon-based kinds (slab / fence /
ceiling / wall), the value to mirror is the DELTA (the group's local
position) — not the world location of the polygon's center.

Expanded the live-drag rule with a position-based vs polygon-based
split and an explicit note that ParametricNodeRenderer reconciles the
group's position via React on every Zustand notification, so the two
writes must agree to the exact value or they fight every frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:47:39 -04:00

5.0 KiB

Tools

Editor tools structure in apps/editor.

Applies to: apps/editor/components/tools/**.

Tools are React components that capture user input (pointer, keyboard) and translate it into useScene mutations. They live exclusively in apps/editor/components/tools/.

Lifecycle

ToolManager reads useEditor (phase + mode + tool) and mounts the active tool component. When the tool changes, the old component unmounts, cleaning up any transient state.

See apps/editor/components/tools/tool-manager.tsx.

Tool Categories by Phase

Site

  • site-boundary-editor — draw/edit property boundary polygon

Structure

  • wall-tool — draw walls segment by segment
  • slab-tool + slab-boundary-editor + slab-hole-editor
  • ceiling-tool + ceiling-boundary-editor + ceiling-hole-editor
  • roof-tool
  • door-tool + door-move-tool
  • window-tool + window-move-tool
  • item-tool + item-move-tool
  • zone-tool + zone-boundary-editor

Furnish

  • item-tool — place furniture

Shared utilities

  • polygon-editor — reusable boundary/hole editing logic
  • cursor-sphere — 3D cursor visualisation

Pattern

// apps/editor/components/tools/my-tool/index.tsx
import { useScene } from '@pascal-app/core'
import { useEditor } from '../../store/use-editor'

export function MyTool() {
  const createNode = useScene(s => s.createNode)
  const setTool = useEditor(s => s.setTool)

  // Pointer handlers mutate the scene store directly.
  // No local geometry — use a renderer for any preview mesh.

  return (
    <mesh onPointerDown={handleDown} onPointerMove={handleMove}>
      {/* ghost / preview geometry only */}
    </mesh>
  )
}

Rules

  • Tools mutate useScene for committed changes and useLiveTransforms for ephemeral drag state. A tool's end-of-interaction write (click-to-commit, release-to-commit) goes to useScene and is captured in undo history. Per-mouse-move previews go to useLiveTransforms so history and subscribers aren't spammed.
  • Live-drag exception for direct mesh transforms. During an active drag a tool may apply a transform offset directly to sceneRegistry.nodes.get(id).position/rotation/scale when and only when the same offset is mirrored into useLiveTransforms for that node. This exception exists because the 3D renderers don't reconcile useLiveTransforms onto mesh.position yet; once a LiveTransformSystem does that, this exception goes away. Conditions:
    • The mesh offset must mirror the useLiveTransforms entry (same exact value, not "same conceptual translation"), so anything reading useLiveTransforms sees the same preview as the 3D view. ParametricNodeRenderer (used for every kind that ships def.geometry) binds <group position={liveTransform.position}> via React — every Zustand notification re-renders and reconciles the group's position back to whatever value useLiveTransforms holds. If mesh.position.set(delta) and useLiveTransforms.set({ position: someOtherValue }) disagree, the two writes fight every frame and the user sees jitter during the drag.
    • For position-based kinds (spawn / item / column): the position field on the node IS the group's local-frame position, so useLiveTransforms.position should hold the live world position of the node (matches the eventual scene.update).
    • For polygon-based kinds (slab / fence / ceiling / wall): the node has no position field — the canonical group position is [0,0,0] with geometry built in level-local coords. useLiveTransforms.position must hold the delta the tool wants to translate by ([deltaX, 0, deltaZ]), not the world location of the polygon's center. The cursor sphere position (which IS the translated polygon center) is tracked separately via React useState, not useLiveTransforms.
    • The offset must be cleared on tool unmount, cancel, and commit — both mesh.position.set(0, 0, 0) and useLiveTransforms.clear(id).
    • The tool must not generate or mutate geometry in this path — only transform writes. Geometry generation still belongs in a core system.
  • No business logic in tools — delegate geometry/constraint rules to core systems.
  • Preview geometry is local — transient meshes shown while a tool is active live in the tool component, not in the scene store.
  • Clean up on unmount — remove any pending/incomplete nodes and any live transforms/mesh offsets when the tool unmounts.
  • Tools must not import from @pascal-app/viewer — use the scene store and core hooks only. sceneRegistry is exported from @pascal-app/core and is the allowed door into the Three.js graph for the narrow purposes above.
  • Each tool should handle a single, well-scoped interaction. Split complex tools (e.g. "draw + move") into separate components selected by useEditor.

Adding a New Tool

  1. Create apps/editor/components/tools/<name>/index.tsx.
  2. Register the tool in ToolManager under the correct phase and mode.
  3. Add the tool identifier to the useEditor tool union type.
  4. If the tool requires new node types, add schema + renderer + system first.