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:
co-authored by
Claude Opus 4.7
parent
c2c2f66427
commit
d1231b4901
@@ -1,26 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import { type SlabNode, useScene } from '@pascal-app/core'
|
||||
import { emitter, type GridEvent, type SlabNode } 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, useMemo, useRef } from 'react'
|
||||
import type { Group } from 'three'
|
||||
import { moveSlabDragAction } from './actions/move'
|
||||
|
||||
/**
|
||||
* Phase 5 Stage D — thin React wrapper around `moveSlabDragAction`.
|
||||
*
|
||||
* Replaces the legacy `MoveSlabTool` (182 LoC). All math + history
|
||||
* dance lives in the action; this wrapper renders the cursor sphere
|
||||
* at the live polygon center.
|
||||
*
|
||||
* NOTE on selector stability: the live polygon center MUST be derived
|
||||
* via `useMemo` over the node reference rather than computed inside
|
||||
* the `useScene` selector — returning a fresh `[x, z]` tuple from the
|
||||
* selector on every call triggers "getSnapshot result not cached"
|
||||
* → infinite re-render. Same pattern in fence/ceiling move-tool.
|
||||
* The cursor sphere follows the raw grid pointer via direct ref mutation
|
||||
* (no React state, no per-tick re-render). The slab mesh itself is
|
||||
* translated by the action using `mesh.position` + `useLiveTransforms`
|
||||
* (live-drag exception). Scene polygon is only written on commit.
|
||||
*/
|
||||
export const SlabMoveTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
||||
const slabId = node.id
|
||||
const cursorRef = useRef<Group>(null)
|
||||
|
||||
const initialCenter: [number, number] = useMemo(() => {
|
||||
if (node.polygon.length === 0) return [0, 0]
|
||||
let sx = 0
|
||||
@@ -32,22 +30,22 @@ export const SlabMoveTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
||||
return [sx / node.polygon.length, sz / node.polygon.length]
|
||||
}, [node.polygon])
|
||||
|
||||
// Subscribe to the live node reference (stable across renders when
|
||||
// the node hasn't changed; new reference per scene update). Derive
|
||||
// the center inside `useMemo` so the selector itself stays cached.
|
||||
const liveNode = useScene((s) => s.nodes[slabId])
|
||||
const liveCenter = useMemo<[number, number]>(() => {
|
||||
if (liveNode?.type !== 'slab') return initialCenter
|
||||
const poly = (liveNode as SlabNode).polygon
|
||||
if (poly.length === 0) return initialCenter
|
||||
let sx = 0
|
||||
let sz = 0
|
||||
for (const [x, z] of poly) {
|
||||
sx += x
|
||||
sz += z
|
||||
// Cursor follows the raw grid pointer — direct Three.js mutation,
|
||||
// bypassing React reconciliation for the per-tick position update.
|
||||
useEffect(() => {
|
||||
const onMove = (event: GridEvent) => {
|
||||
if (!cursorRef.current) return
|
||||
cursorRef.current.position.set(
|
||||
event.localPosition[0],
|
||||
event.localPosition[1],
|
||||
event.localPosition[2],
|
||||
)
|
||||
}
|
||||
return [sx / poly.length, sz / poly.length]
|
||||
}, [liveNode, initialCenter])
|
||||
emitter.on('grid:move', onMove)
|
||||
return () => {
|
||||
emitter.off('grid:move', onMove)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const exitMoveMode = (committed: boolean) => {
|
||||
if (committed) triggerSFX('sfx:item-place')
|
||||
@@ -68,7 +66,7 @@ export const SlabMoveTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
||||
|
||||
return (
|
||||
<group>
|
||||
<CursorSphere position={[liveCenter[0], 0, liveCenter[1]]} showTooltip={false} />
|
||||
<CursorSphere ref={cursorRef} showTooltip={false} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user