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
+17 -7
View File
@@ -8,7 +8,11 @@ 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'
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.
@@ -16,7 +20,8 @@ import { type FencePlanPoint, snapFenceDraftPoint } from '@pascal-app/editor'
* Translates the slab's boundary polygon (and any holes) rigidly under
* the pointer. Snaps to walls / fences / grid at the level. Latches
* the drag anchor on the first preview tick so the slab doesn't jump
* to wherever the activation click landed.
* to wherever the activation click landed. Emits grid-snap sfx when
* the snapped position changes between ticks (matches legacy UX).
*
* Unlike fence move, the slab port does **not** use the live-drag
* exception — polygon CSG geometry is expensive to rebuild per frame,
@@ -52,6 +57,7 @@ export type MoveSlabCtx = {
levelWalls: WallNode[]
levelFences: FenceNode[]
dragAnchor: FencePlanPoint | null
lastSnapped: FencePlanPoint | null
}
export type MoveSlabDraft = {
@@ -98,6 +104,7 @@ export const moveSlabDragAction: DragAction<MoveSlabCtx, MoveSlabDraft> = {
levelWalls,
levelFences,
dragAnchor: null,
lastSnapped: null,
}
},
@@ -107,6 +114,11 @@ export const moveSlabDragAction: DragAction<MoveSlabCtx, MoveSlabDraft> = {
walls: ctx.levelWalls,
fences: ctx.levelFences,
})
// Emit grid-snap sfx when the snapped position changes.
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]
@@ -130,11 +142,9 @@ export const moveSlabDragAction: DragAction<MoveSlabCtx, MoveSlabDraft> = {
},
commit: (draft, ctx, scene) => {
if (draft.deltaX === 0 && draft.deltaZ === 0) return false
// Single-undo dance — revert via snapshot, resume history, re-apply
// the final polygon/holes. Zundo captures the whole drag as one
// Ctrl-Z step.
// Always push — see fence/actions/curve.ts. Even on a no-movement
// commit, the dance must push a pastState entry so Ctrl-Z doesn't
// cancel whatever was on the stack before activation.
scene.restoreAll()
scene.resumeHistory()
scene.update(ctx.slabId, {
+28 -18
View File
@@ -3,32 +3,42 @@
import { type SlabNode, useScene } from '@pascal-app/core'
import { CursorSphere, triggerSFX, useDragAction, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useMemo } from 'react'
import { moveSlabDragAction } from './actions/move'
/**
* Phase 5 Stage D — thin React wrapper around `moveSlabDragAction`.
*
* Replaces the legacy `MoveSlabTool` (182 LoC). All math + history
* dance lives in the action; this wrapper just renders the cursor
* sphere following the live polygon center.
* dance lives in the action; this wrapper renders the cursor sphere
* at the live polygon center.
*
* NOTE on selector stability: the live polygon center MUST be derived
* via `useMemo` over the node reference rather than computed inside
* the `useScene` selector — returning a fresh `[x, z]` tuple from the
* selector on every call triggers "getSnapshot result not cached"
* → infinite re-render. Same pattern in fence/ceiling move-tool.
*/
export const SlabMoveTool: React.FC<{ node: SlabNode }> = ({ node }) => {
const slabId = node.id
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])
const initialCenter: [number, number] =
node.polygon.length > 0
? [
node.polygon.reduce((s, [x]) => s + x, 0) / node.polygon.length,
node.polygon.reduce((s, [, z]) => s + z, 0) / node.polygon.length,
]
: [0, 0]
// Live polygon center — re-derived from the scene store every tick
// since the action writes the translated polygon onto the slab.
const liveCenter = useScene((s) => {
const live = s.nodes[slabId]
if (live?.type !== 'slab') return initialCenter
const poly = (live as SlabNode).polygon
// Subscribe to the live node reference (stable across renders when
// the node hasn't changed; new reference per scene update). Derive
// the center inside `useMemo` so the selector itself stays cached.
const liveNode = useScene((s) => s.nodes[slabId])
const liveCenter = useMemo<[number, number]>(() => {
if (liveNode?.type !== 'slab') return initialCenter
const poly = (liveNode as SlabNode).polygon
if (poly.length === 0) return initialCenter
let sx = 0
let sz = 0
@@ -36,8 +46,8 @@ export const SlabMoveTool: React.FC<{ node: SlabNode }> = ({ node }) => {
sx += x
sz += z
}
return [sx / poly.length, sz / poly.length] as [number, number]
})
return [sx / poly.length, sz / poly.length]
}, [liveNode, initialCenter])
const exitMoveMode = (committed: boolean) => {
if (committed) triggerSFX('sfx:item-place')