fix(export): unbreak bakes and STL/OBJ exports on malformed/placeholder meshes (#466)

- roof-system: replace BoxGeometry placeholders (initial swap + empty-roof
  merged shell) with a group-less degenerate geometry — a Box's 6 groups
  against the roof's 4-material array crashed GLTFExporter on every scene
  with a segment-less roof node (prod: 'reading isShaderMaterial'), and
  count-0 groups crash MeshBVH's packed-tree build.
- glb-export: sanitizeMaterialGroups pass repairs any mesh whose geometry
  groups don't line up with its material array before GLTFExporter runs.
- export-manager: give neutralised (attribute-less) meshes an empty position
  attribute before STL/OBJ export — both exporters read position.count
  unconditionally.
- bake-exporter: log the full error stack so the bake worker's console relay
  captures it in the job's error trail.
- viewer: host-controlled disablePostFx prop (Viewer → PostProcessing) that
  skips building the SSGI/denoise pipeline entirely; the ?disable=postFx URL
  flag previously only bypassed it per-frame while still allocating it.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-07-06 16:07:08 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 9b5e229770
commit f6ffb93a39
6 changed files with 151 additions and 28 deletions
@@ -41,6 +41,10 @@ export function BakeExporter({
const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes)
onComplete(buffer)
} catch (err) {
// The bake worker relays page console output into the job's error
// trail; the message alone rarely localises an exporter crash, so
// surface the full stack here.
console.error('[bake-exporter]', err instanceof Error ? (err.stack ?? err.message) : err)
onError(err instanceof Error ? err.message : String(err))
} finally {
useViewer.getState().setExporting(false)
@@ -4,10 +4,33 @@ import { emitter, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
import { useEffect } from 'react'
import * as THREE from 'three'
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
import { exportSceneToGlb, prepareSceneForExport } from '../../lib/glb-export'
// prepareSceneForExport neutralises container meshes (door/window hitbox roots,
// material-less renderables) with an attribute-less geometry — GLTFExporter
// emits those as plain transform nodes, but STL/OBJExporter read
// `position.count` unconditionally and crash. Swap in a geometry with an empty
// (count-0) position so they iterate zero vertices instead. Shared: the export
// scene is a throwaway clone, only its geometry *ref* is swapped.
const EMPTY_POSITION_GEOMETRY = new THREE.BufferGeometry()
EMPTY_POSITION_GEOMETRY.setAttribute(
'position',
new THREE.Float32BufferAttribute(new Float32Array(0), 3),
)
function ensurePositionAttributes(root: THREE.Object3D) {
root.traverse((object) => {
const renderable = object as THREE.Mesh & { isLine?: boolean; isPoints?: boolean }
if (!(renderable.isMesh || renderable.isLine || renderable.isPoints)) return
if (!renderable.geometry?.getAttribute('position')) {
renderable.geometry = EMPTY_POSITION_GEOMETRY
}
})
}
export function ExportManager() {
const scene = useThree((state) => state.scene)
const setExportScene = useViewer((state) => state.setExportScene)
@@ -41,7 +64,8 @@ export function ExportManager() {
} finally {
emitter.emit('thumbnail:after-capture', undefined)
}
const { scene: exportScene, animations } = prepared
const { scene: exportScene } = prepared
ensurePositionAttributes(exportScene)
if (format === 'stl') {
const exporter = new STLExporter()
+63
View File
@@ -130,6 +130,7 @@ export function prepareSceneForExport(
}
pruneNonRenderableMeshes(scene, identityNodes)
sanitizeMaterialGroups(scene, identityNodes)
convertMaterials(scene)
const { clips, clipNamesByNode } = bakeAnimationClips(cloneByOriginal, nodes)
@@ -229,6 +230,68 @@ function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set<THREE
}
}
/**
* Repair meshes whose geometry groups don't line up with their material array —
* GLTFExporter reads `materials[group.materialIndex]` per group and crashes on
* undefined (`reading 'isShaderMaterial'`). The known producer is a roof
* placeholder (BoxGeometry's 6 groups vs the 4 roof materials), but any
* system/CSG output can end up here, so repair generically:
* - groups indexing past the array (or drawing zero triangles) are dropped;
* - null slots referenced by surviving groups get the hidden placeholder;
* - a mesh left with no drawable group — including an array-material mesh
* with no groups at all (three draws nothing for those, e.g. the roof
* system's degenerate placeholder) — is neutralised like other
* non-renderables (kept as a bare transform node, or removed if a leaf
* that carries no node identity).
* Geometry/material refs are shared with the live scene (`clone(true)` is
* shallow for both), so repairs swap refs instead of mutating in place.
*/
function sanitizeMaterialGroups(root: THREE.Object3D, identityNodes: Set<THREE.Object3D>) {
const toRemove: THREE.Object3D[] = []
root.traverse((object) => {
const mesh = object as THREE.Mesh
if (!mesh.isMesh || !Array.isArray(mesh.material)) return
const materials = mesh.material
const groups = mesh.geometry.groups
const broken =
groups.length === 0 ||
groups.some((g) => (g.materialIndex ?? 0) >= materials.length || g.count === 0) ||
materials.some((m) => m == null)
if (!broken) return
const validGroups = groups.filter(
(g) => (g.materialIndex ?? 0) < materials.length && g.count !== 0,
)
if (validGroups.length === 0) {
if (mesh.children.length > 0 || identityNodes.has(mesh)) {
mesh.geometry = EMPTY_GEOMETRY
mesh.material = PLACEHOLDER_MATERIAL
} else {
toRemove.push(mesh)
}
return
}
// Only the group list needs repair — share the attribute/index refs
// instead of geometry.clone(), which deep-copies every vertex buffer.
if (validGroups.length !== groups.length) {
const geometry = new THREE.BufferGeometry()
geometry.index = mesh.geometry.index
for (const [name, attribute] of Object.entries(mesh.geometry.attributes)) {
geometry.setAttribute(name, attribute)
}
geometry.morphAttributes = mesh.geometry.morphAttributes
geometry.morphTargetsRelative = mesh.geometry.morphTargetsRelative
geometry.setDrawRange(mesh.geometry.drawRange.start, mesh.geometry.drawRange.count)
geometry.groups = validGroups.map((g) => ({ ...g }))
mesh.geometry = geometry
}
mesh.material = materials.map((m) => m ?? PLACEHOLDER_MATERIAL)
})
for (const object of toRemove) {
object.removeFromParent()
}
}
function isRenderableMesh(mesh: THREE.Mesh): boolean {
const position = mesh.geometry?.getAttribute('position')
if (!position || position.count === 0) return false