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
« 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
6import numpy as np
8from .. import graph, util
9from ..constants import log
10from ..util import unique_name
13def _read_mesh(mesh):
14 """
15 Read a `<mesh ` XML element into Numpy vertices and faces.
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.
20 Parameters
21 ----------
22 mesh : lxml.etree.Element
23 Input mesh element with `vertex` and `triangle` children.
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")
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))
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))
58 return v_array, f_array
61def load_3MF(file_obj, postprocess=True, **kwargs):
62 """
63 Load a 3MF formatted file into a Trimesh scene.
65 Parameters
66 ------------
67 file_obj : file-like
68 Contains 3MF formatted data
70 Returns
71 ------------
72 kwargs : dict
73 Constructor arguments for `trimesh.Scene`
74 """
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()))
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"}
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 = []
105 # keep track of names we can use
106 consumed_counts = {}
107 consumed_names = set()
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"]
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
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)
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))
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
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)
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))
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})
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)
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)
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
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]
235 # make the name unique
236 name = unique_name(name, used_names)
237 used_names.add(name)
239 # index in meshes
240 geom = id_name[last]
242 # collect parents if we want to combine later
243 if len(path) > 2:
244 parent = path[-2][0]
245 parents[parent].add(last)
247 graph_args.append(
248 {
249 "frame_from": "world",
250 "frame_to": name,
251 "matrix": transform,
252 "geometry": geom,
253 }
254 )
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]
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"]]
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 }
284 return kwargs
287def export_3MF(mesh, batch_size=4096, compression=zipfile.ZIP_DEFLATED, compresslevel=5):
288 """
289 Converts a Trimesh object into a 3MF file.
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.
302 Returns
303 ---------
304 export : bytes
305 Represents geometry as a 3MF file.
306 """
308 from ..scene.scene import Scene
310 if not isinstance(mesh, Scene):
311 mesh = Scene(mesh)
313 geometry = mesh.geometry
314 graph = mesh.graph.to_networkx()
315 base_frame = mesh.graph.base_frame
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 }
327 rels_nsmap = {None: "http://schemas.openxmlformats.org/package/2006/relationships"}
329 # model ids
330 models = []
332 def model_id(x):
333 if x not in models:
334 models.append(x)
335 return str(models.index(x) + 1)
337 # 3mf archive dict {path: BytesIO}
338 file_obj = io.BytesIO()
340 # specify the parameters for the zip container
341 zip_kwargs = {"compression": compression, "compresslevel": compresslevel}
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()
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 )
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
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 )
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 )
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 )
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"}
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))
490 return file_obj.getvalue()
493def _attrib_to_transform(attrib):
494 """
495 Extract a homogeneous transform from a dictionary.
497 Parameters
498 ------------
499 attrib: dict, optionally containing 'transform'
501 Returns
502 ------------
503 transform: (4, 4) float, homogeonous transformation
504 """
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
514# do import here to keep lxml a soft dependency
515try:
516 import networkx as nx
517 from lxml import etree
519 from .common import XML_PARSER_OPTIONS
521 _three_loaders = {"3mf": load_3MF}
522 _3mf_exporters = {"3mf": export_3MF}
523except BaseException as E:
524 from ..exceptions import ExceptionWrapper
526 _three_loaders = {"3mf": ExceptionWrapper(E)}
527 _3mf_exporters = {"3mf": ExceptionWrapper(E)}