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
@@ -345,6 +345,14 @@ interface ViewerProps {
*/
sceneReadyKey?: string | number | null
onSceneReadyChange?: (ready: boolean) => void
/**
* Skip the TSL post-processing pipeline (SSGI/denoise/ink/outline) and render
* the scene directly. For headless/capture surfaces (the bake page) where
* frame quality is irrelevant: on a software-rasterised worker the pipeline
* consumes the whole CPU budget and bakes time out. Equivalent to the
* `?disable=postFx` diagnostic URL flag, but host-controlled.
*/
disablePostFx?: boolean
}
/** Imperative handle exposed via `ref` on `<Viewer>`. */
@@ -371,6 +379,7 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
isolate,
sceneReadyKey,
onSceneReadyChange,
disablePostFx = false,
},
ref,
) {
@@ -549,7 +558,7 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
kind's `def.system` is loaded via lazy() and rendered here,
ordered by `system.priority`. */}
<RegisteredSystems />
<PostProcessing hoverStyles={hoverStyles} />
<PostProcessing disablePostFx={disablePostFx} hoverStyles={hoverStyles} />
{selectionManager === 'default' && <SelectionManager />}
{(perf || PERF_OVERLAY_ENABLED) && <PerfMonitor />}
{children}
@@ -126,8 +126,11 @@ function sanitizeOutlineObjects(objects: Object3D[]) {
const PostProcessingPasses = ({
hoverStyles = DEFAULT_HOVER_STYLES,
disablePostFx = false,
}: {
hoverStyles?: HoverStyles
/** Host-controlled equivalent of `?disable=postFx` — see the Viewer prop. */
disablePostFx?: boolean
}) => {
const { gl: renderer, invalidate, scene, camera, size } = useThree()
const renderPipelineRef = useRef<RenderPipeline | null>(null)
@@ -264,6 +267,19 @@ const PostProcessingPasses = ({
}
const perfDisable = readPerfDisableFlags()
// postFx off (host prop or ?disable=postFx): never allocate the pipeline —
// useFrame's null-pipeline branch direct-renders. Before this check the
// URL flag only skipped the pipeline at render time; the build still
// allocated every pass.
if (disablePostFx || perfDisable.postFx) {
hasPipelineErrorRef.current = false
if (renderPipelineRef.current) {
renderPipelineRef.current.dispose()
}
renderPipelineRef.current = null
return
}
const ssgiEnabled = shading === 'rendered' && SSGI_PARAMS.enabled && !perfDisable.ao
const denoiseEnabled = ssgiEnabled && !perfDisable.denoise
const outlineEnabled = !perfDisable.outline
@@ -521,6 +537,7 @@ const PostProcessingPasses = ({
// whole pipeline. The uniform refs below are stable (useMemo), so they
// never trigger a rebuild either.
camera,
disablePostFx,
hoverHiddenColor,
hoverPulseMix,
hoverStrength,
@@ -556,7 +573,12 @@ const PostProcessingPasses = ({
sanitizeOutlineObjects(outliner.selectedObjects)
sanitizeOutlineObjects(outliner.hoveredObjects)
if (PERF_POST_FX_DISABLED || hasPipelineErrorRef.current || !renderPipelineRef.current) {
if (
disablePostFx ||
PERF_POST_FX_DISABLED ||
hasPipelineErrorRef.current ||
!renderPipelineRef.current
) {
try {
if ((renderer as any).setClearAlpha) {
;(renderer as any).setClearAlpha(transparentBackground ? 0 : 1)
@@ -125,6 +125,28 @@ const _surfaceV1 = new THREE.Vector3()
const _surfaceV2 = new THREE.Vector3()
const _surfaceFaceNormal = new THREE.Vector3()
/**
* Degenerate placeholder for a roof mesh with nothing to draw (initial
* BoxGeometry swap-out, or a roof whose segments were all deleted/painted).
* Three zero-vertices (one invisible triangle), not an empty attribute: an
* empty position (count 0) leaves WebGPU vertex buffer slot 0 unbound if the
* mesh is ever drawn, and computeBoundsTree needs a real position buffer to
* index. Deliberately NO groups: count-0 groups crash MeshBVH's packed-tree
* build (it partitions roots by group), and a BoxGeometry's 6 groups against
* the 4 roof materials crash raycasts and GLTFExporter. Group-less + a
* zero-area triangle is safe everywhere — it draws nothing under an array
* material and can never be ray-hit.
*/
function createDegenerateRoofPlaceholder(): THREE.BufferGeometry {
const placeholder = new THREE.BufferGeometry()
placeholder.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
placeholder.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
placeholder.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2))
placeholder.setAttribute('uv2', new THREE.Float32BufferAttribute(new Float32Array(6), 2))
computeGeometryBoundsTree(placeholder)
return placeholder
}
// Pending merged-roof updates carried across frames (for throttling)
const pendingRoofUpdates = new Set<AnyNodeId>()
const warnedMergedRoofNaNIds = new Set<AnyNodeId>()
@@ -219,29 +241,7 @@ export const RoofSystem = () => {
// so MeshBVH hits groups[4].materialIndex → undefined.side → crash.
if (mesh.geometry.type === 'BoxGeometry') {
mesh.geometry.dispose()
const placeholder = new THREE.BufferGeometry()
// Three zero-vertices (one degenerate, invisible triangle), not an
// empty attribute: an empty position (count 0) leaves WebGPU vertex
// buffer slot 0 unbound if the mesh is ever drawn, and computeBoundsTree
// needs a real position buffer to index.
placeholder.setAttribute(
'position',
new THREE.Float32BufferAttribute(new Float32Array(9), 3),
)
placeholder.setAttribute(
'normal',
new THREE.Float32BufferAttribute(new Float32Array(9), 3),
)
placeholder.setAttribute(
'uv',
new THREE.Float32BufferAttribute(new Float32Array(6), 2),
)
placeholder.setAttribute(
'uv2',
new THREE.Float32BufferAttribute(new Float32Array(6), 2),
)
computeGeometryBoundsTree(placeholder)
mesh.geometry = placeholder
mesh.geometry = createDegenerateRoofPlaceholder()
}
mesh.position.set(
effectiveSegment.position[0],
@@ -497,8 +497,9 @@ function updateMergedRoofGeometry(
if (children.length === 0) {
mergedMesh.geometry.dispose()
// Keep a valid position attribute so Drei's BVH can index safely.
mergedMesh.geometry = new THREE.BoxGeometry(0, 0, 0)
// Not BoxGeometry: its 6 groups against the merged mesh's 4-material array
// crash GLTFExporter (materials[4] → undefined) when the roof bakes.
mergedMesh.geometry = createDegenerateRoofPlaceholder()
return
}