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
+28 -30
View File
@@ -1,44 +1,39 @@
'use client'
import { type FenceNode, useLiveTransforms } from '@pascal-app/core'
import { emitter, type FenceNode, type GridEvent } from '@pascal-app/core'
import { CursorSphere, triggerSFX, useDragAction, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useMemo } from 'react'
import { useEffect, useRef } from 'react'
import type { Group } from 'three'
import { moveFenceDragAction } from './actions/move'
/**
* Phase 5 Stage D — thin React wrapper around `moveFenceDragAction`.
*
* 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 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
* the legacy MoveTool's per-kind branch chain falls through to here
* via the new affordance dispatch).
* Cursor sphere follows the raw grid pointer via direct ref mutation —
* no React state, no per-tick re-render. The fence mesh translates
* visually through the action's `mesh.position` + `useLiveTransforms`
* writes (live-drag exception); scene start/end are written on commit
* via the single-undo dance.
*/
export const FenceMoveTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const fenceId = node.id
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],
)
const cursorRef = useRef<Group>(null)
// 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],
)
useEffect(() => {
const onMove = (event: GridEvent) => {
if (!cursorRef.current) return
cursorRef.current.position.set(
event.localPosition[0],
event.localPosition[1],
event.localPosition[2],
)
}
emitter.on('grid:move', onMove)
return () => {
emitter.off('grid:move', onMove)
}
}, [])
const exitMoveMode = (committed: boolean) => {
if (committed) triggerSFX('sfx:item-place')
@@ -51,7 +46,10 @@ export const FenceMoveTool: React.FC<{ node: FenceNode }> = ({ node }) => {
action: moveFenceDragAction,
initial: {
node,
point: [originalCenter[0], originalCenter[2]],
// Initial point — useDragAction requires a Vec2. The action's
// begin captures everything else from input.node; this is just
// a placeholder until the first grid:move latches the anchor.
point: [(node.start[0] + node.end[0]) / 2, (node.start[1] + node.end[1]) / 2],
},
onCommit: () => exitMoveMode(true),
onCancel: () => exitMoveMode(false),
@@ -59,7 +57,7 @@ export const FenceMoveTool: React.FC<{ node: FenceNode }> = ({ node }) => {
return (
<group>
<CursorSphere position={liveCenter} showTooltip={false} />
<CursorSphere ref={cursorRef} showTooltip={false} />
</group>
)
}