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
@@ -1,4 +1,5 @@
|
||||
import type { AnyNode, AnyNodeId, CeilingNode, DragAction } from '@pascal-app/core'
|
||||
import { triggerSFX } from '@pascal-app/editor'
|
||||
|
||||
/**
|
||||
* Phase 5 Stage D — whole-ceiling move drag affordance.
|
||||
@@ -6,7 +7,8 @@ import type { AnyNode, AnyNodeId, CeilingNode, DragAction } from '@pascal-app/co
|
||||
* Mirrors `slab/actions/move.ts` shape but ceiling snaps purely to a
|
||||
* 0.5m grid (no wall/fence corner snap — ceilings are typically
|
||||
* placed independent of the floor layout). Drag anchor is latched on
|
||||
* the first preview tick so the ceiling doesn't jump.
|
||||
* the first preview tick so the ceiling doesn't jump. Emits a grid-
|
||||
* snap sfx when the snapped position changes between ticks.
|
||||
*
|
||||
* Single-undo dance on commit, same recipe as slab/fence.
|
||||
*/
|
||||
@@ -17,6 +19,10 @@ function snap(value: number): number {
|
||||
return Math.round(value / GRID_STEP) * GRID_STEP
|
||||
}
|
||||
|
||||
function sameSnap(a: [number, number] | null, b: [number, number]): boolean {
|
||||
return a !== null && a[0] === b[0] && a[1] === b[1]
|
||||
}
|
||||
|
||||
function translatePolygon(
|
||||
polygon: Array<[number, number]>,
|
||||
deltaX: number,
|
||||
@@ -30,6 +36,7 @@ export type MoveCeilingCtx = {
|
||||
originalPolygon: Array<[number, number]>
|
||||
originalHoles: Array<Array<[number, number]>>
|
||||
dragAnchor: [number, number] | null
|
||||
lastSnapped: [number, number] | null
|
||||
}
|
||||
|
||||
export type MoveCeilingDraft = {
|
||||
@@ -50,13 +57,19 @@ export const moveCeilingDragAction: DragAction<MoveCeilingCtx, MoveCeilingDraft>
|
||||
h.map(([x, z]) => [x, z] as [number, number]),
|
||||
),
|
||||
dragAnchor: null,
|
||||
lastSnapped: null,
|
||||
}
|
||||
},
|
||||
|
||||
preview: (ctx, point, _modifiers) => {
|
||||
const sx = snap(point[0])
|
||||
const sz = snap(point[1])
|
||||
if (!ctx.dragAnchor) ctx.dragAnchor = [sx, sz]
|
||||
const snapped: [number, number] = [sx, sz]
|
||||
if (!sameSnap(ctx.lastSnapped, snapped)) {
|
||||
if (ctx.lastSnapped !== null) triggerSFX('sfx:grid-snap')
|
||||
ctx.lastSnapped = snapped
|
||||
}
|
||||
if (!ctx.dragAnchor) ctx.dragAnchor = snapped
|
||||
const deltaX = sx - ctx.dragAnchor[0]
|
||||
const deltaZ = sz - ctx.dragAnchor[1]
|
||||
return {
|
||||
@@ -76,7 +89,9 @@ export const moveCeilingDragAction: DragAction<MoveCeilingCtx, MoveCeilingDraft>
|
||||
},
|
||||
|
||||
commit: (draft, ctx, scene) => {
|
||||
if (draft.deltaX === 0 && draft.deltaZ === 0) return false
|
||||
// Always push — see fence/actions/curve.ts. No-movement still
|
||||
// records a pastState entry so Ctrl-Z doesn't fall through to the
|
||||
// ceiling-create step.
|
||||
scene.restoreAll()
|
||||
scene.resumeHistory()
|
||||
scene.update(ctx.ceilingId, {
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user