Reduce wall rebuilds during window and door placement

This commit is contained in:
sudhir
2026-05-20 00:53:10 +00:00
committed by open-pascal
parent fe68dbeebd
commit d48be77f5d
7 changed files with 62 additions and 30 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 124 KiB

@@ -287,6 +287,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const basePlaneRef = useRef<Mesh>(null!) const basePlaneRef = useRef<Mesh>(null!)
const gridPosition = useRef(new Vector3(0, 0, 0)) const gridPosition = useRef(new Vector3(0, 0, 0))
const lastRawPos = useRef(new Vector3(0, 0, 0)) const lastRawPos = useRef(new Vector3(0, 0, 0))
const lastWallDirtyAtRef = useRef(new Map<string, number>())
const placementState = useRef<PlacementState>( const placementState = useRef<PlacementState>(
config.initialState ?? { config.initialState ?? {
surface: 'floor', surface: 'floor',
@@ -742,7 +743,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} }
// Mark parent wall dirty so it rebuilds geometry — only when position changed // Mark parent wall dirty so it rebuilds geometry — only when position changed
if (result.dirtyNodeId && posChanged) { 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 // Publish live transform for 2D floorplan
+6 -1
View File
@@ -40,6 +40,11 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
useEffect(() => { useEffect(() => {
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
let committed = false let committed = false
const meta =
typeof node.metadata === 'object' && node.metadata !== null
? (node.metadata as Record<string, unknown>)
: {}
const isNew = !!meta.isNew
const applyPreview = (position: [number, number, number]) => { const applyPreview = (position: [number, number, number]) => {
setPreviewPosition(position) setPreviewPosition(position)
@@ -66,7 +71,7 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
committed = true committed = true
useLiveTransforms.getState().clear(nodeId) useLiveTransforms.getState().clear(nodeId)
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
useScene.getState().updateNode(nodeId, { position }) useScene.getState().updateNode(nodeId, { position, ...(isNew ? { metadata: {} } : {}) })
} else if (node.parentId) { } else if (node.parentId) {
const column = ColumnNodeSchema.parse({ const column = ColumnNodeSchema.parse({
...node, ...node,
+13 -2
View File
@@ -68,6 +68,17 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
const markWallDirty = (wallId: string | null) => { const markWallDirty = (wallId: string | null) => {
if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId) if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
} }
const lastWallDirtyAt = new Map<string, number>()
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 getLevelId = () => useViewer.getState().selection.levelId
const getLevelYOffset = () => { const getLevelYOffset = () => {
@@ -144,7 +155,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
}) })
if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId) if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
markWallDirty(event.node.id) markWallDirtyThrottled(event.node.id)
const valid = !hasWallChildOverlap( const valid = !hasWallChildOverlap(
event.node.id, event.node.id,
@@ -212,7 +223,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
position: [clampedX, clampedY, 0], position: [clampedX, clampedY, 0],
rotation: itemRotation, rotation: itemRotation,
}) })
markWallDirty(event.node.id) markWallDirtyThrottled(event.node.id)
const valid = !hasWallChildOverlap( const valid = !hasWallChildOverlap(
event.node.id, event.node.id,
+27 -24
View File
@@ -70,18 +70,29 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
metadata: movingWindowNode.metadata, metadata: movingWindowNode.metadata,
} }
if (!isNew) { // Mark the moving window as transient so it doesn't intercept wall raycasts while repositioning.
// Move mode: mark the existing window as transient so it hides while being repositioned // Without this, duplicates can block `wall:*` events which breaks the cursor box and can cause
useScene.getState().updateNode(movingWindowNode.id, { // rapid enter/leave churn (triggering expensive wall CSG rebuilds).
metadata: { ...meta, isTransient: true }, useScene.getState().updateNode(movingWindowNode.id, {
}) metadata: { ...meta, isTransient: true },
} })
let currentWallId: string | null = movingWindowNode.parentId let currentWallId: string | null = movingWindowNode.parentId
const markWallDirty = (wallId: string | null) => { const markWallDirty = (wallId: string | null) => {
if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId) if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
} }
const lastWallDirtyAt = new Map<string, number>()
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 getLevelId = () => useViewer.getState().selection.levelId
const getLevelYOffset = () => { const getLevelYOffset = () => {
@@ -151,7 +162,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
}) })
if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId) if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
markWallDirty(event.node.id) markWallDirtyThrottled(event.node.id)
const valid = !hasWallChildOverlap( const valid = !hasWallChildOverlap(
event.node.id, event.node.id,
@@ -224,7 +235,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
position: [clampedX, clampedY, 0], position: [clampedX, clampedY, 0],
rotation: itemRotation, rotation: itemRotation,
}) })
markWallDirty(event.node.id) markWallDirtyThrottled(event.node.id)
const valid = !hasWallChildOverlap( const valid = !hasWallChildOverlap(
event.node.id, event.node.id,
@@ -286,28 +297,20 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
useScene.getState().deleteNode(movingWindowNode.id) useScene.getState().deleteNode(movingWindowNode.id)
useScene.temporal.getState().resume() 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({ const node = WindowNode.parse({
...cloned,
position: [clampedX, clampedY, 0], position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0], rotation: [0, itemRotation, 0],
side, side,
wallId: event.node.id, wallId: event.node.id,
parentId: 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) useScene.getState().createNode(node, event.node.id as AnyNodeId)
placedId = node.id placedId = node.id
@@ -35,7 +35,10 @@ export const DoorSystem = () => {
clearDirty(id as AnyNodeId) clearDirty(id as AnyNodeId)
// Rebuild the parent wall so its cutout reflects the updated door geometry // 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<string, unknown> | null)?.isTransient
if (!isTransient && (node as DoorNode).parentId) {
useScene.getState().dirtyNodes.add((node as DoorNode).parentId as AnyNodeId) useScene.getState().dirtyNodes.add((node as DoorNode).parentId as AnyNodeId)
} }
}) })
@@ -42,7 +42,10 @@ export const WindowSystem = () => {
clearDirty(id as AnyNodeId) clearDirty(id as AnyNodeId)
// Rebuild the parent wall so its cutout reflects the updated window geometry // 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<string, unknown> | null)?.isTransient
if (!isTransient && (node as WindowNode).parentId) {
useScene.getState().dirtyNodes.add((node as WindowNode).parentId as AnyNodeId) useScene.getState().dirtyNodes.add((node as WindowNode).parentId as AnyNodeId)
} }
}) })