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:
co-authored by
Claude Opus 4.7
parent
d43cd569d6
commit
c2c2f66427
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user