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
@@ -159,6 +159,21 @@ export type NodeDefinition<S extends ZodObject<any>> = {
|
||||
floorplan?: (node: z.infer<S>, 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<string, () => Promise<{ default: ComponentType<any> }>>
|
||||
affordances?: Affordance<z.infer<S>>[]
|
||||
/**
|
||||
* Contextual shortcut hints shown by `HelperManager` when this kind's
|
||||
|
||||
@@ -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<Phase, Partial<Record<Tool, React.FC>>> = {
|
||||
site: {
|
||||
'property-line': SiteBoundaryEditor,
|
||||
@@ -191,7 +214,17 @@ export const ToolManager: React.FC = () => {
|
||||
{movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />}
|
||||
{movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />}
|
||||
{curvingWall && <CurveWallTool node={curvingWall} />}
|
||||
{curvingFence && <CurveFenceTool node={curvingFence} />}
|
||||
{curvingFence &&
|
||||
(() => {
|
||||
const RegistryAffordance = getRegistryAffordanceTool(curvingFence.type, 'curve')
|
||||
return RegistryAffordance ? (
|
||||
<Suspense fallback={null}>
|
||||
<RegistryAffordance node={curvingFence} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<CurveFenceTool node={curvingFence} />
|
||||
)
|
||||
})()}
|
||||
{movingNode && movingNode.type !== 'building' && (
|
||||
<MoveTool
|
||||
onNodeMoved={handlePlacedNodeSelected}
|
||||
|
||||
@@ -27,6 +27,10 @@ export type { SidebarTab } from './components/ui/sidebar/tab-bar'
|
||||
export type { PresetsAdapter, PresetsTab } from './contexts/presets-context'
|
||||
export { PresetsProvider } from './contexts/presets-context'
|
||||
export type { SaveStatus } from './hooks/use-auto-save'
|
||||
// useDragAction is the React-side glue for the registry's DragAction
|
||||
// primitive. Public so registry-driven kinds (Phase 5+ Stage D ports)
|
||||
// can express their affordances declaratively in their own folder.
|
||||
export { type UseDragActionArgs, useDragAction } from './hooks/use-drag-action'
|
||||
export type { SceneGraph } from './lib/scene'
|
||||
export { applySceneGraphToEditor } from './lib/scene'
|
||||
export { triggerSFX } from './lib/sfx-bus'
|
||||
|
||||
@@ -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