1import { useEffect, useRef, useState } from 'react'
2import { MeshView } from './MeshView'
3import type { MeshData } from './mesh/types'
4import { faceCount, vertexCount } from './mesh/types'
5import { acceptedExtensions, conversionLosses, formatForFilename, formats } from './mesh/formats'
6import {
7 estimateExportSize,
8 getMeshioVersion,
9 initMeshio,
10 parseMeshFile,
11 serializeMesh,
12} from './mesh/meshio'
13import { makeSampleMesh } from './mesh/sample'
14import './App.css'
16type EngineState = 'loading' | 'ready' | 'error'
18function formatBytes(n: number): string {
19 if (n < 1024) return `${n} B`
20 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
21 return `${(n / 1024 / 1024).toFixed(1)} MB`
22}
24function App() {
25 const [mesh, setMesh] = useState<MeshData | null>(null)
26 const [sourceLabel, setSourceLabel] = useState<string>('')
27 const [baseName, setBaseName] = useState<string>('mesh')
28 const [parseWarnings, setParseWarnings] = useState<string[]>([])
29 const [error, setError] = useState<string | null>(null)
30 const [exportFormatId, setExportFormatId] = useState(formats[0].id)
31 const [engine, setEngine] = useState<{ state: EngineState; message: string }>({
32 state: 'loading',
33 message: 'Loading mesh engine…',
34 })
35 const [busy, setBusy] = useState<'parsing' | 'exporting' | 'sizing' | null>(null)
36 const [exportSizes, setExportSizes] = useState<Record<string, number> | null>(null)
37 const fileInputRef = useRef<HTMLInputElement>(null)
39 const exportFormat = formats.find((f) => f.id === exportFormatId) ?? formats[0]
40 const losses = mesh ? conversionLosses(mesh, exportFormat) : []
41 const engineReady = engine.state === 'ready'
43 useEffect(() => {
44 let cancelled = false
45 initMeshio((message) => {
46 if (!cancelled) setEngine({ state: 'loading', message })
47 })
48 .then(() => getMeshioVersion())
49 .then((version) => {
50 if (!cancelled) setEngine({ state: 'ready', message: `meshio ${version} ready` })
51 })
52 .catch((e) => {
53 if (!cancelled) {
54 setEngine({
55 state: 'error',
56 message: `Mesh engine failed to load: ${e instanceof Error ? e.message : String(e)}`,
57 })
58 }
59 })
60 return () => {
61 cancelled = true
62 }
63 }, [])
65 const handleFile = async (file: File) => {
66 setError(null)
67 const format = formatForFilename(file.name)
68 if (!format) {
69 setError(
70 `Unrecognized extension on "${file.name}". Supported: ${acceptedExtensions.join(', ')}`,
71 )
72 return
73 }
74 setBusy('parsing')
75 try {
76 const bytes = new Uint8Array(await file.arrayBuffer())
77 const { mesh: parsed, info } = await parseMeshFile(bytes, format)
78 setMesh(parsed)
79 setExportSizes(null)
80 setParseWarnings(info.warnings)
81 setSourceLabel(`${file.name} (${format.label})`)
82 setBaseName(file.name.replace(/\.[^.]+$/, ''))
83 } catch (e) {
84 setError(e instanceof Error ? e.message : String(e))
85 } finally {
86 setBusy(null)
87 }
88 }
90 const loadSample = () => {
91 setError(null)
92 setMesh(makeSampleMesh())
93 setExportSizes(null)
94 setParseWarnings([])
95 setSourceLabel('built-in sample')
96 setBaseName('rainbow_torus')
97 }
99 const estimateSizes = async () => {
100 if (!mesh) return
101 setError(null)
102 setBusy('sizing')
103 setExportSizes({})
104 try {
105 // pure-Python formats first, so a first-time h5py download doesn't
106 // hold up the quick results
107 const ordered = [...formats].sort(
108 (a, b) => (a.pyodidePackages?.length ?? 0) - (b.pyodidePackages?.length ?? 0),
109 )
110 for (const format of ordered) {
111 const size = await estimateExportSize(mesh, format)
112 setExportSizes((prev) => ({ ...(prev ?? {}), [format.id]: size }))
113 }
114 } catch (e) {
115 setError(e instanceof Error ? e.message : String(e))
116 } finally {
117 setBusy(null)
118 }
119 }
121 const downloadExport = async () => {
122 if (!mesh) return
123 setError(null)
124 setBusy('exporting')
125 try {
126 const bytes = await serializeMesh(mesh, exportFormat)
127 const base = baseName.replace(/[^\w-]+/g, '_').toLowerCase() || 'mesh'
128 const blob = new Blob([bytes], { type: 'application/octet-stream' })
129 const url = URL.createObjectURL(blob)
130 const a = document.createElement('a')
131 a.href = url
132 a.download = base + exportFormat.extension
133 a.click()
134 URL.revokeObjectURL(url)
135 } catch (e) {
136 setError(e instanceof Error ? e.message : String(e))
137 } finally {
138 setBusy(null)
139 }
140 }
142 return (
143 <div className="app">
144 <div className="sidebar">
145 <h1>Mesh Converter</h1>
146 <p className="tagline">
147 Load a mesh, inspect it in 3D, export to another format. Conversion runs entirely in
148 your browser via <a href="https://github.com/nschloe/meshio">meshio</a> on Pyodide.
149 </p>
150 <div className={`engine-status ${engine.state}`}>{engine.message}</div>
152 <section>
153 <h2>Load</h2>
154 <div className="button-row">
155 <button
156 onClick={() => fileInputRef.current?.click()}
157 disabled={!engineReady || busy !== null}
158 >
159 {busy === 'parsing' ? 'Reading…' : 'Open mesh file…'}
160 </button>
161 <button onClick={loadSample} disabled={busy !== null}>
162 Load sample
163 </button>
164 </div>
165 <input
166 ref={fileInputRef}
167 type="file"
168 accept={acceptedExtensions.join(',')}
169 hidden
170 onChange={(e) => {
171 const file = e.target.files?.[0]
172 if (file) handleFile(file)
173 e.target.value = ''
174 }}
175 />
176 {error && <div className="error">{error}</div>}
177 </section>
179 {mesh && (
180 <section>
181 <h2>Loaded mesh</h2>
182 <div className="mesh-info">
183 <div className="source">{sourceLabel}</div>
184 <div>
185 {vertexCount(mesh)} vertices, {faceCount(mesh)} faces
186 </div>
187 <div className="chips">
188 <span className="chip on">positions</span>
189 <span className="chip on">faces</span>
190 <span className={`chip ${mesh.normals ? 'on' : ''}`}>normals</span>
191 <span className={`chip ${mesh.colors ? 'on' : ''}`}>colors</span>
192 </div>
193 {parseWarnings.length > 0 && (
194 <p className="footnote">{parseWarnings.join('; ')}</p>
195 )}
196 </div>
197 </section>
198 )}
200 {mesh && (
201 <section>
202 <h2>Export</h2>
203 <select value={exportFormatId} onChange={(e) => setExportFormatId(e.target.value)}>
204 {formats.map((f) => (
205 <option key={f.id} value={f.id}>
206 {f.label} — {f.extension}
207 {exportSizes?.[f.id] != null ? ` (${formatBytes(exportSizes[f.id])})` : ''}
208 </option>
209 ))}
210 </select>
211 <p className="format-blurb">{exportFormat.blurb}</p>
212 {losses.length > 0 ? (
213 <div className="warning">
214 Exporting to {exportFormat.extension} will drop:{' '}
215 <strong>{losses.join(', ')}</strong>
216 </div>
217 ) : (
218 <div className="ok">Lossless — this format keeps everything in the loaded mesh.</div>
219 )}
220 <button
221 className="primary"
222 onClick={downloadExport}
223 disabled={!engineReady || busy !== null}
224 >
225 {busy === 'exporting' ? 'Converting…' : `Download ${exportFormat.extension}`}
226 </button>
227 </section>
228 )}
230 <section>
231 <h2>Formats</h2>
232 <table className="format-table">
233 <thead>
234 <tr>
235 <th>Format</th>
236 <th>normals</th>
237 <th>colors</th>
238 {exportSizes && <th>size</th>}
239 </tr>
240 </thead>
241 <tbody>
242 {formats.map((f) => (
243 <tr
244 key={f.id}
245 className={f.id === exportFormatId ? 'selected' : ''}
246 onClick={() => setExportFormatId(f.id)}
247 title={`Export as ${f.label}`}
248 >
249 <td>
250 {f.id.toUpperCase()} <span className="ext">{f.extension}</span>
251 </td>
252 <td>{f.capabilities.normals ? '✓' : '—'}</td>
253 <td>{f.capabilities.colors ? '✓' : '—'}</td>
254 {exportSizes && (
255 <td className="size">
256 {exportSizes[f.id] != null ? formatBytes(exportSizes[f.id]) : '…'}
257 </td>
258 )}
259 </tr>
260 ))}
261 </tbody>
262 </table>
263 {mesh && (
264 <button
265 className="subtle"
266 onClick={estimateSizes}
267 disabled={!engineReady || busy !== null}
268 >
269 {busy === 'sizing' ? 'Estimating sizes…' : 'Estimate export sizes for this mesh'}
270 </button>
271 )}
272 <p className="footnote">
273 All formats store positions and triangle faces; ✓ marks the extra attributes this app
274 preserves on export. Quads and polygons are triangulated on import. Click a row to
275 choose the export format.
276 </p>
277 </section>
278 </div>
280 <div className="viewport">
281 {mesh ? (
282 <MeshView mesh={mesh} />
283 ) : (
284 <div className="empty-state">
285 <p>No mesh loaded.</p>
286 <p>Open a {acceptedExtensions.join(', ')} file — or load the sample.</p>
287 </div>
288 )}
289 </div>
290 </div>
291 )
292}
294export default App