diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx
index 5e011722..b8d69862 100644
--- a/packages/editor/src/components/tools/tool-manager.tsx
+++ b/packages/editor/src/components/tools/tool-manager.tsx
@@ -244,7 +244,17 @@ export const ToolManager: React.FC = () => {
)
})()}
- {curvingWall && }
+ {curvingWall &&
+ (() => {
+ const Registry = getRegistryAffordanceTool(curvingWall.type, 'curve')
+ return Registry ? (
+
+
+
+ ) : (
+
+ )
+ })()}
{curvingFence &&
(() => {
const RegistryAffordance = getRegistryAffordanceTool(curvingFence.type, 'curve')
diff --git a/packages/nodes/src/wall/actions/curve.ts b/packages/nodes/src/wall/actions/curve.ts
new file mode 100644
index 00000000..27fa6d59
--- /dev/null
+++ b/packages/nodes/src/wall/actions/curve.ts
@@ -0,0 +1,81 @@
+import {
+ type AnyNode,
+ type AnyNodeId,
+ type DragAction,
+ getClampedWallCurveOffset,
+ getMaxWallCurveOffset,
+ getWallChordFrame,
+ normalizeWallCurveOffset,
+ type WallNode,
+} from '@pascal-app/core'
+
+/**
+ * Phase 5 Stage D — curve-wall drag affordance.
+ *
+ * Mirrors `fence/actions/curve.ts`. Same chord-perpendicular projection,
+ * same clamp/normalize, same single-undo dance on commit. The only
+ * meaningful difference is the wall's snap-step-aware preview (the
+ * legacy CurveWallTool snapped the pointer position to `getWallGridStep`
+ * before projecting). We rely on the wall snap services existing in
+ * the wall-drafting module — exposing those here would bloat the
+ * surface, so we accept the slight precision difference for now (the
+ * normalized offset is what zundo records anyway).
+ */
+
+type CurveWallCtx = {
+ nodeId: AnyNodeId
+ originalCurveOffset: number
+ chord: ReturnType
+ maxCurveOffset: number
+ startNode: WallNode
+}
+
+type CurveWallDraft = {
+ curveOffset: number
+}
+
+export const curveWallDragAction: DragAction = {
+ begin: (input) => {
+ const node = input.node as WallNode | undefined
+ if (!node) throw new Error('[curveWallDragAction] begin requires a wall node')
+ return {
+ nodeId: node.id as AnyNodeId,
+ originalCurveOffset: getClampedWallCurveOffset(node),
+ chord: getWallChordFrame(node),
+ maxCurveOffset: getMaxWallCurveOffset(node),
+ startNode: node,
+ }
+ },
+
+ preview: (ctx, point) => {
+ 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) => {
+ const clamped = Math.max(-ctx.maxCurveOffset, Math.min(ctx.maxCurveOffset, draft.curveOffset))
+ return { curveOffset: normalizeWallCurveOffset(ctx.startNode, clamped) }
+ },
+
+ apply: (draft, ctx, scene) => {
+ scene.update(ctx.nodeId, { curveOffset: draft.curveOffset } as Partial)
+ scene.markDirty(ctx.nodeId)
+ return [ctx.nodeId]
+ },
+
+ commit: (draft, ctx, scene) => {
+ if (draft.curveOffset === ctx.originalCurveOffset) return false
+ scene.restoreAll()
+ scene.resumeHistory()
+ scene.update(ctx.nodeId, { curveOffset: draft.curveOffset } as Partial)
+ return true
+ },
+
+ cancel: (_ctx, _scene) => {
+ // No-op — orchestrator restores via snapshot.
+ },
+}
diff --git a/packages/nodes/src/wall/curve-tool.tsx b/packages/nodes/src/wall/curve-tool.tsx
new file mode 100644
index 00000000..016a0d33
--- /dev/null
+++ b/packages/nodes/src/wall/curve-tool.tsx
@@ -0,0 +1,62 @@
+'use client'
+
+import { getWallMidpointHandlePoint, useScene, type WallNode } from '@pascal-app/core'
+import { CursorSphere, triggerSFX, useDragAction, useEditor } from '@pascal-app/editor'
+import { useViewer } from '@pascal-app/viewer'
+import { useEffect, useState } from 'react'
+import { curveWallDragAction } from './actions/curve'
+
+/**
+ * Phase 5 Stage D — thin React wrapper around `curveWallDragAction`.
+ *
+ * Replaces the legacy `CurveWallTool` (178 LoC). Same UX as the fence
+ * curve port — cursor sphere follows the chord-perpendicular projection
+ * of the pointer, dragging the wall's `curveOffset` live; grid:click
+ * commits with the single-undo dance, Esc cancels.
+ *
+ * Mounted by ToolManager via `def.affordanceTools.curve` when
+ * `useEditor.curvingWall` activates.
+ */
+export const WallCurveTool: React.FC<{ node: WallNode }> = ({ node }) => {
+ const initialHandle = getWallMidpointHandlePoint(node)
+ const [cursorPos, setCursorPos] = useState<[number, number, number]>([
+ initialHandle.x,
+ 0,
+ initialHandle.y,
+ ])
+
+ const exitCurveMode = (committed: boolean) => {
+ if (committed) triggerSFX('sfx:item-place')
+ useViewer.getState().setSelection({ selectedIds: [node.id] })
+ useEditor.getState().setCurvingWall(null)
+ }
+
+ useDragAction({
+ active: true,
+ action: curveWallDragAction,
+ initial: {
+ node,
+ point: [initialHandle.x, initialHandle.y],
+ },
+ onCommit: () => exitCurveMode(true),
+ onCancel: () => exitCurveMode(false),
+ })
+
+ const liveCurveOffset = useScene((s) => {
+ const live = s.nodes[node.id]
+ return live?.type === 'wall' ? ((live as WallNode).curveOffset ?? 0) : 0
+ })
+
+ useEffect(() => {
+ const handlePoint = getWallMidpointHandlePoint({ ...node, curveOffset: liveCurveOffset })
+ setCursorPos([handlePoint.x, 0, handlePoint.y])
+ }, [liveCurveOffset, node])
+
+ return (
+
+
+
+ )
+}
+
+export default WallCurveTool
diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts
index 9fc945f7..0fb2f0db 100644
--- a/packages/nodes/src/wall/definition.ts
+++ b/packages/nodes/src/wall/definition.ts
@@ -57,6 +57,14 @@ export const wallDefinition: NodeDefinition = {
parametrics: wallParametrics,
+ // Stage D (in progress): drag-affordance components owned by the kind.
+ // Only the curve affordance is ported today — the remaining wall
+ // tools (endpoint drag, whole-wall move, placement) are larger and
+ // queued for future sessions.
+ affordanceTools: {
+ curve: () => import('./curve-tool'),
+ },
+
renderer: {
kind: 'parametric',
module: () => import('./renderer'),