diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 0fb7817c..9310c167 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -24,7 +24,11 @@ export { getAngleToSegmentReference, getSegmentAngleReferenceAtPoint, } from './components/tools/shared/segment-angle' -export { isWallLongEnough } from './components/tools/wall/wall-drafting' +export { + getWallGridStep, + isWallLongEnough, + snapScalarToGrid, +} 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' diff --git a/packages/nodes/src/ceiling/actions/move.ts b/packages/nodes/src/ceiling/actions/move.ts deleted file mode 100644 index 25ca95ee..00000000 --- a/packages/nodes/src/ceiling/actions/move.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { - type AnyNode, - type AnyNodeId, - type CeilingNode, - type DragAction, - sceneRegistry, - useLiveTransforms, -} from '@pascal-app/core' -import { triggerSFX } from '@pascal-app/editor' -import type * as THREE from 'three' - -/** - * Phase 5 Stage D — whole-ceiling move drag affordance. - * - * Live-drag exception (same recipe as slab/fence move): translate the - * ceiling MESH visually via `sceneRegistry.nodes.get(ceilingId) - * .position` plus a mirror in `useLiveTransforms`. No scene.update - * during the drag → no React re-render, no polygon CSG rebuild per - * tick. Snaps to a 0.5m grid (no wall/fence corner snap). - * - * On commit the final polygon is written via the single-undo dance - * and the mesh-offset is cleared. - */ - -const GRID_STEP = 0.5 - -function snap(value: number): number { - return Math.round(value / GRID_STEP) * GRID_STEP -} - -function sameSnap(a: [number, number] | null, b: [number, number]): boolean { - return a !== null && a[0] === b[0] && a[1] === b[1] -} - -function translatePolygon( - polygon: Array<[number, number]>, - deltaX: number, - deltaZ: number, -): Array<[number, number]> { - return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number]) -} - -function polygonCenter(polygon: Array<[number, number]>): [number, number] { - if (polygon.length === 0) return [0, 0] - let sx = 0 - let sz = 0 - for (const [x, z] of polygon) { - sx += x - sz += z - } - return [sx / polygon.length, sz / polygon.length] -} - -function setMeshOffset(id: AnyNodeId, deltaX: number, deltaZ: number): void { - const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D | undefined - if (mesh) mesh.position.set(deltaX, 0, deltaZ) -} - -function setLiveTransform( - id: AnyNodeId, - originalCenter: [number, number], - deltaX: number, - deltaZ: number, - height: number, -): void { - useLiveTransforms.getState().set(id, { - position: [originalCenter[0] + deltaX, height, originalCenter[1] + deltaZ], - rotation: 0, - }) -} - -function clearLiveState(id: AnyNodeId): void { - setMeshOffset(id, 0, 0) - useLiveTransforms.getState().clear(id) -} - -export type MoveCeilingCtx = { - ceilingId: AnyNodeId - originalPolygon: Array<[number, number]> - originalHoles: Array> - originalCenter: [number, number] - height: number - dragAnchor: [number, number] | null - lastSnapped: [number, number] | null -} - -export type MoveCeilingDraft = { - deltaX: number - deltaZ: number -} - -export const moveCeilingDragAction: DragAction = { - begin: (input) => { - const ceiling = input.node as CeilingNode | undefined - if (!ceiling) throw new Error('[moveCeilingDragAction] begin requires a ceiling node') - const originalPolygon = ceiling.polygon.map(([x, z]) => [x, z] as [number, number]) - return { - ceilingId: ceiling.id as AnyNodeId, - originalPolygon, - originalHoles: (ceiling.holes ?? []).map((h) => - h.map(([x, z]) => [x, z] as [number, number]), - ), - originalCenter: polygonCenter(originalPolygon), - height: ceiling.height ?? 2.5, - dragAnchor: null, - lastSnapped: null, - } - }, - - preview: (ctx, point, _modifiers) => { - const sx = snap(point[0]) - const sz = snap(point[1]) - const snapped: [number, number] = [sx, sz] - if (!sameSnap(ctx.lastSnapped, snapped)) { - if (ctx.lastSnapped !== null) triggerSFX('sfx:grid-snap') - ctx.lastSnapped = snapped - } - if (!ctx.dragAnchor) ctx.dragAnchor = snapped - return { - deltaX: sx - ctx.dragAnchor[0], - deltaZ: sz - ctx.dragAnchor[1], - } - }, - - apply: (draft, ctx, _scene) => { - setMeshOffset(ctx.ceilingId, draft.deltaX, draft.deltaZ) - setLiveTransform(ctx.ceilingId, ctx.originalCenter, draft.deltaX, draft.deltaZ, ctx.height) - return [] - }, - - commit: (draft, ctx, scene) => { - scene.restoreAll() - scene.resumeHistory() - scene.update(ctx.ceilingId, { - polygon: translatePolygon(ctx.originalPolygon, draft.deltaX, draft.deltaZ), - holes: ctx.originalHoles.map((h) => translatePolygon(h, draft.deltaX, draft.deltaZ)), - } as Partial) - clearLiveState(ctx.ceilingId) - return true - }, - - cancel: (ctx, _scene) => { - clearLiveState(ctx.ceilingId) - }, -} diff --git a/packages/nodes/src/ceiling/definition.ts b/packages/nodes/src/ceiling/definition.ts index d6a753d4..515c21b4 100644 --- a/packages/nodes/src/ceiling/definition.ts +++ b/packages/nodes/src/ceiling/definition.ts @@ -58,12 +58,13 @@ export const ceilingDefinition: NodeDefinition = { // with a vertical TSL-gradient connector + ground-shadow lines. tool: () => import('./tool'), - // Stage D — boundary + hole editors ported. Whole-ceiling move kept - // on the legacy MoveCeilingTool for the same reason as slab (live- - // drag mesh.position one-frame teleport on commit). + // Stage D — all four ceiling drag-affordances live in this folder. + // 1:1 port of the legacy tools (scene.update per tick + history + // dance + preview fill/outline overlay on move). affordanceTools: { 'boundary-edit': () => import('./boundary-editor'), 'hole-edit': () => import('./hole-editor'), + move: () => import('./move-tool'), }, renderer: { diff --git a/packages/nodes/src/ceiling/move-tool.tsx b/packages/nodes/src/ceiling/move-tool.tsx index 137ec283..205d57e5 100644 --- a/packages/nodes/src/ceiling/move-tool.tsx +++ b/packages/nodes/src/ceiling/move-tool.tsx @@ -1,77 +1,270 @@ 'use client' -import { type CeilingNode, emitter, type GridEvent } from '@pascal-app/core' -import { CursorSphere, triggerSFX, useDragAction, useEditor } from '@pascal-app/editor' +import { + type AnyNodeId, + type CeilingNode, + emitter, + type GridEvent, + useScene, +} from '@pascal-app/core' +import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useMemo, useRef } from 'react' -import type { Group } from 'three' -import { moveCeilingDragAction } from './actions/move' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three' /** - * Phase 5 Stage D — thin React wrapper around `moveCeilingDragAction`. + * Phase 5 Stage D — ceiling whole-move tool (kind-owned). * - * Same shape as `slab/move-tool.tsx`: cursor sphere follows the raw - * grid pointer via direct ref mutation, the ceiling mesh translates - * visually via `mesh.position` + `useLiveTransforms`, scene polygon is - * written only on commit (single-undo dance). - * - * No preview fill / outline mesh — moving a translucent overlay every - * tick adds the same per-frame React reconciliation cost we're trying - * to avoid here. The real ceiling mesh translates in place; that's - * enough visual feedback. + * 1:1 port of the legacy `MoveCeilingTool`. 0.5m grid snap, scene.update + * per tick, history dance on commit, preview fill + outline overlay + * matching the legacy. */ -export const CeilingMoveTool: React.FC<{ node: CeilingNode }> = ({ node }) => { - const ceilingId = node.id - const cursorRef = useRef(null) +function snap(value: number) { + return Math.round(value * 2) / 2 +} - const initialCenter: [number, number] = useMemo(() => { - if (node.polygon.length === 0) return [0, 0] - let sx = 0 - let sz = 0 - for (const [x, z] of node.polygon) { - sx += x - sz += z - } - return [sx / node.polygon.length, sz / node.polygon.length] - }, [node.polygon]) +function translatePolygon( + polygon: Array<[number, number]>, + deltaX: number, + deltaZ: number, +): Array<[number, number]> { + return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number]) +} - useEffect(() => { - const onMove = (event: GridEvent) => { - if (!cursorRef.current) return - cursorRef.current.position.set( - event.localPosition[0], - event.localPosition[1], - event.localPosition[2], - ) - } - emitter.on('grid:move', onMove) - return () => { - emitter.off('grid:move', onMove) - } +function getPolygonCenter(polygon: Array<[number, number]>): [number, number] { + if (polygon.length === 0) return [0, 0] + let sumX = 0 + let sumZ = 0 + for (const [x, z] of polygon) { + sumX += x + sumZ += z + } + return [sumX / polygon.length, sumZ / polygon.length] +} + +export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { + const activatedAtRef = useRef(Date.now()) + const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number])) + const originalHolesRef = useRef( + (node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])), + ) + const dragAnchorRef = useRef<[number, number] | null>(null) + const previousGridPosRef = useRef<[number, number] | null>(null) + const previousCursorPosRef = useRef<[number, number, number] | null>(null) + const previousDeltaRef = useRef<[number, number] | null>(null) + const previewRef = useRef<{ + polygon: Array<[number, number]> + holes: Array> + } | null>(null) + + const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => { + const center = getPolygonCenter(node.polygon) + return [center[0], node.height ?? 2.5, center[1]] + }) + const [previewPolygon, setPreviewPolygon] = useState>(node.polygon) + const [previewHoles, setPreviewHoles] = useState>>(node.holes ?? []) + + const exitMoveMode = useCallback(() => { + useEditor.getState().setMovingNode(null) }, []) - const exitMoveMode = (committed: boolean) => { - if (committed) triggerSFX('sfx:item-place') - useViewer.getState().setSelection({ selectedIds: [ceilingId] }) - useEditor.getState().setMovingNode(null) - } + useEffect(() => { + const originalPolygon = originalPolygonRef.current + const originalHoles = originalHolesRef.current - useDragAction({ - active: true, - action: moveCeilingDragAction, - initial: { - node, - point: initialCenter, - }, - onCommit: () => exitMoveMode(true), - onCancel: () => exitMoveMode(false), - }) + useScene.temporal.getState().pause() + let wasCommitted = false + + const applyPreview = ( + polygon: Array<[number, number]>, + holes: Array>, + ) => { + previewRef.current = { polygon, holes } + setPreviewPolygon(polygon) + setPreviewHoles(holes) + const center = getPolygonCenter(polygon) + const nextCursorPos: [number, number, number] = [center[0], node.height ?? 2.5, center[1]] + if ( + !previousCursorPosRef.current || + previousCursorPosRef.current[0] !== nextCursorPos[0] || + previousCursorPosRef.current[1] !== nextCursorPos[1] || + previousCursorPosRef.current[2] !== nextCursorPos[2] + ) { + previousCursorPosRef.current = nextCursorPos + setCursorLocalPos(nextCursorPos) + } + useScene.getState().updateNode(node.id, { polygon, holes }) + useScene.getState().markDirty(node.id as AnyNodeId) + } + + const restoreOriginal = () => { + setPreviewPolygon(originalPolygon) + setPreviewHoles(originalHoles) + useScene.getState().updateNode(node.id, { + holes: originalHoles, + polygon: originalPolygon, + }) + useScene.getState().markDirty(node.id as AnyNodeId) + } + + const onGridMove = (event: GridEvent) => { + const localX = snap(event.localPosition[0]) + const localZ = snap(event.localPosition[2]) + + if ( + previousGridPosRef.current && + (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1]) + ) { + triggerSFX('sfx:grid-snap') + } + previousGridPosRef.current = [localX, localZ] + + const anchor = dragAnchorRef.current ?? [localX, localZ] + dragAnchorRef.current = anchor + + const deltaX = localX - anchor[0] + const deltaZ = localZ - anchor[1] + + if ( + previousDeltaRef.current && + previousDeltaRef.current[0] === deltaX && + previousDeltaRef.current[1] === deltaZ + ) { + return + } + previousDeltaRef.current = [deltaX, deltaZ] + + applyPreview( + translatePolygon(originalPolygon, deltaX, deltaZ), + originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)), + ) + } + + const onGridClick = (event: GridEvent) => { + if (Date.now() - activatedAtRef.current < 150) { + event.nativeEvent?.stopPropagation?.() + return + } + + const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles } + + wasCommitted = true + + // Restore original baseline while paused so the next resume+update + // registers as a single tracked change (undo reverts to original). + useScene.getState().updateNode(node.id, { + polygon: originalPolygon, + holes: originalHoles, + }) + + useScene.temporal.getState().resume() + useScene.getState().updateNode(node.id, preview) + useScene.getState().markDirty(node.id as AnyNodeId) + useScene.temporal.getState().pause() + + triggerSFX('sfx:item-place') + useViewer.getState().setSelection({ selectedIds: [node.id] }) + exitMoveMode() + event.nativeEvent?.stopPropagation?.() + } + + const onCancel = () => { + restoreOriginal() + useViewer.getState().setSelection({ selectedIds: [node.id] }) + useScene.temporal.getState().resume() + markToolCancelConsumed() + exitMoveMode() + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + emitter.on('tool:cancel', onCancel) + + return () => { + if (!wasCommitted) { + restoreOriginal() + } + useScene.temporal.getState().resume() + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + emitter.off('tool:cancel', onCancel) + } + }, [exitMoveMode, node.height, node.id]) + + const previewFillGeometry = useMemo( + () => createCeilingPreviewGeometry(previewPolygon, previewHoles), + [previewHoles, previewPolygon], + ) + + const previewOutlineGeometry = useMemo( + () => createCeilingOutlineGeometry(previewPolygon), + [previewPolygon], + ) return ( - + + + + {/* @ts-ignore */} + + + + ) } -export default CeilingMoveTool +function createCeilingPreviewGeometry( + polygon: Array<[number, number]>, + holes: Array>, +): BufferGeometry { + if (polygon.length < 3) return new BufferGeometry() + + const shape = new Shape() + const [firstX, firstZ] = polygon[0]! + shape.moveTo(firstX, -firstZ) + + for (let i = 1; i < polygon.length; i++) { + const [x, z] = polygon[i]! + shape.lineTo(x, -z) + } + shape.closePath() + + for (const holePolygon of holes) { + if (holePolygon.length < 3) continue + const hole = new Path() + const [hx, hz] = holePolygon[0]! + hole.moveTo(hx, -hz) + for (let i = 1; i < holePolygon.length; i++) { + const [x, z] = holePolygon[i]! + hole.lineTo(x, -z) + } + hole.closePath() + shape.holes.push(hole) + } + + const geometry = new ShapeGeometry(shape) + geometry.rotateX(-Math.PI / 2) + geometry.computeVertexNormals() + return geometry +} + +function createCeilingOutlineGeometry(polygon: Array<[number, number]>): BufferGeometry { + const geometry = new BufferGeometry() + if (polygon.length < 2) return geometry + + const points = polygon.map(([x, z]) => new Vector3(x, 0, z)) + const [firstX, firstZ] = polygon[0]! + points.push(new Vector3(firstX, 0, firstZ)) + geometry.setFromPoints(points) + return geometry +} + +export default MoveCeilingTool diff --git a/packages/nodes/src/fence/actions/curve.ts b/packages/nodes/src/fence/actions/curve.ts deleted file mode 100644 index eb5e7bf5..00000000 --- a/packages/nodes/src/fence/actions/curve.ts +++ /dev/null @@ -1,112 +0,0 @@ -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**: single-undo dance — `restoreAll` → `resumeHistory` → - * re-apply final draft so zundo captures the whole drag as one - * Ctrl-Z step. Rejected when the offset didn't actually change. - * - **cancel**: no-op — `createDragSession.cancel()` calls - * `scene.restoreAll()` via the snapshot. - * - * Pure data: trivially unit-testable, doesn't import React. - */ - -type CurveFenceCtx = { - nodeId: AnyNodeId - originalCurveOffset: number - chord: ReturnType - 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) => { - // Single-undo dance — ALWAYS push a pastState entry, even when the - // offset didn't actually change. The "no-op" case (small drag that - // `normalizeWallCurveOffset` snaps back to 0) used to return false - // here, but that bypassed pastStates entirely; the next Ctrl-Z then - // fell through to whatever was on the stack before activation - // (typically the fence creation), making it look like the bend - // cancelled the create. - // - // Pushing on every commit means a no-op bend's first Ctrl-Z absorbs - // a silent entry (no visible change), then subsequent Ctrl-Z's roll - // back the real prior actions. Matches typical editor behavior. - scene.restoreAll() - scene.resumeHistory() - scene.update(ctx.nodeId, { curveOffset: draft.curveOffset } 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/actions/move.ts b/packages/nodes/src/fence/actions/move.ts deleted file mode 100644 index 78d61cd8..00000000 --- a/packages/nodes/src/fence/actions/move.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { - type AnyNode, - type AnyNodeId, - type DragAction, - type FenceNode, - type LevelNode, - sceneRegistry, - useLiveTransforms, - useScene, - type WallNode, -} from '@pascal-app/core' -import { type FencePlanPoint, snapFenceDraftPoint, triggerSFX } from '@pascal-app/editor' -import type * as THREE from 'three' - -function sameSnap(a: FencePlanPoint | null, b: FencePlanPoint): boolean { - return a !== null && a[0] === b[0] && a[1] === b[1] -} - -/** - * Phase 5 Stage D — whole-fence move drag affordance. - * - * Migrates `MoveFenceTool` (302 LoC legacy) to the `DragAction` primitive. - * - * Visual strategy — **live-drag exception** (see - * `editor/wiki/architecture/tools.md`): instead of writing the new - * start/end into the scene store on every pointer tick (which would - * re-rebuild the fence geometry — many posts, many infill panels — - * every frame), the action keeps the underlying node untouched during - * the drag and visually offsets the mesh directly via - * `sceneRegistry.nodes.get(id).position` plus a mirror entry in - * `useLiveTransforms`. On commit, the final start/end are written to - * the scene with the single-undo dance — one Ctrl-Z reverses the whole - * drag, the geometry rebuilds once. - * - * Linked-fence cascade: any other fence in the same parent whose - * start or end matched one of this fence's endpoints at activation - * follows the move so corners stay connected. No alt-detach (legacy - * doesn't expose it for the whole-fence drag). - */ - -function samePoint(a: FencePlanPoint, b: FencePlanPoint): boolean { - return a[0] === b[0] && a[1] === b[1] -} - -type LinkedFenceSnapshot = { - id: FenceNode['id'] - start: FencePlanPoint - end: FencePlanPoint -} - -function snapshotLinked(args: { - fenceId: FenceNode['id'] - parentId: string | null - originalStart: FencePlanPoint - originalEnd: FencePlanPoint -}): LinkedFenceSnapshot[] { - const { fenceId, parentId, originalStart, originalEnd } = args - 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, originalStart) || - samePoint(node.start, originalEnd) || - samePoint(node.end, originalStart) || - samePoint(node.end, originalEnd) - ) - ) - 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[], - originalStart: FencePlanPoint, - originalEnd: FencePlanPoint, - nextStart: FencePlanPoint, - nextEnd: FencePlanPoint, -): LinkedFenceSnapshot[] { - return linked.map((l) => ({ - id: l.id, - start: samePoint(l.start, originalStart) - ? nextStart - : samePoint(l.start, originalEnd) - ? nextEnd - : l.start, - end: samePoint(l.end, originalStart) - ? nextStart - : samePoint(l.end, originalEnd) - ? nextEnd - : l.end, - })) -} - -function setMeshOffset(fenceId: AnyNodeId, deltaX: number, deltaZ: number): void { - const mesh = sceneRegistry.nodes.get(fenceId) as THREE.Object3D | undefined - if (mesh) mesh.position.set(deltaX, 0, deltaZ) -} - -function setLiveTransform( - fenceId: AnyNodeId, - originalStart: FencePlanPoint, - originalEnd: FencePlanPoint, - deltaX: number, - deltaZ: number, -): void { - const cx = (originalStart[0] + originalEnd[0]) / 2 - const cz = (originalStart[1] + originalEnd[1]) / 2 - useLiveTransforms.getState().set(fenceId, { - position: [cx + deltaX, 0, cz + deltaZ], - rotation: 0, - }) -} - -function clearLiveState(fenceId: AnyNodeId, linked: LinkedFenceSnapshot[]): void { - setMeshOffset(fenceId, 0, 0) - useLiveTransforms.getState().clear(fenceId) - for (const l of linked) { - setMeshOffset(l.id as AnyNodeId, 0, 0) - useLiveTransforms.getState().clear(l.id) - } -} - -export type MoveFenceCtx = { - fenceId: AnyNodeId - originalStart: FencePlanPoint - originalEnd: FencePlanPoint - parentId: string | null - linkedOriginals: LinkedFenceSnapshot[] - levelWalls: WallNode[] - levelFences: FenceNode[] - // Mutable: latched on the first preview call to the snapped pointer - // position. Subsequent previews compute delta = pointer - dragAnchor. - dragAnchor: FencePlanPoint | null - // Mutable: tracks the last snapped pointer so preview can emit a - // grid-snap sfx when the snapped value changes. Matches the legacy - // MoveFenceTool's per-tick sound. - lastSnapped: FencePlanPoint | null -} - -export type MoveFenceDraft = { - start: FencePlanPoint - end: FencePlanPoint - deltaX: number - deltaZ: number - linkedUpdates: LinkedFenceSnapshot[] -} - -export const moveFenceDragAction: DragAction = { - begin: (input) => { - const fence = input.node as FenceNode | undefined - if (!fence) throw new Error('[moveFenceDragAction] begin requires a fence node') - const parentId = fence.parentId ?? null - const originalStart: FencePlanPoint = [fence.start[0], fence.start[1]] - const originalEnd: FencePlanPoint = [fence.end[0], fence.end[1]] - - const { nodes } = useScene.getState() - const levelNode = - parentId && nodes[parentId as AnyNodeId]?.type === 'level' - ? (nodes[parentId as AnyNodeId] as LevelNode) - : null - const levelWalls: WallNode[] = [] - const levelFences: FenceNode[] = [] - if (levelNode) { - for (const childId of levelNode.children ?? []) { - const child = nodes[childId as AnyNodeId] - if (!child) continue - if (child.type === 'wall') levelWalls.push(child) - else if (child.type === 'fence') levelFences.push(child) - } - } - - return { - fenceId: fence.id as AnyNodeId, - originalStart, - originalEnd, - parentId, - linkedOriginals: snapshotLinked({ fenceId: fence.id, parentId, originalStart, originalEnd }), - levelWalls, - levelFences, - dragAnchor: null, - lastSnapped: null, - } - }, - - preview: (ctx, point, _modifiers) => { - const snapped = snapFenceDraftPoint({ - point: [point[0], point[1]], - walls: ctx.levelWalls, - fences: ctx.levelFences, - ignoreFenceIds: [ctx.fenceId as string], - }) - // Emit grid-snap sfx when the snapped position changes between - // ticks — matches the legacy MoveFenceTool's user feedback. - if (!sameSnap(ctx.lastSnapped, snapped)) { - if (ctx.lastSnapped !== null) triggerSFX('sfx:grid-snap') - ctx.lastSnapped = snapped - } - // Latch the anchor on the first preview tick — matches legacy - // "drag is delta from first move" semantics so the fence doesn't - // jump to wherever the activation click landed. - if (!ctx.dragAnchor) ctx.dragAnchor = snapped - const deltaX = snapped[0] - ctx.dragAnchor[0] - const deltaZ = snapped[1] - ctx.dragAnchor[1] - const nextStart: FencePlanPoint = [ctx.originalStart[0] + deltaX, ctx.originalStart[1] + deltaZ] - const nextEnd: FencePlanPoint = [ctx.originalEnd[0] + deltaX, ctx.originalEnd[1] + deltaZ] - return { - start: nextStart, - end: nextEnd, - deltaX, - deltaZ, - linkedUpdates: linkedCascade( - ctx.linkedOriginals, - ctx.originalStart, - ctx.originalEnd, - nextStart, - nextEnd, - ), - } - }, - - apply: (draft, ctx, _scene) => { - // Live-drag exception — visual-only via mesh.position + useLiveTransforms. - // No scene.update during the drag (would re-rebuild fence geometry every - // tick). The scene store still has the original start/end; commit() writes - // the final values. - setMeshOffset(ctx.fenceId, draft.deltaX, draft.deltaZ) - setLiveTransform(ctx.fenceId, ctx.originalStart, ctx.originalEnd, draft.deltaX, draft.deltaZ) - for (const linked of ctx.linkedOriginals) { - setMeshOffset(linked.id as AnyNodeId, draft.deltaX, draft.deltaZ) - setLiveTransform(linked.id as AnyNodeId, linked.start, linked.end, draft.deltaX, draft.deltaZ) - } - // Return no dirty IDs — geometry rebuild deferred to commit. - return [] - }, - - commit: (draft, ctx, scene) => { - // Always push a pastState entry — see fence/actions/curve.ts. The - // no-movement case would otherwise let Ctrl-Z cancel the fence - // creation that preceded the move. - // - // Single-undo dance: snapshot is empty (live-drag exception, no - // scene.update during apply), so restoreAll is a no-op. Resume, - // then write the final draft so zundo records original → final - // as one diff. - scene.restoreAll() - scene.resumeHistory() - scene.update(ctx.fenceId, { - start: draft.start, - end: draft.end, - } as Partial) - for (const linked of draft.linkedUpdates) { - scene.update( - linked.id as AnyNodeId, - { - start: linked.start, - end: linked.end, - } as Partial, - ) - } - - // Clear live-drag visual state — the scene store now has the final - // values, so the renderer will re-mount the mesh at its real position - // and useLiveTransforms is no longer needed. - clearLiveState(ctx.fenceId, ctx.linkedOriginals) - return true - }, - - cancel: (ctx, _scene) => { - // Clear live-drag visual state so the mesh snaps back to the - // original (still-unchanged) scene position. No scene rollback - // needed — we never wrote anything. - clearLiveState(ctx.fenceId, ctx.linkedOriginals) - }, -} diff --git a/packages/nodes/src/fence/curve-tool.tsx b/packages/nodes/src/fence/curve-tool.tsx index 63a7525a..fbbeaa6d 100644 --- a/packages/nodes/src/fence/curve-tool.tsx +++ b/packages/nodes/src/fence/curve-tool.tsx @@ -1,74 +1,196 @@ 'use client' -import { type FenceNode, getWallMidpointHandlePoint, useScene } from '@pascal-app/core' -import { CursorSphere, triggerSFX, useDragAction, useEditor } from '@pascal-app/editor' +import { + type AnyNodeId, + emitter, + type FenceNode, + type GridEvent, + getClampedWallCurveOffset, + getMaxWallCurveOffset, + getWallChordFrame, + getWallMidpointHandlePoint, + normalizeWallCurveOffset, + pauseSceneHistory, + resumeSceneHistory, + useScene, +} from '@pascal-app/core' +import { + CursorSphere, + getWallGridStep, + markToolCancelConsumed, + snapScalarToGrid, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useState } from 'react' -import { curveFenceDragAction } from './actions/curve' +import { useCallback, useEffect, useRef, useState } from 'react' /** - * Phase 5 Stage D — thin React wrapper around `curveFenceDragAction`. + * Phase 5 Stage D — fence curve tool (kind-owned). * - * 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). + * 1:1 port of the legacy `CurveFenceTool` (editor/components/tools/ + * fence/curve-fence-tool.tsx). Same snap pipeline, same Shift override, + * same history dance, same activation grace. Imports adjusted to the + * `@pascal-app/editor` public surface (triggerSFX, markToolCancelConsumed, + * getWallGridStep, snapScalarToGrid). Mounted via + * `def.affordanceTools.curve` — ToolManager picks it up at runtime, + * legacy fallback is unused when this kind is registered. */ -export const FenceCurveTool: React.FC<{ node: FenceNode }> = ({ node }) => { +export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { + const activatedAtRef = useRef(Date.now()) + const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node)) + const previousCurveOffsetRef = useRef(null) + const shiftPressedRef = useRef(false) + const previewOffsetRef = useRef(originalCurveOffsetRef.current) + const initialHandle = getWallMidpointHandlePoint(node) - const [cursorPos, setCursorPos] = useState<[number, number, number]>([ + const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>([ initialHandle.x, 0, initialHandle.y, ]) - const exitCurveMode = (committed: boolean) => { - if (committed) triggerSFX('sfx:item-place') - useViewer.getState().setSelection({ selectedIds: [node.id] }) + const exitCurveMode = useCallback(() => { 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(true), - onCancel: () => exitCurveMode(false), - }) - - // 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]) + const nodeId = node.id + const originalCurveOffset = originalCurveOffsetRef.current + const chord = getWallChordFrame(node) + const maxCurveOffset = getMaxWallCurveOffset(node) + + pauseSceneHistory(useScene) + let wasCommitted = false + + const applyPreview = (curveOffset: number) => { + if (previewOffsetRef.current === curveOffset) { + return + } + previewOffsetRef.current = curveOffset + + const nextNode = { + ...node, + curveOffset, + } + const handlePoint = getWallMidpointHandlePoint(nextNode) + setCursorLocalPos([handlePoint.x, 0, handlePoint.y]) + useScene.getState().updateNode(nodeId, { curveOffset }) + useScene.getState().markDirty(nodeId as AnyNodeId) + } + + const restoreOriginal = () => { + if (previewOffsetRef.current === originalCurveOffset) { + return + } + previewOffsetRef.current = originalCurveOffset + useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset }) + useScene.getState().markDirty(nodeId as AnyNodeId) + } + + const onGridMove = (event: GridEvent) => { + const snapStep = getWallGridStep() + const localX = shiftPressedRef.current + ? event.localPosition[0] + : snapScalarToGrid(event.localPosition[0], snapStep) + const localZ = shiftPressedRef.current + ? event.localPosition[2] + : snapScalarToGrid(event.localPosition[2], snapStep) + + const offsetFromMidpoint = -( + (localX - chord.midpoint.x) * chord.normal.x + + (localZ - chord.midpoint.y) * chord.normal.y + ) + const snappedOffset = shiftPressedRef.current + ? offsetFromMidpoint + : snapScalarToGrid(offsetFromMidpoint, snapStep) + const nextCurveOffset = normalizeWallCurveOffset( + node, + Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)), + ) + + if ( + previousCurveOffsetRef.current !== null && + nextCurveOffset !== previousCurveOffsetRef.current + ) { + triggerSFX('sfx:grid-snap') + } + previousCurveOffsetRef.current = nextCurveOffset + + applyPreview(nextCurveOffset) + } + + const onGridClick = (event: GridEvent) => { + if (Date.now() - activatedAtRef.current < 150) { + event.nativeEvent?.stopPropagation?.() + return + } + + const curveOffset = previewOffsetRef.current + wasCommitted = true + + if (curveOffset !== originalCurveOffset) { + // Restore original baseline while paused so the next resume+update + // registers as a single tracked change (undo reverts to original). + useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset }) + useScene.getState().markDirty(nodeId as AnyNodeId) + + resumeSceneHistory(useScene) + useScene.getState().updateNode(nodeId, { curveOffset }) + useScene.getState().markDirty(nodeId as AnyNodeId) + pauseSceneHistory(useScene) + } + + triggerSFX('sfx:item-place') + useViewer.getState().setSelection({ selectedIds: [nodeId] }) + exitCurveMode() + event.nativeEvent?.stopPropagation?.() + } + + const onCancel = () => { + restoreOriginal() + useViewer.getState().setSelection({ selectedIds: [nodeId] }) + resumeSceneHistory(useScene) + markToolCancelConsumed() + exitCurveMode() + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Shift') { + shiftPressedRef.current = true + } + } + + const onKeyUp = (event: KeyboardEvent) => { + if (event.key === 'Shift') { + shiftPressedRef.current = false + } + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + emitter.on('tool:cancel', onCancel) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + + return () => { + if (!wasCommitted) { + restoreOriginal() + } + resumeSceneHistory(useScene) + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + emitter.off('tool:cancel', onCancel) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + } + }, [exitCurveMode, node]) return ( - + ) } -export default FenceCurveTool +export default CurveFenceTool diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts index f828f037..3b12e4eb 100644 --- a/packages/nodes/src/fence/definition.ts +++ b/packages/nodes/src/fence/definition.ts @@ -74,16 +74,15 @@ export const fenceDefinition: NodeDefinition = { // Legacy `floorplanFenceEntries` short-circuits to [] when fence is // registered (see floorplan-panel.tsx). floorplan: buildFenceFloorplan, - // Stage D — partial port. Endpoint drag (with linked-fence cascade, - // alt-detach, angle label) is ported here; curve + whole-fence move - // are intentionally kept on the legacy CurveFenceTool / MoveFenceTool - // because the legacy code is more polished than the ports were - // (cursor anchoring, snap step, performance, history). Those ports - // remain in `curve-tool.tsx` + `move-tool.tsx` + their `actions/` - // siblings for the next iteration; the legacy fallback runs until - // they reach parity. + // Stage D — all four fence drag-affordances live in this folder. + // curve / move-endpoint / move are 1:1 ports of the legacy tools + // (same snap pipeline, same history dance, same cursor render), + // relocated under `@pascal-app/nodes` and dispatched via + // `def.affordanceTools`. Placement lives in `def.tool` (see below). affordanceTools: { + curve: () => import('./curve-tool'), 'move-endpoint': () => import('./move-endpoint-tool'), + move: () => import('./move-tool'), }, toolHints: [ diff --git a/packages/nodes/src/fence/move-tool.tsx b/packages/nodes/src/fence/move-tool.tsx index f1e2601d..6a7a1ffb 100644 --- a/packages/nodes/src/fence/move-tool.tsx +++ b/packages/nodes/src/fence/move-tool.tsx @@ -1,65 +1,315 @@ 'use client' -import { emitter, type FenceNode, type GridEvent } from '@pascal-app/core' -import { CursorSphere, triggerSFX, useDragAction, useEditor } from '@pascal-app/editor' +import { + type AnyNodeId, + emitter, + type FenceNode, + type GridEvent, + type LevelNode, + sceneRegistry, + useLiveTransforms, + useScene, + type WallNode, +} from '@pascal-app/core' +import { + CursorSphere, + markToolCancelConsumed, + snapFenceDraftPoint, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useRef } from 'react' -import type { Group } from 'three' -import { moveFenceDragAction } from './actions/move' +import { useCallback, useEffect, useRef, useState } from 'react' +import type * as THREE from 'three' /** - * Phase 5 Stage D — thin React wrapper around `moveFenceDragAction`. + * Phase 5 Stage D — fence whole-move tool (kind-owned). * - * Cursor sphere follows the raw grid pointer via direct ref mutation — - * no React state, no per-tick re-render. The fence mesh translates - * visually through the action's `mesh.position` + `useLiveTransforms` - * writes (live-drag exception); scene start/end are written on commit - * via the single-undo dance. + * 1:1 port of the legacy `MoveFenceTool`. Same anchor-on-first-move + * delta drag, same linked-fence cascade, same live mesh.position + + * useLiveTransforms exception, same history dance on commit, same + * cursor render at the polygon center (anchor + delta), same + * activation grace. */ -export const FenceMoveTool: React.FC<{ node: FenceNode }> = ({ node }) => { - const fenceId = node.id - const cursorRef = useRef(null) +function samePoint(a: [number, number], b: [number, number]) { + return a[0] === b[0] && a[1] === b[1] +} - useEffect(() => { - const onMove = (event: GridEvent) => { - if (!cursorRef.current) return - cursorRef.current.position.set( - event.localPosition[0], - event.localPosition[1], - event.localPosition[2], +type LinkedFenceSnapshot = { + id: FenceNode['id'] + start: [number, number] + end: [number, number] +} + +function getLinkedFenceSnapshots(args: { + fenceId: FenceNode['id'] + fenceParentId: string | null + originalStart: [number, number] + originalEnd: [number, number] +}) { + const { fenceId, fenceParentId, originalStart, originalEnd } = args + const { nodes } = useScene.getState() + const snapshots: LinkedFenceSnapshot[] = [] + + for (const node of Object.values(nodes)) { + if (!(node?.type === 'fence' && node.id !== fenceId)) { + continue + } + + if ((node.parentId ?? null) !== fenceParentId) { + continue + } + + if ( + !( + samePoint(node.start, originalStart) || + samePoint(node.start, originalEnd) || + samePoint(node.end, originalStart) || + samePoint(node.end, originalEnd) ) + ) { + continue } - emitter.on('grid:move', onMove) - return () => { - emitter.off('grid:move', onMove) - } - }, []) - const exitMoveMode = (committed: boolean) => { - if (committed) triggerSFX('sfx:item-place') - useViewer.getState().setSelection({ selectedIds: [fenceId] }) - useEditor.getState().setMovingNode(null) + snapshots.push({ + id: node.id, + start: [...node.start] as [number, number], + end: [...node.end] as [number, number], + }) } - useDragAction({ - active: true, - action: moveFenceDragAction, - initial: { - node, - // Initial point — useDragAction requires a Vec2. The action's - // begin captures everything else from input.node; this is just - // a placeholder until the first grid:move latches the anchor. - point: [(node.start[0] + node.end[0]) / 2, (node.start[1] + node.end[1]) / 2], - }, - onCommit: () => exitMoveMode(true), - onCancel: () => exitMoveMode(false), + return snapshots +} + +function getLinkedFenceUpdates( + linkedFences: LinkedFenceSnapshot[], + originalStart: [number, number], + originalEnd: [number, number], + nextStart: [number, number], + nextEnd: [number, number], +) { + return linkedFences.map((fence) => ({ + id: fence.id, + start: samePoint(fence.start, originalStart) + ? nextStart + : samePoint(fence.start, originalEnd) + ? nextEnd + : fence.start, + end: samePoint(fence.end, originalStart) + ? nextStart + : samePoint(fence.end, originalEnd) + ? nextEnd + : fence.end, + })) +} + +export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { + const activatedAtRef = useRef(Date.now()) + const previousGridPosRef = useRef<[number, number] | null>(null) + const originalStartRef = useRef<[number, number]>([...node.start] as [number, number]) + const originalEndRef = useRef<[number, number]>([...node.end] as [number, number]) + const linkedOriginalsRef = useRef( + getLinkedFenceSnapshots({ + fenceId: node.id, + fenceParentId: node.parentId ?? null, + originalStart: node.start, + originalEnd: node.end, + }), + ) + const dragAnchorRef = useRef<[number, number] | null>(null) + const nodeIdRef = useRef(node.id) + const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null) + + const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => { + const centerX = (node.start[0] + node.end[0]) / 2 + const centerZ = (node.start[1] + node.end[1]) / 2 + return [centerX, 0, centerZ] }) + const exitMoveMode = useCallback(() => { + useEditor.getState().setMovingNode(null) + }, []) + + useEffect(() => { + const nodeId = nodeIdRef.current + const originalStart = originalStartRef.current + const originalEnd = originalEndRef.current + const levelNode = + node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level' + ? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode) + : null + const levelChildren = levelNode?.children ?? [] + const levelWalls = levelChildren + .map((childId) => useScene.getState().nodes[childId as AnyNodeId]) + .filter((child): child is WallNode => child?.type === 'wall') + const levelFences = levelChildren + .map((childId) => useScene.getState().nodes[childId as AnyNodeId]) + .filter((child): child is FenceNode => child?.type === 'fence') + + useScene.temporal.getState().pause() + let wasCommitted = false + + const setMeshOffset = (fenceId: FenceNode['id'], deltaX: number, deltaZ: number) => { + const mesh = sceneRegistry.nodes.get(fenceId) as THREE.Object3D | undefined + if (!mesh) { + return + } + + mesh.position.set(deltaX, 0, deltaZ) + } + + const setFenceLiveTransform = (fence: FenceNode, deltaX: number, deltaZ: number) => { + const originalCenterX = (fence.start[0] + fence.end[0]) / 2 + const originalCenterZ = (fence.start[1] + fence.end[1]) / 2 + useLiveTransforms.getState().set(fence.id, { + position: [originalCenterX + deltaX, 0, originalCenterZ + deltaZ], + rotation: 0, + }) + } + + const clearPreviewState = () => { + setMeshOffset(nodeId, 0, 0) + useLiveTransforms.getState().clear(nodeId) + + for (const linkedFence of linkedOriginalsRef.current) { + setMeshOffset(linkedFence.id, 0, 0) + useLiveTransforms.getState().clear(linkedFence.id) + } + } + + const applyNodePreview = ( + updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>, + ) => { + useScene.getState().updateNodes( + updates.map((entry) => ({ + id: entry.id as AnyNodeId, + data: { start: entry.start, end: entry.end }, + })), + ) + for (const entry of updates) { + useScene.getState().markDirty(entry.id as AnyNodeId) + } + } + + const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => { + previewRef.current = { start: nextStart, end: nextEnd } + const centerX = (nextStart[0] + nextEnd[0]) / 2 + const centerZ = (nextStart[1] + nextEnd[1]) / 2 + setCursorLocalPos([centerX, 0, centerZ]) + const deltaX = nextStart[0] - originalStart[0] + const deltaZ = nextStart[1] - originalStart[1] + setMeshOffset(nodeId, deltaX, deltaZ) + setFenceLiveTransform(node, deltaX, deltaZ) + + for (const linkedFence of linkedOriginalsRef.current) { + setMeshOffset(linkedFence.id, deltaX, deltaZ) + setFenceLiveTransform( + { + ...node, + id: linkedFence.id, + start: linkedFence.start, + end: linkedFence.end, + }, + deltaX, + deltaZ, + ) + } + } + + const onGridMove = (event: GridEvent) => { + const [localX, localZ] = snapFenceDraftPoint({ + point: [event.localPosition[0], event.localPosition[2]], + walls: levelWalls, + fences: levelFences, + ignoreFenceIds: [nodeId], + }) + + if ( + previousGridPosRef.current && + (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1]) + ) { + triggerSFX('sfx:grid-snap') + } + previousGridPosRef.current = [localX, localZ] + + const anchor = dragAnchorRef.current ?? [localX, localZ] + dragAnchorRef.current = anchor + + const deltaX = localX - anchor[0] + const deltaZ = localZ - anchor[1] + + const nextStart: [number, number] = [originalStart[0] + deltaX, originalStart[1] + deltaZ] + const nextEnd: [number, number] = [originalEnd[0] + deltaX, originalEnd[1] + deltaZ] + + applyPreview(nextStart, nextEnd) + } + + const onGridClick = (event: GridEvent) => { + if (Date.now() - activatedAtRef.current < 150) { + event.nativeEvent?.stopPropagation?.() + return + } + + const preview = previewRef.current ?? { start: originalStart, end: originalEnd } + + wasCommitted = true + + useScene.temporal.getState().resume() + applyNodePreview([ + { id: nodeId, start: preview.start, end: preview.end }, + ...getLinkedFenceUpdates( + linkedOriginalsRef.current, + originalStart, + originalEnd, + preview.start, + preview.end, + ), + ]) + useLiveTransforms.getState().clear(nodeId) + for (const linkedFence of linkedOriginalsRef.current) { + useLiveTransforms.getState().clear(linkedFence.id) + } + useScene.temporal.getState().pause() + + triggerSFX('sfx:item-place') + useViewer.getState().setSelection({ selectedIds: [nodeId] }) + exitMoveMode() + event.nativeEvent?.stopPropagation?.() + } + + const onCancel = () => { + clearPreviewState() + useViewer.getState().setSelection({ selectedIds: [nodeId] }) + useScene.temporal.getState().resume() + markToolCancelConsumed() + exitMoveMode() + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + emitter.on('tool:cancel', onCancel) + + return () => { + if (wasCommitted) { + useLiveTransforms.getState().clear(nodeId) + for (const linkedFence of linkedOriginalsRef.current) { + useLiveTransforms.getState().clear(linkedFence.id) + } + } else { + clearPreviewState() + } + useScene.temporal.getState().resume() + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + emitter.off('tool:cancel', onCancel) + } + }, [exitMoveMode, node]) + return ( - + ) } -export default FenceMoveTool +export default MoveFenceTool diff --git a/packages/nodes/src/slab/actions/move.ts b/packages/nodes/src/slab/actions/move.ts deleted file mode 100644 index 1d58a347..00000000 --- a/packages/nodes/src/slab/actions/move.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { - type AnyNode, - type AnyNodeId, - type DragAction, - type FenceNode, - type LevelNode, - type SlabNode, - sceneRegistry, - useLiveTransforms, - useScene, - type WallNode, -} from '@pascal-app/core' -import { type FencePlanPoint, snapFenceDraftPoint, triggerSFX } from '@pascal-app/editor' -import type * as THREE from 'three' - -function sameSnap(a: FencePlanPoint | null, b: FencePlanPoint): boolean { - return a !== null && a[0] === b[0] && a[1] === b[1] -} - -/** - * Phase 5 Stage D — whole-slab move drag affordance. - * - * Uses the **live-drag exception** (same recipe as fence move): the - * slab MESH is translated visually via `sceneRegistry.nodes.get(slabId) - * .position` plus a mirror entry in `useLiveTransforms`. The scene - * store's polygon stays untouched during the drag — no React re-render - * per tick, no CSG-with-holes rebuild per frame. - * - * On commit the final polygon is written to the scene via the single- - * undo dance, then the mesh-position offset is cleared. The renderer - * picks up the new polygon, the mesh re-mounts at the new world coords, - * and zundo records one diff. - * - * Hosted items don't follow the visual translation (same as legacy — - * item.position is independent of slab.polygon). Acceptable: the slab - * snaps back into place on commit so the visual mismatch is brief. - */ - -function translatePolygon( - polygon: Array<[number, number]>, - deltaX: number, - deltaZ: number, -): Array<[number, number]> { - return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number]) -} - -function polygonCenter(polygon: Array<[number, number]>): [number, number] { - if (polygon.length === 0) return [0, 0] - let sx = 0 - let sz = 0 - for (const [x, z] of polygon) { - sx += x - sz += z - } - return [sx / polygon.length, sz / polygon.length] -} - -function setMeshOffset(id: AnyNodeId, deltaX: number, deltaZ: number): void { - const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D | undefined - if (mesh) mesh.position.set(deltaX, 0, deltaZ) -} - -function setLiveTransform( - id: AnyNodeId, - originalCenter: [number, number], - deltaX: number, - deltaZ: number, -): void { - useLiveTransforms.getState().set(id, { - position: [originalCenter[0] + deltaX, 0, originalCenter[1] + deltaZ], - rotation: 0, - }) -} - -function clearLiveState(id: AnyNodeId): void { - setMeshOffset(id, 0, 0) - useLiveTransforms.getState().clear(id) -} - -export type MoveSlabCtx = { - slabId: AnyNodeId - originalPolygon: Array<[number, number]> - originalHoles: Array> - originalCenter: [number, number] - parentId: string | null - levelWalls: WallNode[] - levelFences: FenceNode[] - dragAnchor: FencePlanPoint | null - lastSnapped: FencePlanPoint | null -} - -export type MoveSlabDraft = { - polygon: Array<[number, number]> - holes: Array> - deltaX: number - deltaZ: number -} - -export const moveSlabDragAction: DragAction = { - begin: (input) => { - const slab = input.node as SlabNode | undefined - if (!slab) throw new Error('[moveSlabDragAction] begin requires a slab node') - const parentId = slab.parentId ?? null - const originalPolygon: Array<[number, number]> = slab.polygon.map( - ([x, z]) => [x, z] as [number, number], - ) - const originalHoles: Array> = (slab.holes ?? []).map((h) => - h.map(([x, z]) => [x, z] as [number, number]), - ) - - const { nodes } = useScene.getState() - const levelNode = - parentId && nodes[parentId as AnyNodeId]?.type === 'level' - ? (nodes[parentId as AnyNodeId] as LevelNode) - : null - const levelWalls: WallNode[] = [] - const levelFences: FenceNode[] = [] - if (levelNode) { - for (const childId of levelNode.children ?? []) { - const child = nodes[childId as AnyNodeId] - if (!child) continue - if (child.type === 'wall') levelWalls.push(child) - else if (child.type === 'fence') levelFences.push(child) - } - } - - return { - slabId: slab.id as AnyNodeId, - originalPolygon, - originalHoles, - originalCenter: polygonCenter(originalPolygon), - parentId, - levelWalls, - levelFences, - dragAnchor: null, - lastSnapped: null, - } - }, - - preview: (ctx, point, _modifiers) => { - const snapped = snapFenceDraftPoint({ - point: [point[0], point[1]], - walls: ctx.levelWalls, - fences: ctx.levelFences, - }) - if (!sameSnap(ctx.lastSnapped, snapped)) { - if (ctx.lastSnapped !== null) triggerSFX('sfx:grid-snap') - ctx.lastSnapped = snapped - } - if (!ctx.dragAnchor) ctx.dragAnchor = snapped - const deltaX = snapped[0] - ctx.dragAnchor[0] - const deltaZ = snapped[1] - ctx.dragAnchor[1] - // Translation is computed lazily on commit — preview only needs the - // deltas for the mesh-offset visual. - return { - polygon: ctx.originalPolygon, - holes: ctx.originalHoles, - deltaX, - deltaZ, - } - }, - - apply: (draft, ctx, _scene) => { - // Live-drag exception: visual translate via Three.js mesh.position + - // useLiveTransforms. No scene.update during the drag, no React - // re-render of the slab geometry, no CSG-with-holes rebuild. - setMeshOffset(ctx.slabId, draft.deltaX, draft.deltaZ) - setLiveTransform(ctx.slabId, ctx.originalCenter, draft.deltaX, draft.deltaZ) - return [] - }, - - commit: (draft, ctx, scene) => { - // Single-undo dance — snapshot is empty (no scene.update during - // apply), restoreAll is a no-op. Resume history, write the final - // polygon. Zundo records one diff: original → translated. - scene.restoreAll() - scene.resumeHistory() - scene.update(ctx.slabId, { - polygon: translatePolygon(ctx.originalPolygon, draft.deltaX, draft.deltaZ), - holes: ctx.originalHoles.map((h) => translatePolygon(h, draft.deltaX, draft.deltaZ)), - } as Partial) - clearLiveState(ctx.slabId) - return true - }, - - cancel: (ctx, _scene) => { - // Clear live-drag visual state — mesh snaps back to its (unchanged) - // scene position. - clearLiveState(ctx.slabId) - }, -} diff --git a/packages/nodes/src/slab/definition.ts b/packages/nodes/src/slab/definition.ts index 1a2c2695..d843a675 100644 --- a/packages/nodes/src/slab/definition.ts +++ b/packages/nodes/src/slab/definition.ts @@ -58,15 +58,14 @@ export const slabDefinition: NodeDefinition = { // with axis/45° snap (Shift to defeat). tool: () => import('./tool'), - // Stage D — boundary + hole editors ported (thin - // wrappers, behaviorally identical to legacy). Whole-slab move kept - // on the legacy MoveSlabTool: the live-drag mesh.position port - // introduced a one-frame teleport on commit (geometry rebuild lags - // the position clear), and the legacy already has acceptable perf - // via RAF-batched markDirty. + // Stage D — all four slab drag-affordances live in this folder. + // boundary-edit / hole-edit are thin wrappers; move + // is a 1:1 port of the legacy MoveSlabTool (scene.update per tick + // with the same history dance, no live-drag exception). affordanceTools: { 'boundary-edit': () => import('./boundary-editor'), 'hole-edit': () => import('./hole-editor'), + move: () => import('./move-tool'), }, // Stage B: pure geometry function. diff --git a/packages/nodes/src/slab/move-tool.tsx b/packages/nodes/src/slab/move-tool.tsx index d2345cbb..779e852b 100644 --- a/packages/nodes/src/slab/move-tool.tsx +++ b/packages/nodes/src/slab/move-tool.tsx @@ -1,74 +1,193 @@ 'use client' -import { emitter, type GridEvent, type SlabNode } from '@pascal-app/core' -import { CursorSphere, triggerSFX, useDragAction, useEditor } from '@pascal-app/editor' +import { + type AnyNodeId, + emitter, + type FenceNode, + type GridEvent, + type LevelNode, + type SlabNode, + useScene, + type WallNode, +} from '@pascal-app/core' +import { + CursorSphere, + markToolCancelConsumed, + snapFenceDraftPoint, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useMemo, useRef } from 'react' -import type { Group } from 'three' -import { moveSlabDragAction } from './actions/move' +import { useCallback, useEffect, useRef, useState } from 'react' /** - * Phase 5 Stage D — thin React wrapper around `moveSlabDragAction`. + * Phase 5 Stage D — slab whole-move tool (kind-owned). * - * The cursor sphere follows the raw grid pointer via direct ref mutation - * (no React state, no per-tick re-render). The slab mesh itself is - * translated by the action using `mesh.position` + `useLiveTransforms` - * (live-drag exception). Scene polygon is only written on commit. + * 1:1 port of the legacy `MoveSlabTool`. scene.update writes polygon + * + holes per tick (renderer keeps up via RAF-batched markDirty), + * single-undo dance on commit, cursor at polygon center + delta. */ -export const SlabMoveTool: React.FC<{ node: SlabNode }> = ({ node }) => { - const slabId = node.id - const cursorRef = useRef(null) +function translatePolygon( + polygon: Array<[number, number]>, + deltaX: number, + deltaZ: number, +): Array<[number, number]> { + return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number]) +} - const initialCenter: [number, number] = useMemo(() => { - if (node.polygon.length === 0) return [0, 0] - let sx = 0 - let sz = 0 - for (const [x, z] of node.polygon) { - sx += x - sz += z - } - return [sx / node.polygon.length, sz / node.polygon.length] - }, [node.polygon]) +function getPolygonCenter(polygon: Array<[number, number]>): [number, number] { + if (polygon.length === 0) return [0, 0] + let sumX = 0 + let sumZ = 0 + for (const [x, z] of polygon) { + sumX += x + sumZ += z + } + return [sumX / polygon.length, sumZ / polygon.length] +} - // Cursor follows the raw grid pointer — direct Three.js mutation, - // bypassing React reconciliation for the per-tick position update. - useEffect(() => { - const onMove = (event: GridEvent) => { - if (!cursorRef.current) return - cursorRef.current.position.set( - event.localPosition[0], - event.localPosition[1], - event.localPosition[2], - ) - } - emitter.on('grid:move', onMove) - return () => { - emitter.off('grid:move', onMove) - } +export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => { + const activatedAtRef = useRef(Date.now()) + const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number])) + const originalHolesRef = useRef( + (node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])), + ) + const dragAnchorRef = useRef<[number, number] | null>(null) + const previousGridPosRef = useRef<[number, number] | null>(null) + const previewRef = useRef<{ + polygon: Array<[number, number]> + holes: Array> + } | null>(null) + + const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => { + const center = getPolygonCenter(node.polygon) + return [center[0], 0, center[1]] + }) + + const exitMoveMode = useCallback(() => { + useEditor.getState().setMovingNode(null) }, []) - const exitMoveMode = (committed: boolean) => { - if (committed) triggerSFX('sfx:item-place') - useViewer.getState().setSelection({ selectedIds: [slabId] }) - useEditor.getState().setMovingNode(null) - } + useEffect(() => { + const originalPolygon = originalPolygonRef.current + const originalHoles = originalHolesRef.current + const levelNode = + node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level' + ? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode) + : null + const levelChildren = levelNode?.children ?? [] + const levelWalls = levelChildren + .map((childId) => useScene.getState().nodes[childId as AnyNodeId]) + .filter((child): child is WallNode => child?.type === 'wall') + const levelFences = levelChildren + .map((childId) => useScene.getState().nodes[childId as AnyNodeId]) + .filter((child): child is FenceNode => child?.type === 'fence') - useDragAction({ - active: true, - action: moveSlabDragAction, - initial: { - node, - point: initialCenter, - }, - onCommit: () => exitMoveMode(true), - onCancel: () => exitMoveMode(false), - }) + useScene.temporal.getState().pause() + let wasCommitted = false + + const applyPreview = ( + polygon: Array<[number, number]>, + holes: Array>, + ) => { + previewRef.current = { polygon, holes } + const center = getPolygonCenter(polygon) + setCursorLocalPos([center[0], 0, center[1]]) + useScene.getState().updateNode(node.id, { polygon, holes }) + useScene.getState().markDirty(node.id as AnyNodeId) + } + + const restoreOriginal = () => { + useScene.getState().updateNode(node.id, { + holes: originalHoles, + polygon: originalPolygon, + }) + useScene.getState().markDirty(node.id as AnyNodeId) + } + + const onGridMove = (event: GridEvent) => { + const [localX, localZ] = snapFenceDraftPoint({ + point: [event.localPosition[0], event.localPosition[2]], + walls: levelWalls, + fences: levelFences, + }) + + if ( + previousGridPosRef.current && + (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1]) + ) { + triggerSFX('sfx:grid-snap') + } + previousGridPosRef.current = [localX, localZ] + + const anchor = dragAnchorRef.current ?? [localX, localZ] + dragAnchorRef.current = anchor + + const deltaX = localX - anchor[0] + const deltaZ = localZ - anchor[1] + + applyPreview( + translatePolygon(originalPolygon, deltaX, deltaZ), + originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)), + ) + } + + const onGridClick = (event: GridEvent) => { + if (Date.now() - activatedAtRef.current < 150) { + event.nativeEvent?.stopPropagation?.() + return + } + + const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles } + + wasCommitted = true + + // Restore original baseline while paused so the next resume+update + // registers as a single tracked change (undo reverts to original). + useScene.getState().updateNode(node.id, { + polygon: originalPolygon, + holes: originalHoles, + }) + + useScene.temporal.getState().resume() + useScene.getState().updateNode(node.id, preview) + useScene.getState().markDirty(node.id as AnyNodeId) + useScene.temporal.getState().pause() + + triggerSFX('sfx:item-place') + useViewer.getState().setSelection({ selectedIds: [node.id] }) + exitMoveMode() + event.nativeEvent?.stopPropagation?.() + } + + const onCancel = () => { + restoreOriginal() + useViewer.getState().setSelection({ selectedIds: [node.id] }) + useScene.temporal.getState().resume() + markToolCancelConsumed() + exitMoveMode() + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + emitter.on('tool:cancel', onCancel) + + return () => { + if (!wasCommitted) { + restoreOriginal() + } + useScene.temporal.getState().resume() + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + emitter.off('tool:cancel', onCancel) + } + }, [exitMoveMode, node.id, node.parentId, node.polygon]) return ( - + ) } -export default SlabMoveTool +export default MoveSlabTool diff --git a/packages/nodes/src/wall/actions/curve.ts b/packages/nodes/src/wall/actions/curve.ts deleted file mode 100644 index 836689e0..00000000 --- a/packages/nodes/src/wall/actions/curve.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { - type AnyNode, - type AnyNodeId, - type DragAction, - getClampedWallCurveOffset, - getMaxWallCurveOffset, - getWallChordFrame, - normalizeWallCurveOffset, - type WallNode, -} from '@pascal-app/core' - -/** - * Phase 5 Stage D — curve-wall drag affordance. - * - * Mirrors `fence/actions/curve.ts`. Same chord-perpendicular projection, - * same clamp/normalize, same single-undo dance on commit. The only - * meaningful difference is the wall's snap-step-aware preview (the - * legacy CurveWallTool snapped the pointer position to `getWallGridStep` - * before projecting). We rely on the wall snap services existing in - * the wall-drafting module — exposing those here would bloat the - * surface, so we accept the slight precision difference for now (the - * normalized offset is what zundo records anyway). - */ - -type CurveWallCtx = { - nodeId: AnyNodeId - originalCurveOffset: number - chord: ReturnType - maxCurveOffset: number - startNode: WallNode -} - -type CurveWallDraft = { - curveOffset: number -} - -export const curveWallDragAction: DragAction = { - begin: (input) => { - const node = input.node as WallNode | undefined - if (!node) throw new Error('[curveWallDragAction] begin requires a wall node') - return { - nodeId: node.id as AnyNodeId, - originalCurveOffset: getClampedWallCurveOffset(node), - chord: getWallChordFrame(node), - maxCurveOffset: getMaxWallCurveOffset(node), - startNode: node, - } - }, - - preview: (ctx, point) => { - 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) => { - const clamped = Math.max(-ctx.maxCurveOffset, Math.min(ctx.maxCurveOffset, draft.curveOffset)) - return { curveOffset: normalizeWallCurveOffset(ctx.startNode, clamped) } - }, - - apply: (draft, ctx, scene) => { - scene.update(ctx.nodeId, { curveOffset: draft.curveOffset } as Partial) - scene.markDirty(ctx.nodeId) - return [ctx.nodeId] - }, - - commit: (draft, ctx, scene) => { - // Always push a pastState entry — see fence/actions/curve.ts for - // the rationale (no-op-bend would otherwise let Ctrl-Z cancel the - // wall creation). - scene.restoreAll() - scene.resumeHistory() - scene.update(ctx.nodeId, { curveOffset: draft.curveOffset } as Partial) - return true - }, - - cancel: (_ctx, _scene) => { - // No-op — orchestrator restores via snapshot. - }, -} diff --git a/packages/nodes/src/wall/curve-tool.tsx b/packages/nodes/src/wall/curve-tool.tsx index 016a0d33..8cd9fcea 100644 --- a/packages/nodes/src/wall/curve-tool.tsx +++ b/packages/nodes/src/wall/curve-tool.tsx @@ -1,62 +1,191 @@ 'use client' -import { getWallMidpointHandlePoint, useScene, type WallNode } from '@pascal-app/core' -import { CursorSphere, triggerSFX, useDragAction, useEditor } from '@pascal-app/editor' +import { + type AnyNodeId, + emitter, + type GridEvent, + getClampedWallCurveOffset, + getMaxWallCurveOffset, + getWallChordFrame, + getWallMidpointHandlePoint, + normalizeWallCurveOffset, + useScene, + type WallNode, +} from '@pascal-app/core' +import { + CursorSphere, + getWallGridStep, + markToolCancelConsumed, + snapScalarToGrid, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useState } from 'react' -import { curveWallDragAction } from './actions/curve' +import { useCallback, useEffect, useRef, useState } from 'react' /** - * Phase 5 Stage D — thin React wrapper around `curveWallDragAction`. + * Phase 5 Stage D — wall curve tool (kind-owned). * - * Replaces the legacy `CurveWallTool` (178 LoC). Same UX as the fence - * curve port — cursor sphere follows the chord-perpendicular projection - * of the pointer, dragging the wall's `curveOffset` live; grid:click - * commits with the single-undo dance, Esc cancels. - * - * Mounted by ToolManager via `def.affordanceTools.curve` when - * `useEditor.curvingWall` activates. + * 1:1 port of the legacy `CurveWallTool`. Same snap pipeline, Shift + * override, history dance, activation grace. The wall variant uses + * `useScene.temporal.getState().pause()` / `.resume()` directly rather + * than the depth-counted `pauseSceneHistory` helpers — matches legacy. */ -export const WallCurveTool: React.FC<{ node: WallNode }> = ({ node }) => { +export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { + const activatedAtRef = useRef(Date.now()) + const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node)) + const previousCurveOffsetRef = useRef(null) + const shiftPressedRef = useRef(false) + const previewOffsetRef = useRef(originalCurveOffsetRef.current) + const initialHandle = getWallMidpointHandlePoint(node) - const [cursorPos, setCursorPos] = useState<[number, number, number]>([ + const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>([ initialHandle.x, 0, initialHandle.y, ]) - const exitCurveMode = (committed: boolean) => { - if (committed) triggerSFX('sfx:item-place') - useViewer.getState().setSelection({ selectedIds: [node.id] }) + const exitCurveMode = useCallback(() => { useEditor.getState().setCurvingWall(null) - } - - useDragAction({ - active: true, - action: curveWallDragAction, - initial: { - node, - point: [initialHandle.x, initialHandle.y], - }, - onCommit: () => exitCurveMode(true), - onCancel: () => exitCurveMode(false), - }) - - const liveCurveOffset = useScene((s) => { - const live = s.nodes[node.id] - return live?.type === 'wall' ? ((live as WallNode).curveOffset ?? 0) : 0 - }) + }, []) useEffect(() => { - const handlePoint = getWallMidpointHandlePoint({ ...node, curveOffset: liveCurveOffset }) - setCursorPos([handlePoint.x, 0, handlePoint.y]) - }, [liveCurveOffset, node]) + const nodeId = node.id + const originalCurveOffset = originalCurveOffsetRef.current + const chord = getWallChordFrame(node) + const maxCurveOffset = getMaxWallCurveOffset(node) + + useScene.temporal.getState().pause() + let wasCommitted = false + + const applyPreview = (curveOffset: number) => { + if (previewOffsetRef.current === curveOffset) { + return + } + previewOffsetRef.current = curveOffset + + const nextNode = { + ...node, + curveOffset, + } + const handlePoint = getWallMidpointHandlePoint(nextNode) + setCursorLocalPos([handlePoint.x, 0, handlePoint.y]) + useScene.getState().updateNode(nodeId, { curveOffset }) + useScene.getState().markDirty(nodeId as AnyNodeId) + } + + const restoreOriginal = () => { + if (previewOffsetRef.current === originalCurveOffset) { + return + } + previewOffsetRef.current = originalCurveOffset + useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset }) + useScene.getState().markDirty(nodeId as AnyNodeId) + } + + const onGridMove = (event: GridEvent) => { + const snapStep = getWallGridStep() + const localX = shiftPressedRef.current + ? event.localPosition[0] + : snapScalarToGrid(event.localPosition[0], snapStep) + const localZ = shiftPressedRef.current + ? event.localPosition[2] + : snapScalarToGrid(event.localPosition[2], snapStep) + + const offsetFromMidpoint = -( + (localX - chord.midpoint.x) * chord.normal.x + + (localZ - chord.midpoint.y) * chord.normal.y + ) + const snappedOffset = shiftPressedRef.current + ? offsetFromMidpoint + : snapScalarToGrid(offsetFromMidpoint, snapStep) + const nextCurveOffset = normalizeWallCurveOffset( + node, + Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)), + ) + + if ( + previousCurveOffsetRef.current !== null && + nextCurveOffset !== previousCurveOffsetRef.current + ) { + triggerSFX('sfx:grid-snap') + } + previousCurveOffsetRef.current = nextCurveOffset + + applyPreview(nextCurveOffset) + } + + const onGridClick = (event: GridEvent) => { + if (Date.now() - activatedAtRef.current < 150) { + event.nativeEvent?.stopPropagation?.() + return + } + + const curveOffset = previewOffsetRef.current + wasCommitted = true + + if (curveOffset !== originalCurveOffset) { + // Restore original baseline while paused so the next resume+update + // registers as a single tracked change (undo reverts to original). + useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset }) + useScene.getState().markDirty(nodeId as AnyNodeId) + + useScene.temporal.getState().resume() + useScene.getState().updateNode(nodeId, { curveOffset }) + useScene.getState().markDirty(nodeId as AnyNodeId) + useScene.temporal.getState().pause() + } + + triggerSFX('sfx:item-place') + useViewer.getState().setSelection({ selectedIds: [nodeId] }) + exitCurveMode() + event.nativeEvent?.stopPropagation?.() + } + + const onCancel = () => { + restoreOriginal() + useViewer.getState().setSelection({ selectedIds: [nodeId] }) + useScene.temporal.getState().resume() + markToolCancelConsumed() + exitCurveMode() + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Shift') { + shiftPressedRef.current = true + } + } + + const onKeyUp = (event: KeyboardEvent) => { + if (event.key === 'Shift') { + shiftPressedRef.current = false + } + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + emitter.on('tool:cancel', onCancel) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + + return () => { + if (!wasCommitted) { + restoreOriginal() + } + useScene.temporal.getState().resume() + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + emitter.off('tool:cancel', onCancel) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + } + }, [exitCurveMode, node]) return ( - + ) } -export default WallCurveTool +export default CurveWallTool diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index 5d411e40..cba0cd5a 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -57,14 +57,14 @@ export const wallDefinition: NodeDefinition = { parametrics: wallParametrics, - // Stage D — deferred for wall. The curve port (`curve-tool.tsx` + - // `actions/curve.ts`) needs more work to match the legacy - // CurveWallTool's UX (pre-snap on pointer position, 0.5m grid step, - // Shift override, smooth scene.update without cascade overhead). - // Legacy fallback runs until that lands. Endpoint move / whole-wall - // move / placement are all still legacy too — they're the biggest - // tools and have linked-wall corner cascade logic that needs a - // careful port. + // Stage D — wall curve is a 1:1 port of the legacy CurveWallTool, + // relocated into this folder and dispatched via the registry. Endpoint + // move (linked-wall corner cascade + ALT-detach), whole-wall move, and + // placement are still legacy — they're substantially larger and queued + // for separate port passes. + affordanceTools: { + curve: () => import('./curve-tool'), + }, renderer: { kind: 'parametric',