diff --git a/packages/editor/src/components/tools/ceiling/move-ceiling-tool.tsx b/packages/editor/src/components/tools/ceiling/move-ceiling-tool.tsx index 77bd2760a..98745e08a 100644 --- a/packages/editor/src/components/tools/ceiling/move-ceiling-tool.tsx +++ b/packages/editor/src/components/tools/ceiling/move-ceiling-tool.tsx @@ -139,6 +139,14 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles } 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) diff --git a/packages/editor/src/components/tools/roof/move-roof-tool.tsx b/packages/editor/src/components/tools/roof/move-roof-tool.tsx index 1d1de3fae..72d9220c1 100644 --- a/packages/editor/src/components/tools/roof/move-roof-tool.tsx +++ b/packages/editor/src/components/tools/roof/move-roof-tool.tsx @@ -29,9 +29,13 @@ export const MoveRoofTool: React.FC<{ const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => { const obj = sceneRegistry.nodes.get(movingNode.id) if (obj) { - const pos = new THREE.Vector3() - obj.getWorldPosition(pos) - return [pos.x, pos.y, pos.z] + const worldPos = obj.getWorldPosition(new THREE.Vector3()) + // Cursor renders inside the building-local ToolManager group, so convert + // world → building-local to honor any building rotation. + const buildingId = useViewer.getState().selection.buildingId + const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null + if (buildingObj) buildingObj.worldToLocal(worldPos) + return [worldPos.x, worldPos.y, worldPos.z] } // Fallback if not registered (e.g. newly created duplicate without mesh yet) if ( @@ -114,10 +118,15 @@ export const MoveRoofTool: React.FC<{ } } - const computeLocal = (gridX: number, gridZ: number, y: number): [number, number] => { - let localX = gridX - let localZ = gridZ - + const computeLocal = ( + gridX: number, + gridZ: number, + y: number, + buildingLocalX: number, + buildingLocalZ: number, + ): [number, number] => { + // Segments have a transformed parent (stair/roof). Convert world → parent-local + // via Three.js hierarchy so the segment's stored position stays parent-relative. if ( (movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') && movingNode.parentId @@ -128,19 +137,21 @@ export const MoveRoofTool: React.FC<{ if (parentObj) { const worldVec = new THREE.Vector3(gridX, y, gridZ) parentObj.worldToLocal(worldVec) - localX = worldVec.x - localZ = worldVec.z - } else { - const dx = gridX - (parentNode.position[0] as number) - const dz = gridZ - (parentNode.position[2] as number) - const angle = -(parentNode.rotation as number) - localX = dx * Math.cos(angle) - dz * Math.sin(angle) - localZ = dx * Math.sin(angle) + dz * Math.cos(angle) + return [worldVec.x, worldVec.z] } + const dx = gridX - (parentNode.position[0] as number) + const dz = gridZ - (parentNode.position[2] as number) + const angle = -(parentNode.rotation as number) + return [ + dx * Math.cos(angle) - dz * Math.sin(angle), + dx * Math.sin(angle) + dz * Math.cos(angle), + ] } } - return [localX, localZ] + // Stair/roof live directly in the level — their stored position is building-local. + // event.localPosition is already building-local, so using it handles building rotation. + return [buildingLocalX, buildingLocalZ] } const onGridMove = (event: GridEvent) => { @@ -161,7 +172,7 @@ export const MoveRoofTool: React.FC<{ const lz = Math.round(event.localPosition[2] * 2) / 2 setCursorWorldPos([lx, event.localPosition[1], lz]) - const [localX, localZ] = computeLocal(gridX, gridZ, y) + const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz) // Directly update the Three.js mesh — no store update during drag const mesh = sceneRegistry.nodes.get(movingNode.id) @@ -181,8 +192,10 @@ export const MoveRoofTool: React.FC<{ const gridX = Math.round(event.position[0] * 2) / 2 // world, for computeLocal const gridZ = Math.round(event.position[2] * 2) / 2 const y = event.position[1] + const lx = Math.round(event.localPosition[0] * 2) / 2 + const lz = Math.round(event.localPosition[2] * 2) / 2 - const [localX, localZ] = computeLocal(gridX, gridZ, y) + const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz) wasCommitted = true diff --git a/packages/editor/src/components/tools/slab/move-slab-tool.tsx b/packages/editor/src/components/tools/slab/move-slab-tool.tsx index 9a21b3f0e..101920a45 100644 --- a/packages/editor/src/components/tools/slab/move-slab-tool.tsx +++ b/packages/editor/src/components/tools/slab/move-slab-tool.tsx @@ -112,6 +112,14 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => { const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles } 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) diff --git a/packages/editor/src/components/tools/wall/curve-wall-tool.tsx b/packages/editor/src/components/tools/wall/curve-wall-tool.tsx index 6703acec6..0d2675702 100644 --- a/packages/editor/src/components/tools/wall/curve-wall-tool.tsx +++ b/packages/editor/src/components/tools/wall/curve-wall-tool.tsx @@ -110,12 +110,18 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { const curveOffset = previewOffsetRef.current wasCommitted = true - useScene.temporal.getState().resume() - if (curveOffset !== getClampedWallCurveOffset(node)) { + + if (curveOffset !== originalCurveOffset) { + // Restore original baseline while paused so the next resume+update + // registers as a single tracked change (undo reverts to original). + useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset }) + useScene.getState().markDirty(nodeId as AnyNodeId) + + useScene.temporal.getState().resume() useScene.getState().updateNode(nodeId, { curveOffset }) useScene.getState().markDirty(nodeId as AnyNodeId) + useScene.temporal.getState().pause() } - useScene.temporal.getState().pause() sfxEmitter.emit('sfx:item-place') useViewer.getState().setSelection({ selectedIds: [nodeId] }) diff --git a/packages/editor/src/components/tools/wall/move-wall-endpoint-tool.tsx b/packages/editor/src/components/tools/wall/move-wall-endpoint-tool.tsx index 3cf01d89a..eae0e656b 100644 --- a/packages/editor/src/components/tools/wall/move-wall-endpoint-tool.tsx +++ b/packages/editor/src/components/tools/wall/move-wall-endpoint-tool.tsx @@ -201,6 +201,14 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ if (hasChanged && isWallLongEnough(preview.start, preview.end)) { wasCommitted = true + + // Restore original baseline while paused so the next resume+update + // registers as a single tracked change (undo reverts to original). + applyNodePreview([ + { id: nodeId, start: originalStart, end: originalEnd }, + ...linkedOriginalsRef.current, + ]) + useScene.temporal.getState().resume() applyNodePreview([ { id: nodeId, start: preview.start, end: preview.end }, diff --git a/packages/editor/src/components/tools/wall/move-wall-tool.tsx b/packages/editor/src/components/tools/wall/move-wall-tool.tsx index 0811992e1..733e9e38b 100644 --- a/packages/editor/src/components/tools/wall/move-wall-tool.tsx +++ b/packages/editor/src/components/tools/wall/move-wall-tool.tsx @@ -229,22 +229,43 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { const preview = previewRef.current ?? { start: originalStart, end: originalEnd } wasCommitted = true - useScene.temporal.getState().resume() + + // Restore original baseline while paused so the next resume+update + // registers as a single tracked change (undo reverts to original). applyNodePreview([ - { id: nodeId, start: preview.start, end: preview.end }, + { id: nodeId, start: originalStart, end: originalEnd }, + ...linkedOriginalsRef.current, + ]) + + useScene.temporal.getState().resume() + + const commitUpdates = [ + { + id: nodeId as AnyNodeId, + data: isNew + ? { + start: preview.start, + end: preview.end, + metadata: stripWallIsNewMetadata(node.metadata), + } + : { start: preview.start, end: preview.end }, + }, ...getLinkedWallUpdates( linkedOriginalsRef.current, originalStart, originalEnd, preview.start, preview.end, - ), - ]) - if (isNew) { - useScene.getState().updateNode(nodeId, { - metadata: stripWallIsNewMetadata(node.metadata), - }) + ).map((entry) => ({ + id: entry.id as AnyNodeId, + data: { start: entry.start, end: entry.end }, + })), + ] + useScene.getState().updateNodes(commitUpdates) + for (const { id } of commitUpdates) { + useScene.getState().markDirty(id) } + useScene.temporal.getState().pause() sfxEmitter.emit('sfx:item-place')