Skip to content

viewer: baked GLB export + GLB-consuming /viewer (lights, clips, perf)#441

Merged
wass08 merged 17 commits into
mainfrom
feat/baked-glb-export
Jun 25, 2026
Merged

viewer: baked GLB export + GLB-consuming /viewer (lights, clips, perf)#441
wass08 merged 17 commits into
mainfrom
feat/baked-glb-export

Conversation

@wass08

@wass08 wass08 commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

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, with
no parametric runtime. The editor stays the geometry compiler; the GLB is the
artifact.

  • Export (packages/editor lib/glb-export.ts): exportSceneToGlb +
    BakeExporter for headless bake. NodeMaterial→standard conversion, name+extras
    identity 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).
  • GLB-consuming /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.
  • Runtime enrichment from the scene graph (joined by 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 registry
    renderers, all gated by the host.
  • Item-clip registry (packages/core, type-only three) so the bake can see catalog
    clips that aren't in the scene graph.

Server-side bake pipeline, worker, and admin tooling live in private-editor.

How to test

  1. Build the packages (bun build) and run the consuming app (community /viewer
    in private-editor, or apps/editor).
  2. Export/bake a scene to a GLB and load it in the GLB /viewer:
    • geometry + PBR materials match the editor; drill nav building→level→zone→item;
    • doors open on click; ceiling fans spin;
    • lights are lit + dimmable via the in-zone controls overlay (pooled, smooth on zoom);
    • monochrome (textures off) recolors by surface role.
  3. Open the exported GLB in a third-party glTF viewer (e.g. gltf-viewer.donmccurdy.com)
    → correct geometry + PBR, door open clip 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

  • I've tested this locally with bun dev
  • My code follows the existing code style (run bun check to verify)
  • I've updated relevant documentation (if applicable)
  • This PR targets the main branch

Note

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 writes extras (pascalId, labels, zone polygons, openable flags). It bakes animations for doors (pascalSwingLeaf), windows (poseWindowMovingParts), and catalog items via new itemClipRegistry (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, and GlbWalkthroughController (BVH collider + shared BVHEcctrl). Post-FX composites zone tint without SSGI; ControlWidget is 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.

wass08 and others added 15 commits June 22, 2026 11:55
…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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

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>()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 636f8ed. Configure here.

Comment thread packages/viewer/src/components/viewer/glb-scene.tsx
wass08 and others added 2 commits June 25, 2026 12:34
# 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>
@wass08 wass08 merged commit d3c3c05 into main Jun 25, 2026
3 of 4 checks passed

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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).

Fix All in Cursor

❌ 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b7386f2. Configure here.

prepared = prepareSceneForExport(sceneGroup, useScene.getState().nodes)
} finally {
emitter.emit('thumbnail:after-capture', undefined)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b7386f2. Configure here.

else openIds.current.delete(id)
},
[actions],
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b7386f2. Configure here.

spawn: resolveGlbSpawn(gltf.scene),
})
} else {
console.warn('[glb-walkthrough] NO collider world (no eligible meshes)')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b7386f2. Configure here.

@mintlify

mintlify Bot commented Jun 25, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
pascal 🔴 Failed Jun 25, 2026, 4:44 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant