Phase 5 Stage D: fence curve affordance → registry DragAction

First Stage D port — `CurveFenceTool` (178 LoC legacy) split into a pure
`DragAction` primitive (`packages/nodes/src/fence/actions/curve.ts`) plus
a thin React wrapper (`packages/nodes/src/fence/curve-tool.tsx`) that
feeds it through `useDragAction`. The kind declares the affordance via
`def.affordanceTools.curve`; ToolManager lazy-loads it at runtime when
`useEditor.curvingFence` activates. Falls back to the legacy
`CurveFenceTool` for any kind that hasn't been ported.

The lazy-load dispatch dodges the editor→nodes circular dep (nodes
already depends on editor for `useDragAction` + `CursorSphere`).

Establishes the pattern for the remaining fence affordances
(`MoveFenceEndpoint`, `MoveFence`, placement) and for slab/ceiling/wall
D ports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-15 17:18:30 -04:00
co-authored by Claude Opus 4.7
parent 95645d8e37
commit 8ca9686b27
6 changed files with 249 additions and 1 deletions
+73
View File
@@ -0,0 +1,73 @@
'use client'
import { type FenceNode, getWallMidpointHandlePoint, useScene } from '@pascal-app/core'
import { CursorSphere, useDragAction, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useState } from 'react'
import { curveFenceDragAction } from './actions/curve'
/**
* Phase 5 Stage D — thin React wrapper around `curveFenceDragAction`.
*
* Replaces the legacy `CurveFenceTool` (editor/tools/fence/curve-fence-
* tool.tsx). Same UX: a cursor sphere follows the chord-perpendicular
* projection of the pointer, dragging the fence's `curveOffset` live;
* grid:click commits, Esc cancels.
*
* All the lifecycle (history pause/resume, grid:move → preview, snap,
* apply, grid:click → commit, Esc → cancel, unmount cleanup) is owned
* by `useDragAction`. This component only renders the cursor sphere
* and tracks its level-local position to mirror the active curveOffset
* for visual feedback.
*
* Mounted by the legacy ToolManager via the same `curvingFence` editor
* state (drop-in replacement for the old CurveFenceTool import).
*/
export const FenceCurveTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const initialHandle = getWallMidpointHandlePoint(node)
const [cursorPos, setCursorPos] = useState<[number, number, number]>([
initialHandle.x,
0,
initialHandle.y,
])
const exitCurveMode = () => {
useViewer.getState().setSelection({ selectedIds: [node.id] })
useEditor.getState().setCurvingFence(null)
}
useDragAction({
active: true,
action: curveFenceDragAction,
initial: {
node,
// Initial point — useDragAction requires a Vec2; the action's begin
// reads everything it needs from input.node, so this is just a
// placeholder until the first grid:move fires.
point: [initialHandle.x, initialHandle.y],
},
onCommit: exitCurveMode,
onCancel: exitCurveMode,
})
// Mirror the active curveOffset back into the cursor position. The
// useDragAction loop's apply() writes curveOffset onto the node; we
// subscribe to that field and recompute the handle point.
const liveCurveOffset = useScene((s) => {
const live = s.nodes[node.id]
return live?.type === 'fence' ? ((live as FenceNode).curveOffset ?? 0) : 0
})
useEffect(() => {
const handlePoint = getWallMidpointHandlePoint({ ...node, curveOffset: liveCurveOffset })
setCursorPos([handlePoint.x, 0, handlePoint.y])
}, [liveCurveOffset, node])
return (
<group>
<CursorSphere position={cursorPos} showTooltip={false} />
</group>
)
}
export default FenceCurveTool