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
|
||||
|
||||
Reference in New Issue
Block a user