From afadfb2ea8a3fd708a8204280e99dab31359c39f Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 11 May 2026 11:24:44 +0530 Subject: [PATCH] Guard viewer BVH against empty geometries --- .../viewer/src/components/viewer/index.tsx | 6 +- .../src/components/viewer/scene-bvh.tsx | 137 ++++++++++++++++++ 2 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 packages/viewer/src/components/viewer/scene-bvh.tsx diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index bc2b5b85..f4d9bd9b 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -1,6 +1,5 @@ 'use client' -import { Bvh } from '@react-three/drei' import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three/webgpu' @@ -28,6 +27,7 @@ import FrameLimiter from './frame-limiter' import { Lights } from './lights' import { PerfMonitor } from './perf-monitor' import PostProcessing, { DEFAULT_HOVER_STYLES, type HoverStyles } from './post-processing' +import { SceneBvh } from './scene-bvh' import { SelectionManager } from './selection-manager' import { ViewerCamera } from './viewer-camera' @@ -216,9 +216,9 @@ const Viewer: React.FC = ({ {/* */} - + - + {/* Default Systems */} diff --git a/packages/viewer/src/components/viewer/scene-bvh.tsx b/packages/viewer/src/components/viewer/scene-bvh.tsx new file mode 100644 index 00000000..c4517e6b --- /dev/null +++ b/packages/viewer/src/components/viewer/scene-bvh.tsx @@ -0,0 +1,137 @@ +import { useThree } from '@react-three/fiber' +import { + type ReactNode, + forwardRef, + useEffect, + useImperativeHandle, + useRef, +} from 'react' +import { Group, Mesh, type BufferGeometry } from 'three' +import { + SAH, + acceleratedRaycast, + computeBoundsTree, + disposeBoundsTree, + type SplitStrategy, +} from 'three-mesh-bvh' + +type SceneBvhProps = { + children?: ReactNode + enabled?: boolean + firstHitOnly?: boolean + strategy?: SplitStrategy + verbose?: boolean + setBoundingBox?: boolean + maxDepth?: number + maxLeafSize?: number + indirect?: boolean +} + +const isMesh = (object: unknown): object is Mesh => + !!object && typeof object === 'object' && (object as Mesh).isMesh === true + +const hasBvhCompatibleGeometry = (geometry?: BufferGeometry | null) => { + if (!geometry) return false + + const position = geometry.getAttribute('position') + if (!position) return false + + const vertexCount = geometry.getIndex()?.count ?? position.count + return vertexCount >= 3 +} + +export const SceneBvh = forwardRef( + ( + { + children, + enabled = true, + firstHitOnly = false, + strategy = SAH, + verbose = false, + setBoundingBox = true, + maxDepth = 40, + maxLeafSize = 10, + indirect = false, + }, + forwardedRef, + ) => { + const ref = useRef(null) + const raycaster = useThree((state) => state.raycaster) + + useImperativeHandle(forwardedRef, () => ref.current!, []) + + useEffect(() => { + if (!enabled || !ref.current) return + + const options = { + strategy, + verbose, + setBoundingBox, + maxDepth, + maxLeafSize, + indirect, + } + const group = ref.current + const acceleratedMeshes = new Set() + const computedGeometries = new Set() + + ;(raycaster as any).firstHitOnly = firstHitOnly + + group.traverse((child) => { + if (!isMesh(child)) return + + if (child.raycast === Mesh.prototype.raycast) { + child.raycast = acceleratedRaycast + acceleratedMeshes.add(child) + } + + if (child.raycast !== acceleratedRaycast) return + + const geometry = child.geometry + if (geometry.boundsTree || !hasBvhCompatibleGeometry(geometry)) return + + try { + geometry.computeBoundsTree = computeBoundsTree + geometry.disposeBoundsTree = disposeBoundsTree + geometry.computeBoundsTree(options) + computedGeometries.add(geometry) + } catch (error) { + console.warn('[viewer] Skipping BVH for incompatible mesh geometry.', { + mesh: child.name || child.type, + error, + }) + } + }) + + return () => { + delete (raycaster as any).firstHitOnly + + for (const geometry of computedGeometries) { + if (geometry.boundsTree) { + geometry.disposeBoundsTree() + } + } + + for (const mesh of acceleratedMeshes) { + if (mesh.raycast === acceleratedRaycast) { + mesh.raycast = Mesh.prototype.raycast + } + } + } + }, [ + enabled, + firstHitOnly, + strategy, + verbose, + setBoundingBox, + maxDepth, + maxLeafSize, + indirect, + raycaster, + ]) + + return {children} + }, +) + +SceneBvh.displayName = 'SceneBvh'