Merge branch 'main' into feat/upgrade-and-bug-fix

This commit is contained in:
Sudhir Yadav
2026-04-29 10:49:11 +05:30
committed by GitHub
65 changed files with 11172 additions and 4039 deletions
@@ -1,15 +1,25 @@
import type { ErrorInfo, ReactNode } from 'react'
import { Component } from 'react'
export class ErrorBoundary extends Component<
{ children: ReactNode; fallback: ReactNode },
{ hasError: boolean }
> {
interface ErrorBoundaryProps {
children: ReactNode
fallback: ReactNode
/** Tag for log lines so we can tell which boundary swallowed an error. */
scope?: string
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, { hasError: boolean }> {
state = { hasError: false }
static getDerivedStateFromError() {
return { hasError: true }
}
componentDidCatch(_e: Error, _i: ErrorInfo) {}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error(
`[viewer] ErrorBoundary caught${this.props.scope ? ` (${this.props.scope})` : ''}:`,
error,
info.componentStack,
)
}
render() {
return this.state.hasError ? this.props.fallback : this.props.children
}
@@ -22,6 +22,10 @@ import { MeshStandardNodeMaterial } from 'three/webgpu'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { resolveCdnUrl } from '../../../lib/asset-url'
import { useItemLightPool } from '../../../store/use-item-light-pool'
import {
requestItemMeshMetadataSync,
setItemMeshMetadataSourceRoot,
} from '../../../systems/item-mesh-metadata/sync-request'
import { ErrorBoundary } from '../../error-boundary'
import { NodeRenderer } from '../node-renderer'
@@ -107,6 +111,19 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
}, [node.parentId])
// Re-sync when GLTF `scene` or external `metadata` edits should invalidate cached footprint/bounds.
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — asset load and metadata drive mesh-metadata sync
useEffect(() => {
const cloneRoot = ref.current
if (!cloneRoot) return
setItemMeshMetadataSourceRoot(node.id, cloneRoot)
requestItemMeshMetadataSync(node.id)
return () => {
setItemMeshMetadataSourceRoot(node.id, null)
}
}, [node.id, node.metadata, scene])
useEffect(() => {
const interactive = interactiveRef.current
if (!interactive) return
+115 -44
View File
@@ -18,10 +18,12 @@ import * as THREE from 'three/webgpu'
import useViewer from '../../store/use-viewer'
import { GuideSystem } from '../../systems/guide/guide-system'
import { ItemLightSystem } from '../../systems/item-light/item-light-system'
import { ItemMeshMetadataSystem } from '../../systems/item-mesh-metadata/item-mesh-metadata-system'
import { LevelSystem } from '../../systems/level/level-system'
import { ScanSystem } from '../../systems/scan/scan-system'
import { WallCutout } from '../../systems/wall/wall-cutout'
import { ZoneSystem } from '../../systems/zone/zone-system'
import { ErrorBoundary } from '../error-boundary'
import { SceneRenderer } from '../renderers/scene-renderer'
import FrameLimiter from './frame-limiter'
import { Lights } from './lights'
@@ -63,6 +65,22 @@ declare module '@react-three/fiber' {
extend(THREE as any)
// R3F's <Canvas> useLayoutEffect has no deps, so any re-render (theme switch,
// parent re-render, StrictMode double-mount) re-invokes `configure()`. With a
// sync `gl` factory that's harmless — the renderer is created once and reused.
// With an async factory (WebGPURenderer needs `await init()`), two configure
// calls can race: both see `state.gl == null` and both create a renderer. The
// first to resolve gets `setSize`/`setDpr` called on it; the second overwrites
// `state.gl` but R3F's store already holds the new size/dpr, so the new
// renderer is never resized and stays at the canvas's 300×150 default.
//
// Caching by canvas guarantees both branches return the same instance, so
// "duplicate" configure calls become no-ops on an already-sized renderer.
// We cache the in-flight Promise (not just the resolved renderer) so two
// 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>>()
/**
* Monitors the WebGPU device for loss events and logs them.
* WebGPU device loss can happen when:
@@ -77,6 +95,10 @@ type WebGPUDeviceLossInfo = {
type WebGPUDeviceLike = {
lost: Promise<WebGPUDeviceLossInfo>
label?: string
features?: Set<string>
addEventListener?: (type: string, listener: EventListener) => void
removeEventListener?: (type: string, listener: EventListener) => void
}
function GPUDeviceWatcher() {
@@ -86,7 +108,18 @@ function GPUDeviceWatcher() {
const backend = (gl as any).backend
const device = backend?.device as WebGPUDeviceLike | undefined
if (!device) return
if (!device) {
console.warn('[viewer] No WebGPU device on backend — running on a fallback renderer.', {
backend: backend?.constructor?.name ?? 'unknown',
rendererType: (gl as any).constructor?.name ?? 'unknown',
})
return
}
console.log('[viewer] WebGPU device ready', {
label: device.label,
features: device.features ? Array.from(device.features) : [],
})
device.lost.then((info: WebGPUDeviceLossInfo) => {
console.error(
@@ -94,6 +127,17 @@ function GPUDeviceWatcher() {
'The page must be reloaded to recover the GPU context.',
)
})
// Uncaptured errors are normally silent (only console-warned by Chrome at
// best). Pipe them to console.error so silent mobile crashes show up.
const onUncapturedError = (event: any) => {
console.error('[viewer] WebGPU uncaptured error:', event?.error?.message, event?.error)
}
device.addEventListener?.('uncapturederror', onUncapturedError)
return () => {
device.removeEventListener?.('uncapturederror', onUncapturedError)
}
}, [gl])
return null
@@ -119,19 +163,43 @@ const Viewer: React.FC<ViewerProps> = ({
className={`transition-colors duration-700 ${theme === 'dark' ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`}
dpr={[1, 1.5]}
frameloop="never"
gl={async (props) => {
const renderer = new THREE.WebGPURenderer(props as any)
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 0.9
// Awaiting init() is required when the browser falls back to the
// WebGL2 backend (Safari without the WebGPU flag, older Chrome on
// machines without a WebGPU device). In native WebGPU mode the
// init resolves almost instantly. Without this await, the first
// render throws "Renderer: .render() called before the backend is
// initialized" from the post-processing fallback path.
await renderer.init()
return renderer
}}
gl={
((props: { canvas?: HTMLCanvasElement }) => {
const canvas = props.canvas
const cached = canvas ? WEBGPU_RENDERER_CACHE.get(canvas) : undefined
if (cached) return cached
// Surface the env we're about to ask WebGPU for — catches "no
// navigator.gpu" / "adapter request failed" silently failing in
// mobile WebViews where WebGPU is gated behind flags.
const hasGpu = typeof navigator !== 'undefined' && 'gpu' in navigator
console.log('[viewer] Creating WebGPURenderer', {
hasNavigatorGPU: hasGpu,
ua: typeof navigator !== 'undefined' ? navigator.userAgent : 'n/a',
})
const promise = (async () => {
try {
const renderer = new THREE.WebGPURenderer(props as any)
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 0.9
await renderer.init()
console.log('[viewer] WebGPURenderer ready', {
backend: (renderer as any).backend?.constructor?.name,
isWebGPU: (renderer as any).isWebGPURenderer === true,
})
return renderer
} catch (err) {
// Drop the failed promise from the cache so a future Canvas
// mount on the same DOM can retry instead of inheriting the
// rejection forever.
if (canvas) WEBGPU_RENDERER_CACHE.delete(canvas)
console.error('[viewer] WebGPURenderer init failed', err)
throw err
}
})()
if (canvas) WEBGPU_RENDERER_CACHE.set(canvas, promise)
return promise
}) as any
}
resize={{
debounce: 100,
}}
@@ -143,38 +211,41 @@ const Viewer: React.FC<ViewerProps> = ({
<FrameLimiter fps={50} />
{/* <AnimatedBackground isDark={theme === 'dark'} /> */}
<ViewerCamera />
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
/> */}
<Lights />
<Bvh>
<SceneRenderer />
</Bvh>
{/* Default Systems */}
<LevelSystem />
<GuideSystem />
<ScanSystem />
<WallCutout />
{/* Core systems */}
<CeilingSystem />
<DoorSystem />
<FenceSystem />
<ItemSystem />
<RoofSystem />
<SlabSystem />
<StairSystem />
<WallSystem />
<WindowSystem />
<ZoneSystem />
<PostProcessing hoverStyles={hoverStyles} />
{/* <DebugRenderer /> */}
<GPUDeviceWatcher />
<ItemLightSystem />
{selectionManager === 'default' && <SelectionManager />}
{perf && <PerfMonitor />}
{children}
<ErrorBoundary fallback={null} scope="viewer-scene">
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
/> */}
<Lights />
<Bvh>
<SceneRenderer />
</Bvh>
{/* Default Systems */}
<LevelSystem />
<GuideSystem />
<ScanSystem />
<WallCutout />
{/* Core systems */}
<CeilingSystem />
<DoorSystem />
<FenceSystem />
<ItemSystem />
<RoofSystem />
<SlabSystem />
<StairSystem />
<WallSystem />
<WindowSystem />
<ZoneSystem />
<PostProcessing hoverStyles={hoverStyles} />
{/* <DebugRenderer /> */}
<ItemLightSystem />
<ItemMeshMetadataSystem />
{selectionManager === 'default' && <SelectionManager />}
{perf && <PerfMonitor />}
{children}
</ErrorBoundary>
</Canvas>
)
}
@@ -174,9 +174,22 @@ const PostProcessingPasses = ({
void pipelineVersion
if (!(renderer && scene && camera)) {
console.warn('[viewer/post-processing] Skipping pipeline build — missing dependency.', {
hasRenderer: !!renderer,
hasScene: !!scene,
hasCamera: !!camera,
})
return
}
console.log('[viewer/post-processing] Building pipeline', {
version: pipelineVersion,
ssgi: SSGI_PARAMS.enabled,
hoverHighlightMode,
projectId,
rendererCtor: (renderer as any).constructor?.name,
})
hasPipelineErrorRef.current = false
// WebGPU availability check: SSGI, denoise, and RenderPipeline are all
@@ -318,10 +331,16 @@ const PostProcessingPasses = ({
renderPipeline.outputNode = finalOutput
renderPipelineRef.current = renderPipeline
retryCountRef.current = 0
console.log('[viewer/post-processing] Pipeline built OK', { version: pipelineVersion })
} catch (error) {
hasPipelineErrorRef.current = true
console.error(
'[viewer] Failed to set up post-processing pipeline. Rendering without post FX.',
'[viewer/post-processing] Failed to set up post-processing pipeline. Rendering without post FX.',
{
version: pipelineVersion,
ssgi: SSGI_PARAMS.enabled,
rendererCtor: (renderer as any).constructor?.name,
},
error,
)
if (renderPipelineRef.current) {
@@ -366,7 +385,7 @@ const PostProcessingPasses = ({
}
;(renderer as any).render(scene, camera)
} catch (fallbackError) {
console.error('[viewer] Fallback render failed.', fallbackError)
console.error('[viewer/post-processing] Fallback render failed.', fallbackError)
}
return
}
@@ -378,7 +397,11 @@ const PostProcessingPasses = ({
renderPipelineRef.current.render()
} catch (error) {
hasPipelineErrorRef.current = true
console.error('[viewer] Post-processing render pass failed.', error)
console.error('[viewer/post-processing] Render pass failed.', {
retryCount: retryCountRef.current,
rendererCtor: (renderer as any).constructor?.name,
error,
})
if (renderPipelineRef.current) {
renderPipelineRef.current.dispose()
}
@@ -388,7 +411,7 @@ const PostProcessingPasses = ({
// Auto-retry: schedule a pipeline rebuild if we haven't exceeded the retry limit
retryCountRef.current++
console.warn(
`[viewer] Scheduling post-processing rebuild (attempt ${retryCountRef.current}/${MAX_PIPELINE_RETRIES})`,
`[viewer/post-processing] Scheduling pipeline rebuild (attempt ${retryCountRef.current}/${MAX_PIPELINE_RETRIES})`,
)
if (rebuildTimeoutRef.current !== null) {
clearTimeout(rebuildTimeoutRef.current)
@@ -396,7 +419,7 @@ const PostProcessingPasses = ({
rebuildTimeoutRef.current = setTimeout(requestPipelineRebuild, RETRY_DELAY_MS)
} else {
console.error(
'[viewer] Post-processing retries exhausted. Rendering without post FX for this session.',
'[viewer/post-processing] Retries exhausted. Rendering without post FX for this session.',
)
}
}