fix(viewer): wall-clock scene-ready cap + skip unreachable dirty nodes#484
Conversation
Two readiness fixes surfaced by the first successful headless bake of a heavy scene (which exported with 33 items missing): - The 180-FRAME give-up cap silently assumed display-rate frames. The bake page's timer-driven loop (?disable=draw) runs those frames in ~3.6s — shorter than a cold item-model download — so the cap fired and the export raced ahead of real content. New `sceneReadyMaxWaitMs` Viewer prop replaces the frame cap with a wall-clock one on hosts whose frame cadence is decoupled from time; default frame behavior unchanged for interactive surfaces. - hasPendingSceneBuildWork now skips UNREACHABLE dirty nodes: orphans whose parent doesn't list them in `children` (or parentless non-roots) never render — every renderer enumerates the parent's children array — so no system ever builds them or clears their mark. Observed in prod scene data (two dangling windows parented to a level that doesn't list them): they held every bake of that scene to the full readiness cap. Validated: the 200+-item office scene with ?disable=draw settles organically in ~2s and exports the complete artifact; a one-shot 504'd item is now WAITED for through its retry instead of racing the cap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 00459d0. Configure here.
| const reachable = parent | ||
| ? (parent as { children?: string[] }).children?.includes(id) === true | ||
| : rootNodeIds.includes(id) | ||
| if (!reachable) continue |
There was a problem hiding this comment.
Roots ignored for reachability
Medium Severity
hasPendingSceneBuildWork treats a dirty node as unreachable whenever its parentId resolves but that parent’s children omits the id, without also checking rootNodeIds. SceneRenderer still mounts ids listed at the root, so pending build work on those nodes can be ignored and onSceneReadyChange(true) may fire before geometry finishes.
Reviewed by Cursor Bugbot for commit 00459d0. Configure here.
| // `sceneReadyMaxWaitMs` to make the cap wall-clock instead. | ||
| const capReached = sceneReadyMaxWaitMs | ||
| ? performance.now() - waitStartRef.current >= sceneReadyMaxWaitMs | ||
| : waitedFramesRef.current >= SCENE_READY_MAX_WAIT_FRAMES |
There was a problem hiding this comment.
Zero max wait ignored
Low Severity
capReached branches on truthy sceneReadyMaxWaitMs, so passing 0 does not select the wall-clock path and falls back to the 180-frame cap. A host that sets sceneReadyMaxWaitMs={0} expecting an immediate give-up gets editor-style frame timing instead.
Reviewed by Cursor Bugbot for commit 00459d0. Configure here.


What does this PR do?
Two readiness fixes surfaced by the first successful headless CPU-only bake (which completed in 80s — but exported with 33 items missing, caught by the new
skippedItemsmetadata):sceneReadyMaxWaitMsViewer prop — the 180-frame give-up cap silently assumed display-rate frames (~3s interactive). The bake page's timer loop (?disable=draw, fix(viewer): drive the frame loop with a timer when ?disable=draw #483) runs 180 frames in 3.6s of wall time — shorter than a cold item download, so the cap fired and the export raced ahead of content. The prop replaces the frame cap with a wall-clock one; hosts that don't pass it keep the exact current behavior.parentIdpoints at a level that doesn't list them inchildren(dangling nodes from some old detach flow). Nothing ever renders/builds an unreachable node, so its dirty mark lives forever and held every bake of that scene to the full cap.hasPendingSceneBuildWorknow skips nodes not reachable via their parent'schildrenarray (or roots).How to test
bun dev, open/bake/demo_1?disable=postFx,draw— resolves fast, as before.sceneReadyMaxWaitMspassed → same 180-frame cap).?disable=postFx,draw+ the companion bake-page change): settles organically in ~2s, exports the complete 35MB artifact, zero skipped items; with an item URL 504'd once, the bake now waits through the retry and includes the item.Screenshots / screen recording
N/A — readiness timing behavior.
Checklist
bun devbun checkto verify)mainbranch🤖 Generated with Claude Code
Note
Medium Risk
Changes when hosts signal scene-ready (export/capture timing) and ignore dirty marks on graph-unreachable nodes; wrong reachability logic could declare ready while real content is still building, though the existing settled-frame and cap behavior remains.
Overview
Fixes scene readiness getting stuck or firing too early during headless bakes and on scenes with broken graph links.
Unreachable dirty nodes no longer count as pending build work:
hasPendingSceneBuildWorktreats a node as reachable only if its parent lists it inchildrenor it is inrootNodeIds, so orphaned dirty nodes cannot blockonSceneReadyChangeuntil the give-up cap.Optional wall-clock give-up via new Viewer prop
sceneReadyMaxWaitMs: when set,SceneReadyTrackeruses elapsed time instead of the default 180-frame cap—intended for timer-driven hosts (e.g. bake page) where 180 frames is shorter in wall time than slow async work like item downloads. Hosts that omit the prop keep the existing frame-based cap.Reviewed by Cursor Bugbot for commit 00459d0. Bugbot is set up for automated code reviews on this repo. Configure here.