Coverage for trimesh/exchange/stl.py: 90%
103 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-24 04:40 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-24 04:40 +0000
1import numpy as np
3from .. import util
4from ..typed import Stream
7class HeaderError(Exception):
8 # the exception raised if an STL file object doesn't match its header
9 pass
12# define a numpy datatype for the data section of a binary STL file
13# everything in STL is always Little Endian
14# this works natively on Little Endian systems, but blows up on Big Endians
15# so we always specify byteorder
16_stl_dtype = np.dtype(
17 [("normals", "<f4", (3)), ("vertices", "<f4", (3, 3)), ("attributes", "<u2")]
18)
19# define a numpy datatype for the header of a binary STL file
20_stl_dtype_header = np.dtype([("header", np.void, 80), ("face_count", "<u4")])
23def load_stl(file_obj: Stream, **kwargs) -> dict:
24 """
25 Load a binary or an ASCII STL file from a file object.
27 Parameters
28 ----------
29 file_obj
30 Containing STL data
32 Returns
33 ----------
34 loaded
35 Keyword arguments for a Trimesh constructor with
36 data loaded into properly shaped numpy arrays.
37 """
38 # save start of file obj
39 file_pos = file_obj.tell()
40 try:
41 # check the file for a header which matches the file length
42 # if that is true, it is almost certainly a binary STL file
43 # if the header doesn't match the file length a HeaderError will be
44 # raised
45 return load_stl_binary(file_obj)
46 except HeaderError:
47 # move the file back to where it was initially
48 file_obj.seek(file_pos)
49 # try to load the file as an ASCII STL
50 # if the header doesn't match the file length
51 # HeaderError will be raised
52 return load_stl_ascii(file_obj)
55def load_stl_binary(file_obj: Stream) -> dict:
56 """
57 Load a binary STL file from a file object.
59 Parameters
60 ----------
61 file_obj : open file- like object
62 Containing STL data
64 Returns
65 ----------
66 loaded
67 Keyword arguments for a Trimesh constructor with data
68 loaded into properly shaped numpy arrays.
69 """
70 # the header is always 84 bytes long, we just reference the dtype.itemsize
71 # to be explicit about where that magical number comes from
72 header_length = _stl_dtype_header.itemsize
73 header_data = file_obj.read(header_length)
74 if len(header_data) < header_length:
75 raise HeaderError("Binary STL shorter than a fixed header!")
77 try:
78 header = np.frombuffer(header_data, dtype=_stl_dtype_header)
79 except BaseException:
80 raise HeaderError("Binary header incorrect type")
82 try:
83 # save the header block as a string
84 # there could be any garbage in there so wrap in try
85 metadata = {"header": util.decode_text(bytes(header["header"][0])).strip()}
86 except BaseException:
87 metadata = {}
89 # now we check the length from the header versus the length of the file
90 # data_start should always be position 84, but hard coding that felt ugly
91 data_start = file_obj.tell()
92 # this seeks to the end of the file
93 # position 0, relative to the end of the file 'whence=2'
94 file_obj.seek(0, 2)
95 # we save the location of the end of the file and seek back to where we
96 # started from
97 data_end = file_obj.tell()
98 file_obj.seek(data_start)
100 # the binary format has a rigidly defined structure, and if the length
101 # of the file doesn't match the header, the loaded version is almost
102 # certainly going to be garbage.
103 len_data = data_end - data_start
104 len_expected = header["face_count"] * _stl_dtype.itemsize
106 # this check is to see if this really is a binary STL file.
107 # if we don't do this and try to load a file that isn't structured properly
108 # we will be producing garbage or crashing hard
109 # so it's much better to raise an exception here.
110 if len_data != len_expected:
111 raise HeaderError(
112 f"Binary STL has incorrect length in header: {len_data} vs {len_expected}"
113 )
115 blob = np.frombuffer(file_obj.read(), dtype=_stl_dtype)
117 # return empty geometry if there are no vertices
118 if not len(blob["vertices"]):
119 return {"geometry": {}}
121 # all of our vertices will be loaded in order
122 # so faces are just sequential indices reshaped.
123 faces = np.arange(header["face_count"][0] * 3).reshape((-1, 3))
125 # there are two bytes per triangle saved for anything
126 # which is sometimes used for face color
127 result = {
128 "vertices": blob["vertices"].reshape((-1, 3)),
129 "face_normals": blob["normals"].reshape((-1, 3)),
130 "faces": faces,
131 "face_attributes": {"stl": blob["attributes"]},
132 "metadata": metadata,
133 }
134 return result
137def load_stl_ascii(file_obj: Stream) -> dict:
138 """
139 Load an ASCII STL file from a file object.
141 Parameters
142 ----------
143 file_obj : open file- like object
144 Containing input data
146 Returns
147 ----------
148 loaded
149 Keyword arguments for a Trimesh constructor with
150 data loaded into properly shaped numpy arrays.
151 """
153 # read all text into one string
154 raw_mixed = util.decode_text(file_obj.read()).strip()
155 # convert to lower case for solids and name capture
156 raw_lower = raw_mixed.lower()
158 # collect the keyword arguments for the Trimesh constructor
159 kwargs = {}
161 # keep track of our position in the file
162 position = 0
164 # use a for loop to avoid any possibility of infinite looping
165 for _ in range(len(raw_mixed)):
166 # find the start of the solid chunk
167 solid_start = raw_lower.find("solid", position)
168 # find the end of the solid chunk
169 solid_end = raw_lower.find("endsolid", position)
171 # on the next loop we don't have to check the text we've consumed
172 position = solid_end + len("endsolid")
174 # delimiter wasn't found for a chunk so exit
175 if solid_end < 0 or solid_start < 0:
176 break
178 # end delimiter order is wrong so this file is very malformed
179 if solid_start > solid_end:
180 raise ValueError("`endsolid` precedes `solid`!")
182 # get the chunk of text with this particular solid
183 solid = raw_lower[solid_start:solid_end]
185 # extract the vertices
186 vertex_text = solid.split("vertex")
187 vertices = np.fromstring(
188 " ".join(line[: line.find("\n")] for line in vertex_text[1:]),
189 sep=" ",
190 dtype=np.float64,
191 )
192 if len(vertices) < 3:
193 continue
194 if len(vertices) % 3 != 0:
195 raise ValueError("incorrect number of vertices")
197 # reshape vertices to final 3D shape
198 vertices = vertices.reshape((-1, 3))
199 faces = np.arange(len(vertices)).reshape((-1, 3))
201 # try to extract the face normals the same way
202 face_normals = None
203 try:
204 normal_text = solid.split("normal")
205 normals = np.fromstring(
206 " ".join(line[: line.find("\n")] for line in normal_text[1:]),
207 sep=" ",
208 dtype=np.float64,
209 )
210 if len(normals) == len(vertices):
211 face_normals = normals.reshape((-1, 3))
212 except BaseException:
213 util.log.warning("failed to extract face_normals", exc_info=True)
215 try:
216 # Previously checked to make sure there was matching 'solid' for 'endsolid'
217 # the name is right after the `solid` keyword if it exists
218 name = raw_mixed[solid_start : solid_start + solid.find("\n")][6:].strip()
219 except BaseException:
220 # will be filled in by unique_name
221 name = None
223 # make sure geometry has a unique name for the scene
224 name = util.unique_name(name, kwargs)
225 # save the constructor arguments
226 kwargs[name] = {
227 "vertices": vertices.reshape((-1, 3)),
228 "face_normals": face_normals,
229 "faces": faces,
230 "metadata": {"name": name},
231 }
233 if len(kwargs) == 1:
234 return next(iter(kwargs.values()))
236 return {"geometry": kwargs}
239def export_stl(mesh) -> bytes:
240 """
241 Convert a Trimesh object into a binary STL file.
243 Parameters
244 ---------
245 mesh
246 Trimesh object to export.
248 Returns
249 ---------
250 export
251 Represents mesh in binary STL form
252 """
253 header = np.zeros(1, dtype=_stl_dtype_header)
254 if hasattr(mesh, "faces"):
255 header["face_count"] = len(mesh.faces)
256 export = header.tobytes()
258 if hasattr(mesh, "faces"):
259 packed = np.zeros(len(mesh.faces), dtype=_stl_dtype)
260 packed["normals"] = mesh.face_normals
261 packed["vertices"] = mesh.triangles
262 export += packed.tobytes()
264 return export
267def export_stl_ascii(mesh) -> str:
268 """
269 Convert a Trimesh object into an ASCII STL file.
271 Parameters
272 ---------
273 mesh : trimesh.Trimesh
275 Returns
276 ---------
277 export
278 Mesh represented as an ASCII STL file
279 """
281 # move all the data that's going into the STL file into one array
282 blob = np.zeros((len(mesh.faces), 4, 3))
283 blob[:, 0, :] = mesh.face_normals
284 blob[:, 1:, :] = mesh.triangles
286 # create a lengthy format string for the data section of the file
287 formatter = (
288 "\n".join(
289 [
290 "facet normal {} {} {}",
291 "outer loop",
292 "vertex {} {} {}\nvertex {} {} {}\nvertex {} {} {}",
293 "endloop",
294 "endfacet",
295 "",
296 ]
297 )
298 ) * len(mesh.faces)
300 # try applying the name from metadata if it exists
301 name = mesh.metadata.get("name", "")
302 if not isinstance(name, str):
303 name = ""
304 if len(name) > 80 or "\n" in name:
305 name = ""
307 # concatenate the header, data, and footer, and a new line
308 return "\n".join([f"solid {name}", formatter.format(*blob.reshape(-1)), "endsolid\n"])
311_stl_loaders = {"stl": load_stl, "stl_ascii": load_stl}