diff --git a/apps/editor/public/icons/elevator.png b/apps/editor/public/icons/elevator.png index 7bdf1eff..d0278d62 100644 Binary files a/apps/editor/public/icons/elevator.png and b/apps/editor/public/icons/elevator.png differ diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 38c845bf..470a3a75 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -287,6 +287,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const basePlaneRef = useRef(null!) const gridPosition = useRef(new Vector3(0, 0, 0)) const lastRawPos = useRef(new Vector3(0, 0, 0)) + const lastWallDirtyAtRef = useRef(new Map()) const placementState = useRef( config.initialState ?? { surface: 'floor', @@ -742,7 +743,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } // Mark parent wall dirty so it rebuilds geometry — only when position changed if (result.dirtyNodeId && posChanged) { - useScene.getState().dirtyNodes.add(result.dirtyNodeId) + const now = globalThis.performance?.now?.() ?? Date.now() + const last = lastWallDirtyAtRef.current.get(result.dirtyNodeId) ?? 0 + // Wall rebuilds can trigger expensive CSG; throttle live previews to avoid FPS collapse. + if (now - last > 120) { + lastWallDirtyAtRef.current.set(result.dirtyNodeId, now) + useScene.getState().dirtyNodes.add(result.dirtyNodeId) + } } // Publish live transform for 2D floorplan diff --git a/packages/nodes/src/column/move-tool.tsx b/packages/nodes/src/column/move-tool.tsx index cedd62a2..661ca981 100644 --- a/packages/nodes/src/column/move-tool.tsx +++ b/packages/nodes/src/column/move-tool.tsx @@ -40,6 +40,11 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { useEffect(() => { useScene.temporal.getState().pause() let committed = false + const meta = + typeof node.metadata === 'object' && node.metadata !== null + ? (node.metadata as Record) + : {} + const isNew = !!meta.isNew const applyPreview = (position: [number, number, number]) => { setPreviewPosition(position) @@ -66,7 +71,7 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { committed = true useLiveTransforms.getState().clear(nodeId) useScene.temporal.getState().resume() - useScene.getState().updateNode(nodeId, { position }) + useScene.getState().updateNode(nodeId, { position, ...(isNew ? { metadata: {} } : {}) }) } else if (node.parentId) { const column = ColumnNodeSchema.parse({ ...node, diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index feb38d2b..7953139d 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -68,6 +68,17 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => const markWallDirty = (wallId: string | null) => { if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId) } + const lastWallDirtyAt = new Map() + const markWallDirtyThrottled = (wallId: string | null) => { + if (!wallId) return + const now = globalThis.performance?.now?.() ?? Date.now() + const last = lastWallDirtyAt.get(wallId) ?? 0 + // Wall rebuilds can trigger expensive CSG; throttle live previews to avoid FPS collapse. + if (now - last > 120) { + lastWallDirtyAt.set(wallId, now) + markWallDirty(wallId) + } + } const getLevelId = () => useViewer.getState().selection.levelId const getLevelYOffset = () => { @@ -144,7 +155,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => }) if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId) - markWallDirty(event.node.id) + markWallDirtyThrottled(event.node.id) const valid = !hasWallChildOverlap( event.node.id, @@ -212,7 +223,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => position: [clampedX, clampedY, 0], rotation: itemRotation, }) - markWallDirty(event.node.id) + markWallDirtyThrottled(event.node.id) const valid = !hasWallChildOverlap( event.node.id, diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index b581378a..bbb0504e 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -70,18 +70,29 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode metadata: movingWindowNode.metadata, } - if (!isNew) { - // Move mode: mark the existing window as transient so it hides while being repositioned - useScene.getState().updateNode(movingWindowNode.id, { - metadata: { ...meta, isTransient: true }, - }) - } + // Mark the moving window as transient so it doesn't intercept wall raycasts while repositioning. + // Without this, duplicates can block `wall:*` events which breaks the cursor box and can cause + // rapid enter/leave churn (triggering expensive wall CSG rebuilds). + useScene.getState().updateNode(movingWindowNode.id, { + metadata: { ...meta, isTransient: true }, + }) let currentWallId: string | null = movingWindowNode.parentId const markWallDirty = (wallId: string | null) => { if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId) } + const lastWallDirtyAt = new Map() + const markWallDirtyThrottled = (wallId: string | null) => { + if (!wallId) return + const now = globalThis.performance?.now?.() ?? Date.now() + const last = lastWallDirtyAt.get(wallId) ?? 0 + // Wall rebuilds can trigger expensive CSG; throttle live previews to avoid FPS collapse. + if (now - last > 120) { + lastWallDirtyAt.set(wallId, now) + markWallDirty(wallId) + } + } const getLevelId = () => useViewer.getState().selection.levelId const getLevelYOffset = () => { @@ -151,7 +162,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId) - markWallDirty(event.node.id) + markWallDirtyThrottled(event.node.id) const valid = !hasWallChildOverlap( event.node.id, @@ -224,7 +235,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode position: [clampedX, clampedY, 0], rotation: itemRotation, }) - markWallDirty(event.node.id) + markWallDirtyThrottled(event.node.id) const valid = !hasWallChildOverlap( event.node.id, @@ -286,28 +297,20 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode useScene.getState().deleteNode(movingWindowNode.id) useScene.temporal.getState().resume() + const cloned = structuredClone(movingWindowNode) as any + delete cloned.id + if (cloned.metadata && typeof cloned.metadata === 'object') { + delete cloned.metadata.isNew + delete cloned.metadata.isTransient + } + const node = WindowNode.parse({ + ...cloned, position: [clampedX, clampedY, 0], rotation: [0, itemRotation, 0], side, wallId: event.node.id, parentId: event.node.id, - width: movingWindowNode.width, - height: movingWindowNode.height, - windowType: movingWindowNode.windowType, - operationState: movingWindowNode.operationState, - awningDirection: movingWindowNode.awningDirection, - casementStyle: movingWindowNode.casementStyle, - hingesSide: movingWindowNode.hingesSide, - frameThickness: movingWindowNode.frameThickness, - frameDepth: movingWindowNode.frameDepth, - columnRatios: movingWindowNode.columnRatios, - rowRatios: movingWindowNode.rowRatios, - columnDividerThickness: movingWindowNode.columnDividerThickness, - rowDividerThickness: movingWindowNode.rowDividerThickness, - sill: movingWindowNode.sill, - sillDepth: movingWindowNode.sillDepth, - sillThickness: movingWindowNode.sillThickness, }) useScene.getState().createNode(node, event.node.id as AnyNodeId) placedId = node.id diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index feeb216b..c8e4da26 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -35,7 +35,10 @@ export const DoorSystem = () => { clearDirty(id as AnyNodeId) // Rebuild the parent wall so its cutout reflects the updated door geometry - if ((node as DoorNode).parentId) { + // Avoid triggering expensive wall CSG rebuilds while the door is being interactively moved/duplicated. + // The editor tools will request a final wall rebuild on commit. + const isTransient = !!(node.metadata as Record | null)?.isTransient + if (!isTransient && (node as DoorNode).parentId) { useScene.getState().dirtyNodes.add((node as DoorNode).parentId as AnyNodeId) } }) diff --git a/packages/viewer/src/systems/window/window-system.tsx b/packages/viewer/src/systems/window/window-system.tsx index c4657ef2..d9254566 100644 --- a/packages/viewer/src/systems/window/window-system.tsx +++ b/packages/viewer/src/systems/window/window-system.tsx @@ -42,7 +42,10 @@ export const WindowSystem = () => { clearDirty(id as AnyNodeId) // Rebuild the parent wall so its cutout reflects the updated window geometry - if ((node as WindowNode).parentId) { + // Avoid triggering expensive wall CSG rebuilds while the window is being interactively moved/duplicated. + // The editor tools will request a final wall rebuild on commit. + const isTransient = !!(node.metadata as Record | null)?.isTransient + if (!isTransient && (node as WindowNode).parentId) { useScene.getState().dirtyNodes.add((node as WindowNode).parentId as AnyNodeId) } })