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