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