feat(export): reusable exportSceneToGlb + BakeExporter for headless bake

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>
This commit is contained in:
Wassim SAMAD
2026-06-23 09:52:14 -04:00
co-authored by Claude Opus 4.8
parent 0a7cbd2081
commit 064be98d7f
4 changed files with 84 additions and 27 deletions
@@ -0,0 +1,35 @@
'use client'
import { useScene } from '@pascal-app/core'
import { useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import { exportSceneToGlb } from '../../lib/glb-export'
export function BakeExporter({
active,
onComplete,
onError,
}: {
active: boolean
onComplete: (buffer: ArrayBuffer) => void
onError: (message: string) => void
}) {
const scene = useThree((s) => s.scene)
const doneRef = useRef(false)
useEffect(() => {
if (!(active && !doneRef.current)) return
doneRef.current = true
const run = async () => {
try {
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)
} catch (err) {
onError(err instanceof Error ? err.message : String(err))
}
}
void run()
}, [active, scene, onComplete, onError])
return null
}
@@ -4,11 +4,9 @@ import { emitter, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
import { useEffect } from 'react'
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js'
import { prepareSceneForExport } from '../../lib/glb-export'
import { exportSceneToGlb, prepareSceneForExport } from '../../lib/glb-export'
export function ExportManager() {
const scene = useThree((state) => state.scene)
@@ -24,6 +22,14 @@ export function ExportManager() {
}
const date = new Date().toISOString().split('T')[0]
if (format === 'glb') {
const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes)
const blob = new Blob([buffer], { type: 'model/gltf-binary' })
downloadBlob(blob, `model_${date}.glb`)
return
}
// Hide editor affordances that live on the scene layer (selection handles,
// ceiling/site brackets) and let wall-cutout reveal all walls — the same
// synchronous capture path thumbnails use. We clone the scene inside the
@@ -52,30 +58,6 @@ export function ExportManager() {
downloadBlob(blob, `model_${date}.obj`)
return
}
// Default: GLB export with baked identity + door/window animation clips.
const exporter = new GLTFExporter()
// Painted finishes use KTX2 (GPU-compressed) maps; GLTFExporter can't read
// those directly. WebGPUTextureUtils blits each one to RGBA on its own
// offscreen renderer (passing the live renderer would resize/draw over the
// editor canvas), letting the exporter embed standard textures.
exporter.setTextureUtils(WebGPUTextureUtils)
return new Promise<void>((resolve, reject) => {
exporter.parse(
exportScene,
(gltf) => {
const blob = new Blob([gltf as ArrayBuffer], { type: 'model/gltf-binary' })
downloadBlob(blob, `model_${date}.glb`)
resolve()
},
(error) => {
console.error('Export error:', error)
reject(error)
},
{ binary: true, animations },
)
})
}
setExportScene(exportFn)
+2
View File
@@ -11,6 +11,7 @@ export { default as Editor } from './components/editor'
// they're referenced throughout the editor's own internals; the public
// surface uses the shorter, shell-friendly names from the unified
// preset-system spec.
export { BakeExporter } from './components/editor/bake-exporter'
export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu'
// Embed surface — the editor's real in-canvas affordances, so a host can mount
// authentic selection handles, interactive build tools, and the mover on top
@@ -217,6 +218,7 @@ export {
resolveCeilingPlanPointSnap,
} from './lib/ceiling-plan-snap'
export { EDITOR_LAYER } from './lib/constants'
export { exportSceneToGlb } from './lib/glb-export'
// Helper libs used by the kind-owned roof / stair / elevator panels.
export {
resolveCurrentBuildingId,
+38
View File
@@ -1,5 +1,6 @@
import {
type AnyNode,
emitter,
getLevelDisplayName,
type LevelNode,
sceneRegistry,
@@ -7,7 +8,10 @@ import {
type ZoneNode,
} from '@pascal-app/core'
import { poseWindowMovingParts, SCENE_LAYER } from '@pascal-app/viewer'
import type { Object3D } from 'three'
import * as THREE from 'three'
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js'
/**
* Two TRS samples (closed vs open) differing by less than this are treated as
@@ -27,6 +31,40 @@ export type GlbExport = {
animations: THREE.AnimationClip[]
}
export async function exportSceneToGlb(
sceneGroup: Object3D,
nodes: Record<string, AnyNode>,
): Promise<ArrayBuffer> {
emitter.emit('thumbnail:before-capture', undefined)
let prepared: ReturnType<typeof prepareSceneForExport>
try {
prepared = prepareSceneForExport(sceneGroup, nodes)
} finally {
emitter.emit('thumbnail:after-capture', undefined)
}
const { scene: exportScene, animations } = prepared
const exporter = new GLTFExporter()
// Painted finishes use KTX2 (GPU-compressed) maps; GLTFExporter can't read
// those directly. WebGPUTextureUtils blits each one to RGBA on its own
// offscreen renderer (passing the live renderer would resize/draw over the
// editor canvas), letting the exporter embed standard textures.
exporter.setTextureUtils(WebGPUTextureUtils)
return new Promise<ArrayBuffer>((resolve, reject) => {
exporter.parse(
exportScene,
(gltf) => {
resolve(gltf as ArrayBuffer)
},
(error) => {
reject(error)
},
{ binary: true, animations },
)
})
}
/**
* Build an engine-agnostic export tree from the live scene graph. The result is
* a standalone three.js scene plus glTF animation clips, ready for