454 lines
17 KiB
Python
454 lines
17 KiB
Python
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 = {}
|