Phase 5 Stage D fence: port MoveFenceTool to DragAction (whole-fence move)

Third Stage D affordance port (302 LoC legacy → action + thin wrapper).

The action (`actions/move.ts`) uses the live-drag exception documented
in editor/wiki/architecture/tools.md: visual-only updates via
`sceneRegistry.nodes.get(id).position` + `useLiveTransforms` during
the drag, no scene mutations. Avoids re-rebuilding the fence geometry
(many posts + infill panels) every pointer tick. Commit performs the
single-undo dance — writes final start/end to scene, geometry rebuilds
once, Ctrl-Z reverses the whole drag.

Linked-fence cascade follows the same shape as MoveFenceEndpoint —
any fence in the same parent that shared an endpoint at activation
moves with the drag.

`getRegistryAffordanceTool` extracted to
`tools/shared/affordance-dispatch.ts` so move-tool.tsx and
tool-manager.tsx share the lazy-load helper (no duplicate caches).

MoveTool dispatch gains a generic `affordanceTools.move` check after
the capability-driven `movable` shortcut — fence routes through here;
wall / slab / etc. fall through to their legacy per-kind chain until
their D ports land.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-15 18:19:51 -04:00
co-authored by Claude Opus 4.7
parent 36c48b7fc9
commit c16878c876
6 changed files with 383 additions and 20 deletions
+61
View File
@@ -0,0 +1,61 @@
'use client'
import { type FenceNode, useLiveTransforms } from '@pascal-app/core'
import { CursorSphere, triggerSFX, useDragAction, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { moveFenceDragAction } from './actions/move'
/**
* Phase 5 Stage D — thin React wrapper around `moveFenceDragAction`.
*
* Replaces the legacy `MoveFenceTool` (302 LoC). The action owns all
* the math (snap + linked cascade + live-drag mesh offsets +
* single-undo dance on commit). The wrapper just renders the cursor
* sphere and tracks its position from the live-transform store.
*
* Mounted by ToolManager when `useEditor.movingNode` is a fence
* (capability-driven dispatch — fence has no `movable` capability, so
* the legacy MoveTool's per-kind branch chain falls through to here
* via the new affordance dispatch).
*/
export const FenceMoveTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const fenceId = node.id
const originalCenter: [number, number, number] = [
(node.start[0] + node.end[0]) / 2,
0,
(node.start[1] + node.end[1]) / 2,
]
// Live position from the live-transforms store — the action writes
// here every preview tick (live-drag exception). Falls back to the
// original center until the first move.
const liveCenter = useLiveTransforms((s) => {
const t = s.get(fenceId)
return t?.position ?? originalCenter
})
const exitMoveMode = (committed: boolean) => {
if (committed) triggerSFX('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [fenceId] })
useEditor.getState().setMovingNode(null)
}
useDragAction({
active: true,
action: moveFenceDragAction,
initial: {
node,
point: [originalCenter[0], originalCenter[2]],
},
onCommit: () => exitMoveMode(true),
onCancel: () => exitMoveMode(false),
})
return (
<group>
<CursorSphere position={liveCenter as [number, number, number]} showTooltip={false} />
</group>
)
}
export default FenceMoveTool