Fix editor interaction, loading, and IFC cleanup (#385)
* fix: update site labels after camera swaps * fix: gate scene display until viewer is ready * fix: render scene loader on first paint * fix: keep loader during pending scene graph * feat: add wasd camera panning * feat: add x delete mode shortcut * feat: add floorplan north compass * fix: move floorplan compass to lower corner * fix: progressively rebuild heavy scene geometry * fix: simplify noisy ifc wall output
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNodeId, StairOpeningSystem } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
nodeRegistry,
|
||||
StairOpeningSystem,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
|
||||
import * as THREE from 'three/webgpu'
|
||||
@@ -44,6 +50,19 @@ extend(THREE as any)
|
||||
// concurrent configure() calls await the same init instead of creating two
|
||||
// renderers in parallel and only caching the second.
|
||||
const WEBGPU_RENDERER_CACHE = new WeakMap<HTMLCanvasElement, Promise<THREE.WebGPURenderer>>()
|
||||
const SCENE_READY_SETTLED_FRAMES = 2
|
||||
const SCENE_READY_MAX_WAIT_FRAMES = 180
|
||||
const DIRTY_BUILD_KINDS = new Set([
|
||||
'ceiling',
|
||||
'door',
|
||||
'item',
|
||||
'roof',
|
||||
'roof-segment',
|
||||
'stair',
|
||||
'stair-segment',
|
||||
'wall',
|
||||
'window',
|
||||
])
|
||||
|
||||
const warnedEmptyDraw = process.env.NODE_ENV === 'production' ? null : new WeakSet<object>()
|
||||
|
||||
@@ -176,6 +195,73 @@ function ToneMappingExposure() {
|
||||
return null
|
||||
}
|
||||
|
||||
function hasPendingSceneBuildWork() {
|
||||
const { dirtyNodes, nodes } = useScene.getState()
|
||||
|
||||
for (const id of dirtyNodes) {
|
||||
const node = nodes[id]
|
||||
if (!node) continue
|
||||
const def = nodeRegistry.get(node.type)
|
||||
if (def?.geometry || def?.capabilities?.floorPlaced || DIRTY_BUILD_KINDS.has(node.type)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function hasCommittedSceneRoot() {
|
||||
const { nodes, rootNodeIds } = useScene.getState()
|
||||
if (rootNodeIds.length === 0) return Object.keys(nodes).length === 0
|
||||
return rootNodeIds.some((id) => sceneRegistry.nodes.has(id))
|
||||
}
|
||||
|
||||
function SceneReadyTracker({
|
||||
onSceneReadyChange,
|
||||
sceneReadyKey,
|
||||
}: {
|
||||
onSceneReadyChange?: (ready: boolean) => void
|
||||
sceneReadyKey?: string | number | null
|
||||
}) {
|
||||
const readyRef = useRef(false)
|
||||
const settledFramesRef = useRef(0)
|
||||
const waitedFramesRef = useRef(0)
|
||||
const onSceneReadyChangeRef = useRef(onSceneReadyChange)
|
||||
|
||||
useEffect(() => {
|
||||
onSceneReadyChangeRef.current = onSceneReadyChange
|
||||
}, [onSceneReadyChange])
|
||||
|
||||
useEffect(() => {
|
||||
void sceneReadyKey
|
||||
readyRef.current = false
|
||||
settledFramesRef.current = 0
|
||||
waitedFramesRef.current = 0
|
||||
onSceneReadyChangeRef.current?.(false)
|
||||
}, [sceneReadyKey])
|
||||
|
||||
useFrame(() => {
|
||||
if (!(onSceneReadyChangeRef.current && !readyRef.current)) return
|
||||
|
||||
waitedFramesRef.current += 1
|
||||
if (
|
||||
waitedFramesRef.current < SCENE_READY_MAX_WAIT_FRAMES &&
|
||||
(!hasCommittedSceneRoot() || hasPendingSceneBuildWork())
|
||||
) {
|
||||
settledFramesRef.current = 0
|
||||
return
|
||||
}
|
||||
|
||||
settledFramesRef.current += 1
|
||||
if (settledFramesRef.current < SCENE_READY_SETTLED_FRAMES) return
|
||||
|
||||
readyRef.current = true
|
||||
onSceneReadyChangeRef.current(true)
|
||||
}, 10)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
interface ViewerProps {
|
||||
children?: React.ReactNode
|
||||
hoverStyles?: HoverStyles
|
||||
@@ -197,6 +283,14 @@ interface ViewerProps {
|
||||
* for a future focus-mode UX.
|
||||
*/
|
||||
isolate?: AnyNodeId[] | null
|
||||
/**
|
||||
* Host-controlled key for scene readiness. Change it whenever a new scene
|
||||
* graph is being loaded; the viewer will report not-ready until the graph is
|
||||
* mounted, build systems have had a frame to settle, and one rendered frame
|
||||
* has presented the new content.
|
||||
*/
|
||||
sceneReadyKey?: string | number | null
|
||||
onSceneReadyChange?: (ready: boolean) => void
|
||||
}
|
||||
|
||||
/** Imperative handle exposed via `ref` on `<Viewer>`. */
|
||||
@@ -220,6 +314,8 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
|
||||
renderContext = 'editor',
|
||||
defaultRender,
|
||||
isolate,
|
||||
sceneReadyKey,
|
||||
onSceneReadyChange,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@@ -246,13 +342,17 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
|
||||
}, [isolate])
|
||||
|
||||
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
|
||||
const defaultShading = defaultRender?.shading
|
||||
const defaultTextures = defaultRender?.textures
|
||||
const defaultColorPreset = defaultRender?.colorPreset
|
||||
const hasDefaultRender = defaultRender != null
|
||||
useEffect(() => {
|
||||
const ctx = renderContext
|
||||
useViewer.getState().setRenderContext(ctx)
|
||||
const { shading, shadingByContext, setShading } = useViewer.getState()
|
||||
setShading(shadingByContext[ctx] ?? defaultRender?.shading ?? shading)
|
||||
setShading(shadingByContext[ctx] ?? defaultShading ?? shading)
|
||||
|
||||
if (!defaultRender || typeof window === 'undefined') return
|
||||
if (!hasDefaultRender || typeof window === 'undefined') return
|
||||
|
||||
let persistedState: Record<string, unknown> = {}
|
||||
const rawPreferences = window.localStorage.getItem('viewer-preferences')
|
||||
@@ -270,13 +370,13 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (defaultRender.textures !== undefined && !('textures' in persistedState)) {
|
||||
useViewer.getState().setTextures(defaultRender.textures)
|
||||
if (defaultTextures !== undefined && !('textures' in persistedState)) {
|
||||
useViewer.getState().setTextures(defaultTextures)
|
||||
}
|
||||
if (defaultRender.colorPreset && !('colorPreset' in persistedState)) {
|
||||
useViewer.getState().setColorPreset(defaultRender.colorPreset)
|
||||
if (defaultColorPreset && !('colorPreset' in persistedState)) {
|
||||
useViewer.getState().setColorPreset(defaultColorPreset)
|
||||
}
|
||||
}, [])
|
||||
}, [defaultColorPreset, defaultShading, defaultTextures, hasDefaultRender, renderContext])
|
||||
|
||||
// Coarse-pointer devices (phones/tablets) get a tighter DPR ceiling to keep
|
||||
// fragment-shader cost down — saves another ~30% over 1.5x on high-DPI mobile.
|
||||
@@ -329,6 +429,7 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
|
||||
<ViewerCamera />
|
||||
<GPUDeviceWatcher />
|
||||
<ToneMappingExposure />
|
||||
<SceneReadyTracker onSceneReadyChange={onSceneReadyChange} sceneReadyKey={sceneReadyKey} />
|
||||
|
||||
<ErrorBoundary fallback={null} scope="viewer-scene">
|
||||
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import {
|
||||
createSurfaceRoleMaterial,
|
||||
@@ -28,6 +28,9 @@ let revealMaterial: THREE.Material = defaultRevealMaterial
|
||||
let glassMaterial: THREE.Material = defaultGlassMaterial
|
||||
|
||||
const DOOR_RENDER_DEFAULTS = DoorNodeSchema.parse({ id: 'door_render_default' })
|
||||
const MAX_DOOR_REBUILDS_PER_FRAME = 16
|
||||
const DOOR_PROGRESSIVE_DIRTY_THRESHOLD = MAX_DOOR_REBUILDS_PER_FRAME
|
||||
const DOOR_PROGRESSIVE_TIME_BUDGET_MS = 8
|
||||
|
||||
// Legacy/unparsed door nodes can miss schema-defaulted fields (segments,
|
||||
// columnRatios, dividerThickness, …) and crash the geometry build. Re-apply the
|
||||
@@ -47,6 +50,7 @@ export const DoorSystem = () => {
|
||||
const shading = useViewer((state) => state.shading)
|
||||
const textures = useViewer((state) => state.textures)
|
||||
const colorPreset = useViewer((state) => state.colorPreset)
|
||||
const materialRevisionRef = useRef<string | null>(null)
|
||||
// Subscribe so an override-only update (no scene write) still re-runs
|
||||
// the component, letting the gate below pick up the latest dirtyNodes
|
||||
// set from the same render pass that received the override-publishing
|
||||
@@ -59,13 +63,17 @@ export const DoorSystem = () => {
|
||||
glassMaterial = textures ? defaultGlassMaterial : joineryMaterial
|
||||
|
||||
useEffect(() => {
|
||||
const materialRevision = `${shading}:${textures ? 'textures' : 'solid'}:${colorPreset}`
|
||||
if (materialRevisionRef.current === materialRevision) return
|
||||
materialRevisionRef.current = materialRevision
|
||||
|
||||
const nodes = useScene.getState().nodes
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (node?.type === 'door') {
|
||||
useScene.getState().dirtyNodes.add(node.id as AnyNodeId)
|
||||
}
|
||||
}
|
||||
}, [shading, textures, colorPreset])
|
||||
})
|
||||
|
||||
useFrame(() => {
|
||||
if (dirtyNodes.size === 0) return
|
||||
@@ -75,13 +83,35 @@ export const DoorSystem = () => {
|
||||
glassMaterial = textures ? defaultGlassMaterial : frameJoineryMaterial
|
||||
|
||||
const nodes = useScene.getState().nodes
|
||||
const dirtyDoorIds: AnyNodeId[] = []
|
||||
|
||||
dirtyNodes.forEach((id) => {
|
||||
const node = nodes[id]
|
||||
if (!node || node.type !== 'door') return
|
||||
dirtyDoorIds.push(id as AnyNodeId)
|
||||
})
|
||||
|
||||
const useProgressiveDoorRebuilds = dirtyDoorIds.length > DOOR_PROGRESSIVE_DIRTY_THRESHOLD
|
||||
const frameStartedAt = performance.now()
|
||||
let rebuiltDoorsThisFrame = 0
|
||||
|
||||
for (const id of dirtyDoorIds) {
|
||||
if (useProgressiveDoorRebuilds) {
|
||||
if (rebuiltDoorsThisFrame >= MAX_DOOR_REBUILDS_PER_FRAME) {
|
||||
break
|
||||
}
|
||||
if (
|
||||
rebuiltDoorsThisFrame > 0 &&
|
||||
performance.now() - frameStartedAt >= DOOR_PROGRESSIVE_TIME_BUDGET_MS
|
||||
) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const node = nodes[id]
|
||||
if (!node || node.type !== 'door') continue
|
||||
const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh
|
||||
if (!mesh) return // Keep dirty until mesh mounts
|
||||
if (!mesh) continue // Keep dirty until mesh mounts
|
||||
|
||||
// Merge any live override (width / height / position) so the mesh
|
||||
// rebuild reflects the in-flight drag without zustand churn. When
|
||||
@@ -89,6 +119,7 @@ export const DoorSystem = () => {
|
||||
const effectiveNode = getEffectiveNode(node as DoorNode)
|
||||
updateDoorMesh(effectiveNode, mesh)
|
||||
clearDirty(id as AnyNodeId)
|
||||
rebuiltDoorsThisFrame += 1
|
||||
|
||||
// Rebuild the parent wall so its cutout reflects the updated door geometry
|
||||
// Avoid triggering expensive wall CSG rebuilds while the door is being interactively moved/duplicated.
|
||||
@@ -97,7 +128,7 @@ export const DoorSystem = () => {
|
||||
if (!isTransient && effectiveNode.parentId) {
|
||||
useScene.getState().dirtyNodes.add(effectiveNode.parentId as AnyNodeId)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, 3)
|
||||
|
||||
return null
|
||||
|
||||
@@ -329,9 +329,20 @@ let useFrameNb = 0
|
||||
// within ~80ms. Standard CAD-app behavior. Speeds up t-junction drags ~3×,
|
||||
// 4-corner-room drags ~4×.
|
||||
const DRAG_FLUSH_MS = 80
|
||||
const MAX_WALL_REBUILDS_PER_FRAME = 8
|
||||
const WALL_PROGRESSIVE_DIRTY_THRESHOLD = MAX_WALL_REBUILDS_PER_FRAME
|
||||
const WALL_PROGRESSIVE_TIME_BUDGET_MS = 8
|
||||
let lastWallDirtyAtMs = 0
|
||||
const pendingAdjacentByLevel = new Map<string, Set<string>>()
|
||||
|
||||
function getPendingAdjacentCount() {
|
||||
let count = 0
|
||||
for (const ids of pendingAdjacentByLevel.values()) {
|
||||
count += ids.size
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
export const WallSystem = () => {
|
||||
const dirtyNodes = useScene((state) => state.dirtyNodes)
|
||||
const clearDirty = useScene((state) => state.clearDirty)
|
||||
@@ -353,6 +364,7 @@ export const WallSystem = () => {
|
||||
|
||||
// Collect dirty walls and their levels
|
||||
const dirtyWallsByLevel = new Map<string, Set<string>>()
|
||||
let dirtyWallCount = 0
|
||||
|
||||
useFrameNb += 1
|
||||
if (hasDirty) {
|
||||
@@ -367,6 +379,7 @@ export const WallSystem = () => {
|
||||
dirtyWallsByLevel.set(levelId, new Set())
|
||||
}
|
||||
dirtyWallsByLevel.get(levelId)?.add(id)
|
||||
dirtyWallCount += 1
|
||||
})
|
||||
}
|
||||
|
||||
@@ -375,25 +388,53 @@ export const WallSystem = () => {
|
||||
lastWallDirtyAtMs = now
|
||||
}
|
||||
|
||||
const useProgressiveWallRebuilds = dirtyWallCount > WALL_PROGRESSIVE_DIRTY_THRESHOLD
|
||||
let rebuiltWallsThisFrame = 0
|
||||
const rebuildFrameStartedAt = now
|
||||
|
||||
// Process each level that has dirty walls
|
||||
for (const [levelId, dirtyWallIds] of dirtyWallsByLevel) {
|
||||
if (useProgressiveWallRebuilds && rebuiltWallsThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) {
|
||||
break
|
||||
}
|
||||
|
||||
const levelWalls = getLevelWalls(levelId)
|
||||
const miterData = calculateLevelMiters(levelWalls)
|
||||
const rebuiltWallIds = new Set<string>()
|
||||
|
||||
// Update dirty walls — always, no throttling. The dragged wall must
|
||||
// follow the cursor with full fidelity (cutouts and all).
|
||||
// follow the cursor with full fidelity (cutouts and all). Large imports
|
||||
// enter the progressive path so initial load can't lock the tab.
|
||||
for (const wallId of dirtyWallIds) {
|
||||
if (useProgressiveWallRebuilds) {
|
||||
if (rebuiltWallsThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) {
|
||||
break
|
||||
}
|
||||
if (
|
||||
rebuiltWallsThisFrame > 0 &&
|
||||
performance.now() - rebuildFrameStartedAt >= WALL_PROGRESSIVE_TIME_BUDGET_MS
|
||||
) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh
|
||||
if (mesh) {
|
||||
updateWallGeometry(wallId, miterData)
|
||||
clearDirty(wallId as AnyNodeId)
|
||||
rebuiltWallIds.add(wallId)
|
||||
rebuiltWallsThisFrame += 1
|
||||
}
|
||||
// If mesh not found, keep it dirty for next frame
|
||||
}
|
||||
|
||||
if (rebuiltWallIds.size === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Adjacent walls sharing junctions — *defer* during active drag
|
||||
// (dirty arrived this frame), flush on the trailing edge.
|
||||
const adjacentWallIds = getAdjacentWallIds(levelWalls, dirtyWallIds)
|
||||
const adjacentWallIds = getAdjacentWallIds(levelWalls, rebuiltWallIds)
|
||||
let pending = pendingAdjacentByLevel.get(levelId)
|
||||
if (!pending) {
|
||||
pending = new Set()
|
||||
@@ -411,16 +452,45 @@ export const WallSystem = () => {
|
||||
// their correct miter joins.
|
||||
const quiet = !hasDirtyWalls && now - lastWallDirtyAtMs >= DRAG_FLUSH_MS
|
||||
if (quiet && pendingAdjacentByLevel.size > 0) {
|
||||
const pendingCount = getPendingAdjacentCount()
|
||||
const useProgressiveAdjacentRebuilds = pendingCount > WALL_PROGRESSIVE_DIRTY_THRESHOLD
|
||||
let rebuiltAdjacentThisFrame = 0
|
||||
const adjacentFrameStartedAt = performance.now()
|
||||
|
||||
for (const [levelId, pendingIds] of pendingAdjacentByLevel) {
|
||||
if (pendingIds.size === 0) continue
|
||||
const levelWalls = getLevelWalls(levelId)
|
||||
const miterData = calculateLevelMiters(levelWalls)
|
||||
for (const wallId of pendingIds) {
|
||||
for (const wallId of Array.from(pendingIds)) {
|
||||
if (useProgressiveAdjacentRebuilds) {
|
||||
if (rebuiltAdjacentThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) {
|
||||
break
|
||||
}
|
||||
if (
|
||||
rebuiltAdjacentThisFrame > 0 &&
|
||||
performance.now() - adjacentFrameStartedAt >= WALL_PROGRESSIVE_TIME_BUDGET_MS
|
||||
) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh
|
||||
if (mesh) updateWallGeometry(wallId, miterData)
|
||||
pendingIds.delete(wallId)
|
||||
rebuiltAdjacentThisFrame += 1
|
||||
}
|
||||
|
||||
if (pendingIds.size === 0) {
|
||||
pendingAdjacentByLevel.delete(levelId)
|
||||
}
|
||||
|
||||
if (
|
||||
useProgressiveAdjacentRebuilds &&
|
||||
rebuiltAdjacentThisFrame >= MAX_WALL_REBUILDS_PER_FRAME
|
||||
) {
|
||||
break
|
||||
}
|
||||
}
|
||||
pendingAdjacentByLevel.clear()
|
||||
}
|
||||
}, 4)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import {
|
||||
createSurfaceRoleMaterial,
|
||||
@@ -32,12 +32,17 @@ export const LOUVERED_WINDOW_SLATS_NAME = 'louvered-window-slats'
|
||||
export const AWNING_WINDOW_SASH_NAME = 'awning-window-sash'
|
||||
export const HOPPER_WINDOW_SASH_NAME = 'hopper-window-sash'
|
||||
|
||||
const MAX_WINDOW_REBUILDS_PER_FRAME = 16
|
||||
const WINDOW_PROGRESSIVE_DIRTY_THRESHOLD = MAX_WINDOW_REBUILDS_PER_FRAME
|
||||
const WINDOW_PROGRESSIVE_TIME_BUDGET_MS = 8
|
||||
|
||||
export const WindowSystem = () => {
|
||||
const dirtyNodes = useScene((state) => state.dirtyNodes)
|
||||
const clearDirty = useScene((state) => state.clearDirty)
|
||||
const shading = useViewer((state) => state.shading)
|
||||
const textures = useViewer((state) => state.textures)
|
||||
const colorPreset = useViewer((state) => state.colorPreset)
|
||||
const materialRevisionRef = useRef<string | null>(null)
|
||||
// Subscribe so override-only updates re-run this component. Mirrors
|
||||
// WallSystem + DoorSystem.
|
||||
useLiveNodeOverrides((s) => s.overrides)
|
||||
@@ -50,13 +55,17 @@ export const WindowSystem = () => {
|
||||
: createSurfaceRoleMaterial('glazing', colorPreset)
|
||||
|
||||
useEffect(() => {
|
||||
const materialRevision = `${shading}:${textures ? 'textures' : 'solid'}:${colorPreset}`
|
||||
if (materialRevisionRef.current === materialRevision) return
|
||||
materialRevisionRef.current = materialRevision
|
||||
|
||||
const nodes = useScene.getState().nodes
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (node?.type === 'window') {
|
||||
useScene.getState().dirtyNodes.add(node.id as AnyNodeId)
|
||||
}
|
||||
}
|
||||
}, [shading, textures, colorPreset])
|
||||
})
|
||||
|
||||
useFrame(() => {
|
||||
if (dirtyNodes.size === 0) return
|
||||
@@ -68,19 +77,43 @@ export const WindowSystem = () => {
|
||||
: createSurfaceRoleMaterial('glazing', colorPreset)
|
||||
|
||||
const nodes = useScene.getState().nodes
|
||||
const dirtyWindowIds: AnyNodeId[] = []
|
||||
|
||||
dirtyNodes.forEach((id) => {
|
||||
const node = nodes[id]
|
||||
if (!node || node.type !== 'window') return
|
||||
dirtyWindowIds.push(id as AnyNodeId)
|
||||
})
|
||||
|
||||
const useProgressiveWindowRebuilds = dirtyWindowIds.length > WINDOW_PROGRESSIVE_DIRTY_THRESHOLD
|
||||
const frameStartedAt = performance.now()
|
||||
let rebuiltWindowsThisFrame = 0
|
||||
|
||||
for (const id of dirtyWindowIds) {
|
||||
if (useProgressiveWindowRebuilds) {
|
||||
if (rebuiltWindowsThisFrame >= MAX_WINDOW_REBUILDS_PER_FRAME) {
|
||||
break
|
||||
}
|
||||
if (
|
||||
rebuiltWindowsThisFrame > 0 &&
|
||||
performance.now() - frameStartedAt >= WINDOW_PROGRESSIVE_TIME_BUDGET_MS
|
||||
) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const node = nodes[id]
|
||||
if (!node || node.type !== 'window') continue
|
||||
|
||||
const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh
|
||||
if (!mesh) return // Keep dirty until mesh mounts
|
||||
if (!mesh) continue // Keep dirty until mesh mounts
|
||||
|
||||
// Merge any live override (width / height / position) so the mesh
|
||||
// rebuild reflects the in-flight drag without zustand churn.
|
||||
const effectiveNode = getEffectiveNode(node as WindowNode)
|
||||
updateWindowMesh(effectiveNode, mesh)
|
||||
clearDirty(id as AnyNodeId)
|
||||
rebuiltWindowsThisFrame += 1
|
||||
|
||||
// Rebuild the parent wall so its cutout reflects the updated window geometry
|
||||
// Avoid triggering expensive wall CSG rebuilds while the window is being interactively moved/duplicated.
|
||||
@@ -89,7 +122,7 @@ export const WindowSystem = () => {
|
||||
if (!isTransient && effectiveNode.parentId) {
|
||||
useScene.getState().dirtyNodes.add(effectiveNode.parentId as AnyNodeId)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, 3)
|
||||
|
||||
return null
|
||||
|
||||
Reference in New Issue
Block a user