diff --git a/packages/nodes/src/ceiling/move-tool.tsx b/packages/nodes/src/ceiling/move-tool.tsx index 205d57e5..fb991d4b 100644 --- a/packages/nodes/src/ceiling/move-tool.tsx +++ b/packages/nodes/src/ceiling/move-tool.tsx @@ -5,19 +5,27 @@ import { type CeilingNode, emitter, type GridEvent, + sceneRegistry, + useLiveTransforms, useScene, } from '@pascal-app/core' import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three' +import { useCallback, useEffect, useRef, useState } from 'react' +import type * as THREE from 'three' /** - * Phase 5 Stage D — ceiling whole-move tool (kind-owned). + * Phase 5 Stage D — ceiling whole-move tool. * - * 1:1 port of the legacy `MoveCeilingTool`. 0.5m grid snap, scene.update - * per tick, history dance on commit, preview fill + outline overlay - * matching the legacy. + * Live-drag pattern: translate the ceiling MESH visually via + * `sceneRegistry.nodes.get(id).position` + a mirror in + * `useLiveTransforms`. No `scene.update` during the drag — polygon CSG + * with holes isn't rebuilt per tick. On commit we write the translated + * polygon to the scene once; the legacy `CeilingSystem` resets the + * mesh's X/Z position on rebuild (`mesh.position.x = 0`, + * `mesh.position.z = 0`) so the visual transitions smoothly. + * + * 0.5m grid snap (matches legacy). */ function snap(value: number) { return Math.round(value * 2) / 2 @@ -42,27 +50,29 @@ function getPolygonCenter(polygon: Array<[number, number]>): [number, number] { return [sumX / polygon.length, sumZ / polygon.length] } +function setMeshOffset(id: AnyNodeId, deltaX: number, deltaZ: number, height: number): void { + const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D | undefined + // CeilingSystem positions the mesh at height−0.01 on rebuild; we + // preserve the Y while offsetting X/Z during the drag. + if (mesh) mesh.position.set(deltaX, height - 0.01, deltaZ) +} + export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { const activatedAtRef = useRef(Date.now()) const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number])) const originalHolesRef = useRef( (node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])), ) + const originalCenterRef = useRef(getPolygonCenter(originalPolygonRef.current)) + const heightRef = useRef(node.height ?? 2.5) const dragAnchorRef = useRef<[number, number] | null>(null) const previousGridPosRef = useRef<[number, number] | null>(null) - const previousCursorPosRef = useRef<[number, number, number] | null>(null) - const previousDeltaRef = useRef<[number, number] | null>(null) - const previewRef = useRef<{ - polygon: Array<[number, number]> - holes: Array> - } | null>(null) + const deltaRef = useRef<[number, number]>([0, 0]) const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => { - const center = getPolygonCenter(node.polygon) - return [center[0], node.height ?? 2.5, center[1]] + const c = originalCenterRef.current + return [c[0], heightRef.current, c[1]] }) - const [previewPolygon, setPreviewPolygon] = useState>(node.polygon) - const [previewHoles, setPreviewHoles] = useState>>(node.holes ?? []) const exitMoveMode = useCallback(() => { useEditor.getState().setMovingNode(null) @@ -71,40 +81,30 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { useEffect(() => { const originalPolygon = originalPolygonRef.current const originalHoles = originalHolesRef.current + const originalCenter = originalCenterRef.current + const height = heightRef.current + const ceilingId = node.id - useScene.temporal.getState().pause() let wasCommitted = false - const applyPreview = ( - polygon: Array<[number, number]>, - holes: Array>, - ) => { - previewRef.current = { polygon, holes } - setPreviewPolygon(polygon) - setPreviewHoles(holes) - const center = getPolygonCenter(polygon) - const nextCursorPos: [number, number, number] = [center[0], node.height ?? 2.5, center[1]] - if ( - !previousCursorPosRef.current || - previousCursorPosRef.current[0] !== nextCursorPos[0] || - previousCursorPosRef.current[1] !== nextCursorPos[1] || - previousCursorPosRef.current[2] !== nextCursorPos[2] - ) { - previousCursorPosRef.current = nextCursorPos - setCursorLocalPos(nextCursorPos) - } - useScene.getState().updateNode(node.id, { polygon, holes }) - useScene.getState().markDirty(node.id as AnyNodeId) + const applyPreview = (deltaX: number, deltaZ: number) => { + deltaRef.current = [deltaX, deltaZ] + setMeshOffset(ceilingId as AnyNodeId, deltaX, deltaZ, height) + useLiveTransforms.getState().set(ceilingId, { + position: [originalCenter[0] + deltaX, height, originalCenter[1] + deltaZ], + rotation: 0, + }) + setCursorLocalPos([originalCenter[0] + deltaX, height, originalCenter[1] + deltaZ]) } - const restoreOriginal = () => { - setPreviewPolygon(originalPolygon) - setPreviewHoles(originalHoles) - useScene.getState().updateNode(node.id, { - holes: originalHoles, - polygon: originalPolygon, - }) - useScene.getState().markDirty(node.id as AnyNodeId) + const clearPreview = () => { + const mesh = sceneRegistry.nodes.get(ceilingId as AnyNodeId) as THREE.Object3D | undefined + if (mesh) { + mesh.position.x = 0 + mesh.position.z = 0 + // Leave Y at whatever the CeilingSystem set it to. + } + useLiveTransforms.getState().clear(ceilingId) } const onGridMove = (event: GridEvent) => { @@ -122,22 +122,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { const anchor = dragAnchorRef.current ?? [localX, localZ] dragAnchorRef.current = anchor - const deltaX = localX - anchor[0] - const deltaZ = localZ - anchor[1] - - if ( - previousDeltaRef.current && - previousDeltaRef.current[0] === deltaX && - previousDeltaRef.current[1] === deltaZ - ) { - return - } - previousDeltaRef.current = [deltaX, deltaZ] - - applyPreview( - translatePolygon(originalPolygon, deltaX, deltaZ), - originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)), - ) + applyPreview(localX - anchor[0], localZ - anchor[1]) } const onGridClick = (event: GridEvent) => { @@ -146,32 +131,27 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { return } - const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles } - + const [deltaX, deltaZ] = deltaRef.current wasCommitted = true - // Restore original baseline while paused so the next resume+update - // registers as a single tracked change (undo reverts to original). - useScene.getState().updateNode(node.id, { - polygon: originalPolygon, - holes: originalHoles, - }) - - useScene.temporal.getState().resume() - useScene.getState().updateNode(node.id, preview) - useScene.getState().markDirty(node.id as AnyNodeId) - useScene.temporal.getState().pause() + if (deltaX !== 0 || deltaZ !== 0) { + useScene.getState().updateNode(ceilingId, { + polygon: translatePolygon(originalPolygon, deltaX, deltaZ), + holes: originalHoles.map((h) => translatePolygon(h, deltaX, deltaZ)), + }) + useScene.getState().markDirty(ceilingId as AnyNodeId) + } + useLiveTransforms.getState().clear(ceilingId) triggerSFX('sfx:item-place') - useViewer.getState().setSelection({ selectedIds: [node.id] }) + useViewer.getState().setSelection({ selectedIds: [ceilingId] }) exitMoveMode() event.nativeEvent?.stopPropagation?.() } const onCancel = () => { - restoreOriginal() - useViewer.getState().setSelection({ selectedIds: [node.id] }) - useScene.temporal.getState().resume() + clearPreview() + useViewer.getState().setSelection({ selectedIds: [ceilingId] }) markToolCancelConsumed() exitMoveMode() } @@ -182,89 +162,21 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { return () => { if (!wasCommitted) { - restoreOriginal() + clearPreview() + } else { + useLiveTransforms.getState().clear(ceilingId) } - useScene.temporal.getState().resume() emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) } - }, [exitMoveMode, node.height, node.id]) - - const previewFillGeometry = useMemo( - () => createCeilingPreviewGeometry(previewPolygon, previewHoles), - [previewHoles, previewPolygon], - ) - - const previewOutlineGeometry = useMemo( - () => createCeilingOutlineGeometry(previewPolygon), - [previewPolygon], - ) + }, [exitMoveMode, node.id]) return ( - - - - {/* @ts-ignore */} - - - ) } -function createCeilingPreviewGeometry( - 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 createCeilingOutlineGeometry(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 MoveCeilingTool diff --git a/packages/nodes/src/fence/move-tool.tsx b/packages/nodes/src/fence/move-tool.tsx index 6a7a1ffb..7602845a 100644 --- a/packages/nodes/src/fence/move-tool.tsx +++ b/packages/nodes/src/fence/move-tool.tsx @@ -23,13 +23,19 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type * as THREE from 'three' /** - * Phase 5 Stage D — fence whole-move tool (kind-owned). + * Phase 5 Stage D — fence whole-move tool. * - * 1:1 port of the legacy `MoveFenceTool`. Same anchor-on-first-move - * delta drag, same linked-fence cascade, same live mesh.position + - * useLiveTransforms exception, same history dance on commit, same - * cursor render at the polygon center (anchor + delta), same - * activation grace. + * Live-drag pattern: translate the fence MESH (and any linked fences + * sharing an endpoint) via `sceneRegistry.nodes.get(id).position` + + * `useLiveTransforms`. No `scene.update` during the drag — fence + * geometry isn't rebuilt per tick. On commit we write the translated + * start/end (plus the linked-fence cascade) to the scene once. + * `GeometrySystem` resets `mesh.position` on rebuild, so the visual + * transitions smoothly with no teleport. + * + * History stays UNPAUSED during the drag (we're only mutating Three.js + * mesh transforms). The single `scene.update` on commit is the single + * undo step. */ function samePoint(a: [number, number], b: [number, number]) { return a[0] === b[0] && a[1] === b[1] @@ -46,20 +52,13 @@ function getLinkedFenceSnapshots(args: { fenceParentId: string | null originalStart: [number, number] originalEnd: [number, number] -}) { +}): LinkedFenceSnapshot[] { const { fenceId, fenceParentId, originalStart, originalEnd } = args const { nodes } = useScene.getState() const snapshots: LinkedFenceSnapshot[] = [] - for (const node of Object.values(nodes)) { - if (!(node?.type === 'fence' && node.id !== fenceId)) { - continue - } - - if ((node.parentId ?? null) !== fenceParentId) { - continue - } - + if (!(node?.type === 'fence' && node.id !== fenceId)) continue + if ((node.parentId ?? null) !== fenceParentId) continue if ( !( samePoint(node.start, originalStart) || @@ -67,17 +66,14 @@ function getLinkedFenceSnapshots(args: { samePoint(node.end, originalStart) || samePoint(node.end, originalEnd) ) - ) { + ) continue - } - snapshots.push({ id: node.id, start: [...node.start] as [number, number], end: [...node.end] as [number, number], }) } - return snapshots } @@ -103,6 +99,26 @@ function getLinkedFenceUpdates( })) } +function setMeshOffset(fenceId: FenceNode['id'], deltaX: number, deltaZ: number): void { + const mesh = sceneRegistry.nodes.get(fenceId) as THREE.Object3D | undefined + if (mesh) mesh.position.set(deltaX, 0, deltaZ) +} + +function setFenceLiveTransform( + fenceId: FenceNode['id'], + start: [number, number], + end: [number, number], + deltaX: number, + deltaZ: number, +): void { + const cx = (start[0] + end[0]) / 2 + const cz = (start[1] + end[1]) / 2 + useLiveTransforms.getState().set(fenceId, { + position: [cx + deltaX, 0, cz + deltaZ], + rotation: 0, + }) +} + export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { const activatedAtRef = useRef(Date.now()) const previousGridPosRef = useRef<[number, number] | null>(null) @@ -117,8 +133,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { }), ) const dragAnchorRef = useRef<[number, number] | null>(null) - const nodeIdRef = useRef(node.id) - const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null) + const deltaRef = useRef<[number, number]>([0, 0]) const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => { const centerX = (node.start[0] + node.end[0]) / 2 @@ -131,9 +146,11 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { }, []) useEffect(() => { - const nodeId = nodeIdRef.current + const fenceId = node.id const originalStart = originalStartRef.current const originalEnd = originalEndRef.current + const linkedOriginals = linkedOriginalsRef.current + const levelNode = node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level' ? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode) @@ -146,73 +163,28 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { .map((childId) => useScene.getState().nodes[childId as AnyNodeId]) .filter((child): child is FenceNode => child?.type === 'fence') - useScene.temporal.getState().pause() let wasCommitted = false - const setMeshOffset = (fenceId: FenceNode['id'], deltaX: number, deltaZ: number) => { - const mesh = sceneRegistry.nodes.get(fenceId) as THREE.Object3D | undefined - if (!mesh) { - return + const applyPreview = (deltaX: number, deltaZ: number) => { + deltaRef.current = [deltaX, deltaZ] + setMeshOffset(fenceId, deltaX, deltaZ) + setFenceLiveTransform(fenceId, originalStart, originalEnd, deltaX, deltaZ) + for (const linked of linkedOriginals) { + setMeshOffset(linked.id, deltaX, deltaZ) + setFenceLiveTransform(linked.id, linked.start, linked.end, deltaX, deltaZ) } - - mesh.position.set(deltaX, 0, deltaZ) + // Cursor at translated polygon center. + const centerX = (originalStart[0] + originalEnd[0]) / 2 + const centerZ = (originalStart[1] + originalEnd[1]) / 2 + setCursorLocalPos([centerX + deltaX, 0, centerZ + deltaZ]) } - const setFenceLiveTransform = (fence: FenceNode, deltaX: number, deltaZ: number) => { - const originalCenterX = (fence.start[0] + fence.end[0]) / 2 - const originalCenterZ = (fence.start[1] + fence.end[1]) / 2 - useLiveTransforms.getState().set(fence.id, { - position: [originalCenterX + deltaX, 0, originalCenterZ + deltaZ], - rotation: 0, - }) - } - - const clearPreviewState = () => { - setMeshOffset(nodeId, 0, 0) - useLiveTransforms.getState().clear(nodeId) - - for (const linkedFence of linkedOriginalsRef.current) { - setMeshOffset(linkedFence.id, 0, 0) - useLiveTransforms.getState().clear(linkedFence.id) - } - } - - const applyNodePreview = ( - updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>, - ) => { - useScene.getState().updateNodes( - updates.map((entry) => ({ - id: entry.id as AnyNodeId, - data: { start: entry.start, end: entry.end }, - })), - ) - for (const entry of updates) { - useScene.getState().markDirty(entry.id as AnyNodeId) - } - } - - const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => { - previewRef.current = { start: nextStart, end: nextEnd } - const centerX = (nextStart[0] + nextEnd[0]) / 2 - const centerZ = (nextStart[1] + nextEnd[1]) / 2 - setCursorLocalPos([centerX, 0, centerZ]) - const deltaX = nextStart[0] - originalStart[0] - const deltaZ = nextStart[1] - originalStart[1] - setMeshOffset(nodeId, deltaX, deltaZ) - setFenceLiveTransform(node, deltaX, deltaZ) - - for (const linkedFence of linkedOriginalsRef.current) { - setMeshOffset(linkedFence.id, deltaX, deltaZ) - setFenceLiveTransform( - { - ...node, - id: linkedFence.id, - start: linkedFence.start, - end: linkedFence.end, - }, - deltaX, - deltaZ, - ) + const clearPreview = () => { + setMeshOffset(fenceId, 0, 0) + useLiveTransforms.getState().clear(fenceId) + for (const linked of linkedOriginals) { + setMeshOffset(linked.id, 0, 0) + useLiveTransforms.getState().clear(linked.id) } } @@ -221,7 +193,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { point: [event.localPosition[0], event.localPosition[2]], walls: levelWalls, fences: levelFences, - ignoreFenceIds: [nodeId], + ignoreFenceIds: [fenceId], }) if ( @@ -235,13 +207,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { const anchor = dragAnchorRef.current ?? [localX, localZ] dragAnchorRef.current = anchor - const deltaX = localX - anchor[0] - const deltaZ = localZ - anchor[1] - - const nextStart: [number, number] = [originalStart[0] + deltaX, originalStart[1] + deltaZ] - const nextEnd: [number, number] = [originalEnd[0] + deltaX, originalEnd[1] + deltaZ] - - applyPreview(nextStart, nextEnd) + applyPreview(localX - anchor[0], localZ - anchor[1]) } const onGridClick = (event: GridEvent) => { @@ -250,37 +216,47 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { return } - const preview = previewRef.current ?? { start: originalStart, end: originalEnd } - + const [deltaX, deltaZ] = deltaRef.current wasCommitted = true - useScene.temporal.getState().resume() - applyNodePreview([ - { id: nodeId, start: preview.start, end: preview.end }, - ...getLinkedFenceUpdates( - linkedOriginalsRef.current, + if (deltaX !== 0 || deltaZ !== 0) { + const nextStart: [number, number] = [originalStart[0] + deltaX, originalStart[1] + deltaZ] + const nextEnd: [number, number] = [originalEnd[0] + deltaX, originalEnd[1] + deltaZ] + const linkedUpdates = getLinkedFenceUpdates( + linkedOriginals, originalStart, originalEnd, - preview.start, - preview.end, - ), - ]) - useLiveTransforms.getState().clear(nodeId) - for (const linkedFence of linkedOriginalsRef.current) { - useLiveTransforms.getState().clear(linkedFence.id) + nextStart, + nextEnd, + ) + useScene.getState().updateNodes([ + { id: fenceId as AnyNodeId, data: { start: nextStart, end: nextEnd } }, + ...linkedUpdates.map((u) => ({ + id: u.id as AnyNodeId, + data: { start: u.start, end: u.end }, + })), + ]) + useScene.getState().markDirty(fenceId as AnyNodeId) + for (const linked of linkedOriginals) { + useScene.getState().markDirty(linked.id as AnyNodeId) + } + } + // Clear useLiveTransforms but leave mesh.position — GeometrySystem + // resets it on the rebuild next frame. + useLiveTransforms.getState().clear(fenceId) + for (const linked of linkedOriginals) { + useLiveTransforms.getState().clear(linked.id) } - useScene.temporal.getState().pause() triggerSFX('sfx:item-place') - useViewer.getState().setSelection({ selectedIds: [nodeId] }) + useViewer.getState().setSelection({ selectedIds: [fenceId] }) exitMoveMode() event.nativeEvent?.stopPropagation?.() } const onCancel = () => { - clearPreviewState() - useViewer.getState().setSelection({ selectedIds: [nodeId] }) - useScene.temporal.getState().resume() + clearPreview() + useViewer.getState().setSelection({ selectedIds: [fenceId] }) markToolCancelConsumed() exitMoveMode() } @@ -290,15 +266,14 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { emitter.on('tool:cancel', onCancel) return () => { - if (wasCommitted) { - useLiveTransforms.getState().clear(nodeId) - for (const linkedFence of linkedOriginalsRef.current) { - useLiveTransforms.getState().clear(linkedFence.id) - } + if (!wasCommitted) { + clearPreview() } else { - clearPreviewState() + useLiveTransforms.getState().clear(fenceId) + for (const linked of linkedOriginals) { + useLiveTransforms.getState().clear(linked.id) + } } - useScene.temporal.getState().resume() emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) diff --git a/packages/nodes/src/slab/move-tool.tsx b/packages/nodes/src/slab/move-tool.tsx index 779e852b..e319a3d4 100644 --- a/packages/nodes/src/slab/move-tool.tsx +++ b/packages/nodes/src/slab/move-tool.tsx @@ -7,6 +7,8 @@ import { type GridEvent, type LevelNode, type SlabNode, + sceneRegistry, + useLiveTransforms, useScene, type WallNode, } from '@pascal-app/core' @@ -19,13 +21,24 @@ import { } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' +import type * as THREE from 'three' /** - * Phase 5 Stage D — slab whole-move tool (kind-owned). + * Phase 5 Stage D — slab whole-move tool. * - * 1:1 port of the legacy `MoveSlabTool`. scene.update writes polygon - * + holes per tick (renderer keeps up via RAF-batched markDirty), - * single-undo dance on commit, cursor at polygon center + delta. + * Live-drag pattern: translate the slab MESH visually via + * `sceneRegistry.nodes.get(id).position` + a mirror entry in + * `useLiveTransforms`. No `scene.update` during the drag — the slab's + * polygon CSG isn't rebuilt per tick. On commit we write the + * translated polygon to the scene exactly once and clear the + * useLiveTransforms entry. `GeometrySystem` resets `mesh.position` to + * (0,0,0) when it rebuilds, so the visual transitions smoothly with + * no teleport. + * + * History stays UNPAUSED during the drag: the scene state isn't + * changing (we're only mutating Three.js mesh transforms), so there's + * nothing for zundo to record. The single `scene.update` on commit + * becomes the single undo step naturally. */ function translatePolygon( polygon: Array<[number, number]>, @@ -46,22 +59,25 @@ function getPolygonCenter(polygon: Array<[number, number]>): [number, number] { return [sumX / polygon.length, sumZ / 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) +} + export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => { const activatedAtRef = useRef(Date.now()) const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number])) const originalHolesRef = useRef( (node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])), ) + const originalCenterRef = useRef(getPolygonCenter(originalPolygonRef.current)) const dragAnchorRef = useRef<[number, number] | null>(null) const previousGridPosRef = useRef<[number, number] | null>(null) - const previewRef = useRef<{ - polygon: Array<[number, number]> - holes: Array> - } | null>(null) + const deltaRef = useRef<[number, number]>([0, 0]) const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => { - const center = getPolygonCenter(node.polygon) - return [center[0], 0, center[1]] + const c = originalCenterRef.current + return [c[0], 0, c[1]] }) const exitMoveMode = useCallback(() => { @@ -71,6 +87,9 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => { useEffect(() => { const originalPolygon = originalPolygonRef.current const originalHoles = originalHolesRef.current + const originalCenter = originalCenterRef.current + const slabId = node.id + const levelNode = node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level' ? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode) @@ -83,26 +102,24 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => { .map((childId) => useScene.getState().nodes[childId as AnyNodeId]) .filter((child): child is FenceNode => child?.type === 'fence') - useScene.temporal.getState().pause() let wasCommitted = false - const applyPreview = ( - polygon: Array<[number, number]>, - holes: Array>, - ) => { - previewRef.current = { polygon, holes } - const center = getPolygonCenter(polygon) - setCursorLocalPos([center[0], 0, center[1]]) - useScene.getState().updateNode(node.id, { polygon, holes }) - useScene.getState().markDirty(node.id as AnyNodeId) + const applyPreview = (deltaX: number, deltaZ: number) => { + deltaRef.current = [deltaX, deltaZ] + // Visual: translate the slab MESH only. No scene mutation, no + // polygon rebuild, no React re-render of geometry. + setMeshOffset(slabId as AnyNodeId, deltaX, deltaZ) + useLiveTransforms.getState().set(slabId, { + position: [originalCenter[0] + deltaX, 0, originalCenter[1] + deltaZ], + rotation: 0, + }) + // Cursor sphere follows the new polygon center. + setCursorLocalPos([originalCenter[0] + deltaX, 0, originalCenter[1] + deltaZ]) } - const restoreOriginal = () => { - useScene.getState().updateNode(node.id, { - holes: originalHoles, - polygon: originalPolygon, - }) - useScene.getState().markDirty(node.id as AnyNodeId) + const clearPreview = () => { + setMeshOffset(slabId as AnyNodeId, 0, 0) + useLiveTransforms.getState().clear(slabId) } const onGridMove = (event: GridEvent) => { @@ -123,13 +140,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => { const anchor = dragAnchorRef.current ?? [localX, localZ] dragAnchorRef.current = anchor - const deltaX = localX - anchor[0] - const deltaZ = localZ - anchor[1] - - applyPreview( - translatePolygon(originalPolygon, deltaX, deltaZ), - originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)), - ) + applyPreview(localX - anchor[0], localZ - anchor[1]) } const onGridClick = (event: GridEvent) => { @@ -138,32 +149,35 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => { return } - const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles } - + const [deltaX, deltaZ] = deltaRef.current wasCommitted = true - // Restore original baseline while paused so the next resume+update - // registers as a single tracked change (undo reverts to original). - useScene.getState().updateNode(node.id, { - polygon: originalPolygon, - holes: originalHoles, - }) - - useScene.temporal.getState().resume() - useScene.getState().updateNode(node.id, preview) - useScene.getState().markDirty(node.id as AnyNodeId) - useScene.temporal.getState().pause() + if (deltaX !== 0 || deltaZ !== 0) { + // Single scene.update — recorded as one undo step (history was + // never paused). GeometrySystem rebuilds polygon-driven geometry + // and resets the group's transform on the next frame. + useScene.getState().updateNode(slabId, { + polygon: translatePolygon(originalPolygon, deltaX, deltaZ), + holes: originalHoles.map((h) => translatePolygon(h, deltaX, deltaZ)), + }) + useScene.getState().markDirty(slabId as AnyNodeId) + } + // Clear useLiveTransforms but leave mesh.position as-is. The + // GeometrySystem rebuild zeros it on the next frame, by which + // point the new geometry is in place — visual stays smooth. + useLiveTransforms.getState().clear(slabId) triggerSFX('sfx:item-place') - useViewer.getState().setSelection({ selectedIds: [node.id] }) + useViewer.getState().setSelection({ selectedIds: [slabId] }) exitMoveMode() event.nativeEvent?.stopPropagation?.() } const onCancel = () => { - restoreOriginal() - useViewer.getState().setSelection({ selectedIds: [node.id] }) - useScene.temporal.getState().resume() + // No scene state to roll back — we never wrote anything. Just + // restore the mesh visual. + clearPreview() + useViewer.getState().setSelection({ selectedIds: [slabId] }) markToolCancelConsumed() exitMoveMode() } @@ -174,14 +188,15 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => { return () => { if (!wasCommitted) { - restoreOriginal() + clearPreview() + } else { + useLiveTransforms.getState().clear(slabId) } - useScene.temporal.getState().resume() emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) } - }, [exitMoveMode, node.id, node.parentId, node.polygon]) + }, [exitMoveMode, node.id, node.parentId]) return ( diff --git a/packages/viewer/src/systems/ceiling/ceiling-system.tsx b/packages/viewer/src/systems/ceiling/ceiling-system.tsx index d6fc0a0a..63da642d 100644 --- a/packages/viewer/src/systems/ceiling/ceiling-system.tsx +++ b/packages/viewer/src/systems/ceiling/ceiling-system.tsx @@ -54,7 +54,13 @@ function updateCeilingGeometry(node: CeilingNode, mesh: THREE.Mesh) { gridMesh.geometry = newGeo.clone() } - // Position at the ceiling height + // Position at the ceiling height and reset X/Z so live-drag mesh + // offsets (set by move tools during the drag) don't leak into the + // canonical position after the rebuild. Matches the pattern used by + // FenceSystem.updateFenceGeometry / GeometrySystem (both fully reset + // position+rotation after rebuild). + mesh.position.x = 0 + mesh.position.z = 0 mesh.position.y = (node.height ?? 2.5) - 0.01 // Slight offset to avoid z-fighting with upper-level slabs } diff --git a/packages/viewer/src/systems/geometry/geometry-system.tsx b/packages/viewer/src/systems/geometry/geometry-system.tsx index b5fbc220..9facbeef 100644 --- a/packages/viewer/src/systems/geometry/geometry-system.tsx +++ b/packages/viewer/src/systems/geometry/geometry-system.tsx @@ -75,6 +75,15 @@ export const GeometrySystem = () => { for (const child of [...built.children]) { group.add(child) } + // Reset transform — matches the legacy per-kind systems + // (e.g. FenceSystem.updateFenceGeometry) which clear + // mesh.position/rotation when rebuilding. Tools that translate + // the group via `mesh.position` for live-drag visuals rely on + // this reset to restore the canonical position after scene + // state catches up (otherwise the geometry rebuild renders on + // top of the residual offset → double-translation teleport). + group.position.set(0, 0, 0) + group.rotation.set(0, 0, 0) clearDirty(id as AnyNodeId) })