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
+11 -9
View File
@@ -88,15 +88,17 @@ export const curveFenceDragAction: DragAction<CurveFenceCtx, CurveFenceDraft> =
},
commit: (draft, ctx, scene) => {
// Reject when the offset didn't actually change — createDragSession
// will fall through to cancel + scene.restoreAll() (no zundo entry).
if (draft.curveOffset === ctx.originalCurveOffset) return false
// Single-undo dance: revert via the snapshot (paused history → no
// zundo record), resume history, then re-apply the final draft so
// zundo captures the whole drag as one undo step. Without this the
// pause window's mutations never reach pastStates and Ctrl-Z jumps
// past the drag back to the state before activation.
// 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>)
@@ -183,11 +183,12 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
},
commit: (draft, ctx, scene) => {
// Reject when the drag didn't move OR the resulting fence would
// be shorter than the minimum length. createDragSession.cancel()
// restores originals via the snapshot.
const hasChanged = !samePoint(draft.movingPoint, ctx.originalMovingPoint)
if (!hasChanged) return false
// Min-length rejection still matters — too-short fence is invalid
// and should bounce back via the cancel path (snapshot restore).
// But the "no-change" rejection is removed: see
// fence/actions/curve.ts for the rationale (no-op drag must still
// push a pastState entry to avoid Ctrl-Z cancelling the fence
// creation that preceded the activation).
if (!isWallLongEnough(draft.start, draft.end)) return false
// Single-undo dance: revert to originals (paused history → no
+25 -11
View File
@@ -9,9 +9,13 @@ import {
useScene,
type WallNode,
} from '@pascal-app/core'
import { type FencePlanPoint, snapFenceDraftPoint } from '@pascal-app/editor'
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.
*
@@ -137,6 +141,10 @@ export type MoveFenceCtx = {
// 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 = {
@@ -180,6 +188,7 @@ export const moveFenceDragAction: DragAction<MoveFenceCtx, MoveFenceDraft> = {
levelWalls,
levelFences,
dragAnchor: null,
lastSnapped: null,
}
},
@@ -190,6 +199,12 @@ export const moveFenceDragAction: DragAction<MoveFenceCtx, MoveFenceDraft> = {
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.
@@ -229,16 +244,15 @@ export const moveFenceDragAction: DragAction<MoveFenceCtx, MoveFenceDraft> = {
},
commit: (draft, ctx, scene) => {
// Reject when nothing moved — falls through to action.cancel which
// clears the live-drag visual state.
if (draft.deltaX === 0 && draft.deltaZ === 0) return false
// Single-undo dance — paused history during drag means nothing
// was recorded. We didn't scene.update during apply either (live-
// drag exception), so the snapshot is empty and restoreAll is a
// no-op. Resume history, then write the final draft. Zundo
// captures one diff: original → final.
scene.restoreAll() // no-op (no scene updates during apply)
// 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,