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`
48}
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 [anaglyph, setAnaglyph] = useState(false)
65 const [source, setSource] = useState<MeshSource | null>(null)
66 const [shareStatus, setShareStatus] = useState<ShareStatus | null>(null)
67 const fileInputRef = useRef<HTMLInputElement>(null)
68 const shareLoadAttempted = useRef(false)
70 const exportFormat = formats.find((f) => f.id === exportFormatId) ?? formats[0]
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) : []
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 }, [])
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 }
120 setAnaglyph(payload.anaglyph)
121 if (payload.formatId === null) {
122 setMesh(makeSampleMesh())
123 setSource({ kind: 'sample' })
124 setParseWarnings([])
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)
138 setSource({ kind: 'file', formatId: format.id, bytes: payload.bytes })
139 setParseWarnings(info.warnings)
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 }
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 }
169 setBusy('parsing')
170 try {
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)
176 setSourceLabel(`${file.name} (${format.label})`)
177 setBaseName(file.name.replace(/\.[^.]+$/, ''))
178 setSource({ kind: 'file', formatId: format.id, bytes })
179 setShareStatus(null)
180 clearShareHash()
181 } catch (e) {
182 setError(e instanceof Error ? e.message : String(e))
183 } finally {
184 setBusy(null)
185 }
186 }
188 const loadSample = () => {
189 setError(null)
190 setMesh(makeSampleMesh())
191 setExportSizes(null)
192 setParseWarnings([])
193 setSourceLabel('built-in sample')
194 setBaseName('rainbow_torus')
195 setSource({ kind: 'sample' })
196 setShareStatus(null)
197 clearShareHash()
198 }
200 const shareMesh = async () => {
201 if (!source) return
202 setShareStatus(null)
203 try {
204 const url = await buildShareUrl({
205 name: baseName,
206 formatId: source.kind === 'file' ? source.formatId : null,
207 exportFormatId,
208 viewMode,
209 anaglyph,
210 bytes: source.kind === 'file' ? source.bytes : new Uint8Array(0),
211 })
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 }
225 }
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 }
238 const estimateSizes = async () => {
239 if (!mesh || !source) return
240 setError(null)
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) {
250 const size = await exportSizeFor(format)
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 }
258 }
260 const downloadExport = async () => {
261 if (!mesh || !source) return
262 setError(null)
263 setBusy('exporting')
264 try {
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 }
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 }
290 }
292 return (
293 <div className="app">
294 <div className="sidebar">
295 <h1>Mesh Converter</h1>
296 <p className="tagline">
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.
299 </p>
300 <div className={`engine-status ${engine.state}`}>{engine.message}</div>
302 <section>
303 <h2>Load</h2>
304 <div className="button-row">
305 <button
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>
314 </div>
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>
335 {vertexCount(mesh)} vertices, {faceCount(mesh)} faces
336 </div>
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>
343 {parseWarnings.length > 0 && (
344 <p className="footnote">{parseWarnings.join('; ')}</p>
345 )}
346 </div>
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}
357 {exportSizes?.[f.id] != null ? ` (${formatBytes(exportSizes[f.id])})` : ''}
358 </option>
359 ))}
360 </select>
361 <p className="format-blurb">{exportFormat.blurb}</p>
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 ? (
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 )}
374 <button
375 className="primary"
376 onClick={downloadExport}
377 disabled={!engineReady || busy !== null}
378 >
379 {busy === 'exporting' ? 'Converting…' : `Download ${exportFormat.extension}`}
380 </button>
381 </section>
382 )}
384 {mesh && source && (
385 <section>
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 )}
423 <section>
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>
431 {exportSizes && <th>size</th>}
432 </tr>
433 </thead>
434 <tbody>
435 {formats.map((f) => (
436 <tr
437 key={f.id}
438 className={f.id === exportFormatId ? 'selected' : ''}
439 onClick={() => setExportFormatId(f.id)}
440 title={`Export as ${f.label}`}
441 >
442 <td>
443 {f.id.toUpperCase()} <span className="ext">{f.extension}</span>
444 </td>
445 <td>{f.capabilities.normals ? '✓' : '—'}</td>
446 <td>{f.capabilities.colors ? '✓' : '—'}</td>
447 {exportSizes && (
448 <td className="size">
449 {exportSizes[f.id] != null ? formatBytes(exportSizes[f.id]) : '…'}
450 </td>
451 )}
452 </tr>
453 ))}
454 </tbody>
455 </table>
456 {mesh && (
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 )}
465 <p className="footnote">
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.
469 </p>
470 </section>
471 </div>
473 <div className="viewport">
474 {mesh ? (
475 <MeshView
476 mesh={mesh}
477 mode={viewMode}
478 onModeChange={setViewMode}
479 anaglyph={anaglyph}
480 onAnaglyphChange={setAnaglyph}
481 />
482 ) : (
483 <div className="empty-state">
484 <p>No mesh loaded.</p>
485 <p>Open a {acceptedExtensions.join(', ')} file — or load the sample.</p>
486 </div>
487 )}
488 </div>
489 </div>
490 )
491}
493export default App