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
@@ -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 <MoveRegistryNodeTool node={movingNode} />
}
// 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 (
<Suspense fallback={null}>
<RegistryMove node={movingNode} />
</Suspense>
)
}
if (movingNode.type === 'building')
return <MoveBuildingContent node={movingNode as BuildingNode} />
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
@@ -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[<key>]: () => import('./<name>-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<unknown>, ComponentType>()
export function getRegistryAffordanceTool(
kind: string,
affordance: string,
): ComponentType<any> | 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<any> }>)
lazyToolCache.set(loader, Comp as unknown as ComponentType)
return Comp as unknown as ComponentType<any>
}
@@ -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<any> | 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<any> }>)
lazyToolCache.set(loader, Comp as unknown as ComponentType)
return Comp as unknown as ComponentType<any>
}
const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
site: {
'property-line': SiteBoundaryEditor,