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:
Matt Van Horn
2026-03-26 00:30:34 -04:00
committed by GitHub
co-authored by Matt Van Horn Claude Opus 4.6
parent 7d4c8b6bd4
commit a5378da020
4 changed files with 48 additions and 16 deletions
@@ -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>