import { useEffect, useRef, useState } from 'react' import { MeshView } from './MeshView' import type { MeshData } from './mesh/types' import { faceCount, vertexCount } from './mesh/types' import { acceptedExtensions, conversionLosses, formatForFilename, formats } from './mesh/formats' import { estimateExportSize, getMeshioVersion, initMeshio, parseMeshFile, serializeMesh, } from './mesh/meshio' import { makeSampleMesh } from './mesh/sample' import './App.css' type EngineState = 'loading' | 'ready' | 'error' function formatBytes(n: number): string { if (n < 1024) return `${n} B` if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB` return `${(n / 1024 / 1024).toFixed(1)} MB` } function App() { const [mesh, setMesh] = useState(null) const [sourceLabel, setSourceLabel] = useState('') const [baseName, setBaseName] = useState('mesh') const [parseWarnings, setParseWarnings] = useState([]) const [error, setError] = useState(null) const [exportFormatId, setExportFormatId] = useState(formats[0].id) const [engine, setEngine] = useState<{ state: EngineState; message: string }>({ state: 'loading', message: 'Loading mesh engine…', }) const [busy, setBusy] = useState<'parsing' | 'exporting' | 'sizing' | null>(null) const [exportSizes, setExportSizes] = useState | null>(null) const fileInputRef = useRef(null) const exportFormat = formats.find((f) => f.id === exportFormatId) ?? formats[0] const losses = mesh ? conversionLosses(mesh, exportFormat) : [] const engineReady = engine.state === 'ready' useEffect(() => { let cancelled = false initMeshio((message) => { if (!cancelled) setEngine({ state: 'loading', message }) }) .then(() => getMeshioVersion()) .then((version) => { if (!cancelled) setEngine({ state: 'ready', message: `meshio ${version} ready` }) }) .catch((e) => { if (!cancelled) { setEngine({ state: 'error', message: `Mesh engine failed to load: ${e instanceof Error ? e.message : String(e)}`, }) } }) return () => { cancelled = true } }, []) const handleFile = async (file: File) => { setError(null) const format = formatForFilename(file.name) if (!format) { setError( `Unrecognized extension on "${file.name}". Supported: ${acceptedExtensions.join(', ')}`, ) return } setBusy('parsing') try { const bytes = new Uint8Array(await file.arrayBuffer()) const { mesh: parsed, info } = await parseMeshFile(bytes, format) setMesh(parsed) setExportSizes(null) setParseWarnings(info.warnings) setSourceLabel(`${file.name} (${format.label})`) setBaseName(file.name.replace(/\.[^.]+$/, '')) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setBusy(null) } } const loadSample = () => { setError(null) setMesh(makeSampleMesh()) setExportSizes(null) setParseWarnings([]) setSourceLabel('built-in sample') setBaseName('rainbow_torus') } const estimateSizes = async () => { if (!mesh) return setError(null) setBusy('sizing') setExportSizes({}) try { // pure-Python formats first, so a first-time h5py download doesn't // hold up the quick results const ordered = [...formats].sort( (a, b) => (a.pyodidePackages?.length ?? 0) - (b.pyodidePackages?.length ?? 0), ) for (const format of ordered) { const size = await estimateExportSize(mesh, format) setExportSizes((prev) => ({ ...(prev ?? {}), [format.id]: size })) } } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setBusy(null) } } const downloadExport = async () => { if (!mesh) return setError(null) setBusy('exporting') try { const bytes = await serializeMesh(mesh, exportFormat) const base = baseName.replace(/[^\w-]+/g, '_').toLowerCase() || 'mesh' const blob = new Blob([bytes], { type: 'application/octet-stream' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = base + exportFormat.extension a.click() URL.revokeObjectURL(url) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setBusy(null) } } return (

Mesh Converter

Load a mesh, inspect it in 3D, export to another format. Conversion runs entirely in your browser via meshio on Pyodide.

{engine.message}

Load

{ const file = e.target.files?.[0] if (file) handleFile(file) e.target.value = '' }} /> {error &&
{error}
}
{mesh && (

Loaded mesh

{sourceLabel}
{vertexCount(mesh)} vertices, {faceCount(mesh)} faces
positions faces normals colors
{parseWarnings.length > 0 && (

{parseWarnings.join('; ')}

)}
)} {mesh && (

Export

{exportFormat.blurb}

{losses.length > 0 ? (
Exporting to {exportFormat.extension} will drop:{' '} {losses.join(', ')}
) : (
Lossless — this format keeps everything in the loaded mesh.
)}
)}

Formats

{exportSizes && } {formats.map((f) => ( setExportFormatId(f.id)} title={`Export as ${f.label}`} > {exportSizes && ( )} ))}
Format normals colorssize
{f.id.toUpperCase()} {f.extension} {f.capabilities.normals ? '✓' : '—'} {f.capabilities.colors ? '✓' : '—'} {exportSizes[f.id] != null ? formatBytes(exportSizes[f.id]) : '…'}
{mesh && ( )}

All formats store positions and triangle faces; ✓ marks the extra attributes this app preserves on export. Quads and polygons are triangulated on import. Click a row to choose the export format.

{mesh ? ( ) : (

No mesh loaded.

Open a {acceptedExtensions.join(', ')} file — or load the sample.

)}
) } export default App