/ concept-collection / mesh-converter
Sign in
concept-collection / mesh-converter
mesh-converter / src / mesh / bridge.py
371 lines · 15.2 KBBlameHistoryRaw
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.
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)
37def _triangulate_cells(mesh, warnings):
38 """Collect surface cells as triangles, fan-triangulating quads/polygons."""
39 tri_blocks = []
40 skipped = []
41 for block in mesh.cells:
42 data = block.data
43 if not isinstance(data, np.ndarray) or data.ndim != 2 or data.shape[1] < 3:
44 skipped.append(block.type)
45 continue
46 if block.type == "triangle":
47 tri_blocks.append(data)
48 elif block.type in ("quad", "polygon"):
49 k = data.shape[1]
50 for i in range(1, k - 1):
51 tri_blocks.append(np.column_stack([data[:, 0], data[:, i], data[:, i + 1]]))
52 if k > 3:
53 warnings.append(f"{len(data)} {block.type} cells triangulated")
54 else:
55 skipped.append(block.type)
56 if skipped:
57 warnings.append("skipped non-surface cells: " + ", ".join(sorted(set(skipped))))
58 if not tri_blocks:
59 found = ", ".join(sorted({b.type for b in mesh.cells})) or "none"
60 raise ValueError(
61 f"No surface cells (triangle/quad/polygon) found; cell types in file: {found}"
62 )
63 return np.ascontiguousarray(np.vstack(tri_blocks).astype(np.uint32))
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.
69NORMAL_FORMATS = {"ply", "obj", "vtk", "vtu", "gmsh", "xdmf", "med", "h5m", "avsucd", "tecplot"}
70COLOR_FORMATS = NORMAL_FORMATS - {"obj"}
72_NORMAL_ALIASES = ("normals", "normal")
73_COLOR_ALIASES = ("rgb", "rgba", "colors", "color")
76def _extract_normals(mesh):
77 pd = mesh.point_data
78 if "obj:vn" in pd: # wavefront obj
79 vn = np.asarray(pd["obj:vn"], dtype=np.float32)
80 if vn.ndim == 2 and vn.shape[1] >= 3:
81 return vn[:, :3]
82 if all(k in pd for k in ("nx", "ny", "nz")): # ply/tecplot convention
83 return np.column_stack([pd["nx"], pd["ny"], pd["nz"]]).astype(np.float32)
84 for key, value in pd.items():
85 value = np.asarray(value)
86 if key.lower() in _NORMAL_ALIASES and value.ndim == 2 and value.shape[1] == 3:
87 return value.astype(np.float32)
88 return None
91def _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
104def _extract_colors(mesh):
105 """Vertex colors as float32 rgb in [0, 1], or None."""
106 pd = mesh.point_data
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"]]))
109 for key, value in pd.items():
110 value = np.asarray(value)
111 if key.lower() in _COLOR_ALIASES and value.ndim == 2 and value.shape[1] >= 3:
112 return _rgb_to_unit_float(value[:, :3])
113 return None
116def _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
130def _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
146def parse_mesh_file(path, file_format=None):
147 warnings = []
148 try:
149 # meshio's read helper exits the interpreter when every candidate
150 # reader fails; turn that into a normal exception
151 mesh = meshio.read(path, file_format)
152 except SystemExit:
153 raise ValueError(f"Could not read file as {file_format or 'any known format'}")
155 points = np.asarray(mesh.points, dtype=np.float32)
156 if points.ndim != 2:
157 raise ValueError(f"Unexpected points array shape {points.shape}")
158 if points.shape[1] == 2:
159 points = np.column_stack([points, np.zeros(len(points), dtype=np.float32)])
160 warnings.append("2D points: added z=0")
161 points = np.ascontiguousarray(points[:, :3])
163 triangles = _triangulate_cells(mesh, warnings)
164 if triangles.size and int(triangles.max()) >= len(points):
165 raise ValueError(
166 f"Face index {int(triangles.max())} out of range (0..{len(points) - 1})"
167 )
169 normals = _extract_normals(mesh)
170 colors = _extract_colors(mesh)
171 # per-vertex attributes must match the vertex count to be usable
172 if normals is not None and len(normals) != len(points):
173 normals = None
174 if colors is not None and len(colors) != len(points):
175 colors = None
177 with open(POSITIONS_F32, "wb") as f:
178 f.write(points.tobytes())
179 with open(INDICES_U32, "wb") as f:
180 f.write(triangles.tobytes())
181 if normals is not None:
182 with open(NORMALS_F32, "wb") as f:
183 f.write(np.ascontiguousarray(normals).tobytes())
184 if colors is not None:
185 with open(COLORS_F32, "wb") as f:
186 f.write(np.ascontiguousarray(np.clip(colors, 0.0, 1.0)).tobytes())
188 other_point_data = sorted(
189 k for k in mesh.point_data
190 if k not in ("obj:vn", "nx", "ny", "nz", "red", "green", "blue")
191 and k.lower() not in _NORMAL_ALIASES + _COLOR_ALIASES
192 # format-internal bookkeeping, not user data
193 and not k.startswith(("gmsh:", "medit:", "nastran:"))
194 and k != "GLOBAL_ID"
195 )
196 if other_point_data:
197 warnings.append("ignored point data: " + ", ".join(other_point_data))
199 return json.dumps(
200 {
201 "numVertices": len(points),
202 "numFaces": len(triangles),
203 "hasNormals": normals is not None,
204 "hasColors": colors is not None,
205 "warnings": warnings,
206 }
207 )
210def _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
222def _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
232def _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]
242def _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]
252def _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]
260def _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))
279def _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
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]
339def 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)})
357def serialize_mesh(out_path, file_format, include_normals, include_colors):
358 positions = np.fromfile(POSITIONS_F32, dtype=np.float32).reshape(-1, 3)
359 indices = np.fromfile(INDICES_U32, dtype=np.uint32).reshape(-1, 3).astype(np.int32)
361 point_data = {}
362 if include_normals:
363 normals = np.fromfile(NORMALS_F32, dtype=np.float32).reshape(-1, 3)
364 _attach_normals(point_data, file_format, normals)
365 if include_colors:
366 colors = np.fromfile(COLORS_F32, dtype=np.float32).reshape(-1, 3)
367 _attach_colors(point_data, file_format, colors)
369 mesh = meshio.Mesh(positions, [("triangle", indices)], point_data=point_data)
370 meshio.write(out_path, mesh, file_format=file_format, **_write_kwargs(file_format))
371 return json.dumps({"byteLength": os.path.getsize(out_path)})
moveopenescclose