/ concept-collection / mesh-converter
Sign in
concept-collection / mesh-converter
mesh-converter / src / mesh / bridge.py
184 lines · 7.0 KBCodeBlameHistory
50876cdConvert real mesh formats with meshio via PyodideJeremy Magland 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.
8import json
9import os
11import numpy as np
13import meshio
15WORK = "/work"
17POSITIONS_F32 = WORK + "/positions.f32"
18INDICES_U32 = WORK + "/indices.u32"
19NORMALS_F32 = WORK + "/normals.f32"
20COLORS_F32 = WORK + "/colors.f32"
22os.makedirs(WORK, exist_ok=True)
25def _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))
54def _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
69def _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
86def 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'}")
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])
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 )
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
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())
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))
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 )
150def 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)
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
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)})
moveopenescclose