Phase 5 Stage D: fence curve affordance → registry DragAction
First Stage D port — `CurveFenceTool` (178 LoC legacy) split into a pure `DragAction` primitive (`packages/nodes/src/fence/actions/curve.ts`) plus a thin React wrapper (`packages/nodes/src/fence/curve-tool.tsx`) that feeds it through `useDragAction`. The kind declares the affordance via `def.affordanceTools.curve`; ToolManager lazy-loads it at runtime when `useEditor.curvingFence` activates. Falls back to the legacy `CurveFenceTool` for any kind that hasn't been ported. The lazy-load dispatch dodges the editor→nodes circular dep (nodes already depends on editor for `useDragAction` + `CursorSphere`). Establishes the pattern for the remaining fence affordances (`MoveFenceEndpoint`, `MoveFence`, placement) and for slab/ceiling/wall D ports. 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
95645d8e37
commit
8ca9686b27
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type DragAction,
|
||||
type FenceNode,
|
||||
getClampedWallCurveOffset,
|
||||
getMaxWallCurveOffset,
|
||||
getWallChordFrame,
|
||||
normalizeWallCurveOffset,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Phase 5 Stage D — curve-fence drag affordance.
|
||||
*
|
||||
* Migrates `CurveFenceTool` (editor/tools/fence/curve-fence-tool.tsx,
|
||||
* 178 LoC) to the `DragAction` primitive. The pure action lives in the
|
||||
* fence node folder; a thin React wrapper (curve-tool.tsx) feeds it
|
||||
* through `useDragAction`.
|
||||
*
|
||||
* The lifecycle:
|
||||
* - **begin**: capture the node id + original curveOffset + chord +
|
||||
* maxOffset + grid step. These never change during the drag.
|
||||
* - **preview**: convert the pointer's level-local point into a
|
||||
* distance along the chord's normal — that's the curveOffset.
|
||||
* - **snap**: optional grid snap unless `modifiers.shift` (free place).
|
||||
* - **apply**: write the new curveOffset onto the fence node. Returns
|
||||
* the dirty IDs the cascade resolver should walk.
|
||||
* - **commit**: returns true → drag finalizes. `useDragAction`
|
||||
* resumes history so the post-pause final write lands as a single
|
||||
* undo step.
|
||||
* - **cancel**: restore the original curveOffset. Called on Esc /
|
||||
* component unmount / commit-returns-false.
|
||||
*
|
||||
* Pure data: trivially unit-testable, doesn't import React. The
|
||||
* orchestrator (`createDragSession`) handles pauseHistory / resumeHistory
|
||||
* automatically.
|
||||
*/
|
||||
|
||||
const GRID_STEP = 0.5
|
||||
|
||||
function snapScalar(value: number): number {
|
||||
return Math.round(value / GRID_STEP) * GRID_STEP
|
||||
}
|
||||
|
||||
type CurveFenceCtx = {
|
||||
nodeId: AnyNodeId
|
||||
originalCurveOffset: number
|
||||
chord: ReturnType<typeof getWallChordFrame>
|
||||
maxCurveOffset: number
|
||||
// Snapshot of the node at drag start — used to recompute the curve frame
|
||||
// and normalize the offset throughout the drag.
|
||||
startNode: FenceNode
|
||||
}
|
||||
|
||||
type CurveFenceDraft = {
|
||||
curveOffset: number
|
||||
}
|
||||
|
||||
export const curveFenceDragAction: DragAction<CurveFenceCtx, CurveFenceDraft> = {
|
||||
begin: (input) => {
|
||||
const node = input.node as FenceNode | undefined
|
||||
if (!node) {
|
||||
throw new Error('[curveFenceDragAction] begin requires a node')
|
||||
}
|
||||
return {
|
||||
nodeId: node.id as AnyNodeId,
|
||||
originalCurveOffset: getClampedWallCurveOffset(node),
|
||||
chord: getWallChordFrame(node),
|
||||
maxCurveOffset: getMaxWallCurveOffset(node),
|
||||
startNode: node,
|
||||
}
|
||||
},
|
||||
|
||||
preview: (ctx, point) => {
|
||||
// Pointer in level-local meters. Project onto the chord's normal to
|
||||
// get the signed perpendicular distance — that's the new curveOffset.
|
||||
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) => {
|
||||
// Clamp to maxCurveOffset and normalize via the wall-curve helper.
|
||||
const clamped = Math.max(-ctx.maxCurveOffset, Math.min(ctx.maxCurveOffset, draft.curveOffset))
|
||||
const normalized = normalizeWallCurveOffset(ctx.startNode, clamped)
|
||||
return { curveOffset: normalized }
|
||||
},
|
||||
|
||||
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) => {
|
||||
// Returning true tells the orchestrator to finalize. The orchestrator
|
||||
// resumes history and re-applies the final draft — yields a single
|
||||
// undo step for the whole drag.
|
||||
return true
|
||||
},
|
||||
|
||||
cancel: (ctx, scene) => {
|
||||
// Restore the original curve offset (history was paused, so nothing
|
||||
// intermediate is on the undo stack).
|
||||
scene.update(ctx.nodeId, {
|
||||
curveOffset: ctx.originalCurveOffset,
|
||||
} as Partial<AnyNode>)
|
||||
scene.markDirty(ctx.nodeId)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { type FenceNode, getWallMidpointHandlePoint, useScene } from '@pascal-app/core'
|
||||
import { CursorSphere, useDragAction, useEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { curveFenceDragAction } from './actions/curve'
|
||||
|
||||
/**
|
||||
* Phase 5 Stage D — thin React wrapper around `curveFenceDragAction`.
|
||||
*
|
||||
* Replaces the legacy `CurveFenceTool` (editor/tools/fence/curve-fence-
|
||||
* tool.tsx). Same UX: a cursor sphere follows the chord-perpendicular
|
||||
* projection of the pointer, dragging the fence's `curveOffset` live;
|
||||
* grid:click commits, Esc cancels.
|
||||
*
|
||||
* All the lifecycle (history pause/resume, grid:move → preview, snap,
|
||||
* apply, grid:click → commit, Esc → cancel, unmount cleanup) is owned
|
||||
* by `useDragAction`. This component only renders the cursor sphere
|
||||
* and tracks its level-local position to mirror the active curveOffset
|
||||
* for visual feedback.
|
||||
*
|
||||
* Mounted by the legacy ToolManager via the same `curvingFence` editor
|
||||
* state (drop-in replacement for the old CurveFenceTool import).
|
||||
*/
|
||||
export const FenceCurveTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||
const initialHandle = getWallMidpointHandlePoint(node)
|
||||
const [cursorPos, setCursorPos] = useState<[number, number, number]>([
|
||||
initialHandle.x,
|
||||
0,
|
||||
initialHandle.y,
|
||||
])
|
||||
|
||||
const exitCurveMode = () => {
|
||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||
useEditor.getState().setCurvingFence(null)
|
||||
}
|
||||
|
||||
useDragAction({
|
||||
active: true,
|
||||
action: curveFenceDragAction,
|
||||
initial: {
|
||||
node,
|
||||
// Initial point — useDragAction requires a Vec2; the action's begin
|
||||
// reads everything it needs from input.node, so this is just a
|
||||
// placeholder until the first grid:move fires.
|
||||
point: [initialHandle.x, initialHandle.y],
|
||||
},
|
||||
onCommit: exitCurveMode,
|
||||
onCancel: exitCurveMode,
|
||||
})
|
||||
|
||||
// Mirror the active curveOffset back into the cursor position. The
|
||||
// useDragAction loop's apply() writes curveOffset onto the node; we
|
||||
// subscribe to that field and recompute the handle point.
|
||||
const liveCurveOffset = useScene((s) => {
|
||||
const live = s.nodes[node.id]
|
||||
return live?.type === 'fence' ? ((live as FenceNode).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 FenceCurveTool
|
||||
@@ -67,6 +67,16 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
|
||||
// Legacy `floorplanFenceEntries` short-circuits to [] when fence is
|
||||
// registered (see floorplan-panel.tsx).
|
||||
floorplan: buildFenceFloorplan,
|
||||
// Stage D (in progress): drag-affordance components owned by the kind.
|
||||
// ToolManager looks up these lazy modules at runtime when the matching
|
||||
// editor state activates — no static import from editor → nodes
|
||||
// (which would create a circular dep).
|
||||
affordanceTools: {
|
||||
// Triggered by useEditor.curvingFence. Pure DragAction logic in
|
||||
// actions/curve.ts; this component is the React wrapper using
|
||||
// useDragAction + the cursor visuals.
|
||||
curve: () => import('./curve-tool'),
|
||||
},
|
||||
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Set fence start / end' },
|
||||
|
||||
Reference in New Issue
Block a user