Phase 5 Stage D moves: live-drag for slab/ceiling + cursor follows pointer

User-reported regressions after the previous Stage D move ports:

1. Slab/ceiling moves were sluggish because the actions wrote
   scene.update(polygon) every grid:move tick → React re-render +
   CSG-with-holes geometry rebuild per frame.

   Apply the live-drag exception (same recipe fence move already used):
   visual translation via `sceneRegistry.nodes.get(id).position` +
   `useLiveTransforms`; scene.polygon is only written on commit. Polygon
   center precomputed in ctx, mesh-offset clears on commit/cancel.

2. Cursor sphere sat at the polygon center (offset from the user's
   actual cursor by `originalCenter - first_cursor`). Move wrappers
   now subscribe to `grid:move` and set `cursorRef.current.position`
   directly — no React state, no per-tick reconcile. Cursor lands on
   the user's pointer.

3. Fence move had the same React-reconcile-per-tick cost via its
   `useLiveTransforms` subscription. Switched to the same direct
   ref-mutation pattern.

Adds a "REAL bend" test pinning that one Ctrl-Z after a real curve
drag undoes only the bend, not the create. The previously reported
"first undo does nothing" outcome reproduces only for no-op bends
(drags within `normalizeWallCurveOffset`'s straight-snap threshold,
≈1.5cm on a 3m fence). For visible bends the dance pushes a real
pastState entry and one undo step rolls back the bend.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-18 08:27:27 -04:00
co-authored by Claude Opus 4.7
parent c2c2f66427
commit d1231b4901
6 changed files with 242 additions and 197 deletions
@@ -209,6 +209,33 @@ describe('Single-undo dance', () => {
expect((after as { curveOffset: number }).curveOffset).toBe(0)
})
test('REAL bend (draft != original): one Ctrl-Z undoes only the bend', () => {
useScene.getState().createNode(makeFence(0))
const stateAfterCreate = useScene.getState().nodes[FENCE_ID] as { curveOffset: number }
expect(stateAfterCreate.curveOffset).toBe(0)
const scene = createSceneApi(useScene)
scene.pauseHistory()
// Simulate a real drag: capture original, mutate to non-zero.
scene.update(FENCE_ID, { curveOffset: 0.5 } as Partial<AnyNode>)
expect((useScene.getState().nodes[FENCE_ID] as { curveOffset: number }).curveOffset).toBe(0.5)
// Dance.
scene.restoreAll()
expect((useScene.getState().nodes[FENCE_ID] as { curveOffset: number }).curveOffset).toBe(0)
scene.resumeHistory()
scene.update(FENCE_ID, { curveOffset: 0.5 } as Partial<AnyNode>)
expect((useScene.getState().nodes[FENCE_ID] as { curveOffset: number }).curveOffset).toBe(0.5)
// First Ctrl-Z should undo the bend.
useScene.temporal.getState().undo()
const afterFirstUndo = useScene.getState().nodes[FENCE_ID] as
| { curveOffset: number }
| undefined
expect(afterFirstUndo).toBeDefined()
expect(afterFirstUndo?.curveOffset).toBe(0)
})
test('a SECOND undo rolls the create step back', () => {
useScene.getState().createNode(makeFence(0))
const scene = createSceneApi(useScene)