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 {
9 estimateExportSize,
10 getMeshioVersion,
11 initMeshio,
12 parseMeshFile,
13 serializeMesh,
14} from './mesh/meshio'
15import { makeSampleMesh } from './mesh/sample'
16import { buildShareUrl, MAX_SHARE_URL_CHARS, parseShareHash } from './share'
17import './App.css'
19type EngineState = 'loading' | 'ready' | 'error'
21/**
22 * What a share link would carry: the original uploaded file (so the recipient
23 * gets byte-identical data in the original format), or a marker for the
24 * generated sample mesh.
25 */
26type ShareSource =
27 | { kind: 'file'; formatId: string; bytes: Uint8Array<ArrayBuffer> }
28 | { kind: 'sample' }
30type ShareStatus =
31 | { kind: 'copied'; chars: number }
32 | { kind: 'manual'; url: string } // clipboard unavailable — show the link for hand-copying
33 | { kind: 'too-large'; chars: number }
34 | { kind: 'error'; message: string }
36function formatBytes(n: number): string {
37 if (n < 1024) return `${n} B`
38 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
39 return `${(n / 1024 / 1024).toFixed(1)} MB`
40}
42function App() {
43 const [mesh, setMesh] = useState<MeshData | null>(null)
44 const [sourceLabel, setSourceLabel] = useState<string>('')
45 const [baseName, setBaseName] = useState<string>('mesh')
46 const [parseWarnings, setParseWarnings] = useState<string[]>([])
47 const [error, setError] = useState<string | null>(null)
48 const [exportFormatId, setExportFormatId] = useState(formats[0].id)
49 const [engine, setEngine] = useState<{ state: EngineState; message: string }>({
50 state: 'loading',
51 message: 'Loading mesh engine…',
52 })
53 const [busy, setBusy] = useState<'parsing' | 'exporting' | 'sizing' | null>(null)
54 const [exportSizes, setExportSizes] = useState<Record<string, number> | null>(null)
55 const [viewMode, setViewMode] = useState<ViewMode>('both')
56 const [shareSource, setShareSource] = useState<ShareSource | null>(null)
57 const [shareStatus, setShareStatus] = useState<ShareStatus | null>(null)
58 const fileInputRef = useRef<HTMLInputElement>(null)
59 const shareLoadAttempted = useRef(false)
61 const exportFormat = formats.find((f) => f.id === exportFormatId) ?? formats[0]
62 const losses = mesh ? conversionLosses(mesh, exportFormat) : []
63 const engineReady = engine.state === 'ready'
65 useEffect(() => {
66 let cancelled = false
67 initMeshio((message) => {
68 if (!cancelled) setEngine({ state: 'loading', message })
69 })
70 .then(() => getMeshioVersion())
71 .then((version) => {
72 if (!cancelled) setEngine({ state: 'ready', message: `meshio ${version} ready` })
73 })
74 .catch((e) => {
75 if (!cancelled) {
76 setEngine({
77 state: 'error',
78 message: `Mesh engine failed to load: ${e instanceof Error ? e.message : String(e)}`,
79 })
80 }
81 })
82 return () => {
83 cancelled = true
84 }
85 }, [])
87 // Load a mesh from a #share=… link on startup (parseMeshFile waits for the
88 // engine init kicked off above).
89 useEffect(() => {
90 if (shareLoadAttempted.current) return
91 shareLoadAttempted.current = true
92 ;(async () => {
93 let payload
94 try {
95 payload = await parseShareHash(window.location.hash)
96 } catch (e) {
97 setError(`Could not read the share link: ${e instanceof Error ? e.message : String(e)}`)
98 return
99 }
100 if (!payload) return
101 if (formats.some((f) => f.id === payload.exportFormatId)) {
102 setExportFormatId(payload.exportFormatId)
103 }
104 if (VIEW_MODES.some((m) => m.id === payload.viewMode)) {
105 setViewMode(payload.viewMode as ViewMode)
106 }
107 if (payload.formatId === null) {
108 setMesh(makeSampleMesh())
109 setShareSource({ kind: 'sample' })
110 setParseWarnings([])
111 setSourceLabel('built-in sample (from share link)')
112 setBaseName(payload.name || 'rainbow_torus')
113 return
114 }
115 const format = formats.find((f) => f.id === payload.formatId)
116 if (!format) {
117 setError(`Could not read the share link: unknown mesh format "${payload.formatId}"`)
118 return
119 }
120 setBusy('parsing')
121 try {
122 const { mesh: parsed, info } = await parseMeshFile(payload.bytes, format)
123 setMesh(parsed)
124 setShareSource({ kind: 'file', formatId: format.id, bytes: payload.bytes })
125 setParseWarnings(info.warnings)
126 setSourceLabel(`${payload.name}${format.extension} (${format.label}, from share link)`)
127 setBaseName(payload.name || 'mesh')
128 } catch (e) {
129 setError(
130 `Could not load the shared mesh: ${e instanceof Error ? e.message : String(e)}`,
131 )
132 } finally {
133 setBusy(null)
134 }
135 })()
136 }, [])
138 // A share link only describes the mesh it was created for — drop it from
139 // the address bar once a different mesh is loaded.
140 const clearShareHash = () => {
141 if (window.location.hash) {
142 history.replaceState(null, '', window.location.pathname + window.location.search)
143 }
144 }
146 const handleFile = async (file: File) => {
147 setError(null)
148 const format = formatForFilename(file.name)
149 if (!format) {
150 setError(
151 `Unrecognized extension on "${file.name}". Supported: ${acceptedExtensions.join(', ')}`,
152 )
153 return
154 }
155 setBusy('parsing')
156 try {
157 const bytes = new Uint8Array(await file.arrayBuffer())
158 const { mesh: parsed, info } = await parseMeshFile(bytes, format)
159 setMesh(parsed)
160 setExportSizes(null)
161 setParseWarnings(info.warnings)
162 setSourceLabel(`${file.name} (${format.label})`)
163 setBaseName(file.name.replace(/\.[^.]+$/, ''))
164 setShareSource({ kind: 'file', formatId: format.id, bytes })
165 setShareStatus(null)
166 clearShareHash()
167 } catch (e) {
168 setError(e instanceof Error ? e.message : String(e))
169 } finally {
170 setBusy(null)
171 }
172 }
174 const loadSample = () => {
175 setError(null)
176 setMesh(makeSampleMesh())
177 setExportSizes(null)
178 setParseWarnings([])
179 setSourceLabel('built-in sample')
180 setBaseName('rainbow_torus')
181 setShareSource({ kind: 'sample' })
182 setShareStatus(null)
183 clearShareHash()
184 }
186 const shareMesh = async () => {
187 if (!shareSource) return
188 setShareStatus(null)
189 try {
190 const url = await buildShareUrl({
191 name: baseName,
192 formatId: shareSource.kind === 'file' ? shareSource.formatId : null,
193 exportFormatId,
194 viewMode,
195 bytes: shareSource.kind === 'file' ? shareSource.bytes : new Uint8Array(0),
196 })
197 if (url.length > MAX_SHARE_URL_CHARS) {
198 setShareStatus({ kind: 'too-large', chars: url.length })
199 return
200 }
201 try {
202 await navigator.clipboard.writeText(url)
203 setShareStatus({ kind: 'copied', chars: url.length })
204 } catch {
205 setShareStatus({ kind: 'manual', url })
206 }
207 } catch (e) {
208 setShareStatus({ kind: 'error', message: e instanceof Error ? e.message : String(e) })
209 }
210 }
212 const estimateSizes = async () => {
213 if (!mesh) return
214 setError(null)
215 setBusy('sizing')
216 setExportSizes({})
217 try {
218 // pure-Python formats first, so a first-time h5py download doesn't
219 // hold up the quick results
220 const ordered = [...formats].sort(
221 (a, b) => (a.pyodidePackages?.length ?? 0) - (b.pyodidePackages?.length ?? 0),
222 )
223 for (const format of ordered) {
224 const size = await estimateExportSize(mesh, format)
225 setExportSizes((prev) => ({ ...(prev ?? {}), [format.id]: size }))
226 }
227 } catch (e) {
228 setError(e instanceof Error ? e.message : String(e))
229 } finally {
230 setBusy(null)
231 }
232 }
234 const downloadExport = async () => {
235 if (!mesh) return
236 setError(null)
237 setBusy('exporting')
238 try {
239 const bytes = await serializeMesh(mesh, exportFormat)
240 const base = baseName.replace(/[^\w-]+/g, '_').toLowerCase() || 'mesh'
241 const blob = new Blob([bytes], { type: 'application/octet-stream' })
242 const url = URL.createObjectURL(blob)
243 const a = document.createElement('a')
244 a.href = url
245 a.download = base + exportFormat.extension
246 a.click()
247 URL.revokeObjectURL(url)
248 } catch (e) {
249 setError(e instanceof Error ? e.message : String(e))
250 } finally {
251 setBusy(null)
252 }
253 }
255 return (
256 <div className="app">
257 <div className="sidebar">
258 <h1>Mesh Converter</h1>
259 <p className="tagline">
260 Load a mesh, inspect it in 3D, export to another format. Conversion runs entirely in
261 your browser via <a href="https://github.com/nschloe/meshio">meshio</a> on Pyodide.
262 </p>
263 <div className={`engine-status ${engine.state}`}>{engine.message}</div>
265 <section>
266 <h2>Load</h2>
267 <div className="button-row">
268 <button
269 onClick={() => fileInputRef.current?.click()}
270 disabled={!engineReady || busy !== null}
271 >
272 {busy === 'parsing' ? 'Reading…' : 'Open mesh file…'}
273 </button>
274 <button onClick={loadSample} disabled={busy !== null}>
275 Load sample
276 </button>
277 </div>
278 <input
279 ref={fileInputRef}
280 type="file"
281 accept={acceptedExtensions.join(',')}
282 hidden
283 onChange={(e) => {
284 const file = e.target.files?.[0]
285 if (file) handleFile(file)
286 e.target.value = ''
287 }}
288 />
289 {error && <div className="error">{error}</div>}
290 </section>
292 {mesh && (
293 <section>
294 <h2>Loaded mesh</h2>
295 <div className="mesh-info">
296 <div className="source">{sourceLabel}</div>
297 <div>
298 {vertexCount(mesh)} vertices, {faceCount(mesh)} faces
299 </div>
300 <div className="chips">
301 <span className="chip on">positions</span>
302 <span className="chip on">faces</span>
303 <span className={`chip ${mesh.normals ? 'on' : ''}`}>normals</span>
304 <span className={`chip ${mesh.colors ? 'on' : ''}`}>colors</span>
305 </div>
306 {parseWarnings.length > 0 && (
307 <p className="footnote">{parseWarnings.join('; ')}</p>
308 )}
309 </div>
310 </section>
311 )}
313 {mesh && (
314 <section>
315 <h2>Export</h2>
316 <select value={exportFormatId} onChange={(e) => setExportFormatId(e.target.value)}>
317 {formats.map((f) => (
318 <option key={f.id} value={f.id}>
319 {f.label} — {f.extension}
320 {exportSizes?.[f.id] != null ? ` (${formatBytes(exportSizes[f.id])})` : ''}
321 </option>
322 ))}
323 </select>
324 <p className="format-blurb">{exportFormat.blurb}</p>
325 {losses.length > 0 ? (
326 <div className="warning">
327 Exporting to {exportFormat.extension} will drop:{' '}
328 <strong>{losses.join(', ')}</strong>
329 </div>
330 ) : (
331 <div className="ok">Lossless — this format keeps everything in the loaded mesh.</div>
332 )}
333 <button
334 className="primary"
335 onClick={downloadExport}
336 disabled={!engineReady || busy !== null}
337 >
338 {busy === 'exporting' ? 'Converting…' : `Download ${exportFormat.extension}`}
339 </button>
340 </section>
341 )}
343 {mesh && shareSource && (
344 <section>
345 <h2>Share</h2>
346 <button onClick={shareMesh} disabled={busy !== null}>
347 Copy share link
348 </button>
349 {shareStatus?.kind === 'copied' && (
350 <div className="ok">
351 Link copied to clipboard ({shareStatus.chars.toLocaleString()} characters).
352 </div>
353 )}
354 {shareStatus?.kind === 'manual' && (
355 <div className="warning">
356 Couldn’t write to the clipboard — copy the link below by hand.
357 <input
358 className="share-url"
359 readOnly
360 value={shareStatus.url}
361 onFocus={(e) => e.target.select()}
362 />
363 </div>
364 )}
365 {shareStatus?.kind === 'too-large' && (
366 <div className="warning">
367 This mesh is too large to share by URL: the link would be{' '}
368 {shareStatus.chars.toLocaleString()} characters, beyond the{' '}
369 {MAX_SHARE_URL_CHARS.toLocaleString()} that links can reliably carry. Download
370 the file and share it directly instead.
371 </div>
372 )}
373 {shareStatus?.kind === 'error' && <div className="error">{shareStatus.message}</div>}
374 <p className="footnote">
375 The link embeds the compressed mesh (the original file) plus the export and view
376 settings in the URL itself — nothing is uploaded anywhere. Whoever opens it can
377 view the mesh and download it in any format.
378 </p>
379 </section>
380 )}
382 <section>
383 <h2>Formats</h2>
384 <table className="format-table">
385 <thead>
386 <tr>
387 <th>Format</th>
388 <th>normals</th>
389 <th>colors</th>
390 {exportSizes && <th>size</th>}
391 </tr>
392 </thead>
393 <tbody>
394 {formats.map((f) => (
395 <tr
396 key={f.id}
397 className={f.id === exportFormatId ? 'selected' : ''}
398 onClick={() => setExportFormatId(f.id)}
399 title={`Export as ${f.label}`}
400 >
401 <td>
402 {f.id.toUpperCase()} <span className="ext">{f.extension}</span>
403 </td>
404 <td>{f.capabilities.normals ? '✓' : '—'}</td>
405 <td>{f.capabilities.colors ? '✓' : '—'}</td>
406 {exportSizes && (
407 <td className="size">
408 {exportSizes[f.id] != null ? formatBytes(exportSizes[f.id]) : '…'}
409 </td>
410 )}
411 </tr>
412 ))}
413 </tbody>
414 </table>
415 {mesh && (
416 <button
417 className="subtle"
418 onClick={estimateSizes}
419 disabled={!engineReady || busy !== null}
420 >
421 {busy === 'sizing' ? 'Estimating sizes…' : 'Estimate export sizes for this mesh'}
422 </button>
423 )}
424 <p className="footnote">
425 All formats store positions and triangle faces; ✓ marks the extra attributes this app
426 preserves on export. Quads and polygons are triangulated on import. Click a row to
427 choose the export format.
428 </p>
429 </section>
430 </div>
432 <div className="viewport">
433 {mesh ? (
434 <MeshView mesh={mesh} mode={viewMode} onModeChange={setViewMode} />
435 ) : (
436 <div className="empty-state">
437 <p>No mesh loaded.</p>
438 <p>Open a {acceptedExtensions.join(', ')} file — or load the sample.</p>
439 </div>
440 )}
441 </div>
442 </div>
443 )
444}
446export default App