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
@@ -3,6 +3,7 @@
|
||||
import { type FenceNode, useLiveTransforms } from '@pascal-app/core'
|
||||
import { CursorSphere, triggerSFX, useDragAction, useEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useMemo } from 'react'
|
||||
import { moveFenceDragAction } from './actions/move'
|
||||
|
||||
/**
|
||||
@@ -10,8 +11,13 @@ import { moveFenceDragAction } from './actions/move'
|
||||
*
|
||||
* Replaces the legacy `MoveFenceTool` (302 LoC). The action owns all
|
||||
* the math (snap + linked cascade + live-drag mesh offsets +
|
||||
* single-undo dance on commit). The wrapper just renders the cursor
|
||||
* sphere and tracks its position from the live-transform store.
|
||||
* single-undo dance on commit). The wrapper renders the cursor sphere
|
||||
* tracking its position from the live-transform store.
|
||||
*
|
||||
* Selector stability: `originalCenter` is memoized so the live-transform
|
||||
* fallback doesn't return a new array per render — that pattern blows
|
||||
* up zustand's `Object.is` check and trips "getSnapshot result not
|
||||
* cached" → infinite re-render. Same recipe in slab/ceiling move-tool.
|
||||
*
|
||||
* Mounted by ToolManager when `useEditor.movingNode` is a fence
|
||||
* (capability-driven dispatch — fence has no `movable` capability, so
|
||||
@@ -20,19 +26,19 @@ import { moveFenceDragAction } from './actions/move'
|
||||
*/
|
||||
export const FenceMoveTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||
const fenceId = node.id
|
||||
const originalCenter: [number, number, number] = [
|
||||
(node.start[0] + node.end[0]) / 2,
|
||||
0,
|
||||
(node.start[1] + node.end[1]) / 2,
|
||||
]
|
||||
const originalCenter: [number, number, number] = useMemo(
|
||||
() => [(node.start[0] + node.end[0]) / 2, 0, (node.start[1] + node.end[1]) / 2],
|
||||
[node.start, node.end],
|
||||
)
|
||||
|
||||
// Live position from the live-transforms store — the action writes
|
||||
// here every preview tick (live-drag exception). Falls back to the
|
||||
// original center until the first move.
|
||||
const liveCenter = useLiveTransforms((s) => {
|
||||
const t = s.get(fenceId)
|
||||
return t?.position ?? originalCenter
|
||||
})
|
||||
// Subscribe to the live-transform reference only (stable across
|
||||
// renders unless set/clear was called). Derive position via useMemo
|
||||
// so the selector itself stays cached.
|
||||
const liveTransform = useLiveTransforms((s) => s.get(fenceId))
|
||||
const liveCenter: [number, number, number] = useMemo(
|
||||
() => liveTransform?.position ?? originalCenter,
|
||||
[liveTransform, originalCenter],
|
||||
)
|
||||
|
||||
const exitMoveMode = (committed: boolean) => {
|
||||
if (committed) triggerSFX('sfx:item-place')
|
||||
@@ -53,7 +59,7 @@ export const FenceMoveTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||
|
||||
return (
|
||||
<group>
|
||||
<CursorSphere position={liveCenter as [number, number, number]} showTooltip={false} />
|
||||
<CursorSphere position={liveCenter} showTooltip={false} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user