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
+15
View File
@@ -159,6 +159,21 @@ export type NodeDefinition<S extends ZodObject<any>> = {
floorplan?: (node: z.infer<S>, ctx: GeometryContext) => FloorplanGeometry | null floorplan?: (node: z.infer<S>, ctx: GeometryContext) => FloorplanGeometry | null
system?: SystemContribution system?: SystemContribution
tool?: LazyComponent tool?: LazyComponent
/**
* Stage-D drag-affordance components — one per kind-owned editor mode
* triggered by `useEditor` state. Component receives `{ node }` as its
* sole prop. Lazy-loaded by ToolManager when the corresponding editor
* state activates (e.g. `curvingFence` → `affordanceTools.curve`).
*
* Each component is the thin React wrapper around a pure DragAction
* primitive that lives in the kind's `actions/` folder. The split keeps
* the action data unit-testable while letting the wrapper consume
* `useDragAction` + cursor visuals.
*
* Generic record so per-kind state names don't need to land in the
* core type system. ToolManager looks up by string key.
*/
affordanceTools?: Record<string, () => Promise<{ default: ComponentType<any> }>>
affordances?: Affordance<z.infer<S>>[] affordances?: Affordance<z.infer<S>>[]
/** /**
* Contextual shortcut hints shown by `HelperManager` when this kind's * Contextual shortcut hints shown by `HelperManager` when this kind's
@@ -49,6 +49,29 @@ function getRegistryTool(tool: Tool | null): ComponentType | null {
return Comp 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>>> = { const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
site: { site: {
'property-line': SiteBoundaryEditor, 'property-line': SiteBoundaryEditor,
@@ -191,7 +214,17 @@ export const ToolManager: React.FC = () => {
{movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />} {movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />}
{movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />} {movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />}
{curvingWall && <CurveWallTool node={curvingWall} />} {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' && ( {movingNode && movingNode.type !== 'building' && (
<MoveTool <MoveTool
onNodeMoved={handlePlacedNodeSelected} 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 type { PresetsAdapter, PresetsTab } from './contexts/presets-context'
export { PresetsProvider } from './contexts/presets-context' export { PresetsProvider } from './contexts/presets-context'
export type { SaveStatus } from './hooks/use-auto-save' 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 type { SceneGraph } from './lib/scene'
export { applySceneGraphToEditor } from './lib/scene' export { applySceneGraphToEditor } from './lib/scene'
export { triggerSFX } from './lib/sfx-bus' export { triggerSFX } from './lib/sfx-bus'
+113
View File
@@ -0,0 +1,113 @@
import {
type AnyNode,
type AnyNodeId,
type DragAction,
type FenceNode,
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallChordFrame,
normalizeWallCurveOffset,
} from '@pascal-app/core'
/**
* Phase 5 Stage D — curve-fence drag affordance.
*
* Migrates `CurveFenceTool` (editor/tools/fence/curve-fence-tool.tsx,
* 178 LoC) to the `DragAction` primitive. The pure action lives in the
* fence node folder; a thin React wrapper (curve-tool.tsx) feeds it
* through `useDragAction`.
*
* The lifecycle:
* - **begin**: capture the node id + original curveOffset + chord +
* maxOffset + grid step. These never change during the drag.
* - **preview**: convert the pointer's level-local point into a
* distance along the chord's normal — that's the curveOffset.
* - **snap**: optional grid snap unless `modifiers.shift` (free place).
* - **apply**: write the new curveOffset onto the fence node. Returns
* the dirty IDs the cascade resolver should walk.
* - **commit**: returns true → drag finalizes. `useDragAction`
* resumes history so the post-pause final write lands as a single
* undo step.
* - **cancel**: restore the original curveOffset. Called on Esc /
* component unmount / commit-returns-false.
*
* Pure data: trivially unit-testable, doesn't import React. The
* orchestrator (`createDragSession`) handles pauseHistory / resumeHistory
* automatically.
*/
const GRID_STEP = 0.5
function snapScalar(value: number): number {
return Math.round(value / GRID_STEP) * GRID_STEP
}
type CurveFenceCtx = {
nodeId: AnyNodeId
originalCurveOffset: number
chord: ReturnType<typeof getWallChordFrame>
maxCurveOffset: number
// Snapshot of the node at drag start — used to recompute the curve frame
// and normalize the offset throughout the drag.
startNode: FenceNode
}
type CurveFenceDraft = {
curveOffset: number
}
export const curveFenceDragAction: DragAction<CurveFenceCtx, CurveFenceDraft> = {
begin: (input) => {
const node = input.node as FenceNode | undefined
if (!node) {
throw new Error('[curveFenceDragAction] begin requires a node')
}
return {
nodeId: node.id as AnyNodeId,
originalCurveOffset: getClampedWallCurveOffset(node),
chord: getWallChordFrame(node),
maxCurveOffset: getMaxWallCurveOffset(node),
startNode: node,
}
},
preview: (ctx, point) => {
// Pointer in level-local meters. Project onto the chord's normal to
// get the signed perpendicular distance — that's the new curveOffset.
const [px, pz] = point
const offset = -(
(px - ctx.chord.midpoint.x) * ctx.chord.normal.x +
(pz - ctx.chord.midpoint.y) * ctx.chord.normal.y
)
return { curveOffset: offset }
},
snap: (draft, ctx, _services) => {
// Clamp to maxCurveOffset and normalize via the wall-curve helper.
const clamped = Math.max(-ctx.maxCurveOffset, Math.min(ctx.maxCurveOffset, draft.curveOffset))
const normalized = normalizeWallCurveOffset(ctx.startNode, clamped)
return { curveOffset: normalized }
},
apply: (draft, ctx, scene) => {
scene.update(ctx.nodeId, { curveOffset: draft.curveOffset } as Partial<AnyNode>)
scene.markDirty(ctx.nodeId)
return [ctx.nodeId]
},
commit: (_draft, _ctx, _scene) => {
// Returning true tells the orchestrator to finalize. The orchestrator
// resumes history and re-applies the final draft — yields a single
// undo step for the whole drag.
return true
},
cancel: (ctx, scene) => {
// Restore the original curve offset (history was paused, so nothing
// intermediate is on the undo stack).
scene.update(ctx.nodeId, {
curveOffset: ctx.originalCurveOffset,
} as Partial<AnyNode>)
scene.markDirty(ctx.nodeId)
},
}
+73
View File
@@ -0,0 +1,73 @@
'use client'
import { type FenceNode, getWallMidpointHandlePoint, useScene } from '@pascal-app/core'
import { CursorSphere, useDragAction, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useState } from 'react'
import { curveFenceDragAction } from './actions/curve'
/**
* Phase 5 Stage D — thin React wrapper around `curveFenceDragAction`.
*
* Replaces the legacy `CurveFenceTool` (editor/tools/fence/curve-fence-
* tool.tsx). Same UX: a cursor sphere follows the chord-perpendicular
* projection of the pointer, dragging the fence's `curveOffset` live;
* grid:click commits, Esc cancels.
*
* All the lifecycle (history pause/resume, grid:move → preview, snap,
* apply, grid:click → commit, Esc → cancel, unmount cleanup) is owned
* by `useDragAction`. This component only renders the cursor sphere
* and tracks its level-local position to mirror the active curveOffset
* for visual feedback.
*
* Mounted by the legacy ToolManager via the same `curvingFence` editor
* state (drop-in replacement for the old CurveFenceTool import).
*/
export const FenceCurveTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const initialHandle = getWallMidpointHandlePoint(node)
const [cursorPos, setCursorPos] = useState<[number, number, number]>([
initialHandle.x,
0,
initialHandle.y,
])
const exitCurveMode = () => {
useViewer.getState().setSelection({ selectedIds: [node.id] })
useEditor.getState().setCurvingFence(null)
}
useDragAction({
active: true,
action: curveFenceDragAction,
initial: {
node,
// Initial point — useDragAction requires a Vec2; the action's begin
// reads everything it needs from input.node, so this is just a
// placeholder until the first grid:move fires.
point: [initialHandle.x, initialHandle.y],
},
onCommit: exitCurveMode,
onCancel: exitCurveMode,
})
// Mirror the active curveOffset back into the cursor position. The
// useDragAction loop's apply() writes curveOffset onto the node; we
// subscribe to that field and recompute the handle point.
const liveCurveOffset = useScene((s) => {
const live = s.nodes[node.id]
return live?.type === 'fence' ? ((live as FenceNode).curveOffset ?? 0) : 0
})
useEffect(() => {
const handlePoint = getWallMidpointHandlePoint({ ...node, curveOffset: liveCurveOffset })
setCursorPos([handlePoint.x, 0, handlePoint.y])
}, [liveCurveOffset, node])
return (
<group>
<CursorSphere position={cursorPos} showTooltip={false} />
</group>
)
}
export default FenceCurveTool
+10
View File
@@ -67,6 +67,16 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
// Legacy `floorplanFenceEntries` short-circuits to [] when fence is // Legacy `floorplanFenceEntries` short-circuits to [] when fence is
// registered (see floorplan-panel.tsx). // registered (see floorplan-panel.tsx).
floorplan: buildFenceFloorplan, floorplan: buildFenceFloorplan,
// Stage D (in progress): drag-affordance components owned by the kind.
// ToolManager looks up these lazy modules at runtime when the matching
// editor state activates — no static import from editor → nodes
// (which would create a circular dep).
affordanceTools: {
// Triggered by useEditor.curvingFence. Pure DragAction logic in
// actions/curve.ts; this component is the React wrapper using
// useDragAction + the cursor visuals.
curve: () => import('./curve-tool'),
},
toolHints: [ toolHints: [
{ key: 'Left click', label: 'Set fence start / end' }, { key: 'Left click', label: 'Set fence start / end' },