Phase 5 Stage D moves: align useLiveTransforms with the direct mesh.position delta

User reported the slab/fence mesh jittered/teleported continuously
while dragging (not on commit — during the drag). Root cause: the
move tool wrote TWO conflicting values per grid:move tick:

 1. `mesh.position.set(deltaX, 0, deltaZ)` — relative offset, direct
    Three.js mutation.
 2. `useLiveTransforms.set(id, { position: [originalCenter + deltaX,
    0, originalCenter + deltaZ], rotation: 0 })` — absolute world
    position of the translated polygon center.

`ParametricNodeRenderer` reads `useLiveTransforms` and binds it via
React: `<group position={liveTransform.position}>`. So every Zustand
notification re-rendered the renderer and reconciled the group's
position back to "originalCenter + delta" (the absolute), overriding
the "delta" the direct mutation had just written. The two systems
fought every frame → visible jitter.

Fix: `useLiveTransforms.position` now holds the SAME delta the direct
mutation uses (`[deltaX, 0, deltaZ]`). React reconciles to the same
value the direct mutation already set — no conflict.

The cursor sphere position stays as the translated polygon center
(it's tracked separately via React state, not `useLiveTransforms`).

Ceiling aligned for consistency, though CeilingRenderer doesn't read
`useLiveTransforms` so the value there has no rendering effect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-18 09:46:23 -04:00
co-authored by Claude Opus 4.7
parent f4ea07e05b
commit 82c9b5e1df
3 changed files with 22 additions and 15 deletions
+5 -1
View File
@@ -90,8 +90,12 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
const applyPreview = (deltaX: number, deltaZ: number) => { const applyPreview = (deltaX: number, deltaZ: number) => {
deltaRef.current = [deltaX, deltaZ] deltaRef.current = [deltaX, deltaZ]
setMeshOffset(ceilingId as AnyNodeId, deltaX, deltaZ, height) setMeshOffset(ceilingId as AnyNodeId, deltaX, deltaZ, height)
// Aligned with slab/fence: the delta matches the direct mesh
// mutation. CeilingRenderer doesn't bind position via React, so
// this entry isn't consumed for rendering, but kept consistent
// in case other systems read it.
useLiveTransforms.getState().set(ceilingId, { useLiveTransforms.getState().set(ceilingId, {
position: [originalCenter[0] + deltaX, height, originalCenter[1] + deltaZ], position: [deltaX, 0, deltaZ],
rotation: 0, rotation: 0,
}) })
setCursorLocalPos([originalCenter[0] + deltaX, height, originalCenter[1] + deltaZ]) setCursorLocalPos([originalCenter[0] + deltaX, height, originalCenter[1] + deltaZ])
+9 -12
View File
@@ -104,17 +104,14 @@ function setMeshOffset(fenceId: FenceNode['id'], deltaX: number, deltaZ: number)
if (mesh) mesh.position.set(deltaX, 0, deltaZ) if (mesh) mesh.position.set(deltaX, 0, deltaZ)
} }
function setFenceLiveTransform( function setFenceLiveTransform(fenceId: FenceNode['id'], deltaX: number, deltaZ: number): void {
fenceId: FenceNode['id'], // useLiveTransforms holds the SAME delta the direct mesh.position
start: [number, number], // mutation uses — ParametricNodeRenderer reads it and reconciles
end: [number, number], // `<group position={liveTransform.position}>` via React. Mismatched
deltaX: number, // values here cause the two systems to fight per frame (jitter
deltaZ: number, // during drag).
): void {
const cx = (start[0] + end[0]) / 2
const cz = (start[1] + end[1]) / 2
useLiveTransforms.getState().set(fenceId, { useLiveTransforms.getState().set(fenceId, {
position: [cx + deltaX, 0, cz + deltaZ], position: [deltaX, 0, deltaZ],
rotation: 0, rotation: 0,
}) })
} }
@@ -168,10 +165,10 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const applyPreview = (deltaX: number, deltaZ: number) => { const applyPreview = (deltaX: number, deltaZ: number) => {
deltaRef.current = [deltaX, deltaZ] deltaRef.current = [deltaX, deltaZ]
setMeshOffset(fenceId, deltaX, deltaZ) setMeshOffset(fenceId, deltaX, deltaZ)
setFenceLiveTransform(fenceId, originalStart, originalEnd, deltaX, deltaZ) setFenceLiveTransform(fenceId, deltaX, deltaZ)
for (const linked of linkedOriginals) { for (const linked of linkedOriginals) {
setMeshOffset(linked.id, deltaX, deltaZ) setMeshOffset(linked.id, deltaX, deltaZ)
setFenceLiveTransform(linked.id, linked.start, linked.end, deltaX, deltaZ) setFenceLiveTransform(linked.id, deltaX, deltaZ)
} }
// Cursor at translated polygon center. // Cursor at translated polygon center.
const centerX = (originalStart[0] + originalEnd[0]) / 2 const centerX = (originalStart[0] + originalEnd[0]) / 2
+8 -2
View File
@@ -109,11 +109,17 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
// Visual: translate the slab MESH only. No scene mutation, no // Visual: translate the slab MESH only. No scene mutation, no
// polygon rebuild, no React re-render of geometry. // polygon rebuild, no React re-render of geometry.
setMeshOffset(slabId as AnyNodeId, deltaX, deltaZ) setMeshOffset(slabId as AnyNodeId, deltaX, deltaZ)
// useLiveTransforms holds the same delta the direct mesh.position
// mutation uses — ParametricNodeRenderer reads it and reconciles
// `<group position={liveTransform.position}>` via React. Mismatched
// values here cause the two systems to fight per frame (jitter
// during drag).
useLiveTransforms.getState().set(slabId, { useLiveTransforms.getState().set(slabId, {
position: [originalCenter[0] + deltaX, 0, originalCenter[1] + deltaZ], position: [deltaX, 0, deltaZ],
rotation: 0, rotation: 0,
}) })
// Cursor sphere follows the new polygon center. // Cursor sphere follows the new polygon center (independent of
// group position).
setCursorLocalPos([originalCenter[0] + deltaX, 0, originalCenter[1] + deltaZ]) setCursorLocalPos([originalCenter[0] + deltaX, 0, originalCenter[1] + deltaZ])
} }