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
+270
View File
@@ -0,0 +1,270 @@
import {
type AnyNode,
type AnyNodeId,
type DragAction,
type FenceNode,
type LevelNode,
sceneRegistry,
useLiveTransforms,
useScene,
type WallNode,
} from '@pascal-app/core'
import { type FencePlanPoint, snapFenceDraftPoint } from '@pascal-app/editor'
import type * as THREE from 'three'
/**
* Phase 5 Stage D — whole-fence move drag affordance.
*
* Migrates `MoveFenceTool` (302 LoC legacy) to the `DragAction` primitive.
*
* Visual strategy — **live-drag exception** (see
* `editor/wiki/architecture/tools.md`): instead of writing the new
* start/end into the scene store on every pointer tick (which would
* re-rebuild the fence geometry — many posts, many infill panels —
* every frame), the action keeps the underlying node untouched during
* the drag and visually offsets the mesh directly via
* `sceneRegistry.nodes.get(id).position` plus a mirror entry in
* `useLiveTransforms`. On commit, the final start/end are written to
* the scene with the single-undo dance — one Ctrl-Z reverses the whole
* drag, the geometry rebuilds once.
*
* Linked-fence cascade: any other fence in the same parent whose
* start or end matched one of this fence's endpoints at activation
* follows the move so corners stay connected. No alt-detach (legacy
* doesn't expose it for the whole-fence drag).
*/
function samePoint(a: FencePlanPoint, b: FencePlanPoint): boolean {
return a[0] === b[0] && a[1] === b[1]
}
type LinkedFenceSnapshot = {
id: FenceNode['id']
start: FencePlanPoint
end: FencePlanPoint
}
function snapshotLinked(args: {
fenceId: FenceNode['id']
parentId: string | null
originalStart: FencePlanPoint
originalEnd: FencePlanPoint
}): LinkedFenceSnapshot[] {
const { fenceId, parentId, originalStart, originalEnd } = args
const { nodes } = useScene.getState()
const out: LinkedFenceSnapshot[] = []
for (const node of Object.values(nodes)) {
if (!node || node.type !== 'fence') continue
if (node.id === fenceId) continue
if ((node.parentId ?? null) !== parentId) continue
if (
!(
samePoint(node.start, originalStart) ||
samePoint(node.start, originalEnd) ||
samePoint(node.end, originalStart) ||
samePoint(node.end, originalEnd)
)
)
continue
out.push({
id: node.id,
start: [node.start[0], node.start[1]],
end: [node.end[0], node.end[1]],
})
}
return out
}
function linkedCascade(
linked: LinkedFenceSnapshot[],
originalStart: FencePlanPoint,
originalEnd: FencePlanPoint,
nextStart: FencePlanPoint,
nextEnd: FencePlanPoint,
): LinkedFenceSnapshot[] {
return linked.map((l) => ({
id: l.id,
start: samePoint(l.start, originalStart)
? nextStart
: samePoint(l.start, originalEnd)
? nextEnd
: l.start,
end: samePoint(l.end, originalStart)
? nextStart
: samePoint(l.end, originalEnd)
? nextEnd
: l.end,
}))
}
function setMeshOffset(fenceId: AnyNodeId, deltaX: number, deltaZ: number): void {
const mesh = sceneRegistry.nodes.get(fenceId) as THREE.Object3D | undefined
if (mesh) mesh.position.set(deltaX, 0, deltaZ)
}
function setLiveTransform(
fenceId: AnyNodeId,
originalStart: FencePlanPoint,
originalEnd: FencePlanPoint,
deltaX: number,
deltaZ: number,
): void {
const cx = (originalStart[0] + originalEnd[0]) / 2
const cz = (originalStart[1] + originalEnd[1]) / 2
useLiveTransforms.getState().set(fenceId, {
position: [cx + deltaX, 0, cz + deltaZ],
rotation: 0,
})
}
function clearLiveState(fenceId: AnyNodeId, linked: LinkedFenceSnapshot[]): void {
setMeshOffset(fenceId, 0, 0)
useLiveTransforms.getState().clear(fenceId)
for (const l of linked) {
setMeshOffset(l.id as AnyNodeId, 0, 0)
useLiveTransforms.getState().clear(l.id)
}
}
export type MoveFenceCtx = {
fenceId: AnyNodeId
originalStart: FencePlanPoint
originalEnd: FencePlanPoint
parentId: string | null
linkedOriginals: LinkedFenceSnapshot[]
levelWalls: WallNode[]
levelFences: FenceNode[]
// Mutable: latched on the first preview call to the snapped pointer
// position. Subsequent previews compute delta = pointer - dragAnchor.
dragAnchor: FencePlanPoint | null
}
export type MoveFenceDraft = {
start: FencePlanPoint
end: FencePlanPoint
deltaX: number
deltaZ: number
linkedUpdates: LinkedFenceSnapshot[]
}
export const moveFenceDragAction: DragAction<MoveFenceCtx, MoveFenceDraft> = {
begin: (input) => {
const fence = input.node as FenceNode | undefined
if (!fence) throw new Error('[moveFenceDragAction] begin requires a fence node')
const parentId = fence.parentId ?? null
const originalStart: FencePlanPoint = [fence.start[0], fence.start[1]]
const originalEnd: FencePlanPoint = [fence.end[0], fence.end[1]]
const { nodes } = useScene.getState()
const levelNode =
parentId && nodes[parentId as AnyNodeId]?.type === 'level'
? (nodes[parentId as AnyNodeId] as LevelNode)
: null
const levelWalls: WallNode[] = []
const levelFences: FenceNode[] = []
if (levelNode) {
for (const childId of levelNode.children ?? []) {
const child = nodes[childId as AnyNodeId]
if (!child) continue
if (child.type === 'wall') levelWalls.push(child)
else if (child.type === 'fence') levelFences.push(child)
}
}
return {
fenceId: fence.id as AnyNodeId,
originalStart,
originalEnd,
parentId,
linkedOriginals: snapshotLinked({ fenceId: fence.id, parentId, originalStart, originalEnd }),
levelWalls,
levelFences,
dragAnchor: null,
}
},
preview: (ctx, point, _modifiers) => {
const snapped = snapFenceDraftPoint({
point: [point[0], point[1]],
walls: ctx.levelWalls,
fences: ctx.levelFences,
ignoreFenceIds: [ctx.fenceId as string],
})
// Latch the anchor on the first preview tick — matches legacy
// "drag is delta from first move" semantics so the fence doesn't
// jump to wherever the activation click landed.
if (!ctx.dragAnchor) ctx.dragAnchor = snapped
const deltaX = snapped[0] - ctx.dragAnchor[0]
const deltaZ = snapped[1] - ctx.dragAnchor[1]
const nextStart: FencePlanPoint = [ctx.originalStart[0] + deltaX, ctx.originalStart[1] + deltaZ]
const nextEnd: FencePlanPoint = [ctx.originalEnd[0] + deltaX, ctx.originalEnd[1] + deltaZ]
return {
start: nextStart,
end: nextEnd,
deltaX,
deltaZ,
linkedUpdates: linkedCascade(
ctx.linkedOriginals,
ctx.originalStart,
ctx.originalEnd,
nextStart,
nextEnd,
),
}
},
apply: (draft, ctx, _scene) => {
// Live-drag exception — visual-only via mesh.position + useLiveTransforms.
// No scene.update during the drag (would re-rebuild fence geometry every
// tick). The scene store still has the original start/end; commit() writes
// the final values.
setMeshOffset(ctx.fenceId, draft.deltaX, draft.deltaZ)
setLiveTransform(ctx.fenceId, ctx.originalStart, ctx.originalEnd, draft.deltaX, draft.deltaZ)
for (const linked of ctx.linkedOriginals) {
setMeshOffset(linked.id as AnyNodeId, draft.deltaX, draft.deltaZ)
setLiveTransform(linked.id as AnyNodeId, linked.start, linked.end, draft.deltaX, draft.deltaZ)
}
// Return no dirty IDs — geometry rebuild deferred to commit.
return []
},
commit: (draft, ctx, scene) => {
// Reject when nothing moved — falls through to action.cancel which
// clears the live-drag visual state.
if (draft.deltaX === 0 && draft.deltaZ === 0) return false
// Single-undo dance — paused history during drag means nothing
// was recorded. We didn't scene.update during apply either (live-
// drag exception), so the snapshot is empty and restoreAll is a
// no-op. Resume history, then write the final draft. Zundo
// captures one diff: original → final.
scene.restoreAll() // no-op (no scene updates during apply)
scene.resumeHistory()
scene.update(ctx.fenceId, {
start: draft.start,
end: draft.end,
} as Partial<AnyNode>)
for (const linked of draft.linkedUpdates) {
scene.update(
linked.id as AnyNodeId,
{
start: linked.start,
end: linked.end,
} as Partial<AnyNode>,
)
}
// Clear live-drag visual state — the scene store now has the final
// values, so the renderer will re-mount the mesh at its real position
// and useLiveTransforms is no longer needed.
clearLiveState(ctx.fenceId, ctx.linkedOriginals)
return true
},
cancel: (ctx, _scene) => {
// Clear live-drag visual state so the mesh snaps back to the
// original (still-unchanged) scene position. No scene rollback
// needed — we never wrote anything.
clearLiveState(ctx.fenceId, ctx.linkedOriginals)
},
}
+7
View File
@@ -80,6 +80,13 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
// actions/move-endpoint.ts (linked-fence cascade, alt-detach,
// single-undo dance). Wrapper owns the angle label + detach badge.
'move-endpoint': () => import('./move-endpoint-tool'),
// Triggered by useEditor.movingNode when the moving node is a
// fence. Whole-fence rigid translation with linked-fence cascade.
// Pure logic in actions/move.ts uses the live-drag exception
// (mesh.position + useLiveTransforms) to avoid rebuilding fence
// geometry every pointer tick; commits the final start/end with
// the single-undo dance.
move: () => import('./move-tool'),
},
toolHints: [
+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