Coverage for trimesh/exchange/stl.py: 90%

103 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-08-31 18:21 +0000

1import numpy as np 

2 

3from .. import util 

4from ..typed import Stream 

5 

6 

7class HeaderError(Exception): 

8 # the exception raised if an STL file object doesn't match its header 

9 pass 

10 

11 

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")]) 

21 

22 

23def load_stl(file_obj: Stream, **kwargs) -> dict: 

24 """ 

25 Load a binary or an ASCII STL file from a file object. 

26 

27 Parameters 

28 ---------- 

29 file_obj 

30 Containing STL data 

31 

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) 

53 

54 

55def load_stl_binary(file_obj: Stream) -> dict: 

56 """ 

57 Load a binary STL file from a file object. 

58 

59 Parameters 

60 ---------- 

61 file_obj : open file- like object 

62 Containing STL data 

63 

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!") 

76 

77 try: 

78 header = np.frombuffer(header_data, dtype=_stl_dtype_header) 

79 except BaseException: 

80 raise HeaderError("Binary header incorrect type") 

81 

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 = {} 

88 

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) 

99 

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 # cast to `int` as `uint32` wraps on overflow 

105 len_expected = int(header["face_count"][0]) * _stl_dtype.itemsize 

106 

107 # this check is to see if this really is a binary STL file. 

108 # if we don't do this and try to load a file that isn't structured properly 

109 # we will be producing garbage or crashing hard 

110 # so it's much better to raise an exception here. 

111 if len_data != len_expected: 

112 raise HeaderError( 

113 f"Binary STL has incorrect length in header: {len_data} vs {len_expected}" 

114 ) 

115 

116 blob = np.frombuffer(file_obj.read(), dtype=_stl_dtype) 

117 

118 # return empty geometry if there are no vertices 

119 if not len(blob["vertices"]): 

120 return {"geometry": {}} 

121 

122 # all of our vertices will be loaded in order 

123 # so faces are just sequential indices reshaped. 

124 faces = np.arange(header["face_count"][0] * 3).reshape((-1, 3)) 

125 

126 # there are two bytes per triangle saved for anything 

127 # which is sometimes used for face color 

128 result = { 

129 "vertices": blob["vertices"].reshape((-1, 3)), 

130 "face_normals": blob["normals"].reshape((-1, 3)), 

131 "faces": faces, 

132 "face_attributes": {"stl": blob["attributes"]}, 

133 "metadata": metadata, 

134 } 

135 return result 

136 

137 

138def load_stl_ascii(file_obj: Stream) -> dict: 

139 """ 

140 Load an ASCII STL file from a file object. 

141 

142 Parameters 

143 ---------- 

144 file_obj : open file- like object 

145 Containing input data 

146 

147 Returns 

148 ---------- 

149 loaded 

150 Keyword arguments for a Trimesh constructor with 

151 data loaded into properly shaped numpy arrays. 

152 """ 

153 

154 # read all text into one string 

155 raw_mixed = util.decode_text(file_obj.read()).strip() 

156 # convert to lower case for solids and name capture 

157 raw_lower = raw_mixed.lower() 

158 

159 # collect the keyword arguments for the Trimesh constructor 

160 kwargs = {} 

161 

162 # keep track of our position in the file 

163 position = 0 

164 

165 # use a for loop to avoid any possibility of infinite looping 

166 for _ in range(len(raw_mixed)): 

167 # find the start of the solid chunk 

168 solid_start = raw_lower.find("solid", position) 

169 # find the end of the solid chunk 

170 solid_end = raw_lower.find("endsolid", position) 

171 

172 # on the next loop we don't have to check the text we've consumed 

173 position = solid_end + len("endsolid") 

174 

175 # delimiter wasn't found for a chunk so exit 

176 if solid_end < 0 or solid_start < 0: 

177 break 

178 

179 # end delimiter order is wrong so this file is very malformed 

180 if solid_start > solid_end: 

181 raise ValueError("`endsolid` precedes `solid`!") 

182 

183 # get the chunk of text with this particular solid 

184 solid = raw_lower[solid_start:solid_end] 

185 

186 # extract the vertices 

187 vertex_text = solid.split("vertex") 

