Phase 5 Stage D wall: port CurveWallTool to DragAction (first wall D)

Direct copy of the fence curve recipe — pure
`curveWallDragAction` (chord-perpendicular projection + clamp +
normalize + single-undo dance) plus a thin wrapper feeding
`useDragAction`. Mounted via `def.affordanceTools.curve`.

Slight precision difference vs legacy: the legacy CurveWallTool snapped
the pointer position to `getWallGridStep()` before projecting onto the
chord normal; the ported action skips that pre-snap and relies on
`normalizeWallCurveOffset` to settle the final value. The user-visible
result is the same magnitude of step, just with the snap applied at
the offset level instead of the position level.

Remaining wall D affordances (endpoint move, whole-wall move,
placement) are larger and queued for future sessions — wall's move
tool alone is 804 LoC with the linked-wall corner-cascade logic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-15 18:34:40 -04:00
co-authored by Claude Opus 4.7
parent de3efa1b53
commit 7f2f9c685b
4 changed files with 162 additions and 1 deletions
@@ -244,7 +244,17 @@ export const ToolManager: React.FC = () => {
<MoveFenceEndpointTool target={movingFenceEndpoint} />
)
})()}
{curvingWall && <CurveWallTool node={curvingWall} />}
{curvingWall &&
(() => {
const Registry = getRegistryAffordanceTool(curvingWall.type, 'curve')
return Registry ? (
<Suspense fallback={null}>
<Registry node={curvingWall} />
</Suspense>
) : (
<CurveWallTool node={curvingWall} />
)
})()}
{curvingFence &&
(() => {
const RegistryAffordance = getRegistryAffordanceTool(curvingFence.type, 'curve')
+81
View File
@@ -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<typeof getWallChordFrame>
maxCurveOffset: number
startNode: WallNode
}
type CurveWallDraft = {
curveOffset: number
}
export const curveWallDragAction: DragAction<CurveWallCtx, CurveWallDraft> = {
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<AnyNode>)
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<AnyNode>)
return true
},
cancel: (_ctx, _scene) => {
// No-op — orchestrator restores via snapshot.
},
}
+62
View File
@@ -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 (
<group>
<CursorSphere position={cursorPos} showTooltip={false} />
</group>
)
}
export default WallCurveTool
+8
View File
@@ -57,6 +57,14 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
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'),