/ concept-collection / mesh-converter
Sign in
concept-collection / mesh-converter
mesh-converter / src / App.tsx
484 lines · 17.3 KBBlameHistoryRaw
1import { useEffect, useRef, useState } from 'react'
2import { MeshView } from './MeshView'
3import { VIEW_MODES } from './viewModes'
4import type { ViewMode } from './viewModes'
5import type { MeshData } from './mesh/types'
6import { faceCount, vertexCount } from './mesh/types'
7import { acceptedExtensions, conversionLosses, formatForFilename, formats } from './mesh/formats'
8import type { MeshFormat } from './mesh/formats'
9import {
10 convertMesh,
11 estimateConvertSize,
12 estimateExportSize,
13 getMeshioVersion,
14 initMeshio,
15 parseMeshFile,
16 serializeMesh,
17} from './mesh/meshio'
18import { makeSampleMesh } from './mesh/sample'
19import { buildShareUrl, MAX_SHARE_URL_CHARS, parseShareHash } from './share'
20import './App.css'
22type EngineState = 'loading' | 'ready' | 'error'
24/**
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.
33 */
34type MeshSource =
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 }
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`
50function App() {
51 const [mesh, setMesh] = useState<MeshData | null>(null)
52 const [sourceLabel, setSourceLabel] = useState<string>('')
53 const [baseName, setBaseName] = useState<string>('mesh')
54 const [parseWarnings, setParseWarnings] = useState<string[]>([])
55 const [error, setError] = useState<string | null>(null)
56 const [exportFormatId, setExportFormatId] = useState(formats[0].id)
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)
63 const [viewMode, setViewMode] = useState<ViewMode>('both')
64 const [source, setSource] = useState<MeshSource | null>(null)
65 const [shareStatus, setShareStatus] = useState<ShareStatus | null>(null)
66 const fileInputRef = useRef<HTMLInputElement>(null)
67 const shareLoadAttempted = useRef(false)
69 const exportFormat = formats.find((f) => f.id === exportFormatId) ?? formats[0]
70 const sourceFormat =
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) : []
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 }, [])
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())
121 setSource({ kind: 'sample' })
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)
136 setSource({ kind: 'file', formatId: format.id, bytes: payload.bytes })
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 }
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 }
167 setBusy('parsing')
168 try {
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)
174 setSourceLabel(`${file.name} (${format.label})`)
175 setBaseName(file.name.replace(/\.[^.]+$/, ''))
176 setSource({ kind: 'file', formatId: format.id, bytes })
177 setShareStatus(null)
178 clearShareHash()
179 } catch (e) {
180 setError(e instanceof Error ? e.message : String(e))
181 } finally {
182 setBusy(null)
183 }
184 }
186 const loadSample = () => {
187 setError(null)
188 setMesh(makeSampleMesh())
189 setExportSizes(null)
190 setParseWarnings([])
191 setSourceLabel('built-in sample')
192 setBaseName('rainbow_torus')
193 setSource({ kind: 'sample' })
194 setShareStatus(null)
195 clearShareHash()
196 }
198 const shareMesh = async () => {
199 if (!source) return
200 setShareStatus(null)
201 try {
202 const url = await buildShareUrl({
203 name: baseName,
204 formatId: source.kind === 'file' ? source.formatId : null,
205 exportFormatId,
206 viewMode,
207 bytes: source.kind === 'file' ? source.bytes : new Uint8Array(0),
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 }
222 }
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 }
235 const estimateSizes = async () => {
236 if (!mesh || !source) return
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) {
247 const size = await exportSizeFor(format)
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 }
255 }
257 const downloadExport = async () => {
258 if (!mesh || !source) return
259 setError(null)
260 setBusy('exporting')
261 try {
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 }
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 }
287 }
289 return (
290 <div className="app">
291 <div className="sidebar">
292 <h1>Mesh Converter</h1>
293 <p className="tagline">
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.
296 </p>
297 <div className={`engine-status ${engine.state}`}>{engine.message}</div>
299 <section>
300 <h2>Load</h2>
301 <div className="button-row">
302 <button
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>
311 </div>
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>
332 {vertexCount(mesh)} vertices, {faceCount(mesh)} faces
333 </div>
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>
340 {parseWarnings.length > 0 && (
341 <p className="footnote">{parseWarnings.join('; ')}</p>
342 )}
343 </div>
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}
354 {exportSizes?.[f.id] != null ? ` (${formatBytes(exportSizes[f.id])})` : ''}
355 </option>
356 ))}
357 </select>
358 <p className="format-blurb">{exportFormat.blurb}</p>
359 {identicalToSource ? (
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 ? (
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 )}
371 <button
372 className="primary"
373 onClick={downloadExport}
374 disabled={!engineReady || busy !== null}
375 >
376 {busy === 'exporting' ? 'Converting…' : `Download ${exportFormat.extension}`}
377 </button>
378 </section>
379 )}
381 {mesh && source && (
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 )}
420 <section>
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>
428 {exportSizes && <th>size</th>}
429 </tr>
430 </thead>
431 <tbody>
432 {formats.map((f) => (
433 <tr
434 key={f.id}
435 className={f.id === exportFormatId ? 'selected' : ''}
436 onClick={() => setExportFormatId(f.id)}
437 title={`Export as ${f.label}`}
438 >
439 <td>
440 {f.id.toUpperCase()} <span className="ext">{f.extension}</span>
441 </td>
442 <td>{f.capabilities.normals ? '✓' : '—'}</td>
443 <td>{f.capabilities.colors ? '✓' : '—'}</td>
444 {exportSizes && (
445 <td className="size">
446 {exportSizes[f.id] != null ? formatBytes(exportSizes[f.id]) : '…'}
447 </td>
448 )}
449 </tr>
450 ))}
451 </tbody>
452 </table>
453 {mesh && (
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 )}
462 <p className="footnote">
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.
466 </p>
467 </section>
468 </div>
470 <div className="viewport">
471 {mesh ? (
472 <MeshView mesh={mesh} mode={viewMode} onModeChange={setViewMode} />
473 ) : (
474 <div className="empty-state">
475 <p>No mesh loaded.</p>
476 <p>Open a {acceptedExtensions.join(', ')} file — or load the sample.</p>
477 </div>
478 )}
479 </div>
480 </div>
481 )
484export default App
moveopenescclose