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/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, 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 }} 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) 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 + } } } }