/ tools / mesh-converter
Sign in
tools / mesh-converter
mesh-converter / src / App.tsx
484 lines · 17.3 KBCodeBlameHistory
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'
bd1359eShare meshes as self-contained URLsJeremy Magland 3import { VIEW_MODES } from './viewModes'
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'
11 estimateConvertSize,
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 12 estimateExportSize,
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'
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 22type EngineState = 'loading' | 'ready' | 'error'
bd1359eShare meshes as self-contained URLsJeremy Magland 24/**
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.
bd1359eShare meshes as self-contained URLsJeremy Magland 33 */
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`
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')
dfa5737Export by converting the original file natively; fix gmsh/tecplot capabilitiesJeremy Magland 64 const [source, setSource] = useState<MeshSource | null>(null)
bd1359eShare meshes as self-contained URLsJeremy Magland 65 const [shareStatus, setShareStatus] = useState<ShareStatus | null>(null)
edd4e73Mesh converter: upload, view, and export meshes across formatsJeremy Magland 66 const fileInputRef = useRef<HTMLInputElement>(null)
bd1359eShare meshes as self-contained URLsJeremy Magland 67 const shareLoadAttempted = useRef(false)
69 const exportFormat = formats.find((f) => f.id === exportFormatId) ?? formats[0]
71 source?.kind === 'file' ? (formats.find((f) => f.id === source.formatId) ?? null) : null
72 // A same-format export of an uploaded file is the original bytes, untouched.
73 const identicalToSource = source?.kind === 'file' && source.formatId === exportFormat.id
74 const losses = mesh && !identicalToSource ? conversionLosses(mesh, exportFormat) : []
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 75 const engineReady = engine.state === 'ready'
77 useEffect(() => {
78 let cancelled = false
79 initMeshio((message) => {
80 if (!cancelled) setEngine({ state: 'loading', message })
81 })
82 .then(() => getMeshioVersion())
83 .then((version) => {
84 if (!cancelled) setEngine({ state: 'ready', message: `meshio ${version} ready` })
85 })
86 .catch((e) => {
87 if (!cancelled) {
88 setEngine({
89 state: 'error',
90 message: `Mesh engine failed to load: ${e instanceof Error ? e.message : String(e)}`,
91 })
92 }
93 })
94 return () => {
95 cancelled = true
96 }
97 }, [])
bd1359eShare meshes as self-contained URLsJeremy Magland 99 // Load a mesh from a #share=… link on startup (parseMeshFile waits for the
100 // engine init kicked off above).
101 useEffect(() => {
102 if (shareLoadAttempted.current) return
103 shareLoadAttempted.current = true
104 ;(async () => {
105 let payload
106 try {
107 payload = await parseShareHash(window.location.hash)
108 } catch (e) {
109 setError(`Could not read the share link: ${e instanceof Error ? e.message : String(e)}`)
110 return
111 }
112 if (!payload) return
113 if (formats.some((f) => f.id === payload.exportFormatId)) {
114 setExportFormatId(payload.exportFormatId)
115 }
116 if (VIEW_MODES.some((m) => m.id === payload.viewMode)) {
117 setViewMode(payload.viewMode as ViewMode)
118 }
119 if (payload.formatId === null) {
120 setMesh(makeSampleMesh())
bd1359eShare meshes as self-contained URLsJeremy Magland 122 setParseWarnings([])
123 setSourceLabel('built-in sample (from share link)')
124 setBaseName(payload.name || 'rainbow_torus')
125 return
126 }
127 const format = formats.find((f) => f.id === payload.formatId)
128 if (!format) {
129 setError(`Could not read the share link: unknown mesh format "${payload.formatId}"`)
130 return
131 }
132 setBusy('parsing')
133 try {
134 const { mesh: parsed, info } = await parseMeshFile(payload.bytes, format)
135 setMesh(parsed)
dfa5737Export by converting the original file natively; fix gmsh/tecplot capabilitiesJeremy Magland 136 setSource({ kind: 'file', formatId: format.id, bytes: payload.bytes })
bd1359eShare meshes as self-contained URLsJeremy Magland 137 setParseWarnings(info.warnings)
138 setSourceLabel(`${payload.name}${format.extension} (${format.label}, from share link)`)
139 setBaseName(payload.name || 'mesh')
140 } catch (e) {
141 setError(
142 `Could not load the shared mesh: ${e instanceof Error ? e.message : String(e)}`,
143 )
144 } finally {
145 setBusy(null)
146 }
147 })()
148 }, [])
150 // A share link only describes the mesh it was created for — drop it from
151 // the address bar once a different mesh is loaded.
152 const clearShareHash = () => {
153 if (window.location.hash) {
154 history.replaceState(null, '', window.location.pathname + window.location.search)
155 }
156 }
edd4e73Mesh converter: upload, view, and export meshes across formatsJeremy Magland 158 const handleFile = async (file: File) => {
159 setError(null)
160 const format = formatForFilename(file.name)
161 if (!format) {
162 setError(
163 `Unrecognized extension on "${file.name}". Supported: ${acceptedExtensions.join(', ')}`,
164 )
165 return
166 }
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 167 setBusy('parsing')
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 169 const bytes = new Uint8Array(await file.arrayBuffer())
170 const { mesh: parsed, info } = await parseMeshFile(bytes, format)
171 setMesh(parsed)
172 setExportSizes(null)
173 setParseWarnings(info.warnings)
edd4e73Mesh converter: upload, view, and export meshes across formatsJeremy Magland 174 setSourceLabel(`${file.name} (${format.label})`)
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 175 setBaseName(file.name.replace(/\.[^.]+$/, ''))
dfa5737Export by converting the original file natively; fix gmsh/tecplot capabilitiesJeremy Magland 176 setSource({ kind: 'file', formatId: format.id, bytes })
bd1359eShare meshes as self-contained URLsJeremy Magland 177 setShareStatus(null)
178 clearShareHash()
180 setError(e instanceof Error ? e.message : String(e))
182 setBusy(null)
184 }
186 const loadSample = () => {
187 setError(null)
188 setMesh(makeSampleMesh())
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 189 setExportSizes(null)
190 setParseWarnings([])
edd4e73Mesh converter: upload, view, and export meshes across formatsJeremy Magland 191 setSourceLabel('built-in sample')
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 192 setBaseName('rainbow_torus')
bd1359eShare meshes as self-contained URLsJeremy Magland 194 setShareStatus(null)
195 clearShareHash()
196 }
198 const shareMesh = async () => {
bd1359eShare meshes as self-contained URLsJeremy Magland 200 setShareStatus(null)
201 try {
202 const url = await buildShareUrl({
203 name: baseName,
dfa5737Export by converting the original file natively; fix gmsh/tecplot capabilitiesJeremy Magland 204 formatId: source.kind === 'file' ? source.formatId : null,
bd1359eShare meshes as self-contained URLsJeremy Magland 205 exportFormatId,
206 viewMode,
dfa5737Export by converting the original file natively; fix gmsh/tecplot capabilitiesJeremy Magland 207 bytes: source.kind === 'file' ? source.bytes : new Uint8Array(0),
bd1359eShare meshes as self-contained URLsJeremy Magland 208 })
209 if (url.length > MAX_SHARE_URL_CHARS) {
210 setShareStatus({ kind: 'too-large', chars: url.length })
211 return
212 }
213 try {
214 await navigator.clipboard.writeText(url)
215 setShareStatus({ kind: 'copied', chars: url.length })
216 } catch {
217 setShareStatus({ kind: 'manual', url })
218 }
219 } catch (e) {
220 setShareStatus({ kind: 'error', message: e instanceof Error ? e.message : String(e) })
221 }
dfa5737Export by converting the original file natively; fix gmsh/tecplot capabilitiesJeremy Magland 224 // Size of the mesh exported to `format`, taken from the native source: the
225 // original bytes when it matches, meshio conversion otherwise; the sample
226 // has no native file, so it serializes from its common-form MeshData.
227 const exportSizeFor = async (format: MeshFormat): Promise<number> => {
228 if (source?.kind === 'file') {
229 if (source.formatId === format.id) return source.bytes.length
230 return estimateConvertSize(source.bytes, sourceFormat!, format)
231 }
232 return estimateExportSize(mesh!, format)
233 }
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 235 const estimateSizes = async () => {
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 237 setError(null)
238 setBusy('sizing')
239 setExportSizes({})
240 try {
241 // pure-Python formats first, so a first-time h5py download doesn't
242 // hold up the quick results
243 const ordered = [...formats].sort(
244 (a, b) => (a.pyodidePackages?.length ?? 0) - (b.pyodidePackages?.length ?? 0),
245 )
246 for (const format of ordered) {
dfa5737Export by converting the original file natively; fix gmsh/tecplot capabilitiesJeremy Magland 247 const size = await exportSizeFor(format)
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 248 setExportSizes((prev) => ({ ...(prev ?? {}), [format.id]: size }))
249 }
250 } catch (e) {
251 setError(e instanceof Error ? e.message : String(e))
252 } finally {
253 setBusy(null)
254 }
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 257 const downloadExport = async () => {
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 259 setError(null)
260 setBusy('exporting')
261 try {
dfa5737Export by converting the original file natively; fix gmsh/tecplot capabilitiesJeremy Magland 262 // Export from the native source of truth, not the viewer's MeshData:
263 // same format -> the original bytes untouched; different format ->
264 // meshio native-to-native; sample -> serialize its generated MeshData.
265 let bytes: Uint8Array<ArrayBuffer>
266 if (source.kind === 'file') {
267 bytes =
268 source.formatId === exportFormat.id
269 ? source.bytes
270 : await convertMesh(source.bytes, sourceFormat!, exportFormat)
271 } else {
272 bytes = await serializeMesh(mesh, exportFormat)
273 }
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 274 const base = baseName.replace(/[^\w-]+/g, '_').toLowerCase() || 'mesh'
275 const blob = new Blob([bytes], { type: 'application/octet-stream' })
276 const url = URL.createObjectURL(blob)
277 const a = document.createElement('a')
278 a.href = url
279 a.download = base + exportFormat.extension
280 a.click()
281 URL.revokeObjectURL(url)
282 } catch (e) {
283 setError(e instanceof Error ? e.message : String(e))
284 } finally {
285 setBusy(null)
286 }
289 return (
290 <div className="app">
291 <div className="sidebar">
292 <h1>Mesh Converter</h1>
293 <p className="tagline">
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 294 Load a mesh, inspect it in 3D, export to another format. Conversion runs entirely in
295 your browser via <a href="https://github.com/nschloe/meshio">meshio</a> on Pyodide.
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 297 <div className={`engine-status ${engine.state}`}>{engine.message}</div>
299 <section>
300 <h2>Load</h2>
301 <div className="button-row">
303 onClick={() => fileInputRef.current?.click()}
304 disabled={!engineReady || busy !== null}
305 >
306 {busy === 'parsing' ? 'Reading…' : 'Open mesh file…'}
307 </button>
308 <button onClick={loadSample} disabled={busy !== null}>
309 Load sample
310 </button>
312 <input
313 ref={fileInputRef}
314 type="file"
315 accept={acceptedExtensions.join(',')}
316 hidden
317 onChange={(e) => {
318 const file = e.target.files?.[0]
319 if (file) handleFile(file)
320 e.target.value = ''
321 }}
322 />
323 {error && <div className="error">{error}</div>}
324 </section>
326 {mesh && (
327 <section>
328 <h2>Loaded mesh</h2>
329 <div className="mesh-info">
330 <div className="source">{sourceLabel}</div>
331 <div>
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 332 {vertexCount(mesh)} vertices, {faceCount(mesh)} faces
334 <div className="chips">
335 <span className="chip on">positions</span>
336 <span className="chip on">faces</span>
337 <span className={`chip ${mesh.normals ? 'on' : ''}`}>normals</span>
338 <span className={`chip ${mesh.colors ? 'on' : ''}`}>colors</span>
339 </div>
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 340 {parseWarnings.length > 0 && (
341 <p className="footnote">{parseWarnings.join('; ')}</p>
342 )}
344 </section>
345 )}
347 {mesh && (
348 <section>
349 <h2>Export</h2>
350 <select value={exportFormatId} onChange={(e) => setExportFormatId(e.target.value)}>
351 {formats.map((f) => (
352 <option key={f.id} value={f.id}>
353 {f.label} — {f.extension}
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 354 {exportSizes?.[f.id] != null ? ` (${formatBytes(exportSizes[f.id])})` : ''}
356 ))}
357 </select>
358 <p className="format-blurb">{exportFormat.blurb}</p>
360 <div className="ok">
361 Same as the source format — you get the original file back, byte for byte.
362 </div>
363 ) : losses.length > 0 ? (
edd4e73Mesh converter: upload, view, and export meshes across formatsJeremy Magland 364 <div className="warning">
365 Exporting to {exportFormat.extension} will drop:{' '}
366 <strong>{losses.join(', ')}</strong>
367 </div>
368 ) : (
369 <div className="ok">Lossless — this format keeps everything in the loaded mesh.</div>
370 )}
372 className="primary"
373 onClick={downloadExport}
374 disabled={!engineReady || busy !== null}
375 >
376 {busy === 'exporting' ? 'Converting…' : `Download ${exportFormat.extension}`}
378 </section>
379 )}
bd1359eShare meshes as self-contained URLsJeremy Magland 382 <section>
383 <h2>Share</h2>
384 <button onClick={shareMesh} disabled={busy !== null}>
385 Copy share link
386 </button>
387 {shareStatus?.kind === 'copied' && (
388 <div className="ok">
389 Link copied to clipboard ({shareStatus.chars.toLocaleString()} characters).
390 </div>
391 )}
392 {shareStatus?.kind === 'manual' && (
393 <div className="warning">
394 Couldn’t write to the clipboard — copy the link below by hand.
395 <input
396 className="share-url"
397 readOnly
398 value={shareStatus.url}
399 onFocus={(e) => e.target.select()}
400 />
401 </div>
402 )}
403 {shareStatus?.kind === 'too-large' && (
404 <div className="warning">
405 This mesh is too large to share by URL: the link would be{' '}
406 {shareStatus.chars.toLocaleString()} characters, beyond the{' '}
407 {MAX_SHARE_URL_CHARS.toLocaleString()} that links can reliably carry. Download
408 the file and share it directly instead.
409 </div>
410 )}
411 {shareStatus?.kind === 'error' && <div className="error">{shareStatus.message}</div>}
412 <p className="footnote">
413 The link embeds the compressed mesh (the original file) plus the export and view
414 settings in the URL itself — nothing is uploaded anywhere. Whoever opens it can
415 view the mesh and download it in any format.
416 </p>
417 </section>
418 )}
421 <h2>Formats</h2>
422 <table className="format-table">
423 <thead>
424 <tr>
425 <th>Format</th>
426 <th>normals</th>
427 <th>colors</th>
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 428 {exportSizes && <th>size</th>}
430 </thead>
431 <tbody>
432 {formats.map((f) => (
434 key={f.id}
435 className={f.id === exportFormatId ? 'selected' : ''}
436 onClick={() => setExportFormatId(f.id)}
437 title={`Export as ${f.label}`}
438 >
440 {f.id.toUpperCase()} <span className="ext">{f.extension}</span>
441 </td>
442 <td>{f.capabilities.normals ? '✓' : '—'}</td>
443 <td>{f.capabilities.colors ? '✓' : '—'}</td>
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 444 {exportSizes && (
445 <td className="size">
446 {exportSizes[f.id] != null ? formatBytes(exportSizes[f.id]) : '…'}
447 </td>
448 )}
450 ))}
451 </tbody>
452 </table>
454 <button
455 className="subtle"
456 onClick={estimateSizes}
457 disabled={!engineReady || busy !== null}
458 >
459 {busy === 'sizing' ? 'Estimating sizes…' : 'Estimate export sizes for this mesh'}
460 </button>
461 )}
edd4e73Mesh converter: upload, view, and export meshes across formatsJeremy Magland 462 <p className="footnote">
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 463 All formats store positions and triangle faces; ✓ marks the extra attributes this app
464 preserves on export. Quads and polygons are triangulated on import. Click a row to
465 choose the export format.
467 </section>
468 </div>
470 <div className="viewport">
471 {mesh ? (
bd1359eShare meshes as self-contained URLsJeremy Magland 472 <MeshView mesh={mesh} mode={viewMode} onModeChange={setViewMode} />
474 <div className="empty-state">
475 <p>No mesh loaded.</p>
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 476 <p>Open a {acceptedExtensions.join(', ')} file — or load the sample.</p>
478 )}
479 </div>
480 </div>
481 )
484export default App
↑↓ move↵openescclose