Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
49 changes: 31 additions & 18 deletions packages/editor/src/components/tools/roof/move-roof-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand All @@ -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) => {
Expand All @@ -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)
Expand All @@ -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

Expand Down
8 changes: 8 additions & 0 deletions packages/editor/src/components/tools/slab/move-slab-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 9 additions & 3 deletions packages/editor/src/components/tools/wall/curve-wall-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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] })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
37 changes: 29 additions & 8 deletions packages/editor/src/components/tools/wall/move-wall-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down