feat: add STL and OBJ export formats (#175)
Extend the export manager to support STL and OBJ formats in addition to the existing GLB export. Uses Three.js built-in STLExporter and OBJExporter - no new dependencies. STL is the standard format for 3D printing. OBJ is widely used in rendering pipelines and supports basic materials. Both are requested in the community discussion on #145. Changes: - export-manager.tsx: add STLExporter and OBJExporter, accept format param - settings-panel: three format-specific export buttons (GLB, STL, OBJ) - use-viewer store: update exportScene type to accept optional format The format parameter defaults to 'glb', so existing callers (command palette) continue to work without changes. Relates to #145 Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Matt Van Horn
Claude Opus 4.6
parent
7d4c8b6bd4
commit
a5378da020
@@ -4,13 +4,15 @@ 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 { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
|
||||
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
|
||||
|
||||
export function ExportManager() {
|
||||
const scene = useThree((state) => state.scene)
|
||||
const setExportScene = useViewer((state) => state.setExportScene)
|
||||
|
||||
useEffect(() => {
|
||||
const exportFn = async () => {
|
||||
const exportFn = async (format: 'glb' | 'stl' | 'obj' = 'glb') => {
|
||||
// Find the scene renderer group by name
|
||||
const sceneGroup = scene.getObjectByName('scene-renderer')
|
||||
if (!sceneGroup) {
|
||||
@@ -18,20 +20,33 @@ export function ExportManager() {
|
||||
return
|
||||
}
|
||||
|
||||
const exporter = new GLTFExporter()
|
||||
const date = new Date().toISOString().split('T')[0]
|
||||
|
||||
if (format === 'stl') {
|
||||
const exporter = new STLExporter()
|
||||
const result = exporter.parse(sceneGroup, { binary: true })
|
||||
const blob = new Blob([result], { type: 'model/stl' })
|
||||
downloadBlob(blob, `model_${date}.stl`)
|
||||
return
|
||||
}
|
||||
|
||||
if (format === 'obj') {
|
||||
const exporter = new OBJExporter()
|
||||
const result = exporter.parse(sceneGroup)
|
||||
const blob = new Blob([result], { type: 'model/obj' })
|
||||
downloadBlob(blob, `model_${date}.obj`)
|
||||
return
|
||||
}
|
||||
|
||||
// Default: GLB export (existing behavior)
|
||||
const exporter = new GLTFExporter()
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
exporter.parse(
|
||||
sceneGroup,
|
||||
(gltf) => {
|
||||
const blob = new Blob([gltf as ArrayBuffer], { type: 'model/gltf-binary' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `model_${date}.glb`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
downloadBlob(blob, `model_${date}.glb`)
|
||||
resolve()
|
||||
},
|
||||
(error) => {
|
||||
@@ -52,3 +67,12 @@ export function ExportManager() {
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
@@ -202,9 +202,9 @@ export function SettingsPanel({
|
||||
|
||||
const isLocalProject = false // Props-based; only show cloud sections when projectId provided
|
||||
|
||||
const handleExport = async () => {
|
||||
const handleExport = async (format: 'glb' | 'stl' | 'obj' = 'glb') => {
|
||||
if (exportScene) {
|
||||
await exportScene()
|
||||
await exportScene(format)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,9 +318,17 @@ export function SettingsPanel({
|
||||
{/* Export Section */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">Export</label>
|
||||
<Button className="w-full justify-start gap-2" onClick={handleExport} variant="outline">
|
||||
<Button className="w-full justify-start gap-2" onClick={() => handleExport('glb')} variant="outline">
|
||||
<Download className="size-4" />
|
||||
Export 3D Model
|
||||
Export as GLB
|
||||
</Button>
|
||||
<Button className="w-full justify-start gap-2" onClick={() => handleExport('stl')} variant="outline">
|
||||
<Download className="size-4" />
|
||||
Export as STL
|
||||
</Button>
|
||||
<Button className="w-full justify-start gap-2" onClick={() => handleExport('obj')} variant="outline">
|
||||
<Download className="size-4" />
|
||||
Export as OBJ
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
+2
-2
@@ -27,8 +27,8 @@ type ViewerState = {
|
||||
setSelection: (updates: Partial<SelectionPath>) => void
|
||||
resetSelection: () => void
|
||||
outliner: Outliner
|
||||
exportScene: (() => Promise<void>) | null
|
||||
setExportScene: (fn: (() => Promise<void>) | null) => void
|
||||
exportScene: ((format?: 'glb' | 'stl' | 'obj') => Promise<void>) | null
|
||||
setExportScene: (fn: ((format?: 'glb' | 'stl' | 'obj') => Promise<void>) | null) => void
|
||||
}
|
||||
declare const useViewer: import('zustand').UseBoundStore<import('zustand').StoreApi<ViewerState>>
|
||||
export default useViewer
|
||||
|
||||
@@ -61,8 +61,8 @@ type ViewerState = {
|
||||
outliner: Outliner // No setter as we will manipulate directly the arrays
|
||||
|
||||
// Export functionality
|
||||
exportScene: (() => Promise<void>) | null
|
||||
setExportScene: (fn: (() => Promise<void>) | null) => void
|
||||
exportScene: ((format?: 'glb' | 'stl' | 'obj') => Promise<void>) | null
|
||||
setExportScene: (fn: ((format?: 'glb' | 'stl' | 'obj') => Promise<void>) | null) => void
|
||||
|
||||
debugColors: boolean
|
||||
setDebugColors: (enabled: boolean) => void
|
||||
|
||||
Reference in New Issue
Block a user