Port floorplan item thumbnails, full mobile editor UI, and mobile polish from the private monorepo into the public editor. Monorepo PR #297 (feat/item-admin-advanced): - Add optional floorPlanUrl to item asset schema (core) - Render 2D floor-plan images inside item footprints on floorplan - Improve slider drag with modifier key re-anchoring Monorepo PR #302 (feat/mobile-ui): - Mobile editor layout with draggable bottom sheet - Mobile tab bar, selection bar, and panel sheet - Mobile-aware panel manager and panel wrapper - useIsMobile rewrite (useSyncExternalStore, SSR-safe) - Camera actions hideOrbit prop - GridSnapControl and SecondaryToggles exports - Action menu hides on mobile contextual tabs - SidebarTab extended with mobileDefaultSnap/mobileIcon Monorepo PR #306 (feat/mobile-ui-polish): - Touch gesture mapping for camera controls (one/two/three finger) - Snap ratio constants for bottom sheet - Thumbnail generator WebGL2 fallback (bottom-up row flip) - ErrorBoundary scope logging, viewer scene wrap - GPUDeviceWatcher enhanced logging and uncaptured error handler - WebGPURenderer init error handling and diagnostics - Post-processing log improvements - MergedOutlineNode WebGL2 FBO corruption fix Excluded: apps/community/*, apps/editor/*, packages/community-*, .cursor/*, .env.example (monorepo-only files) Co-authored-by: Pascal <open@pascal.app>
This commit is contained in:
@@ -1,15 +1,25 @@
|
||||
import type { ErrorInfo, ReactNode } from 'react'
|
||||
import { Component } from 'react'
|
||||
|
||||
export class ErrorBoundary extends Component<
|
||||
{ children: ReactNode; fallback: ReactNode },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode
|
||||
fallback: ReactNode
|
||||
/** Tag for log lines so we can tell which boundary swallowed an error. */
|
||||
scope?: string
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, { hasError: boolean }> {
|
||||
state = { hasError: false }
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true }
|
||||
}
|
||||
componentDidCatch(_e: Error, _i: ErrorInfo) {}
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error(
|
||||
`[viewer] ErrorBoundary caught${this.props.scope ? ` (${this.props.scope})` : ''}:`,
|
||||
error,
|
||||
info.componentStack,
|
||||
)
|
||||
}
|
||||
render() {
|
||||
return this.state.hasError ? this.props.fallback : this.props.children
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { LevelSystem } from '../../systems/level/level-system'
|
||||
import { ScanSystem } from '../../systems/scan/scan-system'
|
||||
import { WallCutout } from '../../systems/wall/wall-cutout'
|
||||
import { ZoneSystem } from '../../systems/zone/zone-system'
|
||||
import { ErrorBoundary } from '../error-boundary'
|
||||
import { SceneRenderer } from '../renderers/scene-renderer'
|
||||
import FrameLimiter from './frame-limiter'
|
||||
import { Lights } from './lights'
|
||||
@@ -78,6 +79,10 @@ type WebGPUDeviceLossInfo = {
|
||||
|
||||
type WebGPUDeviceLike = {
|
||||
lost: Promise<WebGPUDeviceLossInfo>
|
||||
label?: string
|
||||
features?: Set<string>
|
||||
addEventListener?: (type: string, listener: EventListener) => void
|
||||
removeEventListener?: (type: string, listener: EventListener) => void
|
||||
}
|
||||
|
||||
function GPUDeviceWatcher() {
|
||||
@@ -87,7 +92,18 @@ function GPUDeviceWatcher() {
|
||||
const backend = (gl as any).backend
|
||||
const device = backend?.device as WebGPUDeviceLike | undefined
|
||||
|
||||
if (!device) return
|
||||
if (!device) {
|
||||
console.warn('[viewer] No WebGPU device on backend — running on a fallback renderer.', {
|
||||
backend: backend?.constructor?.name ?? 'unknown',
|
||||
rendererType: (gl as any).constructor?.name ?? 'unknown',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[viewer] WebGPU device ready', {
|
||||
label: device.label,
|
||||
features: device.features ? Array.from(device.features) : [],
|
||||
})
|
||||
|
||||
device.lost.then((info: WebGPUDeviceLossInfo) => {
|
||||
console.error(
|
||||
@@ -95,6 +111,17 @@ function GPUDeviceWatcher() {
|
||||
'The page must be reloaded to recover the GPU context.',
|
||||
)
|
||||
})
|
||||
|
||||
// Uncaptured errors are normally silent (only console-warned by Chrome at
|
||||
// best). Pipe them to console.error so silent mobile crashes show up.
|
||||
const onUncapturedError = (event: any) => {
|
||||
console.error('[viewer] WebGPU uncaptured error:', event?.error?.message, event?.error)
|
||||
}
|
||||
device.addEventListener?.('uncapturederror', onUncapturedError)
|
||||
|
||||
return () => {
|
||||
device.removeEventListener?.('uncapturederror', onUncapturedError)
|
||||
}
|
||||
}, [gl])
|
||||
|
||||
return null
|
||||
@@ -121,17 +148,34 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
dpr={[1, 1.5]}
|
||||
frameloop="never"
|
||||
gl={async (props) => {
|
||||
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
|
||||
try {
|
||||
// Surface the env we're about to ask WebGPU for — catches "no
|
||||
// navigator.gpu" / "adapter request failed" silently failing in
|
||||
// mobile WebViews where WebGPU is gated behind flags.
|
||||
const hasGpu = typeof navigator !== 'undefined' && 'gpu' in navigator
|
||||
console.log('[viewer] Creating WebGPURenderer', {
|
||||
hasNavigatorGPU: hasGpu,
|
||||
ua: typeof navigator !== 'undefined' ? navigator.userAgent : 'n/a',
|
||||
})
|
||||
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()
|
||||
console.log('[viewer] WebGPURenderer ready', {
|
||||
backend: (renderer as any).backend?.constructor?.name,
|
||||
isWebGPU: (renderer as any).isWebGPURenderer === true,
|
||||
})
|
||||
return renderer
|
||||
} catch (err) {
|
||||
console.error('[viewer] WebGPURenderer init failed', err)
|
||||
throw err
|
||||
}
|
||||
}}
|
||||
resize={{
|
||||
debounce: 100,
|
||||
@@ -144,39 +188,41 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
<FrameLimiter fps={50} />
|
||||
{/* <AnimatedBackground isDark={theme === 'dark'} /> */}
|
||||
<ViewerCamera />
|
||||
|
||||
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
|
||||
/> */}
|
||||
<Lights />
|
||||
<Bvh>
|
||||
<SceneRenderer />
|
||||
</Bvh>
|
||||
|
||||
{/* Default Systems */}
|
||||
<LevelSystem />
|
||||
<GuideSystem />
|
||||
<ScanSystem />
|
||||
<WallCutout />
|
||||
{/* Core systems */}
|
||||
<CeilingSystem />
|
||||
<DoorSystem />
|
||||
<FenceSystem />
|
||||
<ItemSystem />
|
||||
<RoofSystem />
|
||||
<SlabSystem />
|
||||
<StairSystem />
|
||||
<WallSystem />
|
||||
<WindowSystem />
|
||||
<ZoneSystem />
|
||||
<PostProcessing hoverStyles={hoverStyles} />
|
||||
{/* <DebugRenderer /> */}
|
||||
<GPUDeviceWatcher />
|
||||
|
||||
<ItemLightSystem />
|
||||
<ItemMeshMetadataSystem />
|
||||
{selectionManager === 'default' && <SelectionManager />}
|
||||
{perf && <PerfMonitor />}
|
||||
{children}
|
||||
<ErrorBoundary fallback={null} scope="viewer-scene">
|
||||
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
|
||||
/> */}
|
||||
<Lights />
|
||||
<Bvh>
|
||||
<SceneRenderer />
|
||||
</Bvh>
|
||||
|
||||
{/* Default Systems */}
|
||||
<LevelSystem />
|
||||
<GuideSystem />
|
||||
<ScanSystem />
|
||||
<WallCutout />
|
||||
{/* Core systems */}
|
||||
<CeilingSystem />
|
||||
<DoorSystem />
|
||||
<FenceSystem />
|
||||
<ItemSystem />
|
||||
<RoofSystem />
|
||||
<SlabSystem />
|
||||
<StairSystem />
|
||||
<WallSystem />
|
||||
<WindowSystem />
|
||||
<ZoneSystem />
|
||||
<PostProcessing hoverStyles={hoverStyles} />
|
||||
{/* <DebugRenderer /> */}
|
||||
|
||||
<ItemLightSystem />
|
||||
<ItemMeshMetadataSystem />
|
||||
{selectionManager === 'default' && <SelectionManager />}
|
||||
{perf && <PerfMonitor />}
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</Canvas>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -174,9 +174,22 @@ const PostProcessingPasses = ({
|
||||
void pipelineVersion
|
||||
|
||||
if (!(renderer && scene && camera)) {
|
||||
console.warn('[viewer/post-processing] Skipping pipeline build — missing dependency.', {
|
||||
hasRenderer: !!renderer,
|
||||
hasScene: !!scene,
|
||||
hasCamera: !!camera,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[viewer/post-processing] Building pipeline', {
|
||||
version: pipelineVersion,
|
||||
ssgi: SSGI_PARAMS.enabled,
|
||||
hoverHighlightMode,
|
||||
projectId,
|
||||
rendererCtor: (renderer as any).constructor?.name,
|
||||
})
|
||||
|
||||
hasPipelineErrorRef.current = false
|
||||
|
||||
// WebGPU availability check: SSGI, denoise, and RenderPipeline are all
|
||||
@@ -318,10 +331,16 @@ const PostProcessingPasses = ({
|
||||
renderPipeline.outputNode = finalOutput
|
||||
renderPipelineRef.current = renderPipeline
|
||||
retryCountRef.current = 0
|
||||
console.log('[viewer/post-processing] Pipeline built OK', { version: pipelineVersion })
|
||||
} catch (error) {
|
||||
hasPipelineErrorRef.current = true
|
||||
console.error(
|
||||
'[viewer] Failed to set up post-processing pipeline. Rendering without post FX.',
|
||||
'[viewer/post-processing] Failed to set up post-processing pipeline. Rendering without post FX.',
|
||||
{
|
||||
version: pipelineVersion,
|
||||
ssgi: SSGI_PARAMS.enabled,
|
||||
rendererCtor: (renderer as any).constructor?.name,
|
||||
},
|
||||
error,
|
||||
)
|
||||
if (renderPipelineRef.current) {
|
||||
@@ -366,7 +385,7 @@ const PostProcessingPasses = ({
|
||||
}
|
||||
;(renderer as any).render(scene, camera)
|
||||
} catch (fallbackError) {
|
||||
console.error('[viewer] Fallback render failed.', fallbackError)
|
||||
console.error('[viewer/post-processing] Fallback render failed.', fallbackError)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -378,7 +397,11 @@ const PostProcessingPasses = ({
|
||||
renderPipelineRef.current.render()
|
||||
} catch (error) {
|
||||
hasPipelineErrorRef.current = true
|
||||
console.error('[viewer] Post-processing render pass failed.', error)
|
||||
console.error('[viewer/post-processing] Render pass failed.', {
|
||||
retryCount: retryCountRef.current,
|
||||
rendererCtor: (renderer as any).constructor?.name,
|
||||
error,
|
||||
})
|
||||
if (renderPipelineRef.current) {
|
||||
renderPipelineRef.current.dispose()
|
||||
}
|
||||
@@ -388,7 +411,7 @@ const PostProcessingPasses = ({
|
||||
// Auto-retry: schedule a pipeline rebuild if we haven't exceeded the retry limit
|
||||
retryCountRef.current++
|
||||
console.warn(
|
||||
`[viewer] Scheduling post-processing rebuild (attempt ${retryCountRef.current}/${MAX_PIPELINE_RETRIES})`,
|
||||
`[viewer/post-processing] Scheduling pipeline rebuild (attempt ${retryCountRef.current}/${MAX_PIPELINE_RETRIES})`,
|
||||
)
|
||||
if (rebuildTimeoutRef.current !== null) {
|
||||
clearTimeout(rebuildTimeoutRef.current)
|
||||
@@ -396,7 +419,7 @@ const PostProcessingPasses = ({
|
||||
rebuildTimeoutRef.current = setTimeout(requestPipelineRebuild, RETRY_DELAY_MS)
|
||||
} else {
|
||||
console.error(
|
||||
'[viewer] Post-processing retries exhausted. Rendering without post FX for this session.',
|
||||
'[viewer/post-processing] Retries exhausted. Rendering without post FX for this session.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +166,14 @@ export class MergedOutlineNode extends TempNode {
|
||||
private readonly _cacheA = new Set<Object3D>()
|
||||
private readonly _cacheB = new Set<Object3D>()
|
||||
|
||||
// Tracks whether either group rendered last frame. We use this to decide
|
||||
// when it's safe to skip renderer state manipulation entirely — touching
|
||||
// the renderer (resetRendererAndSceneState + setRenderTarget + clearColor)
|
||||
// corrupts the FBO state on the WebGL2 backend (iOS Chrome fallback) and
|
||||
// the subsequent scene render comes out blank.
|
||||
private _wroteGroupALastFrame = false
|
||||
private _wroteGroupBLastFrame = false
|
||||
|
||||
private readonly _textureNodeA: any
|
||||
private readonly _textureNodeB: any
|
||||
|
||||
@@ -294,6 +302,17 @@ export class MergedOutlineNode extends TempNode {
|
||||
updateBefore(frame: any) {
|
||||
const hasPrimary = this.primaryObjects.length > 0
|
||||
const hasSecondary = this.secondaryObjects.length > 0
|
||||
const hasAny = hasPrimary || hasSecondary
|
||||
|
||||
// Fast-path: nothing to render and nothing was rendered last frame either,
|
||||
// so there are no stale composites to clear. Touch nothing — on the WebGL2
|
||||
// backend (iOS Chrome fallback) even an empty reset/setRenderTarget cycle
|
||||
// corrupts the framebuffer state and the next scene render goes blank.
|
||||
const needsCleanupA = !hasPrimary && this._wroteGroupALastFrame
|
||||
const needsCleanupB = !hasSecondary && this._wroteGroupBLastFrame
|
||||
if (!(hasAny || needsCleanupA || needsCleanupB)) {
|
||||
return
|
||||
}
|
||||
|
||||
const { renderer } = frame
|
||||
const { camera, scene } = this
|
||||
@@ -303,24 +322,27 @@ export class MergedOutlineNode extends TempNode {
|
||||
const size = renderer.getDrawingBufferSize(_size)
|
||||
this.setSize(size.width, size.height)
|
||||
|
||||
// Clear composites for inactive groups so stale outlines don't persist on GPU.
|
||||
// Must happen inside resetRendererAndSceneState to avoid MSAA state corruption.
|
||||
if (!hasPrimary) {
|
||||
// Clear composites for groups that just transitioned from "has content"
|
||||
// to "empty" — without this, the previous outline lingers on the GPU.
|
||||
if (needsCleanupA) {
|
||||
renderer.setRenderTarget(this._groupA.composite)
|
||||
renderer.clearColor()
|
||||
this._wroteGroupALastFrame = false
|
||||
}
|
||||
if (!hasSecondary) {
|
||||
if (needsCleanupB) {
|
||||
renderer.setRenderTarget(this._groupB.composite)
|
||||
renderer.clearColor()
|
||||
this._wroteGroupBLastFrame = false
|
||||
}
|
||||
|
||||
const hasAny = hasPrimary || hasSecondary
|
||||
if (!hasAny) {
|
||||
RendererUtils.restoreRendererAndSceneState(renderer, scene, _rendererState)
|
||||
return
|
||||
}
|
||||
|
||||
renderer.setClearColor(0xff_ff_ff, 1)
|
||||
this._wroteGroupALastFrame = hasPrimary
|
||||
this._wroteGroupBLastFrame = hasSecondary
|
||||
|
||||
if (hasPrimary) this._buildCache(this.primaryObjects, this._cacheA)
|
||||
if (hasSecondary) this._buildCache(this.secondaryObjects, this._cacheB)
|
||||
|
||||
Reference in New Issue
Block a user