188 vertices = np.fromstring( 

189 " ".join(line[: line.find("\n")] for line in vertex_text[1:]), 

190 sep=" ", 

191 dtype=np.float64, 

192 ) 

193 if len(vertices) < 3: 

194 continue 

195 if len(vertices) % 3 != 0: 

196 raise ValueError("incorrect number of vertices") 

197 

198 # reshape vertices to final 3D shape 

199 vertices = vertices.reshape((-1, 3)) 

200 faces = np.arange(len(vertices)).reshape((-1, 3)) 

201 

202 # try to extract the face normals the same way 

203 face_normals = None 

204 try: 

205 normal_text = solid.split("normal") 

206 normals = np.fromstring( 

207 " ".join(line[: line.find("\n")] for line in normal_text[1:]), 

208 sep=" ", 

209 dtype=np.float64, 

210 ) 

211 if len(normals) == len(vertices): 

212 face_normals = normals.reshape((-1, 3)) 

213 except BaseException: 

214 util.log.warning("failed to extract face_normals", exc_info=True) 

215 

216 try: 

217 # Previously checked to make sure there was matching 'solid' for 'endsolid' 

218 # the name is right after the `solid` keyword if it exists 

219 name = raw_mixed[solid_start : solid_start + solid.find("\n")][6:].strip() 

220 except BaseException: 

221 # will be filled in by unique_name 

222 name = None 

223 

224 # make sure geometry has a unique name for the scene 

225 name = util.unique_name(name, kwargs) 

226 # save the constructor arguments 

227 kwargs[name] = { 

228 "vertices": vertices.reshape((-1, 3)), 

229 "face_normals": face_normals, 

230 "faces": faces, 

231 "metadata": {"name": name}, 

232 } 

233 

234 if len(kwargs) == 1: 

235 return next(iter(kwargs.values())) 

236 

237 return {"geometry": kwargs} 

238 

239 

240def export_stl(mesh) -> bytes: 

241 """ 

242 Convert a Trimesh object into a binary STL file. 

243 

244 Parameters 

245 --------- 

246 mesh 

247 Trimesh object to export. 

248 

249 Returns 

250 --------- 

251 export 

252 Represents mesh in binary STL form 

253 """ 

254 header = np.zeros(1, dtype=_stl_dtype_header) 

255 if hasattr(mesh, "faces"): 

256 header["face_count"] = len(mesh.faces) 

257 export = header.tobytes() 

258 

259 if hasattr(mesh, "faces"): 

260 packed = np.zeros(len(mesh.faces), dtype=_stl_dtype) 

261 packed["normals"] = mesh.face_normals 

262 packed["vertices"] = mesh.triangles 

263 export += packed.tobytes() 

264 

265 return export 

266 

267 

268def export_stl_ascii(mesh) -> str: 

269 """ 

270 Convert a Trimesh object into an ASCII STL file. 

271 

272 Parameters 

273 --------- 

274 mesh : trimesh.Trimesh 

275 

276 Returns 

277 --------- 

278 export 

279 Mesh represented as an ASCII STL file 

280 """ 

281 

282 # move all the data that's going into the STL file into one array 

283 blob = np.zeros((len(mesh.faces), 4, 3)) 

284 blob[:, 0, :] = mesh.face_normals 

285 blob[:, 1:, :] = mesh.triangles 

286 

287 # create a lengthy format string for the data section of the file 

288 formatter = ( 

289 "\n".join( 

290 [ 

291 "facet normal {} {} {}", 

292 "outer loop", 

293 "vertex {} {} {}\nvertex {} {} {}\nvertex {} {} {}", 

294 "endloop", 

295 "endfacet", 

296 "", 

297 ] 

298 ) 

299 ) * len(mesh.faces) 

300 

301 # try applying the name from metadata if it exists 

302 name = mesh.metadata.get("name", "") 

303 if not isinstance(name, str): 

304 name = "" 

305 if len(name) > 80 or "\n" in name: 

306 name = "" 

307 

308 # concatenate the header, data, and footer, and a new line 

309 return "\n".join([f"solid {name}", formatter.format(*blob.reshape(-1)), "endsolid\n"]) 

310 

311 

312_stl_loaders = {"stl": load_stl, "stl_ascii": load_stl}