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
|
floorplan?: (node: z.infer<S>, ctx: GeometryContext) => FloorplanGeometry | null
|
||||||
system?: SystemContribution
|
system?: SystemContribution
|
||||||
tool?: LazyComponent
|
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>>[]
|
affordances?: Affordance<z.infer<S>>[]
|
||||||
/**
|
/**
|
||||||
* Contextual shortcut hints shown by `HelperManager` when this kind's
|
* Contextual shortcut hints shown by `HelperManager` when this kind's
|
||||||
|
|||||||
@@ -49,6 +49,29 @@ function getRegistryTool(tool: Tool | null): ComponentType | null {
|
|||||||
return Comp
|
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>>> = {
|
const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
|
||||||
site: {
|
site: {
|
||||||
'property-line': SiteBoundaryEditor,
|
'property-line': SiteBoundaryEditor,
|
||||||
@@ -191,7 +214,17 @@ export const ToolManager: React.FC = () => {
|
|||||||
{movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />}
|
{movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />}
|
||||||
{movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />}
|
{movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />}
|
||||||
{curvingWall && <CurveWallTool node={curvingWall} />}
|
{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' && (
|
{movingNode && movingNode.type !== 'building' && (
|
||||||
<MoveTool
|
<MoveTool
|
||||||
onNodeMoved={handlePlacedNodeSelected}
|
onNodeMoved={handlePlacedNodeSelected}
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export type { SidebarTab } from './components/ui/sidebar/tab-bar'
|
|||||||
export type { PresetsAdapter, PresetsTab } from './contexts/presets-context'
|
export type { PresetsAdapter, PresetsTab } from './contexts/presets-context'
|
||||||
export { PresetsProvider } from './contexts/presets-context'
|
export { PresetsProvider } from './contexts/presets-context'
|
||||||
export type { SaveStatus } from './hooks/use-auto-save'
|
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 type { SceneGraph } from './lib/scene'
|
||||||
export { applySceneGraphToEditor } from './lib/scene'
|
export { applySceneGraphToEditor } from './lib/scene'
|
||||||
export { triggerSFX } from './lib/sfx-bus'
|
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
|
// Legacy `floorplanFenceEntries` short-circuits to [] when fence is
|
||||||
// registered (see floorplan-panel.tsx).
|
// registered (see floorplan-panel.tsx).
|
||||||
floorplan: buildFenceFloorplan,
|
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: [
|
toolHints: [
|
||||||
{ key: 'Left click', label: 'Set fence start / end' },
|
{ key: 'Left click', label: 'Set fence start / end' },
|
||||||
|
|||||||
Reference in New Issue
Block a user