Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 98 additions & 7 deletions packages/nodes/src/item/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import { useAnimations } from '@react-three/drei'
import { Clone } from '@react-three/drei/core/Clone'
import { useGLTF } from '@react-three/drei/core/Gltf'
import { useFrame } from '@react-three/fiber'
import { Suspense, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { AnimationAction, Group, Material, Mesh } from 'three'
import { MathUtils } from 'three'
import { positionLocal, smoothstep, time } from 'three/tsl'
Expand Down Expand Up @@ -181,6 +181,7 @@ const resolveItemMaterial = (
const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
const handlers = useNodeEvents(node, 'item')
const shading = useViewer((s) => s.shading)
const isExporting = useViewer((s) => s.isExporting)
const [w, h, d] = node.asset.dimensions
const material = useMemo(() => {
const next = createDefaultMaterial('#ef4444', 1, shading) as MutableMaterial
Expand All @@ -191,6 +192,10 @@ const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
return next
}, [shading])

// Debug affordance only — a bake must never ship the red placeholder box
// (observed baked into a prod artifact when an item GLB 504'd mid-capture).
if (isExporting) return null

return (
<mesh position-y={h / 2} {...handlers}>
<boxGeometry args={[w, h, d]} />
Expand All @@ -199,11 +204,88 @@ const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
)
}

const MODEL_RETRY_DELAYS_MS = [1_000, 3_000]

/**
* Load the item model with bounded retries. drei's `useGLTF` caches a rejected
* load by URL, so a transient fetch failure (e.g. a storage 504 under the bake
* page's asset-request burst) would otherwise stay broken for the whole
* session — clear the cache entry and re-mount. After the retries are
* exhausted the item settles as SKIPPED: it renders the debug box (nothing
* during exports) and lands in `useViewer.itemLoadFailures` so a bake host can
* record which items are missing from the artifact.
*/
const ModelWithRetry = ({
node,
setSettled,
}: {
node: ItemNode
setSettled: (value: boolean) => void
}) => {
// `failures` counts boundary catches; `epoch` bumps after each cache clear
// to reset the boundary and re-mount the loader. The retry timer is owned by
// an effect (not the error handler) so StrictMode's synthetic
// unmount/remount re-arms it instead of silently discarding it. The host
// keys this component by asset URL, so a model swap starts from a clean
// retry budget — and the mount effect below un-settles the item so the new
// load is awaited too.
const [failures, setFailures] = useState(0)
const [epoch, setEpoch] = useState(0)
const url = resolveCdnUrl(node.asset.src) || ''
const gaveUp = !url || failures > MODEL_RETRY_DELAYS_MS.length

const handleError = useCallback(() => setFailures((current) => current + 1), [])

useEffect(() => {
setSettled(false)
}, [setSettled])

useEffect(() => {
if (failures === 0 || gaveUp) return
const delay = MODEL_RETRY_DELAYS_MS[failures - 1] ?? 0
const timer = setTimeout(() => {
console.log(`[item] retrying model load (${failures}/${MODEL_RETRY_DELAYS_MS.length}) ${url}`)
useGLTF.clear(url)
setEpoch((current) => current + 1)
}, delay)
return () => clearTimeout(timer)
}, [failures, gaveUp, url])

const markSettled = useCallback(() => setSettled(true), [setSettled])

useEffect(() => {
if (!gaveUp) return
markSettled()
useViewer.getState().reportItemLoadFailure(node.id, url)
return () => useViewer.getState().clearItemLoadFailure(node.id)
}, [gaveUp, markSettled, node.id, url])

if (gaveUp) return <BrokenItemFallback node={node} />

return (
<ErrorBoundary fallback={<PreviewModel node={node} />} onError={handleError} resetKey={epoch}>
<Suspense fallback={<PreviewModel node={node} />}>
<ModelRenderer markSettled={markSettled} node={node} />
</Suspense>
</ErrorBoundary>
)
}

export const ItemRenderer = ({ node: storeNode }: { node: ItemNode }) => {
const ref = useRef<Group>(null!)

useRegistry(storeNode.id, storeNode.type, ref)

// "Settled" = the model resolved, terminally failed (skipped), or was never
// expected. `ItemSystem` holds the dirty mark until then, so scene-ready
// (and headless bakes) wait for real item content instead of exporting the
// loading placeholder. A model swap un-settles (ModelWithRetry's mount
// effect via its URL key) so the replacement load is awaited too.
const setSettled = useCallback((value: boolean) => {
const group = ref.current as (Group & { userData: Record<string, unknown> }) | null
if (group) group.userData.itemModelSettled = value
}, [])
Comment thread
cursor[bot] marked this conversation as resolved.

// Merge live drag overrides so the mesh transforms in real time during a
// drag (e.g. the in-world rotate gizmo). The handle writes the in-flight
// rotation to `useLiveNodeOverrides` on every pointer move and commits to
Expand All @@ -217,17 +299,17 @@ export const ItemRenderer = ({ node: storeNode }: { node: ItemNode }) => {
const roomClearPreview =
(node as ItemNode & { roomClearPreview?: unknown }).roomClearPreview === true

useEffect(() => {
if (roomClearPreview) setSettled(true)
}, [roomClearPreview, setSettled])

const content = (
<group position={node.position} ref={ref} rotation={node.rotation} visible={node.visible}>
{roomClearPreview ? (
<ClearPreviewModel node={node} />
) : (
<>
<ErrorBoundary fallback={<BrokenItemFallback node={node} />}>
<Suspense fallback={<PreviewModel node={node} />}>
<ModelRenderer node={node} />
</Suspense>
</ErrorBoundary>
<ModelWithRetry key={node.asset.src ?? 'no-src'} node={node} setSettled={setSettled} />
{node.children?.map((childId) => (
<NodeRenderer key={childId} nodeId={childId} />
))}
Expand Down Expand Up @@ -262,6 +344,9 @@ function getPreviewMaterial(shading: RenderShading): Material {

const PreviewModel = ({ node }: { node: ItemNode }) => {
const shading = useViewer((s) => s.shading)
const isExporting = useViewer((s) => s.isExporting)
// Loading placeholder — must never land in an exported GLB.
if (isExporting) return null
return (
<mesh material={getPreviewMaterial(shading)} position-y={node.asset.dimensions[1] / 2}>
<boxGeometry
Expand Down Expand Up @@ -296,10 +381,16 @@ const multiplyScales = (
b: [number, number, number],
): [number, number, number] => [a[0] * b[0], a[1] * b[1], a[2] * b[2]]

const ModelRenderer = ({ node }: { node: ItemNode }) => {
const ModelRenderer = ({ node, markSettled }: { node: ItemNode; markSettled?: () => void }) => {
const { scene, nodes, animations } = useGLTF(resolveCdnUrl(node.asset.src) || '')
const ref = useRef<Group>(null!)
const { actions } = useAnimations(animations, ref)

// Mounting past the suspense gate means the GLB resolved — the item's build
// work is done (`ItemSystem` may clear its dirty mark, scene-ready may fire).
useEffect(() => {
markSettled?.()
}, [markSettled])
const shading = useViewer((s) => s.shading)
const textures = useViewer((s) => s.textures)
const colorPreset = useViewer((s) => s.colorPreset)
Expand Down
11 changes: 11 additions & 0 deletions packages/viewer/src/components/error-boundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ interface ErrorBoundaryProps {
fallback: ReactNode
/** Tag for log lines so we can tell which boundary swallowed an error. */
scope?: string
/** Notified once per caught error — lets the host schedule a retry. */
onError?: (error: Error) => void
/** Changing this key clears a caught error and re-mounts `children` — the
* retry half of `onError` (bump it after clearing whatever failed). */
resetKey?: unknown
}

export class ErrorBoundary extends Component<ErrorBoundaryProps, { hasError: boolean }> {
Expand All @@ -19,6 +24,12 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, { hasError: boo
error,
info.componentStack,
)
this.props.onError?.(error)
}
componentDidUpdate(prevProps: ErrorBoundaryProps) {
if (this.state.hasError && prevProps.resetKey !== this.props.resetKey) {
this.setState({ hasError: false })
}
}
render() {
return this.state.hasError ? this.props.fallback : this.props.children
Expand Down
23 changes: 23 additions & 0 deletions packages/viewer/src/store/use-viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ type ViewerState = {
isExporting: boolean
setExporting: (value: boolean) => void

/** Item model loads that exhausted their retries — nodeId → asset URL. The
* scene renders without these items (they settle as skipped); a bake host
* can persist the map onto the artifact's metadata so a missing item is
* queryable instead of silently absent. Transient (never persisted). */
itemLoadFailures: Record<string, string>
reportItemLoadFailure: (nodeId: string, url: string) => void
clearItemLoadFailure: (nodeId: string) => void

/** Suspend the render loop while the canvas is fully covered (e.g. studio gallery). */
renderPaused: boolean
setRenderPaused: (value: boolean) => void
Expand Down Expand Up @@ -245,6 +253,21 @@ const useViewer = create<ViewerState>()(
isExporting: false,
setExporting: (value) => set({ isExporting: value }),

itemLoadFailures: {},
reportItemLoadFailure: (nodeId, url) =>
set((state) =>
state.itemLoadFailures[nodeId] === url
? state
: { itemLoadFailures: { ...state.itemLoadFailures, [nodeId]: url } },
),
clearItemLoadFailure: (nodeId) =>
set((state) => {
if (!(nodeId in state.itemLoadFailures)) return state
const next = { ...state.itemLoadFailures }
delete next[nodeId]
return { itemLoadFailures: next }
}),

renderPaused: false,
setRenderPaused: (value) => set({ renderPaused: value }),

Expand Down
7 changes: 7 additions & 0 deletions packages/viewer/src/systems/item/item-system.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ export const ItemSystem = () => {
}
}

// Hold the mark until the model settles (loaded, terminally failed, or
// never expected — see ItemRenderer.markSettled). Registration alone
// isn't "built": clearing then would let scene-ready fire while GLBs are
// still downloading and a bake would export loading placeholders.
const settled = (mesh.userData as { itemModelSettled?: boolean }).itemModelSettled
if (!settled) return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scene ready beats item settlement

High Severity

ItemSystem now keeps items in dirtyNodes until itemModelSettled, but SceneReadyTracker still marks the scene ready after SCENE_READY_MAX_WAIT_FRAMES even when hasPendingSceneBuildWork() remains true. A bake that follows that signal can export while GLBs are still loading; with isExporting, item placeholders render nothing, so those items can appear as empty groups in the GLB.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6a7d9e9. Configure here.


clearDirty(id as AnyNodeId)
})
}, 2)
Expand Down
Loading