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 { 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 { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
|
|
||||||
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 * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js'
|
import { exportSceneToGlb, prepareSceneForExport } from '../../lib/glb-export'
|
||||||
import { prepareSceneForExport } from '../../lib/glb-export'
|
|
||||||
|
|
||||||
export function ExportManager() {
|
export function ExportManager() {
|
||||||
const scene = useThree((state) => state.scene)
|
const scene = useThree((state) => state.scene)
|
||||||
@@ -24,6 +22,14 @@ export function ExportManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const date = new Date().toISOString().split('T')[0]
|
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,
|
// Hide editor affordances that live on the scene layer (selection handles,
|
||||||
// ceiling/site brackets) and let wall-cutout reveal all walls — the same
|
// ceiling/site brackets) and let wall-cutout reveal all walls — the same
|
||||||
// synchronous capture path thumbnails use. We clone the scene inside the
|
// synchronous capture path thumbnails use. We clone the scene inside the
|
||||||
@@ -52,30 +58,6 @@ export function ExportManager() {
|
|||||||
downloadBlob(blob, `model_${date}.obj`)
|
downloadBlob(blob, `model_${date}.obj`)
|
||||||
return
|
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)
|
setExportScene(exportFn)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export { default as Editor } from './components/editor'
|
|||||||
// they're referenced throughout the editor's own internals; the public
|
// they're referenced throughout the editor's own internals; the public
|
||||||
// surface uses the shorter, shell-friendly names from the unified
|
// surface uses the shorter, shell-friendly names from the unified
|
||||||
// preset-system spec.
|
// preset-system spec.
|
||||||
|
export { BakeExporter } from './components/editor/bake-exporter'
|
||||||
export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu'
|
export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu'
|
||||||
// Embed surface — the editor's real in-canvas affordances, so a host can mount
|
// 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
|
// authentic selection handles, interactive build tools, and the mover on top
|
||||||
@@ -217,6 +218,7 @@ export {
|
|||||||
resolveCeilingPlanPointSnap,
|
resolveCeilingPlanPointSnap,
|
||||||
} from './lib/ceiling-plan-snap'
|
} from './lib/ceiling-plan-snap'
|
||||||
export { EDITOR_LAYER } from './lib/constants'
|
export { EDITOR_LAYER } from './lib/constants'
|
||||||
|
export { exportSceneToGlb } from './lib/glb-export'
|
||||||
// Helper libs used by the kind-owned roof / stair / elevator panels.
|
// Helper libs used by the kind-owned roof / stair / elevator panels.
|
||||||
export {
|
export {
|
||||||
resolveCurrentBuildingId,
|
resolveCurrentBuildingId,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
|
emitter,
|
||||||
getLevelDisplayName,
|
getLevelDisplayName,
|
||||||
type LevelNode,
|
type LevelNode,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
@@ -7,7 +8,10 @@ import {
|
|||||||
type ZoneNode,
|
type ZoneNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { poseWindowMovingParts, SCENE_LAYER } from '@pascal-app/viewer'
|
import { poseWindowMovingParts, SCENE_LAYER } from '@pascal-app/viewer'
|
||||||
|
import type { Object3D } from 'three'
|
||||||
import * as THREE 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
|
* Two TRS samples (closed vs open) differing by less than this are treated as
|
||||||
@@ -27,6 +31,40 @@ export type GlbExport = {
|
|||||||
animations: THREE.AnimationClip[]
|
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
|
* 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
|
* a standalone three.js scene plus glTF animation clips, ready for
|
||||||
|
|||||||
Reference in New Issue
Block a user