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
+ }
}
}
}