diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 25051af0..c31ab41b 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -159,6 +159,21 @@ export type NodeDefinition> = { floorplan?: (node: z.infer, ctx: GeometryContext) => FloorplanGeometry | null system?: SystemContribution tool?: LazyComponent + /** + * Stage-D drag-affordance components — one per kind-owned editor mode + * triggered by `useEditor` state. Component receives `{ node }` as its + * sole prop. Lazy-loaded by ToolManager when the corresponding editor + * state activates (e.g. `curvingFence` → `affordanceTools.curve`). + * + * Each component is the thin React wrapper around a pure DragAction + * primitive that lives in the kind's `actions/` folder. The split keeps + * the action data unit-testable while letting the wrapper consume + * `useDragAction` + cursor visuals. + * + * Generic record so per-kind state names don't need to land in the + * core type system. ToolManager looks up by string key. + */ + affordanceTools?: Record Promise<{ default: ComponentType }>> affordances?: Affordance>[] /** * Contextual shortcut hints shown by `HelperManager` when this kind's diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 75f6fcfd..b06d056c 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -49,6 +49,29 @@ function getRegistryTool(tool: Tool | null): ComponentType | null { return Comp } +/** + * Lazy-loads the kind's drag-affordance component from + * `def.affordanceTools[name]`. Lets editor consume kind-owned tools + * from `@pascal-app/nodes` without a static import (which would create + * a circular dep: nodes already depends on editor). + * + * Returns null when the kind doesn't declare the affordance — caller + * renders the legacy fallback in that case. + */ +function getRegistryAffordanceTool( + kind: string, + affordance: string, +): ComponentType<{ node: any }> | null { + const def = nodeRegistry.get(kind) + const loader = def?.affordanceTools?.[affordance] + if (!loader) return null + const cached = lazyToolCache.get(loader) + if (cached) return cached as ComponentType<{ node: any }> + const Comp = lazy(loader as () => Promise<{ default: ComponentType<{ node: any }> }>) + lazyToolCache.set(loader, Comp as unknown as ComponentType) + return Comp +} + const tools: Record>> = { site: { 'property-line': SiteBoundaryEditor, @@ -191,7 +214,17 @@ export const ToolManager: React.FC = () => { {movingWallEndpoint && } {movingFenceEndpoint && } {curvingWall && } - {curvingFence && } + {curvingFence && + (() => { + const RegistryAffordance = getRegistryAffordanceTool(curvingFence.type, 'curve') + return RegistryAffordance ? ( + + + + ) : ( + + ) + })()} {movingNode && movingNode.type !== 'building' && ( + 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 = { + 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) + 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) + scene.markDirty(ctx.nodeId) + }, +} diff --git a/packages/nodes/src/fence/curve-tool.tsx b/packages/nodes/src/fence/curve-tool.tsx new file mode 100644 index 00000000..ed7e642f --- /dev/null +++ b/packages/nodes/src/fence/curve-tool.tsx @@ -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 ( + + + + ) +} + +export default FenceCurveTool diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts index 19612678..0d97e79c 100644 --- a/packages/nodes/src/fence/definition.ts +++ b/packages/nodes/src/fence/definition.ts @@ -67,6 +67,16 @@ export const fenceDefinition: NodeDefinition = { // 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' },