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

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 len_expected = header["face_count"] * _stl_dtype.itemsize 

105 

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 ) 

114 

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

116 

117 # return empty geometry if there are no vertices 

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

119 return {"geometry": {}} 

120 

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

124 

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 

135 

136 

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

138 """ 

139 Load an ASCII STL file from a file object. 

140 

141 Parameters 

142 ---------- 

143 file_obj : open file- like object 

144 Containing input data 

145 

146 Returns 

147 ---------- 

148 loaded 

149 Keyword arguments for a Trimesh constructor with 

150 data loaded into properly shaped numpy arrays. 

151 """ 

152 

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() 

157 

158 # collect the keyword arguments for the Trimesh constructor 

159 kwargs = {} 

160 

161 # keep track of our position in the file 

162 position = 0 

163 

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) 

170 

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

172 position = solid_end + len("endsolid") 

173 

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

175 if solid_end < 0 or solid_start < 0: 

176 break 

177 

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

179 if solid_start > solid_end: 

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

181 

182 # get the chunk of text with this particular solid 

183 solid = raw_lower[solid_start:solid_end] 

184 

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

196 

197 # reshape vertices to final 3D shape 

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

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

200 

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) 

214 

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 

222 

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 } 

232 

233 if len(kwargs) == 1: 

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

235 

236 return {"geometry": kwargs} 

237 

238 

239def export_stl(mesh) -> bytes: 

240 """ 

241 Convert a Trimesh object into a binary STL file. 

242 

243 Parameters 

244 --------- 

245 mesh 

246 Trimesh object to export. 

247 

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() 

257 

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() 

263 

264 return export 

265 

266 

267def export_stl_ascii(mesh) -> str: 

268 """ 

269 Convert a Trimesh object into an ASCII STL file. 

270 

271 Parameters 

272 --------- 

273 mesh : trimesh.Trimesh 

274 

275 Returns 

276 --------- 

277 export 

278 Mesh represented as an ASCII STL file 

279 """ 

280 

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 

285 

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) 

299 

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 = "" 

306 

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

309 

310 

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