From fb2c7c6c711fe161cefc919efdf3a01e7deed548 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Thu, 5 Mar 2026 15:42:58 -0500 Subject: [PATCH] fix: harden viewer/editor renderer init and error handling (#133) --- apps/editor/app/editor/[projectId]/error.tsx | 42 +++ apps/editor/app/viewer/[id]/error.tsx | 42 +++ apps/editor/app/viewer/[id]/page.tsx | 80 +++++- apps/editor/components/editor/index.tsx | 64 +++-- .../viewer/src/components/viewer/index.tsx | 8 +- .../src/components/viewer/post-processing.tsx | 269 ++++++++++-------- packages/viewer/src/hooks/use-gltf-ktx2.tsx | 36 ++- 7 files changed, 386 insertions(+), 155 deletions(-) create mode 100644 apps/editor/app/editor/[projectId]/error.tsx create mode 100644 apps/editor/app/viewer/[id]/error.tsx diff --git a/apps/editor/app/editor/[projectId]/error.tsx b/apps/editor/app/editor/[projectId]/error.tsx new file mode 100644 index 00000000..7ca2858b --- /dev/null +++ b/apps/editor/app/editor/[projectId]/error.tsx @@ -0,0 +1,42 @@ +'use client' + +import Link from 'next/link' +import { useEffect } from 'react' + +export default function EditorRouteError({ + error, + reset, +}: Readonly<{ + error: Error & { digest?: string } + reset: () => void +}>) { + useEffect(() => { + console.error('[editor-route] Unhandled editor error:', error) + }, [error]) + + return ( +
+
+

Editor error

+

+ We couldn't load this editor route. You can retry or return home. +

+
+ + + Back to home + +
+
+
+ ) +} diff --git a/apps/editor/app/viewer/[id]/error.tsx b/apps/editor/app/viewer/[id]/error.tsx new file mode 100644 index 00000000..ef886307 --- /dev/null +++ b/apps/editor/app/viewer/[id]/error.tsx @@ -0,0 +1,42 @@ +'use client' + +import Link from 'next/link' +import { useEffect } from 'react' + +export default function ViewerRouteError({ + error, + reset, +}: Readonly<{ + error: Error & { digest?: string } + reset: () => void +}>) { + useEffect(() => { + console.error('[viewer-route] Unhandled viewer error:', error) + }, [error]) + + return ( +
+
+

Viewer error

+

+ We couldn't load this project view. You can retry without leaving the app. +

+
+ + + Back to home + +
+
+
+ ) +} diff --git a/apps/editor/app/viewer/[id]/page.tsx b/apps/editor/app/viewer/[id]/page.tsx index 8d06101b..bc173276 100644 --- a/apps/editor/app/viewer/[id]/page.tsx +++ b/apps/editor/app/viewer/[id]/page.tsx @@ -2,8 +2,11 @@ import { initSpatialGridSync, useScene } from '@pascal-app/core' import { useViewer, Viewer } from '@pascal-app/viewer' +import Link from 'next/link' import { useParams } from 'next/navigation' import { useEffect, useState } from 'react' +import { ErrorBoundary } from '@/components/ui/primitives/error-boundary' +import { SceneLoader } from '@/components/ui/scene-loader' import { getProjectModelPublic, incrementProjectViews, @@ -14,7 +17,34 @@ import { ViewerGuestCTA } from './viewer-guest-cta' import { ViewerOverlay } from './viewer-overlay' import { ViewerZoneSystem } from './viewer-zone-system' -import { SceneLoader } from '@/components/ui/scene-loader' +function ViewerSceneCrashFallback({ projectName }: { projectName?: string | null }) { + return ( +
+
+

The 3D scene failed to render

+

+ {projectName ? `"${projectName}" ` : ''} + hit a rendering error. The rest of the app is still available. +

+
+ + + Back to home + +
+
+
+ ) +} export default function ViewerPage() { const params = useParams() @@ -33,27 +63,46 @@ export default function ViewerPage() { }, [projectId]) useEffect(() => { + let cancelled = false + setLoading(true) + setError(null) + setProjectId(null) + setProjectName(null) + setOwner(null) + setCanShowScans(true) + setCanShowGuides(true) + useViewer.getState().setShowScans(true) + useViewer.getState().setShowGuides(true) + const loadContent = async () => { try { // Check if it's a demo file (starts with 'demo_') if (id.startsWith('demo_')) { const response = await fetch(`/demos/${id}.json`) + if (cancelled) return + if (!response.ok) { throw new Error(`Demo "${id}" not found`) } + const data = await response.json() + if (cancelled) return + if (data.nodes && data.rootNodeIds) { setScene(data.nodes, data.rootNodeIds) initSpatialGridSync() } + setProjectName('Demo') } else { // Load from database (public project) const result = await getProjectModelPublic(id) + if (cancelled) return if (result.success && result.data) { const { project, model, isOwner } = result.data const projectData = project as any + setProjectId(project.id) setProjectName(project.name) setOwner(projectData.owner ?? null) @@ -81,19 +130,28 @@ export default function ViewerPage() { // Increment view count await incrementProjectViews(id) + if (cancelled) return } else { throw new Error(result.error || 'Project not found') } } - setLoading(false) + if (!cancelled) { + setLoading(false) + } } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load content') - setLoading(false) + if (!cancelled) { + setError(err instanceof Error ? err.message : 'Failed to load content') + setLoading(false) + } } } loadContent() + + return () => { + cancelled = true + } }, [id, setScene]) if (error) { @@ -107,7 +165,7 @@ export default function ViewerPage() { return (
{loading && } - + {!loading && ( <> + + }> + + + + + )} - - - - -
) } diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx index 0fe78eee..ff8af373 100644 --- a/apps/editor/components/editor/index.tsx +++ b/apps/editor/components/editor/index.tsx @@ -16,6 +16,7 @@ import { ToolManager } from '../tools/tool-manager' import { ActionMenu } from '../ui/action-menu' import { HelperManager } from '../ui/helpers/helper-manager' import { PanelManager } from '../ui/panels/panel-manager' +import { ErrorBoundary } from '../ui/primitives/error-boundary' import { SidebarProvider } from '../ui/primitives/sidebar' import { SceneLoader } from '../ui/scene-loader' import { AppSidebar } from '../ui/sidebar/app-sidebar' @@ -23,12 +24,11 @@ import { CustomCameraControls } from './custom-camera-controls' import { ExportManager } from './export-manager' import { FloatingActionMenu } from './floating-action-menu' import { Grid } from './grid' +import { PresetThumbnailGenerator } from './preset-thumbnail-generator' import { SelectionManager } from './selection-manager' import { SiteEdgeLabels } from './site-edge-labels' -import { PresetThumbnailGenerator } from './preset-thumbnail-generator' import { ThumbnailGenerator } from './thumbnail-generator' - // Load default scene initially (will be replaced when project loads) useScene.getState().loadScene() initSpatialGridSync() @@ -66,6 +66,34 @@ interface EditorProps { projectId?: string } +function EditorSceneCrashFallback() { + return ( +
+
+

The editor scene failed to render

+

+ You can retry the scene or return home without reloading the whole app shell. +

+
+ + + Back to home + +
+
+
+ ) +} + export default function Editor({ projectId }: EditorProps) { useKeyboard() useProjectScene() @@ -109,21 +137,23 @@ export default function Editor({ projectId }: EditorProps) { - - - - - {/* Editor only system to toggle zone visibility */} - - - {/* */} - - - - - - - + }> + + + + + {/* Editor only system to toggle zone visibility */} + + + {/* */} + + + + + + + + ) } diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 4840125c..ebbae641 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -64,20 +64,18 @@ interface ViewerProps { selectionManager?: 'default' | 'custom' } -const Viewer: React.FC = ({ - children, - selectionManager = 'default', -}) => { +const Viewer: React.FC = ({ children, selectionManager = 'default' }) => { const theme = useViewer((state) => state.theme) return ( { + gl={async (props) => { const renderer = new THREE.WebGPURenderer(props as any) renderer.toneMapping = THREE.ACESFilmicToneMapping renderer.toneMappingExposure = 0.9 + await renderer.init() return renderer }} shadows={{ diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index a8a2cde1..42a335eb 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -27,7 +27,7 @@ import useViewer from '../../store/use-viewer' // SSGI Parameters - adjust these to fine-tune global illumination and ambient occlusion export const SSGI_PARAMS = { enabled: true, - sliceCount: 2, + sliceCount: 2, stepCount: 8, radius: 1, expFactor: 1.5, @@ -43,19 +43,31 @@ export const SSGI_PARAMS = { const PostProcessingPasses = () => { const { gl: renderer, scene, camera } = useThree() const renderPipelineRef = useRef(null) + const hasPipelineErrorRef = useRef(false) const [isInitialized, setIsInitialized] = useState(false) useEffect(() => { let mounted = true + const initRenderer = async () => { - if (renderer && (renderer as any).init) { - await (renderer as any).init() - } - if (mounted) { - setIsInitialized(true) + try { + if (renderer && (renderer as any).init) { + await (renderer as any).init() + } + + if (mounted) { + setIsInitialized(true) + } + } catch (error) { + console.error('[viewer] Failed to initialize renderer for post-processing.', error) + if (mounted) { + setIsInitialized(false) + } } } + initRenderer() + return () => { mounted = false } @@ -66,123 +78,138 @@ const PostProcessingPasses = () => { return } - // Scene pass with MRT for SSGI - const scenePass = pass(scene, camera) - scenePass.setMRT( - mrt({ - output: output, - diffuseColor: diffuseColor, - normal: directionToColor(normalView), - velocity: velocity, - }), - ) + hasPipelineErrorRef.current = false - // Get texture outputs - const scenePassColor = scenePass.getTextureNode('output') - const scenePassDiffuse = scenePass.getTextureNode('diffuseColor') - const scenePassDepth = scenePass.getTextureNode('depth') - const scenePassNormal = scenePass.getTextureNode('normal') - const scenePassVelocity = scenePass.getTextureNode('velocity') + try { + // Scene pass with MRT for SSGI + const scenePass = pass(scene, camera) + scenePass.setMRT( + mrt({ + output: output, + diffuseColor: diffuseColor, + normal: directionToColor(normalView), + velocity: velocity, + }), + ) - // Optimize texture bandwidth - const diffuseTexture = scenePass.getTexture('diffuseColor') - diffuseTexture.type = UnsignedByteType + // Get texture outputs + const scenePassColor = scenePass.getTextureNode('output') + const scenePassDiffuse = scenePass.getTextureNode('diffuseColor') + const scenePassDepth = scenePass.getTextureNode('depth') + const scenePassNormal = scenePass.getTextureNode('normal') + const scenePassVelocity = scenePass.getTextureNode('velocity') - const normalTexture = scenePass.getTexture('normal') - normalTexture.type = UnsignedByteType + // Optimize texture bandwidth + const diffuseTexture = scenePass.getTexture('diffuseColor') + diffuseTexture.type = UnsignedByteType - // Extract normal from color-encoded texture - const sceneNormal = sample((uv) => { - return colorToDirection(scenePassNormal.sample(uv)) - }) + const normalTexture = scenePass.getTexture('normal') + normalTexture.type = UnsignedByteType - // SSGI Pass (cast to PerspectiveCamera for SSGI) - const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, camera as any) - - - giPass.sliceCount.value = SSGI_PARAMS.sliceCount - giPass.stepCount.value = SSGI_PARAMS.stepCount - giPass.radius.value = SSGI_PARAMS.radius - giPass.expFactor.value = SSGI_PARAMS.expFactor - giPass.thickness.value = SSGI_PARAMS.thickness - giPass.backfaceLighting.value = SSGI_PARAMS.backfaceLighting - giPass.aoIntensity.value = SSGI_PARAMS.aoIntensity - giPass.giIntensity.value = SSGI_PARAMS.giIntensity - giPass.useLinearThickness.value = SSGI_PARAMS.useLinearThickness - giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling - giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering - - // Extract GI and AO from SSGI pass - const gi = giPass.rgb - const ao = giPass.a - - // Composite: scene * AO + diffuse * GI - const compositePass = vec4( - add(scenePassColor.rgb.mul(ao), scenePassDiffuse.rgb.mul(gi)), - scenePassColor.a, - ) - - function generateSelectedOutlinePass() { - const edgeStrength = uniform(3) - const edgeGlow = uniform(0) - const edgeThickness = uniform(1) - const visibleEdgeColor = uniform(new Color(0xffffff)) - const hiddenEdgeColor = uniform(new Color(0xf3ff47)) - - const outlinePass = outline(scene, camera, { - selectedObjects: useViewer.getState().outliner.selectedObjects, - edgeGlow, - edgeThickness, + // Extract normal from color-encoded texture + const sceneNormal = sample((uv) => { + return colorToDirection(scenePassNormal.sample(uv)) }) - const { visibleEdge, hiddenEdge } = outlinePass - const outlineColor = visibleEdge - .mul(visibleEdgeColor) - .add(hiddenEdge.mul(hiddenEdgeColor)) - .mul(edgeStrength) + // SSGI Pass (cast to PerspectiveCamera for SSGI) + const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, camera as any) - return outlineColor + giPass.sliceCount.value = SSGI_PARAMS.sliceCount + giPass.stepCount.value = SSGI_PARAMS.stepCount + giPass.radius.value = SSGI_PARAMS.radius + giPass.expFactor.value = SSGI_PARAMS.expFactor + giPass.thickness.value = SSGI_PARAMS.thickness + giPass.backfaceLighting.value = SSGI_PARAMS.backfaceLighting + giPass.aoIntensity.value = SSGI_PARAMS.aoIntensity + giPass.giIntensity.value = SSGI_PARAMS.giIntensity + giPass.useLinearThickness.value = SSGI_PARAMS.useLinearThickness + giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling + giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering + + // Extract GI and AO from SSGI pass + const gi = giPass.rgb + const ao = giPass.a + + // Composite: scene * AO + diffuse * GI + const compositePass = vec4( + add(scenePassColor.rgb.mul(ao), scenePassDiffuse.rgb.mul(gi)), + scenePassColor.a, + ) + + function generateSelectedOutlinePass() { + const edgeStrength = uniform(3) + const edgeGlow = uniform(0) + const edgeThickness = uniform(1) + const visibleEdgeColor = uniform(new Color(0xffffff)) + const hiddenEdgeColor = uniform(new Color(0xf3ff47)) + + const outlinePass = outline(scene, camera, { + selectedObjects: useViewer.getState().outliner.selectedObjects, + edgeGlow, + edgeThickness, + }) + const { visibleEdge, hiddenEdge } = outlinePass + + const outlineColor = visibleEdge + .mul(visibleEdgeColor) + .add(hiddenEdge.mul(hiddenEdgeColor)) + .mul(edgeStrength) + + return outlineColor + } + + function generateHoverOutlinePass() { + const edgeStrength = uniform(5) + const edgeGlow = uniform(0.5) + const edgeThickness = uniform(1.5) + const pulsePeriod = uniform(3) + const visibleEdgeColor = uniform(new Color(0x00aaff)) + const hiddenEdgeColor = uniform(new Color(0xf3ff47)) + + const outlinePass = outline(scene, camera, { + selectedObjects: useViewer.getState().outliner.hoveredObjects, + edgeGlow, + edgeThickness, + }) + const { visibleEdge, hiddenEdge } = outlinePass + + const period = time.div(pulsePeriod).mul(2) + const osc = oscSine(period).mul(0.5).add(0.5) // osc [ 0.5, 1.0 ] + + const outlineColor = visibleEdge + .mul(visibleEdgeColor) + .add(hiddenEdge.mul(hiddenEdgeColor)) + .mul(edgeStrength) + const outlinePulse = pulsePeriod.greaterThan(0).select(outlineColor.mul(osc), outlineColor) + + return outlinePulse + } + + const selectedOutlinePass = generateSelectedOutlinePass() + const hoverOutlinePass = generateHoverOutlinePass() + + // Combine composite with outlines BEFORE applying TRAA + const compositeWithOutlines = SSGI_PARAMS.enabled + ? vec4(add(compositePass.rgb, selectedOutlinePass.add(hoverOutlinePass)), compositePass.a) + : vec4(add(scenePassColor.rgb, selectedOutlinePass.add(hoverOutlinePass)), scenePassColor.a) + + // TRAA (Temporal Reprojection Anti-Aliasing) - applied AFTER combining everything + const finalOutput = traa(compositeWithOutlines, scenePassDepth, scenePassVelocity, camera) + + const renderPipeline = new RenderPipeline(renderer as unknown as WebGPURenderer) + renderPipeline.outputNode = finalOutput + renderPipelineRef.current = renderPipeline + } catch (error) { + hasPipelineErrorRef.current = true + console.error( + '[viewer] Failed to set up post-processing pipeline. Rendering without post FX.', + error, + ) + if (renderPipelineRef.current) { + renderPipelineRef.current.dispose() + } + renderPipelineRef.current = null } - function generateHoverOutlinePass() { - const edgeStrength = uniform(5) - const edgeGlow = uniform(0.5) - const edgeThickness = uniform(1.5) - const pulsePeriod = uniform(3) - const visibleEdgeColor = uniform(new Color(0x00aaff)) - const hiddenEdgeColor = uniform(new Color(0xf3ff47)) - - const outlinePass = outline(scene, camera, { - selectedObjects: useViewer.getState().outliner.hoveredObjects, - edgeGlow, - edgeThickness, - }) - const { visibleEdge, hiddenEdge } = outlinePass - - const period = time.div(pulsePeriod).mul(2) - const osc = oscSine(period).mul(0.5).add(0.5) // osc [ 0.5, 1.0 ] - - const outlineColor = visibleEdge - .mul(visibleEdgeColor) - .add(hiddenEdge.mul(hiddenEdgeColor)) - .mul(edgeStrength) - const outlinePulse = pulsePeriod.greaterThan(0).select(outlineColor.mul(osc), outlineColor) - return outlinePulse - } - - const selectedOutlinePass = generateSelectedOutlinePass() - const hoverOutlinePass = generateHoverOutlinePass() - - // Combine composite with outlines BEFORE applying TRAA - const compositeWithOutlines = SSGI_PARAMS.enabled - ? vec4(add(compositePass.rgb, selectedOutlinePass.add(hoverOutlinePass)), compositePass.a) - : vec4(add(scenePassColor.rgb, selectedOutlinePass.add(hoverOutlinePass)), scenePassColor.a) - - // TRAA (Temporal Reprojection Anti-Aliasing) - applied AFTER combining everything - const finalOutput = traa(compositeWithOutlines, scenePassDepth, scenePassVelocity, camera) - - const renderPipeline = new RenderPipeline(renderer as unknown as WebGPURenderer) - renderPipeline.outputNode = finalOutput - renderPipelineRef.current = renderPipeline return () => { if (renderPipelineRef.current) { @@ -193,8 +220,20 @@ const PostProcessingPasses = () => { }, [renderer, scene, camera, isInitialized]) useFrame(() => { - if (renderPipelineRef.current) { + if (hasPipelineErrorRef.current || !renderPipelineRef.current) { + return + } + + try { renderPipelineRef.current.render() + } catch (error) { + hasPipelineErrorRef.current = true + console.error( + '[viewer] Post-processing render pass failed. Disabling post FX for this session.', + error, + ) + renderPipelineRef.current.dispose() + renderPipelineRef.current = null } }, 1) diff --git a/packages/viewer/src/hooks/use-gltf-ktx2.tsx b/packages/viewer/src/hooks/use-gltf-ktx2.tsx index 70d93cd3..28578c76 100644 --- a/packages/viewer/src/hooks/use-gltf-ktx2.tsx +++ b/packages/viewer/src/hooks/use-gltf-ktx2.tsx @@ -1,19 +1,39 @@ -import { useGLTF } from "@react-three/drei" -import { useThree } from "@react-three/fiber" -import { KTX2Loader } from "three/examples/jsm/Addons.js" -import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js" +import { useGLTF } from '@react-three/drei' +import { useThree } from '@react-three/fiber' +import { KTX2Loader } from 'three/examples/jsm/Addons.js' +import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js' const ktx2LoaderInstance = new KTX2Loader() ktx2LoaderInstance.setTranscoderPath('https://cdn.jsdelivr.net/gh/pmndrs/drei-assets@master/basis/') +const ktx2ConfiguredRenderers = new WeakSet() +const ktx2WarningLoggedRenderers = new WeakSet() const useGLTFKTX2 = (path: string): ReturnType => { const gl = useThree((state) => state.gl) return useGLTF(path, true, true, (loader) => { - ktx2LoaderInstance.detectSupport(gl) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - loader.setKTX2Loader(ktx2LoaderInstance as any) + const renderer = gl as unknown as object + + if (!ktx2ConfiguredRenderers.has(renderer)) { + try { + ktx2LoaderInstance.detectSupport(gl) + ktx2ConfiguredRenderers.add(renderer) + } catch (error) { + // Some WebGPU flows can transiently call this before backend init. + // Avoid crashing the whole scene; scans may render without KTX2 on this pass. + if (!ktx2WarningLoggedRenderers.has(renderer)) { + console.warn('[viewer] Skipping KTX2 support detection for now.', error) + ktx2WarningLoggedRenderers.add(renderer) + } + } + } + + if (ktx2ConfiguredRenderers.has(renderer)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + loader.setKTX2Loader(ktx2LoaderInstance as any) + } + loader.setMeshoptDecoder(MeshoptDecoder) }) } -export { useGLTFKTX2 } \ No newline at end of file +export { useGLTFKTX2 }