From 05efa25edba4f17472f71e254cb04592f306f2c5 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 15 May 2026 17:51:04 -0400 Subject: [PATCH] Phase 5 Stage D fence: port MoveFenceEndpointTool to DragAction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second Stage D affordance port (425 LoC legacy → ~430 split across action + wrapper). All math (snap, linked-fence cascade, alt-detach, min-length gate, single-undo dance) lives in the pure `moveFenceEndpointDragAction`. The React wrapper handles the UI overlays (cursor sphere, Drag/Detach badge, angle label) and the live state subscriptions. The action introduces the single-undo dance pattern for multi-write commits: `commit()` calls `scene.restoreAll()` → `resumeHistory()` → re-applies the final draft so zundo records the entire drag as one undo step. Reusable shape for slab/wall/door endpoint ports. Transitional exports added to `@pascal-app/editor`'s public surface (`snapFenceDraftPoint`, `isWallLongEnough`, the segment-angle helpers, `MovingFenceEndpoint`). Stage F cleanup moves these into `@pascal-app/nodes` once every consumer is registry-driven. `getRegistryAffordanceTool` is now generic (`ComponentType`) so affordances with different prop shapes (`{ node }`, `{ target }`, …) all dispatch through the same helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/tools/tool-manager.tsx | 26 ++- packages/editor/src/index.tsx | 18 +- .../nodes/src/fence/actions/move-endpoint.ts | 218 ++++++++++++++++++ packages/nodes/src/fence/definition.ts | 4 + .../nodes/src/fence/move-endpoint-tool.tsx | 213 +++++++++++++++++ 5 files changed, 470 insertions(+), 9 deletions(-) create mode 100644 packages/nodes/src/fence/actions/move-endpoint.ts create mode 100644 packages/nodes/src/fence/move-endpoint-tool.tsx diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index b06d056c..a231d2f8 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -58,18 +58,15 @@ function getRegistryTool(tool: Tool | null): ComponentType | null { * 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 { +function getRegistryAffordanceTool(kind: string, affordance: string): ComponentType | 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 }> }>) + if (cached) return cached + const Comp = lazy(loader as () => Promise<{ default: ComponentType }>) lazyToolCache.set(loader, Comp as unknown as ComponentType) - return Comp + return Comp as unknown as ComponentType } const tools: Record>> = { @@ -212,7 +209,20 @@ export const ToolManager: React.FC = () => { )} {movingWallEndpoint && } - {movingFenceEndpoint && } + {movingFenceEndpoint && + (() => { + const RegistryAffordance = getRegistryAffordanceTool( + movingFenceEndpoint.fence.type, + 'move-endpoint', + ) + return RegistryAffordance ? ( + + + + ) : ( + + ) + })()} {curvingWall && } {curvingFence && (() => { diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 4a53d9f7..afc95f9c 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -4,7 +4,18 @@ export { type SnapshotCameraData, ThumbnailGenerator, } from './components/editor/thumbnail-generator' +// Phase 5 Stage D transitional exports — pure drafting / angle helpers +// consumed by kind-owned drag actions in @pascal-app/nodes. Stage F +// cleanup moves these into @pascal-app/nodes (fence/drafting.ts + +// shared/segment-angle.ts) once every Stage D port is in. +export { type FencePlanPoint, snapFenceDraftPoint } from './components/tools/fence/fence-drafting' export { CursorSphere } from './components/tools/shared/cursor-sphere' +export { + formatAngleRadians, + getAngleToSegmentReference, + getSegmentAngleReferenceAtPoint, +} from './components/tools/shared/segment-angle' +export { isWallLongEnough } from './components/tools/wall/wall-drafting' export { CameraActions as ViewerToolbarRight } from './components/ui/action-menu/camera-actions' export { ViewToggles as ViewerToolbarLeft } from './components/ui/action-menu/view-toggles' export { useCommandPalette } from './components/ui/command-palette' @@ -36,7 +47,12 @@ export { applySceneGraphToEditor } from './lib/scene' export { triggerSFX } from './lib/sfx-bus' export { default as useAudio } from './store/use-audio' export { type CommandAction, useCommandRegistry } from './store/use-command-registry' -export type { FloorplanSelectionTool, SplitOrientation, ViewMode } from './store/use-editor' +export type { + FloorplanSelectionTool, + MovingFenceEndpoint, + SplitOrientation, + ViewMode, +} from './store/use-editor' export { default as useEditor } from './store/use-editor' export { type PaletteView, diff --git a/packages/nodes/src/fence/actions/move-endpoint.ts b/packages/nodes/src/fence/actions/move-endpoint.ts new file mode 100644 index 00000000..5bf6bfbf --- /dev/null +++ b/packages/nodes/src/fence/actions/move-endpoint.ts @@ -0,0 +1,218 @@ +import { + type AnyNode, + type AnyNodeId, + type DragAction, + type FenceNode, + useScene, + type WallNode, +} from '@pascal-app/core' +import { type FencePlanPoint, isWallLongEnough, snapFenceDraftPoint } from '@pascal-app/editor' + +/** + * Phase 5 Stage D — move-fence-endpoint drag affordance. + * + * Pure orchestration of an endpoint drag: + * - **begin**: snapshot originals (start / end / moving point / fixed + * point), look up linked fences (any other fence in the same parent + * whose start or end matches the moving point at activation time), + * cache walls + fences at the level for snap targets. + * - **preview**: snap the pointer (grid + 45° angle + wall/fence corner + * + span snaps via `snapFenceDraftPoint`), compute next start / end + * and the cascade of linked fence endpoint updates. Alt-key detaches + * the cascade for this tick (legacy "detach endpoint" semantics). + * - **apply**: writes the fence + linked fence endpoints into the + * scene. Drag-session paused history captures originals; cascade + * resolver fans dirty marks through `endpoint-match`. + * - **commit**: requires `hasChanged` && `isWallLongEnough(next)`. + * Performs the single-undo dance — revert to originals (snapshot), + * resume history, re-apply final draft — so the entire drag is one + * `Ctrl-Z` step. Returns false to reject; `createDragSession.cancel` + * restores all touched nodes. + * - **cancel**: nothing to do — `createDragSession.cancel`'s built-in + * `scene.restoreAll()` puts every touched node back. + * + * Pure data — no React, no DOM. Tests drive it through + * `createDragSession` with a stub `SceneApi` + a `useScene` fixture + * pre-populated by the test. + */ + +const LINKED_FENCE_ENDPOINT_EPSILON = 0.025 + +function samePoint(a: FencePlanPoint, b: FencePlanPoint): boolean { + return ( + Math.abs(a[0] - b[0]) <= LINKED_FENCE_ENDPOINT_EPSILON && + Math.abs(a[1] - b[1]) <= LINKED_FENCE_ENDPOINT_EPSILON + ) +} + +type LinkedFenceSnapshot = { + id: FenceNode['id'] + start: FencePlanPoint + end: FencePlanPoint +} + +export type MoveFenceEndpointCtx = { + fenceId: AnyNodeId + endpoint: 'start' | 'end' + originalStart: FencePlanPoint + originalEnd: FencePlanPoint + originalMovingPoint: FencePlanPoint + fixedPoint: FencePlanPoint + parentId: string | null + linkedOriginals: LinkedFenceSnapshot[] + levelWalls: WallNode[] + levelFences: FenceNode[] +} + +export type MoveFenceEndpointDraft = { + movingPoint: FencePlanPoint + start: FencePlanPoint + end: FencePlanPoint + linkedUpdates: LinkedFenceSnapshot[] + detached: boolean +} + +function snapshotLinked( + fenceId: FenceNode['id'], + parentId: string | null, + linkedPoint: FencePlanPoint, +): LinkedFenceSnapshot[] { + const { nodes } = useScene.getState() + const out: LinkedFenceSnapshot[] = [] + for (const node of Object.values(nodes)) { + if (!node || node.type !== 'fence') continue + if (node.id === fenceId) continue + if ((node.parentId ?? null) !== parentId) continue + if (!samePoint(node.start, linkedPoint) && !samePoint(node.end, linkedPoint)) continue + out.push({ + id: node.id, + start: [node.start[0], node.start[1]], + end: [node.end[0], node.end[1]], + }) + } + return out +} + +function linkedCascade( + linked: LinkedFenceSnapshot[], + origin: FencePlanPoint, + next: FencePlanPoint, +): LinkedFenceSnapshot[] { + return linked.map((l) => ({ + id: l.id, + start: samePoint(l.start, origin) ? next : l.start, + end: samePoint(l.end, origin) ? next : l.end, + })) +} + +export const moveFenceEndpointDragAction: DragAction = + { + begin: (input) => { + const fence = input.node as FenceNode | undefined + if (!fence) throw new Error('[moveFenceEndpointDragAction] begin requires a fence node') + const endpoint = (input.handleId ?? 'end') as 'start' | 'end' + const parentId = fence.parentId ?? null + const originalStart: FencePlanPoint = [fence.start[0], fence.start[1]] + const originalEnd: FencePlanPoint = [fence.end[0], fence.end[1]] + const originalMovingPoint = endpoint === 'start' ? originalStart : originalEnd + const fixedPoint = endpoint === 'start' ? originalEnd : originalStart + + const { nodes } = useScene.getState() + const levelWalls: WallNode[] = [] + const levelFences: FenceNode[] = [] + for (const node of Object.values(nodes)) { + if (!node) continue + if ((node.parentId ?? null) !== parentId) continue + if (node.type === 'wall') levelWalls.push(node) + else if (node.type === 'fence') levelFences.push(node) + } + + return { + fenceId: fence.id as AnyNodeId, + endpoint, + originalStart, + originalEnd, + originalMovingPoint, + fixedPoint, + parentId, + linkedOriginals: snapshotLinked(fence.id, parentId, originalMovingPoint), + levelWalls, + levelFences, + } + }, + + preview: (ctx, point, modifiers) => { + const planPoint: FencePlanPoint = [point[0], point[1]] + const snapped = snapFenceDraftPoint({ + point: planPoint, + walls: ctx.levelWalls, + fences: ctx.levelFences, + start: ctx.fixedPoint, + angleSnap: !modifiers.shift, + ignoreFenceIds: [ctx.fenceId as string], + }) + const nextStart = ctx.endpoint === 'start' ? snapped : ctx.fixedPoint + const nextEnd = ctx.endpoint === 'end' ? snapped : ctx.fixedPoint + const detached = modifiers.alt + const linkedUpdates = detached + ? [] + : linkedCascade(ctx.linkedOriginals, ctx.originalMovingPoint, snapped) + return { + movingPoint: snapped, + start: nextStart, + end: nextEnd, + linkedUpdates, + detached, + } + }, + + apply: (draft, ctx, scene) => { + scene.update(ctx.fenceId, { start: draft.start, end: draft.end } as Partial) + const dirty: AnyNodeId[] = [ctx.fenceId] + for (const linked of draft.linkedUpdates) { + scene.update( + linked.id as AnyNodeId, + { + start: linked.start, + end: linked.end, + } as Partial, + ) + dirty.push(linked.id as AnyNodeId) + } + return dirty + }, + + commit: (draft, ctx, scene) => { + // Reject when the drag didn't move OR the resulting fence would + // be shorter than the minimum length. createDragSession.cancel() + // restores originals via the snapshot. + const hasChanged = !samePoint(draft.movingPoint, ctx.originalMovingPoint) + if (!hasChanged) return false + if (!isWallLongEnough(draft.start, draft.end)) return false + + // Single-undo dance: revert to originals (paused history → no + // zundo record), resume history, then re-apply the final draft + // so zundo captures the entire drag as one undo step. terminate() + // calls resumeHistory again — depth-counted, becomes a no-op. + scene.restoreAll() + scene.resumeHistory() + scene.update(ctx.fenceId, { start: draft.start, end: draft.end } as Partial) + if (!draft.detached) { + for (const linked of draft.linkedUpdates) { + scene.update( + linked.id as AnyNodeId, + { + start: linked.start, + end: linked.end, + } as Partial, + ) + } + } + return true + }, + + cancel: (_ctx, _scene) => { + // No-op — createDragSession.cancel() calls scene.restoreAll() + // which puts every touched node back via the snapshot. + }, + } diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts index 0d97e79c..c7b1adee 100644 --- a/packages/nodes/src/fence/definition.ts +++ b/packages/nodes/src/fence/definition.ts @@ -76,6 +76,10 @@ export const fenceDefinition: NodeDefinition = { // actions/curve.ts; this component is the React wrapper using // useDragAction + the cursor visuals. curve: () => import('./curve-tool'), + // Triggered by useEditor.movingFenceEndpoint. Pure logic in + // actions/move-endpoint.ts (linked-fence cascade, alt-detach, + // single-undo dance). Wrapper owns the angle label + detach badge. + 'move-endpoint': () => import('./move-endpoint-tool'), }, toolHints: [ diff --git a/packages/nodes/src/fence/move-endpoint-tool.tsx b/packages/nodes/src/fence/move-endpoint-tool.tsx new file mode 100644 index 00000000..16c09e98 --- /dev/null +++ b/packages/nodes/src/fence/move-endpoint-tool.tsx @@ -0,0 +1,213 @@ +'use client' + +import { type FenceNode, useScene, type WallNode } from '@pascal-app/core' +import { + CursorSphere, + type FencePlanPoint, + formatAngleRadians, + getAngleToSegmentReference, + getSegmentAngleReferenceAtPoint, + type MovingFenceEndpoint, + triggerSFX, + useDragAction, + useEditor, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { useEffect, useMemo, useState } from 'react' +import { moveFenceEndpointDragAction } from './actions/move-endpoint' + +/** + * Phase 5 Stage D — thin React wrapper around `moveFenceEndpointDragAction`. + * + * Replaces the legacy `MoveFenceEndpointTool` (425 LoC). All the math + * (snap, linked cascade, length gate, single-undo dance) lives in the + * pure action; this wrapper owns the React-only surface: + * + * - Live cursor sphere tracking the moving endpoint (subscribed from + * `useScene` so it follows the draft as `apply()` writes). + * - Alt-key detach badge — pure UX, reads window keystate so the badge + * updates without requiring a pointer move. + * - Angle label between this segment and any neighbour segment sharing + * the dragged endpoint — same legacy treatment. + * + * Mounted by the legacy ToolManager via the `move-endpoint` affordance + * key. `target.fence` + `target.endpoint` come from the editor store + * (`useEditor.movingFenceEndpoint`). + */ +type SegmentLike = { + id: string + start: FencePlanPoint + end: FencePlanPoint + curveOffset?: number +} + +function referenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] { + return [ + ...walls.map((w) => ({ id: w.id, start: w.start, end: w.end, curveOffset: w.curveOffset })), + ...fences.map((f) => ({ id: f.id, start: f.start, end: f.end, curveOffset: f.curveOffset })), + ] +} + +function pickAngleLabel(args: { + fenceId: FenceNode['id'] + start: FencePlanPoint + end: FencePlanPoint + curveOffset?: number + segments: SegmentLike[] +}): { label: string; position: [number, number, number] } | null { + const target: SegmentLike = { + id: args.fenceId, + start: args.start, + end: args.end, + curveOffset: args.curveOffset, + } + for (const endpoint of [args.start, args.end] as FencePlanPoint[]) { + const targetRef = getSegmentAngleReferenceAtPoint(endpoint, target) + if (!targetRef) continue + const neighbour = args.segments.find( + (s) => s.id !== args.fenceId && Boolean(getSegmentAngleReferenceAtPoint(endpoint, s)), + ) + if (!neighbour) continue + const neighbourRef = getSegmentAngleReferenceAtPoint(endpoint, neighbour) + if (!neighbourRef) continue + const angle = getAngleToSegmentReference(targetRef.vector, neighbourRef) + if (angle === null) continue + return { + label: formatAngleRadians(angle), + position: [endpoint[0], 0.34, endpoint[1]], + } + } + return null +} + +export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = ({ target }) => { + const fenceId = target.fence.id + const endpoint = target.endpoint + const initialPoint: FencePlanPoint = + endpoint === 'start' + ? [target.fence.start[0], target.fence.start[1]] + : [target.fence.end[0], target.fence.end[1]] + + const [altPressed, setAltPressed] = useState(false) + + const exitMoveMode = (committed: boolean) => { + if (committed) triggerSFX('sfx:item-place') + useViewer.getState().setSelection({ selectedIds: [fenceId] }) + useEditor.getState().setMovingFenceEndpoint(null) + } + + useDragAction({ + active: true, + action: moveFenceEndpointDragAction, + initial: { + node: target.fence, + handleId: endpoint, + point: initialPoint, + }, + onCommit: () => exitMoveMode(true), + onCancel: () => exitMoveMode(false), + }) + + // Live subscriptions — the action writes onto the fence node every + // grid:move, so the cursor + angle label can mirror current state. + const live = useScene((s) => s.nodes[fenceId]) + const liveFence = live?.type === 'fence' ? (live as FenceNode) : null + const liveStart = liveFence?.start ?? target.fence.start + const liveEnd = liveFence?.end ?? target.fence.end + const movingPoint = endpoint === 'start' ? liveStart : liveEnd + + // Neighbour segments at the parent level — computed once at mount. + const parentId = target.fence.parentId ?? null + const neighbourSegments = useMemo(() => { + const { nodes } = useScene.getState() + const walls: WallNode[] = [] + const fences: FenceNode[] = [] + for (const node of Object.values(nodes)) { + if (!node) continue + if ((node.parentId ?? null) !== parentId) continue + if (node.type === 'wall') walls.push(node) + else if (node.type === 'fence' && node.id !== fenceId) fences.push(node) + } + return referenceSegments(walls, fences) + }, [parentId, fenceId]) + + const angleLabel = useMemo( + () => + pickAngleLabel({ + fenceId, + start: liveStart, + end: liveEnd, + curveOffset: liveFence?.curveOffset ?? target.fence.curveOffset, + segments: neighbourSegments, + }), + [ + fenceId, + liveStart, + liveEnd, + liveFence?.curveOffset, + target.fence.curveOffset, + neighbourSegments, + ], + ) + + // Window-level keystate for the detach badge — independent of grid + // event modifiers so the badge can toggle without a pointer move. + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return + if (e.key === 'Alt') setAltPressed(true) + } + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Alt') setAltPressed(false) + } + const onBlur = () => setAltPressed(false) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + window.addEventListener('blur', onBlur) + return () => { + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + window.removeEventListener('blur', onBlur) + } + }, []) + + const cursorPos: [number, number, number] = [movingPoint[0], 0, movingPoint[1]] + + return ( + + + +
+
+ {altPressed ? 'Detach endpoint' : 'Drag endpoint'} +
+
+ + {angleLabel && ( + +
+ {angleLabel.label} +
+ + )} +
+ ) +} + +export default MoveFenceEndpointTool