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} />
|
<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 &&
|
{curvingFence &&
|
||||||
(() => {
|
(() => {
|
||||||
const RegistryAffordance = getRegistryAffordanceTool(curvingFence.type, 'curve')
|
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,
|
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: {
|
renderer: {
|
||||||
kind: 'parametric',
|
kind: 'parametric',
|
||||||
module: () => import('./renderer'),
|
module: () => import('./renderer'),
|
||||||
|
|||||||
Reference in New Issue
Block a user