diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx
index 1eaffb0c..03da9e27 100644
--- a/packages/editor/src/components/tools/item/move-tool.tsx
+++ b/packages/editor/src/components/tools/item/move-tool.tsx
@@ -17,6 +17,7 @@ import type {
WindowNode,
} from '@pascal-app/core'
import { nodeRegistry } from '@pascal-app/core'
+import { Suspense } from 'react'
import { Vector3 } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
@@ -28,6 +29,7 @@ import { MoveElevatorTool } from '../elevator/move-elevator-tool'
import { MoveFenceTool } from '../fence/move-fence-tool'
import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool'
import { MoveRoofTool } from '../roof/move-roof-tool'
+import { getRegistryAffordanceTool } from '../shared/affordance-dispatch'
import { MoveSlabTool } from '../slab/move-slab-tool'
import { MoveSpawnTool } from '../spawn/move-spawn-tool'
import { MoveWallTool } from '../wall/move-wall-tool'
@@ -122,6 +124,18 @@ export const MoveTool: React.FC<{
return
}
+ // Phase 5 Stage D: registry-driven move affordance (kind-owned
+ // `DragAction` with bespoke semantics). Falls through to the legacy
+ // per-kind chain below when the kind hasn't ported its move tool.
+ const RegistryMove = getRegistryAffordanceTool(movingNode.type, 'move')
+ if (RegistryMove) {
+ return (
+
+
+
+ )
+ }
+
if (movingNode.type === 'building')
return
if (movingNode.type === 'door') return
diff --git a/packages/editor/src/components/tools/shared/affordance-dispatch.ts b/packages/editor/src/components/tools/shared/affordance-dispatch.ts
new file mode 100644
index 00000000..0fc35c6a
--- /dev/null
+++ b/packages/editor/src/components/tools/shared/affordance-dispatch.ts
@@ -0,0 +1,30 @@
+import { nodeRegistry } from '@pascal-app/core'
+import { type ComponentType, lazy } from 'react'
+
+/**
+ * Phase 5 Stage D — runtime lazy-load of a kind's affordance tool.
+ *
+ * The editor can't statically import from `@pascal-app/nodes` (the
+ * nodes package depends on editor — static imports would cycle). The
+ * kind declares its drag-affordance components in
+ * `def.affordanceTools[]: () => import('./-tool')`; this
+ * helper resolves that to a `React.lazy` component at the call site.
+ *
+ * Returns null when the kind doesn't declare the affordance — callers
+ * mount the legacy fallback in that case.
+ */
+const lazyToolCache = new WeakMap<() => Promise, ComponentType>()
+
+export function getRegistryAffordanceTool(
+ kind: string,
+ affordance: string,
+): ComponentType | null {
+ const def = nodeRegistry.get(kind)
+ const loader = def?.affordanceTools?.[affordance]
+ if (!loader) return null
+ const cached = lazyToolCache.get(loader)
+ if (cached) return cached
+ const Comp = lazy(loader as () => Promise<{ default: ComponentType }>)
+ lazyToolCache.set(loader, Comp as unknown as ComponentType)
+ return Comp as unknown as ComponentType
+}
diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx
index a231d2f8..b4d51407 100644
--- a/packages/editor/src/components/tools/tool-manager.tsx
+++ b/packages/editor/src/components/tools/tool-manager.tsx
@@ -21,6 +21,7 @@ import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool'
import { ItemTool } from './item/item-tool'
import { MoveTool } from './item/move-tool'
import { RoofTool } from './roof/roof-tool'
+import { getRegistryAffordanceTool } from './shared/affordance-dispatch'
import { SiteBoundaryEditor } from './site/site-boundary-editor'
import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
import { SlabHoleEditor } from './slab/slab-hole-editor'
@@ -49,26 +50,6 @@ function getRegistryTool(tool: Tool | null): ComponentType | null {
return Comp
}
-/**
- * Lazy-loads the kind's drag-affordance component from
- * `def.affordanceTools[name]`. Lets editor consume kind-owned tools
- * from `@pascal-app/nodes` without a static import (which would create
- * a circular dep: nodes already depends on editor).
- *
- * Returns null when the kind doesn't declare the affordance — caller
- * renders the legacy fallback in that case.
- */
-function getRegistryAffordanceTool(kind: string, affordance: string): ComponentType | null {
- const def = nodeRegistry.get(kind)
- const loader = def?.affordanceTools?.[affordance]
- if (!loader) return null
- const cached = lazyToolCache.get(loader)
- if (cached) return cached
- const Comp = lazy(loader as () => Promise<{ default: ComponentType }>)
- lazyToolCache.set(loader, Comp as unknown as ComponentType)
- return Comp as unknown as ComponentType
-}
-
const tools: Record>> = {
site: {
'property-line': SiteBoundaryEditor,
diff --git a/packages/nodes/src/fence/actions/move.ts b/packages/nodes/src/fence/actions/move.ts
new file mode 100644
index 00000000..d9f7616e
--- /dev/null
+++ b/packages/nodes/src/fence/actions/move.ts
@@ -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 = {
+ 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)
+ for (const linked of draft.linkedUpdates) {
+ scene.update(
+ linked.id as AnyNodeId,
+ {
+ start: linked.start,
+ end: linked.end,
+ } as Partial,
+ )
+ }
+
+ // 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)
+ },
+}
diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts
index c7b1adee..869e0519 100644
--- a/packages/nodes/src/fence/definition.ts
+++ b/packages/nodes/src/fence/definition.ts
@@ -80,6 +80,13 @@ export const fenceDefinition: NodeDefinition = {
// 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: [
diff --git a/packages/nodes/src/fence/move-tool.tsx b/packages/nodes/src/fence/move-tool.tsx
new file mode 100644
index 00000000..c307216a
--- /dev/null
+++ b/packages/nodes/src/fence/move-tool.tsx
@@ -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 (
+
+
+
+ )
+}
+
+export default FenceMoveTool