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:
co-authored by
Claude Fable 5
parent
9b5e229770
commit
f6ffb93a39
@@ -41,6 +41,10 @@ export function BakeExporter({
|
|||||||
const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes)
|
const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes)
|
||||||
onComplete(buffer)
|
onComplete(buffer)
|
||||||
} catch (err) {
|
} 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))
|
onError(err instanceof Error ? err.message : String(err))
|
||||||
} finally {
|
} finally {
|
||||||
useViewer.getState().setExporting(false)
|
useViewer.getState().setExporting(false)
|
||||||
|
|||||||
@@ -4,10 +4,33 @@ import { emitter, useScene } from '@pascal-app/core'
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useThree } from '@react-three/fiber'
|
import { useThree } from '@react-three/fiber'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
|
import * as THREE from 'three'
|
||||||
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
|
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
|
||||||
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
|
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
|
||||||
import { exportSceneToGlb, prepareSceneForExport } from '../../lib/glb-export'
|
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() {
|
export function ExportManager() {
|
||||||
const scene = useThree((state) => state.scene)
|
const scene = useThree((state) => state.scene)
|
||||||
const setExportScene = useViewer((state) => state.setExportScene)
|
const setExportScene = useViewer((state) => state.setExportScene)
|
||||||
@@ -41,7 +64,8 @@ export function ExportManager() {
|
|||||||
} finally {
|
} finally {
|
||||||
emitter.emit('thumbnail:after-capture', undefined)
|
emitter.emit('thumbnail:after-capture', undefined)
|
||||||
}
|
}
|
||||||
const { scene: exportScene, animations } = prepared
|
const { scene: exportScene } = prepared
|
||||||
|
ensurePositionAttributes(exportScene)
|
||||||
|
|
||||||
if (format === 'stl') {
|
if (format === 'stl') {
|
||||||
const exporter = new STLExporter()
|
const exporter = new STLExporter()
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ export function prepareSceneForExport(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pruneNonRenderableMeshes(scene, identityNodes)
|
pruneNonRenderableMeshes(scene, identityNodes)
|
||||||
|
sanitizeMaterialGroups(scene, identityNodes)
|
||||||
convertMaterials(scene)
|
convertMaterials(scene)
|
||||||
|
|
||||||
const { clips, clipNamesByNode } = bakeAnimationClips(cloneByOriginal, nodes)
|
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 {
|
function isRenderableMesh(mesh: THREE.Mesh): boolean {
|
||||||
const position = mesh.geometry?.getAttribute('position')
|
const position = mesh.geometry?.getAttribute('position')
|
||||||
if (!position || position.count === 0) return false
|
if (!position || position.count === 0) return false
|
||||||
|
|||||||
@@ -345,6 +345,14 @@ interface ViewerProps {
|
|||||||
*/
|
*/
|
||||||
sceneReadyKey?: string | number | null
|
sceneReadyKey?: string | number | null
|
||||||
onSceneReadyChange?: (ready: boolean) => void
|
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>`. */
|
/** Imperative handle exposed via `ref` on `<Viewer>`. */
|
||||||
@@ -371,6 +379,7 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
|
|||||||
isolate,
|
isolate,
|
||||||
sceneReadyKey,
|
sceneReadyKey,
|
||||||
onSceneReadyChange,
|
onSceneReadyChange,
|
||||||
|
disablePostFx = false,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
@@ -549,7 +558,7 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
|
|||||||
kind's `def.system` is loaded via lazy() and rendered here,
|
kind's `def.system` is loaded via lazy() and rendered here,
|
||||||
ordered by `system.priority`. */}
|
ordered by `system.priority`. */}
|
||||||
<RegisteredSystems />
|
<RegisteredSystems />
|
||||||
<PostProcessing hoverStyles={hoverStyles} />
|
<PostProcessing disablePostFx={disablePostFx} hoverStyles={hoverStyles} />
|
||||||
{selectionManager === 'default' && <SelectionManager />}
|
{selectionManager === 'default' && <SelectionManager />}
|
||||||
{(perf || PERF_OVERLAY_ENABLED) && <PerfMonitor />}
|
{(perf || PERF_OVERLAY_ENABLED) && <PerfMonitor />}
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -126,8 +126,11 @@ function sanitizeOutlineObjects(objects: Object3D[]) {
|
|||||||
|
|
||||||
const PostProcessingPasses = ({
|
const PostProcessingPasses = ({
|
||||||
hoverStyles = DEFAULT_HOVER_STYLES,
|
hoverStyles = DEFAULT_HOVER_STYLES,
|
||||||
|
disablePostFx = false,
|
||||||
}: {
|
}: {
|
||||||
hoverStyles?: HoverStyles
|
hoverStyles?: HoverStyles
|
||||||
|
/** Host-controlled equivalent of `?disable=postFx` — see the Viewer prop. */
|
||||||
|
disablePostFx?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
const { gl: renderer, invalidate, scene, camera, size } = useThree()
|
const { gl: renderer, invalidate, scene, camera, size } = useThree()
|
||||||
const renderPipelineRef = useRef<RenderPipeline | null>(null)
|
const renderPipelineRef = useRef<RenderPipeline | null>(null)
|
||||||
@@ -264,6 +267,19 @@ const PostProcessingPasses = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const perfDisable = readPerfDisableFlags()
|
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 ssgiEnabled = shading === 'rendered' && SSGI_PARAMS.enabled && !perfDisable.ao
|
||||||
const denoiseEnabled = ssgiEnabled && !perfDisable.denoise
|
const denoiseEnabled = ssgiEnabled && !perfDisable.denoise
|
||||||
const outlineEnabled = !perfDisable.outline
|
const outlineEnabled = !perfDisable.outline
|
||||||
@@ -521,6 +537,7 @@ const PostProcessingPasses = ({
|
|||||||
// whole pipeline. The uniform refs below are stable (useMemo), so they
|
// whole pipeline. The uniform refs below are stable (useMemo), so they
|
||||||
// never trigger a rebuild either.
|
// never trigger a rebuild either.
|
||||||
camera,
|
camera,
|
||||||
|
disablePostFx,
|
||||||
hoverHiddenColor,
|
hoverHiddenColor,
|
||||||
hoverPulseMix,
|
hoverPulseMix,
|
||||||
hoverStrength,
|
hoverStrength,
|
||||||
@@ -556,7 +573,12 @@ const PostProcessingPasses = ({
|
|||||||
sanitizeOutlineObjects(outliner.selectedObjects)
|
sanitizeOutlineObjects(outliner.selectedObjects)
|
||||||
sanitizeOutlineObjects(outliner.hoveredObjects)
|
sanitizeOutlineObjects(outliner.hoveredObjects)
|
||||||
|
|
||||||
if (PERF_POST_FX_DISABLED || hasPipelineErrorRef.current || !renderPipelineRef.current) {
|
if (
|
||||||
|
disablePostFx ||
|
||||||
|
PERF_POST_FX_DISABLED ||
|
||||||
|
hasPipelineErrorRef.current ||
|
||||||
|
!renderPipelineRef.current
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
if ((renderer as any).setClearAlpha) {
|
if ((renderer as any).setClearAlpha) {
|
||||||
;(renderer as any).setClearAlpha(transparentBackground ? 0 : 1)
|
;(renderer as any).setClearAlpha(transparentBackground ? 0 : 1)
|
||||||
|
|||||||
@@ -125,6 +125,28 @@ const _surfaceV1 = new THREE.Vector3()
|
|||||||
const _surfaceV2 = new THREE.Vector3()
|
const _surfaceV2 = new THREE.Vector3()
|
||||||
const _surfaceFaceNormal = 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)
|
// Pending merged-roof updates carried across frames (for throttling)
|
||||||
const pendingRoofUpdates = new Set<AnyNodeId>()
|
const pendingRoofUpdates = new Set<AnyNodeId>()
|
||||||
const warnedMergedRoofNaNIds = new Set<AnyNodeId>()
|
const warnedMergedRoofNaNIds = new Set<AnyNodeId>()
|
||||||
@@ -219,29 +241,7 @@ export const RoofSystem = () => {
|
|||||||
// so MeshBVH hits groups[4].materialIndex → undefined.side → crash.
|
// so MeshBVH hits groups[4].materialIndex → undefined.side → crash.
|
||||||
if (mesh.geometry.type === 'BoxGeometry') {
|
if (mesh.geometry.type === 'BoxGeometry') {
|
||||||
mesh.geometry.dispose()
|
mesh.geometry.dispose()
|
||||||
const placeholder = new THREE.BufferGeometry()
|
mesh.geometry = createDegenerateRoofPlaceholder()
|
||||||
// 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.position.set(
|
mesh.position.set(
|
||||||
effectiveSegment.position[0],
|
effectiveSegment.position[0],
|
||||||
@@ -497,8 +497,9 @@ function updateMergedRoofGeometry(
|
|||||||
|
|
||||||
if (children.length === 0) {
|
if (children.length === 0) {
|
||||||
mergedMesh.geometry.dispose()
|
mergedMesh.geometry.dispose()
|
||||||
// Keep a valid position attribute so Drei's BVH can index safely.
|
// Not BoxGeometry: its 6 groups against the merged mesh's 4-material array
|
||||||
mergedMesh.geometry = new THREE.BoxGeometry(0, 0, 0)
|
// crash GLTFExporter (materials[4] → undefined) when the roof bakes.
|
||||||
|
mergedMesh.geometry = createDegenerateRoofPlaceholder()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user