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>
This commit is contained in:
Wassim SAMAD
2026-05-18 08:12:07 -04:00
co-authored by Claude Opus 4.7
parent d43cd569d6
commit c2c2f66427
9 changed files with 134 additions and 70 deletions
+5 -1
View File
@@ -22,7 +22,11 @@ export const CeilingMoveTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
const live = useScene((s) => s.nodes[ceilingId])
const liveCeiling = live?.type === 'ceiling' ? (live as CeilingNode) : node
const polygon = liveCeiling.polygon
const holes = liveCeiling.holes ?? []
// `?? []` would return a NEW empty array per render, busting downstream
// useMemo deps and (under StrictMode) potentially triggering the
// "getSnapshot result not cached" warning. Memoize against the source.
const EMPTY_HOLES = useMemo(() => [] as Array<Array<[number, number]>>, [])
const holes = liveCeiling.holes ?? EMPTY_HOLES
const center: [number, number] = useMemo(() => {
if (polygon.length === 0) return [0, 0]