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