From 7f2f9c685bcf97de0ab53619a4a43c2248692313 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 15 May 2026 18:34:40 -0400 Subject: [PATCH] Phase 5 Stage D wall: port CurveWallTool to DragAction (first wall D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/components/tools/tool-manager.tsx | 12 ++- packages/nodes/src/wall/actions/curve.ts | 81 +++++++++++++++++++ packages/nodes/src/wall/curve-tool.tsx | 62 ++++++++++++++ packages/nodes/src/wall/definition.ts | 8 ++ 4 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 packages/nodes/src/wall/actions/curve.ts create mode 100644 packages/nodes/src/wall/curve-tool.tsx 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'),