From 93d9d68e193af5317278e5148a112940049ada50 Mon Sep 17 00:00:00 2001 From: billy <61808505+b9llach@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:23:25 -0400 Subject: [PATCH 1/4] fix(viewer): skip post-processing pipeline when WebGPU is unavailable (#234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `RenderPipeline`, SSGI, and the denoise TSL node imported from `three/webgpu` and `three/tsl` are all WebGPU-only. On a browser without `navigator.gpu`, the WebGPURenderer falls back to WebGL2, and attempting to build the TSL post-processing pipeline either throws or produces broken output — the scene renders for a few frames, then goes black as the 3-attempt retry loop fights the direct-render fallback path in `useFrame`. This sets `hasPipelineErrorRef.current = true` at pipeline setup time when `navigator.gpu` is undefined, so `useFrame` takes the existing direct `renderer.render(scene, camera)` path exclusively and never tries to build the broken TSL pipeline. No behavioural change in WebGPU mode — the guard only fires when the WebGPU API is literally not exposed by the browser. The rare edge case of `navigator.gpu` being defined but device creation failing at runtime still falls through the existing try/catch unchanged. --- .../src/components/viewer/post-processing.tsx | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 8daf68be..ef3fa646 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -117,6 +117,24 @@ const PostProcessingPasses = () => { hasPipelineErrorRef.current = false + // WebGPU availability check: SSGI, denoise, and RenderPipeline are all + // WebGPU-only APIs. When the browser falls back to WebGL2 (no + // `navigator.gpu`, or the device couldn't be created), building the + // pipeline either throws silently or produces a broken output where + // the scene renders for a few frames and then goes black as the retry + // loop fights the direct-render fallback path. Short-circuit here so + // `useFrame` uses the direct `renderer.render(scene, camera)` path + // exclusively and never attempts the TSL pipeline. + const hasWebGPU = typeof navigator !== 'undefined' && typeof navigator.gpu !== 'undefined' + if (!hasWebGPU) { + console.warn( + '[viewer] WebGPU unavailable — rendering without post-processing (SSGI, outlines, denoise).', + ) + hasPipelineErrorRef.current = true + renderPipelineRef.current = null + return + } + // Clear outliner arrays synchronously to prevent stale Object3D refs // from the previous project leaking into the new pipeline's outline passes. const outliner = useViewer.getState().outliner @@ -263,15 +281,7 @@ const PostProcessingPasses = () => { } renderPipelineRef.current = null } - }, [ - renderer, - scene, - camera, - hoverHighlightMode, - zoneLayers, - projectId, - pipelineVersion, - ]) + }, [renderer, scene, camera, hoverHighlightMode, zoneLayers, projectId, pipelineVersion]) useFrame((_, delta) => { // Animate background colour toward the current theme target (same lerp as AnimatedBackground) From 341725b37aa01d514da57c0a9d69093ca1dcc9c4 Mon Sep 17 00:00:00 2001 From: billy <61808505+b9llach@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:23:27 -0400 Subject: [PATCH 2/4] fix(viewer): await renderer.init() in Canvas gl factory (#233) The WebGPURenderer needs its backend initialized before any direct `.render(scene, camera)` call. The commented-out `// renderer.init()` said "Only use when using ", but the non-debug path also calls direct render from the post-processing fallback (post-processing.tsx:318), which throws "Renderer: .render() called before the backend is initialized" on any browser that falls back to the WebGL2 backend. Switching the gl factory to an async function and awaiting init() before returning is safe in both backends (init() is idempotent and the async factory is a supported @react-three/fiber v9+ pattern), and prevents the error in WebGL2 fallback. --- packages/viewer/src/components/viewer/index.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index cf00dd5e..bfbfdc96 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -110,6 +110,12 @@ const Viewer: React.FC = ({ 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 }} From e3ba4ab92172fe3f4bd29a3dd64aa4b5152b6699 Mon Sep 17 00:00:00 2001 From: Roshan Warrier Date: Thu, 16 Apr 2026 02:55:01 +0530 Subject: [PATCH 3/4] fix editor furnish item initialization (#237) Co-authored-by: txhno <198242577+txhno@users.noreply.github.com> --- .../ui/item-catalog/catalog-items.tsx | 5 ++ packages/editor/src/store/use-editor.tsx | 54 ++++++++++++++++--- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/packages/editor/src/components/ui/item-catalog/catalog-items.tsx b/packages/editor/src/components/ui/item-catalog/catalog-items.tsx index d2ef0b1c..a43aa21b 100755 --- a/packages/editor/src/components/ui/item-catalog/catalog-items.tsx +++ b/packages/editor/src/components/ui/item-catalog/catalog-items.tsx @@ -1578,3 +1578,8 @@ export const CATALOG_ITEMS: AssetInput[] = [ }, }, ] + +export function getDefaultCatalogItem(category: string | null | undefined): AssetInput | null { + if (!category) return null + return CATALOG_ITEMS.find((item) => item.category === category) ?? null +} diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 73b83021..9f624144 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -21,6 +21,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { create } from 'zustand' import { persist } from 'zustand/middleware' +import { getDefaultCatalogItem } from '../components/ui/item-catalog/catalog-items' const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'site' const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5 @@ -344,6 +345,10 @@ export function selectDefaultBuildingAndLevel() { } } +function getDefaultSelectedItemForCategory(category: CatalogCategory | null): AssetInput | null { + return getDefaultCatalogItem(category) +} + const useEditor = create()( persist( (set, get) => ({ @@ -365,7 +370,11 @@ const useEditor = create()( } else if (phase === 'structure') { set({ tool: 'wall', catalogCategory: null }) } else if (phase === 'furnish') { - set({ tool: 'item', catalogCategory: 'furniture' }) + set({ + tool: 'item', + catalogCategory: 'furniture', + selectedItem: getDefaultSelectedItemForCategory('furniture'), + }) } } else { // Reset to select mode and clear tool/catalog when switching phases @@ -405,8 +414,15 @@ const useEditor = create()( } else if (phase === 'structure' && structureLayer === 'elements') { set({ tool: 'wall' }) } else if (phase === 'furnish') { - set({ tool: 'item', catalogCategory: 'furniture' }) + set({ + tool: 'item', + catalogCategory: 'furniture', + selectedItem: getDefaultSelectedItemForCategory('furniture'), + }) } + } else if (phase === 'furnish' && tool === 'item' && !get().selectedItem) { + const category = get().catalogCategory ?? 'furniture' + set({ selectedItem: getDefaultSelectedItemForCategory(category) }) } } // When leaving build mode, clear tool @@ -434,7 +450,17 @@ const useEditor = create()( }) }, catalogCategory: DEFAULT_PERSISTED_EDITOR_UI_STATE.catalogCategory, - setCatalogCategory: (category) => set({ catalogCategory: category }), + setCatalogCategory: (category) => + set((state) => ({ + catalogCategory: category, + selectedItem: + category !== null && + state.phase === 'furnish' && + state.mode === 'build' && + state.tool === 'item' + ? getDefaultSelectedItemForCategory(category) + : state.selectedItem, + })), selectedItem: null, setSelectedItem: (item) => set({ selectedItem: item }), movingNode: null as @@ -517,11 +543,23 @@ const useEditor = create()( }), { name: 'pascal-editor-ui-preferences', - merge: (persistedState, currentState) => ({ - ...currentState, - ...normalizePersistedEditorUiState(persistedState as Partial), - ...normalizePersistedEditorLayoutState(persistedState as Partial), - }), + merge: (persistedState, currentState) => { + const mergedState = { + ...currentState, + ...normalizePersistedEditorUiState(persistedState as Partial), + ...normalizePersistedEditorLayoutState(persistedState as Partial), + } + + return { + ...mergedState, + selectedItem: + mergedState.phase === 'furnish' && + mergedState.mode === 'build' && + mergedState.tool === 'item' + ? getDefaultSelectedItemForCategory(mergedState.catalogCategory ?? 'furniture') + : currentState.selectedItem, + } + }, partialize: (state) => ({ phase: state.phase, mode: state.mode, From 3d1005847b8bd5fc72e0969d1cb107d8b0a2fd5a Mon Sep 17 00:00:00 2001 From: Huy Hoang <83349539+nnhhoang@users.noreply.github.com> Date: Thu, 16 Apr 2026 04:25:03 +0700 Subject: [PATCH 4/4] fix: prevent crash when duplicating elements (#239) - Guard _buildCache in merged-outline-node against stale/disposed Object3D refs that cause TypeError on .id access during render - Reset children array when duplicating roofs to prevent inconsistent parent-child relationships (matching existing stair behavior) - Use obj?.parent check in EditorOutlinerSync to ensure objects are still in the scene graph before adding to outliner arrays - Resume temporal state on parse failure to prevent undo/redo freeze Closes #232 --- .../src/components/editor/floating-action-menu.tsx | 7 +++++++ .../src/components/editor/selection-manager.tsx | 4 ++-- packages/viewer/src/lib/merged-outline-node.ts | 11 ++++++++--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index 1f6a6280..0f4cdbc8 100755 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -147,6 +147,7 @@ export function FloatingActionMenu() { duplicate.start = [duplicate.start[0] + 1, duplicate.start[1] + 1] duplicate.end = [duplicate.end[0] + 1, duplicate.end[1] + 1] } else if (node.type === 'roof') { + duplicateInfo.children = [] duplicate = RoofNode.parse(duplicateInfo) } else if (node.type === 'roof-segment') { duplicate = RoofSegmentNode.parse(duplicateInfo) @@ -160,6 +161,12 @@ export function FloatingActionMenu() { } } catch (error) { console.error('Failed to parse duplicate', error) + useScene.temporal.getState().resume() + return + } + + if (!duplicate) { + useScene.temporal.getState().resume() return } diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index a4b74934..3d9ef80a 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -920,13 +920,13 @@ const EditorOutlinerSync = () => { outliner.selectedObjects.length = 0 for (const id of idsToHighlight) { const obj = sceneRegistry.nodes.get(id) - if (obj) outliner.selectedObjects.push(obj) + if (obj?.parent) outliner.selectedObjects.push(obj) } outliner.hoveredObjects.length = 0 if (hoveredId) { const obj = sceneRegistry.nodes.get(hoveredId) - if (obj) outliner.hoveredObjects.push(obj) + if (obj?.parent) outliner.hoveredObjects.push(obj) } }, [phase, previewSelectedIds, selection, hoveredId, outliner]) diff --git a/packages/viewer/src/lib/merged-outline-node.ts b/packages/viewer/src/lib/merged-outline-node.ts index 440535d1..f3617b35 100644 --- a/packages/viewer/src/lib/merged-outline-node.ts +++ b/packages/viewer/src/lib/merged-outline-node.ts @@ -600,9 +600,14 @@ export class MergedOutlineNode extends TempNode { private _buildCache(objects: Object3D[], cache: Set) { for (const obj of objects) { - obj.traverse((child: any) => { - if (child.isMesh || child.isSprite) cache.add(child) - }) + if (!obj || !obj.traverse) continue + try { + obj.traverse((child: any) => { + if (child.isMesh || child.isSprite) cache.add(child) + }) + } catch { + // Skip objects that were disposed or removed from the scene graph + } } } }