viewer: baked GLB export + GLB-consuming /viewer (lights, clips, perf)#441
Conversation
…es 0-1)
Promote the client GLB export into the baked-artifact format from
plans/editor-baked-glb-export.md (phases 0 and 1).
- NodeMaterial -> classic MeshStandardMaterial conversion at export. The
viewer's MeshStandard/LambertNodeMaterial set isNodeMaterial, not
isMeshStandardMaterial, so GLTFExporter would otherwise drop every
surface to a blank default. KTX2 (compressed) maps are decompressed via
WebGPUTextureUtils so the exporter can embed them (PNG for now; KTX2
re-encode is deferred to the phase-3 bake worker).
- Identity stamping from sceneRegistry: node.name = pascalId and
extras = { pascalId, kind, label?, openable?, clips? }; all other
userData stripped so editor/runtime ephemera never reach glTF extras.
- Door/window open clips baked from the build-once + pose-at-t primitives
(door pascalSwingLeaf marker, window poseWindowMovingParts). Clips named
by label ("Door 1: open"), carry extras.loop = false (consumers play
once and hold; dumb glTF players still loop).
- Cutout fix: door/window selection hitboxes hide via material.visible,
which onlyVisible misses, so the hitbox box plugged the wall opening.
Non-renderable container meshes now keep their node but lose geometry;
childless ones are removed.
- Editor-overlay stripping mirrors the thumbnail capture: emit
thumbnail:before/after-capture so scene-layer affordances (handles,
ceiling/site brackets) self-hide, and drop anything off SCENE_LAYER
(gizmos, grid, zone fills).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add `GlbScene` — the viewer that renders a baked GLB artifact with no parametric scene graph, and finish the phase-1 export contract it consumes. Viewer (packages/viewer): - GlbScene: loads the artifact, drives a building → level → zone → node drill hierarchy on useViewer.selection. Room/zone picking resolves from the floor plane (ray ∩ floor + point-in-polygon), node picking uses a footprint test, so walls/ceilings/zone-helpers never skew or block selection. Level modes (stacked/exploded/solo), dollhouse (hide a focused floor's ceilings + roof so rooms are visible and pickable), reconstructed zone floor fills + gradient edge borders + room labels (faded, hover-brightened), baked door/window open clips, click-outside / empty-space deselect, and the shared outline post-FX. Exported as GlbScene/GlbLevel/GlbIdentity/GlbHover. - post-processing: composite the zone-pass tint into the base scene so rooms show whether or not SSGI is enabled (previously only added in the SSGI branch). Export contract (packages/editor/src/lib/glb-export.ts): - Convert WebGPU NodeMaterials to classic glTF-standard materials; decompress KTX2 maps via WebGPUTextureUtils so GLTFExporter can embed them. - Stamp name + extras identity (pascalId/kind/label/openable/clips), bake door/window open clips (loop:false), strip editor overlays (off-layer + invisible-material hitboxes) so cutouts aren't plugged. - Fix opaque-but-flagged-transparent materials (no spurious alphaMode=BLEND) and BackSide → FrontSide + winding flip (glTF has no back-face-only). - Force levels + zones visible and stamp level display names + zone polygons so every floor bakes and /viewer can reconstruct rooms and label the breadcrumb. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract the GLB build (prepare + GLTFExporter + WebGPUTextureUtils) out of ExportManager into exportSceneToGlb so it can run outside the editor download flow. Add a BakeExporter R3F component that fires the export once a host viewer signals scene-ready and hands back the ArrayBuffer. Both exported for the headless bake route (baked-glb-export phase 3, headless de-risk). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GlbScene accepts an optional lodUrls list and swaps rendered detail by camera distance to the building's world-space center: lod0 (interactive — identity, selection, zones, clips all bind here) for normal + building-view distances, simpler visual-only levels only past ~2.5R / ~8R. Single-URL callers are unchanged. useGLTFKTX2 now accepts a URL array. Manual visibility toggling rather than THREE.LOD: THREE.LOD measures distance to its own origin (0,0,0), which mis-selected levels (blanking the scene) when the baked geometry is offset from the origin. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reverts the LOD switching (0469b25). Rendering lod1/lod2 on zoom-out produced WebGPU validation errors (invalid bindGroup_object) on real-GPU browsers — not caught earlier because headless Chromium falls back to the WebGL2 backend, so the WebGPU path was never exercised. The viewer returns to loading the baked KTX2 lod0 (still the 22MB->12MB win). LOD switching to revisit with a real-GPU test loop and on-demand loading instead of preload-all. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
exportSceneToGlb now calls snapLevelsToTruePositions() (the same clean stacked presentation thumbnail capture uses) before snapshotting the scene, so the GLB always reflects the stacked building regardless of the live levelMode (exploded/solo) or an unsettled level lerp. Fixes baked GLBs where a level was captured at a stray offset (e.g. ~ -100k Y), which inflated the bounding box and made the model unframable / invisible in third-party glTF viewers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A door/window only carries extras.openable + extras.clips when an open animation actually bakes. Cased openings (no leaf) and fixed windows (no operable sash) are no longer mislabelled openable, so the GLB never claims a part opens when nothing moves. /viewer is unaffected (it already required openable && clips). Adds a regression test for the no-clip case. (Export barrels reordered by the formatter.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the first-person walkthrough into @pascal-app/viewer (BVHEcctrl + GlbWalkthroughController) and round out the GLB-consuming viewer: - Walkthrough: fallback ground only on level 0 (upper floors rely on baked slabs), hidden spawn marker, auto pointer-lock on enter, single-Esc exit via pointerlockchange, force perspective on enter / restore on exit. - GlbScene: monochrome strips baked textures and recolours meshes by surface role using the active theme's clay tints; spawn node hidden from render. - glb-export: camera/label/spawn identity extras for the viewer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Items mounted on a ceiling (lamps, fans, recessed lights) are child nodes of the ceiling, so hiding the whole occluder node hid them too. Hide only the ceiling/roof's own meshes (stop descending at nested identity nodes) so hosted items stay visible when a floor is opened up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a GLB interactive layer (`GlbInteractive`) that re-creates the item interactivity the parametric viewer has — point lights and the controls overlay — on top of a baked artifact. Effects + controls come from the DB scene graph (joined to baked nodes by `pascalId`, no sidecar); world transforms come from the baked Object3Ds. Lights are portaled into their item node so they ride level stacking, and intensity tracks the shared `useInteractive` store so overlay dimming works. Baked scenes load "lit" (toggles default on) for a showcase viewer feel. Extract `ControlWidget` into its own module so the parametric `InteractiveSystem` and the GLB overlay render identical controls. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… viewer A catalog GLB ships its own animation clips (a ceiling fan's spin), but those clips aren't in the editor scene graph, so the bake couldn't see them. Add an `itemClipRegistry` (core, type-only three) that the item renderer fills with the resolved clip per node while the scene is live; the GLB export reads it and re-emits each item's clip onto the baked subtree, rebinding tracks to the cloned spinning node's uuid. Catalog node names repeat across instances and the glTF roundtrip rebinds by name, so the targeted node is uniquified per item (`<id>__lamp_018`) — every fan animates independently. The baked viewer plays these as looping ambient motion: `<id>: loop` clips are set to LoopRepeat (not the door/window LoopOnce) and GlbItemAnimation drives them off each item's toggle (lit/spinning by default). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Match the parametric ItemLightSystem instead of mounting a light per item: a fixed pool of point lights is assigned to the nearest/most-visible lit items each tick (camera-proximity scored, hysteresis, level factor), snapped to each item's world position + offset, and faded on reassignment — so a large house doesn't blow the renderer's light budget. The controls overlay already mounts its <Html> only while the item sits in the focused zone (not hide/show); add stopPropagation on the overlay so toggling a control no longer bubbles to the canvas and deselects the zone. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… recompiles) Toggling pointLight.visible changes the active-light count, which makes the WebGPU renderer rebuild every material's lighting node + pipeline — a multi- hundred-ms stall. The pool reassigns on every camera move, so zooming churned visibility and tanked FPS. Keep all 12 pool lights permanently visible and animate only intensity (an idle light lerps to 0); the light-set never changes, so no recompiles. Also make reassignment O(n) (score lookup map, not find). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Scans (LiDAR meshes) and guides (floorplan images) are heavy reference assets stored elsewhere, not part of the compiled building — and baking them into a public static GLB would also bypass the per-project show_*_public flags. Strip both from the export entirely (previously they leaked in as empty identity nodes, timing-dependent). The GLB viewer re-adds them at runtime from the scene graph, like lights: GlbReferenceNodes resolves each scan/guide's registry renderer (no static nodes import — same nodeRegistry path the viewer already uses) and portals it into its parent level's baked node, so the node's level-local transform resolves to the right world pose and rides level stacking. Uses the same GuideRenderer/ ScanRenderer + asset resolver as the parametric viewer, so http-backed assets show for everyone and local asset:// ones for the owner — exact parity. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| const doneRef = useRef(false) | ||
| useEffect(() => { | ||
| if (!(active && !doneRef.current)) return | ||
| doneRef.current = true |
There was a problem hiding this comment.
BakeExporter cannot rebake
Medium Severity
The BakeExporter component's doneRef flag prevents the export logic from running more than once per component mount. Since doneRef is never reset, any subsequent attempts to trigger an export (e.g., by toggling active or after a previous export failed) are silently skipped.
Reviewed by Cursor Bugbot for commit 636f8ed. Configure here.
| * item subtree. Door/window motion is synthesized separately and never goes | ||
| * here. Keyed by node id; cleared with the rest of the scene refs on unload. | ||
| */ | ||
| export const itemClipRegistry = new Map<string, ItemClipEntry>() |
There was a problem hiding this comment.
Clip registry survives scene reset
Medium Severity
The new itemClipRegistry is documented as cleared on scene unload, but only per-item delete runs on renderer unmount. resetEditorInteractionState still calls sceneRegistry.clear() without clearing this map, so stale catalog clips can remain keyed by old node ids.
Reviewed by Cursor Bugbot for commit 636f8ed. Configure here.
| action.timeScale = willOpen ? 1 : -1 | ||
| action.play() | ||
| if (willOpen) openIds.current.add(id) | ||
| else openIds.current.delete(id) |
There was a problem hiding this comment.
Door close animation may fail
High Severity
Closing a baked door sets timeScale to -1 and calls play() without moving the action time to the clip end. After an open finishes, play() often restarts from time zero, so reverse playback may not run and the mesh can stay open while openIds is cleared.
Reviewed by Cursor Bugbot for commit 636f8ed. Configure here.
# Conflicts: # packages/editor/src/components/editor/export-manager.tsx
…turn) Main's biome config flags a callback returning a value; the ternary in the zone-shape forEach implicitly returned moveTo/lineTo. Use a block body. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 5 potential issues.
There are 8 total unresolved issues (including 3 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit b7386f2. Configure here.
| const sceneGroup = scene.getObjectByName('scene-renderer') | ||
| if (!sceneGroup) throw new Error('scene-renderer group not found') | ||
| const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes) | ||
| onComplete(buffer) |
There was a problem hiding this comment.
Item clips missing early bake
Medium Severity
Catalog item clips are registered only after each item’s GLTF animations load in a useEffect, while BakeExporter can export as soon as active is true. An early bake often finds an empty itemClipRegistry, so fan-style ambient clips are omitted from the GLB.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit b7386f2. Configure here.
| prepared = prepareSceneForExport(sceneGroup, useScene.getState().nodes) | ||
| } finally { | ||
| emitter.emit('thumbnail:after-capture', undefined) | ||
| } |
There was a problem hiding this comment.
OBJ STL skip level snap
Medium Severity
This refactor routes GLB export through exportSceneToGlb, which snaps levels to stacked positions before cloning, but OBJ/STL still call prepareSceneForExport on the live tree with no snap. With exploded or solo level mode, mesh exports can bake wrong vertical offsets while GLB/thumbnails match the stacked building.
Reviewed by Cursor Bugbot for commit b7386f2. Configure here.
| action.timeScale = willOpen ? 1 : -1 | ||
| action.play() | ||
| if (willOpen) openIds.current.add(id) | ||
| else openIds.current.delete(id) |
There was a problem hiding this comment.
Door HUD ahead of animation
Low Severity
toggleOpenable updates openIds as soon as the user toggles, before the one-second clip finishes. Walkthrough HUD reads that set for isOpen, so prompts can say the door is closed while it is still open visually, or the reverse, until the animation completes.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit b7386f2. Configure here.
| else openIds.current.delete(id) | ||
| }, | ||
| [actions], | ||
| ) |
There was a problem hiding this comment.
Door open state survives URL swap
Medium Severity
openIds tracks which doors/windows are open but nothing clears it when the url prop changes. After loading another baked GLB in the same GlbScene instance, toggles and walkthrough HUD can treat doors as open or closed incorrectly relative to the new mesh rest pose.
Reviewed by Cursor Bugbot for commit b7386f2. Configure here.
| spawn: resolveGlbSpawn(gltf.scene), | ||
| }) | ||
| } else { | ||
| console.warn('[glb-walkthrough] NO collider world (no eligible meshes)') |
There was a problem hiding this comment.
Walkthrough debug logging left in
Low Severity
GlbWalkthroughController emits console.warn diagnostics whenever a collider is built or missing. That runs in normal viewer sessions, not only during local debugging.
Reviewed by Cursor Bugbot for commit b7386f2. Configure here.
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |


What does this PR do?
The editor-package side of the baked-GLB export epic: scenes compile to a
self-contained, engine-agnostic GLB consumed by a new GLB-driven
/viewer, withno parametric runtime. The editor stays the geometry compiler; the GLB is the
artifact.
packages/editorlib/glb-export.ts):exportSceneToGlb+BakeExporterfor headless bake. NodeMaterial→standard conversion,name+extrasidentity stamping, door/window open-close clips, level-snap, cutout/material fixes,
zone polygons. Bakes catalog item clips (e.g. a fan's spin) onto the baked
subtree, uniquified per instance so every fan animates independently. Strips
scans/guides (heavy reference assets re-added at runtime).
/viewer(packages/viewer):GlbScene— drill hierarchy(building→level→zone→item), level modes + dollhouse, reconstructed zone fills/labels,
baked clips, post-FX outline, camera bookmarks, walkthrough, monochrome-by-role.
pascalId, no sidecar):a pooled point-light system (mirrors the parametric pool; fixed visible count to
avoid WebGPU recompiles), the item controls overlay (zone-scoped, conditional
<Html>), fan/ambient animation, and scans/guides re-added via the registryrenderers, all gated by the host.
packages/core, type-only three) so the bake can see catalogclips that aren't in the scene graph.
Server-side bake pipeline, worker, and admin tooling live in
private-editor.How to test
bun build) and run the consuming app (community/viewerin private-editor, or
apps/editor)./viewer:→ correct geometry + PBR, door
openclip plays with no Pascal runtime.Screenshots / screen recording
N/A here — the visual surfaces (lit scene, fan spin, drill nav, dimming, scans/guides)
were validated in a real browser against baked artifacts during development; recordings
can be added on request.
Checklist
bun devbun checkto verify)mainbranchNote
Medium Risk
Large new export and runtime paths touch materials, animation baking, and viewer interaction; regressions could affect published GLBs and walkthrough, but changes are mostly additive to the 3D pipeline rather than auth or data integrity.
Overview
Adds a compiler-style GLB bake from the live editor scene and a parametric-free GLB viewer that reads identity, clips, and zones from the artifact.
Export (
glb-export,BakeExporter,ExportManager) clones the scene, converts WebGPU NodeMaterials to glTF-standard materials, strips editor overlays/hitboxes and heavy scan/guide nodes, snaps levels for capture, and writesextras(pascalId, labels, zone polygons, openable flags). It bakes animations for doors (pascalSwingLeaf), windows (poseWindowMovingParts), and catalog items via newitemClipRegistry(item renderer registers ambient clips; export retargets per instance).Viewer introduces
GlbScene(building→level→zone drill, dollhouse, zone fills, baked open/loop clips, walkthrough HUD),GlbInteractive(pooled lights, fan loops, zone-scoped controls from the DB graph),GlbReferenceNodes, andGlbWalkthroughController(BVH collider + sharedBVHEcctrl). Post-FX composites zone tint without SSGI;ControlWidgetis shared with the parametric interactive overlay.Reviewed by Cursor Bugbot for commit b7386f2. Bugbot is set up for automated code reviews on this repo. Configure here.