/ concept-collection / mesh-converter
Sign in
concept-collection / mesh-converter
Export by converting the original file natively; fix gmsh/tecplot capabilities
Jeremy Magland <jmagland@flatironinstitute.org> committed commit dfa57370ddaf parent bd1359e Browse files
5 changed files+390−78
README.mdmodified+34−14View file
@@ -18,7 +18,7 @@ the jsDelivr CDN, cached afterwards).
1818 | OFF (Object File Format) | `.off` | ✓ | — | — | |
1919 | VTK legacy | `.vtk` | ✓ | ✓ | ✓ | |
2020 | VTU (VTK XML) | `.vtu` | ✓ | ✓ | ✓ | |
21-| Gmsh MSH | `.msh` | ✓ | — | — | |
21+| Gmsh MSH | `.msh` | ✓ | ✓ | ✓ | |
2222 | XDMF | `.xdmf` | ✓ | ✓ | ✓ | h5py† |
2323 | MED (Salome) | `.med` | ✓ | ✓ | ✓ | h5py† |
2424 | H5M (MOAB) | `.h5m` | ✓ | ✓ | ✓ | h5py† |
@@ -28,7 +28,7 @@ the jsDelivr CDN, cached afterwards).
2828 | Medit | `.mesh` | ✓ | — | — | |
2929 | Netgen | `.vol` | ✓ | — | — | |
3030 | MDPA (Kratos) | `.mdpa` | ✓ | — | — | |
31-| Tecplot | `.dat` | ✓ | — | — | |
31+| Tecplot | `.dat` | ✓ | ✓ | ✓ | |
3232 | DOLFIN XML | `.xml` | ✓ | — | — | |
3333 | PERMAS | `.post` | ✓ | — | — | |
3434
@@ -36,11 +36,26 @@ the jsDelivr CDN, cached afterwards).
3636 lazily the first time such a format is used, so the default footprint stays
3737 small.
3838
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.
39+A loaded file is kept in its original bytes, in its original format — that is
40+the source of truth. Export runs meshio directly on those bytes
41+(`read` the original, `write` the target), so a conversion drops only what the
42+target format genuinely cannot express, and exporting back to the *same* format
43+returns the original file byte-for-byte with no conversion at all. During
44+conversion the app translates vertex normals and colors between the formats'
45+native representations (so PLY's `nx/ny/nz` properties become `vn` lines in
46+OBJ, and so on), normalizes byte order and integer widths where meshio's
47+writers are picky about dtypes, and strips data that meshio 5.3.5 would write
48+corruptly (MDPA mesh data) or crash on (H5M and DOLFIN XML cell data). The 3D
49+view is fed by a separate, triangle-only "common" representation: quads and
50+polygons are fan-triangulated and volume cells are skipped *for display*, but
51+none of that touches what gets exported.
52+
53+✓ marks the attributes the app tracks for its loss warnings, and is how it
54+writes the generated sample mesh (which, unlike an uploaded file, has no
55+original bytes): vertex normals map to each format's native representation
56+(`nx/ny/nz` scalars in PLY and Tecplot, `vn` lines in OBJ, a `Normals`
57+point-data array elsewhere) and vertex colors likewise (`red/green/blue` in
58+PLY and Tecplot, an `RGB` point-data array elsewhere).
4459
4560 Some meshio formats are deliberately absent: ansys, cgns, su2, and ugrid
4661 cannot roundtrip their own output in meshio 5.3.5; exodus fails writing under
@@ -74,14 +89,19 @@ Built with Vite, React, TypeScript, and three.js (react-three-fiber).
7489
7590 ## How it works
7691
77-- `src/mesh/bridge.py` runs inside Pyodide: it reads/writes mesh files with
78- meshio and exchanges arrays with JS as raw little-endian buffers through
79- Pyodide's in-memory filesystem.
92+- `src/mesh/bridge.py` runs inside Pyodide with three entry points:
93+ `parse_mesh_file` (native file → the triangle-only common form that feeds the
94+ viewer), `convert_mesh` (native → native, straight through meshio, the export
95+ path for uploaded files), and `serialize_mesh` (common form → native, used
96+ only for the generated sample). It exchanges arrays with JS as raw
97+ little-endian buffers through Pyodide's in-memory filesystem.
8098 - `src/mesh/meshio.ts` loads Pyodide (script tag in `index.html`), installs
81- meshio via micropip, and wraps the bridge in typed async
82- `parseMeshFile`/`serializeMesh` functions built on the internal `MeshData`
83- representation (typed arrays of positions, triangle indices, optional
84- normals/colors).
99+ meshio via micropip, and wraps the bridge in typed async functions:
100+ `parseMeshFile` for the viewer's `MeshData` (typed arrays of positions,
101+ triangle indices, optional normals/colors), `convertMesh` for lossless
102+ native-to-native export, and `serializeMesh` for the sample. `App.tsx` keeps
103+ the loaded file's original bytes as the source of truth and routes export
104+ through them — same-format exports skip meshio entirely.
85105 - `src/mesh/formats.ts` declares the supported formats and which attributes
86106 each preserves; the upload, capability table, and loss-warning UI derive
87107 from it. To add a meshio-supported format, add a descriptor there (with
src/App.tsxmodified+57−19View file
@@ -5,7 +5,10 @@ import type { ViewMode } from './viewModes'
55 import type { MeshData } from './mesh/types'
66 import { faceCount, vertexCount } from './mesh/types'
77 import { acceptedExtensions, conversionLosses, formatForFilename, formats } from './mesh/formats'
8+import type { MeshFormat } from './mesh/formats'
89 import {
10+ convertMesh,
11+ estimateConvertSize,
912 estimateExportSize,
1013 getMeshioVersion,
1114 initMeshio,
@@ -19,11 +22,16 @@ import './App.css'
1922 type EngineState = 'loading' | 'ready' | 'error'
2023
2124 /**
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+ * 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.
2533 */
26-type ShareSource =
34+type MeshSource =
2735 | { kind: 'file'; formatId: string; bytes: Uint8Array<ArrayBuffer> }
2836 | { kind: 'sample' }
2937
@@ -53,13 +61,17 @@ function App() {
5361 const [busy, setBusy] = useState<'parsing' | 'exporting' | 'sizing' | null>(null)
5462 const [exportSizes, setExportSizes] = useState<Record<string, number> | null>(null)
5563 const [viewMode, setViewMode] = useState<ViewMode>('both')
56- const [shareSource, setShareSource] = useState<ShareSource | null>(null)
64+ const [source, setSource] = useState<MeshSource | null>(null)
5765 const [shareStatus, setShareStatus] = useState<ShareStatus | null>(null)
5866 const fileInputRef = useRef<HTMLInputElement>(null)
5967 const shareLoadAttempted = useRef(false)
6068
6169 const exportFormat = formats.find((f) => f.id === exportFormatId) ?? formats[0]
62- const losses = mesh ? conversionLosses(mesh, exportFormat) : []
70+ const sourceFormat =
71+ source?.kind === 'file' ? (formats.find((f) => f.id === source.formatId) ?? null) : null
72+ // A same-format export of an uploaded file is the original bytes, untouched.
73+ const identicalToSource = source?.kind === 'file' && source.formatId === exportFormat.id
74+ const losses = mesh && !identicalToSource ? conversionLosses(mesh, exportFormat) : []
6375 const engineReady = engine.state === 'ready'
6476
6577 useEffect(() => {
@@ -106,7 +118,7 @@ function App() {
106118 }
107119 if (payload.formatId === null) {
108120 setMesh(makeSampleMesh())
109- setShareSource({ kind: 'sample' })
121+ setSource({ kind: 'sample' })
110122 setParseWarnings([])
111123 setSourceLabel('built-in sample (from share link)')
112124 setBaseName(payload.name || 'rainbow_torus')
@@ -121,7 +133,7 @@ function App() {
121133 try {
122134 const { mesh: parsed, info } = await parseMeshFile(payload.bytes, format)
123135 setMesh(parsed)
124- setShareSource({ kind: 'file', formatId: format.id, bytes: payload.bytes })
136+ setSource({ kind: 'file', formatId: format.id, bytes: payload.bytes })
125137 setParseWarnings(info.warnings)
126138 setSourceLabel(`${payload.name}${format.extension} (${format.label}, from share link)`)
127139 setBaseName(payload.name || 'mesh')
@@ -161,7 +173,7 @@ function App() {
161173 setParseWarnings(info.warnings)
162174 setSourceLabel(`${file.name} (${format.label})`)
163175 setBaseName(file.name.replace(/\.[^.]+$/, ''))
164- setShareSource({ kind: 'file', formatId: format.id, bytes })
176+ setSource({ kind: 'file', formatId: format.id, bytes })
165177 setShareStatus(null)
166178 clearShareHash()
167179 } catch (e) {
@@ -178,21 +190,21 @@ function App() {
178190 setParseWarnings([])
179191 setSourceLabel('built-in sample')
180192 setBaseName('rainbow_torus')
181- setShareSource({ kind: 'sample' })
193+ setSource({ kind: 'sample' })
182194 setShareStatus(null)
183195 clearShareHash()
184196 }
185197
186198 const shareMesh = async () => {
187- if (!shareSource) return
199+ if (!source) return
188200 setShareStatus(null)
189201 try {
190202 const url = await buildShareUrl({
191203 name: baseName,
192- formatId: shareSource.kind === 'file' ? shareSource.formatId : null,
204+ formatId: source.kind === 'file' ? source.formatId : null,
193205 exportFormatId,
194206 viewMode,
195- bytes: shareSource.kind === 'file' ? shareSource.bytes : new Uint8Array(0),
207+ bytes: source.kind === 'file' ? source.bytes : new Uint8Array(0),
196208 })
197209 if (url.length > MAX_SHARE_URL_CHARS) {
198210 setShareStatus({ kind: 'too-large', chars: url.length })
@@ -209,8 +221,19 @@ function App() {
209221 }
210222 }
211223
224+ // Size of the mesh exported to `format`, taken from the native source: the
225+ // original bytes when it matches, meshio conversion otherwise; the sample
226+ // has no native file, so it serializes from its common-form MeshData.
227+ const exportSizeFor = async (format: MeshFormat): Promise<number> => {
228+ if (source?.kind === 'file') {
229+ if (source.formatId === format.id) return source.bytes.length
230+ return estimateConvertSize(source.bytes, sourceFormat!, format)
231+ }
232+ return estimateExportSize(mesh!, format)
233+ }
234+
212235 const estimateSizes = async () => {
213- if (!mesh) return
236+ if (!mesh || !source) return
214237 setError(null)
215238 setBusy('sizing')
216239 setExportSizes({})
@@ -221,7 +244,7 @@ function App() {
221244 (a, b) => (a.pyodidePackages?.length ?? 0) - (b.pyodidePackages?.length ?? 0),
222245 )
223246 for (const format of ordered) {
224- const size = await estimateExportSize(mesh, format)
247+ const size = await exportSizeFor(format)
225248 setExportSizes((prev) => ({ ...(prev ?? {}), [format.id]: size }))
226249 }
227250 } catch (e) {
@@ -232,11 +255,22 @@ function App() {
232255 }
233256
234257 const downloadExport = async () => {
235- if (!mesh) return
258+ if (!mesh || !source) return
236259 setError(null)
237260 setBusy('exporting')
238261 try {
239- const bytes = await serializeMesh(mesh, exportFormat)
262+ // Export from the native source of truth, not the viewer's MeshData:
263+ // same format -> the original bytes untouched; different format ->
264+ // meshio native-to-native; sample -> serialize its generated MeshData.
265+ let bytes: Uint8Array<ArrayBuffer>
266+ if (source.kind === 'file') {
267+ bytes =
268+ source.formatId === exportFormat.id
269+ ? source.bytes
270+ : await convertMesh(source.bytes, sourceFormat!, exportFormat)
271+ } else {
272+ bytes = await serializeMesh(mesh, exportFormat)
273+ }
240274 const base = baseName.replace(/[^\w-]+/g, '_').toLowerCase() || 'mesh'
241275 const blob = new Blob([bytes], { type: 'application/octet-stream' })
242276 const url = URL.createObjectURL(blob)
@@ -322,7 +356,11 @@ function App() {
322356 ))}
323357 </select>
324358 <p className="format-blurb">{exportFormat.blurb}</p>
325- {losses.length > 0 ? (
359+ {identicalToSource ? (
360+ <div className="ok">
361+ Same as the source format — you get the original file back, byte for byte.
362+ </div>
363+ ) : losses.length > 0 ? (
326364 <div className="warning">
327365 Exporting to {exportFormat.extension} will drop:{' '}
328366 <strong>{losses.join(', ')}</strong>
@@ -340,7 +378,7 @@ function App() {
340378 </section>
341379 )}
342380
343- {mesh && shareSource && (
381+ {mesh && source && (
344382 <section>
345383 <h2>Share</h2>
346384 <button onClick={shareMesh} disabled={busy !== null}>
src/mesh/bridge.pymodified+223−36View file
@@ -1,5 +1,17 @@
11 # Runs inside Pyodide. Bridges meshio to the JS app.
22 #
3+# There are two directions:
4+# * parse_mesh_file — native file -> the app's triangle-only "common"
5+# representation, used to feed the 3D viewer. This is lossy by design
6+# (cells are triangulated, only normals/colors are carried).
7+# * convert_mesh — native file -> native file, going straight through
8+# meshio with no detour through the common representation, so nothing the
9+# source format holds is discarded beyond what the target format cannot
10+# express. This is the export path for uploaded/shared files.
11+# * serialize_mesh — common representation -> native file, used only for
12+# the built-in sample mesh, which is generated as the common form and so
13+# has no original file to preserve.
14+#
315 # Arrays cross the JS/Python boundary as little-endian binary files in
416 # Pyodide's in-memory filesystem (positions/normals/colors as float32
517 # xyz-triples, indices as uint32 triangle triples); each call returns a JSON
@@ -51,38 +63,86 @@ def _triangulate_cells(mesh, warnings):
5163 return np.ascontiguousarray(np.vstack(tri_blocks).astype(np.uint32))
5264
5365
66+# Formats whose meshio writer+reader round-trip vertex normals/colors via the
67+# conventions in _attach_normals/_attach_colors. Mirrors `capabilities` in
68+# formats.ts — keep the two in sync.
69+NORMAL_FORMATS = {"ply", "obj", "vtk", "vtu", "gmsh", "xdmf", "med", "h5m", "avsucd", "tecplot"}
70+COLOR_FORMATS = NORMAL_FORMATS - {"obj"}
71+
72+_NORMAL_ALIASES = ("normals", "normal")
73+_COLOR_ALIASES = ("rgb", "rgba", "colors", "color")
74+
75+
5476 def _extract_normals(mesh):
5577 pd = mesh.point_data
5678 if "obj:vn" in pd: # wavefront obj
5779 vn = np.asarray(pd["obj:vn"], dtype=np.float32)
5880 if vn.ndim == 2 and vn.shape[1] >= 3:
5981 return vn[:, :3]
60- if all(k in pd for k in ("nx", "ny", "nz")): # ply convention
82+ if all(k in pd for k in ("nx", "ny", "nz")): # ply/tecplot convention
6183 return np.column_stack([pd["nx"], pd["ny"], pd["nz"]]).astype(np.float32)
6284 for key, value in pd.items():
6385 value = np.asarray(value)
64- if key.lower() in ("normals", "normal") and value.ndim == 2 and value.shape[1] == 3:
86+ if key.lower() in _NORMAL_ALIASES and value.ndim == 2 and value.shape[1] == 3:
6587 return value.astype(np.float32)
6688 return None
6789
6890
91+def _rgb_to_unit_float(rgb):
92+ """Color channels as float32 in [0, 1]. Integer data is assumed 0-255;
93+ float data that exceeds 1 is treated as 0-255 too (formats like tecplot
94+ and gmsh store everything as floats, losing the integer dtype)."""
95+ rgb = np.asarray(rgb)
96+ if np.issubdtype(rgb.dtype, np.integer):
97+ return (rgb / 255.0).astype(np.float32)
98+ rgb = rgb.astype(np.float32)
99+ if rgb.size and float(rgb.max()) > 1.0:
100+ rgb = rgb / np.float32(255.0)
101+ return rgb
102+
103+
69104 def _extract_colors(mesh):
70105 """Vertex colors as float32 rgb in [0, 1], or None."""
71106 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)
107+ if all(k in pd for k in ("red", "green", "blue")): # ply/tecplot convention
108+ return _rgb_to_unit_float(np.column_stack([pd["red"], pd["green"], pd["blue"]]))
76109 for key, value in pd.items():
77110 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)
111+ if key.lower() in _COLOR_ALIASES and value.ndim == 2 and value.shape[1] >= 3:
112+ return _rgb_to_unit_float(value[:, :3])
83113 return None
84114
85115
116+def _attach_normals(point_data, file_format, normals):
117+ """Attach vertex normals under file_format's native naming."""
118+ if file_format == "obj":
119+ point_data["obj:vn"] = normals
120+ elif file_format in ("ply", "tecplot"):
121+ # scalar properties/variables; tecplot would split a 2D array into
122+ # opaque Normals_0/1/2 columns
123+ point_data["nx"] = normals[:, 0]
124+ point_data["ny"] = normals[:, 1]
125+ point_data["nz"] = normals[:, 2]
126+ else:
127+ point_data["Normals"] = normals
128+
129+
130+def _attach_colors(point_data, file_format, colors):
131+ """Attach vertex colors (float rgb in [0, 1]) under file_format's native
132+ naming."""
133+ if file_format == "ply":
134+ rgb = np.clip(np.round(colors * 255.0), 0, 255).astype(np.uint8)
135+ point_data["red"] = rgb[:, 0]
136+ point_data["green"] = rgb[:, 1]
137+ point_data["blue"] = rgb[:, 2]
138+ elif file_format == "tecplot":
139+ point_data["red"] = colors[:, 0]
140+ point_data["green"] = colors[:, 1]
141+ point_data["blue"] = colors[:, 2]
142+ else:
143+ point_data["RGB"] = colors
144+
145+
86146 def parse_mesh_file(path, file_format=None):
87147 warnings = []
88148 try:
@@ -128,9 +188,9 @@ def parse_mesh_file(path, file_format=None):
128188 other_point_data = sorted(
129189 k for k in mesh.point_data
130190 if k not in ("obj:vn", "nx", "ny", "nz", "red", "green", "blue")
131- and k.lower() not in ("normals", "normal", "rgb", "rgba", "colors", "color")
191+ and k.lower() not in _NORMAL_ALIASES + _COLOR_ALIASES
132192 # format-internal bookkeeping, not user data
133- and not k.startswith(("gmsh:", "medit:"))
193+ and not k.startswith(("gmsh:", "medit:", "nastran:"))
134194 and k != "GLOBAL_ID"
135195 )
136196 if other_point_data:
@@ -147,6 +207,153 @@ def parse_mesh_file(path, file_format=None):
147207 )
148208
149209
210+def _write_kwargs(file_format):
211+ """Per-format quirks for meshio.write, shared by both write paths."""
212+ kwargs = {}
213+ if file_format == "stl":
214+ kwargs["binary"] = True # meshio defaults STL to ASCII
215+ elif file_format == "xdmf":
216+ # default "HDF" puts the data in a companion .h5 file, which a
217+ # single-file download can't deliver; inline it in the XML instead
218+ kwargs["data_format"] = "XML"
219+ return kwargs
220+
221+
222+def _native_order(arr):
223+ """Byte-swap to native endianness. Legacy VTK reads come back big-endian,
224+ and several writers (ply, medit) look dtypes up in tables keyed by the
225+ native forms only."""
226+ arr = np.asarray(arr)
227+ if not arr.dtype.isnative:
228+ return arr.astype(arr.dtype.newbyteorder("="))
229+ return arr
230+
231+
232+def _normalize_arrays(mesh):
233+ mesh.points = _native_order(mesh.points)
234+ for block in mesh.cells:
235+ block.data = _native_order(block.data)
236+ for key, value in list(mesh.point_data.items()):
237+ mesh.point_data[key] = _native_order(value)
238+ for key, blocks in list(mesh.cell_data.items()):
239+ mesh.cell_data[key] = [_native_order(b) for b in blocks]
240+
241+
242+def _pop_normal_keys(pd):
243+ """Remove every recognized representation of vertex normals."""
244+ pd.pop("obj:vn", None)
245+ if all(k in pd for k in ("nx", "ny", "nz")):
246+ for k in ("nx", "ny", "nz"):
247+ del pd[k]
248+ for key in [k for k in pd if k.lower() in _NORMAL_ALIASES]:
249+ del pd[key]
250+
251+
252+def _pop_color_keys(pd):
253+ if all(k in pd for k in ("red", "green", "blue")):
254+ for k in ("red", "green", "blue"):
255+ del pd[k]
256+ for key in [k for k in pd if k.lower() in _COLOR_ALIASES]:
257+ del pd[key]
258+
259+
260+def _remap_attributes(mesh, out_format):
261+ """Translate vertex normals/colors from the source format's naming into
262+ out_format's native naming so they survive conversion (PLY's nx/ny/nz
263+ become OBJ vn lines, and so on). Unrecognized point data passes through
264+ untouched. Attributes the target cannot express are removed rather than
265+ left under a name its writer would drop or mangle."""
266+ num_points = len(mesh.points)
267+ normals = _extract_normals(mesh)
268+ if normals is not None and len(normals) == num_points:
269+ _pop_normal_keys(mesh.point_data)
270+ if out_format in NORMAL_FORMATS:
271+ _attach_normals(mesh.point_data, out_format, np.ascontiguousarray(normals))
272+ colors = _extract_colors(mesh)
273+ if colors is not None and len(colors) == num_points:
274+ _pop_color_keys(mesh.point_data)
275+ if out_format in COLOR_FORMATS:
276+ _attach_colors(mesh.point_data, out_format, np.clip(colors, 0.0, 1.0))
277+
278+
279+def _sanitize_for_target(mesh, out_format):
280+ """Work around meshio 5.3.5 writer defects that would crash or corrupt
281+ the output file."""
282+ if out_format == "mdpa":
283+ # the NodalData/ElementalData writer reprs values ("np.float32(0.0)"
284+ # under numpy>=2) and the reader can't parse those sections anyway;
285+ # keep only the gmsh tag keys, which feed the structural element path
286+ mesh.point_data.clear()
287+ for key in [k for k in mesh.cell_data if k not in ("gmsh:physical", "gmsh:geometrical")]:
288+ del mesh.cell_data[key]
289+ elif out_format == "h5m":
290+ # the writer still uses the pre-5.x dict-of-dicts cell_data API and
291+ # crashes on any cell_data at all
292+ mesh.cell_data.clear()
293+ elif out_format == "dolfin-xml":
294+ # cell_data goes to separate companion files a single-file download
295+ # can't deliver, and integer data crashes the writer under numpy>=2
296+ mesh.cell_data.clear()
297+ elif out_format == "gmsh":
298+ # the 4.1 writer's $Entities section needs the complete tag trio
299+ # (gmsh:dim_tags point data + gmsh:physical/gmsh:geometrical cell
300+ # data) and KeyErrors on a partial set — which is what re-reading a
301+ # gmsh file without physical groups produces; fall back to writing no
302+ # entity bookkeeping at all
303+ have_all = (
304+ "gmsh:dim_tags" in mesh.point_data
305+ and "gmsh:physical" in mesh.cell_data
306+ and "gmsh:geometrical" in mesh.cell_data
307+ )
308+ if not have_all:
309+ mesh.point_data.pop("gmsh:dim_tags", None)
310+ mesh.cell_data.pop("gmsh:physical", None)
311+ mesh.cell_data.pop("gmsh:geometrical", None)
312+ elif out_format == "medit":
313+ # the writer formats the first integer array it finds as its scalar
314+ # label column; multi-column int arrays (e.g. gmsh:dim_tags) crash it
315+ for key in [k for k, v in mesh.point_data.items()
316+ if v.ndim > 1 and np.issubdtype(v.dtype, np.integer)]:
317+ del mesh.point_data[key]
318+ for key in [k for k, blocks in mesh.cell_data.items()
319+ if any(b.ndim > 1 and np.issubdtype(b.dtype, np.integer) for b in blocks)]:
320+ del mesh.cell_data[key]
321+ elif out_format == "xdmf":
322+ # the XML data path (forced by _write_kwargs) has no format strings
323+ # for sub-32-bit ints or float16 -> KeyError; upcast them
324+ def upcast(arr):
325+ if arr.dtype.kind in ("b", "i") and arr.dtype.itemsize < 4:
326+ return arr.astype(np.int32)
327+ if arr.dtype.kind == "u" and arr.dtype.itemsize < 4:
328+ return arr.astype(np.uint32)
329+ if arr.dtype.kind == "f" and arr.dtype.itemsize < 4:
330+ return arr.astype(np.float32)
331+ return arr
332+
333+ for key, value in list(mesh.point_data.items()):
334+ mesh.point_data[key] = upcast(value)
335+ for key, blocks in list(mesh.cell_data.items()):
336+ mesh.cell_data[key] = [upcast(b) for b in blocks]
337+
338+
339+def convert_mesh(in_path, in_format, out_path, out_format):
340+ """Native -> native, straight through meshio. Reads the original file in
341+ its own format and writes the target format without collapsing to the
342+ app's triangle-only representation, so nothing is dropped except what the
343+ target format genuinely cannot store. Vertex normals/colors are renamed
344+ to the target's convention; everything else passes through as meshio
345+ read it (modulo the writer workarounds in _sanitize_for_target)."""
346+ try:
347+ mesh = meshio.read(in_path, in_format)
348+ except SystemExit:
349+ raise ValueError(f"Could not read file as {in_format or 'any known format'}")
350+ _normalize_arrays(mesh)
351+ _remap_attributes(mesh, out_format)
352+ _sanitize_for_target(mesh, out_format)
353+ meshio.write(out_path, mesh, file_format=out_format, **_write_kwargs(out_format))
354+ return json.dumps({"byteLength": os.path.getsize(out_path)})
355+
356+
150357 def serialize_mesh(out_path, file_format, include_normals, include_colors):
151358 positions = np.fromfile(POSITIONS_F32, dtype=np.float32).reshape(-1, 3)
152359 indices = np.fromfile(INDICES_U32, dtype=np.uint32).reshape(-1, 3).astype(np.int32)
@@ -154,31 +361,11 @@ def serialize_mesh(out_path, file_format, include_normals, include_colors):
154361 point_data = {}
155362 if include_normals:
156363 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
364+ _attach_normals(point_data, file_format, normals)
165365 if include_colors:
166366 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
367+ _attach_colors(point_data, file_format, colors)
174368
175369 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)
370+ meshio.write(out_path, mesh, file_format=file_format, **_write_kwargs(file_format))
184371 return json.dumps({"byteLength": os.path.getsize(out_path)})
src/mesh/formats.tsmodified+15−9View file
@@ -44,15 +44,17 @@ export const formats: MeshFormat[] = [
4444 label: 'STL',
4545 extension: '.stl',
4646 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.',
47+ 'The 3D-printing staple: a bare triangle soup (written binary). Per-facet normals ' +
48+ 'are kept when present (else recomputed); vertex normals and colors cannot be stored.',
4949 capabilities: { normals: false, colors: false },
5050 },
5151 {
5252 id: 'off',
5353 label: 'OFF (Object File Format)',
5454 extension: '.off',
55- blurb: 'Minimal academic text format: vertex coordinates and faces, nothing else.',
55+ blurb:
56+ 'Minimal academic text format: vertex coordinates and triangle faces, nothing else ' +
57+ '(quad/color OFF variants are not supported by meshio).',
5658 capabilities: { normals: false, colors: false },
5759 },
5860 {
@@ -78,9 +80,9 @@ export const formats: MeshFormat[] = [
7880 label: 'Gmsh MSH',
7981 extension: '.msh',
8082 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 },
83+ 'Native format of the Gmsh mesh generator (v4.1 binary). Normals and colors travel ' +
84+ 'as NodeData point-data arrays.',
85+ capabilities: { normals: true, colors: true },
8486 },
8587 {
8688 id: 'xdmf',
@@ -153,15 +155,19 @@ export const formats: MeshFormat[] = [
153155 id: 'mdpa',
154156 label: 'MDPA (Kratos)',
155157 extension: '.mdpa',
156- blurb: 'Input format of the Kratos multiphysics framework. Geometry only.',
158+ blurb:
159+ 'Input format of the Kratos multiphysics framework. Geometry only — meshio writes ' +
160+ 'mesh data corruptly, so the app strips it to keep the file valid.',
157161 capabilities: { normals: false, colors: false },
158162 },
159163 {
160164 id: 'tecplot',
161165 label: 'Tecplot',
162166 extension: '.dat',
163- blurb: 'Tecplot ASCII data format. Geometry only.',
164- capabilities: { normals: false, colors: false },
167+ blurb:
168+ 'Tecplot ASCII data format. Normals and colors travel as per-node variables ' +
169+ '(nx/ny/nz, red/green/blue).',
170+ capabilities: { normals: true, colors: true },
165171 },
166172 {
167173 id: 'dolfin-xml',
src/mesh/meshio.tsmodified+61−0View file
@@ -141,6 +141,62 @@ export async function parseMeshFile(
141141 return { mesh, info }
142142 }
143143
144+/**
145+ * Native -> native conversion: write the original file bytes into /work and
146+ * run convert_mesh, returning [pyodide, outPath, byteLength]. Both formats'
147+ * extra packages are ensured — the source format's are needed to read, the
148+ * target's to write.
149+ */
150+async function runConvert(
151+ bytes: Uint8Array,
152+ srcFormat: MeshFormat,
153+ dstFormat: MeshFormat,
154+): Promise<[Pyodide, string, number]> {
155+ const pyodide = await initMeshio()
156+ await ensurePackages(pyodide, srcFormat)
157+ await ensurePackages(pyodide, dstFormat)
158+ pyodide.FS.mkdirTree('/work')
159+ const inPath = '/work/convert_in' + srcFormat.extension
160+ const outPath = '/work/convert_out' + dstFormat.extension
161+ pyodide.FS.writeFile(inPath, bytes)
162+ const result = runBridge(
163+ pyodide,
164+ `convert_mesh(${JSON.stringify(inPath)}, ${JSON.stringify(srcFormat.id)}, ` +
165+ `${JSON.stringify(outPath)}, ${JSON.stringify(dstFormat.id)})`,
166+ )
167+ pyodide.FS.unlink(inPath)
168+ const { byteLength } = JSON.parse(result) as { byteLength: number }
169+ return [pyodide, outPath, byteLength]
170+}
171+
172+/**
173+ * Convert the original file bytes from `srcFormat` to `dstFormat` through
174+ * meshio directly (no detour through the viewer's common representation), so
175+ * only what `dstFormat` cannot express is lost. Callers should short-circuit
176+ * the same-format case and hand back the original bytes untouched.
177+ */
178+export async function convertMesh(
179+ bytes: Uint8Array,
180+ srcFormat: MeshFormat,
181+ dstFormat: MeshFormat,
182+): Promise<Uint8Array<ArrayBuffer>> {
183+ const [pyodide, outPath] = await runConvert(bytes, srcFormat, dstFormat)
184+ const out = pyodide.FS.readFile(outPath)
185+ pyodide.FS.unlink(outPath)
186+ return out
187+}
188+
189+/** Byte size `bytes` would have converted to `dstFormat`, without keeping the bytes. */
190+export async function estimateConvertSize(
191+ bytes: Uint8Array,
192+ srcFormat: MeshFormat,
193+ dstFormat: MeshFormat,
194+): Promise<number> {
195+ const [pyodide, outPath, byteLength] = await runConvert(bytes, srcFormat, dstFormat)
196+ pyodide.FS.unlink(outPath)
197+ return byteLength
198+}
199+
144200 /** Write the mesh arrays into /work and run serialize_mesh; returns [pyodide, outPath, byteLength]. */
145201 async function runSerialize(
146202 mesh: MeshData,
@@ -166,6 +222,11 @@ async function runSerialize(
166222 return [pyodide, outPath, byteLength]
167223 }
168224
225+/**
226+ * Serialize the common-form `MeshData` to `format`. Used for the generated
227+ * sample mesh, which has no original file; uploaded files export losslessly
228+ * through {@link convertMesh} from their original bytes instead.
229+ */
169230 export async function serializeMesh(
170231 mesh: MeshData,
171232 format: MeshFormat,
moveopenescclose