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
@@ -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