Coverage for trimesh/exchange/threemf.py: 91%

190 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-24 04:40 +0000

1import io 

2import uuid 

3import zipfile 

4from collections import defaultdict 

5 

6import numpy as np 

7 

8from .. import graph, util 

9from ..constants import log 

10from ..util import unique_name 

11 

12 

13def _read_mesh(mesh): 

14 """ 

15 Read a `<mesh ` XML element into Numpy vertices and faces. 

16 

17 This is generally the most expensive operation in the load as it 

18 has to operate in Python-space on every single vertex and face. 

19 

20 Parameters 

21 ---------- 

22 mesh : lxml.etree.Element 

23 Input mesh element with `vertex` and `triangle` children. 

24 

25 Returns 

26 ---------- 

27 vertex_array : (n, 3) float64 

28 Vertices 

29 face_array : (n, 3) int64 

30 Indexes of vertices forming triangles. 

31 """ 

32 # get the XML elements for vertices and faces 

33 vertices = mesh.find("{*}vertices") 

34 faces = mesh.find("{*}triangles") 

35 

36 # get every value as a flat space-delimited string 

37 # this is very sensitive as it is large, i.e. it is 

38 # much faster with the full list comprehension before 

39 # the `.join` as the giant string can be fully allocated 

40 vs = " ".join( 

41 [ 

42 f"{i.attrib['x']} {i.attrib['y']} {i.attrib['z']}" 

43 for i in vertices.iter("{*}vertex") 

44 ] 

45 ) 

46 # convert every value to floating point in one-shot rather than in a loop 

47 v_array = np.fromstring(vs, dtype=np.float64, sep=" ").reshape((-1, 3)) 

48 

49 # do the same behavior for faces but as an integer 

50 fs = " ".join( 

51 [ 

52 f"{i.attrib['v1']} {i.attrib['v2']} {i.attrib['v3']}" 

53 for i in faces.iter("{*}triangle") 

54 ] 

55 ) 

56 f_array = np.fromstring(fs, dtype=np.int64, sep=" ").reshape((-1, 3)) 

57 

58 return v_array, f_array 

59 

60 

61def load_3MF(file_obj, postprocess=True, **kwargs): 

62 """ 

63 Load a 3MF formatted file into a Trimesh scene. 

64 

65 Parameters 

66 ------------ 

67 file_obj : file-like 

68 Contains 3MF formatted data 

69 

70 Returns 

71 ------------ 

72 kwargs : dict 

73 Constructor arguments for `trimesh.Scene` 

74 """ 

75 

76 # dict, {name in archive: BytesIo} 

77 archive = util.decompress(file_obj, file_type="zip") 

78 # get model with case-insensitive keys 

79 model = next(iter(v for k, v in archive.items() if "3d/3dmodel.model" in k.lower())) 

80 

81 # read root attributes only from XML first 

82 _event, root = next( 

83 etree.iterparse(model, tag=("{*}model"), events=("start",), **XML_PARSER_OPTIONS) 

84 ) 

85 # collect unit information from the tree 

86 if "unit" in root.attrib: 

87 metadata = {"units": root.attrib["unit"]} 

88 else: 

89 # the default units, defined by the specification 

90 metadata = {"units": "millimeters"} 

91 

92 # { mesh id : mesh name} 

93 id_name = {} 

94 # { mesh id: (n,3) float vertices} 

95 v_seq = defaultdict(list) 

96 # { mesh id: (n,3) int faces} 

97 f_seq = defaultdict(list) 

98 # components are objects that contain other objects 

99 # {id : [other ids]} 

100 components = defaultdict(list) 

101 # load information about the scene graph 

102 # each instance is a single geometry 

103 build_items = [] 

104 

105 # keep track of names we can use 

106 consumed_counts = {} 

107 consumed_names = set() 

108 

109 # iterate the XML object and build elements with an LXML iterator 

110 # loaded elements are cleared to avoid ballooning memory 

111 model.seek(0) 

112 for _, obj in etree.iterparse( 

113 model, tag=("{*}object", "{*}build"), events=("end",), **XML_PARSER_OPTIONS 

114 ): 

115 # parse objects 

116 if "object" in obj.tag: 

117 # id is mandatory 

118 index = obj.attrib["id"] 

119 

120 # start with stored name 

121 # apparently some exporters name multiple meshes 

122 # the same thing so check to see if it's been used 

123 name = unique_name( 

124 obj.attrib.get("name", str(index)), consumed_names, consumed_counts 

125 ) 

126 consumed_names.add(name) 

127 # store name reference on the index 

128 id_name[index] = name 

129 

130 # if the object has actual geometry data parse here 

131 for mesh in obj.iter("{*}mesh"): 

