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:
Wassim SAMAD
2026-06-08 15:04:28 -04:00
committed by GitHub
parent ce6f999310
commit 812b7306e8
15 changed files with 1197 additions and 36 deletions
+109 -8
View File
@@ -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