'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