From d1231b490138bf61d8174e3b82d00b3318f5440b Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 18 May 2026 08:27:27 -0400 Subject: [PATCH] Phase 5 Stage D moves: live-drag for slab/ceiling + cursor follows pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/services/single-undo-dance.test.ts | 27 +++++ packages/nodes/src/ceiling/actions/move.ts | 96 ++++++++++----- packages/nodes/src/ceiling/move-tool.tsx | 114 ++++++------------ packages/nodes/src/fence/move-tool.tsx | 58 +++++---- packages/nodes/src/slab/actions/move.ts | 92 +++++++++----- packages/nodes/src/slab/move-tool.tsx | 52 ++++---- 6 files changed, 242 insertions(+), 197 deletions(-) diff --git a/packages/core/src/services/single-undo-dance.test.ts b/packages/core/src/services/single-undo-dance.test.ts index d7a88f0a..64442a38 100644 --- a/packages/core/src/services/single-undo-dance.test.ts +++ b/packages/core/src/services/single-undo-dance.test.ts @@ -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) + 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) + 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) diff --git a/packages/nodes/src/ceiling/actions/move.ts b/packages/nodes/src/ceiling/actions/move.ts index 9960f4be..25ca95ee 100644 --- a/packages/nodes/src/ceiling/actions/move.ts +++ b/packages/nodes/src/ceiling/actions/move.ts @@ -1,16 +1,25 @@ -import type { AnyNode, AnyNodeId, CeilingNode, DragAction } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + type CeilingNode, + type DragAction, + sceneRegistry, + useLiveTransforms, +} from '@pascal-app/core' import { triggerSFX } from '@pascal-app/editor' +import type * as THREE from 'three' /** * Phase 5 Stage D — whole-ceiling move drag affordance. * - * Mirrors `slab/actions/move.ts` shape but ceiling snaps purely to a - * 0.5m grid (no wall/fence corner snap — ceilings are typically - * placed independent of the floor layout). Drag anchor is latched on - * the first preview tick so the ceiling doesn't jump. Emits a grid- - * snap sfx when the snapped position changes between ticks. + * Live-drag exception (same recipe as slab/fence move): translate the + * ceiling MESH visually via `sceneRegistry.nodes.get(ceilingId) + * .position` plus a mirror in `useLiveTransforms`. No scene.update + * during the drag → no React re-render, no polygon CSG rebuild per + * tick. Snaps to a 0.5m grid (no wall/fence corner snap). * - * Single-undo dance on commit, same recipe as slab/fence. + * On commit the final polygon is written via the single-undo dance + * and the mesh-offset is cleared. */ const GRID_STEP = 0.5 @@ -31,17 +40,51 @@ function translatePolygon( return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number]) } +function polygonCenter(polygon: Array<[number, number]>): [number, number] { + if (polygon.length === 0) return [0, 0] + let sx = 0 + let sz = 0 + for (const [x, z] of polygon) { + sx += x + sz += z + } + return [sx / polygon.length, sz / polygon.length] +} + +function setMeshOffset(id: AnyNodeId, deltaX: number, deltaZ: number): void { + const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D | undefined + if (mesh) mesh.position.set(deltaX, 0, deltaZ) +} + +function setLiveTransform( + id: AnyNodeId, + originalCenter: [number, number], + deltaX: number, + deltaZ: number, + height: number, +): void { + useLiveTransforms.getState().set(id, { + position: [originalCenter[0] + deltaX, height, originalCenter[1] + deltaZ], + rotation: 0, + }) +} + +function clearLiveState(id: AnyNodeId): void { + setMeshOffset(id, 0, 0) + useLiveTransforms.getState().clear(id) +} + export type MoveCeilingCtx = { ceilingId: AnyNodeId originalPolygon: Array<[number, number]> originalHoles: Array> + originalCenter: [number, number] + height: number dragAnchor: [number, number] | null lastSnapped: [number, number] | null } export type MoveCeilingDraft = { - polygon: Array<[number, number]> - holes: Array> deltaX: number deltaZ: number } @@ -50,12 +93,15 @@ export const moveCeilingDragAction: DragAction begin: (input) => { const ceiling = input.node as CeilingNode | undefined if (!ceiling) throw new Error('[moveCeilingDragAction] begin requires a ceiling node') + const originalPolygon = ceiling.polygon.map(([x, z]) => [x, z] as [number, number]) return { ceilingId: ceiling.id as AnyNodeId, - originalPolygon: ceiling.polygon.map(([x, z]) => [x, z] as [number, number]), + originalPolygon, originalHoles: (ceiling.holes ?? []).map((h) => h.map(([x, z]) => [x, z] as [number, number]), ), + originalCenter: polygonCenter(originalPolygon), + height: ceiling.height ?? 2.5, dragAnchor: null, lastSnapped: null, } @@ -70,38 +116,30 @@ export const moveCeilingDragAction: DragAction ctx.lastSnapped = snapped } if (!ctx.dragAnchor) ctx.dragAnchor = snapped - const deltaX = sx - ctx.dragAnchor[0] - const deltaZ = sz - ctx.dragAnchor[1] return { - polygon: translatePolygon(ctx.originalPolygon, deltaX, deltaZ), - holes: ctx.originalHoles.map((h) => translatePolygon(h, deltaX, deltaZ)), - deltaX, - deltaZ, + deltaX: sx - ctx.dragAnchor[0], + deltaZ: sz - ctx.dragAnchor[1], } }, - apply: (draft, ctx, scene) => { - scene.update(ctx.ceilingId, { - polygon: draft.polygon, - holes: draft.holes, - } as Partial) - return [ctx.ceilingId] + apply: (draft, ctx, _scene) => { + setMeshOffset(ctx.ceilingId, draft.deltaX, draft.deltaZ) + setLiveTransform(ctx.ceilingId, ctx.originalCenter, draft.deltaX, draft.deltaZ, ctx.height) + return [] }, commit: (draft, ctx, scene) => { - // Always push — see fence/actions/curve.ts. No-movement still - // records a pastState entry so Ctrl-Z doesn't fall through to the - // ceiling-create step. scene.restoreAll() scene.resumeHistory() scene.update(ctx.ceilingId, { - polygon: draft.polygon, - holes: draft.holes, + polygon: translatePolygon(ctx.originalPolygon, draft.deltaX, draft.deltaZ), + holes: ctx.originalHoles.map((h) => translatePolygon(h, draft.deltaX, draft.deltaZ)), } as Partial) + clearLiveState(ctx.ceilingId) return true }, - cancel: (_ctx, _scene) => { - // No-op — orchestrator's scene.restoreAll() restores via snapshot. + cancel: (ctx, _scene) => { + clearLiveState(ctx.ceilingId) }, } diff --git a/packages/nodes/src/ceiling/move-tool.tsx b/packages/nodes/src/ceiling/move-tool.tsx index 8e8dd80d..137ec283 100644 --- a/packages/nodes/src/ceiling/move-tool.tsx +++ b/packages/nodes/src/ceiling/move-tool.tsx @@ -1,46 +1,54 @@ 'use client' -import { type CeilingNode, useScene } from '@pascal-app/core' +import { type CeilingNode, emitter, 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 { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three' +import { useEffect, useMemo, useRef } from 'react' +import type { Group } from 'three' import { moveCeilingDragAction } from './actions/move' /** * Phase 5 Stage D — thin React wrapper around `moveCeilingDragAction`. * - * Renders the cursor sphere at the ceiling polygon's live center plus - * a translucent preview fill + outline so the user sees where the - * ceiling lands before clicking. Polygon + holes are pulled from - * `useScene` so the wrapper mirrors the action's per-tick writes. + * Same shape as `slab/move-tool.tsx`: cursor sphere follows the raw + * grid pointer via direct ref mutation, the ceiling mesh translates + * visually via `mesh.position` + `useLiveTransforms`, scene polygon is + * written only on commit (single-undo dance). + * + * No preview fill / outline mesh — moving a translucent overlay every + * tick adds the same per-frame React reconciliation cost we're trying + * to avoid here. The real ceiling mesh translates in place; that's + * enough visual feedback. */ export const CeilingMoveTool: React.FC<{ node: CeilingNode }> = ({ node }) => { const ceilingId = node.id - const height = node.height ?? 2.5 + const cursorRef = useRef(null) - const live = useScene((s) => s.nodes[ceilingId]) - const liveCeiling = live?.type === 'ceiling' ? (live as CeilingNode) : node - const polygon = liveCeiling.polygon - // `?? []` would return a NEW empty array per render, busting downstream - // useMemo deps and (under StrictMode) potentially triggering the - // "getSnapshot result not cached" warning. Memoize against the source. - const EMPTY_HOLES = useMemo(() => [] as Array>, []) - const holes = liveCeiling.holes ?? EMPTY_HOLES - - const center: [number, number] = useMemo(() => { - if (polygon.length === 0) return [0, 0] + const initialCenter: [number, number] = useMemo(() => { + if (node.polygon.length === 0) return [0, 0] let sx = 0 let sz = 0 - for (const [x, z] of polygon) { + for (const [x, z] of node.polygon) { sx += x sz += z } - return [sx / polygon.length, sz / polygon.length] - }, [polygon]) + return [sx / node.polygon.length, sz / node.polygon.length] + }, [node.polygon]) - const previewFillGeometry = useMemo(() => createPreviewFill(polygon, holes), [polygon, holes]) - const previewOutlineGeometry = useMemo(() => createOutline(polygon), [polygon]) + 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') @@ -53,7 +61,7 @@ export const CeilingMoveTool: React.FC<{ node: CeilingNode }> = ({ node }) => { action: moveCeilingDragAction, initial: { node, - point: center, + point: initialCenter, }, onCommit: () => exitMoveMode(true), onCancel: () => exitMoveMode(false), @@ -61,63 +69,9 @@ export const CeilingMoveTool: React.FC<{ node: CeilingNode }> = ({ node }) => { return ( - - - - {/* @ts-ignore */} - - - - + ) } -function createPreviewFill( - polygon: Array<[number, number]>, - holes: Array>, -): BufferGeometry { - if (polygon.length < 3) return new BufferGeometry() - const shape = new Shape() - const [firstX, firstZ] = polygon[0]! - shape.moveTo(firstX, -firstZ) - for (let i = 1; i < polygon.length; i++) { - const [x, z] = polygon[i]! - shape.lineTo(x, -z) - } - shape.closePath() - for (const holePolygon of holes) { - if (holePolygon.length < 3) continue - const hole = new Path() - const [hx, hz] = holePolygon[0]! - hole.moveTo(hx, -hz) - for (let i = 1; i < holePolygon.length; i++) { - const [x, z] = holePolygon[i]! - hole.lineTo(x, -z) - } - hole.closePath() - shape.holes.push(hole) - } - const geometry = new ShapeGeometry(shape) - geometry.rotateX(-Math.PI / 2) - geometry.computeVertexNormals() - return geometry -} - -function createOutline(polygon: Array<[number, number]>): BufferGeometry { - const geometry = new BufferGeometry() - if (polygon.length < 2) return geometry - const points = polygon.map(([x, z]) => new Vector3(x, 0, z)) - const [firstX, firstZ] = polygon[0]! - points.push(new Vector3(firstX, 0, firstZ)) - geometry.setFromPoints(points) - return geometry -} - export default CeilingMoveTool diff --git a/packages/nodes/src/fence/move-tool.tsx b/packages/nodes/src/fence/move-tool.tsx index 22e2ea78..f1e2601d 100644 --- a/packages/nodes/src/fence/move-tool.tsx +++ b/packages/nodes/src/fence/move-tool.tsx @@ -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(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 ( - + ) } diff --git a/packages/nodes/src/slab/actions/move.ts b/packages/nodes/src/slab/actions/move.ts index 6719ad78..1d58a347 100644 --- a/packages/nodes/src/slab/actions/move.ts +++ b/packages/nodes/src/slab/actions/move.ts @@ -5,10 +5,13 @@ import { type FenceNode, type LevelNode, type SlabNode, + sceneRegistry, + useLiveTransforms, useScene, type WallNode, } from '@pascal-app/core' import { type FencePlanPoint, snapFenceDraftPoint, triggerSFX } from '@pascal-app/editor' +import type * as THREE from 'three' function sameSnap(a: FencePlanPoint | null, b: FencePlanPoint): boolean { return a !== null && a[0] === b[0] && a[1] === b[1] @@ -17,17 +20,20 @@ function sameSnap(a: FencePlanPoint | null, b: FencePlanPoint): boolean { /** * Phase 5 Stage D — whole-slab move drag affordance. * - * Translates the slab's boundary polygon (and any holes) rigidly under - * the pointer. Snaps to walls / fences / grid at the level. Latches - * the drag anchor on the first preview tick so the slab doesn't jump - * to wherever the activation click landed. Emits grid-snap sfx when - * the snapped position changes between ticks (matches legacy UX). + * Uses the **live-drag exception** (same recipe as fence move): the + * slab MESH is translated visually via `sceneRegistry.nodes.get(slabId) + * .position` plus a mirror entry in `useLiveTransforms`. The scene + * store's polygon stays untouched during the drag — no React re-render + * per tick, no CSG-with-holes rebuild per frame. * - * Unlike fence move, the slab port does **not** use the live-drag - * exception — polygon CSG geometry is expensive to rebuild per frame, - * but the legacy tool already writes the polygon to the scene every - * pointer tick and the user perceives that as smooth. Matching that - * for now; optimization is a separate task once we measure. + * On commit the final polygon is written to the scene via the single- + * undo dance, then the mesh-position offset is cleared. The renderer + * picks up the new polygon, the mesh re-mounts at the new world coords, + * and zundo records one diff. + * + * Hosted items don't follow the visual translation (same as legacy — + * item.position is independent of slab.polygon). Acceptable: the slab + * snaps back into place on commit so the visual mismatch is brief. */ function translatePolygon( @@ -49,10 +55,33 @@ function polygonCenter(polygon: Array<[number, number]>): [number, number] { return [sx / polygon.length, sz / polygon.length] } +function setMeshOffset(id: AnyNodeId, deltaX: number, deltaZ: number): void { + const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D | undefined + if (mesh) mesh.position.set(deltaX, 0, deltaZ) +} + +function setLiveTransform( + id: AnyNodeId, + originalCenter: [number, number], + deltaX: number, + deltaZ: number, +): void { + useLiveTransforms.getState().set(id, { + position: [originalCenter[0] + deltaX, 0, originalCenter[1] + deltaZ], + rotation: 0, + }) +} + +function clearLiveState(id: AnyNodeId): void { + setMeshOffset(id, 0, 0) + useLiveTransforms.getState().clear(id) +} + export type MoveSlabCtx = { slabId: AnyNodeId originalPolygon: Array<[number, number]> originalHoles: Array> + originalCenter: [number, number] parentId: string | null levelWalls: WallNode[] levelFences: FenceNode[] @@ -65,7 +94,6 @@ export type MoveSlabDraft = { holes: Array> deltaX: number deltaZ: number - center: [number, number] } export const moveSlabDragAction: DragAction = { @@ -100,6 +128,7 @@ export const moveSlabDragAction: DragAction = { slabId: slab.id as AnyNodeId, originalPolygon, originalHoles, + originalCenter: polygonCenter(originalPolygon), parentId, levelWalls, levelFences, @@ -114,7 +143,6 @@ export const moveSlabDragAction: DragAction = { walls: ctx.levelWalls, fences: ctx.levelFences, }) - // Emit grid-snap sfx when the snapped position changes. if (!sameSnap(ctx.lastSnapped, snapped)) { if (ctx.lastSnapped !== null) triggerSFX('sfx:grid-snap') ctx.lastSnapped = snapped @@ -122,40 +150,42 @@ export const moveSlabDragAction: DragAction = { if (!ctx.dragAnchor) ctx.dragAnchor = snapped const deltaX = snapped[0] - ctx.dragAnchor[0] const deltaZ = snapped[1] - ctx.dragAnchor[1] - const polygon = translatePolygon(ctx.originalPolygon, deltaX, deltaZ) - const holes = ctx.originalHoles.map((h) => translatePolygon(h, deltaX, deltaZ)) + // Translation is computed lazily on commit — preview only needs the + // deltas for the mesh-offset visual. return { - polygon, - holes, + polygon: ctx.originalPolygon, + holes: ctx.originalHoles, deltaX, deltaZ, - center: polygonCenter(polygon), } }, - apply: (draft, ctx, scene) => { - scene.update(ctx.slabId, { - polygon: draft.polygon, - holes: draft.holes, - } as Partial) - return [ctx.slabId] + apply: (draft, ctx, _scene) => { + // Live-drag exception: visual translate via Three.js mesh.position + + // useLiveTransforms. No scene.update during the drag, no React + // re-render of the slab geometry, no CSG-with-holes rebuild. + setMeshOffset(ctx.slabId, draft.deltaX, draft.deltaZ) + setLiveTransform(ctx.slabId, ctx.originalCenter, draft.deltaX, draft.deltaZ) + return [] }, commit: (draft, ctx, scene) => { - // Always push — see fence/actions/curve.ts. Even on a no-movement - // commit, the dance must push a pastState entry so Ctrl-Z doesn't - // cancel whatever was on the stack before activation. + // Single-undo dance — snapshot is empty (no scene.update during + // apply), restoreAll is a no-op. Resume history, write the final + // polygon. Zundo records one diff: original → translated. scene.restoreAll() scene.resumeHistory() scene.update(ctx.slabId, { - polygon: draft.polygon, - holes: draft.holes, + polygon: translatePolygon(ctx.originalPolygon, draft.deltaX, draft.deltaZ), + holes: ctx.originalHoles.map((h) => translatePolygon(h, draft.deltaX, draft.deltaZ)), } as Partial) + clearLiveState(ctx.slabId) return true }, - cancel: (_ctx, _scene) => { - // No-op — orchestrator's scene.restoreAll() puts the original - // polygon/holes back via the snapshot. + cancel: (ctx, _scene) => { + // Clear live-drag visual state — mesh snaps back to its (unchanged) + // scene position. + clearLiveState(ctx.slabId) }, } diff --git a/packages/nodes/src/slab/move-tool.tsx b/packages/nodes/src/slab/move-tool.tsx index 665bd714..d2345cbb 100644 --- a/packages/nodes/src/slab/move-tool.tsx +++ b/packages/nodes/src/slab/move-tool.tsx @@ -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(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 ( - + ) }