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:
co-authored by
Claude Opus 4.7
parent
de3efa1b53
commit
7f2f9c685b
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user