Coverage for trimesh/exchange/threedxml.py: 93%
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
1"""
2threedxml.py
3-------------
5Load 3DXML files, a scene format from Dassault products like Solidworks, Abaqus, Catia
6"""
8import numpy as np
10try:
11 # `pip install pillow`
12 # optional: used for textured meshes
13 from PIL import Image
14except BaseException as E:
15 # if someone tries to use Image re-raise
16 # the import error so they can debug easily
17 from ..exceptions import ExceptionWrapper
19 Image = ExceptionWrapper(E)
21import collections
22import json
24from .. import util
25from ..visual.texture import TextureVisuals
28def load_3DXML(file_obj, *args, **kwargs):
29 """
30 Load a 3DXML scene into kwargs. 3DXML is a CAD format
31 that can be exported from Solidworks
33 Parameters
34 ------------
35 file_obj : file object
36 Open and containing 3DXML data
38 Returns
39 -----------
40 kwargs : dict
41 Can be passed to trimesh.exchange.load.load_kwargs
42 """
43 archive = util.decompress(file_obj, file_type="zip")
45 # a dictionary of file name : lxml etree
46 as_etree = {}
47 for k, v in archive.items():
48 # wrap in try statement, as sometimes 3DXML
49 # contains non- xml files, like JPG previews
50 try:
51 as_etree[k] = etree.fromstring(
52 v.read(), parser=etree.XMLParser(**XML_PARSER_OPTIONS)
53 )
54 except etree.XMLSyntaxError:
55 # move the file object back to the file start
56 v.seek(0)
58 # the file name of the root scene
59 root_file = as_etree["Manifest.xml"].find("{*}Root").text
60 # the etree of the scene layout
61 tree = as_etree[root_file]
62 # index of root element of directed acyclic graph
63 root_id = tree.find("{*}ProductStructure").attrib["root"]
65 # load the materials library from the materials elements
66 colors = {}
67 images = {}
68 # but only if it exists
69 material_key = "CATMaterialRef.3dxml"
70 if material_key in as_etree:
71 material_tree = as_etree[material_key]
72 for MaterialDomain in material_tree.iter("{*}MaterialDomain"):
73 material_id = MaterialDomain.attrib["id"]
74 material_file = MaterialDomain.attrib["associatedFile"].split("urn:3DXML:")[
75 -1
76 ]
77 rend = as_etree[material_file].find("{*}Feature[@Alias='RenderingFeature']")
78 diffuse = rend.find("{*}Attr[@Name='DiffuseColor']")
79 # specular = rend.find("{*}Attr[@Name='SpecularColor']")
80 # emissive = rend.find("{*}Attr[@Name='EmissiveColor']")
81 if diffuse is not None:
82 rgb = (np.array(json.loads(diffuse.attrib["Value"])) * 255).astype(
83 np.uint8
84 )
85 colors[material_id] = rgb
86 texture = rend.find("{*}Attr[@Name='TextureImage']")
87 if texture is not None:
88 tex_file, tex_id = texture.attrib["Value"].split(":")[-1].split("#")
89 rep_image = as_etree[tex_file].find(
90 f"{{*}}CATRepImage/{{*}}CATRepresentationImage[@id='{tex_id}']"
91 )
92 if rep_image is not None:
93 image_file = rep_image.get("associatedFile", "").split(":")[-1]
94 images[material_id] = Image.open(archive[image_file])
96 # copy indexes for instances of colors
97 for MaterialDomainInstance in material_tree.iter("{*}MaterialDomainInstance"):
98 instance = MaterialDomainInstance.find("{*}IsInstanceOf")
99 # colors[b.attrib['id']] = colors[instance.text]
100 for aggregate in MaterialDomainInstance.findall("{*}IsAggregatedBy"):
101 colors[aggregate.text] = colors.get(instance.text)
102 images[aggregate.text] = images.get(instance.text)
104 # references which hold the 3DXML scene structure as a dict
105 # element id : {key : value}
106 references = collections.defaultdict(dict)
108 def get_rgba(color):
109 """
110 Return (4,) uint8 color array defined by Color element attributes.
112 Parameters
113 -----------
114 color : lxml.Element
115 Element containing RGBA colors.
117 Returns
118 -----------
119 as_int : (4,) np.uint8
120 Colors as uint8 RGBA.
121 """
122 assert "RGBAColorType" in color.attrib.values()
123 # colors will be float 0.0 - 1.0
124 rgba = np.array(
125 [color.get(channel, 1.0) for channel in ("red", "green", "blue", "alpha")],
126 dtype=np.float64,
127 )
128 # convert to int colors
129 return (rgba * 255).astype(np.uint8)
131 # the 3DXML can specify different visual properties for occurrences
132 view = tree.find("{*}DefaultView")
133 if view is not None:
134 for ViewProp in view.iter("{*}DefaultViewProperty"):
135 color = ViewProp.find(
136 "{*}GraphicProperties/" + "{*}SurfaceAttributes/{*}Color"
137 )
138 if color is None:
139 continue
140 rgba = get_rgba(color)
141 for occurrence in ViewProp.findall("{*}OccurenceId/{*}id"):
142 reference_id = occurrence.text.split("#")[-1]
143 references[reference_id]["color"] = rgba
145 # geometries will hold meshes
146 geometries = {}
148 # get geometry
149 for ReferenceRep in tree.iter(tag="{*}ReferenceRep"):
150 # the str of an int that represents this meshes unique ID
151 part_id = ReferenceRep.attrib["id"]
152 # which part file in the archive contains the geometry we care about
153 part_file = ReferenceRep.attrib["associatedFile"].split(":")[-1]
154 # the format of the geometry file
155 part_format = ReferenceRep.attrib["format"]
156 if part_format not in ("TESSELLATED",):
157 util.log.warning(
158 f"ReferenceRep {part_file!r} unsupported format {part_format!r}"
159 )
160 continue
162 # load actual geometry
163 mesh_faces = []
164 mesh_colors = []
165 mesh_normals = []
166 mesh_vertices = []
167 mesh_uv = []
168 mesh_image = None
170 if part_file not in as_etree and part_file in archive:
171 # the data is stored in some binary format
172 util.log.warning(f"unable to load Rep {part_file!r}")
173 # data = archive[part_file]
174 continue
176 # the geometry is stored in a Rep
177 for Rep in as_etree[part_file].iter("{*}Rep"):
178 rep_faces = [] # faces sharing the same list of vertices
179 vertices = Rep.find("{*}VertexBuffer/{*}Positions")
180 if vertices is None:
181 continue
183 # they mix delimiters like we couldn't figure it out from the
184 # shape :(
185 # load vertices into (n, 3) float64
186 mesh_vertices.append(
187 np.fromstring(
188 vertices.text.replace(",", " "), sep=" ", dtype=np.float64
189 ).reshape((-1, 3))
190 )
192 # load vertex normals into (n, 3) float64
193 normals = Rep.find("{*}VertexBuffer/{*}Normals")
194 mesh_normals.append(
195 np.fromstring(
196 normals.text.replace(",", " "), sep=" ", dtype=np.float64
197 ).reshape((-1, 3))
198 )
200 uv = Rep.find("{*}VertexBuffer/{*}TextureCoordinates")
201 if uv is not None: # texture coordinates are available
202 rep_uv = np.fromstring(
203 uv.text.replace(",", " "), sep=" ", dtype=np.float64
204 )
205 if "1D" == uv.get("dimension"):
206 mesh_uv.append(np.stack([rep_uv, np.zeros(len(rep_uv))], axis=1))
207 else: # 2D
208 mesh_uv.append(rep_uv.reshape(-1, 2))
210 material = Rep.find(
211 "{*}SurfaceAttributes/" + "{*}MaterialApplication/" + "{*}MaterialId"
212 )
213 if material is None:
214 material_id = None
215 else:
216 (material_file, material_id) = (
217 material.attrib["id"].split("urn:3DXML:")[-1].split("#")
218 )
219 mesh_image = images.get(material_id) # texture for this Rep, if any
221 for faces in Rep.iter("{*}Faces"):
222 triangles = [] # mesh triangles for this Faces element
223 for face in faces.iter("{*}Face"):
224 # Each Face may have optional strips, triangles or fans attributes
225 if "strips" in face.attrib:
226 # triangle strips, sequence of arbitrary length lists
227 # np.fromstring is substantially faster than np.array(i.split())
228 # inside the list comprehension
229 strips = [
230 np.fromstring(i, sep=" ", dtype=np.int64)
231 for i in face.attrib["strips"].split(",")
232 ]
233 # convert strips to (m, 3) int triangles
234 triangles.extend(util.triangle_strips_to_faces(strips))
236 if "triangles" in face.attrib:
237 triangles.extend(
238 np.fromstring(
239 face.attrib["triangles"], sep=" ", dtype=np.int64
240 ).reshape((-1, 3))
241 )
243 if "fans" in face.attrib:
244 fans = [
245 np.fromstring(i, sep=" ", dtype=np.int64)
246 for i in face.attrib["fans"].split(",")
247 ]
248 # convert fans to (m, 3) int triangles
249 triangles.extend(util.triangle_fans_to_faces(fans))
251 rep_faces.extend(triangles)
253 # store the material information as (m, 3) uint8 FACE COLORS
254 faceColor = colors.get(material_id, [128, 128, 128])
255 # each Face may have its own color
256 colorElement = face.find("{*}SurfaceAttributes/{*}Color")
257 if colorElement is not None:
258 faceColor = get_rgba(colorElement)[:3]
259 mesh_colors.append(np.tile(faceColor, (len(triangles), 1)))
260 mesh_faces.append(rep_faces)
262 # save each mesh as the kwargs for a trimesh.Trimesh constructor
263 # aka, a Trimesh object can be created with trimesh.Trimesh(**mesh)
264 # this avoids needing trimesh- specific imports in this IO function
265 mesh = {}
266 (mesh["vertices"], mesh["faces"]) = util.append_faces(mesh_vertices, mesh_faces)
267 mesh["vertex_normals"] = np.vstack(mesh_normals)
268 if mesh_uv and mesh_image:
269 mesh["visual"] = TextureVisuals(uv=np.vstack(mesh_uv), image=mesh_image)
270 else:
271 mesh["face_colors"] = np.vstack(mesh_colors)
273 # as far as I can tell, all 3DXML files are exported as
274 # implicit millimeters (it isn't specified in the file)
275 mesh["metadata"] = {"units": "mm"}
276 mesh["class"] = "Trimesh"
278 geometries[part_id] = mesh
279 references[part_id]["geometry"] = part_id
281 # a Reference3D maps to a subassembly or assembly
282 for Reference3D in tree.iter("{*}Reference3D"):
283 references[Reference3D.attrib["id"]] = {
284 "name": Reference3D.attrib["name"],
285 "type": "Reference3D",
286 }
288 # a node that is the connectivity between a geometry and the Reference3D
289 for InstanceRep in tree.iter("{*}InstanceRep"):
290 current = InstanceRep.attrib["id"]
291 instance = InstanceRep.find("{*}IsInstanceOf").text
292 aggregate = InstanceRep.find("{*}IsAggregatedBy").text
294 references[current].update(
295 {"aggregate": aggregate, "instance": instance, "type": "InstanceRep"}
296 )
298 # an Instance3D maps basically to a part
299 for Instance3D in tree.iter("{*}Instance3D"):
300 matrix = np.eye(4)
301 relative = Instance3D.find("{*}RelativeMatrix")
302 if relative is not None:
303 relative = np.array(relative.text.split(), dtype=np.float64)
305 # rotation component
306 matrix[:3, :3] = relative[:9].reshape((3, 3)).T
307 # translation component
308 matrix[:3, 3] = relative[9:]
310 current = Instance3D.attrib["id"]
311 name = Instance3D.attrib["name"]
312 instance = Instance3D.find("{*}IsInstanceOf").text
313 aggregate = Instance3D.find("{*}IsAggregatedBy").text
315 references[current].update(
316 {
317 "aggregate": aggregate,
318 "instance": instance,
319 "matrix": matrix,
320 "name": name,
321 "type": "Instance3D",
322 }
323 )
325 # turn references into directed graph for path finding
326 graph = nx.DiGraph()
327 for k, v in references.items():
328 # IsAggregatedBy points up to a parent
329 if "aggregate" in v:
330 graph.add_edge(v["aggregate"], k)
331 # IsInstanceOf indicates a child
332 if "instance" in v:
333 graph.add_edge(k, v["instance"])
335 # the 3DXML format is stored as a directed acyclic graph that needs all
336 # paths from the root to a geometry to generate the tree of the scene
337 paths = []
338 for geometry_id in geometries.keys():
339 paths.extend(nx.all_simple_paths(graph, source=root_id, target=geometry_id))
341 # the name of the root frame
342 root_name = references[root_id]["name"]
343 # create a list of kwargs to send to the scene.graph.update function
344 # start with a transform from the graphs base frame to our root name
346 graph_kwargs = [{"frame_to": root_name, "matrix": np.eye(4)}]
348 # we are going to collect prettier geometry names as we traverse paths
349 geom_names = {}
350 # loop through every simple path and generate transforms tree
351 # note that we are flattening the transform tree here
352 for path in paths:
353 name = ""
354 if "name" in references[path[-3]]:
355 name = references[path[-3]]["name"]
356 geom_names[path[-1]] = name
357 # we need a unique node name for our geometry instance frame
358 # due to the nature of the DAG names specified by the file may not
359 # be unique, so we add an Instance3D name then append the path ids
360 node_name = name + "#" + ":".join(path)
362 # pull all transformations in the path
363 matrices = [references[i]["matrix"] for i in path if "matrix" in references[i]]
364 if len(matrices) == 0:
365 matrix = np.eye(4)
366 elif len(matrices) == 1:
367 matrix = matrices[0]
368 else:
369 matrix = util.multi_dot(matrices)
371 graph_kwargs.append(
372 {
373 "matrix": matrix,
374 "frame_from": root_name,
375 "frame_to": node_name,
376 "geometry": path[-1],
377 }
378 )
380 # remap geometry names from id numbers to the name string
381 # we extracted from the 3DXML tree
382 geom_final = {}
383 for key, value in geometries.items():
384 if key in geom_names:
385 geom_final[geom_names[key]] = value
386 # change geometry names in graph kwargs in place
387 for kwarg in graph_kwargs:
388 if "geometry" not in kwarg:
389 continue
390 kwarg["geometry"] = geom_names[kwarg["geometry"]]
392 # create the kwargs for load_kwargs
393 result = {"class": "Scene", "geometry": geom_final, "graph": graph_kwargs}
395 return result
398def print_element(element):
399 """
400 Pretty-print an lxml.etree element.
402 Parameters
403 ------------
404 element : etree element
405 """
406 pretty = etree.tostring(element, pretty_print=True).decode("utf-8")
407 return pretty
410try:
411 # soft dependencies
412 import networkx as nx
413 from lxml import etree
415 from .common import XML_PARSER_OPTIONS
417 _threedxml_loaders = {"3dxml": load_3DXML}
418except BaseException as E:
419 # set loader to exception wrapper
420 from ..exceptions import ExceptionWrapper
422 _threedxml_loaders = {"3dxml": ExceptionWrapper(E)}