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

1""" 

2threedxml.py 

3------------- 

4 

5Load 3DXML files, a scene format from Dassault products like Solidworks, Abaqus, Catia 

6""" 

7 

8import numpy as np 

9 

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 

18 

19 Image = ExceptionWrapper(E) 

20 

21import collections 

22import json 

23 

24from .. import util 

25from ..visual.texture import TextureVisuals 

26 

27 

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 

32 

33 Parameters 

34 ------------ 

35 file_obj : file object 

36 Open and containing 3DXML data 

37 

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

44 

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) 

57 

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

64 

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

95 

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) 

103 

104 # references which hold the 3DXML scene structure as a dict 

105 # element id : {key : value} 

106 references = collections.defaultdict(dict) 

107 

108 def get_rgba(color): 

109 """ 

110 Return (4,) uint8 color array defined by Color element attributes. 

111 

112 Parameters 

113 ----------- 

114 color : lxml.Element 

115 Element containing RGBA colors. 

116 

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) 

130 

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 

144 

145 # geometries will hold meshes 

146 geometries = {} 

147 

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 

161 

162 # load actual geometry 

163 mesh_faces = [] 

164 mesh_colors = [] 

165 mesh_normals = [] 

166 mesh_vertices = [] 

167 mesh_uv = [] 

168 mesh_image = None 

169 

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 

175 

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 

182 

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 ) 

191 

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 ) 

199 

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

209 

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 

220 

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

235 

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 ) 

242 

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

250 

251 rep_faces.extend(triangles) 

252 

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) 

261 

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) 

272 

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" 

277 

278 geometries[part_id] = mesh 

279 references[part_id]["geometry"] = part_id 

280 

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 } 

287 

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 

293 

294 references[current].update( 

295 {"aggregate": aggregate, "instance": instance, "type": "InstanceRep"} 

296 ) 

297 

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) 

304 

305 # rotation component 

306 matrix[:3, :3] = relative[:9].reshape((3, 3)).T 

307 # translation component 

308 matrix[:3, 3] = relative[9:] 

309 

310 current = Instance3D.attrib["id"] 

311 name = Instance3D.attrib["name"] 

312 instance = Instance3D.find("{*}IsInstanceOf").text 

313 aggregate = Instance3D.find("{*}IsAggregatedBy").text 

314 

315 references[current].update( 

316 { 

317 "aggregate": aggregate, 

318 "instance": instance, 

319 "matrix": matrix, 

320 "name": name, 

321 "type": "Instance3D", 

322 } 

323 ) 

324 

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

334 

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

340 

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 

345 

346 graph_kwargs = [{"frame_to": root_name, "matrix": np.eye(4)}] 

347 

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) 

361 

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) 

370 

371 graph_kwargs.append( 

372 { 

373 "matrix": matrix, 

374 "frame_from": root_name, 

375 "frame_to": node_name, 

376 "geometry": path[-1], 

377 } 

378 ) 

379 

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

391 

392 # create the kwargs for load_kwargs 

393 result = {"class": "Scene", "geometry": geom_final, "graph": graph_kwargs} 

394 

395 return result 

396 

397 

398def print_element(element): 

399 """ 

400 Pretty-print an lxml.etree element. 

401 

402 Parameters 

403 ------------ 

404 element : etree element 

405 """ 

406 pretty = etree.tostring(element, pretty_print=True).decode("utf-8") 

407 return pretty 

408 

409 

410try: 

411 # soft dependencies 

412 import networkx as nx 

413 from lxml import etree 

414 

415 from .common import XML_PARSER_OPTIONS 

416 

417 _threedxml_loaders = {"3dxml": load_3DXML} 

418except BaseException as E: 

419 # set loader to exception wrapper 

420 from ..exceptions import ExceptionWrapper 

421 

422 _threedxml_loaders = {"3dxml": ExceptionWrapper(E)}