132 v, f = _read_mesh(mesh) 

133 v_seq[index].append(v) 

134 f_seq[index].append(f) 

135 

136 # components are references to other geometries 

137 for c in obj.iter("{*}component"): 

138 mesh_index = c.attrib["objectid"] 

139 transform = _attrib_to_transform(c.attrib) 

140 components[index].append((mesh_index, transform)) 

141 

142 # if this references another file as the `path` attrib 

143 path = next( 

144 (v.strip("/") for k, v in c.attrib.items() if k.endswith("path")), 

145 None, 

146 ) 

147 if path is not None and path in archive: 

148 archive[path].seek(0) 

149 name = unique_name( 

150 obj.attrib.get("name", str(mesh_index)), 

151 consumed_names, 

152 consumed_counts, 

153 ) 

154 consumed_names.add(name) 

155 # store name reference on the index 

156 id_name[mesh_index] = name 

157 

158 for _, m in etree.iterparse( 

159 archive[path], 

160 tag=("{*}mesh"), 

161 events=("end",), 

162 **XML_PARSER_OPTIONS, 

163 ): 

164 v, f = _read_mesh(m) 

165 v_seq[mesh_index].append(v) 

166 f_seq[mesh_index].append(f) 

167 

168 # parse build 

169 if "build" in obj.tag: 

170 # scene graph information stored here, aka "build" the scene 

171 for item in obj.iter("{*}item"): 

172 # get a transform from the item's attributes 

173 transform = _attrib_to_transform(item.attrib) 

174 partnumber = item.attrib.get("partnumber", None) 

175 # the index of the geometry this item instantiates 

176 build_items.append((item.attrib["objectid"], transform, partnumber)) 

177 

178 # have one mesh per 3MF object 

179 # one mesh per geometry ID, store as kwargs for the object 

180 meshes = {} 

181 for gid in v_seq.keys(): 

182 v, f = util.append_faces(v_seq[gid], f_seq[gid]) 

183 name = id_name[gid] 

184 meshes[name] = { 

185 "vertices": v, 

186 "faces": f, 

187 "metadata": metadata.copy(), 

188 } 

189 # apply any keyword arguments that aren't None 

190 meshes[name].update({k: v for k, v in kwargs.items() if v is not None}) 

191 

192 # turn the item / component representation into 

193 # a MultiDiGraph to compound our pain 

194 g = nx.MultiDiGraph() 

195 # build items are the only things that exist according to 3MF 

196 # so we accomplish that by linking them to the base frame 

197 # if partnumbers are None, the key will be an int 

198 for gid, tf, partnumber in build_items: 

199 g.add_edge("world", gid, key=partnumber, matrix=tf) 

200 # components are instances which need to be linked to base 

201 # frame by a build_item 

202 for start, group in components.items(): 

203 for gid, tf in group: 

204 g.add_edge(start, gid, matrix=tf) 

205 

206 # turn the graph into kwargs for a scene graph 

207 # flatten the scene structure and simplify to 

208 # a single unique node per instance 

209 graph_args = [] 

210 parents = defaultdict(set) 

211 used_names = set() 

212 for path in graph.multigraph_paths(G=g, source="world"): 

213 # collect all the transform on the path 

214 transforms = graph.multigraph_collect(G=g, traversal=path, attrib="matrix") 

215 # combine them into a single transform 

216 if len(transforms) == 1: 

217 transform = transforms[0] 

218 else: 

219 transform = util.multi_dot(transforms) 

220 

221 # the last element of the path should be the geometry 

222 last = path[-1][0] 

223 # if someone included an undefined component, skip it 

224 if last not in id_name: 

225 log.warning(f"id {last} included but not defined!") 

226 continue 

227 

228 if len(path[-1]) > 1 and isinstance(path[-1][1], str): 

229 # use the `partnumber` as the name 

230 name = path[-1][1] 

231 else: 

232 # use the name from the id 

233 name = id_name[last] 

234 

235 # make the name unique 

236 name = unique_name(name, used_names) 

237 used_names.add(name) 

238 

239 # index in meshes 

240 geom = id_name[last] 

241 

242 # collect parents if we want to combine later 

243 if len(path) > 2: 

244 parent = path[-2][0] 

245 parents[parent].add(last) 

246 

247 graph_args.append( 

248 { 

249 "frame_from": "world", 

250 "frame_to": name, 

251 "matrix": transform, 

252 "geometry": geom, 

253 } 

254 ) 

255 

256 # solidworks will export each body as its own mesh with the part 

257 # name as the parent so optionally rename and combine these bodies 

258 if postprocess and all("body" in i.lower() for i in meshes.keys()): 

259 # don't rename by default 

260 rename = {k: k for k in meshes.keys()} 

