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
@@ -49,6 +49,29 @@ 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<{ node: 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 as ComponentType<{ node: any }>
const Comp = lazy(loader as () => Promise<{ default: ComponentType<{ node: any }> }>)
lazyToolCache.set(loader, Comp as unknown as ComponentType)
return Comp
}
const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
site: {
'property-line': SiteBoundaryEditor,
@@ -191,7 +214,17 @@ export const ToolManager: React.FC = () => {
{movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />}
{movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />}
{curvingWall && <CurveWallTool node={curvingWall} />}
{curvingFence && <CurveFenceTool node={curvingFence} />}
{curvingFence &&
(() => {
const RegistryAffordance = getRegistryAffordanceTool(curvingFence.type, 'curve')
return RegistryAffordance ? (
<Suspense fallback={null}>
<RegistryAffordance node={curvingFence} />
</Suspense>
) : (
<CurveFenceTool node={curvingFence} />
)
})()}
{movingNode && movingNode.type !== 'building' && (
<MoveTool
onNodeMoved={handlePlacedNodeSelected}
+4
View File
@@ -27,6 +27,10 @@ export type { SidebarTab } from './components/ui/sidebar/tab-bar'
export type { PresetsAdapter, PresetsTab } from './contexts/presets-context'
export { PresetsProvider } from './contexts/presets-context'
export type { SaveStatus } from './hooks/use-auto-save'
// useDragAction is the React-side glue for the registry's DragAction
// primitive. Public so registry-driven kinds (Phase 5+ Stage D ports)
// can express their affordances declaratively in their own folder.
export { type UseDragActionArgs, useDragAction } from './hooks/use-drag-action'
export type { SceneGraph } from './lib/scene'
export { applySceneGraphToEditor } from './lib/scene'
export { triggerSFX } from './lib/sfx-bus'