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>
5.0 KiB
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 segmentslab-tool+slab-boundary-editor+slab-hole-editorceiling-tool+ceiling-boundary-editor+ceiling-hole-editorroof-tooldoor-tool+door-move-toolwindow-tool+window-move-toolitem-tool+item-move-toolzone-tool+zone-boundary-editor
Furnish
item-tool— place furniture
Shared utilities
polygon-editor— reusable boundary/hole editing logiccursor-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
useScenefor committed changes anduseLiveTransformsfor ephemeral drag state. A tool's end-of-interaction write (click-to-commit, release-to-commit) goes touseSceneand is captured in undo history. Per-mouse-move previews go touseLiveTransformsso 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/scalewhen and only when the same offset is mirrored intouseLiveTransformsfor that node. This exception exists because the 3D renderers don't reconcileuseLiveTransformsontomesh.positionyet; once aLiveTransformSystemdoes that, this exception goes away. Conditions:- The mesh offset must mirror the
useLiveTransformsentry (same exact value, not "same conceptual translation"), so anything readinguseLiveTransformssees the same preview as the 3D view.ParametricNodeRenderer(used for every kind that shipsdef.geometry) binds<group position={liveTransform.position}>via React — every Zustand notification re-renders and reconciles the group's position back to whatever valueuseLiveTransformsholds. Ifmesh.position.set(delta)anduseLiveTransforms.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
positionfield on the node IS the group's local-frame position, souseLiveTransforms.positionshould hold the live world position of the node (matches the eventualscene.update). - For polygon-based kinds (slab / fence / ceiling / wall): the node has no
positionfield — the canonical group position is[0,0,0]with geometry built in level-local coords.useLiveTransforms.positionmust 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 ReactuseState, notuseLiveTransforms. - The offset must be cleared on tool unmount, cancel, and commit — both
mesh.position.set(0, 0, 0)anduseLiveTransforms.clear(id). - The tool must not generate or mutate geometry in this path — only transform writes. Geometry generation still belongs in a core system.
- The mesh offset must mirror the
- 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.sceneRegistryis exported from@pascal-app/coreand 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
- Create
apps/editor/components/tools/<name>/index.tsx. - Register the tool in
ToolManagerunder the correct phase and mode. - Add the tool identifier to the
useEditortool union type. - If the tool requires new node types, add schema + renderer + system first.