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:
co-authored by
Claude Opus 4.8
parent
0a7cbd2081
commit
064be98d7f
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user