1# Runs inside Pyodide. Bridges meshio to the JS app.
2#
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#
15# Arrays cross the JS/Python boundary as little-endian binary files in
16# Pyodide's in-memory filesystem (positions/normals/colors as float32
17# xyz-triples, indices as uint32 triangle triples); each call returns a JSON
18# string with counts, flags, and warnings. See meshio.ts for the JS side.
20import json
21import os
23import numpy as np
25import meshio
27WORK = "/work"
29POSITIONS_F32 = WORK + "/positions.f32"
30INDICES_U32 = WORK + "/indices.u32"
31NORMALS_F32 = WORK + "/normals.f32"
32COLORS_F32 = WORK + "/colors.f32"
34os.makedirs(WORK, exist_ok=True)
37class _ObjTolerantMesh(meshio.Mesh):
38 """OBJ faces index texture coordinates and normals independently of
39 vertex positions, so a file with UV seams legally has more vt (or vn)
40 entries than v entries. meshio shoehorns those into point_data, whose
41 per-vertex length check then rejects the whole file; drop the unmappable
42 arrays instead and remember what was dropped so callers can warn."""
44 def __init__(self, points, cells, point_data=None, **kwargs):
45 point_data = point_data or {}
46 self.dropped_point_data = {
47 key: len(value)
48 for key, value in point_data.items()
49 if len(value) != len(points)
50 }
51 point_data = {
52 key: value
53 for key, value in point_data.items()
54 if key not in self.dropped_point_data
55 }
56 super().__init__(points, cells, point_data=point_data, **kwargs)
59# the reader binds Mesh at module level, so this rebinding scopes the
60# tolerance to OBJ reads only (elsewhere a mismatch means real corruption)
61meshio.obj._obj.Mesh = _ObjTolerantMesh
63_OBJ_POINT_DATA_NAMES = {"obj:vt": "texture coordinates", "obj:vn": "vertex normals"}
66def _triangulate_cells(mesh, warnings):
67 """Collect surface cells as triangles, fan-triangulating quads/polygons."""
68 tri_blocks = []
69 skipped = []
70 for block in mesh.cells:
71 data = block.data
72 if not isinstance(data, np.ndarray) or data.ndim != 2 or data.shape[1] < 3:
73 skipped.append(block.type)
74 continue
75 if block.type == "triangle":
76 tri_blocks.append(data)
77 elif block.type in ("quad", "polygon"):
78 k = data.shape[1]
79 for i in range(1, k - 1):
80 tri_blocks.append(np.column_stack([data[:, 0], data[:, i], data[:, i + 1]]))
81 if k > 3:
82 warnings.append(f"{len(data)} {block.type} cells triangulated")
83 else:
84 skipped.append(block.type)
85 if skipped:
86 warnings.append("skipped non-surface cells: " + ", ".join(sorted(set(skipped))))
87 if not tri_blocks:
88 found = ", ".join(sorted({b.type for b in mesh.cells})) or "none"
89 raise ValueError(
90 f"No surface cells (triangle/quad/polygon) found; cell types in file: {found}"
91 )
92 return np.ascontiguousarray(np.vstack(tri_blocks).astype(np.uint32))
95# Formats whose meshio writer+reader round-trip vertex normals/colors via the
96# conventions in _attach_normals/_attach_colors. Mirrors `capabilities` in
97# formats.ts — keep the two in sync.
98NORMAL_FORMATS = {"ply", "obj", "vtk", "vtu", "gmsh", "xdmf", "med", "h5m", "avsucd", "tecplot"}
99COLOR_FORMATS = NORMAL_FORMATS - {"obj"}
101_NORMAL_ALIASES = ("normals", "normal")
102_COLOR_ALIASES = ("rgb", "rgba", "colors", "color")
105def _extract_normals(mesh):
106 pd = mesh.point_data
107 if "obj:vn" in pd: # wavefront obj
108 vn = np.asarray(pd["obj:vn"], dtype=np.float32)
109 if vn.ndim == 2 and vn.shape[1] >= 3:
110 return vn[:, :3]
111 if all(k in pd for k in ("nx", "ny", "nz")): # ply/tecplot convention
112 return np.column_stack([pd["nx"], pd["ny"], pd["nz"]]).astype(np.float32)
113 for key, value in pd.items():
114 value = np.asarray(value)
115 if key.lower() in _NORMAL_ALIASES and value.ndim == 2 and value.shape[1] == 3:
116 return value.astype(np.float32)
117 return None
120def _rgb_to_unit_float(rgb):
121 """Color channels as float32 in [0, 1]. Integer data is assumed 0-255;
122 float data that exceeds 1 is treated as 0-255 too (formats like tecplot
123 and gmsh store everything as floats, losing the integer dtype)."""
124 rgb = np.asarray(rgb)
125 if np.issubdtype(rgb.dtype, np.integer):
126 return (rgb / 255.0).astype(np.float32)
127 rgb = rgb.astype(np.float32)
128 if rgb.size and float(rgb.max()) > 1.0:
129 rgb = rgb / np.float32(255.0)
130 return rgb
133def _extract_colors(mesh):
134 """Vertex colors as float32 rgb in [0, 1], or None."""
135 pd = mesh.point_data
136 if all(k in pd for k in ("red", "green", "blue")): # ply/tecplot convention
137 return _rgb_to_unit_float(np.column_stack([pd["red"], pd["green"], pd["blue"]]))
138 for key, value in pd.items():
139 value = np.asarray(value)
140 if key.lower() in _COLOR_ALIASES and value.ndim == 2 and value.shape[1] >= 3:
141 return _rgb_to_unit_float(value[:, :3])
142 return None
145def _attach_normals(point_data, file_format, normals):
146 """Attach vertex normals under file_format's native naming."""
147 if file_format == "obj":
148 point_data["obj:vn"] = normals
149 elif file_format in ("ply", "tecplot"):
150 # scalar properties/variables; tecplot would split a 2D array into
151 # opaque Normals_0/1/2 columns
152 point_data["nx"] = normals[:, 0]
153 point_data["ny"] = normals[:, 1]
154 point_data["nz"] = normals[:, 2]
155 else:
156 point_data["Normals"] = normals
159def _attach_colors(point_data, file_format, colors):
160 """Attach vertex colors (float rgb in [0, 1]) under file_format's native
161 naming."""
162 if file_format == "ply":
163 rgb = np.clip(np.round(colors * 255.0), 0, 255).astype(np.uint8)
164 point_data["red"] = rgb[:, 0]
165 point_data["green"] = rgb[:, 1]
166 point_data["blue"] = rgb[:, 2]
167 elif file_format == "tecplot":
168 point_data["red"] = colors[:, 0]
169 point_data["green"] = colors[:, 1]
170 point_data["blue"] = colors[:, 2]
171 else:
172 point_data["RGB"] = colors
175def parse_mesh_file(path, file_format=None):
176 warnings = []
177 try:
178 # meshio's read helper exits the interpreter when every candidate
179 # reader fails; turn that into a normal exception
180 mesh = meshio.read(path, file_format)
181 except SystemExit:
182 raise ValueError(f"Could not read file as {file_format or 'any known format'}")
184 for key, count in getattr(mesh, "dropped_point_data", {}).items():
185 name = _OBJ_POINT_DATA_NAMES.get(key, key)
186 warnings.append(
187 f"dropped {name}: {count} entries for {len(mesh.points)} vertices"
188 )
190 points = np.asarray(mesh.points, dtype=np.float32)
191 if points.ndim != 2:
192 raise ValueError(f"Unexpected points array shape {points.shape}")
193 if points.shape[1] == 2:
194 points = np.column_stack([points, np.zeros(len(points), dtype=np.float32)])
195 warnings.append("2D points: added z=0")
196 points = np.ascontiguousarray(points[:, :3])
198 triangles = _triangulate_cells(mesh, warnings)
199 if triangles.size and int(triangles.max()) >= len(points):
200 raise ValueError(
201 f"Face index {int(triangles.max())} out of range (0..{len(points) - 1})"
202 )
204 normals = _extract_normals(mesh)
205 colors = _extract_colors(mesh)
206 # per-vertex attributes must match the vertex count to be usable
207 if normals is not None and len(normals) != len(points):
208 normals = None
209 if colors is not None and len(colors) != len(points):
210 colors = None
212 with open(POSITIONS_F32, "wb") as f:
213 f.write(points.tobytes())
214 with open(INDICES_U32, "wb") as f:
215 f.write(triangles.tobytes())
216 if normals is not None:
217 with open(NORMALS_F32, "wb") as f:
218 f.write(np.ascontiguousarray(normals).tobytes())
219 if colors is not None:
220 with open(COLORS_F32, "wb") as f:
221 f.write(np.ascontiguousarray(np.clip(colors, 0.0, 1.0)).tobytes())
223 other_point_data = sorted(
224 k for k in mesh.point_data
225 if k not in ("obj:vn", "nx", "ny", "nz", "red", "green", "blue")
226 and k.lower() not in _NORMAL_ALIASES + _COLOR_ALIASES
227 # format-internal bookkeeping, not user data
228 and not k.startswith(("gmsh:", "medit:", "nastran:"))
229 and k != "GLOBAL_ID"
230 )
231 if other_point_data:
232 warnings.append("ignored point data: " + ", ".join(other_point_data))
234 return json.dumps(
235 {
236 "numVertices": len(points),
237 "numFaces": len(triangles),
238 "hasNormals": normals is not None,
239 "hasColors": colors is not None,
240 "warnings": warnings,
241 }
242 )
245def _write_kwargs(file_format):
246 """Per-format quirks for meshio.write, shared by both write paths."""
247 kwargs = {}
248 if file_format == "stl":
249 kwargs["binary"] = True # meshio defaults STL to ASCII
250 elif file_format == "xdmf":
251 # default "HDF" puts the data in a companion .h5 file, which a
252 # single-file download can't deliver; inline it in the XML instead
253 kwargs["data_format"] = "XML"
254 return kwargs
257def _native_order(arr):
258 """Byte-swap to native endianness. Legacy VTK reads come back big-endian,
259 and several writers (ply, medit) look dtypes up in tables keyed by the
260 native forms only."""
261 arr = np.asarray(arr)
262 if not arr.dtype.isnative:
263 return arr.astype(arr.dtype.newbyteorder("="))
264 return arr
267def _normalize_arrays(mesh):
268 mesh.points = _native_order(mesh.points)
269 for block in mesh.cells:
270 block.data = _native_order(block.data)
271 for key, value in list(mesh.point_data.items()):
272 mesh.point_data[key] = _native_order(value)
273 for key, blocks in list(mesh.cell_data.items()):
274 mesh.cell_data[key] = [_native_order(b) for b in blocks]
277def _pop_normal_keys(pd):
278 """Remove every recognized representation of vertex normals."""
279 pd.pop("obj:vn", None)
280 if all(k in pd for k in ("nx", "ny", "nz")):
281 for k in ("nx", "ny", "nz"):
282 del pd[k]
283 for key in [k for k in pd if k.lower() in _NORMAL_ALIASES]:
284 del pd[key]
287def _pop_color_keys(pd):
288 if all(k in pd for k in ("red", "green", "blue")):
289 for k in ("red", "green", "blue"):
290 del pd[k]
291 for key in [k for k in pd if k.lower() in _COLOR_ALIASES]:
292 del pd[key]
295def _remap_attributes(mesh, out_format):
296 """Translate vertex normals/colors from the source format's naming into
297 out_format's native naming so they survive conversion (PLY's nx/ny/nz
298 become OBJ vn lines, and so on). Unrecognized point data passes through
299 untouched. Attributes the target cannot express are removed rather than
300 left under a name its writer would drop or mangle."""
301 num_points = len(mesh.points)
302 normals = _extract_normals(mesh)
303 if normals is not None and len(normals) == num_points:
304 _pop_normal_keys(mesh.point_data)
305 if out_format in NORMAL_FORMATS:
306 _attach_normals(mesh.point_data, out_format, np.ascontiguousarray(normals))
307 colors = _extract_colors(mesh)
308 if colors is not None and len(colors) == num_points:
309 _pop_color_keys(mesh.point_data)
310 if out_format in COLOR_FORMATS:
311 _attach_colors(mesh.point_data, out_format, np.clip(colors, 0.0, 1.0))
314def _sanitize_for_target(mesh, out_format):
315 """Work around meshio 5.3.5 writer defects that would crash or corrupt
316 the output file."""
317 if out_format == "mdpa":
318 # the NodalData/ElementalData writer reprs values ("np.float32(0.0)"
319 # under numpy>=2) and the reader can't parse those sections anyway;
320 # keep only the gmsh tag keys, which feed the structural element path
321 mesh.point_data.clear()
322 for key in [k for k in mesh.cell_data if k not in ("gmsh:physical", "gmsh:geometrical")]:
323 del mesh.cell_data[key]
324 elif out_format == "h5m":
325 # the writer still uses the pre-5.x dict-of-dicts cell_data API and
326 # crashes on any cell_data at all
327 mesh.cell_data.clear()
328 elif out_format == "dolfin-xml":
329 # cell_data goes to separate companion files a single-file download
330 # can't deliver, and integer data crashes the writer under numpy>=2
331 mesh.cell_data.clear()
332 elif out_format == "gmsh":
333 # the 4.1 writer's $Entities section needs the complete tag trio
334 # (gmsh:dim_tags point data + gmsh:physical/gmsh:geometrical cell
335 # data) and KeyErrors on a partial set — which is what re-reading a
336 # gmsh file without physical groups produces; fall back to writing no
337 # entity bookkeeping at all
338 have_all = (
339 "gmsh:dim_tags" in mesh.point_data
340 and "gmsh:physical" in mesh.cell_data
341 and "gmsh:geometrical" in mesh.cell_data
342 )
343 if not have_all:
344 mesh.point_data.pop("gmsh:dim_tags", None)
345 mesh.cell_data.pop("gmsh:physical", None)
346 mesh.cell_data.pop("gmsh:geometrical", None)
347 elif out_format == "medit":
348 # the writer formats the first integer array it finds as its scalar
349 # label column; multi-column int arrays (e.g. gmsh:dim_tags) crash it
350 for key in [k for k, v in mesh.point_data.items()
351 if v.ndim > 1 and np.issubdtype(v.dtype, np.integer)]:
352 del mesh.point_data[key]
353 for key in [k for k, blocks in mesh.cell_data.items()
354 if any(b.ndim > 1 and np.issubdtype(b.dtype, np.integer) for b in blocks)]:
355 del mesh.cell_data[key]
356 elif out_format == "xdmf":
357 # the XML data path (forced by _write_kwargs) has no format strings
358 # for sub-32-bit ints or float16 -> KeyError; upcast them
359 def upcast(arr):
360 if arr.dtype.kind in ("b", "i") and arr.dtype.itemsize < 4:
361 return arr.astype(np.int32)
362 if arr.dtype.kind == "u" and arr.dtype.itemsize < 4:
363 return arr.astype(np.uint32)
364 if arr.dtype.kind == "f" and arr.dtype.itemsize < 4:
365 return arr.astype(np.float32)
366 return arr
368 for key, value in list(mesh.point_data.items()):
369 mesh.point_data[key] = upcast(value)
370 for key, blocks in list(mesh.cell_data.items()):
371 mesh.cell_data[key] = [upcast(b) for b in blocks]
374def convert_mesh(in_path, in_format, out_path, out_format):
375 """Native -> native, straight through meshio. Reads the original file in
376 its own format and writes the target format without collapsing to the
377 app's triangle-only representation, so nothing is dropped except what the
378 target format genuinely cannot store. Vertex normals/colors are renamed
379 to the target's convention; everything else passes through as meshio
380 read it (modulo the writer workarounds in _sanitize_for_target)."""
381 try:
382 mesh = meshio.read(in_path, in_format)
383 except SystemExit:
384 raise ValueError(f"Could not read file as {in_format or 'any known format'}")
385 _normalize_arrays(mesh)
386 _remap_attributes(mesh, out_format)
387 _sanitize_for_target(mesh, out_format)
388 meshio.write(out_path, mesh, file_format=out_format, **_write_kwargs(out_format))
389 return json.dumps({"byteLength": os.path.getsize(out_path)})
392def serialize_mesh(out_path, file_format, include_normals, include_colors):
393 positions = np.fromfile(POSITIONS_F32, dtype=np.float32).reshape(-1, 3)
394 indices = np.fromfile(INDICES_U32, dtype=np.uint32).reshape(-1, 3).astype(np.int32)
396 point_data = {}
397 if include_normals:
398 normals = np.fromfile(NORMALS_F32, dtype=np.float32).reshape(-1, 3)
399 _attach_normals(point_data, file_format, normals)
400 if include_colors:
401 colors = np.fromfile(COLORS_F32, dtype=np.float32).reshape(-1, 3)
402 _attach_colors(point_data, file_format, colors)
404 mesh = meshio.Mesh(positions, [("triangle", indices)], point_data=point_data)
405 meshio.write(out_path, mesh, file_format=file_format, **_write_kwargs(file_format))
406 return json.dumps({"byteLength": os.path.getsize(out_path)})