Wassim SAMAD c2c2f66427 Phase 5 Stage D: fix bend history + move-tool loop + grid-snap sfx
Three user-reported regressions, all in the Stage D move/curve ports:

1. **Fence/wall bend cancelled the fence creation on Ctrl-Z.** The
   action.commit's `return false` shortcut on "no offset change"
   bypassed the dance entirely — pastStates wasn't touched, so the
   next Ctrl-Z fell through to whatever preceded activation
   (typically the create step). Fix: always run the dance, even on
   no-op commits. First Ctrl-Z then absorbs a silent no-op entry,
   subsequent presses roll back real prior actions. Same fix applied
   to fence move-endpoint, fence move, slab move, ceiling move.

2. **Slab/ceiling move 'maximum update depth exceeded' loop.** The
   `useScene` selector in `SlabMoveTool` returned a freshly-allocated
   `[sx, sz]` tuple on every call. Zustand's `Object.is` equality
   failed each comparison → re-subscribe → re-render → loop. Fix:
   subscribe to the stable live-node reference and derive the center
   via `useMemo`. Same recipe for fence/ceiling move-tools, including
   memoizing the `originalCenter` fallback that was getting a new
   array per render.

3. **No grid-snap sfx during move drag.** Action `preview` now tracks
   the last snapped pointer on a mutable `lastSnapped` ctx field and
   emits `sfx:grid-snap` when it changes between ticks. Matches the
   legacy MoveFenceTool's per-tick sound.

Locking test for foot-gun 1 lives in
`packages/core/src/services/single-undo-dance.test.ts`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:12:07 -04:00

68 lines
2.4 KiB
TypeScript

'use client'
import { type FenceNode, useLiveTransforms } from '@pascal-app/core'
import { CursorSphere, triggerSFX, useDragAction, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useMemo } from 'react'
import { moveFenceDragAction } from './actions/move'
/**
* Phase 5 Stage D — thin React wrapper around `moveFenceDragAction`.
*
* Replaces the legacy `MoveFenceTool` (302 LoC). The action owns all
* the math (snap + linked cascade + live-drag mesh offsets +
* single-undo dance on commit). The wrapper renders the cursor sphere
* tracking its position from the live-transform store.
*
* Selector stability: `originalCenter` is memoized so the live-transform
* fallback doesn't return a new array per render — that pattern blows
* up zustand's `Object.is` check and trips "getSnapshot result not
* cached" → infinite re-render. Same recipe in slab/ceiling move-tool.
*
* Mounted by ToolManager when `useEditor.movingNode` is a fence
* (capability-driven dispatch — fence has no `movable` capability, so
* the legacy MoveTool's per-kind branch chain falls through to here
* via the new affordance dispatch).
*/
export const FenceMoveTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const fenceId = node.id
const originalCenter: [number, number, number] = useMemo(
() => [(node.start[0] + node.end[0]) / 2, 0, (node.start[1] + node.end[1]) / 2],
[node.start, node.end],
)
// Subscribe to the live-transform reference only (stable across
// renders unless set/clear was called). Derive position via useMemo
// so the selector itself stays cached.
const liveTransform = useLiveTransforms((s) => s.get(fenceId))
const liveCenter: [number, number, number] = useMemo(
() => liveTransform?.position ?? originalCenter,
[liveTransform, originalCenter],
)
const exitMoveMode = (committed: boolean) => {
if (committed) triggerSFX('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [fenceId] })
useEditor.getState().setMovingNode(null)
}
useDragAction({
active: true,
action: moveFenceDragAction,
initial: {
node,
point: [originalCenter[0], originalCenter[2]],
},
onCommit: () => exitMoveMode(true),
onCancel: () => exitMoveMode(false),
})
return (
<group>
<CursorSphere position={liveCenter} showTooltip={false} />
</group>
)
}
export default FenceMoveTool