Phase 5 Stage D: re-port curve + whole-item move tools 1:1 from legacy

Each tool is now a direct copy of the legacy implementation, relocated
under @pascal-app/nodes/<kind>/ and dispatched via the registry's
def.affordanceTools. No DragAction abstraction, no clever live-drag
exception, no novel snap pipeline — same code, same UX, same
performance, same history dance.

Ports:
- fence/curve-tool.tsx    (legacy CurveFenceTool, 1:1)
- fence/move-tool.tsx     (legacy MoveFenceTool, 1:1 — including
                           the mesh.position + useLiveTransforms
                           exception that the legacy uses for fence
                           specifically)
- wall/curve-tool.tsx     (legacy CurveWallTool, 1:1)
- slab/move-tool.tsx      (legacy MoveSlabTool, 1:1)
- ceiling/move-tool.tsx   (legacy MoveCeilingTool, 1:1 — preview
                           fill + outline overlay preserved)

Drops the obsolete DragAction-based action files
(packages/nodes/src/{fence,wall,slab,ceiling}/actions/{curve,move}.ts)
and their now-empty actions/ directories where applicable. Fence
keeps actions/move-endpoint.ts since that port works.

Editor public surface gains `getWallGridStep` + `snapScalarToGrid`
(transitional exports — Stage F moves them into @pascal-app/nodes).

ToolManager + MoveTool dispatch unchanged: the same legacy-fallback
branches now mount the registry component because the affordances are
declared, but the rendered behavior matches the legacy because the
implementations are copies.

Per-kind progress: fence D  (curve / move-endpoint / move / placement
all kind-owned), slab D , ceiling D , wall D 🟡 (curve only,
endpoint/move/placement still legacy).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-18 08:53:51 -04:00
co-authored by Claude Opus 4.7
parent da63081f73
commit 1e15e10185
15 changed files with 1089 additions and 1088 deletions
-112
View File
@@ -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<typeof getWallChordFrame>
maxCurveOffset: number
// Snapshot of the node at drag start — used to recompute the curve frame
// and normalize the offset throughout the drag.
startNode: FenceNode
}
type CurveFenceDraft = {
curveOffset: number
}
export const curveFenceDragAction: DragAction<CurveFenceCtx, CurveFenceDraft> = {
begin: (input) => {
const node = input.node as FenceNode | undefined
if (!node) {
throw new Error('[curveFenceDragAction] begin requires a node')
}
return {
nodeId: node.id as AnyNodeId,
originalCurveOffset: getClampedWallCurveOffset(node),
chord: getWallChordFrame(node),
maxCurveOffset: getMaxWallCurveOffset(node),
startNode: node,
}
},
preview: (ctx, point) => {
// Pointer in level-local meters. Project onto the chord's normal to
// get the signed perpendicular distance — that's the new curveOffset.
const [px, pz] = point
const offset = -(
(px - ctx.chord.midpoint.x) * ctx.chord.normal.x +
(pz - ctx.chord.midpoint.y) * ctx.chord.normal.y
)
return { curveOffset: offset }
},
snap: (draft, ctx, _services) => {
// Clamp to maxCurveOffset and normalize via the wall-curve helper.
const clamped = Math.max(-ctx.maxCurveOffset, Math.min(ctx.maxCurveOffset, draft.curveOffset))
const normalized = normalizeWallCurveOffset(ctx.startNode, clamped)
return { curveOffset: normalized }
},
apply: (draft, ctx, scene) => {
scene.update(ctx.nodeId, { curveOffset: draft.curveOffset } as Partial<AnyNode>)
scene.markDirty(ctx.nodeId)
return [ctx.nodeId]
},
commit: (draft, ctx, scene) => {
// 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<AnyNode>)
return true
},
cancel: (_ctx, _scene) => {
// No-op — createDragSession.cancel() calls scene.restoreAll() which
// puts every touched node back via the snapshot.
},
}
-284
View File
@@ -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<MoveFenceCtx, MoveFenceDraft> = {
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<AnyNode>)
for (const linked of draft.linkedUpdates) {
scene.update(
linked.id as AnyNodeId,
{
start: linked.start,
end: linked.end,
} as Partial<AnyNode>,
)
}
// 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)
},
}
+173 -51
View File
@@ -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<number>(Date.now())
const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node))
const previousCurveOffsetRef = useRef<number | null>(null)
const shiftPressedRef = useRef(false)
const previewOffsetRef = useRef<number>(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 (
<group>
<CursorSphere position={cursorPos} showTooltip={false} />
<CursorSphere position={cursorLocalPos} showTooltip={false} />
</group>
)
}
export default FenceCurveTool
export default CurveFenceTool
+7 -8
View File
@@ -74,16 +74,15 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
// 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: [
+294 -44
View File
@@ -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<Group>(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<number>(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 (
<group>
<CursorSphere ref={cursorRef} showTooltip={false} />
<CursorSphere position={cursorLocalPos} showTooltip={false} />
</group>
)
}
export default FenceMoveTool
export default MoveFenceTool