261 for parent, mesh_name in parents.items(): 

262 # only handle the case where a parent has a single child 

263 # if there are multiple children we would do a combine op 

264 if len(mesh_name) != 1: 

265 continue 

266 # rename the part 

267 rename[id_name[next(iter(mesh_name))]] = id_name[parent].split("(")[0] 

268 

269 # apply the rename operation meshes 

270 meshes = {rename[k]: m for k, m in meshes.items()} 

271 # rename geometry references in the scene graph 

272 for arg in graph_args: 

273 if "geometry" in arg: 

274 arg["geometry"] = rename[arg["geometry"]] 

275 

276 # construct the kwargs to load the scene 

277 kwargs = { 

278 "base_frame": "world", 

279 "graph": graph_args, 

280 "geometry": meshes, 

281 "metadata": metadata, 

282 } 

283 

284 return kwargs 

285 

286 

287def export_3MF(mesh, batch_size=4096, compression=zipfile.ZIP_DEFLATED, compresslevel=5): 

288 """ 

289 Converts a Trimesh object into a 3MF file. 

290 

291 Parameters 

292 --------- 

293 mesh trimesh.trimesh 

294 Mesh or Scene to export. 

295 batch_size : int 

296 Number of nodes to write per batch. 

297 compression : zipfile.ZIP_* 

298 Type of zip compression to use in this export. 

299 compresslevel : int 

300 Specify the 0-9 compression level. 

301 

302 Returns 

303 --------- 

304 export : bytes 

305 Represents geometry as a 3MF file. 

306 """ 

307 

308 from ..scene.scene import Scene 

309 

310 if not isinstance(mesh, Scene): 

311 mesh = Scene(mesh) 

312 

313 geometry = mesh.geometry 

314 graph = mesh.graph.to_networkx() 

315 base_frame = mesh.graph.base_frame 

316 

317 # xml namespaces 

318 model_nsmap = { 

319 None: "http://schemas.microsoft.com/3dmanufacturing/core/2015/02", 

320 "m": "http://schemas.microsoft.com/3dmanufacturing/material/2015/02", 

321 "p": "http://schemas.microsoft.com/3dmanufacturing/production/2015/06", 

322 "b": "http://schemas.microsoft.com/3dmanufacturing/beamlattice/2017/02", 

323 "s": "http://schemas.microsoft.com/3dmanufacturing/slice/2015/07", 

324 "sc": "http://schemas.microsoft.com/3dmanufacturing/securecontent/2019/04", 

325 } 

326 

327 rels_nsmap = {None: "http://schemas.openxmlformats.org/package/2006/relationships"} 

328 

329 # model ids 

330 models = [] 

331 

332 def model_id(x): 

333 if x not in models: 

334 models.append(x) 

335 return str(models.index(x) + 1) 

336 

337 # 3mf archive dict {path: BytesIO} 

338 file_obj = io.BytesIO() 

339 

340 # specify the parameters for the zip container 

341 zip_kwargs = {"compression": compression, "compresslevel": compresslevel} 

342 

343 with zipfile.ZipFile(file_obj, mode="w", **zip_kwargs) as z: 

344 # 3dmodel.model 

345 with ( 

346 z.open("3D/3dmodel.model", mode="w") as f, 

347 etree.xmlfile(f, encoding="utf-8") as xf, 

348 ): 

349 xf.write_declaration() 

350 

351 # stream elements 

352 with xf.element("model", {"unit": "millimeter"}, nsmap=model_nsmap): 

353 # objects with mesh data and/or references to other objects 

354 with xf.element("resources"): 

355 # stream objects with actual mesh data 

356 for i, (name, m) in enumerate(geometry.items()): 

357 # attributes for object 

358 attribs = { 

359 "id": model_id(name), 

360 "name": name, 

361 "type": "model", 

362 "p:UUID": str(uuid.uuid4()), 

363 } 

364 with xf.element("object", **attribs): 

365 with xf.element("mesh"): 

366 with xf.element("vertices"): 

367 # vertex nodes are written directly to the file 

368 # so make sure lxml's buffer is flushed 

369 xf.flush() 

370 for i in range(0, len(m.vertices), batch_size): 

371 batch = m.vertices[i : i + batch_size] 

372 fragment = ( 

373 '<vertex x="{}" y="{}" z="{}" />' * len(batch) 

374 ) 

375 f.write( 

376 fragment.format(*batch.flatten()).encode( 

377 "utf-8" 

378 ) 

379 ) 

380 with xf.element("triangles"): 

381 xf.flush() 

382 for i in range(0, len(m.faces), batch_size): 

383 batch = m.faces[i : i + batch_size] 

384 fragment = ( 

385 '<triangle v1="{}" v2="{}" v3="{}" />' 

386 * len(batch) 

387 ) 

