feat(viewer): GLB-consuming /viewer path (baked-glb-export phase 2)

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>
This commit is contained in:
Wassim SAMAD
2026-06-23 09:14:39 -04:00
co-authored by Claude Opus 4.8
parent 06e4cd7748
commit 0a7cbd2081
5 changed files with 882 additions and 11 deletions
@@ -146,6 +146,54 @@ describe('prepareSceneForExport', () => {
expect(leafMarkerSurvived).toBe(false)
})
test('keeps the zone identity node with its polygon and strips the fill mesh', () => {
const root = new THREE.Group()
const zoneGroup = new THREE.Group()
const fill = meshWithNodeMaterial(nodeMaterial())
fill.layers.set(2) // ZONE_LAYER
zoneGroup.add(fill)
zoneGroup.visible = false // the editor often hides zones at export time
root.add(zoneGroup)
const zoneId = 'zone_living'
const polygon: [number, number][] = [
[0, 0],
[4, 0],
[4, 3],
]
sceneRegistry.nodes.set(zoneId, zoneGroup)
const nodes: Record<string, AnyNode> = {
[zoneId]: {
object: 'node',
id: zoneId,
type: 'zone',
name: 'Living Room',
polygon,
color: '#ff0000',
} as unknown as AnyNode,
}
const { scene } = prepareSceneForExport(root, nodes)
const exported = scene.getObjectByProperty('name', zoneId)
expect(exported).toBeDefined()
// Forced visible so GLTFExporter's onlyVisible keeps the metadata node.
expect(exported?.visible).toBe(true)
expect(exported?.userData).toEqual({
pascalId: zoneId,
kind: 'zone',
label: 'Living Room',
polygon,
color: '#ff0000',
})
// The ZONE_LAYER fill mesh must not survive (rebuilt in /viewer instead).
let hasMesh = false
exported?.traverse((o) => {
if ((o as THREE.Mesh).isMesh) hasMesh = true
})
expect(hasMesh).toBe(false)
})
test('bakes a swing door into an open quaternion clip', () => {
const root = new THREE.Group()
const doorGroup = new THREE.Group()
+104 -10
View File
@@ -1,4 +1,11 @@
import { type AnyNode, sceneRegistry, type WindowNode } from '@pascal-app/core'
import {
type AnyNode,
getLevelDisplayName,
type LevelNode,
sceneRegistry,
type WindowNode,
type ZoneNode,
} from '@pascal-app/core'
import { poseWindowMovingParts, SCENE_LAYER } from '@pascal-app/viewer'
import * as THREE from 'three'
@@ -45,7 +52,17 @@ export function prepareSceneForExport(
const scene = source.clone(true)
const cloneByOriginal = pairClones(source, scene)
pruneNonRenderableMeshes(scene)
// Object3Ds that carry node identity — never strip these even when they sit on
// a non-scene layer. Some are metadata-only: a zone's visible fill/wall meshes
// are stripped, but its identity node stays to carry the polygon that /viewer
// reconstructs the room from.
const identityNodes = new Set<THREE.Object3D>()
for (const original of sceneRegistry.nodes.values()) {
const clone = cloneByOriginal.get(original)
if (clone) identityNodes.add(clone)
}
pruneNonRenderableMeshes(scene, identityNodes)
convertMaterials(scene)
const { clips, clipNamesByNode } = bakeAnimationClips(cloneByOriginal, nodes)
@@ -95,13 +112,16 @@ const EMPTY_GEOMETRY = new THREE.BufferGeometry()
* With children (it parents the visible frame + leaf) it keeps its node but
* loses its geometry; childless ones are removed outright.
*/
function pruneNonRenderableMeshes(root: THREE.Object3D) {
function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set<THREE.Object3D>) {
const toRemove: THREE.Object3D[] = []
root.traverse((object) => {
// Editor-only overlays (gizmos, selection handles, ground grid, zone fills)
// live off the scene layer; the editor camera renders them via extra layers
// but a thumbnail/bake only wants layer 0. Drop the whole overlay subtree.
// live off the scene layer; the editor camera shows them via extra layers
// but a thumbnail/bake only wants layer 0. Drop the whole overlay subtree
// except identity nodes, which we keep (their off-layer mesh children are
// still pruned as the traversal continues).
if (!object.layers.isEnabled(SCENE_LAYER)) {
if (identityNodes.has(object)) return
toRemove.push(object)
return
}
@@ -148,12 +168,62 @@ function convertMaterials(root: THREE.Object3D) {
const mesh = object as THREE.Mesh
if (!mesh.isMesh) return
const material = mesh.material
mesh.material = Array.isArray(material)
? material.map((m) => convertMaterial(m, cache))
: convertMaterial(material, cache)
if (Array.isArray(material)) {
mesh.material = material.map((m) => convertMaterial(m, cache))
return
}
// glTF has no BackSide — GLTFExporter renders the *front* face for any
// non-DoubleSide material, which inverts a BackSide surface (e.g. the
// ceiling underside, meant to be seen from the room). Flip the mesh winding
// so the intended face shows with the FrontSide material convertMaterial
// produces. Per-mesh geometry clone keeps shared geometry untouched.
if (
(material as { isNodeMaterial?: boolean }).isNodeMaterial &&
material.side === THREE.BackSide
) {
mesh.geometry = flipGeometryWinding(mesh.geometry)
}
mesh.material = convertMaterial(material, cache)
})
}
/**
* Reverse triangle winding and negate normals so a surface authored for
* `BackSide` reads correctly once exported as `FrontSide` (glTF can't express
* back-face-only rendering).
*/
function flipGeometryWinding(geometry: THREE.BufferGeometry): THREE.BufferGeometry {
const flipped = geometry.clone()
const index = flipped.getIndex()
if (index) {
const a = index.array
for (let i = 0; i < a.length; i += 3) {
const tmp = a[i]!
a[i] = a[i + 2]!
a[i + 2] = tmp
}
index.needsUpdate = true
} else {
for (const attribute of Object.values(flipped.attributes)) {
const { array, itemSize } = attribute
for (let i = 0; i < array.length; i += itemSize * 3) {
for (let k = 0; k < itemSize; k++) {
const tmp = array[i + k]!
array[i + k] = array[i + 2 * itemSize + k]!
array[i + 2 * itemSize + k] = tmp
}
}
attribute.needsUpdate = true
}
}
const normal = flipped.getAttribute('normal')
if (normal) {
for (let i = 0; i < normal.array.length; i++) normal.array[i] = -normal.array[i]!
normal.needsUpdate = true
}
return flipped
}
/**
* Convert a viewer NodeMaterial into the classic `MeshStandardMaterial` the
* glTF exporter understands. Classic materials pass through untouched, and the
@@ -180,9 +250,15 @@ function convertMaterial(
// rough, non-metallic surface is the faithful lit fallback.
target.roughness = typeof src.roughness === 'number' ? src.roughness : 1
target.metalness = typeof src.metalness === 'number' ? src.metalness : 0
target.transparent = material.transparent
// Only genuinely see-through surfaces stay transparent. Several viewer
// materials set `transparent: true` while fully opaque (opacity 1); exporting
// those as alphaMode=BLEND makes them render see-through with no depth write
// (e.g. the ceiling looked semi-transparent). Glass (opacity < 1) is kept.
target.transparent = material.transparent && material.opacity < 1
target.opacity = material.opacity
target.side = material.side
// BackSide is flipped to FrontSide (with the mesh winding reversed in
// convertMaterials) because glTF has no back-face-only mode.
target.side = material.side === THREE.BackSide ? THREE.FrontSide : material.side
target.alphaTest = material.alphaTest
target.depthWrite = material.depthWrite
target.depthTest = material.depthTest
@@ -368,11 +444,29 @@ function stampIdentity(
target.name = id
const extras: Record<string, unknown> = { pascalId: id, kind: node.type }
if (node.name) extras.label = node.name
// Levels carry no stored name; stamp the editor's display name ("Level 1")
// so the baked viewer's level/breadcrumb UI reads the same labels. Force the
// node visible: the bake must capture every floor regardless of the editor's
// current level mode (solo/hidden floors would otherwise be dropped by
// GLTFExporter's `onlyVisible`).
if (node.type === 'level') {
extras.label = getLevelDisplayName(node as LevelNode)
target.visible = true
}
if (node.type === 'door' || node.type === 'window') {
extras.openable = true
const clipNames = clipNamesByNode.get(id)
if (clipNames) extras.clips = clipNames
}
if (node.type === 'zone') {
// Zone fills are stripped from the bake; /viewer rebuilds the room from
// this polygon. Force the identity node visible so GLTFExporter's
// `onlyVisible` keeps it even when the editor had zones hidden at export.
const zone = node as ZoneNode
extras.polygon = zone.polygon
extras.color = zone.color
target.visible = true
}
target.userData = extras
}
}