50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 1import { useEffect, useRef, useState } from 'react'
edd4e73Mesh converter: upload, view, and export meshes across formatsJeremy Magland 2import { MeshView } from './MeshView'
4import type { ViewMode } from './viewModes'
edd4e73Mesh converter: upload, view, and export meshes across formatsJeremy Magland 5import type { MeshData } from './mesh/types'
6import { faceCount, vertexCount } from './mesh/types'
7import { acceptedExtensions, conversionLosses, formatForFilename, formats } from './mesh/formats'
dfa5737Export by converting the original file natively; fix gmsh/tecplot capabilitiesJeremy Magland 8import type { MeshFormat } from './mesh/formats'
dfa5737Export by converting the original file natively; fix gmsh/tecplot capabilitiesJeremy Magland 10 convertMesh,
11 estimateConvertSize,
13 getMeshioVersion,
14 initMeshio,
15 parseMeshFile,
16 serializeMesh,
17} from './mesh/meshio'
edd4e73Mesh converter: upload, view, and export meshes across formatsJeremy Magland 18import { makeSampleMesh } from './mesh/sample'
bd1359eShare meshes as self-contained URLsJeremy Magland 19import { buildShareUrl, MAX_SHARE_URL_CHARS, parseShareHash } from './share'
edd4e73Mesh converter: upload, view, and export meshes across formatsJeremy Magland 20import './App.css'
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 22type EngineState = 'loading' | 'ready' | 'error'
dfa5737Export by converting the original file natively; fix gmsh/tecplot capabilitiesJeremy Magland 25 * The mesh's source of truth, kept in its original native form. Export and
26 * share both read from this rather than from the viewer's common `MeshData`,
27 * so a conversion loses only what the target format cannot express — and a
28 * same-format export returns the original bytes verbatim.
29 *
30 * `file` is an uploaded or shared file, byte-for-byte in its original format.
31 * `sample` is the built-in mesh, generated directly as `MeshData` (its own
32 * lossless ground truth), so it has no native bytes to keep.
dfa5737Export by converting the original file natively; fix gmsh/tecplot capabilitiesJeremy Magland 34type MeshSource =
bd1359eShare meshes as self-contained URLsJeremy Magland 35 | { kind: 'file'; formatId: string; bytes: Uint8Array<ArrayBuffer> }
36 | { kind: 'sample' }
38type ShareStatus =
39 | { kind: 'copied'; chars: number }
40 | { kind: 'manual'; url: string } // clipboard unavailable — show the link for hand-copying
41 | { kind: 'too-large'; chars: number }
42 | { kind: 'error'; message: string }
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 44function formatBytes(n: number): string {
45 if (n < 1024) return `${n} B`
46 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
47 return `${(n / 1024 / 1024).toFixed(1)} MB`
48}
edd4e73Mesh converter: upload, view, and export meshes across formatsJeremy Magland 50function App() {
51 const [mesh, setMesh] = useState<MeshData | null>(null)
52 const [sourceLabel, setSourceLabel] = useState<string>('')
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 53 const [baseName, setBaseName] = useState<string>('mesh')
54 const [parseWarnings, setParseWarnings] = useState<string[]>([])
edd4e73Mesh converter: upload, view, and export meshes across formatsJeremy Magland 55 const [error, setError] = useState<string | null>(null)
56 const [exportFormatId, setExportFormatId] = useState(formats[0].id)
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 57 const [engine, setEngine] = useState<{ state: EngineState; message: string }>({
58 state: 'loading',
59 message: 'Loading mesh engine…',
60 })
61 const [busy, setBusy] = useState<'parsing' | 'exporting' | 'sizing' | null>(null)
62 const [exportSizes, setExportSizes] = useState<Record<string, number> | null>(null)
bd1359eShare meshes as self-contained URLsJeremy Magland 63 const [viewMode, setViewMode] = useState<ViewMode>('both')
c26fd7dAnaglyph stereo view mode for red/cyan 3D glassesJeremy Magland 64 const [anaglyph, setAnaglyph] = useState(false)
dfa5737Export by converting the original file natively; fix gmsh/tecplot capabilitiesJeremy Magland 65 const [source, setSource] = useState<MeshSource | null>(null)
bd1359eShare meshes as self-contained URLsJeremy Magland 66 const [shareStatus, setShareStatus] = useState<ShareStatus | null>(null)
edd4e73Mesh converter: upload, view, and export meshes across formatsJeremy Magland 67 const fileInputRef = useRef<HTMLInputElement>(null)
bd1359eShare meshes as self-contained URLsJeremy Magland 68 const shareLoadAttempted = useRef(false)
70 const exportFormat = formats.find((f) => f.id === exportFormatId) ?? formats[0]
dfa5737Export by converting the original file natively; fix gmsh/tecplot capabilitiesJeremy Magland 71 const sourceFormat =
72 source?.kind === 'file' ? (formats.find((f) => f.id === source.formatId) ?? null) : null
73 // A same-format export of an uploaded file is the original bytes, untouched.
74 const identicalToSource = source?.kind === 'file' && source.formatId === exportFormat.id
75 const losses = mesh && !identicalToSource ? conversionLosses(mesh, exportFormat) : []
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 76 const engineReady = engine.state === 'ready'
78 useEffect(() => {
79 let cancelled = false
80 initMeshio((message) => {
81 if (!cancelled) setEngine({ state: 'loading', message })
82 })
83 .then(() => getMeshioVersion())
84 .then((version) => {
85 if (!cancelled) setEngine({ state: 'ready', message: `meshio ${version} ready` })
86 })
87 .catch((e) => {
88 if (!cancelled) {
89 setEngine({
90 state: 'error',
91 message: `Mesh engine failed to load: ${e instanceof Error ? e.message : String(e)}`,
92 })
93 }
94 })
95 return () => {
96 cancelled = true
97 }
98 }, [])
bd1359eShare meshes as self-contained URLsJeremy Magland 100 // Load a mesh from a #share=… link on startup (parseMeshFile waits for the
101 // engine init kicked off above).
102 useEffect(() => {
103 if (shareLoadAttempted.current) return
104 shareLoadAttempted.current = true
105 ;(async () => {
106 let payload
107 try {
108 payload = await parseShareHash(window.location.hash)
109 } catch (e) {
110 setError(`Could not read the share link: ${e instanceof Error ? e.message : String(e)}`)
111 return
112 }
113 if (!payload) return
114 if (formats.some((f) => f.id === payload.exportFormatId)) {
115 setExportFormatId(payload.exportFormatId)
116 }
117 if (VIEW_MODES.some((m) => m.id === payload.viewMode)) {
118 setViewMode(payload.viewMode as ViewMode)
119 }
c26fd7dAnaglyph stereo view mode for red/cyan 3D glassesJeremy Magland 120 setAnaglyph(payload.anaglyph)
122 setMesh(makeSampleMesh())
dfa5737Export by converting the original file natively; fix gmsh/tecplot capabilitiesJeremy Magland 123 setSource({ kind: 'sample' })
125 setSourceLabel('built-in sample (from share link)')
126 setBaseName(payload.name || 'rainbow_torus')
127 return
128 }
129 const format = formats.find((f) => f.id === payload.formatId)
130 if (!format) {
131 setError(`Could not read the share link: unknown mesh format "${payload.formatId}"`)
132 return
133 }
134 setBusy('parsing')
135 try {
136 const { mesh: parsed, info } = await parseMeshFile(payload.bytes, format)
137 setMesh(parsed)
dfa5737Export by converting the original file natively; fix gmsh/tecplot capabilitiesJeremy Magland 138 setSource({ kind: 'file', formatId: format.id, bytes: payload.bytes })
140 setSourceLabel(`${payload.name}${format.extension} (${format.label}, from share link)`)
141 setBaseName(payload.name || 'mesh')
142 } catch (e) {
143 setError(
144 `Could not load the shared mesh: ${e instanceof Error ? e.message : String(e)}`,
145 )
146 } finally {
147 setBusy(null)
148 }
149 })()
150 }, [])
152 // A share link only describes the mesh it was created for — drop it from
153 // the address bar once a different mesh is loaded.
154 const clearShareHash = () => {
155 if (window.location.hash) {
156 history.replaceState(null, '', window.location.pathname + window.location.search)
157 }
158 }
edd4e73Mesh converter: upload, view, and export meshes across formatsJeremy Magland 160 const handleFile = async (file: File) => {
161 setError(null)
162 const format = formatForFilename(file.name)
163 if (!format) {
164 setError(
165 `Unrecognized extension on "${file.name}". Supported: ${acceptedExtensions.join(', ')}`,
166 )
167 return
168 }
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 171 const bytes = new Uint8Array(await file.arrayBuffer())
172 const { mesh: parsed, info } = await parseMeshFile(bytes, format)
173 setMesh(parsed)
174 setExportSizes(null)
175 setParseWarnings(info.warnings)
edd4e73Mesh converter: upload, view, and export meshes across formatsJeremy Magland 176 setSourceLabel(`${file.name} (${format.label})`)
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 177 setBaseName(file.name.replace(/\.[^.]+$/, ''))
dfa5737Export by converting the original file natively; fix gmsh/tecplot capabilitiesJeremy Magland 178 setSource({ kind: 'file', formatId: format.id, bytes })
180 clearShareHash()
edd4e73Mesh converter: upload, view, and export meshes across formatsJeremy Magland 181 } catch (e) {
182 setError(e instanceof Error ? e.message : String(e))
184 setBusy(null)
186 }
188 const loadSample = () => {
189 setError(null)
190 setMesh(makeSampleMesh())
192 setParseWarnings([])
edd4e73Mesh converter: upload, view, and export meshes across formatsJeremy Magland 193 setSourceLabel('built-in sample')
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 194 setBaseName('rainbow_torus')
dfa5737Export by converting the original file natively; fix gmsh/tecplot capabilitiesJeremy Magland 195 setSource({ kind: 'sample' })
197 clearShareHash()
198 }
200 const shareMesh = async () => {
dfa5737Export by converting the original file natively; fix gmsh/tecplot capabilitiesJeremy Magland 201 if (!source) return
203 try {
204 const url = await buildShareUrl({
205 name: baseName,
dfa5737Export by converting the original file natively; fix gmsh/tecplot capabilitiesJeremy Magland 206 formatId: source.kind === 'file' ? source.formatId : null,
208 viewMode,
dfa5737Export by converting the original file natively; fix gmsh/tecplot capabilitiesJeremy Magland 210 bytes: source.kind === 'file' ? source.bytes : new Uint8Array(0),
212 if (url.length > MAX_SHARE_URL_CHARS) {
213 setShareStatus({ kind: 'too-large', chars: url.length })
214 return
215 }
216 try {
217 await navigator.clipboard.writeText(url)
218 setShareStatus({ kind: 'copied', chars: url.length })
219 } catch {
220 setShareStatus({ kind: 'manual', url })
221 }
222 } catch (e) {
223 setShareStatus({ kind: 'error', message: e instanceof Error ? e.message : String(e) })
224 }
dfa5737Export by converting the original file natively; fix gmsh/tecplot capabilitiesJeremy Magland 227 // Size of the mesh exported to `format`, taken from the native source: the
228 // original bytes when it matches, meshio conversion otherwise; the sample
229 // has no native file, so it serializes from its common-form MeshData.
230 const exportSizeFor = async (format: MeshFormat): Promise<number> => {
231 if (source?.kind === 'file') {
232 if (source.formatId === format.id) return source.bytes.length
233 return estimateConvertSize(source.bytes, sourceFormat!, format)
234 }
235 return estimateExportSize(mesh!, format)
236 }
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 238 const estimateSizes = async () => {
dfa5737Export by converting the original file natively; fix gmsh/tecplot capabilitiesJeremy Magland 239 if (!mesh || !source) return
241 setBusy('sizing')
242 setExportSizes({})
243 try {
244 // pure-Python formats first, so a first-time h5py download doesn't
245 // hold up the quick results
246 const ordered = [...formats].sort(
247 (a, b) => (a.pyodidePackages?.length ?? 0) - (b.pyodidePackages?.length ?? 0),
248 )
249 for (const format of ordered) {
dfa5737Export by converting the original file natively; fix gmsh/tecplot capabilitiesJeremy Magland 250 const size = await exportSizeFor(format)
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 251 setExportSizes((prev) => ({ ...(prev ?? {}), [format.id]: size }))
252 }
253 } catch (e) {
254 setError(e instanceof Error ? e.message : String(e))
255 } finally {
256 setBusy(null)
257 }
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 260 const downloadExport = async () => {
dfa5737Export by converting the original file natively; fix gmsh/tecplot capabilitiesJeremy Magland 261 if (!mesh || !source) return
263 setBusy('exporting')
264 try {
dfa5737Export by converting the original file natively; fix gmsh/tecplot capabilitiesJeremy Magland 265 // Export from the native source of truth, not the viewer's MeshData:
266 // same format -> the original bytes untouched; different format ->
267 // meshio native-to-native; sample -> serialize its generated MeshData.
268 let bytes: Uint8Array<ArrayBuffer>
269 if (source.kind === 'file') {
270 bytes =
271 source.formatId === exportFormat.id
272 ? source.bytes
273 : await convertMesh(source.bytes, sourceFormat!, exportFormat)
274 } else {
275 bytes = await serializeMesh(mesh, exportFormat)
276 }
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 277 const base = baseName.replace(/[^\w-]+/g, '_').toLowerCase() || 'mesh'
278 const blob = new Blob([bytes], { type: 'application/octet-stream' })
279 const url = URL.createObjectURL(blob)
280 const a = document.createElement('a')
281 a.href = url
282 a.download = base + exportFormat.extension
283 a.click()
284 URL.revokeObjectURL(url)
285 } catch (e) {
286 setError(e instanceof Error ? e.message : String(e))
287 } finally {
288 setBusy(null)
289 }
292 return (
293 <div className="app">
294 <div className="sidebar">
295 <h1>Mesh Converter</h1>
296 <p className="tagline">
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 297 Load a mesh, inspect it in 3D, export to another format. Conversion runs entirely in
298 your browser via <a href="https://github.com/nschloe/meshio">meshio</a> on Pyodide.
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 300 <div className={`engine-status ${engine.state}`}>{engine.message}</div>
302 <section>
303 <h2>Load</h2>
304 <div className="button-row">
306 onClick={() => fileInputRef.current?.click()}
307 disabled={!engineReady || busy !== null}
308 >
309 {busy === 'parsing' ? 'Reading…' : 'Open mesh file…'}
310 </button>
311 <button onClick={loadSample} disabled={busy !== null}>
312 Load sample
313 </button>
315 <input
316 ref={fileInputRef}
317 type="file"
318 accept={acceptedExtensions.join(',')}
319 hidden
320 onChange={(e) => {
321 const file = e.target.files?.[0]
322 if (file) handleFile(file)
323 e.target.value = ''
324 }}
325 />
326 {error && <div className="error">{error}</div>}
327 </section>
329 {mesh && (
330 <section>
331 <h2>Loaded mesh</h2>
332 <div className="mesh-info">
333 <div className="source">{sourceLabel}</div>
334 <div>
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 335 {vertexCount(mesh)} vertices, {faceCount(mesh)} faces
337 <div className="chips">
338 <span className="chip on">positions</span>
339 <span className="chip on">faces</span>
340 <span className={`chip ${mesh.normals ? 'on' : ''}`}>normals</span>
341 <span className={`chip ${mesh.colors ? 'on' : ''}`}>colors</span>
342 </div>
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 343 {parseWarnings.length > 0 && (
344 <p className="footnote">{parseWarnings.join('; ')}</p>
345 )}
347 </section>
348 )}
350 {mesh && (
351 <section>
352 <h2>Export</h2>
353 <select value={exportFormatId} onChange={(e) => setExportFormatId(e.target.value)}>
354 {formats.map((f) => (
355 <option key={f.id} value={f.id}>
356 {f.label} — {f.extension}
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 357 {exportSizes?.[f.id] != null ? ` (${formatBytes(exportSizes[f.id])})` : ''}
359 ))}
360 </select>
361 <p className="format-blurb">{exportFormat.blurb}</p>
dfa5737Export by converting the original file natively; fix gmsh/tecplot capabilitiesJeremy Magland 362 {identicalToSource ? (
363 <div className="ok">
364 Same as the source format — you get the original file back, byte for byte.
365 </div>
366 ) : losses.length > 0 ? (
edd4e73Mesh converter: upload, view, and export meshes across formatsJeremy Magland 367 <div className="warning">
368 Exporting to {exportFormat.extension} will drop:{' '}
369 <strong>{losses.join(', ')}</strong>
370 </div>
371 ) : (
372 <div className="ok">Lossless — this format keeps everything in the loaded mesh.</div>
373 )}
375 className="primary"
376 onClick={downloadExport}
377 disabled={!engineReady || busy !== null}
378 >
379 {busy === 'exporting' ? 'Converting…' : `Download ${exportFormat.extension}`}
381 </section>
382 )}
dfa5737Export by converting the original file natively; fix gmsh/tecplot capabilitiesJeremy Magland 384 {mesh && source && (
386 <h2>Share</h2>
387 <button onClick={shareMesh} disabled={busy !== null}>
388 Copy share link
389 </button>
390 {shareStatus?.kind === 'copied' && (
391 <div className="ok">
392 Link copied to clipboard ({shareStatus.chars.toLocaleString()} characters).
393 </div>
394 )}
395 {shareStatus?.kind === 'manual' && (
396 <div className="warning">
397 Couldn’t write to the clipboard — copy the link below by hand.
398 <input
399 className="share-url"
400 readOnly
401 value={shareStatus.url}
402 onFocus={(e) => e.target.select()}
403 />
404 </div>
405 )}
406 {shareStatus?.kind === 'too-large' && (
407 <div className="warning">
408 This mesh is too large to share by URL: the link would be{' '}
409 {shareStatus.chars.toLocaleString()} characters, beyond the{' '}
410 {MAX_SHARE_URL_CHARS.toLocaleString()} that links can reliably carry. Download
411 the file and share it directly instead.
412 </div>
413 )}
414 {shareStatus?.kind === 'error' && <div className="error">{shareStatus.message}</div>}
415 <p className="footnote">
416 The link embeds the compressed mesh (the original file) plus the export and view
417 settings in the URL itself — nothing is uploaded anywhere. Whoever opens it can
418 view the mesh and download it in any format.
419 </p>
420 </section>
421 )}
424 <h2>Formats</h2>
425 <table className="format-table">
426 <thead>
427 <tr>
428 <th>Format</th>
429 <th>normals</th>
430 <th>colors</th>
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 431 {exportSizes && <th>size</th>}
433 </thead>
434 <tbody>
435 {formats.map((f) => (
437 key={f.id}
438 className={f.id === exportFormatId ? 'selected' : ''}
439 onClick={() => setExportFormatId(f.id)}
440 title={`Export as ${f.label}`}
441 >
443 {f.id.toUpperCase()} <span className="ext">{f.extension}</span>
444 </td>
445 <td>{f.capabilities.normals ? '✓' : '—'}</td>
446 <td>{f.capabilities.colors ? '✓' : '—'}</td>
448 <td className="size">
449 {exportSizes[f.id] != null ? formatBytes(exportSizes[f.id]) : '…'}
450 </td>
451 )}
453 ))}
454 </tbody>
455 </table>
457 <button
458 className="subtle"
459 onClick={estimateSizes}
460 disabled={!engineReady || busy !== null}
461 >
462 {busy === 'sizing' ? 'Estimating sizes…' : 'Estimate export sizes for this mesh'}
463 </button>
464 )}
edd4e73Mesh converter: upload, view, and export meshes across formatsJeremy Magland 465 <p className="footnote">
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 466 All formats store positions and triangle faces; ✓ marks the extra attributes this app
467 preserves on export. Quads and polygons are triangulated on import. Click a row to
468 choose the export format.
470 </section>
471 </div>
473 <div className="viewport">
474 {mesh ? (
476 mesh={mesh}
477 mode={viewMode}
478 onModeChange={setViewMode}
479 anaglyph={anaglyph}
480 onAnaglyphChange={setAnaglyph}
481 />
483 <div className="empty-state">
484 <p>No mesh loaded.</p>
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 485 <p>Open a {acceptedExtensions.join(', ')} file — or load the sample.</p>
487 )}
488 </div>
489 </div>
490 )
491}
493export default App