Source code for ladybug_geometry.geometry3d.mesh

# coding=utf-8
"""3D Mesh"""
from __future__ import division

from .._mesh import MeshBase
from ..geometry2d.mesh import Mesh2D
from .pointvector import Point3D, Vector3D
from .line import LineSegment3D
from .polyline import Polyline3D
from .plane import Plane

try:
    from itertools import izip as zip  # python 2
except ImportError:
    xrange = range  # python 3


[docs] class Mesh3D(MeshBase): """3D Mesh object. Args: vertices: A list or tuple of Point3D objects for vertices. faces: A list of tuples with each tuple having either 3 or 4 integers. These integers correspond to indices within the list of vertices. colors: An optional list of colors that correspond to either the faces of the mesh or the vertices of the mesh. Default is None. Properties: * vertices * faces * colors * is_color_by_face * min * max * center * area * face_areas * face_centroids * face_area_centroids * face_vertices * face_normals * vertex_normals * vertex_connected_faces * face_edges * edges * naked_edges * internal_edges * non_manifold_edges """ __slots__ = ('_min', '_max', '_center', '_face_normals', '_vertex_normals') def __init__(self, vertices, faces, colors=None): """Initialize Mesh3D.""" self._vertices = self._check_vertices_input(vertices) self._faces = self._check_faces_input(faces) self._is_color_by_face = False # default if colors is None self.colors = colors self._min = None self._max = None self._center = None self._area = None self._face_areas = None self._face_centroids = None self._face_area_centroids = None self._face_normals = None self._vertex_normals = None self._vertex_connected_faces = None self._edge_indices = None self._edge_types = None self._edges = None self._naked_edges = None self._internal_edges = None self._non_manifold_edges = None
[docs] @classmethod def from_dict(cls, data): """Create a Mesh3D from a dictionary. Args: data: A python dictionary in the following format .. code-block:: python { "type": "Mesh3D", "vertices": [(0, 0, 0), (10, 0, 0), (0, 10, 0)], "faces": [(0, 1, 2)], "colors": [{"r": 255, "g": 0, "b": 0}] } """ colors = None if 'colors' in data and data['colors'] is not None and len(data['colors']) != 0: try: from ladybug.color import Color except ImportError: raise ImportError('Colors are specified in input Mesh2D dictionary ' 'but failed to import ladybug.color') colors = tuple(Color.from_dict(col) for col in data['colors']) fcs = tuple(tuple(f) for f in data['faces']) # cast to immutable type return cls(tuple(Point3D.from_array(pt) for pt in data['vertices']), fcs, colors)
[docs] @classmethod def from_face_vertices(cls, faces, purge=True): """Create a mesh from a list of faces with each face defined by Point3Ds. Args: faces: A list of faces with each face defined as a list of 3 or 4 Point3D. purge: A boolean to indicate if duplicate vertices should be shared between faces. Default is True to purge duplicate vertices, which can be slow for large lists of faces but results in a higher-quality mesh with a smaller size in memory. Note that vertices are only considered duplicate if the coordinate values are equal to one another within floating point tolerance. To remove duplicate vertices within a specified tolerance other than floating point, the from_purged_face_vertices method should be used instead. """ vertices, face_collector = cls._interpret_input_from_face_vertices(faces, purge) return cls(tuple(vertices), tuple(face_collector))
[docs] @classmethod def from_purged_face_vertices(cls, faces, tolerance): """Create a mesh from a list of faces with each face defined by Point3Ds. This method is slower than 'from_face_vertices' but will result in a mesh with fewer vertices and a smaller size in memory. This method is similar to using the 'purge' option in 'from_face_vertices' but will result in more shared vertices since it uses a tolerance to check equivalent vertices rather than comparing within floating point tolerance. Args: faces: A list of faces with each face defined as a list of 3 or 4 Point3D. tolerance: A number for the minimum difference between coordinate values at which point vertices are considered equal to one another. """ vertices, faces = cls._interpret_input_from_face_vertices_with_tolerance( faces, tolerance) return cls(tuple(vertices), tuple(faces))
[docs] @classmethod def from_mesh2d(cls, mesh_2d, plane=None): """Create a Mesh3D from a Mesh2D and a Plane in which the mesh exists. Args: mesh_2d: A Mesh2D object. plane: A Plane object to represent the plane in which the Mesh2D exists within 3D space. If None, the WorldXY plane will be used. """ assert isinstance(mesh_2d, Mesh2D), 'Expected Mesh2D for from_mesh_2d. ' \ 'Got {}.'.format(type(mesh_2d)) if plane is None: return cls(tuple(Point3D(pt.x, pt.y, 0) for pt in mesh_2d.vertices), mesh_2d.faces, mesh_2d.colors) else: assert isinstance(plane, Plane), 'Expected Plane. Got {}'.format(type(plane)) _verts3d = tuple(plane.xy_to_xyz(_v) for _v in mesh_2d.vertices) return cls(_verts3d, mesh_2d.faces, mesh_2d.colors)
[docs] @classmethod def from_stl(cls, file_path): """Create a Mesh3D from an STL file. Args: file_path: Path to an STL file as a text string. The STL file can be in either ASCII or binary format. """ from ladybug_geometry.interop.stl import STL # avoid circular import face_vertices = STL.from_file(file_path).face_vertices return cls.from_face_vertices(face_vertices)
[docs] @classmethod def from_obj(cls, file_path): """Create a Mesh3D from an OBJ file. Args: file_path: Path to an OBJ file as a text string. """ from ladybug_geometry.interop.obj import OBJ # avoid circular import transl_obj = OBJ.from_file(file_path) return cls(transl_obj.vertices, transl_obj.faces, transl_obj.vertex_colors)
@property def min(self): """A Point3D for the minimum bounding box vertex around this mesh.""" if self._min is None: self._calculate_min_max() return self._min @property def max(self): """A Point3D for the maximum bounding box vertex around this mesh.""" if self._max is None: self._calculate_min_max() return self._max @property def center(self): """A Point3D for the center of the bounding box around this mesh.""" if self._center is None: min, max = self.min, self.max self._center = Point3D( (min.x + max.x) / 2, (min.y + max.y) / 2, (min.z + max.z) / 2) return self._center @property def face_areas(self): """A tuple of face areas that parallels the faces property.""" if self._face_normals is None: self._calculate_face_areas_and_normals() elif isinstance(self._face_areas, (float, int)): # same area for each face self._face_areas = tuple(self._face_areas for face in self.faces) return self._face_areas @property def face_normals(self): """Tuple of Vector3D objects for all face normals.""" if self._face_normals is None: self._calculate_face_areas_and_normals() elif isinstance(self._face_normals, Vector3D): # same normal for each face self._face_normals = tuple(self._face_normals for face in self.faces) return self._face_normals @property def vertex_normals(self): """Tuple of Vector3D objects for all vertex normals.""" if not self._vertex_normals: self._calculate_vertex_normals() elif isinstance(self._vertex_normals, Vector3D): # same normal for each vertex self._vertex_normals = tuple(self._vertex_normals for face in self.vertices) return self._vertex_normals @property def face_edges(self): """List of polylines with one Polyline3D for each face. This is faster to compute compared to the edges and results in effectively the same type of wireframe visualization. """ _all_verts = self._vertices f_edges = [] for face in self._faces: verts = tuple(_all_verts[v] for v in face) + (_all_verts[face[0]],) f_edges.append(Polyline3D(verts)) return f_edges @property def edges(self): """"Tuple of all edges in this Mesh3D as LineSegment3D objects. Note that this method will return only the unique edges in the mesh without any duplicates. This is sometimes desirable but can take a lot of time to compute for large meshes. For a faster property, use face_edges.""" if self._edges is None: if self._edge_indices is None: self._compute_edge_info() self._edges = tuple(LineSegment3D.from_end_points( self.vertices[seg[0]], self.vertices[seg[1]]) for seg in self._edge_indices) return self._edges @property def naked_edges(self): """"Tuple of all naked edges in this Mesh3D as LineSegment3D objects. Naked edges belong to only one face in the mesh (they are not shared between faces). """ if self._naked_edges is None: self._naked_edges = self._get_edge_type(0) return self._naked_edges @property def internal_edges(self): """"Tuple of all internal edges in this Mesh3D as LineSegment3D objects. Internal edges are shared between two faces in the mesh. """ if self._internal_edges is None: self._internal_edges = self._get_edge_type(1) return self._internal_edges @property def non_manifold_edges(self): """"Tuple of all non-manifold edges in this mesh as LineSegment3D objects. Non-manifold edges are shared between three or more faces. """ if self._non_manifold_edges is None: if self._edges is None: self.edges nm_edges = [] for i, type in enumerate(self._edge_types): if type > 1: nm_edges.append(self._edges[i]) self._non_manifold_edges = tuple(nm_edges) return self._non_manifold_edges
[docs] def remove_vertices(self, pattern): """Get a version of this mesh where vertices are removed according to a pattern. Args: pattern: A list of boolean values denoting whether a vertex should remain in the mesh (True) or be removed from the mesh (False). The length of this list must match the number of this mesh's vertices. Returns: A tuple with two elements. - new_mesh: A mesh where the vertices have been removed according to the input pattern. - face_pattern: A list of boolean values that corresponds to the original mesh faces noting whether the face is in the new mesh (True) or has been removed from the new mesh (False). """ _new_verts, _new_faces, _new_colors, _new_f_cent, _new_f_area, face_pattern = \ self._remove_vertices(pattern) new_mesh = Mesh3D(_new_verts, _new_faces, _new_colors) new_mesh._face_centroids = _new_f_cent new_mesh._face_areas = _new_f_area return new_mesh, face_pattern
[docs] def remove_faces(self, pattern): """Get a version of this mesh where faces are removed according to a pattern. Args: pattern: A list of boolean values denoting whether a face should remain in the mesh (True) or be removed from the mesh (False). The length of this list must match the number of this mesh's faces. Returns: A tuple with two elements. - new_mesh: A mesh where the faces have been removed according to the input pattern. - vertex_pattern: A list of boolean values that corresponds to the original mesh vertices noting whether the vertex is in the new mesh (True) or has been removed from the new mesh (False). """ vertex_pattern = self._vertex_pattern_from_remove_faces(pattern) _new_verts, _new_faces, _new_colors, _new_f_cent, _new_f_area, face_pattern = \ self._remove_vertices(vertex_pattern, pattern) new_mesh = Mesh3D(_new_verts, _new_faces, _new_colors) new_mesh._face_centroids = _new_f_cent new_mesh._face_areas = _new_f_area return new_mesh, vertex_pattern
[docs] def remove_faces_only(self, pattern): """Get a version of this mesh where faces are removed and vertices are unaltered. This is faster than the Mesh3D.remove_faces method but will likely result a lower-quality mesh where several vertices exist in the mesh that are not referenced by any face. This may be preferred if pure speed of removing faces is a priority over smallest size of the mesh in memory. Args: pattern: A list of boolean values denoting whether a face should remain in the mesh (True) or be removed from the mesh (False). The length of this list must match the number of this mesh's faces. Returns: new_mesh -- A mesh where the faces have been removed according to the input pattern. """ _new_faces, _new_colors, _new_f_cent, _new_f_area = \ self._remove_faces_only(pattern) new_mesh = Mesh3D(self.vertices, _new_faces, _new_colors) new_mesh._face_centroids = _new_f_cent new_mesh._face_areas = _new_f_area return new_mesh
[docs] def rotate(self, axis, angle, origin): """Rotate a mesh by a certain angle around an axis and origin. Right hand rule applies: If axis has a positive orientation, rotation will be clockwise. If axis has a negative orientation, rotation will be counterclockwise. Args: axis: A Vector3D axis representing the axis of rotation. angle: An angle for rotation in radians. origin: A Point3D for the origin around which the point will be rotated. """ _verts = tuple(pt.rotate(axis, angle, origin) for pt in self.vertices) return self._mesh_transform(_verts)
[docs] def rotate_xy(self, angle, origin): """Get a mesh rotated counterclockwise in the XY plane by a certain angle. Args: angle: An angle for rotation in radians. origin: A Point3D for the origin around which the point will be rotated. """ _verts = tuple(pt.rotate_xy(angle, origin) for pt in self.vertices) return self._mesh_transform(_verts)
[docs] def scale(self, factor, origin=None): """Scale a mesh by a factor from an origin point. Args: factor: A number representing how much the mesh should be scaled. origin: A Point representing the origin from which to scale. If None, it will be scaled from the World origin (0, 0, 0). """ if origin is None: _verts = tuple( Point3D(pt.x * factor, pt.y * factor, pt.z * factor) for pt in self.vertices) else: _verts = tuple(pt.scale(factor, origin) for pt in self.vertices) return self._mesh_scale(_verts, factor)
[docs] def offset_mesh(self, distance): """Get a Mesh3D that has been offset from this one by a certain difference. Effectively, this method moves each mesh vertex along the vertex normal by the offset distance. Args: distance: A number for the distance to offset the mesh. """ new_verts = tuple(pt.move(norm * distance) for pt, norm in zip(self.vertices, self.vertex_normals)) return Mesh3D(new_verts, self.faces, self._colors)
[docs] def height_field_mesh(self, values, domain): """Get a Mesh3D that has faces or vertices offset according to a list of values. Args: values: A list of values that has a length matching the number of faces or vertices in this mesh. domain: A tuple or list of two numbers for the upper and lower distances that the mesh vertices should be offset. (ie. (0, 3)) """ assert isinstance(domain, (tuple, list)), 'Expected tuple for domain. '\ 'Got {}.'.format(type(domain)) assert len(domain) == 2, 'Expected domain to be in the format (min, max). ' \ 'Got {}.'.format(domain) if len(values) == len(self.faces): remap_vals = Mesh3D._remap_values(values, domain[0], domain[-1]) vert_remap_vals = [] for vf in self.vertex_connected_faces: v = 0 for j in vf: v += remap_vals[j] try: v /= len(vf) # average the vertex value over its connected faces except ZeroDivisionError: pass # lone vertex without any faces vert_remap_vals.append(v) new_verts = tuple(pt.move(norm * dist) for pt, norm, dist in zip(self.vertices, self.vertex_normals, vert_remap_vals)) elif len(values) == len(self.vertices): remap_vals = Mesh3D._remap_values(values, domain[0], domain[-1]) new_verts = tuple(pt.move(norm * dist) for pt, norm, dist in zip(self.vertices, self.vertex_normals, remap_vals)) else: raise ValueError( 'Input values for height_field_mesh ({}) does not match the number of' ' mesh faces ({}) nor the number of vertices ({}).' .format(len(values), len(self.faces), len(self.vertices))) return Mesh3D(new_verts, self.faces, self._colors)
[docs] def to_dict(self): """Get Mesh3D as a dictionary.""" base = {'type': 'Mesh3D', 'vertices': [pt.to_array() for pt in self.vertices], 'faces': self.faces} if self.colors is not None: base['colors'] = [col.to_dict() for col in self.colors] return base
[docs] def to_stl(self, folder, name=None): """Write the Mesh3D to an ASCII STL file. Args: folder: A text string for the directory where the STL will be written. name: A text string for the name of the STL file. """ from ladybug_geometry.interop.stl import STL # avoid circular import stl_obj = STL(self.face_vertices, self.face_normals) return stl_obj.to_file(folder, name)
[docs] def to_obj(self, folder, name, include_colors=True, include_normals=False, triangulate_quads=False, include_mtl=False): """Write the Mesh3D to an ASCII OBJ file. Args: folder: A text string for the directory where the OBJ will be written. name: A text string for the name of the OBJ file. include_colors: Boolean to note whether the Mesh3D colors should be included in the OBJ file. (Default: True). include_normals: Boolean to note whether the vertex normals should be included in the OBJ file. (Default: False). triangulate_quads: Boolean to note whether quad faces should be triangulated upon export to OBJ. This may be needed for certain software platforms that require the mesh to be composed entirely of triangles (eg. Radiance). (Default: False). include_mtl: Boolean to note whether an .mtl file should be automatically generated next to the .obj file in the output folder. All materials in the mtl file will be diffuse white, with the assumption that these will be customized later. (Default: False). """ from ladybug_geometry.interop.obj import OBJ # avoid circular import transl_obj = OBJ.from_mesh3d(self, include_colors, include_normals) return transl_obj.to_file(folder, name, triangulate_quads, include_mtl)
[docs] @staticmethod def join_meshes(meshes): """Join an array of Mesh3Ds into a single Mesh3D. Args: meshes: An array of meshes to be joined into one. Returns: A single Mesh3D object derived from the input meshes. """ # set up empty lists of objects to be filled verts = [] faces = [] colors = [] # loop through all of the meshes and get new faces total_v_i = 0 for mesh in meshes: verts.extend(mesh._vertices) for fc in mesh._faces: faces.append(tuple(v_i + total_v_i for v_i in fc)) total_v_i += len(mesh._vertices) if mesh._colors: colors.extend(mesh._colors) # create the new mesh if len(colors) != 0: new_mesh = Mesh3D(verts, faces, colors) else: new_mesh = Mesh3D(verts, faces) return new_mesh
def _calculate_min_max(self): """Calculate maximum and minimum Point3D for this object.""" min_pt = [self.vertices[0].x, self.vertices[0].y, self.vertices[0].z] max_pt = [self.vertices[0].x, self.vertices[0].y, self.vertices[0].z] for v in self.vertices[1:]: if v.x < min_pt[0]: min_pt[0] = v.x elif v.x > max_pt[0]: max_pt[0] = v.x if v.y < min_pt[1]: min_pt[1] = v.y elif v.y > max_pt[1]: max_pt[1] = v.y if v.z < min_pt[2]: min_pt[2] = v.z elif v.z > max_pt[2]: max_pt[2] = v.z self._min = Point3D(min_pt[0], min_pt[1], min_pt[2]) self._max = Point3D(max_pt[0], max_pt[1], max_pt[2]) def _calculate_face_areas_and_normals(self): """Calculate face areas and normals from vertices.""" _f_norm = [] _f_area = [] for face in self.faces: pts = tuple(self._vertices[i] for i in face) if len(face) == 3: n, a = self._calculate_normal_and_area_for_triangle(pts) else: n, a = self._calculate_normal_and_area_for_quad(pts) _f_norm.append(n) _f_area.append(a) self._face_normals = tuple(_f_norm) self._face_areas = tuple(_f_area) def _calculate_vertex_normals(self): """Calculate vertex normals. This is accomplished by normalizing the average of the surface normals of the faces that contain that vertex. This particular method weights this average by the area of each face, though this does not always need to be the case as noted here: https://en.wikipedia.org/wiki/Vertex_normal """ # find shared faces for each vertices mapper = [[] for v in xrange(len(self.vertices))] for c, face in enumerate(self.faces): for i in face: mapper[i].append(c) # now calculate vertex normal based on face normals vn = [] fn = self.face_normals fa = self.face_areas for fi in mapper: x, y, z = 0, 0, 0 for n, a in zip(tuple(fn[i] for i in fi), tuple(fa[i] for i in fi)): x += n.x * a y += n.y * a z += n.z * a _v = Vector3D(x, y, z) vn.append(_v.normalize()) self._vertex_normals = tuple(vn) def _get_edge_type(self, edge_type): """Get all of the edges of a certain type in this mesh.""" if self._edges is None: self.edges sel_edges = [] for i, type in enumerate(self._edge_types): if type == edge_type: sel_edges.append(self._edges[i]) return tuple(sel_edges) def _tri_face_centroid(self, face): """Compute the centroid of a triangular face.""" return Mesh3D._tri_centroid(tuple(self._vertices[i] for i in face)) def _quad_face_centroid(self, face): """Compute the centroid of a quadrilateral face.""" return Mesh3D._quad_centroid(tuple(self._vertices[i] for i in face)) def _mesh_transform(self, verts): """Transform mesh in a way that transfers properties and avoids extra checks.""" _new_mesh = Mesh3D(verts, self.faces) self._transfer_properties(_new_mesh) return _new_mesh def _mesh_transform_move(self, verts): """Move mesh in a way that transfers properties and avoids extra checks.""" _new_mesh = Mesh3D(verts, self.faces) self._transfer_properties(_new_mesh) _new_mesh._face_normals = self._face_normals _new_mesh._vertex_normals = self._vertex_normals return _new_mesh def _mesh_scale(self, verts, factor): """Scale mesh in a way that transfers properties and avoids extra checks.""" _new_mesh = Mesh3D(verts, self.faces) self._transfer_properties_scale(_new_mesh, factor) _new_mesh._face_normals = self._face_normals _new_mesh._vertex_normals = self._vertex_normals return _new_mesh def _check_vertices_input(self, vertices): """Check the input vertices.""" if not isinstance(vertices, tuple): vertices = tuple(vertices) for vert in vertices: assert isinstance(vert, Point3D), \ 'Expected Point3D for {} vertex. Got {}.'.format( self.__class__.__name__, type(vert)) return vertices @staticmethod def _calculate_normal_and_area_for_triangle(pts): """Calculate normal and area for three points. Returns: n = Normalized normal vector for the triangle. a = Area of the triangle. """ v1 = pts[1] - pts[0] v2 = pts[2] - pts[0] n = v1.cross(v2) a = n.magnitude / 2 return n.normalize(), a @staticmethod def _calculate_normal_and_area_for_quad(pts): """Calculate normal and area for four points. This method uses an area-weighted average of the two triangle normals that compose the quad face. Returns: n = Normalized normal vector for the quad. a = Area of the quad. """ # TODO: Get this method to work for concave quads. # This method is only reliable when quads are convex since we assume # either diagonal of the quad splits it into two triangles. # It seems Rhino never produces concave quads when it automatically meshes # but we will likely want to add support for this if meshes have other origins v1 = pts[1] - pts[0] v2 = pts[2] - pts[0] n1 = v1.cross(v2) v3 = pts[3] - pts[2] v4 = pts[1] - pts[2] n2 = v3.cross(v4) a = (n1.magnitude + n2.magnitude) / 2 n = Vector3D((n1.x + n2.x) / 2, (n1.y + n2.y) / 2, (n1.z + n2.z) / 2) return n.normalize(), a @staticmethod def _face_center(verts): """Get the center of a list of Point3D vertices.""" _cent_x = sum([v.x for v in verts]) _cent_y = sum([v.y for v in verts]) _cent_z = sum([v.z for v in verts]) v_count = len(verts) return Point3D(_cent_x / v_count, _cent_y / v_count, _cent_z / v_count) @staticmethod def _tri_centroid(verts): """Get the centroid of a list of 3 Point3D vertices.""" _cent_x = sum([v.x for v in verts]) _cent_y = sum([v.y for v in verts]) _cent_z = sum([v.z for v in verts]) return Point3D(_cent_x / 3, _cent_y / 3, _cent_z / 3) @staticmethod def _quad_centroid(verts): """Get the centroid of a list of 4 Point3D vertices.""" # TODO: Get this method to recognize concave quads. # This method is only reliable when quads are convex since we assume # either diagonal of the quad splits it into two triangles. # It seems Rhino never produces concave quads when it automatically meshes _tri_verts = ((verts[0], verts[1], verts[2]), (verts[2], verts[3], verts[0])) _tri_c = [Mesh3D._tri_centroid(tri) for tri in _tri_verts] _tri_a = [Mesh3D._get_tri_area(tri) for tri in _tri_verts] _tot_a = sum(_tri_a) try: _cent_x = (_tri_c[0].x * _tri_a[0] + _tri_c[1].x * _tri_a[1]) / _tot_a _cent_y = (_tri_c[0].y * _tri_a[0] + _tri_c[1].y * _tri_a[1]) / _tot_a _cent_z = (_tri_c[0].z * _tri_a[0] + _tri_c[1].z * _tri_a[1]) / _tot_a except ZeroDivisionError: _cent_x = sum([v.x for v in verts]) / 4 _cent_y = sum([v.y for v in verts]) / 4 _cent_z = sum([v.z for v in verts]) / 4 return Point3D(_cent_x, _cent_y, _cent_z) @staticmethod def _get_tri_area(pts): """Get the area of a triangle from three Point3D objects.""" v1 = pts[1] - pts[0] v2 = pts[2] - pts[0] n1 = v1.cross(v2) return n1.magnitude / 2 @staticmethod def _remap_values(values, tmin, tmax): """Remap a set of values to offset distances within a domain.""" omin = min(values) omax = max(values) odiff = omax - omin tdiff = tmax - tmin if odiff == 0: return [tmin] * len(values) else: return [(v - omin) * tdiff / odiff + tmin for v in values] def __copy__(self): _new_mesh = Mesh3D(self.vertices, self.faces) self._transfer_properties(_new_mesh) _new_mesh._face_centroids = self._face_centroids _new_mesh._face_normals = self._face_normals _new_mesh._vertex_normals = self._vertex_normals return _new_mesh def __key(self): """A tuple based on the object properties, useful for hashing.""" return tuple(hash(pt) for pt in self._vertices) + \ tuple(hash(face) for face in self._faces) def __hash__(self): return hash(self.__key()) def __eq__(self, other): return isinstance(other, Mesh3D) and self.__key() == other.__key() def __repr__(self): return 'Mesh3D ({} faces) ({} vertices)'.format( len(self.faces), len(self))