Convert real mesh formats with meshio via Pyodide
Replace the invented text formats with 19 real formats (PLY, OBJ, STL,
OFF, VTK, VTU, Gmsh, XDMF, MED, H5M, AVS-UCD, Abaqus, Nastran, Medit,
Netgen, MDPA, Tecplot, DOLFIN XML, PERMAS), read and written by meshio
running in-browser on Pyodide. HDF5-based formats load h5py lazily on
first use. Adds on-demand export size estimates, clickable format rows,
and shaded/wire/both/points view modes.
20 changed files+9159−18846
README.mdmodified+61−16View file
@@ -1,21 +1,57 @@
11 # mesh-converter
22
33 A static web app for converting meshes between formats: upload a mesh file,
4-inspect it in an interactive 3D view, and download it in a format of your
5-choice. Formats differ in what they can store, and the UI shows exactly which
6-attributes (normals, colors, name) would be dropped by a lossy conversion.
4+inspect it in an interactive 3D view (shaded, wireframe, shaded + wireframe,
5+or points), and download it in a format of your choice. Formats differ in what they can store, and the UI shows exactly which
6+attributes (normals, colors) would be dropped by a lossy conversion.
77
8-Currently ships with three **invented text formats** to illustrate the
9-workflow; real formats will replace them later:
8+Conversion is powered by [meshio](https://github.com/nschloe/meshio) running
9+in the browser via [Pyodide](https://pyodide.org) — no server, all mesh I/O
10+happens client-side. The first visit downloads the Python runtime (~15 MB from
11+the jsDelivr CDN, cached afterwards).
1012
11-| Format | Extension | positions/faces | normals | colors | name |
13+| Format | Extension | positions/faces | normals | colors | notes |
1214 | --- | --- | --- | --- | --- | --- |
13-| MOPF (Mesh Omni-Portable Format) | `.mopf` | ✓ | ✓ | ✓ | ✓ |
14-| TRICOL (TriColor Interchange) | `.tricol` | ✓ | — | ✓ | — |
15-| BMSH (BareMesh) | `.bmsh` | ✓ | — | — | — |
15+| PLY (Polygon File Format) | `.ply` | ✓ | ✓ | ✓ | |
16+| Wavefront OBJ | `.obj` | ✓ | ✓ | — | |
17+| STL | `.stl` | ✓ | — | — | binary |
18+| OFF (Object File Format) | `.off` | ✓ | — | — | |
19+| VTK legacy | `.vtk` | ✓ | ✓ | ✓ | |
20+| VTU (VTK XML) | `.vtu` | ✓ | ✓ | ✓ | |
21+| Gmsh MSH | `.msh` | ✓ | — | — | |
22+| XDMF | `.xdmf` | ✓ | ✓ | ✓ | h5py† |
23+| MED (Salome) | `.med` | ✓ | ✓ | ✓ | h5py† |
24+| H5M (MOAB) | `.h5m` | ✓ | ✓ | ✓ | h5py† |
25+| AVS-UCD | `.avs` | ✓ | ✓ | ✓ | |
26+| Abaqus | `.inp` | ✓ | — | — | |
27+| Nastran | `.bdf` | ✓ | — | — | |
28+| Medit | `.mesh` | ✓ | — | — | |
29+| Netgen | `.vol` | ✓ | — | — | |
30+| MDPA (Kratos) | `.mdpa` | ✓ | — | — | |
31+| Tecplot | `.dat` | ✓ | — | — | |
32+| DOLFIN XML | `.xml` | ✓ | — | — | |
33+| PERMAS | `.post` | ✓ | — | — | |
34+
35+† HDF5-based formats need the h5py Pyodide package (~4 MB); it is loaded
36+lazily the first time such a format is used, so the default footprint stays
37+small.
38+
39+✓ marks what this app preserves when writing the format: vertex normals map to
40+each format's native representation (`nx/ny/nz` properties in PLY, `vn` lines
41+in OBJ, a `Normals` point-data array elsewhere) and vertex colors likewise
42+(`red/green/blue` in PLY, an `RGB` point-data array elsewhere). On import,
43+quads and polygons are fan-triangulated; volume cells are skipped.
44+
45+Some meshio formats are deliberately absent: ansys, cgns, su2, and ugrid
46+cannot roundtrip their own output in meshio 5.3.5; exodus fails writing under
47+wasm; flac3d holds volume cells only; wkt's reader hangs on non-toy meshes;
48+tetgen spans two files; svg is write-only.
1649
1750 A built-in sample mesh (a rainbow torus with normals and vertex colors) is
18-available from the UI for trying things out without a file.
51+available from the UI for trying things out without a file, and `examples/`
52+holds it pre-exported in a few formats. An on-demand "estimate export sizes"
53+action serializes the loaded mesh to every format in memory and shows the
54+resulting file sizes in the format table and export dropdown.
1955
2056 ## Development
2157
@@ -27,10 +63,19 @@ npm run build # static build in dist/
2763
2864 Built with Vite, React, TypeScript, and three.js (react-three-fiber).
2965
30-## Adding a format
66+## How it works
3167
32-Implement the `MeshFormat` interface in `src/mesh/formats/` (parse and
33-serialize against the internal `MeshData` representation, declaring which
34-optional attributes the format supports) and register it in
35-`src/mesh/formats/index.ts`. The upload, viewer, capability table, and
36-loss-warning UI pick it up automatically.
68+- `src/mesh/bridge.py` runs inside Pyodide: it reads/writes mesh files with
69+ meshio and exchanges arrays with JS as raw little-endian buffers through
70+ Pyodide's in-memory filesystem.
71+- `src/mesh/meshio.ts` loads Pyodide (script tag in `index.html`), installs
72+ meshio via micropip, and wraps the bridge in typed async
73+ `parseMeshFile`/`serializeMesh` functions built on the internal `MeshData`
74+ representation (typed arrays of positions, triangle indices, optional
75+ normals/colors).
76+- `src/mesh/formats.ts` declares the supported formats and which attributes
77+ each preserves; the upload, capability table, and loss-warning UI derive
78+ from it. To add a meshio-supported format, add a descriptor there (with
79+ `pyodidePackages` if it needs extra prebuilt packages such as h5py) and, if
80+ it stores normals/colors in a format-specific way, teach
81+ `bridge.py` how to map them.
examples/rainbow_torus.bmshdeleted+0−6146View file
This diff is 6,151 lines long and is not shown.
examples/rainbow_torus.mopfdeleted+0−6148View file
This diff is 6,153 lines long and is not shown.
examples/rainbow_torus.objadded+8193−0View file
This diff is 8,198 lines long and is not shown.
examples/rainbow_torus.plyadded+0−0View file
Binary file not shown.
examples/rainbow_torus.stladded+0−0View file
Binary file not shown.
examples/rainbow_torus.tricoldeleted+0−6145View file
This diff is 6,150 lines long and is not shown.
index.htmlmodified+1−0View file
@@ -5,6 +5,7 @@
55 <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
66 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
77 <title>Mesh Converter</title>
8+ <script src="https://cdn.jsdelivr.net/pyodide/v314.0.2/full/pyodide.js"></script>
89 </head>
910 <body>
1011 <div id="root"></div>
src/App.cssmodified+97−1View file
@@ -19,11 +19,32 @@
1919 }
2020
2121 .tagline {
22- margin: 0 0 16px;
22+ margin: 0 0 10px;
2323 color: #9aa1ac;
2424 font-size: 13px;
2525 }
2626
27+.tagline a {
28+ color: #7fb0e0;
29+}
30+
31+.engine-status {
32+ margin: 0 0 16px;
33+ font-size: 12px;
34+}
35+
36+.engine-status.loading {
37+ color: #ecc477;
38+}
39+
40+.engine-status.ready {
41+ color: #8fd7ab;
42+}
43+
44+.engine-status.error {
45+ color: #f2a3a3;
46+}
47+
2748 .sidebar section {
2849 margin-bottom: 22px;
2950 }
@@ -161,6 +182,22 @@ select {
161182 font-weight: 500;
162183 }
163184
185+.format-table tbody tr {
186+ cursor: pointer;
187+}
188+
189+.format-table tbody tr:hover {
190+ background: #262b34;
191+}
192+
193+.format-table tbody tr.selected {
194+ background: rgba(47, 111, 179, 0.18);
195+}
196+
197+.format-table tbody tr.selected td:first-child {
198+ color: #9cc4ea;
199+}
200+
164201 .format-table th:not(:first-child),
165202 .format-table td:not(:first-child) {
166203 text-align: center;
@@ -171,6 +208,29 @@ select {
171208 font-size: 12px;
172209 }
173210
211+.format-table td.size {
212+ color: #9aa1ac;
213+ white-space: nowrap;
214+}
215+
216+button.subtle {
217+ margin-top: 8px;
218+ padding: 3px 8px;
219+ font-size: 12px;
220+ color: #9aa1ac;
221+ background: transparent;
222+ border-color: #2c313a;
223+}
224+
225+button.subtle:hover {
226+ background: #2a2f39;
227+}
228+
229+button.subtle:disabled {
230+ opacity: 0.5;
231+ cursor: default;
232+}
233+
174234 .footnote {
175235 margin: 8px 0 0;
176236 color: #6b7280;
@@ -183,6 +243,42 @@ select {
183243 position: relative;
184244 }
185245
246+.view-toolbar {
247+ position: absolute;
248+ top: 12px;
249+ right: 12px;
250+ z-index: 1;
251+ display: flex;
252+ gap: 0;
253+ border: 1px solid #3a4150;
254+ border-radius: 6px;
255+ overflow: hidden;
256+ background: rgba(30, 33, 40, 0.85);
257+}
258+
259+.view-toolbar button {
260+ border: none;
261+ border-radius: 0;
262+ background: transparent;
263+ padding: 5px 10px;
264+ font-size: 12px;
265+ color: #9aa1ac;
266+}
267+
268+.view-toolbar button + button {
269+ border-left: 1px solid #3a4150;
270+}
271+
272+.view-toolbar button:hover {
273+ background: #2a2f39;
274+ color: #e2e5ea;
275+}
276+
277+.view-toolbar button.active {
278+ background: rgba(47, 111, 179, 0.35);
279+ color: #d9e8f7;
280+}
281+
186282 .empty-state {
187283 height: 100%;
188284 display: flex;
src/App.tsxmodified+147−28View file
@@ -1,20 +1,66 @@
1-import { useRef, useState } from 'react'
1+import { useEffect, useRef, useState } from 'react'
22 import { MeshView } from './MeshView'
33 import type { MeshData } from './mesh/types'
44 import { faceCount, vertexCount } from './mesh/types'
55 import { acceptedExtensions, conversionLosses, formatForFilename, formats } from './mesh/formats'
6+import {
7+ estimateExportSize,
8+ getMeshioVersion,
9+ initMeshio,
10+ parseMeshFile,
11+ serializeMesh,
12+} from './mesh/meshio'
613 import { makeSampleMesh } from './mesh/sample'
714 import './App.css'
815
16+type EngineState = 'loading' | 'ready' | 'error'
17+
18+function formatBytes(n: number): string {
19+ if (n < 1024) return `${n} B`
20+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
21+ return `${(n / 1024 / 1024).toFixed(1)} MB`
22+}
23+
924 function App() {
1025 const [mesh, setMesh] = useState<MeshData | null>(null)
1126 const [sourceLabel, setSourceLabel] = useState<string>('')
27+ const [baseName, setBaseName] = useState<string>('mesh')
28+ const [parseWarnings, setParseWarnings] = useState<string[]>([])
1229 const [error, setError] = useState<string | null>(null)
1330 const [exportFormatId, setExportFormatId] = useState(formats[0].id)
31+ const [engine, setEngine] = useState<{ state: EngineState; message: string }>({
32+ state: 'loading',
33+ message: 'Loading mesh engine…',
34+ })
35+ const [busy, setBusy] = useState<'parsing' | 'exporting' | 'sizing' | null>(null)
36+ const [exportSizes, setExportSizes] = useState<Record<string, number> | null>(null)
1437 const fileInputRef = useRef<HTMLInputElement>(null)
1538
1639 const exportFormat = formats.find((f) => f.id === exportFormatId) ?? formats[0]
1740 const losses = mesh ? conversionLosses(mesh, exportFormat) : []
41+ const engineReady = engine.state === 'ready'
42+
43+ useEffect(() => {
44+ let cancelled = false
45+ initMeshio((message) => {
46+ if (!cancelled) setEngine({ state: 'loading', message })
47+ })
48+ .then(() => getMeshioVersion())
49+ .then((version) => {
50+ if (!cancelled) setEngine({ state: 'ready', message: `meshio ${version} ready` })
51+ })
52+ .catch((e) => {
53+ if (!cancelled) {
54+ setEngine({
55+ state: 'error',
56+ message: `Mesh engine failed to load: ${e instanceof Error ? e.message : String(e)}`,
57+ })
58+ }
59+ })
60+ return () => {
61+ cancelled = true
62+ }
63+ }, [])
1864
1965 const handleFile = async (file: File) => {
2066 setError(null)
@@ -25,32 +71,72 @@ function App() {
2571 )
2672 return
2773 }
74+ setBusy('parsing')
2875 try {
29- const text = await file.text()
30- setMesh(format.parse(text))
76+ const bytes = new Uint8Array(await file.arrayBuffer())
77+ const { mesh: parsed, info } = await parseMeshFile(bytes, format)
78+ setMesh(parsed)
79+ setExportSizes(null)
80+ setParseWarnings(info.warnings)
3181 setSourceLabel(`${file.name} (${format.label})`)
82+ setBaseName(file.name.replace(/\.[^.]+$/, ''))
3283 } catch (e) {
3384 setError(e instanceof Error ? e.message : String(e))
85+ } finally {
86+ setBusy(null)
3487 }
3588 }
3689
3790 const loadSample = () => {
3891 setError(null)
3992 setMesh(makeSampleMesh())
93+ setExportSizes(null)
94+ setParseWarnings([])
4095 setSourceLabel('built-in sample')
96+ setBaseName('rainbow_torus')
97+ }
98+
99+ const estimateSizes = async () => {
100+ if (!mesh) return
101+ setError(null)
102+ setBusy('sizing')
103+ setExportSizes({})
104+ try {
105+ // pure-Python formats first, so a first-time h5py download doesn't
106+ // hold up the quick results
107+ const ordered = [...formats].sort(
108+ (a, b) => (a.pyodidePackages?.length ?? 0) - (b.pyodidePackages?.length ?? 0),
109+ )
110+ for (const format of ordered) {
111+ const size = await estimateExportSize(mesh, format)
112+ setExportSizes((prev) => ({ ...(prev ?? {}), [format.id]: size }))
113+ }
114+ } catch (e) {
115+ setError(e instanceof Error ? e.message : String(e))
116+ } finally {
117+ setBusy(null)
118+ }
41119 }
42120
43- const downloadExport = () => {
121+ const downloadExport = async () => {
44122 if (!mesh) return
45- const text = exportFormat.serialize(mesh)
46- const base = (mesh.name ?? 'mesh').replace(/[^\w-]+/g, '_').toLowerCase() || 'mesh'
47- const blob = new Blob([text], { type: 'text/plain' })
48- const url = URL.createObjectURL(blob)
49- const a = document.createElement('a')
50- a.href = url
51- a.download = base + exportFormat.extension
52- a.click()
53- URL.revokeObjectURL(url)
123+ setError(null)
124+ setBusy('exporting')
125+ try {
126+ const bytes = await serializeMesh(mesh, exportFormat)
127+ const base = baseName.replace(/[^\w-]+/g, '_').toLowerCase() || 'mesh'
128+ const blob = new Blob([bytes], { type: 'application/octet-stream' })
129+ const url = URL.createObjectURL(blob)
130+ const a = document.createElement('a')
131+ a.href = url
132+ a.download = base + exportFormat.extension
133+ a.click()
134+ URL.revokeObjectURL(url)
135+ } catch (e) {
136+ setError(e instanceof Error ? e.message : String(e))
137+ } finally {
138+ setBusy(null)
139+ }
54140 }
55141
56142 return (
@@ -58,15 +144,23 @@ function App() {
58144 <div className="sidebar">
59145 <h1>Mesh Converter</h1>
60146 <p className="tagline">
61- Load a mesh, inspect it in 3D, export to another format. Formats differ in what they can
62- store — anything the target can't hold is dropped.
147+ Load a mesh, inspect it in 3D, export to another format. Conversion runs entirely in
148+ your browser via <a href="https://github.com/nschloe/meshio">meshio</a> on Pyodide.
63149 </p>
150+ <div className={`engine-status ${engine.state}`}>{engine.message}</div>
64151
65152 <section>
66153 <h2>Load</h2>
67154 <div className="button-row">
68- <button onClick={() => fileInputRef.current?.click()}>Open mesh file…</button>
69- <button onClick={loadSample}>Load sample</button>
155+ <button
156+ onClick={() => fileInputRef.current?.click()}
157+ disabled={!engineReady || busy !== null}
158+ >
159+ {busy === 'parsing' ? 'Reading…' : 'Open mesh file…'}
160+ </button>
161+ <button onClick={loadSample} disabled={busy !== null}>
162+ Load sample
163+ </button>
70164 </div>
71165 <input
72166 ref={fileInputRef}
@@ -88,16 +182,17 @@ function App() {
88182 <div className="mesh-info">
89183 <div className="source">{sourceLabel}</div>
90184 <div>
91- <strong>{mesh.name ?? '(unnamed)'}</strong> — {vertexCount(mesh)} vertices,{' '}
92- {faceCount(mesh)} faces
185+ {vertexCount(mesh)} vertices, {faceCount(mesh)} faces
93186 </div>
94187 <div className="chips">
95188 <span className="chip on">positions</span>
96189 <span className="chip on">faces</span>
97190 <span className={`chip ${mesh.normals ? 'on' : ''}`}>normals</span>
98191 <span className={`chip ${mesh.colors ? 'on' : ''}`}>colors</span>
99- <span className={`chip ${mesh.name ? 'on' : ''}`}>name</span>
100192 </div>
193+ {parseWarnings.length > 0 && (
194+ <p className="footnote">{parseWarnings.join('; ')}</p>
195+ )}
101196 </div>
102197 </section>
103198 )}
@@ -109,6 +204,7 @@ function App() {
109204 {formats.map((f) => (
110205 <option key={f.id} value={f.id}>
111206 {f.label} — {f.extension}
207+ {exportSizes?.[f.id] != null ? ` (${formatBytes(exportSizes[f.id])})` : ''}
112208 </option>
113209 ))}
114210 </select>
@@ -121,8 +217,12 @@ function App() {
121217 ) : (
122218 <div className="ok">Lossless — this format keeps everything in the loaded mesh.</div>
123219 )}
124- <button className="primary" onClick={downloadExport}>
125- Download {exportFormat.extension}
220+ <button
221+ className="primary"
222+ onClick={downloadExport}
223+ disabled={!engineReady || busy !== null}
224+ >
225+ {busy === 'exporting' ? 'Converting…' : `Download ${exportFormat.extension}`}
126226 </button>
127227 </section>
128228 )}
@@ -135,25 +235,44 @@ function App() {
135235 <th>Format</th>
136236 <th>normals</th>
137237 <th>colors</th>
138- <th>name</th>
238+ {exportSizes && <th>size</th>}
139239 </tr>
140240 </thead>
141241 <tbody>
142242 {formats.map((f) => (
143- <tr key={f.id}>
243+ <tr
244+ key={f.id}
245+ className={f.id === exportFormatId ? 'selected' : ''}
246+ onClick={() => setExportFormatId(f.id)}
247+ title={`Export as ${f.label}`}
248+ >
144249 <td>
145250 {f.id.toUpperCase()} <span className="ext">{f.extension}</span>
146251 </td>
147252 <td>{f.capabilities.normals ? '✓' : '—'}</td>
148253 <td>{f.capabilities.colors ? '✓' : '—'}</td>
149- <td>{f.capabilities.name ? '✓' : '—'}</td>
254+ {exportSizes && (
255+ <td className="size">
256+ {exportSizes[f.id] != null ? formatBytes(exportSizes[f.id]) : '…'}
257+ </td>
258+ )}
150259 </tr>
151260 ))}
152261 </tbody>
153262 </table>
263+ {mesh && (
264+ <button
265+ className="subtle"
266+ onClick={estimateSizes}
267+ disabled={!engineReady || busy !== null}
268+ >
269+ {busy === 'sizing' ? 'Estimating sizes…' : 'Estimate export sizes for this mesh'}
270+ </button>
271+ )}
154272 <p className="footnote">
155- These are invented text formats for illustration; real formats come later. All store
156- positions and triangle faces.
273+ All formats store positions and triangle faces; ✓ marks the extra attributes this app
274+ preserves on export. Quads and polygons are triangulated on import. Click a row to
275+ choose the export format.
157276 </p>
158277 </section>
159278 </div>
@@ -164,7 +283,7 @@ function App() {
164283 ) : (
165284 <div className="empty-state">
166285 <p>No mesh loaded.</p>
167- <p>Open a .mopf, .tricol, or .bmsh file — or load the sample.</p>
286+ <p>Open a {acceptedExtensions.join(', ')} file — or load the sample.</p>
168287 </div>
169288 )}
170289 </div>
src/MeshView.tsxmodified+82−20View file
@@ -1,14 +1,25 @@
1-import { useEffect, useMemo } from 'react'
1+import { useEffect, useMemo, useState } from 'react'
22 import { Canvas } from '@react-three/fiber'
33 import { OrbitControls } from '@react-three/drei'
44 import * as THREE from 'three'
55 import type { MeshData } from './mesh/types'
66
7-function MeshObject({ mesh }: { mesh: MeshData }) {
7+type ViewMode = 'shaded' | 'wire' | 'both' | 'points'
8+
9+const VIEW_MODES: { id: ViewMode; label: string }[] = [
10+ { id: 'shaded', label: 'Shaded' },
11+ { id: 'wire', label: 'Wire' },
12+ { id: 'both', label: 'Both' },
13+ { id: 'points', label: 'Points' },
14+]
15+
16+const PLAIN_COLOR = '#8fb4d9'
17+
18+function MeshObject({ mesh, mode }: { mesh: MeshData; mode: ViewMode }) {
819 const geometry = useMemo(() => {
920 const g = new THREE.BufferGeometry()
1021 g.setAttribute('position', new THREE.Float32BufferAttribute(mesh.positions, 3))
11- g.setIndex(mesh.indices)
22+ g.setIndex(new THREE.Uint32BufferAttribute(mesh.indices, 1))
1223 if (mesh.normals) {
1324 g.setAttribute('normal', new THREE.Float32BufferAttribute(mesh.normals, 3))
1425 } else {
@@ -26,29 +37,80 @@ function MeshObject({ mesh }: { mesh: MeshData }) {
2637 useEffect(() => () => geometry.dispose(), [geometry])
2738
2839 const scale = 1.6 / (geometry.boundingSphere?.radius || 1)
40+ const useVertexColors = !!mesh.colors
41+ // material settings are baked into the compiled shader; remount materials
42+ // when they change so three.js rebuilds the program
43+ const matKey = `${mode}-${useVertexColors ? 'vc' : 'plain'}`
2944
3045 return (
31- <mesh geometry={geometry} scale={scale}>
32- <meshStandardMaterial
33- vertexColors={!!mesh.colors}
34- color={mesh.colors ? 'white' : '#8fb4d9'}
35- roughness={0.55}
36- metalness={0.1}
37- side={THREE.DoubleSide}
38- />
39- </mesh>
46+ <group scale={scale}>
47+ {(mode === 'shaded' || mode === 'both') && (
48+ <mesh geometry={geometry}>
49+ <meshStandardMaterial
50+ key={matKey}
51+ vertexColors={useVertexColors}
52+ color={useVertexColors ? 'white' : PLAIN_COLOR}
53+ roughness={0.55}
54+ metalness={0.1}
55+ side={THREE.DoubleSide}
56+ polygonOffset={mode === 'both'}
57+ polygonOffsetFactor={1}
58+ polygonOffsetUnits={1}
59+ />
60+ </mesh>
61+ )}
62+ {(mode === 'wire' || mode === 'both') && (
63+ <mesh geometry={geometry}>
64+ <meshBasicMaterial
65+ key={matKey}
66+ wireframe
67+ // over the shaded surface use thin dark lines; standalone
68+ // wireframe keeps the mesh's own coloring
69+ vertexColors={mode === 'wire' && useVertexColors}
70+ color={mode === 'both' ? '#10161f' : useVertexColors ? 'white' : PLAIN_COLOR}
71+ transparent={mode === 'both'}
72+ opacity={mode === 'both' ? 0.35 : 1}
73+ />
74+ </mesh>
75+ )}
76+ {mode === 'points' && (
77+ <points geometry={geometry}>
78+ <pointsMaterial
79+ key={matKey}
80+ vertexColors={useVertexColors}
81+ color={useVertexColors ? 'white' : PLAIN_COLOR}
82+ size={0.02}
83+ />
84+ </points>
85+ )}
86+ </group>
4087 )
4188 }
4289
4390 export function MeshView({ mesh }: { mesh: MeshData }) {
91+ const [mode, setMode] = useState<ViewMode>('both')
92+
4493 return (
45- <Canvas camera={{ position: [2.6, 1.8, 2.6], fov: 45 }}>
46- <color attach="background" args={['#16181d']} />
47- <ambientLight intensity={0.5} />
48- <directionalLight position={[5, 8, 4]} intensity={1.6} />
49- <directionalLight position={[-4, -3, -6]} intensity={0.4} />
50- <MeshObject mesh={mesh} />
51- <OrbitControls makeDefault />
52- </Canvas>
94+ <>
95+ <div className="view-toolbar">
96+ {VIEW_MODES.map((m) => (
97+ <button
98+ key={m.id}
99+ className={mode === m.id ? 'active' : ''}
100+ onClick={() => setMode(m.id)}
101+ >
102+ {m.label}
103+ </button>
104+ ))}
105+ </div>
106+ <Canvas camera={{ position: [2.6, 1.8, 2.6], fov: 45 }}>
107+ <color attach="background" args={['#16181d']} />
108+ <ambientLight intensity={0.5} />
109+ <directionalLight position={[5, 8, 4]} intensity={1.6} />
110+ <directionalLight position={[-4, -3, -6]} intensity={0.4} />
111+ <MeshObject mesh={mesh} mode={mode} />
112+ <OrbitControls makeDefault />
113+ </Canvas>
114+ </>
53115 )
54116 }
src/mesh/bridge.pyadded+184−0View file
@@ -0,0 +1,184 @@
1+# Runs inside Pyodide. Bridges meshio to the JS app.
2+#
3+# Arrays cross the JS/Python boundary as little-endian binary files in
4+# Pyodide's in-memory filesystem (positions/normals/colors as float32
5+# xyz-triples, indices as uint32 triangle triples); each call returns a JSON
6+# string with counts, flags, and warnings. See meshio.ts for the JS side.
7+
8+import json
9+import os
10+
11+import numpy as np
12+
13+import meshio
14+
15+WORK = "/work"
16+
17+POSITIONS_F32 = WORK + "/positions.f32"
18+INDICES_U32 = WORK + "/indices.u32"
19+NORMALS_F32 = WORK + "/normals.f32"
20+COLORS_F32 = WORK + "/colors.f32"
21+
22+os.makedirs(WORK, exist_ok=True)
23+
24+
25+def _triangulate_cells(mesh, warnings):
26+ """Collect surface cells as triangles, fan-triangulating quads/polygons."""
27+ tri_blocks = []
28+ skipped = []
29+ for block in mesh.cells:
30+ data = block.data
31+ if not isinstance(data, np.ndarray) or data.ndim != 2 or data.shape[1] < 3:
32+ skipped.append(block.type)
33+ continue
34+ if block.type == "triangle":
35+ tri_blocks.append(data)
36+ elif block.type in ("quad", "polygon"):
37+ k = data.shape[1]
38+ for i in range(1, k - 1):
39+ tri_blocks.append(np.column_stack([data[:, 0], data[:, i], data[:, i + 1]]))
40+ if k > 3:
41+ warnings.append(f"{len(data)} {block.type} cells triangulated")
42+ else:
43+ skipped.append(block.type)
44+ if skipped:
45+ warnings.append("skipped non-surface cells: " + ", ".join(sorted(set(skipped))))
46+ if not tri_blocks:
47+ found = ", ".join(sorted({b.type for b in mesh.cells})) or "none"
48+ raise ValueError(
49+ f"No surface cells (triangle/quad/polygon) found; cell types in file: {found}"
50+ )
51+ return np.ascontiguousarray(np.vstack(tri_blocks).astype(np.uint32))
52+
53+
54+def _extract_normals(mesh):
55+ pd = mesh.point_data
56+ if "obj:vn" in pd: # wavefront obj
57+ vn = np.asarray(pd["obj:vn"], dtype=np.float32)
58+ if vn.ndim == 2 and vn.shape[1] >= 3:
59+ return vn[:, :3]
60+ if all(k in pd for k in ("nx", "ny", "nz")): # ply convention
61+ return np.column_stack([pd["nx"], pd["ny"], pd["nz"]]).astype(np.float32)
62+ for key, value in pd.items():
63+ value = np.asarray(value)
64+ if key.lower() in ("normals", "normal") and value.ndim == 2 and value.shape[1] == 3:
65+ return value.astype(np.float32)
66+ return None
67+
68+
69+def _extract_colors(mesh):
70+ """Vertex colors as float32 rgb in [0, 1], or None."""
71+ pd = mesh.point_data
72+ if all(k in pd for k in ("red", "green", "blue")): # ply convention
73+ rgb = np.column_stack([pd["red"], pd["green"], pd["blue"]])
74+ scale = 255.0 if np.issubdtype(rgb.dtype, np.integer) else 1.0
75+ return (rgb / scale).astype(np.float32)
76+ for key, value in pd.items():
77+ value = np.asarray(value)
78+ if key.lower() in ("rgb", "rgba", "colors", "color") and value.ndim == 2 and value.shape[1] >= 3:
79+ rgb = value[:, :3]
80+ if np.issubdtype(rgb.dtype, np.integer):
81+ return (rgb / 255.0).astype(np.float32)
82+ return rgb.astype(np.float32)
83+ return None
84+
85+
86+def parse_mesh_file(path, file_format=None):
87+ warnings = []
88+ try:
89+ # meshio's read helper exits the interpreter when every candidate
90+ # reader fails; turn that into a normal exception
91+ mesh = meshio.read(path, file_format)
92+ except SystemExit:
93+ raise ValueError(f"Could not read file as {file_format or 'any known format'}")
94+
95+ points = np.asarray(mesh.points, dtype=np.float32)
96+ if points.ndim != 2:
97+ raise ValueError(f"Unexpected points array shape {points.shape}")
98+ if points.shape[1] == 2:
99+ points = np.column_stack([points, np.zeros(len(points), dtype=np.float32)])
100+ warnings.append("2D points: added z=0")
101+ points = np.ascontiguousarray(points[:, :3])
102+
103+ triangles = _triangulate_cells(mesh, warnings)
104+ if triangles.size and int(triangles.max()) >= len(points):
105+ raise ValueError(
106+ f"Face index {int(triangles.max())} out of range (0..{len(points) - 1})"
107+ )
108+
109+ normals = _extract_normals(mesh)
110+ colors = _extract_colors(mesh)
111+ # per-vertex attributes must match the vertex count to be usable
112+ if normals is not None and len(normals) != len(points):
113+ normals = None
114+ if colors is not None and len(colors) != len(points):
115+ colors = None
116+
117+ with open(POSITIONS_F32, "wb") as f:
118+ f.write(points.tobytes())
119+ with open(INDICES_U32, "wb") as f:
120+ f.write(triangles.tobytes())
121+ if normals is not None:
122+ with open(NORMALS_F32, "wb") as f:
123+ f.write(np.ascontiguousarray(normals).tobytes())
124+ if colors is not None:
125+ with open(COLORS_F32, "wb") as f:
126+ f.write(np.ascontiguousarray(np.clip(colors, 0.0, 1.0)).tobytes())
127+
128+ other_point_data = sorted(
129+ k for k in mesh.point_data
130+ if k not in ("obj:vn", "nx", "ny", "nz", "red", "green", "blue")
131+ and k.lower() not in ("normals", "normal", "rgb", "rgba", "colors", "color")
132+ # format-internal bookkeeping, not user data
133+ and not k.startswith(("gmsh:", "medit:"))
134+ and k != "GLOBAL_ID"
135+ )
136+ if other_point_data:
137+ warnings.append("ignored point data: " + ", ".join(other_point_data))
138+
139+ return json.dumps(
140+ {
141+ "numVertices": len(points),
142+ "numFaces": len(triangles),
143+ "hasNormals": normals is not None,
144+ "hasColors": colors is not None,
145+ "warnings": warnings,
146+ }
147+ )
148+
149+
150+def serialize_mesh(out_path, file_format, include_normals, include_colors):
151+ positions = np.fromfile(POSITIONS_F32, dtype=np.float32).reshape(-1, 3)
152+ indices = np.fromfile(INDICES_U32, dtype=np.uint32).reshape(-1, 3).astype(np.int32)
153+
154+ point_data = {}
155+ if include_normals:
156+ normals = np.fromfile(NORMALS_F32, dtype=np.float32).reshape(-1, 3)
157+ if file_format == "obj":
158+ point_data["obj:vn"] = normals
159+ elif file_format == "ply":
160+ point_data["nx"] = normals[:, 0]
161+ point_data["ny"] = normals[:, 1]
162+ point_data["nz"] = normals[:, 2]
163+ else:
164+ point_data["Normals"] = normals
165+ if include_colors:
166+ colors = np.fromfile(COLORS_F32, dtype=np.float32).reshape(-1, 3)
167+ if file_format == "ply":
168+ rgb = np.clip(np.round(colors * 255.0), 0, 255).astype(np.uint8)
169+ point_data["red"] = rgb[:, 0]
170+ point_data["green"] = rgb[:, 1]
171+ point_data["blue"] = rgb[:, 2]
172+ else:
173+ point_data["RGB"] = colors
174+
175+ mesh = meshio.Mesh(positions, [("triangle", indices)], point_data=point_data)
176+ kwargs = {}
177+ if file_format == "stl":
178+ kwargs["binary"] = True # meshio defaults STL to ASCII
179+ elif file_format == "xdmf":
180+ # default "HDF" puts the data in a companion .h5 file, which a
181+ # single-file download can't deliver; inline it in the XML instead
182+ kwargs["data_format"] = "XML"
183+ meshio.write(out_path, mesh, file_format=file_format, **kwargs)
184+ return json.dumps({"byteLength": os.path.getsize(out_path)})
src/mesh/formats.tsadded+198−0View file
@@ -0,0 +1,198 @@
1+import type { MeshData, MeshCapabilities } from './types'
2+
3+/**
4+ * A real mesh format handled by meshio (id = meshio's file_format name).
5+ * `capabilities` declares which optional attributes this app preserves when
6+ * writing the format — it drives the loss warnings in the UI and which
7+ * point-data arrays the bridge attaches on export.
8+ */
9+export interface MeshFormat {
10+ id: string
11+ label: string
12+ /** File extension including the dot, e.g. ".ply" */
13+ extension: string
14+ blurb: string
15+ capabilities: MeshCapabilities
16+ /**
17+ * Prebuilt Pyodide packages this format needs (e.g. h5py). Loaded lazily on
18+ * first use so the default footprint stays small.
19+ */
20+ pyodidePackages?: string[]
21+}
22+
23+export const formats: MeshFormat[] = [
24+ {
25+ id: 'ply',
26+ label: 'PLY (Polygon File Format)',
27+ extension: '.ply',
28+ blurb:
29+ 'The Stanford scanner format, widely understood by mesh tools. Stores vertex ' +
30+ 'normals (nx/ny/nz) and colors (red/green/blue) as standard vertex properties.',
31+ capabilities: { normals: true, colors: true },
32+ },
33+ {
34+ id: 'obj',
35+ label: 'Wavefront OBJ',
36+ extension: '.obj',
37+ blurb:
38+ 'Ubiquitous text format from the graphics world. Carries vertex normals (vn lines) ' +
39+ 'but has no standard slot for per-vertex colors.',
40+ capabilities: { normals: true, colors: false },
41+ },
42+ {
43+ id: 'stl',
44+ label: 'STL',
45+ extension: '.stl',
46+ blurb:
47+ 'The 3D-printing staple: a bare triangle soup (written binary). Facet normals are ' +
48+ 'recomputed from geometry; vertex normals and colors cannot be stored.',
49+ capabilities: { normals: false, colors: false },
50+ },
51+ {
52+ id: 'off',
53+ label: 'OFF (Object File Format)',
54+ extension: '.off',
55+ blurb: 'Minimal academic text format: vertex coordinates and faces, nothing else.',
56+ capabilities: { normals: false, colors: false },
57+ },
58+ {
59+ id: 'vtk',
60+ label: 'VTK legacy',
61+ extension: '.vtk',
62+ blurb:
63+ 'Legacy VTK unstructured grid. Normals and colors travel as named point-data ' +
64+ 'arrays (Normals, RGB), visible in ParaView.',
65+ capabilities: { normals: true, colors: true },
66+ },
67+ {
68+ id: 'vtu',
69+ label: 'VTU (VTK XML)',
70+ extension: '.vtu',
71+ blurb:
72+ 'Modern XML VTK unstructured grid with compressed binary data. Normals and colors ' +
73+ 'travel as point-data arrays.',
74+ capabilities: { normals: true, colors: true },
75+ },
76+ {
77+ id: 'gmsh',
78+ label: 'Gmsh MSH',
79+ extension: '.msh',
80+ blurb:
81+ 'Native format of the Gmsh mesh generator (v4.1 binary). This app writes geometry ' +
82+ 'only — normals and colors are dropped.',
83+ capabilities: { normals: false, colors: false },
84+ },
85+ {
86+ id: 'xdmf',
87+ label: 'XDMF',
88+ extension: '.xdmf',
89+ blurb:
90+ 'XML metadata with HDF5-backed heavy data, common in HPC simulation. Normals and ' +
91+ 'colors travel as point-data arrays. Loads the h5py package on first use.',
92+ capabilities: { normals: true, colors: true },
93+ pyodidePackages: ['h5py'],
94+ },
95+ {
96+ id: 'med',
97+ label: 'MED (Salome)',
98+ extension: '.med',
99+ blurb:
100+ 'HDF5-based format of the Salome platform and code_aster. Normals and colors ' +
101+ 'travel as point-data fields. Loads the h5py package on first use.',
102+ capabilities: { normals: true, colors: true },
103+ pyodidePackages: ['h5py'],
104+ },
105+ {
106+ id: 'h5m',
107+ label: 'H5M (MOAB)',
108+ extension: '.h5m',
109+ blurb:
110+ 'HDF5-based format of the MOAB mesh library. Normals and colors travel as tags. ' +
111+ 'Loads the h5py package on first use.',
112+ capabilities: { normals: true, colors: true },
113+ pyodidePackages: ['h5py'],
114+ },
115+ {
116+ id: 'avsucd',
117+ label: 'AVS-UCD',
118+ extension: '.avs',
119+ blurb:
120+ 'AVS unstructured cell data, a classic visualization text format. Normals and ' +
121+ 'colors travel as node data.',
122+ capabilities: { normals: true, colors: true },
123+ },
124+ {
125+ id: 'abaqus',
126+ label: 'Abaqus',
127+ extension: '.inp',
128+ blurb: 'Abaqus FEA input deck (text). Geometry only.',
129+ capabilities: { normals: false, colors: false },
130+ },
131+ {
132+ id: 'nastran',
133+ label: 'Nastran',
134+ extension: '.bdf',
135+ blurb: 'Nastran bulk data file, widespread in structural analysis. Geometry only.',
136+ capabilities: { normals: false, colors: false },
137+ },
138+ {
139+ id: 'medit',
140+ label: 'Medit',
141+ extension: '.mesh',
142+ blurb: 'Text format of the Medit/INRIA meshing tools (also used by mmg). Geometry only.',
143+ capabilities: { normals: false, colors: false },
144+ },
145+ {
146+ id: 'netgen',
147+ label: 'Netgen',
148+ extension: '.vol',
149+ blurb: 'Native format of the Netgen mesh generator. Geometry only.',
150+ capabilities: { normals: false, colors: false },
151+ },
152+ {
153+ id: 'mdpa',
154+ label: 'MDPA (Kratos)',
155+ extension: '.mdpa',
156+ blurb: 'Input format of the Kratos multiphysics framework. Geometry only.',
157+ capabilities: { normals: false, colors: false },
158+ },
159+ {
160+ id: 'tecplot',
161+ label: 'Tecplot',
162+ extension: '.dat',
163+ blurb: 'Tecplot ASCII data format. Geometry only.',
164+ capabilities: { normals: false, colors: false },
165+ },
166+ {
167+ id: 'dolfin-xml',
168+ label: 'DOLFIN XML',
169+ extension: '.xml',
170+ blurb: 'Legacy XML format of the FEniCS/DOLFIN project. Geometry only.',
171+ capabilities: { normals: false, colors: false },
172+ },
173+ {
174+ id: 'permas',
175+ label: 'PERMAS',
176+ extension: '.post',
177+ blurb: 'PERMAS FEA text format. Geometry only.',
178+ capabilities: { normals: false, colors: false },
179+ },
180+]
181+
182+export function formatForFilename(filename: string): MeshFormat | null {
183+ const lower = filename.toLowerCase()
184+ return formats.find((f) => lower.endsWith(f.extension)) ?? null
185+}
186+
187+export const acceptedExtensions = formats.map((f) => f.extension)
188+
189+/**
190+ * Human-readable list of attributes of `mesh` that `target` cannot store
191+ * (empty array means the conversion is lossless).
192+ */
193+export function conversionLosses(mesh: MeshData, target: MeshFormat): string[] {
194+ const losses: string[] = []
195+ if (mesh.normals && !target.capabilities.normals) losses.push('vertex normals')
196+ if (mesh.colors && !target.capabilities.colors) losses.push('vertex colors')
197+ return losses
198+}
src/mesh/formats/bmsh.tsdeleted+0−60View file
@@ -1,60 +0,0 @@
1-import type { MeshData, MeshFormat } from '../types'
2-import { parseNumbers, validateIndices } from '../types'
3-
4-/**
5- * BMSH — "BareMesh" (invented, for illustration).
6- * The minimal format: raw geometry only. No normals, colors, or name.
7- *
8- * BMSH
9- * <nVertices> <nFaces>
10- * <x> <y> <z> (nVertices lines)
11- * <a> <b> <c> (nFaces lines)
12- */
13-export const bmshFormat: MeshFormat = {
14- id: 'bmsh',
15- label: 'BMSH (BareMesh)',
16- extension: '.bmsh',
17- blurb: 'Bare geometry only: positions and faces, nothing else.',
18- capabilities: { normals: false, colors: false, name: false },
19-
20- parse(text: string): MeshData {
21- const tokens = text.split(/\s+/).filter((t) => t.length > 0)
22- if (tokens[0] !== 'BMSH') {
23- throw new Error('BMSH: file must start with "BMSH"')
24- }
25- const nums = parseNumbers(tokens.slice(1), 'BMSH')
26- const nVertices = nums[0]
27- const nFaces = nums[1]
28- if (!Number.isInteger(nVertices) || !Number.isInteger(nFaces) || nVertices <= 0 || nFaces < 0) {
29- throw new Error('BMSH: invalid vertex/face counts')
30- }
31- const expected = 2 + nVertices * 3 + nFaces * 3
32- if (nums.length !== expected) {
33- throw new Error(`BMSH: expected ${expected - 2} numbers after counts, got ${nums.length - 2}`)
34- }
35- const positions = nums.slice(2, 2 + nVertices * 3)
36- const indices = nums.slice(2 + nVertices * 3)
37- validateIndices(indices, nVertices, 'BMSH')
38-
39- return { name: null, positions, indices, normals: null, colors: null }
40- },
41-
42- serialize(mesh: MeshData): string {
43- const nVertices = mesh.positions.length / 3
44- const nFaces = mesh.indices.length / 3
45- const out: string[] = ['BMSH', `${nVertices} ${nFaces}`]
46- for (let i = 0; i < nVertices; i++) {
47- out.push(
48- `${round6(mesh.positions[3 * i])} ${round6(mesh.positions[3 * i + 1])} ${round6(mesh.positions[3 * i + 2])}`,
49- )
50- }
51- for (let i = 0; i < nFaces; i++) {
52- out.push(`${mesh.indices[3 * i]} ${mesh.indices[3 * i + 1]} ${mesh.indices[3 * i + 2]}`)
53- }
54- return out.join('\n') + '\n'
55- },
56-}
57-
58-function round6(x: number): number {
59- return Math.round(x * 1e6) / 1e6
60-}
src/mesh/formats/index.tsdeleted+0−25View file
@@ -1,25 +0,0 @@
1-import type { MeshData, MeshFormat } from '../types'
2-import { mopfFormat } from './mopf'
3-import { tricolFormat } from './tricol'
4-import { bmshFormat } from './bmsh'
5-
6-export const formats: MeshFormat[] = [mopfFormat, tricolFormat, bmshFormat]
7-
8-export function formatForFilename(filename: string): MeshFormat | null {
9- const lower = filename.toLowerCase()
10- return formats.find((f) => lower.endsWith(f.extension)) ?? null
11-}
12-
13-export const acceptedExtensions = formats.map((f) => f.extension)
14-
15-/**
16- * Human-readable list of attributes of `mesh` that `target` cannot store
17- * (empty array means the conversion is lossless).
18- */
19-export function conversionLosses(mesh: MeshData, target: MeshFormat): string[] {
20- const losses: string[] = []
21- if (mesh.normals && !target.capabilities.normals) losses.push('vertex normals')
22- if (mesh.colors && !target.capabilities.colors) losses.push('vertex colors')
23- if (mesh.name && !target.capabilities.name) losses.push('mesh name')
24- return losses
25-}
src/mesh/formats/mopf.tsdeleted+0−123View file
@@ -1,123 +0,0 @@
1-import type { MeshData, MeshFormat } from '../types'
2-import { parseNumbers, validateIndices } from '../types'
3-
4-/**
5- * MOPF — "Mesh Omni-Portable Format" (invented, for illustration).
6- * The full-fidelity format: positions, faces, normals, colors, mesh name.
7- *
8- * MOPF/1
9- * # comment
10- * name Rainbow Torus
11- * attributes position normal color
12- * counts <nVertices> <nFaces>
13- * v <x> <y> <z> [<nx> <ny> <nz>] [<r> <g> <b>]
14- * f <a> <b> <c>
15- */
16-export const mopfFormat: MeshFormat = {
17- id: 'mopf',
18- label: 'MOPF (Mesh Omni-Portable Format)',
19- extension: '.mopf',
20- blurb: 'Full-fidelity: geometry, normals, colors, and mesh name.',
21- capabilities: { normals: true, colors: true, name: true },
22-
23- parse(text: string): MeshData {
24- const lines = text
25- .split('\n')
26- .map((l) => l.trim())
27- .filter((l) => l.length > 0 && !l.startsWith('#'))
28- if (lines[0] !== 'MOPF/1') {
29- throw new Error('MOPF: file must start with "MOPF/1"')
30- }
31-
32- let name: string | null = null
33- let attributes = ['position']
34- let counts: [number, number] | null = null
35- const positions: number[] = []
36- const normals: number[] = []
37- const colors: number[] = []
38- const indices: number[] = []
39-
40- for (const line of lines.slice(1)) {
41- const tokens = line.split(/\s+/)
42- const keyword = tokens[0]
43- if (keyword === 'name') {
44- name = tokens.slice(1).join(' ')
45- } else if (keyword === 'attributes') {
46- attributes = tokens.slice(1)
47- if (attributes[0] !== 'position') {
48- throw new Error('MOPF: attributes must start with "position"')
49- }
50- } else if (keyword === 'counts') {
51- const [nv, nf] = parseNumbers(tokens.slice(1), 'MOPF counts')
52- counts = [nv, nf]
53- } else if (keyword === 'v') {
54- const expected = attributes.length * 3
55- const nums = parseNumbers(tokens.slice(1), 'MOPF vertex')
56- if (nums.length !== expected) {
57- throw new Error(`MOPF: vertex line has ${nums.length} numbers, expected ${expected}`)
58- }
59- let k = 0
60- positions.push(...nums.slice(k, (k += 3)))
61- if (attributes.includes('normal')) normals.push(...nums.slice(k, (k += 3)))
62- if (attributes.includes('color')) colors.push(...nums.slice(k, (k += 3)))
63- } else if (keyword === 'f') {
64- const nums = parseNumbers(tokens.slice(1), 'MOPF face')
65- if (nums.length !== 3) {
66- throw new Error('MOPF: face line must have exactly 3 indices')
67- }
68- indices.push(...nums)
69- } else {
70- throw new Error(`MOPF: unknown keyword "${keyword}"`)
71- }
72- }
73-
74- const nVertices = positions.length / 3
75- if (counts && (counts[0] !== nVertices || counts[1] !== indices.length / 3)) {
76- throw new Error(
77- `MOPF: counts header says ${counts[0]} vertices / ${counts[1]} faces, ` +
78- `found ${nVertices} / ${indices.length / 3}`,
79- )
80- }
81- if (nVertices === 0) throw new Error('MOPF: no vertices found')
82- validateIndices(indices, nVertices, 'MOPF')
83-
84- return {
85- name,
86- positions,
87- indices,
88- normals: normals.length > 0 ? normals : null,
89- colors: colors.length > 0 ? colors : null,
90- }
91- },
92-
93- serialize(mesh: MeshData): string {
94- const attributes = ['position']
95- if (mesh.normals) attributes.push('normal')
96- if (mesh.colors) attributes.push('color')
97- const nVertices = mesh.positions.length / 3
98- const nFaces = mesh.indices.length / 3
99-
100- const out: string[] = ['MOPF/1']
101- if (mesh.name) out.push(`name ${mesh.name}`)
102- out.push(`attributes ${attributes.join(' ')}`)
103- out.push(`counts ${nVertices} ${nFaces}`)
104- for (let i = 0; i < nVertices; i++) {
105- const parts = [fmt3(mesh.positions, i)]
106- if (mesh.normals) parts.push(fmt3(mesh.normals, i))
107- if (mesh.colors) parts.push(fmt3(mesh.colors, i))
108- out.push(`v ${parts.join(' ')}`)
109- }
110- for (let i = 0; i < nFaces; i++) {
111- out.push(`f ${mesh.indices[3 * i]} ${mesh.indices[3 * i + 1]} ${mesh.indices[3 * i + 2]}`)
112- }
113- return out.join('\n') + '\n'
114- },
115-}
116-
117-function fmt3(arr: number[], i: number): string {
118- return `${round6(arr[3 * i])} ${round6(arr[3 * i + 1])} ${round6(arr[3 * i + 2])}`
119-}
120-
121-function round6(x: number): number {
122- return Math.round(x * 1e6) / 1e6
123-}
src/mesh/formats/tricol.tsdeleted+0−93View file
@@ -1,93 +0,0 @@
1-import type { MeshData, MeshFormat } from '../types'
2-import { parseNumbers, validateIndices } from '../types'
3-
4-/**
5- * TRICOL — "TriColor Interchange" (invented, for illustration).
6- * Comma-separated records, one per line. Stores geometry and optional
7- * per-vertex colors, but no normals and no mesh name.
8- *
9- * # comment
10- * V,<x>,<y>,<z>[,<r>,<g>,<b>]
11- * F,<a>,<b>,<c>
12- */
13-export const tricolFormat: MeshFormat = {
14- id: 'tricol',
15- label: 'TRICOL (TriColor Interchange)',
16- extension: '.tricol',
17- blurb: 'Geometry plus vertex colors. No normals, no mesh name.',
18- capabilities: { normals: false, colors: true, name: false },
19-
20- parse(text: string): MeshData {
21- const positions: number[] = []
22- const colors: number[] = []
23- const indices: number[] = []
24- let sawColorless = false
25-
26- for (const rawLine of text.split('\n')) {
27- const line = rawLine.trim()
28- if (line.length === 0 || line.startsWith('#')) continue
29- const tokens = line.split(',').map((t) => t.trim())
30- const kind = tokens[0]
31- if (kind === 'V') {
32- const nums = parseNumbers(tokens.slice(1), 'TRICOL vertex')
33- if (nums.length === 3) {
34- sawColorless = true
35- } else if (nums.length === 6) {
36- colors.push(nums[3], nums[4], nums[5])
37- } else {
38- throw new Error(`TRICOL: V record needs 3 or 6 numbers, got ${nums.length}`)
39- }
40- positions.push(nums[0], nums[1], nums[2])
41- } else if (kind === 'F') {
42- const nums = parseNumbers(tokens.slice(1), 'TRICOL face')
43- if (nums.length !== 3) {
44- throw new Error('TRICOL: F record must have exactly 3 indices')
45- }
46- indices.push(...nums)
47- } else {
48- throw new Error(`TRICOL: unknown record type "${kind}"`)
49- }
50- }
51-
52- const nVertices = positions.length / 3
53- if (nVertices === 0) throw new Error('TRICOL: no vertices found')
54- if (colors.length > 0 && sawColorless) {
55- throw new Error('TRICOL: either all V records have colors or none do')
56- }
57- validateIndices(indices, nVertices, 'TRICOL')
58-
59- return {
60- name: null,
61- positions,
62- indices,
63- normals: null,
64- colors: colors.length > 0 ? colors : null,
65- }
66- },
67-
68- serialize(mesh: MeshData): string {
69- const nVertices = mesh.positions.length / 3
70- const nFaces = mesh.indices.length / 3
71- const out: string[] = ['# TRICOL mesh']
72- for (let i = 0; i < nVertices; i++) {
73- const p = [mesh.positions[3 * i], mesh.positions[3 * i + 1], mesh.positions[3 * i + 2]]
74- const fields = p.map(round6)
75- if (mesh.colors) {
76- fields.push(
77- round6(mesh.colors[3 * i]),
78- round6(mesh.colors[3 * i + 1]),
79- round6(mesh.colors[3 * i + 2]),
80- )
81- }
82- out.push(`V,${fields.join(',')}`)
83- }
84- for (let i = 0; i < nFaces; i++) {
85- out.push(`F,${mesh.indices[3 * i]},${mesh.indices[3 * i + 1]},${mesh.indices[3 * i + 2]}`)
86- }
87- return out.join('\n') + '\n'
88- },
89-}
90-
91-function round6(x: number): number {
92- return Math.round(x * 1e6) / 1e6
93-}
src/mesh/meshio.tsadded+184−0View file
@@ -0,0 +1,184 @@
1+/**
2+ * JS side of the meshio bridge. Loads Pyodide (from the script tag in
3+ * index.html), installs meshio via micropip, and exchanges mesh arrays with
4+ * bridge.py through Pyodide's in-memory filesystem — everything runs in the
5+ * browser, no server involved.
6+ */
7+import bridgeCode from './bridge.py?raw'
8+import type { MeshData } from './types'
9+import type { MeshFormat } from './formats'
10+
11+const PYODIDE_PACKAGES = ['micropip']
12+const MESHIO_SPEC = 'meshio==5.3.5'
13+
14+const POSITIONS_F32 = '/work/positions.f32'
15+const INDICES_U32 = '/work/indices.u32'
16+const NORMALS_F32 = '/work/normals.f32'
17+const COLORS_F32 = '/work/colors.f32'
18+
19+interface Pyodide {
20+ runPython(code: string): unknown
21+ loadPackage(names: string[]): Promise<unknown>
22+ pyimport(name: string): { install(spec: string): Promise<void> }
23+ FS: {
24+ writeFile(path: string, data: Uint8Array): void
25+ readFile(path: string): Uint8Array<ArrayBuffer>
26+ unlink(path: string): void
27+ mkdirTree(path: string): void
28+ }
29+}
30+
31+declare global {
32+ // provided by the pyodide.js script tag in index.html
33+ function loadPyodide(options?: { indexURL?: string }): Promise<Pyodide>
34+}
35+
36+export interface ParseInfo {
37+ numVertices: number
38+ numFaces: number
39+ hasNormals: boolean
40+ hasColors: boolean
41+ warnings: string[]
42+}
43+
44+let initPromise: Promise<Pyodide> | null = null
45+
46+async function doInit(onProgress: (message: string) => void): Promise<Pyodide> {
47+ if (typeof loadPyodide !== 'function') {
48+ throw new Error('Pyodide script failed to load (offline? blocked CDN?)')
49+ }
50+ onProgress('Loading Python runtime (Pyodide)…')
51+ const pyodide = await loadPyodide()
52+ onProgress('Installing meshio…')
53+ await pyodide.loadPackage(PYODIDE_PACKAGES)
54+ await pyodide.pyimport('micropip').install(MESHIO_SPEC)
55+ pyodide.runPython(bridgeCode)
56+ return pyodide
57+}
58+
59+/**
60+ * Kick off (or join) the one-time Pyodide + meshio setup. Safe to call
61+ * repeatedly; only the first caller's onProgress is used.
62+ */
63+export function initMeshio(onProgress: (message: string) => void = () => {}): Promise<Pyodide> {
64+ if (!initPromise) initPromise = doInit(onProgress)
65+ return initPromise
66+}
67+
68+export async function getMeshioVersion(): Promise<string> {
69+ const pyodide = await initMeshio()
70+ return String(pyodide.runPython('meshio.__version__'))
71+}
72+
73+const loadedPackages = new Set<string>()
74+
75+/**
76+ * Load a format's extra Pyodide packages (e.g. h5py) on first use, so the
77+ * default startup stays at just meshio + numpy.
78+ */
79+async function ensurePackages(pyodide: Pyodide, format: MeshFormat): Promise<void> {
80+ const needed = (format.pyodidePackages ?? []).filter((p) => !loadedPackages.has(p))
81+ if (needed.length === 0) return
82+ await pyodide.loadPackage(needed)
83+ needed.forEach((p) => loadedPackages.add(p))
84+}
85+
86+/** Last line of a Python traceback, without the exception class name. */
87+function pythonErrorMessage(err: unknown): string {
88+ const raw = err instanceof Error ? err.message : String(err)
89+ const lines = raw
90+ .trim()
91+ .split('\n')
92+ .filter((l) => l.trim())
93+ const last = lines[lines.length - 1] ?? raw
94+ return last.replace(/^[\w.]+(?:Error|Exception|Exit)\s*:\s*/, '')
95+}
96+
97+function runBridge(pyodide: Pyodide, code: string): string {
98+ try {
99+ return String(pyodide.runPython(code))
100+ } catch (err) {
101+ throw new Error(pythonErrorMessage(err))
102+ }
103+}
104+
105+function readF32(pyodide: Pyodide, path: string): Float32Array {
106+ const bytes = pyodide.FS.readFile(path)
107+ return new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4)
108+}
109+
110+function readU32(pyodide: Pyodide, path: string): Uint32Array {
111+ const bytes = pyodide.FS.readFile(path)
112+ return new Uint32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4)
113+}
114+
115+function asBytes(array: Float32Array | Uint32Array): Uint8Array {
116+ return new Uint8Array(array.buffer, array.byteOffset, array.byteLength)
117+}
118+
119+export async function parseMeshFile(
120+ bytes: Uint8Array,
121+ format: MeshFormat,
122+): Promise<{ mesh: MeshData; info: ParseInfo }> {
123+ const pyodide = await initMeshio()
124+ await ensurePackages(pyodide, format)
125+ const inputPath = '/work/input' + format.extension
126+ pyodide.FS.mkdirTree('/work')
127+ pyodide.FS.writeFile(inputPath, bytes)
128+ const info: ParseInfo = JSON.parse(
129+ runBridge(
130+ pyodide,
131+ `parse_mesh_file(${JSON.stringify(inputPath)}, ${JSON.stringify(format.id)})`,
132+ ),
133+ )
134+ const mesh: MeshData = {
135+ positions: readF32(pyodide, POSITIONS_F32),
136+ indices: readU32(pyodide, INDICES_U32),
137+ normals: info.hasNormals ? readF32(pyodide, NORMALS_F32) : null,
138+ colors: info.hasColors ? readF32(pyodide, COLORS_F32) : null,
139+ }
140+ pyodide.FS.unlink(inputPath)
141+ return { mesh, info }
142+}
143+
144+/** Write the mesh arrays into /work and run serialize_mesh; returns [pyodide, outPath, byteLength]. */
145+async function runSerialize(
146+ mesh: MeshData,
147+ format: MeshFormat,
148+): Promise<[Pyodide, string, number]> {
149+ const pyodide = await initMeshio()
150+ await ensurePackages(pyodide, format)
151+ pyodide.FS.mkdirTree('/work')
152+ pyodide.FS.writeFile(POSITIONS_F32, asBytes(mesh.positions))
153+ pyodide.FS.writeFile(INDICES_U32, asBytes(mesh.indices))
154+ const includeNormals = !!mesh.normals && format.capabilities.normals
155+ const includeColors = !!mesh.colors && format.capabilities.colors
156+ if (includeNormals) pyodide.FS.writeFile(NORMALS_F32, asBytes(mesh.normals!))
157+ if (includeColors) pyodide.FS.writeFile(COLORS_F32, asBytes(mesh.colors!))
158+
159+ const outPath = '/work/out' + format.extension
160+ const result = runBridge(
161+ pyodide,
162+ `serialize_mesh(${JSON.stringify(outPath)}, ${JSON.stringify(format.id)}, ` +
163+ `${includeNormals ? 'True' : 'False'}, ${includeColors ? 'True' : 'False'})`,
164+ )
165+ const { byteLength } = JSON.parse(result) as { byteLength: number }
166+ return [pyodide, outPath, byteLength]
167+}
168+
169+export async function serializeMesh(
170+ mesh: MeshData,
171+ format: MeshFormat,
172+): Promise<Uint8Array<ArrayBuffer>> {
173+ const [pyodide, outPath] = await runSerialize(mesh, format)
174+ const out = pyodide.FS.readFile(outPath)
175+ pyodide.FS.unlink(outPath)
176+ return out
177+}
178+
179+/** Byte size the mesh would have in `format`, without keeping the bytes. */
180+export async function estimateExportSize(mesh: MeshData, format: MeshFormat): Promise<number> {
181+ const [pyodide, outPath, byteLength] = await runSerialize(mesh, format)
182+ pyodide.FS.unlink(outPath)
183+ return byteLength
184+}
src/mesh/sample.tsmodified+6−1View file
@@ -38,7 +38,12 @@ export function makeSampleMesh(): MeshData {
3838 }
3939 }
4040
41- return { name: 'Rainbow Torus', positions, indices, normals, colors }
41+ return {
42+ positions: new Float32Array(positions),
43+ indices: new Uint32Array(indices),
44+ normals: new Float32Array(normals),
45+ colors: new Float32Array(colors),
46+ }
4247 }
4348
4449 function hslToRgb(h: number, s: number, l: number): [number, number, number] {
src/mesh/types.tsmodified+6−40View file
@@ -1,37 +1,21 @@
11 /**
2- * Internal mesh representation. Every format parses into this and
3- * serializes out of it. Optional attributes are null when absent.
2+ * Internal mesh representation: a triangle mesh with optional per-vertex
3+ * attributes. Every format parses into this and serializes out of it.
44 */
55 export interface MeshData {
6- /** Human-readable mesh name (not all formats can store one) */
7- name: string | null
86 /** Flat xyz triples, 3 numbers per vertex */
9- positions: number[]
7+ positions: Float32Array
108 /** Flat triangle indices (0-based), 3 numbers per face */
11- indices: number[]
9+ indices: Uint32Array
1210 /** Flat xyz triples, 3 numbers per vertex, or null */
13- normals: number[] | null
11+ normals: Float32Array | null
1412 /** Flat rgb triples in [0,1], 3 numbers per vertex, or null */
15- colors: number[] | null
13+ colors: Float32Array | null
1614 }
1715
1816 export interface MeshCapabilities {
1917 normals: boolean
2018 colors: boolean
21- name: boolean
22-}
23-
24-export interface MeshFormat {
25- id: string
26- label: string
27- /** File extension including the dot, e.g. ".mopf" */
28- extension: string
29- blurb: string
30- capabilities: MeshCapabilities
31- /** Parse file text; throws Error with a user-facing message on bad input */
32- parse(text: string): MeshData
33- /** Serialize, silently dropping attributes the format cannot hold */
34- serialize(mesh: MeshData): string
3519 }
3620
3721 export function vertexCount(mesh: MeshData): number {
@@ -41,21 +25,3 @@ export function vertexCount(mesh: MeshData): number {
4125 export function faceCount(mesh: MeshData): number {
4226 return mesh.indices.length / 3
4327 }
44-
45-export function parseNumbers(tokens: string[], context: string): number[] {
46- return tokens.map((t) => {
47- const x = Number(t)
48- if (!Number.isFinite(x)) {
49- throw new Error(`${context}: "${t}" is not a number`)
50- }
51- return x
52- })
53-}
54-
55-export function validateIndices(indices: number[], nVertices: number, context: string): void {
56- for (const i of indices) {
57- if (!Number.isInteger(i) || i < 0 || i >= nVertices) {
58- throw new Error(`${context}: face index ${i} out of range (0..${nVertices - 1})`)
59- }
60- }
61-}