388 f.write( 

389 fragment.format(*batch.flatten()).encode( 

390 "utf-8" 

391 ) 

392 ) 

393 

394 # stream components 

395 for node in graph.nodes: 

396 if node == base_frame or node.startswith("camera"): 

397 continue 

398 if len(graph[node]) == 0: 

399 continue 

400 

401 attribs = { 

402 "id": model_id(node), 

403 "name": node, 

404 "type": "model", 

405 "p:UUID": str(uuid.uuid4()), 

406 } 

407 with xf.element("object", **attribs): 

408 with xf.element("components"): 

409 for next, data in graph[node].items(): 

410 transform = " ".join( 

411 str(i) 

412 for i in np.array(data["matrix"])[ 

413 :3, :4 

414 ].T.flatten() 

415 ) 

416 xf.write( 

417 etree.Element( 

418 "component", 

419 { 

420 "objectid": model_id(data["geometry"]) 

421 if "geometry" in data 

422 else model_id(next), 

423 "transform": transform, 

424 }, 

425 ) 

426 ) 

427 

428 # stream build (objects on base_frame) 

429 with xf.element("build", {"p:UUID": str(uuid.uuid4())}): 

430 for node, data in graph[base_frame].items(): 

431 if node.startswith("camera"): 

432 continue 

433 transform = " ".join( 

434 str(i) for i in np.array(data["matrix"])[:3, :4].T.flatten() 

435 ) 

436 uuid_tag = "{{{}}}UUID".format(model_nsmap["p"]) 

437 xf.write( 

438 etree.Element( 

439 "item", 

440 { 

441 "objectid": model_id(data.get("geometry", node)), 

442 "transform": transform, 

443 uuid_tag: str(uuid.uuid4()), 

444 "partnumber": node, 

445 }, 

446 nsmap=model_nsmap, 

447 ) 

448 ) 

449 

450 # .rels 

451 with z.open("_rels/.rels", "w") as f, etree.xmlfile(f, encoding="utf-8") as xf: 

452 xf.write_declaration() 

453 # stream elements 

454 with xf.element("Relationships", nsmap=rels_nsmap): 

455 rt = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel" 

456 xf.write( 

457 etree.Element( 

458 "Relationship", 

459 Type=rt, 

460 Target="/3D/3dmodel.model", 

461 Id="rel0", 

462 ) 

463 ) 

464 

465 # [Content_Types].xml 

466 with ( 

467 z.open("[Content_Types].xml", "w") as f, 

468 etree.xmlfile(f, encoding="utf-8") as xf, 

469 ): 

470 xf.write_declaration() 

471 # xml namespaces 

472 nsmap = {None: "http://schemas.openxmlformats.org/package/2006/content-types"} 

473 

474 # stream elements 

475 types = [ 

476 ("jpeg", "image/jpeg"), 

477 ("jpg", "image/jpeg"), 

478 ("model", "application/vnd.ms-package.3dmanufacturing-3dmodel+xml"), 

479 ("png", "image/png"), 

480 ("rels", "application/vnd.openxmlformats-package.relationships+xml"), 

481 ( 

482 "texture", 

483 "application/vnd.ms-package.3dmanufacturing-3dmodeltexture", 

484 ), 

485 ] 

486 with xf.element("Types", nsmap=nsmap): 

487 for ext, ctype in types: 

488 xf.write(etree.Element("Default", Extension=ext, ContentType=ctype)) 

489 

490 return file_obj.getvalue() 

491 

492 

493def _attrib_to_transform(attrib): 

494 """ 

495 Extract a homogeneous transform from a dictionary. 

496 

497 Parameters 

498 ------------ 

499 attrib: dict, optionally containing 'transform' 

500 

501 Returns 

502 ------------ 

503 transform: (4, 4) float, homogeonous transformation 

504 """ 

505 

506 transform = np.eye(4, dtype=np.float64) 

507 if "transform" in attrib: 

508 # wangle their transform format 

509 values = np.array(attrib["transform"].split(), dtype=np.float64).reshape((4, 3)).T 

510 transform[:3, :4] = values 

511 return transform 

512 

513 

514# do import here to keep lxml a soft dependency 

515try: 

516 import networkx as nx 

517 from lxml import etree 

518 

519 from .common import XML_PARSER_OPTIONS 

520 

521 _three_loaders = {"3mf": load_3MF} 

522 _3mf_exporters = {"3mf": export_3MF} 

523except BaseException as E: 

524 from ..exceptions import ExceptionWrapper 

525 

526 _three_loaders = {"3mf": ExceptionWrapper(E)} 

527 _3mf_exporters = {"3mf": ExceptionWrapper(E)}