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'),