import numpy as np import collections import json from .. import util from .. import visual from .. import transformations as tf try: import networkx as nx except BaseException as E: # create a dummy module which will raise the ImportError # or other exception only when someone tries to use networkx from ..exceptions import ExceptionModule nx = ExceptionModule(E) def load_XAML(file_obj, *args, **kwargs): """ Load a 3D XAML file. Parameters ---------- file_obj : file object Open, containing XAML file Returns ---------- result : dict kwargs for a trimesh constructor, including: vertices: (n,3) np.float64, points in space faces: (m,3) np.int64, indices of vertices face_colors: (m,4) np.uint8, RGBA colors vertex_normals: (n,3) np.float64, vertex normals """ def element_to_color(element): """ Turn an XML element into a (4,) np.uint8 RGBA color """ if element is None: return visual.DEFAULT_COLOR hexcolor = int(element.attrib['Color'].replace('#', ''), 16) opacity = float(element.attrib['Opacity']) rgba = [(hexcolor >> 16) & 0xFF, (hexcolor >> 8) & 0xFF, (hexcolor & 0xFF), opacity * 0xFF] rgba = np.array(rgba, dtype=np.uint8) return rgba def element_to_transform(element): """ Turn an XML element into a (4,4) np.float64 transformation matrix. """ try: matrix = next(element.iter( tag=ns + 'MatrixTransform3D')).attrib['Matrix'] matrix = np.array(matrix.split(), dtype=np.float64).reshape((4, 4)).T return matrix except StopIteration: # this will be raised if the MatrixTransform3D isn't in the passed # elements tree return np.eye(4) # read the file and parse XML file_data = file_obj.read() root = etree.XML(file_data) # the XML namespace ns = root.tag.split('}')[0] + '}' # the linked lists our results are going in vertices = [] faces = [] colors = [] normals = [] # iterate through the element tree # the GeometryModel3D tag contains a material and geometry for geometry in root.iter(tag=ns + 'GeometryModel3D'): # get the diffuse and specular colors specified in the material color_search = './/{ns}{color}Material/*/{ns}SolidColorBrush' diffuse = geometry.find(color_search.format(ns=ns, color='Diffuse')) specular = geometry.find(color_search.format(ns=ns, color='Specular')) # convert the element into a (4,) np.uint8 RGBA color diffuse = element_to_color(diffuse) specular = element_to_color(specular) # to get the final transform of a component we'll have to traverse # all the way back to the root node and save transforms we find current = geometry transforms = collections.deque() # when the root node is reached its parent will be None and we stop while current is not None: # element.find will only return elements that are direct children # of the current element as opposed to element.iter, # which will return any depth of child transform_element = current.find(ns + 'ModelVisual3D.Transform') if transform_element is not None: # we are traversing the tree backwards, so append new # transforms to the left of the deque transforms.appendleft(element_to_transform(transform_element)) # we are going from the lowest level of the tree to the highest # this avoids having to traverse any branches that don't have # geometry current = current.getparent() if len(transforms) == 0: # no transforms in the tree mean an identity matrix transform = np.eye(4) elif len(transforms) == 1: # one transform in the tree we can just use transform = transforms.pop() else: # multiple transforms we apply all of them in order transform = util.multi_dot(transforms) # iterate through the contained mesh geometry elements for g in geometry.iter(tag=ns + 'MeshGeometry3D'): c_normals = np.array(g.attrib['Normals'].replace(',', ' ').split(), dtype=np.float64).reshape((-1, 3)) c_vertices = np.array( g.attrib['Positions'].replace( ',', ' ').split(), dtype=np.float64).reshape( (-1, 3)) # bake in the transform as we're saving c_vertices = tf.transform_points(c_vertices, transform) c_faces = np.array( g.attrib['TriangleIndices'].replace( ',', ' ').split(), dtype=np.int64).reshape( (-1, 3)) # save data to a sequence vertices.append(c_vertices) faces.append(c_faces) colors.append(np.tile(diffuse, (len(c_faces), 1))) normals.append(c_normals) # compile the results into clean numpy arrays result = dict() result['vertices'], result['faces'] = util.append_faces(vertices, faces) result['face_colors'] = np.vstack(colors) result['vertex_normals'] = np.vstack(normals) return result def load_3DXML(file_obj, *args, **kwargs): """ Load a 3DXML scene into kwargs. 3DXML is a CAD format that can be exported from Solidworks Parameters ------------ file_obj : file object Open and containing 3DXML data Returns ----------- kwargs : dict Can be passed to trimesh.exchange.load.load_kwargs """ archive = util.decompress(file_obj, file_type='zip') # a dictionary of file name : lxml etree as_etree = {} for k, v in archive.items(): # wrap in try statement, as sometimes 3DXML # contains non- xml files, like JPG previews try: as_etree[k] = etree.XML(v.read()) except etree.XMLSyntaxError: # move the file object back to the file start v.seek(0) # the file name of the root scene root_file = as_etree['Manifest.xml'].find('{*}Root').text # the etree of the scene layout tree = as_etree[root_file] # index of root element of directed acyclic graph root_id = tree.find('{*}ProductStructure').attrib['root'] # load the materials library from the materials elements colors = {} material_tree = as_etree['CATMaterialRef.3dxml'] for MaterialDomain in material_tree.iter('{*}MaterialDomain'): material_id = MaterialDomain.attrib['id'] material_file = MaterialDomain.attrib['associatedFile'].split( 'urn:3DXML:')[-1] rend = as_etree[material_file].find( "{*}Feature[@Alias='RenderingFeature']") diffuse = rend.find("{*}Attr[@Name='DiffuseColor']") # specular = rend.find("{*}Attr[@Name='SpecularColor']") # emissive = rend.find("{*}Attr[@Name='EmissiveColor']") rgb = (np.array(json.loads( diffuse.attrib['Value'])) * 255).astype(np.uint8) colors[material_id] = rgb # copy indexes for instances of colors for MaterialDomainInstance in material_tree.iter( '{*}MaterialDomainInstance'): instance = MaterialDomainInstance.find('{*}IsInstanceOf') # colors[b.attrib['id']] = colors[instance.text] for aggregate in MaterialDomainInstance.findall('{*}IsAggregatedBy'): colors[aggregate.text] = colors[instance.text] # references which hold the 3DXML scene structure as a dict # element id : {key : value} references = collections.defaultdict(dict) # the 3DXML can specify different visual properties for occurrences view = tree.find('{*}DefaultView') for ViewProp in view.iter('{*}DefaultViewProperty'): color = ViewProp.find('{*}GraphicProperties/' + '{*}SurfaceAttributes/{*}Color') if (color is None or 'RGBAColorType' not in color.attrib.values()): continue rgba = np.array([color.attrib[i] for i in ['red', 'green', 'blue', 'alpha']], dtype=np.float) rgba = (rgba * 255).astype(np.uint8) for occurrence in ViewProp.findall('{*}OccurenceId/{*}id'): reference_id = occurrence.text.split('#')[-1] references[reference_id]['color'] = rgba # geometries will hold meshes geometries = dict() # get geometry for ReferenceRep in tree.iter(tag='{*}ReferenceRep'): # the str of an int that represents this meshes unique ID part_id = ReferenceRep.attrib['id'] # which part file in the archive contains the geometry we care about part_file = ReferenceRep.attrib['associatedFile'].split(':')[-1] # load actual geometry mesh_faces = [] mesh_colors = [] mesh_normals = [] mesh_vertices = [] # the geometry is stored in a Rep for Rep in as_etree[part_file].iter('{*}Rep'): faces = Rep.find('{*}Faces/{*}Face') vertices = Rep.find('{*}VertexBuffer/{*}Positions') if faces is None or vertices is None: continue # these are vertex normals normals = Rep.find('{*}VertexBuffer/{*}Normals') material = Rep.find('{*}SurfaceAttributes/' + '{*}MaterialApplication/' + '{*}MaterialId') (material_file, material_id) = material.attrib['id'].split( 'urn:3DXML:')[-1].split('#') # triangle strips, sequence of arbitrary length lists # np.fromstring is substantially faster than np.array(i.split()) # inside the list comprehension strips = [np.fromstring(i, sep=' ', dtype=np.int64) for i in faces.attrib['strips'].split(',')] # convert strips to (m,3) int mesh_faces.append(util.triangle_strips_to_faces(strips)) # they mix delimiters like we couldn't figure it out from the # shape :( # load vertices into (n, 3) float64 mesh_vertices.append(np.fromstring( vertices.text.replace(',', ' '), sep=' ', dtype=np.float64).reshape((-1, 3))) # load vertex normals into (n, 3) float64 mesh_normals.append(np.fromstring( normals.text.replace(',', ' '), sep=' ', dtype=np.float64).reshape((-1, 3))) # store the material information as (m,3) uint8 FACE COLORS mesh_colors.append(np.tile(colors[material_id], (len(mesh_faces[-1]), 1))) # save each mesh as the kwargs for a trimesh.Trimesh constructor # aka, a Trimesh object can be created with trimesh.Trimesh(**mesh) # this avoids needing trimesh- specific imports in this IO function mesh = dict() (mesh['vertices'], mesh['faces']) = util.append_faces(mesh_vertices, mesh_faces) mesh['vertex_normals'] = np.vstack(mesh_normals) mesh['face_colors'] = np.vstack(mesh_colors) # as far as I can tell, all 3DXML files are exported as # implicit millimeters (it isn't specified in the file) mesh['metadata'] = {'units': 'mm'} mesh['class'] = 'Trimesh' geometries[part_id] = mesh references[part_id]['geometry'] = part_id # a Reference3D maps to a subassembly or assembly for Reference3D in tree.iter('{*}Reference3D'): references[Reference3D.attrib['id']] = { 'name': Reference3D.attrib['name'], 'type': 'Reference3D'} # a node that is the connectivity between a geometry and the Reference3D for InstanceRep in tree.iter('{*}InstanceRep'): current = InstanceRep.attrib['id'] instance = InstanceRep.find('{*}IsInstanceOf').text aggregate = InstanceRep.find('{*}IsAggregatedBy').text references[current].update({'aggregate': aggregate, 'instance': instance, 'type': 'InstanceRep'}) # an Instance3D maps basically to a part for Instance3D in tree.iter('{*}Instance3D'): matrix = np.eye(4) relative = Instance3D.find('{*}RelativeMatrix') if relative is not None: relative = np.array(relative.text.split(), dtype=np.float64) # rotation component matrix[:3, :3] = relative[:9].reshape((3, 3)).T # translation component matrix[:3, 3] = relative[9:] current = Instance3D.attrib['id'] name = Instance3D.attrib['name'] instance = Instance3D.find('{*}IsInstanceOf').text aggregate = Instance3D.find('{*}IsAggregatedBy').text references[current].update({'aggregate': aggregate, 'instance': instance, 'matrix': matrix, 'name': name, 'type': 'Instance3D'}) # turn references into directed graph for path finding graph = nx.DiGraph() for k, v in references.items(): # IsAggregatedBy points up to a parent if 'aggregate' in v: graph.add_edge(v['aggregate'], k) # IsInstanceOf indicates a child if 'instance' in v: graph.add_edge(k, v['instance']) # the 3DXML format is stored as a directed acyclic graph that needs all # paths from the root to a geometry to generate the tree of the scene paths = [] for geometry_id in geometries.keys(): paths.extend(nx.all_simple_paths(graph, source=root_id, target=geometry_id)) # the name of the root frame root_name = references[root_id]['name'] # create a list of kwargs to send to the scene.graph.update function # start with a transform from the graphs base frame to our root name graph_kwargs = [{'frame_to': root_name, 'matrix': np.eye(4)}] # we are going to collect prettier geometry names as we traverse paths geom_names = {} # loop through every simple path and generate transforms tree # note that we are flattening the transform tree here for path_index, path in enumerate(paths): name = '' if 'name' in references[path[-3]]: name = references[path[-3]]['name'] geom_names[path[-1]] = name # we need a unique node name for our geometry instance frame # due to the nature of the DAG names specified by the file may not # be unique, so we add an Instance3D name then append the path ids node_name = name + '#' + ':'.join(path) # pull all transformations in the path matrices = [references[i]['matrix'] for i in path if 'matrix' in references[i]] if len(matrices) == 0: matrix = np.eye(4) elif len(matrices) == 1: matrix = matrices[0] else: matrix = util.multi_dot(matrices) graph_kwargs.append({'matrix': matrix, 'frame_from': root_name, 'frame_to': node_name, 'geometry': path[-1]}) # remap geometry names from id numbers to the name string # we extracted from the 3DXML tree geom_final = {} for key, value in geometries.items(): if key in geom_names: geom_final[geom_names[key]] = value # change geometry names in graph kwargs in place for kwarg in graph_kwargs: if 'geometry' not in kwarg: continue kwarg['geometry'] = geom_names[kwarg['geometry']] # create the kwargs for load_kwargs result = {'class': 'Scene', 'geometry': geom_final, 'graph': graph_kwargs} return result def print_element(element): """ Pretty- print an lxml.etree element. Parameters ------------ element : etree element """ pretty = etree.tostring( element, pretty_print=True).decode('utf-8') print(pretty) return pretty try: from lxml import etree _xml_loaders = {'xaml': load_XAML, '3dxml': load_3DXML} except ImportError: _xml_loaders = {}