diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ee95cf723f2..ed2622d4a1d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +* Merged `compas.datastructures.Halfedge` into `compas.datastructures.Mesh`. + ### Removed +* Removed `compas.datastructures.Halfedge`. + ## [2.0.0-beta.2] 2024-01-12 ### Added diff --git a/src/compas/datastructures/__init__.py b/src/compas/datastructures/__init__.py index 6afb1650bb7a..383758f40ded 100644 --- a/src/compas/datastructures/__init__.py +++ b/src/compas/datastructures/__init__.py @@ -57,7 +57,6 @@ from .graph.graph import Graph from .network.network import Network -from .halfedge.halfedge import HalfEdge from .mesh.mesh import Mesh from .halfface.halfface import HalfFace from .volmesh.volmesh import VolMesh @@ -72,7 +71,6 @@ "Graph", "CellNetwork", "Network", - "HalfEdge", "Mesh", "HalfFace", "VolMesh", diff --git a/src/compas/datastructures/mesh/mesh.py b/src/compas/datastructures/mesh/mesh.py index 9d41369231da..08f39f18546e 100644 --- a/src/compas/datastructures/mesh/mesh.py +++ b/src/compas/datastructures/mesh/mesh.py @@ -4,6 +4,7 @@ from math import pi from itertools import product +from random import sample import compas @@ -55,8 +56,12 @@ from compas.topology import breadth_first_traverse from compas.topology import face_adjacency +from compas.topology import connected_components -from compas.datastructures import HalfEdge +from compas.datastructures.datastructure import Datastructure +from compas.datastructures.attributes import VertexAttributeView +from compas.datastructures.attributes import EdgeAttributeView +from compas.datastructures.attributes import FaceAttributeView from .operations.collapse import mesh_collapse_edge from .operations.merge import mesh_merge_faces @@ -74,8 +79,8 @@ from .smoothing import mesh_smooth_area -class Mesh(HalfEdge): - """Geometric implementation of a half edge data structure for polygon meshses. +class Mesh(Datastructure): + """Data structure for representing open or closed surface meshes. Parameters ---------- @@ -88,6 +93,23 @@ class Mesh(HalfEdge): **kwargs : dict, optional Additional attributes to add to the mesh object. + Attributes + ---------- + attributes : dict[str, Any] + General attributes of the data structure that are included in the data representation and serialization. + default_vertex_attributes : dict[str, Any] + Dictionary containing default values for the attributes of vertices. + It is recommended to add a default to this dictionary using :meth:`update_default_vertex_attributes` + for every vertex attribute used in the data structure. + default_edge_attributes : dict[str, Any] + Dictionary containing default values for the attributes of edges. + It is recommended to add a default to this dictionary using :meth:`update_default_edge_attributes` + for every edge attribute used in the data structure. + default_face_attributes : dict[str, Any] + Dictionary contnaining default values for the attributes of faces. + It is recommended to add a default to this dictionary using :meth:`update_default_face_attributes` + for every face attribute used in the data structure. + Examples -------- >>> from compas.datastructures import Mesh @@ -100,6 +122,54 @@ class Mesh(HalfEdge): """ + DATASCHEMA = { + "type": "object", + "properties": { + "dva": {"type": "object"}, + "dea": {"type": "object"}, + "dfa": {"type": "object"}, + "vertex": { + "type": "object", + "patternProperties": {"^[0-9]+$": {"type": "object"}}, + "additionalProperties": False, + }, + "face": { + "type": "object", + "patternProperties": { + "^[0-9]+$": { + "type": "array", + "items": {"type": "integer", "minimum": 0}, + "minItems": 3, + } + }, + "additionalProperties": False, + }, + "facedata": { + "type": "object", + "patternProperties": {"^[0-9]+$": {"type": "object"}}, + "additionalProperties": False, + }, + "edgedata": { + "type": "object", + "patternProperties": {"^\\([0-9]+, [0-9]+\\)$": {"type": "object"}}, + "additionalProperties": False, + }, + "max_vertex": {"type": "integer", "minimum": -1}, + "max_face": {"type": "integer", "minimum": -1}, + }, + "required": [ + "dva", + "dea", + "dfa", + "vertex", + "face", + "facedata", + "edgedata", + "max_vertex", + "max_face", + ], + } + collapse_edge = mesh_collapse_edge merge_faces = mesh_merge_faces split_edge = mesh_split_edge @@ -119,28 +189,83 @@ class Mesh(HalfEdge): def __init__( self, default_vertex_attributes=None, default_edge_attributes=None, default_face_attributes=None, **kwargs ): - _default_vertex_attributes = {"x": 0.0, "y": 0.0, "z": 0.0} - _default_edge_attributes = {} - _default_face_attributes = {} + super(Mesh, self).__init__(**kwargs) + self._max_vertex = -1 + self._max_face = -1 + self.vertex = {} + self.halfedge = {} + self.face = {} + self.facedata = {} + self.edgedata = {} + self.default_vertex_attributes = {"x": 0.0, "y": 0.0, "z": 0.0} + self.default_edge_attributes = {} + self.default_face_attributes = {} if default_vertex_attributes: - _default_vertex_attributes.update(default_vertex_attributes) + self.default_vertex_attributes.update(default_vertex_attributes) if default_edge_attributes: - _default_edge_attributes.update(default_edge_attributes) + self.default_edge_attributes.update(default_edge_attributes) if default_face_attributes: - _default_face_attributes.update(default_face_attributes) - super(Mesh, self).__init__( - default_vertex_attributes=_default_vertex_attributes, - default_edge_attributes=_default_edge_attributes, - default_face_attributes=_default_face_attributes, - **kwargs - ) + self.default_face_attributes.update(default_face_attributes) def __str__(self): tpl = "" return tpl.format(self.number_of_vertices(), self.number_of_faces(), self.number_of_edges()) # -------------------------------------------------------------------------- - # from/to + # Data + # -------------------------------------------------------------------------- + + @property + def data(self): + return { + "dva": self.default_vertex_attributes, + "dea": self.default_edge_attributes, + "dfa": self.default_face_attributes, + "vertex": {str(vertex): attr for vertex, attr in self.vertex.items()}, + "face": {str(face): vertices for face, vertices in self.face.items()}, + "facedata": {str(face): attr for face, attr in self.facedata.items()}, + "edgedata": self.edgedata, + "max_vertex": self._max_vertex, + "max_face": self._max_face, + } + + @classmethod + def from_data(cls, data): + dva = data.get("dva") or {} + dfa = data.get("dfa") or {} + dea = data.get("dea") or {} + + halfedge = cls(default_vertex_attributes=dva, default_face_attributes=dfa, default_edge_attributes=dea) + + vertex = data.get("vertex") or {} + face = data.get("face") or {} + facedata = data.get("facedata") or {} + edgedata = data.get("edgedata") or {} + + for key, attr in iter(vertex.items()): + halfedge.add_vertex(key=key, attr_dict=attr) + + for fkey, vertices in iter(face.items()): + attr = facedata.get(fkey) or {} + halfedge.add_face(vertices, fkey=fkey, attr_dict=attr) + + halfedge.edgedata = edgedata + + halfedge._max_vertex = data.get("max_vertex", halfedge._max_vertex) + halfedge._max_face = data.get("max_face", halfedge._max_face) + + return halfedge + + # -------------------------------------------------------------------------- + # Properties + # -------------------------------------------------------------------------- + + @property + def adjacency(self): + return self.halfedge + + # -------------------------------------------------------------------------- + # Constructors # -------------------------------------------------------------------------- @classmethod @@ -185,32 +310,6 @@ def from_obj(cls, filepath, precision=None): # type: (...) -> Mesh return cls.from_lines(lines) return cls() - def to_obj(self, filepath, precision=None, unweld=False, **kwargs): - """Write the mesh to an OBJ file. - - Parameters - ---------- - filepath : str - Full path of the file. - precision: str, optional - The precision of the geometric map that is used to connect the lines. - unweld : bool, optional - If True, all faces have their own unique vertices. - If False (default), vertices are shared between faces if this is also the case in the mesh. - - Returns - ------- - None - - Warnings - -------- - This function only writes geometric data about the vertices and - the faces to the file. - - """ - obj = OBJ(filepath, precision=precision) - obj.write(self, unweld=unweld, **kwargs) - @classmethod def from_ply(cls, filepath, precision=None): # type: (...) -> Mesh """Construct a mesh object from the data described in a PLY file. @@ -232,22 +331,6 @@ def from_ply(cls, filepath, precision=None): # type: (...) -> Mesh mesh = cls.from_vertices_and_faces(vertices, faces) return mesh - def to_ply(self, filepath, **kwargs): - """Write a mesh object to a PLY file. - - Parameters - ---------- - filepath : str - The path to the file. - - Returns - ------- - None - - """ - ply = PLY(filepath) - ply.write(self, **kwargs) - @classmethod def from_stl(cls, filepath, precision=None): # type: (...) -> Mesh """Construct a mesh object from the data described in a STL file. @@ -271,34 +354,6 @@ def from_stl(cls, filepath, precision=None): # type: (...) -> Mesh mesh = cls.from_vertices_and_faces(vertices, faces) return mesh - def to_stl(self, filepath, precision=None, binary=False, **kwargs): - """Write a mesh to an STL file. - - Parameters - ---------- - filepath : str - The path to the file. - precision : str, optional - Rounding precision for the vertex coordinates. - Defaults to the value of :attr:`compas.PRECISION`. - binary : bool, optional - If True, the file will be written in binary format. - ASCII otherwise. - - Returns - ------- - None - - Notes - ----- - STL files only support triangle faces. - It is the user's responsibility to convert all faces of a mesh to triangles. - For example, with :func:`compas.datastructures.mesh_quads_to_triangles`. - - """ - stl = STL(filepath, precision) - stl.write(self, binary=binary, **kwargs) - @classmethod def from_off(cls, filepath): # type: (...) -> Mesh """Construct a mesh object from the data described in a OFF file. @@ -320,22 +375,6 @@ def from_off(cls, filepath): # type: (...) -> Mesh mesh = cls.from_vertices_and_faces(vertices, faces) return mesh - def to_off(self, filepath, **kwargs): - """Write a mesh object to an OFF file. - - Parameters - ---------- - filepath : str - The path to the file. - - Returns - ------- - None - - """ - off = OFF(filepath) - off.write(self, **kwargs) - @classmethod def from_lines(cls, lines, delete_boundary_face=False, precision=None): # type: (...) -> Mesh """Construct a mesh object from a list of lines described by start and end point coordinates. @@ -369,17 +408,6 @@ def from_lines(cls, lines, delete_boundary_face=False, precision=None): # type: mesh.cull_vertices() return mesh - def to_lines(self): - """Return the lines of the mesh as pairs of start and end point coordinates. - - Returns - ------- - list[tuple[list[float], list[float]]] - A list of lines each defined by a pair of point coordinates. - - """ - return [self.edge_coordinates(edge) for edge in self.edges()] - @classmethod def from_polylines(cls, boundary_polylines, other_polylines): # type: (...) -> Mesh """Construct mesh from polylines. @@ -450,17 +478,6 @@ def from_polylines(cls, boundary_polylines, other_polylines): # type: (...) -> return cls.from_vertices_and_faces(vertices, faces) - def to_polylines(self): - """Convert the mesh to a collection of polylines. - - Returns - ------- - list[list[list[float]]] - A list of polylines which are each defined as a list of points. - - """ - raise NotImplementedError - @classmethod def from_vertices_and_faces(cls, vertices, faces): # type: (...) -> Mesh """Construct a mesh object from a list of vertices and faces. @@ -498,51 +515,6 @@ def from_vertices_and_faces(cls, vertices, faces): # type: (...) -> Mesh return mesh - def to_vertices_and_faces(self, triangulated=False): - """Return the vertices and faces of a mesh. - - Parameters - ---------- - triangulated: bool, optional - If True, triangulate the faces. - - Returns - ------- - list[list[float]] - The vertices as a list of XYZ coordinates. - list[list[int]] - The faces as a list of lists of vertex indices. - - """ - vertex_index = self.vertex_index() - vertices = [self.vertex_coordinates(vertex) for vertex in self.vertices()] - - if not triangulated: - faces = [[vertex_index[vertex] for vertex in self.face_vertices(face)] for face in self.faces()] - return vertices, faces - - faces = [] - - for fkey in self.faces(): - face_vertices = self.face_vertices(fkey) - - if len(face_vertices) == 3: - a, b, c = face_vertices - faces.append([vertex_index[a], vertex_index[b], vertex_index[c]]) - elif len(face_vertices) == 4: - a, b, c, d = face_vertices - faces.append([vertex_index[a], vertex_index[b], vertex_index[c]]) - faces.append([vertex_index[a], vertex_index[c], vertex_index[d]]) - else: - centroid = centroid_polygon([vertices[vertex_index[vertex]] for vertex in face_vertices]) - c = len(vertices) - vertices.append(centroid) - - for a, b in pairwise(face_vertices + face_vertices[:1]): - faces.append([vertex_index[a], vertex_index[b], c]) - - return vertices, faces - @classmethod def from_polyhedron(cls, f): # type: (...) -> Mesh """Construct a mesh from a platonic solid. @@ -604,17 +576,6 @@ def from_points(cls, points): # type: (...) -> Mesh vertices, faces = delaunay_triangulation(points) return cls.from_vertices_and_faces(vertices, faces) - def to_points(self): - """Convert the mesh to a collection of points. - - Returns - ------- - list[list[float]] - The points representing the vertices of the mesh. - - """ - raise NotImplementedError - @classmethod def from_polygons(cls, polygons, precision=None): # type: (...) -> Mesh """Construct a mesh from a series of polygons. @@ -648,17 +609,6 @@ def from_polygons(cls, polygons, precision=None): # type: (...) -> Mesh faces[:] = [[gkey_index[gkey] for gkey in face] for face in faces] return cls.from_vertices_and_faces(vertices, faces) - def to_polygons(self): - """Convert the mesh to a collection of polygons. - - Returns - ------- - list[list[list[float]]] - A list of polygons defined each as a list of points. - - """ - return [self.face_coordinates(fkey) for fkey in self.faces()] - @classmethod def from_meshgrid(cls, dx, nx, dy=None, ny=None): # type: (...) -> Mesh """Create a mesh from faces and vertices on a regular grid. @@ -700,178 +650,304 @@ def from_meshgrid(cls, dx, nx, dy=None, ny=None): # type: (...) -> Mesh return cls.from_vertices_and_faces(vertices, faces) # -------------------------------------------------------------------------- - # helpers + # Conversions # -------------------------------------------------------------------------- - def vertex_gkey(self, precision=None): - """Returns a dictionary that maps vertex dictionary keys to the corresponding - *geometric key* up to a certain precision. - - Parameters - ---------- - precision : int, optional - Precision for converting numbers to strings. - Default is :attr:`TOL.precision`. + def to_lines(self): + """Return the lines of the mesh as pairs of start and end point coordinates. Returns ------- - dict[int, str] - A dictionary of key-geometric key pairs. + list[tuple[list[float], list[float]]] + A list of lines each defined by a pair of point coordinates. """ - gkey = TOL.geometric_key - xyz = self.vertex_coordinates - return {key: gkey(xyz(key), precision) for key in self.vertices()} - - def gkey_vertex(self, precision=None): - """Returns a dictionary that maps *geometric keys* of a certain precision - to the keys of the corresponding vertices. + return [self.edge_coordinates(edge) for edge in self.edges()] - Parameters - ---------- - precision : int, optional - Precision for converting numbers to strings. - Default is :attr:`TOL.precision`. + def to_polylines(self): + """Convert the mesh to a collection of polylines. Returns ------- - dict[str, int] - A dictionary of geometric key-key pairs. + list[list[list[float]]] + A list of polylines which are each defined as a list of points. """ - gkey = TOL.geometric_key - xyz = self.vertex_coordinates - return {gkey(xyz(key), precision): key for key in self.vertices()} - - # -------------------------------------------------------------------------- - # builders - # -------------------------------------------------------------------------- + raise NotImplementedError - # rename this to "add" - # and add an alias - def join(self, other, weld=False, precision=None): - """Add the vertices and faces of another mesh to the current mesh. + def to_vertices_and_faces(self, triangulated=False): + """Return the vertices and faces of a mesh. Parameters ---------- - other : :class:`compas.datastructures.Mesh` - The other mesh. - weld : bool, optional - If True, weld close vertices after joining. - Default is False. - precision : int, optional - The precision used for welding. - Default is :attr:`TOL.precision`. + triangulated: bool, optional + If True, triangulate the faces. Returns ------- - None - The mesh is modified in place. - - Examples + list[list[float]] + The vertices as a list of XYZ coordinates. + list[list[int]] + The faces as a list of lists of vertex indices. + + """ + vertex_index = self.vertex_index() + vertices = [self.vertex_coordinates(vertex) for vertex in self.vertices()] + + if not triangulated: + faces = [[vertex_index[vertex] for vertex in self.face_vertices(face)] for face in self.faces()] + return vertices, faces + + faces = [] + + for fkey in self.faces(): + face_vertices = self.face_vertices(fkey) + + if len(face_vertices) == 3: + a, b, c = face_vertices + faces.append([vertex_index[a], vertex_index[b], vertex_index[c]]) + elif len(face_vertices) == 4: + a, b, c, d = face_vertices + faces.append([vertex_index[a], vertex_index[b], vertex_index[c]]) + faces.append([vertex_index[a], vertex_index[c], vertex_index[d]]) + else: + centroid = centroid_polygon([vertices[vertex_index[vertex]] for vertex in face_vertices]) + c = len(vertices) + vertices.append(centroid) + + for a, b in pairwise(face_vertices + face_vertices[:1]): + faces.append([vertex_index[a], vertex_index[b], c]) + + return vertices, faces + + def to_points(self): + """Convert the mesh to a collection of points. + + Returns + ------- + list[list[float]] + The points representing the vertices of the mesh. + + """ + raise NotImplementedError + + def to_polygons(self): + """Convert the mesh to a collection of polygons. + + Returns + ------- + list[list[list[float]]] + A list of polygons defined each as a list of points. + + """ + return [self.face_coordinates(fkey) for fkey in self.faces()] + + def to_obj(self, filepath, precision=None, unweld=False, **kwargs): + """Write the mesh to an OBJ file. + + Parameters + ---------- + filepath : str + Full path of the file. + precision: str, optional + The precision of the geometric map that is used to connect the lines. + unweld : bool, optional + If True, all faces have their own unique vertices. + If False (default), vertices are shared between faces if this is also the case in the mesh. + + Returns + ------- + None + + Warnings -------- - >>> from compas.geometry import Box - >>> from compas.geometry import Translation - >>> from compas.datastructures import Mesh - >>> a = Box.from_width_height_depth(1, 1, 1) - >>> b = Box.from_width_height_depth(1, 1, 1) - >>> T = Translation.from_vector([2, 0, 0]) - >>> b.transform(T) - >>> a = Mesh.from_shape(a) - >>> b = Mesh.from_shape(b) - >>> a.number_of_vertices() - 8 - >>> a.number_of_faces() - 6 - >>> b.number_of_vertices() - 8 - >>> b.number_of_faces() - 6 - >>> a.join(b) - >>> a.number_of_vertices() - 16 - >>> a.number_of_faces() - 12 + This function only writes geometric data about the vertices and + the faces to the file. """ - self.default_vertex_attributes.update(other.default_vertex_attributes) - self.default_edge_attributes.update(other.default_edge_attributes) - self.default_face_attributes.update(other.default_face_attributes) + obj = OBJ(filepath, precision=precision) + obj.write(self, unweld=unweld, **kwargs) - vertex_old_new = {} + def to_ply(self, filepath, **kwargs): + """Write a mesh object to a PLY file. - for vertex, attr in other.vertices(True): - key = self.add_vertex(attr_dict=attr) - vertex_old_new[vertex] = key + Parameters + ---------- + filepath : str + The path to the file. - for face, attr in other.faces(True): - vertices = [vertex_old_new[key] for key in other.face_vertices(face)] - self.add_face(vertices, attr_dict=attr) + Returns + ------- + None - if weld: - self.weld(precision=precision) + """ + ply = PLY(filepath) + ply.write(self, **kwargs) - # -------------------------------------------------------------------------- - # modifiers - # -------------------------------------------------------------------------- + def to_stl(self, filepath, precision=None, binary=False, **kwargs): + """Write a mesh to an STL file. - def insert_vertex(self, fkey, key=None, xyz=None, return_fkeys=False): - """Insert a vertex in the specified face. + Parameters + ---------- + filepath : str + The path to the file. + precision : str, optional + Rounding precision for the vertex coordinates. + Defaults to the value of :attr:`compas.PRECISION`. + binary : bool, optional + If True, the file will be written in binary format. + ASCII otherwise. + + Returns + ------- + None + + Notes + ----- + STL files only support triangle faces. + It is the user's responsibility to convert all faces of a mesh to triangles. + For example, with :func:`compas.datastructures.mesh_quads_to_triangles`. + + """ + stl = STL(filepath, precision) + stl.write(self, binary=binary, **kwargs) + + def to_off(self, filepath, **kwargs): + """Write a mesh object to an OFF file. Parameters ---------- - fkey : int - The key of the face in which the vertex should be inserted. - key : int, optional - The key to be used to identify the inserted vertex. - xyz : list[float], optional - Specific XYZ coordinates for the inserted vertex. - return_fkeys : bool, optional - If True, return the identifiers of the newly created faces in addition to the identifier of the inserted vertex. + filepath : str + The path to the file. Returns ------- - int | tuple[int, list[int]] - If `return_fkeys` is False, the key of the inserted vertex. - If `return_fkeys` is True, the key of the newly created vertex and a list with the newly created faces. + None """ - fkeys = [] - if not xyz: - x, y, z = self.face_center(fkey) - else: - x, y, z = xyz - w = self.add_vertex(key=key, x=x, y=y, z=z) - for u, v in self.face_halfedges(fkey): - fkeys.append(self.add_face([u, v, w])) - self.delete_face(fkey) - if return_fkeys: - return w, fkeys - return w + off = OFF(filepath) + off.write(self, **kwargs) # -------------------------------------------------------------------------- - # cleanup + # Helpers # -------------------------------------------------------------------------- - def weld(self, precision=None): - """Weld vertices that are closer than a given precision. + def clear(self): + """Clear all the mesh data. + + Returns + ------- + None + + """ + del self.vertex + del self.edgedata + del self.halfedge + del self.face + del self.facedata + self.vertex = {} + self.edgedata = {} + self.halfedge = {} + self.face = {} + self.facedata = {} + self._max_vertex = -1 + self._max_face = -1 + + def vertex_sample(self, size=1): + """A random sample of the vertices. Parameters ---------- - precision : int, optional - The precision of the geometric map that is used to connect the lines. - Defaults to the value of :attr:`compas.PRECISION`. + size : int, optional + The number of vertices in the random sample. Returns ------- - None - The mesh is modified in place. + list[int] + The identifiers of the vertices. + + See Also + -------- + :meth:`edge_sample`, :meth:`face_sample` """ - self.remove_duplicate_vertices(precision=precision) + return sample(list(self.vertices()), size) - def remove_duplicate_vertices(self, precision=None): - """Remove all duplicate vertices and clean up any affected faces. + def edge_sample(self, size=1): + """A random sample of the edges. + + Parameters + ---------- + size : int, optional + The number of edges in the random sample. + + Returns + ------- + list[tuple[int, int]] + The identifiers of the edges. + + See Also + -------- + :meth:`vertex_sample`, :meth:`face_sample` + + """ + return sample(list(self.edges()), size) + + def face_sample(self, size=1): + """A random sample of the faces. + + Parameters + ---------- + size : int, optional + The number of faces in the random sample. + + Returns + ------- + list[int] + The identifiers of the faces. + + See Also + -------- + :meth:`vertex_sample`, :meth:`edge_sample` + + """ + return sample(list(self.faces()), size) + + def vertex_index(self): + """Returns a dictionary that maps vertex identifiers to the + corresponding index in a vertex list or array. + + Returns + ------- + dict[int, int] + A dictionary of vertex-index pairs. + + See Also + -------- + :meth:`index_vertex` + + """ + return {key: index for index, key in enumerate(self.vertices())} + + def index_vertex(self): + """Returns a dictionary that maps the indices of a vertex list to + the corresponding vertex identifiers. + + Returns + ------- + dict[int, int] + A dictionary of index-vertex pairs. + + See Also + -------- + :meth:`vertex_index` + + """ + return dict(enumerate(self.vertices())) + + def vertex_gkey(self, precision=None): + """Returns a dictionary that maps vertex dictionary keys to the corresponding + *geometric key* up to a certain precision. Parameters ---------- @@ -881,14 +957,1908 @@ def remove_duplicate_vertices(self, precision=None): Returns ------- - None - The mesh is modified in-place. + dict[int, str] + A dictionary of key-geometric key pairs. - Examples - -------- - >>> import compas - >>> from compas.datastructures import Mesh - >>> mesh = Mesh.from_obj(compas.get('faces.obj')) + """ + gkey = TOL.geometric_key + xyz = self.vertex_coordinates + return {key: gkey(xyz(key), precision) for key in self.vertices()} + + def gkey_vertex(self, precision=None): + """Returns a dictionary that maps *geometric keys* of a certain precision + to the keys of the corresponding vertices. + + Parameters + ---------- + precision : int, optional + Precision for converting numbers to strings. + Default is :attr:`TOL.precision`. + + Returns + ------- + dict[str, int] + A dictionary of geometric key-key pairs. + + """ + gkey = TOL.geometric_key + xyz = self.vertex_coordinates + return {gkey(xyz(key), precision): key for key in self.vertices()} + + # -------------------------------------------------------------------------- + # Builders + # -------------------------------------------------------------------------- + + def add_vertex(self, key=None, attr_dict=None, **kwattr): + """Add a vertex to the mesh object. + + Parameters + ---------- + key : int, optional + The vertex identifier. + attr_dict : dict[str, Any], optional + A dictionary of vertex attributes. + **kwattr : dict[str, Any], optional + A dictionary of additional attributes compiled of remaining named arguments. + + Returns + ------- + int + The identifier of the vertex. + + See Also + -------- + :meth:`add_face` + :meth:`delete_vertex`, :meth:`delete_face` + + Notes + ----- + If no key is provided for the vertex, one is generated + automatically. An automatically generated key is an integer that increments + the highest integer value of any key used so far by 1. + + If a key with an integer value is provided that is higher than the current + highest integer key value, then the highest integer value is updated accordingly. + + Examples + -------- + >>> from compas.datastructures import Mesh + >>> mesh = Mesh() + >>> mesh.add_vertex() + 0 + >>> mesh.add_vertex(x=0, y=0, z=0) + 1 + >>> mesh.add_vertex(key=2) + 2 + >>> mesh.add_vertex(key=0, x=1) + 0 + + """ + if key is None: + key = self._max_vertex = self._max_vertex + 1 + key = int(key) + if key > self._max_vertex: + self._max_vertex = key + if key not in self.vertex: + self.vertex[key] = {} + self.halfedge[key] = {} + attr = attr_dict or {} + attr.update(kwattr) + self.vertex[key].update(attr) + return key + + def add_face(self, vertices, fkey=None, attr_dict=None, **kwattr): + """Add a face to the mesh object. + + Parameters + ---------- + vertices : list[int] + A list of vertex keys. + attr_dict : dict[str, Any], optional + A dictionary of face attributes. + **kwattr : dict[str, Any], optional + A dictionary of additional attributes compiled of remaining named arguments. + + See Also + -------- + :meth:`add_vertex` + :meth:`delete_face`, :meth:`delete_vertex` + + Returns + ------- + int + The key of the face. + + Raises + ------ + TypeError + If the provided face key is of an unhashable type. + + Notes + ----- + If no key is provided for the face, one is generated + automatically. An automatically generated key is an integer that increments + the highest integer value of any key used so far by 1. + + If a key with an integer value is provided that is higher than the current + highest integer key value, then the highest integer value is updated accordingly. + + """ + if vertices[-1] == vertices[0]: + vertices = vertices[:-1] + vertices = [int(key) for key in vertices] + vertices[:] = [u for u, v in pairwise(vertices + vertices[:1]) if u != v] + if len(vertices) < 3: + return + if fkey is None: + fkey = self._max_face = self._max_face + 1 + fkey = int(fkey) + if fkey > self._max_face: + self._max_face = fkey + attr = attr_dict or {} + attr.update(kwattr) + self.face[fkey] = vertices + self.facedata.setdefault(fkey, attr) + for u, v in pairwise(vertices + vertices[:1]): + self.halfedge[u][v] = fkey + if u not in self.halfedge[v]: + self.halfedge[v][u] = None + return fkey + + # rename this to "add" + # and add an alias + def join(self, other, weld=False, precision=None): + """Add the vertices and faces of another mesh to the current mesh. + + Parameters + ---------- + other : :class:`compas.datastructures.Mesh` + The other mesh. + weld : bool, optional + If True, weld close vertices after joining. + Default is False. + precision : int, optional + The precision used for welding. + Default is :attr:`TOL.precision`. + + Returns + ------- + None + The mesh is modified in place. + + Examples + -------- + >>> from compas.geometry import Box + >>> from compas.geometry import Translation + >>> from compas.datastructures import Mesh + >>> a = Box.from_width_height_depth(1, 1, 1) + >>> b = Box.from_width_height_depth(1, 1, 1) + >>> T = Translation.from_vector([2, 0, 0]) + >>> b.transform(T) + >>> a = Mesh.from_shape(a) + >>> b = Mesh.from_shape(b) + >>> a.number_of_vertices() + 8 + >>> a.number_of_faces() + 6 + >>> b.number_of_vertices() + 8 + >>> b.number_of_faces() + 6 + >>> a.join(b) + >>> a.number_of_vertices() + 16 + >>> a.number_of_faces() + 12 + + """ + self.default_vertex_attributes.update(other.default_vertex_attributes) + self.default_edge_attributes.update(other.default_edge_attributes) + self.default_face_attributes.update(other.default_face_attributes) + + vertex_old_new = {} + + for vertex, attr in other.vertices(True): + key = self.add_vertex(attr_dict=attr) + vertex_old_new[vertex] = key + + for face, attr in other.faces(True): + vertices = [vertex_old_new[key] for key in other.face_vertices(face)] + self.add_face(vertices, attr_dict=attr) + + if weld: + self.weld(precision=precision) + + # -------------------------------------------------------------------------- + # Modifiers + # -------------------------------------------------------------------------- + + def delete_vertex(self, key): + """Delete a vertex from the mesh and everything that is attached to it. + + Parameters + ---------- + key : int + The identifier of the vertex. + + Returns + ------- + None + + See Also + -------- + :meth:`delete_face` + :meth:`add_vertex`, :meth:`add_face` + + Notes + ----- + In some cases, disconnected vertices can remain after application of this + method. To remove these vertices as well, combine this method with vertex + culling (:meth:`cull_vertices`). + + """ + nbrs = self.vertex_neighbors(key) + for nbr in nbrs: + fkey = self.halfedge[key][nbr] + if fkey is None: + continue + for u, v in self.face_halfedges(fkey): + self.halfedge[u][v] = None + del self.face[fkey] + if fkey in self.facedata: + del self.facedata[fkey] + for nbr in nbrs: + del self.halfedge[nbr][key] + edge = "-".join(map(str, sorted([nbr, key]))) + if edge in self.edgedata: + del self.edgedata[edge] + for nbr in nbrs: + for n in self.vertex_neighbors(nbr): + if self.halfedge[nbr][n] is None and self.halfedge[n][nbr] is None: + del self.halfedge[nbr][n] + del self.halfedge[n][nbr] + edge = "-".join(map(str, sorted([nbr, n]))) + if edge in self.edgedata: + del self.edgedata[edge] + del self.halfedge[key] + del self.vertex[key] + + def delete_face(self, fkey): + """Delete a face from the mesh object. + + Parameters + ---------- + fkey : int + The identifier of the face. + + Returns + ------- + None + + See Also + -------- + :meth:`delete_vertex` + :meth:`add_vertex`, :meth:`add_face` + + Notes + ----- + In some cases, disconnected vertices can remain after application of this + method. To remove these vertices as well, combine this method with vertex + culling (:meth:`cull_vertices`). + + """ + for u, v in self.face_halfedges(fkey): + if self.halfedge[u][v] == fkey: + # if the halfedge still points to the face + # this might not be the case of the deletion is executed + # during the procedure of adding a new (replacement) face + self.halfedge[u][v] = None + if self.halfedge[v][u] is None: + del self.halfedge[u][v] + del self.halfedge[v][u] + edge = "-".join(map(str, sorted([u, v]))) + if edge in self.edgedata: + del self.edgedata[edge] + del self.face[fkey] + if fkey in self.facedata: + del self.facedata[fkey] + + def remove_unused_vertices(self): + """Remove all unused vertices from the mesh object. + + Returns + ------- + None + + See Also + -------- + :meth:`delete_vertex` + + """ + for u in list(self.vertices()): + if u not in self.halfedge: + del self.vertex[u] + else: + if not self.halfedge[u]: + del self.vertex[u] + del self.halfedge[u] + + cull_vertices = remove_unused_vertices + + def flip_cycles(self): + """Flip the cycle directions of all faces. + + Returns + ------- + None + The mesh is modified in place. + + Notes + ----- + This function does not care about the directions being unified or not. It + just reverses whatever direction it finds. + + """ + self.halfedge = {key: {} for key in self.vertices()} + for fkey in self.faces(): + self.face[fkey][:] = self.face[fkey][::-1] + for u, v in self.face_halfedges(fkey): + self.halfedge[u][v] = fkey + if u not in self.halfedge[v]: + self.halfedge[v][u] = None + + def insert_vertex(self, fkey, key=None, xyz=None, return_fkeys=False): + """Insert a vertex in the specified face. + + Parameters + ---------- + fkey : int + The key of the face in which the vertex should be inserted. + key : int, optional + The key to be used to identify the inserted vertex. + xyz : list[float], optional + Specific XYZ coordinates for the inserted vertex. + return_fkeys : bool, optional + If True, return the identifiers of the newly created faces in addition to the identifier of the inserted vertex. + + Returns + ------- + int | tuple[int, list[int]] + If `return_fkeys` is False, the key of the inserted vertex. + If `return_fkeys` is True, the key of the newly created vertex and a list with the newly created faces. + + """ + fkeys = [] + if not xyz: + x, y, z = self.face_center(fkey) + else: + x, y, z = xyz + w = self.add_vertex(key=key, x=x, y=y, z=z) + for u, v in self.face_halfedges(fkey): + fkeys.append(self.add_face([u, v, w])) + self.delete_face(fkey) + if return_fkeys: + return w, fkeys + return w + + # -------------------------------------------------------------------------- + # Accessors + # -------------------------------------------------------------------------- + + def vertices(self, data=False): + """Iterate over the vertices of the mesh. + + Parameters + ---------- + data : bool, optional + If True, yield the vertex attributes in addition to the vertex identifiers. + + Yields + ------ + int | tuple[int, dict[str, Any]] + If `data` is False, the next vertex identifier. + If `data` is True, the next vertex as a (key, attr) tuple. + + See Also + -------- + :meth:`faces`, :meth:`edges` + :meth:`vertices_where`, :meth:`edges_where`, :meth:`faces_where` + + """ + for key in self.vertex: + if not data: + yield key + else: + yield key, self.vertex_attributes(key) + + def faces(self, data=False): + """Iterate over the faces of the mesh. + + Parameters + ---------- + data : bool, optional + If True, yield the face attributes in addition to the face identifiers. + + Yields + ------ + int | tuple[int, dict[str, Any]] + If `data` is False, the next face identifier. + If `data` is True, the next face as a (fkey, attr) tuple. + + See Also + -------- + :meth:`vertices`, :meth:`edges` + :meth:`vertices_where`, :meth:`edges_where`, :meth:`faces_where` + + """ + for key in self.face: + if not data: + yield key + else: + yield key, self.face_attributes(key) + + def edges(self, data=False): + """Iterate over the edges of the mesh. + + Parameters + ---------- + data : bool, optional + If True, yield the edge attributes in addition to the edge identifiers. + + Yields + ------ + tuple[int, int] | tuple[tuple[int, int], dict[str, Any]] + If `data` is False, the next edge as a (u, v) tuple. + If `data` is True, the next edge as a ((u, v), data) tuple. + + See Also + -------- + :meth:`vertices`, :meth:`faces` + :meth:`vertices_where`, :meth:`edges_where`, :meth:`faces_where` + + Notes + ----- + Mesh edges have no topological meaning. They are only used to store data. + Edges are not automatically created when vertices and faces are added to + the mesh. Instead, they are created when data is stored on them, or when + they are accessed using this method. + + This method yields the directed edges of the mesh. + Unless edges were added explicitly using :meth:`add_edge` the order of + edges is *as they come out*. However, as long as the toplogy remains + unchanged, the order is consistent. + + """ + seen = set() + for u in self.halfedge: + for v in self.halfedge[u]: + key = u, v + ikey = v, u + if key in seen or ikey in seen: + continue + seen.add(key) + seen.add(ikey) + if not data: + yield key + else: + yield key, self.edge_attributes(key) + + def vertices_where(self, conditions=None, data=False, **kwargs): + """Get vertices for which a certain condition or set of conditions is true. + + Parameters + ---------- + conditions : dict, optional + A set of conditions in the form of key-value pairs. + The keys should be attribute names. The values can be attribute + values or ranges of attribute values in the form of min/max pairs. + data : bool, optional + If True, yield the vertex attributes in addition to the vertex identifiers. + **kwargs : dict[str, Any], optional + Additional conditions provided as named function arguments. + + Yields + ------ + int | tuple[int, dict[str, Any]] + If `data` is False, the next vertex that matches the condition. + If `data` is True, the next vertex and its attributes. + + See Also + -------- + :meth:`faces_where`, :meth:`edges_where` + :meth:`vertices_where_predicate`, :meth:`edges_where_predicate`, :meth:`faces_where_predicate` + + """ + conditions = conditions or {} + conditions.update(kwargs) + + for key, attr in self.vertices(True): + is_match = True + attr = attr or {} + + for name, value in conditions.items(): + method = getattr(self, name, None) + + if callable(method): + val = method(key) + + if isinstance(val, list): + if value not in val: + is_match = False + break + break + + if isinstance(value, (tuple, list)): + minval, maxval = value + if val < minval or val > maxval: + is_match = False + break + else: + if value != val: + is_match = False + break + + else: + if name not in attr: + is_match = False + break + + if isinstance(attr[name], list): + if value not in attr[name]: + is_match = False + break + break + + if isinstance(value, (tuple, list)): + minval, maxval = value + if attr[name] < minval or attr[name] > maxval: + is_match = False + break + else: + if value != attr[name]: + is_match = False + break + + if is_match: + if data: + yield key, attr + else: + yield key + + def vertices_where_predicate(self, predicate, data=False): + """Get vertices for which a certain condition or set of conditions is true using a lambda function. + + Parameters + ---------- + predicate : callable + The condition you want to evaluate. + The callable takes 2 parameters: the vertex identifier and the vertex attributes, + and should return True or False. + data : bool, optional + If True, yield the vertex attributes in addition to the vertex identifiers. + + Yields + ------ + int | tuple[int, dict[str, Any]] + If `data` is False, the next vertex that matches the condition. + If `data` is True, the next vertex and its attributes. + + See Also + -------- + :meth:`faces_where_predicate`, :meth:`edges_where_predicate` + :meth:`vertices_where`, :meth:`edges_where`, :meth:`faces_where` + + """ + for key, attr in self.vertices(True): + if predicate(key, attr): + if data: + yield key, attr + else: + yield key + + def edges_where(self, conditions=None, data=False, **kwargs): + """Get edges for which a certain condition or set of conditions is true. + + Parameters + ---------- + conditions : dict, optional + A set of conditions in the form of key-value pairs. + The keys should be attribute names. The values can be attribute + values or ranges of attribute values in the form of min/max pairs. + data : bool, optional + If True, yield the edge attributes in addition to the edge identifiers. + **kwargs : dict[str, Any], optional + Additional conditions provided as named function arguments. + + Yields + ------ + tuple[int, int] | tuple[tuple[int, int], dict[str, Any]] + If `data` is False, the next edge as a (u, v) tuple. + If `data` is True, the next edge as a (u, v, data) tuple. + + See Also + -------- + :meth:`vertices_where`, :meth:`faces_where` + :meth:`vertices_where_predicate`, :meth:`edges_where_predicate`, :meth:`faces_where_predicate` + + """ + conditions = conditions or {} + conditions.update(kwargs) + + for key in self.edges(): + is_match = True + + attr = self.edge_attributes(key) or {} + + for name, value in conditions.items(): + method = getattr(self, name, None) + + if method and callable(method): + val = method(key) + elif name in attr: + val = attr[name] + else: + is_match = False + break + + if isinstance(val, list): + if value not in val: + is_match = False + break + elif isinstance(value, (tuple, list)): + minval, maxval = value + if val < minval or val > maxval: + is_match = False + break + else: + if value != val: + is_match = False + break + + if is_match: + if data: + yield key, attr + else: + yield key + + def edges_where_predicate(self, predicate, data=False): + """Get edges for which a certain condition or set of conditions is true using a lambda function. + + Parameters + ---------- + predicate : callable + The condition you want to evaluate. + The callable takes 3 parameters: + the identifier of the first vertex, the identifier of the second vertex, and the edge attributes, + and should return True or False. + data : bool, optional + If True, yield the vertex attributes in addition ot the vertex identifiers. + + Yields + ------ + tuple[int, int] | tuple[tuple[int, int], dict[str, Any]] + If `data` is False, the next edge as a (u, v) tuple. + If `data` is True, the next edge as a (u, v, data) tuple. + + See Also + -------- + :meth:`faces_where_predicate`, :meth:`vertices_where_predicate` + :meth:`vertices_where`, :meth:`edges_where`, :meth:`faces_where` + + """ + for key, attr in self.edges(True): + if predicate(key, attr): + if data: + yield key, attr + else: + yield key + + def faces_where(self, conditions=None, data=False, **kwargs): + """Get faces for which a certain condition or set of conditions is true. + + Parameters + ---------- + conditions : dict, optional + A set of conditions in the form of key-value pairs. + The keys should be attribute names. The values can be attribute + values or ranges of attribute values in the form of min/max pairs. + data : bool, optional + If True, yield the face attributes in addition to face identifiers. + **kwargs : dict[str, Any], optional + Additional conditions provided as named function arguments. + + Yields + ------ + int | tuple[int, dict[str, Any]] + If `data` is False, the next face that matches the condition. + If `data` is True, the next face and its attributes. + + See Also + -------- + :meth:`vertices_where`, :meth:`edges_where` + :meth:`vertices_where_predicate`, :meth:`edges_where_predicate`, :meth:`faces_where_predicate` + + """ + conditions = conditions or {} + conditions.update(kwargs) + + for fkey in self.faces(): + is_match = True + + attr = self.face_attributes(fkey) or {} + + for name, value in conditions.items(): + method = getattr(self, name, None) + + if method and callable(method): + val = method(fkey) + elif name in attr: + val = attr[name] + else: + is_match = False + break + + if isinstance(val, list): + if value not in val: + is_match = False + break + elif isinstance(value, (tuple, list)): + minval, maxval = value + if val < minval or val > maxval: + is_match = False + break + else: + if value != val: + is_match = False + break + + if is_match: + if data: + yield fkey, attr + else: + yield fkey + + def faces_where_predicate(self, predicate, data=False): + """Get faces for which a certain condition or set of conditions is true using a lambda function. + + Parameters + ---------- + predicate : callable + The condition you want to evaluate. + The callable takes 2 parameters: the face identifier and the face attributes, + and should return True or False. + data : bool, optional + If True, yield the face attributes in addition to the face identifiers. + + Yields + ------ + int | tuple[int, dict[str, Any]] + If `data` is False, the next face that matches the condition. + If `data` is True, the next face and its attributes. + + See Also + -------- + :meth:`edges_where_predicate`, :meth:`vertices_where_predicate` + :meth:`vertices_where`, :meth:`edges_where`, :meth:`faces_where` + + """ + for fkey, attr in self.faces(True): + if predicate(fkey, attr): + if data: + yield fkey, attr + else: + yield fkey + + # -------------------------------------------------------------------------- + # Attributes + # -------------------------------------------------------------------------- + + def update_default_vertex_attributes(self, attr_dict=None, **kwattr): + """Update the default vertex attributes. + + Parameters + ---------- + attr_dict : dict[str, Any], optional + A dictionary of attributes with their default values. + **kwattr : dict[str, Any], optional + A dictionary compiled of remaining named arguments. + + Returns + ------- + None + + See Also + -------- + :meth:`update_default_edge_attributes` + :meth:`update_default_face_attributes` + + Notes + ----- + Named arguments overwrite corresponding key-value pairs in the attribute dictionary. + + """ + if not attr_dict: + attr_dict = {} + attr_dict.update(kwattr) + self.default_vertex_attributes.update(attr_dict) + + def vertex_attribute(self, key, name, value=None): + """Get or set an attribute of a vertex. + + Parameters + ---------- + key : int + The vertex identifier. + name : str + The name of the attribute + value : object, optional + The value of the attribute. + + Returns + ------- + object | None + The value of the attribute, + or None when the function is used as a "setter". + + Raises + ------ + KeyError + If the vertex does not exist. + + See Also + -------- + :meth:`vertex_attributes`, :meth:`vertices_attribute`, :meth:`vertices_attributes` + :meth:`unset_vertex_attribute` + :meth:`edge_attribute` + :meth:`face_attribute` + + """ + if key not in self.vertex: + raise KeyError(key) + if value is not None: + self.vertex[key][name] = value + return None + if name in self.vertex[key]: + return self.vertex[key][name] + else: + if name in self.default_vertex_attributes: + return self.default_vertex_attributes[name] + + def unset_vertex_attribute(self, key, name): + """Unset the attribute of a vertex. + + Parameters + ---------- + key : int + The vertex identifier. + name : str + The name of the attribute. + + Returns + ------- + None + + Raises + ------ + KeyError + If the vertex does not exist. + + See Also + -------- + :meth:`vertex_attribute`, :meth:`vertex_attributes`, :meth:`vertices_attribute`, :meth:`vertices_attributes` + :meth:`unset_edge_attribute` + :meth:`unset_face_attribute` + + Notes + ----- + Unsetting the value of a vertex attribute implicitly sets it back to the value + stored in the default vertex attribute dict. + + """ + if name in self.vertex[key]: + del self.vertex[key][name] + + def vertex_attributes(self, key, names=None, values=None): + """Get or set multiple attributes of a vertex. + + Parameters + ---------- + key : int + The identifier of the vertex. + names : list[str], optional + A list of attribute names. + values : list[Any], optional + A list of attribute values. + + Returns + ------- + dict[str, Any] | list[Any] | None + If the parameter `names` is empty, + the function returns a dictionary of all attribute name-value pairs of the vertex. + If the parameter `names` is not empty, + the function returns a list of the values corresponding to the requested attribute names. + The function returns None if it is used as a "setter". + + Raises + ------ + KeyError + If the vertex does not exist. + + See Also + -------- + :meth:`vertex_attribute`, :meth:`vertices_attribute`, :meth:`vertices_attributes` + :meth:`edge_attributes` + :meth:`face_attributes` + + """ + if key not in self.vertex: + raise KeyError(key) + if names and values is not None: + # use it as a setter + for name, value in zip(names, values): + self.vertex[key][name] = value + return + # use it as a getter + if not names: + # return all vertex attributes as a dict + return VertexAttributeView(self.default_vertex_attributes, self.vertex[key]) + values = [] + for name in names: + if name in self.vertex[key]: + values.append(self.vertex[key][name]) + elif name in self.default_vertex_attributes: + values.append(self.default_vertex_attributes[name]) + else: + values.append(None) + return values + + def vertices_attribute(self, name, value=None, keys=None): + """Get or set an attribute of multiple vertices. + + Parameters + ---------- + name : str + The name of the attribute. + value : object, optional + The value of the attribute. + Default is None. + keys : list[int], optional + A list of vertex identifiers. + + Returns + ------- + list[Any] | None + The value of the attribute for each vertex, + or None if the function is used as a "setter". + + Raises + ------ + KeyError + If any of the vertices does not exist. + + See Also + -------- + :meth:`vertex_attribute`, :meth:`vertex_attributes`, :meth:`vertices_attributes` + :meth:`edges_attribute` + :meth:`faces_attribute` + + """ + if not keys: + keys = self.vertices() + if value is not None: + for key in keys: + self.vertex_attribute(key, name, value) + return + return [self.vertex_attribute(key, name) for key in keys] + + def vertices_attributes(self, names=None, values=None, keys=None): + """Get or set multiple attributes of multiple vertices. + + Parameters + ---------- + names : list[str], optional + The names of the attribute. + values : list[Any], optional + The values of the attributes. + keys : list[int], optional + A list of vertex identifiers. + + Returns + ------- + list[dict[str, Any]] | list[list[Any]] | None + If the parameter `names` is empty, + the function returns a list containing an attribute dict per vertex. + If the parameter `names` is not empty, + the function returns a list containing a list of attribute values per vertex corresponding to the provided attribute names. + The function returns None if it is used as a "setter". + + Raises + ------ + KeyError + If any of the vertices does not exist. + + See Also + -------- + :meth:`vertex_attribute`, :meth:`vertex_attributes`, :meth:`vertices_attribute` + :meth:`edges_attributes` + :meth:`faces_attributes` + + """ + if not keys: + keys = self.vertices() + if values is not None: + for key in keys: + self.vertex_attributes(key, names, values) + return + return [self.vertex_attributes(key, names) for key in keys] + + def update_default_face_attributes(self, attr_dict=None, **kwattr): + """Update the default face attributes. + + Parameters + ---------- + attr_dict : dict[str, Any], optional + A dictionary of attributes with their default values. + **kwattr : dict[str, Any], optional + A dictionary compiled of remaining named arguments. + + Returns + ------- + None + + See Also + -------- + :meth:`update_default_vertex_attributes` + :meth:`update_default_edge_attributes` + + Notes + ----- + Named arguments overwrite corresponding key-value pairs in the attribute dictionary. + + """ + if not attr_dict: + attr_dict = {} + attr_dict.update(kwattr) + self.default_face_attributes.update(attr_dict) + + def face_attribute(self, key, name, value=None): + """Get or set an attribute of a face. + + Parameters + ---------- + key : int + The face identifier. + name : str + The name of the attribute. + value : object, optional + The value of the attribute. + + Returns + ------- + object | None + The value of the attribute, or None when the function is used as a "setter". + + Raises + ------ + KeyError + If the face does not exist. + + See Also + -------- + :meth:`face_attributes`, :meth:`faces_attribute`, :meth:`faces_attributes` + :meth:`unset_face_attribute` + :meth:`edge_attribute` + :meth:`vertex_attribute` + + """ + if key not in self.face: + raise KeyError(key) + if value is not None: + if key not in self.facedata: + self.facedata[key] = {} + self.facedata[key][name] = value + return + if key in self.facedata and name in self.facedata[key]: + return self.facedata[key][name] + if name in self.default_face_attributes: + return self.default_face_attributes[name] + + def unset_face_attribute(self, key, name): + """Unset the attribute of a face. + + Parameters + ---------- + key : int + The face identifier. + name : str + The name of the attribute. + + Returns + ------- + None + + Raises + ------ + KeyError + If the face does not exist. + + See Also + -------- + :meth:`face_attribute`, :meth:`face_attributes`, :meth:`faces_attribute`, :meth:`faces_attributes` + :meth:`unset_edge_attribute` + :meth:`unset_vertex_attribute` + + Notes + ----- + Unsetting the value of a face attribute implicitly sets it back to the value + stored in the default face attribute dict. + + """ + if key not in self.face: + raise KeyError(key) + if key in self.facedata: + if name in self.facedata[key]: + del self.facedata[key][name] + + def face_attributes(self, key, names=None, values=None): + """Get or set multiple attributes of a face. + + Parameters + ---------- + key : int + The identifier of the face. + names : list[str], optional + A list of attribute names. + values : list[Any], optional + A list of attribute values. + + Returns + ------- + dict[str, Any] | list[Any] | None + If the parameter `names` is empty, + a dictionary of all attribute name-value pairs of the face. + If the parameter `names` is not empty, + a list of the values corresponding to the provided names. + None if the function is used as a "setter". + + Raises + ------ + KeyError + If the face does not exist. + + See Also + -------- + :meth:`face_attribute`, :meth:`faces_attribute`, :meth:`faces_attributes` + :meth:`edge_attributes` + :meth:`vertex_attributes` + + """ + if key not in self.face: + raise KeyError(key) + if names and values is not None: + # use it as a setter + for name, value in zip(names, values): + if key not in self.facedata: + self.facedata[key] = {} + self.facedata[key][name] = value + return + # use it as a getter + if not names: + return FaceAttributeView(self.default_face_attributes, self.facedata.setdefault(key, {})) + values = [] + for name in names: + value = self.face_attribute(key, name) + values.append(value) + return values + + def faces_attribute(self, name, value=None, keys=None): + """Get or set an attribute of multiple faces. + + Parameters + ---------- + name : str + The name of the attribute. + value : object, optional + The value of the attribute. + Default is None. + keys : list[int], optional + A list of face identifiers. + + Returns + ------- + list[Any] | None + A list containing the value per face of the requested attribute, + or None if the function is used as a "setter". + + Raises + ------ + KeyError + If any of the faces does not exist. + + See Also + -------- + :meth:`face_attribute`, :meth:`face_attributes`, :meth:`faces_attributes` + :meth:`edges_attribute` + :meth:`vertices_attribute` + + """ + if not keys: + keys = self.faces() + if value is not None: + for key in keys: + self.face_attribute(key, name, value) + return + return [self.face_attribute(key, name) for key in keys] + + def faces_attributes(self, names=None, values=None, keys=None): + """Get or set multiple attributes of multiple faces. + + Parameters + ---------- + names : list[str], optional + The names of the attribute. + Default is None. + values : list[Any], optional + The values of the attributes. + Default is None. + keys : list[int], optional + A list of face identifiers. + + Returns + ------- + list[dict[str, Any]] | list[list[Any]] | None + If the parameter `names` is empty, + a list containing per face an attribute dict with all attributes (default + custom) of the face. + If the parameter `names` is not empty, + a list containing per face a list of attribute values corresponding to the requested names. + None if the function is used as a "setter". + + Raises + ------ + KeyError + If any of the faces does not exist. + + See Also + -------- + :meth:`face_attribute`, :meth:`face_attributes`, :meth:`faces_attribute` + :meth:`edges_attributes` + :meth:`vertices_attributes` + + """ + if not keys: + keys = self.faces() + if values is not None: + for key in keys: + self.face_attributes(key, names, values) + return + return [self.face_attributes(key, names) for key in keys] + + def update_default_edge_attributes(self, attr_dict=None, **kwattr): + """Update the default edge attributes. + + Parameters + ---------- + attr_dict : dict[str, Any], optional + A dictionary of attributes with their default values. + **kwattr : dict[str, Any], optional + A dictionary compiled of remaining named arguments. + + Returns + ------- + None + + See Also + -------- + :meth:`update_default_vertex_attributes` + :meth:`update_default_face_attributes` + + Notes + ----- + Named arguments overwrite corresponding key-value pairs in the attribute dictionary. + + """ + if not attr_dict: + attr_dict = {} + attr_dict.update(kwattr) + self.default_edge_attributes.update(attr_dict) + + def edge_attribute(self, edge, name, value=None): + """Get or set an attribute of an edge. + + Parameters + ---------- + edge : tuple[int, int] + The identifier of the edge as a pair of vertex identifiers. + name : str + The name of the attribute. + value : object, optional + The value of the attribute. + Default is None. + + Returns + ------- + object | None + The value of the attribute, or None when the function is used as a "setter". + + Raises + ------ + KeyError + If the edge does not exist. + + See Also + -------- + :meth:`edge_attributes`, :meth:`edges_attribute`, :meth:`edges_attributes` + :meth:`unset_edge_attribute` + :meth:`vertex_attribute` + :meth:`face_attribute` + + """ + u, v = edge + if u not in self.halfedge or v not in self.halfedge[u]: + raise KeyError(edge) + key = str(tuple(sorted(edge))) + if value is not None: + if key not in self.edgedata: + self.edgedata[key] = {} + self.edgedata[key][name] = value + return + if key in self.edgedata and name in self.edgedata[key]: + return self.edgedata[key][name] + if name in self.default_edge_attributes: + return self.default_edge_attributes[name] + + def unset_edge_attribute(self, edge, name): + """Unset the attribute of an edge. + + Parameters + ---------- + edge : tuple[int, int] + The edge identifier. + name : str + The name of the attribute. + + Returns + ------- + None + + Raises + ------ + KeyError + If the edge does not exist. + + See Also + -------- + :meth:`edge_attribute`, :meth:`edge_attributes`, :meth:`edges_attribute`, :meth:`edges_attributes` + :meth:`unset_vertex_attribute` + :meth:`unset_face_attribute` + + Notes + ----- + Unsetting the value of an edge attribute implicitly sets it back to the value + stored in the default edge attribute dict. + + """ + u, v = edge + if u not in self.halfedge or v not in self.halfedge[u]: + raise KeyError(edge) + key = str(tuple(sorted(edge))) + if key in self.edgedata and name in self.edgedata[key]: + del self.edgedata[key][name] + + def edge_attributes(self, edge, names=None, values=None): + """Get or set multiple attributes of an edge. + + Parameters + ---------- + edge : tuple[int, int] + The identifier of the edge. + names : list[str], optional + A list of attribute names. + values : list[Any], optional + A list of attribute values. + + Returns + ------- + dict[str, Any] | list[Any] | None + If the parameter `names` is empty, + a dictionary of all attribute name-value pairs of the edge. + If the parameter `names` is not empty, + a list of the values corresponding to the provided names. + None if the function is used as a "setter". + + Raises + ------ + KeyError + If the edge does not exist. + + See Also + -------- + :meth:`edge_attribute`, :meth:`edges_attribute`, :meth:`edges_attributes` + :meth:`vertex_attributes` + :meth:`face_attributes` + + """ + u, v = edge + if u not in self.halfedge or v not in self.halfedge[u]: + raise KeyError(edge) + if names and values is not None: + # use it as a setter + for name, value in zip(names, values): + self.edge_attribute(edge, name, value) + return + # use it as a getter + if not names: + key = str(tuple(sorted(edge))) + # get the entire attribute dict + return EdgeAttributeView(self.default_edge_attributes, self.edgedata.setdefault(key, {})) + # get only the values of the named attributes + values = [] + for name in names: + value = self.edge_attribute(edge, name) + values.append(value) + return values + + def edges_attribute(self, name, value=None, keys=None): + """Get or set an attribute of multiple edges. + + Parameters + ---------- + name : str + The name of the attribute. + value : object, optional + The value of the attribute. + Default is None. + keys : list[tuple[int, int]], optional + A list of edge identifiers. + + Returns + ------- + list[Any] | None + A list containing the value per edge of the requested attribute, + or None if the function is used as a "setter". + + Raises + ------ + KeyError + If any of the edges does not exist. + + See Also + -------- + :meth:`edge_attribute`, :meth:`edge_attributes`, :meth:`edges_attributes` + :meth:`vertex_attributes` + :meth:`face_attributes` + + """ + edges = keys or self.edges() + if value is not None: + for edge in edges: + self.edge_attribute(edge, name, value) + return + return [self.edge_attribute(edge, name) for edge in edges] + + def edges_attributes(self, names=None, values=None, keys=None): + """Get or set multiple attributes of multiple edges. + + Parameters + ---------- + names : list[str], optional + The names of the attribute. + Default is None. + values : list[Any], optional + The values of the attributes. + Default is None. + keys : list[tuple[int, int]], optional + A list of edge identifiers. + + Returns + ------- + list[dict[str, Any]] | list[list[Any]] | None + If the parameter `names` is empty, + a list containing per edge an attribute dict with all attributes (default + custom) of the edge. + If the parameter `names` is not empty, + a list containing per edge a list of attribute values corresponding to the requested names. + None if the function is used as a "setter". + + Raises + ------ + KeyError + If any of the edges does not exist. + + See Also + -------- + :meth:`edge_attribute`, :meth:`edge_attributes`, :meth:`edges_attribute` + :meth:`vertex_attributes` + :meth:`face_attributes` + + """ + edges = keys or self.edges() + if values is not None: + for edge in edges: + self.edge_attributes(edge, names, values) + return + return [self.edge_attributes(edge, names) for edge in edges] + + # -------------------------------------------------------------------------- + # Info + # -------------------------------------------------------------------------- + + def summary(self): + """Print a summary of the mesh. + + Returns + ------- + str + The formatted summary text. + + """ + tpl = "\n".join( + [ + "{} summary", + "=" * (len(self.name) + len(" summary")), + "- vertices: {}", + "- edges: {}", + "- faces: {}", + ] + ) + return tpl.format( + self.name, + self.number_of_vertices(), + self.number_of_edges(), + self.number_of_faces(), + ) + + def number_of_vertices(self): + """Count the number of vertices in the mesh. + + Returns + ------- + int + + See Also + -------- + :meth:`number_of_edges` + :meth:`number_of_faces` + + """ + return len(list(self.vertices())) + + def number_of_edges(self): + """Count the number of edges in the mesh. + + Returns + ------- + int + + See Also + -------- + :meth:`number_of_vertices` + :meth:`number_of_faces` + + """ + return len(list(self.edges())) + + def number_of_faces(self): + """Count the number of faces in the mesh. + + Returns + ------- + int + + See Also + -------- + :meth:`number_of_vertices` + :meth:`number_of_edges` + + """ + return len(list(self.faces())) + + def is_valid(self): + """Verify that the mesh is valid. + + A mesh is valid if the following conditions are fulfilled: + + * halfedges don't point at non-existing faces + * all vertices are in the halfedge dict + * there are no None-None halfedges + * all faces have corresponding halfedge entries + + Returns + ------- + bool + True, if the mesh is valid. + False, otherwise. + + See Also + -------- + :meth:`is_regular`, :meth:`is_manifold`, :meth:`is_orientable`, :meth:`is_empty`, :meth:`is_closed`, :meth:`is_trimesh`, :meth:`is_quadmesh` + + """ + for key in self.vertices(): + if key not in self.halfedge: + return False + + for u in self.halfedge: + if u not in self.vertex: + return False + for v in self.halfedge[u]: + if v not in self.vertex: + return False + if self.halfedge[u][v] is None and self.halfedge[v][u] is None: + return False + fkey = self.halfedge[u][v] + if fkey is not None: + if fkey not in self.face: + return False + + for fkey in self.faces(): + for u, v in self.face_halfedges(fkey): + if u not in self.vertex: + return False + if v not in self.vertex: + return False + if u not in self.halfedge: + return False + if v not in self.halfedge[u]: + return False + if fkey != self.halfedge[u][v]: + return False + return True + + def is_regular(self): + """Verify that the mesh is regular. + + A mesh is regular if the following conditions are fulfilled: + + * All faces have the same number of edges. + * All vertices have the same degree, i.e. they are incident to the same number of edges. + + Returns + ------- + bool + True, if the mesh is regular. + False, otherwise. + + See Also + -------- + :meth:`is_valid`, :meth:`is_manifold`, :meth:`is_orientable`, :meth:`is_empty`, :meth:`is_closed`, :meth:`is_trimesh`, :meth:`is_quadmesh` + + """ + if not self.vertex or not self.face: + return False + + vkey = self.vertex_sample(size=1)[0] + degree = self.vertex_degree(vkey) + + for vkey in self.vertices(): + if self.vertex_degree(vkey) != degree: + return False + + fkey = self.face_sample(size=1)[0] + vcount = len(self.face_vertices(fkey)) + + for fkey in self.faces(): + vertices = self.face_vertices(fkey) + if len(vertices) != vcount: + return False + + return True + + def is_manifold(self): + """Verify that the mesh is manifold. + + A mesh is manifold if the following conditions are fulfilled: + + * Each edge is incident to only one or two faces. + * The faces incident to a vertex form a closed or an open fan. + + Returns + ------- + bool + True, if the mesh is manifold. + False, otherwise. + + See Also + -------- + :meth:`is_valid`, :meth:`is_regular`, :meth:`is_orientable`, :meth:`is_empty`, :meth:`is_closed`, :meth:`is_trimesh`, :meth:`is_quadmesh` + + """ + if not self.vertex: + return False + + for key in self.vertices(): + if list(self.halfedge[key].values()).count(None) > 1: + return False + + nbrs = self.vertex_neighbors(key, ordered=True) + + if not nbrs: + return False + + if self.halfedge[nbrs[0]][key] is None: + for nbr in nbrs[1:-1]: + if self.halfedge[key][nbr] is None: + return False + + if self.halfedge[key][nbrs[-1]] is not None: + return False + else: + for nbr in nbrs[1:]: + if self.halfedge[key][nbr] is None: + return False + + return True + + def is_orientable(self): + """Verify that the mesh is orientable. + + A manifold mesh is orientable if any two adjacent faces have compatible orientation, + i.e. the faces have a unified cycle direction. + + Returns + ------- + bool + True, if the mesh is orientable. + False, otherwise. + + See Also + -------- + :meth:`is_valid`, :meth:`is_regular`, :meth:`is_manifold`, :meth:`is_empty`, :meth:`is_closed`, :meth:`is_trimesh`, :meth:`is_quadmesh` + + """ + raise NotImplementedError + + def is_trimesh(self): + """Verify that the mesh consists of only triangles. + + Returns + ------- + bool + True, if the mesh is a triangle mesh. + False, otherwise. + + See Also + -------- + :meth:`is_valid`, :meth:`is_regular`, :meth:`is_manifold`, :meth:`is_orientable`, :meth:`is_empty`, :meth:`is_closed`, :meth:`is_quadmesh` + + """ + if not self.face: + return False + return not any(3 != len(self.face_vertices(fkey)) for fkey in self.faces()) + + def is_quadmesh(self): + """Verify that the mesh consists of only quads. + + Returns + ------- + bool + True, if the mesh is a quad mesh. + False, otherwise. + + See Also + -------- + :meth:`is_valid`, :meth:`is_regular`, :meth:`is_manifold`, :meth:`is_orientable`, :meth:`is_empty`, :meth:`is_closed`, :meth:`is_trimesh` + + """ + if not self.face: + return False + return not any(4 != len(self.face_vertices(fkey)) for fkey in self.faces()) + + def is_empty(self): + """Verify that the mesh is empty. + + Returns + ------- + bool + True if the mesh has no vertices. + False otherwise. + + See Also + -------- + :meth:`is_valid`, :meth:`is_regular`, :meth:`is_manifold`, :meth:`is_orientable`, :meth:`is_closed`, :meth:`is_trimesh`, :meth:`is_quadmesh` + + """ + if self.number_of_vertices() == 0: + return True + return False + + def is_closed(self): + """Verify that the mesh is closed. + + Returns + ------- + bool + True if the mesh is not empty and has no naked edges. + False otherwise. + + See Also + -------- + :meth:`is_valid`, :meth:`is_regular`, :meth:`is_manifold`, :meth:`is_orientable`, :meth:`is_empty`, :meth:`is_trimesh`, :meth:`is_quadmesh` + + """ + if self.is_empty(): + return False + for edge in self.edges(): + if self.is_edge_on_boundary(edge): + return False + return True + + def is_connected(self): + """Verify that the mesh is connected. + + Returns + ------- + bool + True if the mesh is not empty and has no naked edges. + False otherwise. + + See Also + -------- + :meth:`is_valid`, :meth:`is_regular`, :meth:`is_manifold`, :meth:`is_orientable`, :meth:`is_empty`, :meth:`is_trimesh`, :meth:`is_quadmesh` + + """ + if not self.vertex: + return False + nodes = breadth_first_traverse(self.adjacency, self.vertex_sample(size=1)[0]) + return len(nodes) == self.number_of_vertices() + + def euler(self): + """Calculate the Euler characteristic. + + Returns + ------- + int + The Euler characteristic. + + See Also + -------- + :meth:`genus` + + """ + V = len([vkey for vkey in self.vertices() if len(self.vertex_neighbors(vkey)) != 0]) + E = self.number_of_edges() + F = self.number_of_faces() + return V - E + F + + # -------------------------------------------------------------------------- + # Cleanup + # -------------------------------------------------------------------------- + + def weld(self, precision=None): + """Weld vertices that are closer than a given precision. + + Parameters + ---------- + precision : int, optional + The precision of the geometric map that is used to connect the lines. + Defaults to the value of :attr:`compas.PRECISION`. + + Returns + ------- + None + The mesh is modified in place. + + """ + self.remove_duplicate_vertices(precision=precision) + + def remove_duplicate_vertices(self, precision=None): + """Remove all duplicate vertices and clean up any affected faces. + + Parameters + ---------- + precision : int, optional + Precision for converting numbers to strings. + Default is :attr:`TOL.precision`. + + Returns + ------- + None + The mesh is modified in-place. + + Examples + -------- + >>> import compas + >>> from compas.datastructures import Mesh + >>> mesh = Mesh.from_obj(compas.get('faces.obj')) >>> mesh.number_of_vertices() 36 >>> for x, y, z in mesh.vertices_attributes('xyz', keys=list(mesh.vertices())[:5]): @@ -904,137 +2874,1009 @@ def remove_duplicate_vertices(self, precision=None): 36 """ - vertex_gkey = {} - for vertex in self.vertices(): - gkey = TOL.geometric_key(self.vertex_coordinates(vertex), precision=precision) - vertex_gkey[vertex] = gkey + vertex_gkey = {} + for vertex in self.vertices(): + gkey = TOL.geometric_key(self.vertex_coordinates(vertex), precision=precision) + vertex_gkey[vertex] = gkey + + gkey_vertex = {gkey: vertex for vertex, gkey in iter(vertex_gkey.items())} + + for boundary in self.vertices_on_boundaries(): + for vertex in boundary: + gkey = TOL.geometric_key(self.vertex_coordinates(vertex), precision=precision) + gkey_vertex[gkey] = vertex + + for vertex in list(self.vertices()): + test = gkey_vertex[vertex_gkey[vertex]] + if test != vertex: + del self.vertex[vertex] + del self.halfedge[vertex] + for u in self.halfedge: + nbrs = list(self.halfedge[u].keys()) + for v in nbrs: + if v == vertex: + del self.halfedge[u][v] + + for face in self.faces(): + seen = set() + vertices = [] + for vertex in [gkey_vertex[vertex_gkey[vertex]] for vertex in self.face_vertices(face)]: + if vertex not in seen: + seen.add(vertex) + vertices.append(vertex) + self.face[face] = vertices + for u, v in self.face_halfedges(face): + self.halfedge[u][v] = face + if u not in self.halfedge[v]: + self.halfedge[v][u] = None + + # only reason this is here is because of the potential angles check + def quads_to_triangles(self, check_angles=False): + """Convert all quadrilateral faces to triangles by adding a diagonal edge. + + Parameters + ---------- + check_angles : bool, optional + Flag indicating that the angles of the quads should be checked to choose the best diagonal. + + Returns + ------- + None + The mesh is modified in place. + + """ + for face in list(self.faces()): + attr = self.face_attributes(face) + vertices = self.face_vertices(face) + if len(vertices) == 4: + a, b, c, d = vertices + t1, t2 = self.split_face(face, b, d) + self.face_attributes(t1, names=attr.keys(), values=attr.values()) # type: ignore + self.face_attributes(t2, names=attr.keys(), values=attr.values()) # type: ignore + # self.facedata[t1] = attr.copy() + # self.facedata[t2] = attr.copy() + if face in self.facedata: + del self.facedata[face] + + # only reason this is here and not on the halfedge is because of the spatial tree + def unify_cycles(self, root=None): + """Unify the cycles of the mesh. + + Returns + ------- + None + The mesh is modified in place. + + """ + + def unify(node, nbr): + # find the common edge + for u, v in self.face_halfedges(nbr): + if u in self.face[node] and v in self.face[node]: + # node and nbr have edge u-v in common + i = self.face[node].index(u) + j = self.face[node].index(v) + if i == j - 1 or (j == 0 and u == self.face[node][-1]): + # if the traversal of a neighboring halfedge + # is in the same direction + # flip the neighbor + self.face[nbr][:] = self.face[nbr][::-1] + return + + if root is None: + root = self.face_sample(size=1)[0] + + index_face = {index: face for index, face in enumerate(self.faces())} + points = self.vertices_attributes("xyz") + faces = [self.face_vertices(face) for face in self.faces()] + + adj = face_adjacency(points, faces) + adjacency = {} + for face in adj: + adjacency[index_face[face]] = [index_face[nbr] for nbr in adj[face]] + + visited = breadth_first_traverse(adjacency, root, unify) + + if len(list(visited)) != self.number_of_faces(): + raise Exception("Not all faces were visited.") + + self.halfedge = {key: {} for key in self.vertices()} + for fkey in self.faces(): + for u, v in self.face_halfedges(fkey): + self.halfedge[u][v] = fkey + if u not in self.halfedge[v]: + self.halfedge[v][u] = None + + # -------------------------------------------------------------------------- + # Components + # -------------------------------------------------------------------------- + + def connected_vertices(self): + """Find groups of connected vertices. + + Returns + ------- + list[list[int]] + Groups of connected vertices. + + """ + return connected_components(self.adjacency) + + def connected_faces(self): + """Find groups of connected faces. + + Returns + ------- + list[list[int]] + Groups of connected faces. + + """ + # return connected_components(self.face_adjacency) + parts = self.connected_vertices() + return [set([face for vertex in part for face in self.vertex_faces(vertex)]) for part in parts] + + # -------------------------------------------------------------------------- + # Vertex topology + # -------------------------------------------------------------------------- + + def has_vertex(self, key): + """Verify that a vertex is in the mesh. + + Parameters + ---------- + key : int + The identifier of the vertex. + + Returns + ------- + bool + True if the vertex is in the mesh. + False otherwise. + + """ + return key in self.vertex + + def is_vertex_connected(self, key): + """Verify that a vertex is connected. + + Parameters + ---------- + key : int + The identifier of the vertex. + + Returns + ------- + bool + True if the vertex is connected to at least one other vertex. + False otherwise. + + """ + return self.vertex_degree(key) > 0 + + def is_vertex_on_boundary(self, key): + """Verify that a vertex is on a boundary. + + Parameters + ---------- + key : int + The identifier of the vertex. + + Returns + ------- + bool + True if the vertex is on the boundary. + False otherwise. + + """ + for nbr in self.halfedge[key]: + if self.halfedge[key][nbr] is None: + return True + return False + + def vertex_neighbors(self, key, ordered=False): + """Return the neighbors of a vertex. + + Parameters + ---------- + key : int + The identifier of the vertex. + ordered : bool, optional + If True, return the neighbors in the cycling order of the faces. + + Returns + ------- + list[int] + The list of neighboring vertices. + If the vertex lies on the boundary of the mesh, + an ordered list always starts and ends with with boundary vertices. + + Notes + ----- + Due to the nature of the ordering algorithm, the neighbors cycle around + the node in the opposite direction as the cycling direction of the faces. + For some algorithms this produces the expected results. For others it doesn't. + For example, a dual mesh constructed relying on these conventions will have + oposite face cycle directions compared to the original. + + """ + temp = list(self.halfedge[key]) + if not ordered: + return temp + if not temp: + return temp + if len(temp) == 1: + return temp + # if one of the neighbors points to the *outside* face + # start there + # otherwise the starting point can be random + start = temp[0] + for nbr in temp: + if self.halfedge[key][nbr] is None: + start = nbr + break + # start in the opposite direction + # to avoid pointing at an *outside* face again + fkey = self.halfedge[start][key] + nbrs = [start] + count = 1000 + while count: + count -= 1 + nbr = self.face_vertex_descendant(fkey, key) + fkey = self.halfedge[nbr][key] + if nbr == start: + break + nbrs.append(nbr) + if fkey is None: + break + return nbrs + + def vertex_neighborhood(self, key, ring=1): + """Return the vertices in the neighborhood of a vertex. + + Parameters + ---------- + key : int + The identifier of the vertex. + ring : int, optional + The number of neighborhood rings to include. + + Returns + ------- + list[int] + The vertices in the neighborhood. + + Notes + ----- + The vertices in the neighborhood are unordered. + + """ + nbrs = set(self.vertex_neighbors(key)) + i = 1 + while True: + if i == ring: + break + temp = [] + for key in nbrs: + temp += self.vertex_neighbors(key) + nbrs.update(temp) + i += 1 + return nbrs + + def vertex_degree(self, key): + """Count the neighbors of a vertex. + + Parameters + ---------- + key : int + The identifier of the vertex. + + Returns + ------- + int + The degree of the vertex. + + """ + return len(self.vertex_neighbors(key)) + + def vertex_min_degree(self): + """Compute the minimum degree of all vertices. + + Returns + ------- + int + The lowest degree of all vertices. + + """ + if not self.vertex: + return 0 + return min(self.vertex_degree(key) for key in self.vertices()) + + def vertex_max_degree(self): + """Compute the maximum degree of all vertices. + + Returns + ------- + int + The highest degree of all vertices. + + """ + if not self.vertex: + return 0 + return max(self.vertex_degree(key) for key in self.vertices()) + + def vertex_faces(self, key, ordered=False, include_none=False): + """The faces connected to a vertex. + + Parameters + ---------- + key : int + The identifier of the vertex. + ordered : bool, optional + If True, return the faces in cycling order. + include_none : bool, optional + If True, include *outside* faces in the list. + + Returns + ------- + list[int] + The faces connected to a vertex. + + """ + if not ordered: + faces = list(self.halfedge[key].values()) + else: + nbrs = self.vertex_neighbors(key, ordered=True) + faces = [self.halfedge[key][n] for n in nbrs] + if include_none: + return faces + return [fkey for fkey in faces if fkey is not None] + + # -------------------------------------------------------------------------- + # Edge topology + # -------------------------------------------------------------------------- + + def has_edge(self, key): + """Verify that the mesh contains a specific edge. + + Warnings + -------- + This method may produce unexpected results. + + Parameters + ---------- + key : tuple[int, int] + The identifier of the edge. + + Returns + ------- + bool + True if the edge exists. + False otherwise. + + """ + return key in set(self.edges()) + + def has_halfedge(self, key): + """Verify that a halfedge is part of the mesh. + + Parameters + ---------- + key : tuple[int, int] + The identifier of the halfedge. + + Returns + ------- + bool + True if the halfedge is part of the mesh. + False otherwise. + + """ + u, v = key + return u in self.halfedge and v in self.halfedge[u] + + def edge_faces(self, edge): + """Find the two faces adjacent to an edge. + + Parameters + ---------- + edge : tuple[int, int] + The identifier of the edge. + + Returns + ------- + tuple[int, int] + The identifiers of the adjacent faces. + If the edge is on the boundary, one of the identifiers is None. + + """ + u, v = edge + return self.halfedge[u][v], self.halfedge[v][u] + + def halfedge_face(self, edge): + """Find the face corresponding to a halfedge. + + Parameters + ---------- + edge : tuple[int, int] + The identifier of the halfedge. + + Returns + ------- + int | None + The identifier of the face corresponding to the halfedge. + None, if the halfedge is on the outside of a boundary. + + Raises + ------ + KeyError + If the halfedge does not exist. + + """ + u, v = edge + return self.halfedge[u][v] + + def is_edge_on_boundary(self, edge): + """Verify that an edge is on the boundary. + + Parameters + ---------- + edge : tuple[int, int] + The identifier of the edge. + + Returns + ------- + bool + True if the edge is on the boundary. + False otherwise. + + """ + u, v = edge + return self.halfedge[v][u] is None or self.halfedge[u][v] is None + + # -------------------------------------------------------------------------- + # Polyedge topology + # -------------------------------------------------------------------------- + + def edge_loop(self, edge): + """Find all edges on the same loop as a given edge. + + Parameters + ---------- + edge : tuple[int, int] + The identifier of the starting edge. + + Returns + ------- + list[tuple[int, int]] + The edges on the same loop as the given edge. + + """ + u, v = edge + uv_loop = self.halfedge_loop((u, v)) + if uv_loop[0][0] == uv_loop[-1][1]: + return uv_loop + vu_loop = self.halfedge_loop((v, u)) + vu_loop[:] = [(u, v) for v, u in vu_loop[::-1]] + return vu_loop + uv_loop[1:] + + def halfedge_loop(self, edge): + """Find all edges on the same loop as the halfedge, in the direction of the halfedge. + + Parameters + ---------- + edge : tuple[int, int] + The identifier of the starting edge. + + Returns + ------- + list[tuple[int, int]] + The edges on the same loop as the given edge. + + """ + if self.is_edge_on_boundary(edge): + return self._halfedge_loop_on_boundary(edge) + edges = [edge] + u, v = edge + while True: + nbrs = self.vertex_neighbors(v, ordered=True) + if len(nbrs) != 4: + break + i = nbrs.index(u) + u = v + v = nbrs[i - 2] + edges.append((u, v)) + if v == edges[0][0]: + break + return edges + + def _halfedge_loop_on_boundary(self, edge): + """Find all edges on the same loop as the halfedge, in the direction of the halfedge, if the halfedge is on the boundary. + + Parameters + ---------- + edge : tuple[int, int] + The identifier of the starting edge. + + Returns + ------- + list[tuple[int, int]] + The edges on the same loop as the given edge. + + """ + edges = [edge] + u, v = edge + while True: + nbrs = self.vertex_neighbors(v) + if len(nbrs) == 2: + break + nbr = None + for temp in nbrs: + if temp == u: + continue + if self.is_edge_on_boundary((v, temp)): + nbr = temp + break + if nbr is None: + break + u, v = v, nbr + edges.append((u, v)) + if v == edges[0][0]: + break + return edges + + def edge_strip(self, edge, return_faces=False): + """Find all edges on the same strip as a given edge. + + Parameters + ---------- + edge : tuple[int, int] + The identifier of the starting edge. + return_faces : bool, optional + Return the faces on the strip in addition to the edges. + + Returns + ------- + list[tuple[int, int]] | tuple[list[tuple[int, int]], list[int]] + If `return_faces` is False, the edges on the same strip as the given edge. + If `return_faces` is False, the edges on the same strip and the corresponding faces. + + """ + u, v = edge + if self.halfedge[v][u] is None: + strip = self.halfedge_strip((u, v)) + elif self.halfedge[u][v] is None: + edges = self.halfedge_strip((v, u)) + strip = [(u, v) for v, u in edges[::-1]] + else: + vu_strip = self.halfedge_strip((v, u)) + vu_strip[:] = [(u, v) for v, u in vu_strip[::-1]] + if vu_strip[0] == vu_strip[-1]: + strip = vu_strip + else: + uv_strip = self.halfedge_strip((u, v)) + strip = vu_strip[:-1] + uv_strip + if not return_faces: + return strip + faces = [self.halfedge_face(edge) for edge in strip[:-1]] + return strip, faces + + def halfedge_strip(self, edge): + """Find all edges on the same strip as a given halfedge. + + Parameters + ---------- + edge : tuple[int, int] + The identifier of the starting edge. + + Returns + ------- + list[tuple[int, int]] + The edges on the same strip as the given halfedge. + + """ + u, v = edge + edges = [edge] + while True: + face = self.halfedge[u][v] + if face is None: + break + vertices = self.face_vertices(face) + if len(vertices) != 4: + break + i = vertices.index(u) + u = vertices[i - 1] + v = vertices[i - 2] + edges.append((u, v)) + if (u, v) == edge: + break + return edges + + # -------------------------------------------------------------------------- + # Face topology + # -------------------------------------------------------------------------- + + def has_face(self, fkey): + """Verify that a face is part of the mesh. + + Parameters + ---------- + fkey : int + The identifier of the face. + + Returns + ------- + bool + True if the face exists. + False otherwise. + + """ + return fkey in self.face + + def face_vertices(self, fkey): + """The vertices of a face. + + Parameters + ---------- + fkey : int + Identifier of the face. + + Returns + ------- + list[int] + Ordered vertex identifiers. + + """ + return self.face[fkey] + + def face_halfedges(self, fkey): + """The halfedges of a face. + + Parameters + ---------- + fkey : int + Identifier of the face. + + Returns + ------- + list[tuple[int, int]] + The halfedges of a face. + + """ + vertices = self.face_vertices(fkey) + return list(pairwise(vertices + vertices[0:1])) + + def face_corners(self, fkey): + """Return triplets of face vertices forming the corners of the face. + + Parameters + ---------- + fkey : int + Identifier of the face. + + Returns + ------- + list[int] + The corners of the face in the form of a list of vertex triplets. + + """ + vertices = self.face_vertices(fkey) + return list(window(vertices + vertices[0:2], 3)) + + def face_neighbors(self, fkey): + """Return the neighbors of a face across its edges. + + Parameters + ---------- + fkey : int + Identifier of the face. + + Returns + ------- + list[int] + The identifiers of the neighboring faces. + + """ + nbrs = [] + for u, v in self.face_halfedges(fkey): + nbr = self.halfedge[v][u] + if nbr is not None: + nbrs.append(nbr) + return nbrs + + def face_neighborhood(self, key, ring=1): + """Return the faces in the neighborhood of a face. + + Parameters + ---------- + key : int + The identifier of the face. + ring : int, optional + The size of the neighborhood. + + Returns + ------- + list[int] + A list of face identifiers. + + """ + nbrs = set(self.face_neighbors(key)) + i = 1 + while True: + if i == ring: + break + temp = [] + for key in nbrs: + temp += self.face_neighbors(key) + nbrs.update(temp) + i += 1 + return list(nbrs) + + def face_degree(self, fkey): + """Count the neighbors of a face. + + Parameters + ---------- + fkey : int + Identifier of the face. + + Returns + ------- + int + The count. + + """ + return len(self.face_neighbors(fkey)) + + def face_min_degree(self): + """Compute the minimum degree of all faces. + + Returns + ------- + int + The lowest degree. + + """ + if not self.face: + return 0 + return min(self.face_degree(fkey) for fkey in self.faces()) + + def face_max_degree(self): + """Compute the maximum degree of all faces. + + Returns + ------- + int + The highest degree. + + """ + if not self.face: + return 0 + return max(self.face_degree(fkey) for fkey in self.faces()) + + def face_vertex_ancestor(self, fkey, key, n=1): + """Return the n-th vertex before the specified vertex in a specific face. + + Parameters + ---------- + fkey : int + Identifier of the face. + key : int + The identifier of the vertex. + n : int, optional + The index of the vertex ancestor. + Default is 1, meaning the previous vertex. + + Returns + ------- + int + The identifier of the vertex before the given vertex in the face cycle. + + Raises + ------ + ValueError + If the vertex is not part of the face. + + """ + i = self.face[fkey].index(key) + return self.face[fkey][(i - n) % len(self.face[fkey])] + + def face_vertex_descendant(self, fkey, key, n=1): + """Return the n-th vertex after the specified vertex in a specific face. + + Parameters + ---------- + fkey : int + Identifier of the face. + key : int + The identifier of the vertex. + n : int, optional + The index of the vertex descendant. + Default is 1, meaning the next vertex. + + Returns + ------- + int + The identifier of the vertex after the given vertex in the face cycle. + + Raises + ------ + ValueError + If the vertex is not part of the face. + + """ + i = self.face[fkey].index(key) + return self.face[fkey][(i + n) % len(self.face[fkey])] + + def face_adjacency_halfedge(self, f1, f2): + """Find one half-edge over which two faces are adjacent. + + Parameters + ---------- + f1 : int + The identifier of the first face. + f2 : int + The identifier of the second face. + + Returns + ------- + tuple[int, int] | None + The half-edge separating face 1 from face 2, + or None, if the faces are not adjacent. + + Notes + ----- + For use in form-finding algorithms, that rely on form-force duality information, + further checks relating to the orientation of the corresponding are required. + + """ + for u, v in self.face_halfedges(f1): + if self.halfedge[v][u] == f2: + return u, v - gkey_vertex = {gkey: vertex for vertex, gkey in iter(vertex_gkey.items())} + def face_adjacency_vertices(self, f1, f2): + """Find all vertices over which two faces are adjacent. - for boundary in self.vertices_on_boundaries(): - for vertex in boundary: - gkey = TOL.geometric_key(self.vertex_coordinates(vertex), precision=precision) - gkey_vertex[gkey] = vertex + Parameters + ---------- + f1 : int + The identifier of the first face. + f2 : int + The identifier of the second face. - for vertex in list(self.vertices()): - test = gkey_vertex[vertex_gkey[vertex]] - if test != vertex: - del self.vertex[vertex] - del self.halfedge[vertex] - for u in self.halfedge: - nbrs = list(self.halfedge[u].keys()) - for v in nbrs: - if v == vertex: - del self.halfedge[u][v] + Returns + ------- + list[int] | None + The vertices separating face 1 from face 2, + or None, if the faces are not adjacent. - for face in self.faces(): - seen = set() - vertices = [] - for vertex in [gkey_vertex[vertex_gkey[vertex]] for vertex in self.face_vertices(face)]: - if vertex not in seen: - seen.add(vertex) - vertices.append(vertex) - self.face[face] = vertices - for u, v in self.face_halfedges(face): - self.halfedge[u][v] = face - if u not in self.halfedge[v]: - self.halfedge[v][u] = None + """ + return [vkey for vkey in self.face_vertices(f1) if vkey in self.face_vertices(f2)] - # only reason this is here is because of the potential angles check - def quads_to_triangles(self, check_angles=False): - """Convert all quadrilateral faces to triangles by adding a diagonal edge. + def is_face_on_boundary(self, key): + """Verify that a face is on a boundary. Parameters ---------- - check_angles : bool, optional - Flag indicating that the angles of the quads should be checked to choose the best diagonal. + key : int + The identifier of the face. Returns ------- - None - The mesh is modified in place. + bool + True if the face is on the boundary. + False otherwise. """ - for face in list(self.faces()): - attr = self.face_attributes(face) - vertices = self.face_vertices(face) - if len(vertices) == 4: - a, b, c, d = vertices - t1, t2 = self.split_face(face, b, d) - self.face_attributes(t1, names=attr.keys(), values=attr.values()) # type: ignore - self.face_attributes(t2, names=attr.keys(), values=attr.values()) # type: ignore - # self.facedata[t1] = attr.copy() - # self.facedata[t2] = attr.copy() - if face in self.facedata: - del self.facedata[face] + a = [self.halfedge[v][u] for u, v in self.face_halfedges(key)] + if None in a: + return True + else: + return False - # only reason this is here and not on the halfedge is because of the spatial tree - def unify_cycles(self, root=None): - """Unify the cycles of the mesh. + face_vertex_after = face_vertex_descendant + face_vertex_before = face_vertex_ancestor + + def halfedge_after(self, edge): + """Find the halfedge after the given halfedge in the same face. + + Parameters + ---------- + edge : tuple[int, int] + The identifier of the starting halfedge. Returns ------- - None - The mesh is modified in place. + tuple[int, int] + The next halfedge. """ + u, v = edge + face = self.halfedge_face(edge) + if face is not None: + w = self.face_vertex_after(face, v) + return v, w + nbrs = self.vertex_neighbors(v, ordered=True) + w = nbrs[0] + return v, w + + def halfedge_before(self, edge): + """Find the halfedge before the given halfedge in the same face. - def unify(node, nbr): - # find the common edge - for u, v in self.face_halfedges(nbr): - if u in self.face[node] and v in self.face[node]: - # node and nbr have edge u-v in common - i = self.face[node].index(u) - j = self.face[node].index(v) - if i == j - 1 or (j == 0 and u == self.face[node][-1]): - # if the traversal of a neighboring halfedge - # is in the same direction - # flip the neighbor - self.face[nbr][:] = self.face[nbr][::-1] - return + Parameters + ---------- + edge : tuple[int, int] + The identifier of the starting halfedge. - if root is None: - root = self.face_sample(size=1)[0] + Returns + ------- + tuple[int, int] + The previous halfedge. - index_face = {index: face for index, face in enumerate(self.faces())} - points = self.vertices_attributes("xyz") - faces = [self.face_vertices(face) for face in self.faces()] + """ + u, v = edge + face = self.halfedge_face(edge) + if face is not None: + t = self.face_vertex_before(face, u) + return t, u + nbrs = self.vertex_neighbors(u, ordered=True) + t = nbrs[-1] + return t, u + + def vertex_edges(self, vertex): + """Find all edges connected to a given vertex. - adj = face_adjacency(points, faces) - adjacency = {} - for face in adj: - adjacency[index_face[face]] = [index_face[nbr] for nbr in adj[face]] + Parameters + ---------- + vertex : int - visited = breadth_first_traverse(adjacency, root, unify) + Returns + ------- + list[tuple[int, int]] - if len(list(visited)) != self.number_of_faces(): - raise Exception("Not all faces were visited.") + """ + edges = [] + for nbr in self.vertex_neighbors(vertex): + if self.has_edge((vertex, nbr)): + edges.append((vertex, nbr)) + else: + edges.append((nbr, vertex)) + return edges - self.halfedge = {key: {} for key in self.vertices()} - for fkey in self.faces(): - for u, v in self.face_halfedges(fkey): - self.halfedge[u][v] = fkey - if u not in self.halfedge[v]: - self.halfedge[v][u] = None + def halfedge_loop_vertices(self, edge): + """Find all vertices on the same loop as a given halfedge. - # -------------------------------------------------------------------------- - # vertex topology - # -------------------------------------------------------------------------- + Parameters + ---------- + edge : tuple[int, int] + The identifier of the starting halfedge. + Returns + ------- + list[int] + The vertices on the same loop as the given halfedge. - # -------------------------------------------------------------------------- - # edge topology - # -------------------------------------------------------------------------- + """ + loop = self.halfedge_loop(edge) + return [loop[0][0]] + [edge[1] for edge in loop] - # -------------------------------------------------------------------------- - # polyedge topology - # -------------------------------------------------------------------------- + def halfedge_strip_faces(self, edge): + """Find all faces on the same strip as a given halfedge. - # -------------------------------------------------------------------------- - # face topology - # -------------------------------------------------------------------------- + Parameters + ---------- + edge : tuple[int, int] + The identifier of the starting halfedge. + + Returns + ------- + list[int] + The faces on the same strip as the given halfedge. + + """ + strip = self.halfedge_strip(edge) + return [self.halfedge_face(edge) for edge in strip] # -------------------------------------------------------------------------- - # mesh geometry + # Mesh geometry # -------------------------------------------------------------------------- def area(self): @@ -1101,7 +3943,7 @@ def obb(self): return oriented_bounding_box(xyz) # -------------------------------------------------------------------------- - # vertex geometry + # Vertex geometry # -------------------------------------------------------------------------- def vertex_coordinates(self, key, axes="xyz"): @@ -1288,7 +4130,7 @@ def vertex_curvature(self, vkey): return 2 * pi - C # -------------------------------------------------------------------------- - # edge geometry + # Edge geometry # -------------------------------------------------------------------------- def edge_coordinates(self, edge, axes="xyz"): @@ -1450,7 +4292,7 @@ def edge_line(self, edge): return Line(*self.edge_coordinates(edge)) # -------------------------------------------------------------------------- - # face geometry + # Face geometry # -------------------------------------------------------------------------- def face_coordinates(self, fkey, axes="xyz"): @@ -1737,28 +4579,191 @@ def face_frame(self, face): return Frame(point, xaxis, yaxis) # -------------------------------------------------------------------------- - # boundary + # Boundaries # -------------------------------------------------------------------------- + def vertices_on_boundary(self): + """Find the vertices on the longest boundary. + + Returns + ------- + list[int] + The vertices of the longest boundary. + + """ + boundaries = self.vertices_on_boundaries() + return boundaries[0] if boundaries else [] + + def edges_on_boundary(self): + """Find the edges on the longest boundary. + + Returns + ------- + list[tuple[int, int]] + The edges of the longest boundary. + + """ + boundaries = self.edges_on_boundaries() + return boundaries[0] if boundaries else [] + + def faces_on_boundary(self): + """Find the faces on the longest boundary. + + Returns + ------- + list[int] + The faces on the longest boundary. + + """ + boundaries = self.faces_on_boundaries() + return boundaries[0] if boundaries else [] + def vertices_on_boundaries(self): - """Find the vertices on the boundaries of the mesh. + """Find the vertices on all boundaries of the mesh. Returns ------- list[list[int]] A list of vertex keys per boundary. - The longest boundary is returned first. + The boundary with the most vertices is returned first. """ + # all boundary vertices + vertices_set = set() + for key, nbrs in iter(self.halfedge.items()): + for nbr, face in iter(nbrs.items()): + if face is None: + vertices_set.add(key) + vertices_set.add(nbr) + vertices_all = list(vertices_set) + + # return an empty list if there are no boundary vertices + if not vertices_all: + return [] + + # init container for boundary groups + boundaries = [] + + # identify *special* vertices + # these vertices are non-manifold + # and should be processed differently + special = [] + for key in vertices_all: + count = 0 + for nbr in self.vertex_neighbors(key): + face = self.halfedge_face((key, nbr)) + if face is None: + count += 1 + if count > 1: + if key not in special: + special.append(key) + + superspecial = special[:] + + # process the special vertices first + while special: + start = special.pop() + nbrs = [] + # find all neighbors of the current special vertex + # that are on the mesh boundary + for nbr in self.vertex_neighbors(start): + face = self.halfedge_face((start, nbr)) + if face is None: + nbrs.append(nbr) + # for normal mesh vertices + # there should be only 1 boundary neighbor + # for special vertices there are more and they all have to be processed + while nbrs: + vertex = nbrs.pop() + vertices = [start, vertex] + while True: + # this is a *super* special case + if vertex in superspecial: + boundaries.append(vertices) + break + # find the boundary loop for the current starting halfedge + for nbr in self.vertex_neighbors(vertex): + if nbr == vertices[-2]: + continue + face = self.halfedge_face((vertex, nbr)) + if face is None: + vertices.append(nbr) + vertex = nbr + break + if vertex == start: + boundaries.append(vertices) + break + # remove any neighbors that might be part of an already identified boundary + nbrs = [vertex for vertex in nbrs if vertex not in vertices] + + # remove all boundary vertices that were already identified + vertices_all = [vertex for vertex in vertices_all if all(vertex not in vertices for vertices in boundaries)] + + # process the remaining boundary vertices if any + if vertices_all: + key = vertices_all[0] + while vertices_all: + vertices = [key] + start = key + while True: + for nbr in self.vertex_neighbors(key): + face = self.halfedge_face((key, nbr)) + if face is None: + vertices.append(nbr) + key = nbr + break + if key == start: + boundaries.append(vertices) + vertices_all = [x for x in vertices_all if x not in vertices] + break + if vertices_all: + key = vertices_all[0] def length(boundary): return sum(self.edge_length(edge) for edge in pairwise(boundary + boundary[:1])) # type: ignore - boundaries = super(Mesh, self).vertices_on_boundaries() return sorted(boundaries, key=length, reverse=True) + def edges_on_boundaries(self): + """Find the edges on all boundaries of the mesh. + + Returns + ------- + list[list[tuple[int, int]]] + A list of edges per boundary. + + """ + vertexgroups = self.vertices_on_boundaries() + edgegroups = [] + for vertices in vertexgroups: + edgegroups.append(list(pairwise(vertices))) + return edgegroups + + def faces_on_boundaries(self): + """Find the faces on all boundaries of the mesh. + + Returns + ------- + list[list[int]] + lists of faces, grouped and sorted per boundary. + + """ + vertexgroups = self.vertices_on_boundaries() + facegroups = [] + for vertices in vertexgroups: + temp = [self.halfedge_face((v, u)) for u, v in pairwise(vertices)] + faces = [] + for face in temp: + if face is None: + continue + if face not in faces and all(face not in group for group in facegroups): + faces.append(face) + if faces: + facegroups.append(faces) + return facegroups + # -------------------------------------------------------------------------- - # transformations + # Transformations # -------------------------------------------------------------------------- def transform(self, T): @@ -1816,7 +4821,182 @@ def transform_numpy(self, T): self.vertex_attributes(vertex, "xyz", point) # -------------------------------------------------------------------------- - # other methods + # Matrices + # -------------------------------------------------------------------------- + + def adjacency_matrix(self, rtype="array"): + """Compute the adjacency matrix of the mesh. + + Parameters + ---------- + rtype : Literal['array', 'csc', 'csr', 'coo', 'list'], optional + Format of the result. + + Returns + ------- + array-like + The adjacency matrix. + + """ + from compas.topology import adjacency_matrix + + vertex_index = self.vertex_index() + adjacency = [[vertex_index[nbr] for nbr in self.vertex_neighbors(vertex)] for vertex in self.vertices()] + return adjacency_matrix(adjacency, rtype=rtype) + + def connectivity_matrix(self, rtype="array"): + """Compute the connectivity matrix of the mesh. + + Parameters + ---------- + rtype : Literal['array', 'csc', 'csr', 'coo', 'list'], optional + Format of the result. + + Returns + ------- + array-like + The connectivity matrix. + + """ + from compas.topology import connectivity_matrix + + vertex_index = self.vertex_index() + adjacency = [[vertex_index[nbr] for nbr in self.vertex_neighbors(vertex)] for vertex in self.vertices()] + return connectivity_matrix(adjacency, rtype=rtype) + + def degree_matrix(self, rtype="array"): + """Compute the degree matrix of the mesh. + + Parameters + ---------- + rtype : Literal['array', 'csc', 'csr', 'coo', 'list'], optional + Format of the result. + + Returns + ------- + array-like + The degree matrix. + + """ + from compas.topology import degree_matrix + + vertex_index = self.vertex_index() + adjacency = [[vertex_index[nbr] for nbr in self.vertex_neighbors(vertex)] for vertex in self.vertices()] + return degree_matrix(adjacency, rtype=rtype) + + def face_matrix(self, rtype="array"): + r"""Compute the face matrix of the mesh. + + Parameters + ---------- + rtype : Literal['array', 'csc', 'csr', 'coo', 'list'], optional + Format of the result. + + Returns + ------- + array-like + The face matrix. + + Notes + ----- + The face matrix represents the relationship between faces and vertices. + Each row of the matrix represents a face. Each column represents a vertex. + The matrix is filled with zeros except where a relationship between a vertex + and a face exist. + + .. math:: + + F_{ij} = + \begin{cases} + 1 & \text{if vertex j is part of face i} \\ + 0 & \text{otherwise} + \end{cases} + + The face matrix can for example be used to compute the centroids of all + faces of a mesh. + + Examples + -------- + >>> from compas.datastructures import Mesh + >>> mesh = Mesh.from_polyhedron(6) + >>> F = mesh.face_matrix() + >>> type(F) + + + >>> from numpy import allclose + >>> xyz = asarray(mesh.vertices_attributes('xyz')) + >>> F = mesh.face_matrix(mesh, rtype='csr') + >>> c1 = F.dot(xyz) / F.sum(axis=1) + >>> c2 = [mesh.face_centroid(fkey) for fkey in mesh.faces()] + >>> allclose(c1, c2) + True + + """ + from compas.topology import face_matrix + + vertex_index = self.vertex_index() + faces = [[vertex_index[vertex] for vertex in self.face_vertices(face)] for face in self.faces()] + return face_matrix(faces, rtype=rtype) + + def laplacian_matrix(self, rtype="array"): + r"""Compute the Laplacian matrix of the mesh. + + Parameters + ---------- + rtype : Literal['array', 'csc', 'csr', 'coo', 'list'], optional + Format of the result. + + Returns + ------- + array-like + The Laplacian matrix. + + Notes + ----- + The :math:`n \times n` uniform Laplacian matrix :math:`\mathbf{L}` of a mesh + with vertices :math:`\mathbf{V}` and edges :math:`\mathbf{E}` is defined as + follows [1]_ + + .. math:: + + \mathbf{L}_{ij} = + \begin{cases} + -1 & i = j \\ + \frac{1}{deg(i)} & (i, j) \in \mathbf{E} \\ + 0 & \text{otherwise} + \end{cases} + + with :math:`deg(i)` the degree of vertex :math:`i`. + + Therefore, the uniform Laplacian of a vertex :math:`\mathbf{v}_{i}` points to + the centroid of its neighboring vertices. + + References + ---------- + .. [1] Nealen A., Igarashi T., Sorkine O. and Alexa M. + `Laplacian Mesh Optimization `_. + + Examples + -------- + >>> from compas.datastructures import Mesh + >>> mesh = Mesh.from_polyhedron(6) + >>> L = mesh.laplacian_matrix(mesh, rtype='array') + >>> type(L) + + + >>> xyz = asarray(mesh.vertices_attributes('xyz')) + >>> L = mesh.laplacian_matrix(mesh) + >>> d = L.dot(xyz) + + """ + from compas.topology import laplacian_matrix + + vertex_index = self.vertex_index() + adjacency = [[vertex_index[nbr] for nbr in self.vertex_neighbors(vertex)] for vertex in self.vertices()] + return laplacian_matrix(adjacency, rtype=rtype) + + # -------------------------------------------------------------------------- + # Other methods # -------------------------------------------------------------------------- def offset(self, distance=1.0): diff --git a/tests/compas/data/test_dataschema.py b/tests/compas/data/test_dataschema.py index 34fe7285d22f..7a608d757c5b 100644 --- a/tests/compas/data/test_dataschema.py +++ b/tests/compas/data/test_dataschema.py @@ -21,7 +21,7 @@ from compas.geometry import Pointcloud from compas.datastructures import Graph -from compas.datastructures import HalfEdge +from compas.datastructures import Mesh if not compas.IPY: import jsonschema.exceptions @@ -668,7 +668,7 @@ def test_schema_graph_invalid(graph): Graph.validate_data(graph) @pytest.mark.parametrize( - "halfedge", + "mesh", [ { "attributes": {}, @@ -756,11 +756,11 @@ def test_schema_graph_invalid(graph): }, ], ) - def test_schema_halfedge_valid(halfedge): - HalfEdge.validate_data(halfedge) + def test_schema_mesh_valid(mesh): + Mesh.validate_data(mesh) @pytest.mark.parametrize( - "halfedge", + "mesh", [ { "attributes": {}, @@ -812,12 +812,12 @@ def test_schema_halfedge_valid(halfedge): }, ], ) - def test_schema_halfedge_invalid(halfedge): + def test_schema_mesh_invalid(mesh): with pytest.raises(jsonschema.exceptions.ValidationError): - HalfEdge.validate_data(halfedge) + Mesh.validate_data(mesh) @pytest.mark.parametrize( - "halfedge", + "mesh", [ { "attributes": {}, @@ -845,6 +845,6 @@ def test_schema_halfedge_invalid(halfedge): }, ], ) - def test_schema_halfedge_failing(halfedge): + def test_schema_mesh_failing(mesh): with pytest.raises(TypeError): - HalfEdge.validate_data(halfedge) + Mesh.validate_data(mesh) diff --git a/tests/compas/datastructures/test_halfedge.py b/tests/compas/datastructures/test_halfedge.py deleted file mode 100644 index 7dcebc57d021..000000000000 --- a/tests/compas/datastructures/test_halfedge.py +++ /dev/null @@ -1,479 +0,0 @@ -import pytest -import random -import json -import compas - -from compas.geometry import Sphere -from compas.geometry import Box - -from compas.datastructures import HalfEdge -from compas.datastructures import Mesh - - -# ============================================================================== -# Fixtures -# ============================================================================== - - -@pytest.fixture -def mesh(): - vertices = [None, None, None, None] - faces = [[0, 1, 2], [0, 3, 1]] - he = HalfEdge() - for vertex in vertices: - he.add_vertex() - for face in faces: - he.add_face(face) - return he - - -@pytest.fixture -def vertex_key(): - return 2 - - -@pytest.fixture -def face_key(): - return 1 - - -@pytest.fixture -def edge_key(): - return (0, 1) - - -@pytest.fixture -def sphere(): - sphere = Sphere(radius=1.0) - mesh = Mesh.from_shape(sphere, u=16, v=16) - return mesh - - -@pytest.fixture -def box(): - box = Box.from_corner_corner_height([0, 0, 0], [1, 1, 0], 1.0) - mesh = Mesh.from_shape(box) - return mesh - - -@pytest.fixture -def grid(): - mesh = Mesh.from_meshgrid(dx=10, nx=10) - return mesh - - -# ============================================================================== -# ??? -# ============================================================================== - - -def test_edgedata_nondirectionality(mesh): - mesh.update_default_edge_attributes({"index": 0}) - for index, (u, v) in enumerate(mesh.edges()): - mesh.edge_attribute((u, v), "index", index) - assert all(mesh.edge_attribute((u, v), "index") == mesh.edge_attribute((v, u), "index") for u, v in mesh.edges()) - - -def test_edgedata_io(mesh): - mesh.update_default_edge_attributes({"index": 0}) - for index, (u, v) in enumerate(mesh.edges()): - mesh.edge_attribute((u, v), "index", index) - other = HalfEdge.from_data(mesh.data) - assert all(other.edge_attribute(edge, "index") == index for index, edge in enumerate(other.edges())) - - -# ============================================================================== -# Basics -# ============================================================================== - -# ============================================================================== -# Data -# ============================================================================== - - -def test_halfedge_data(mesh): - other = HalfEdge.from_data(json.loads(json.dumps(mesh.data))) - - assert mesh.data == other.data - assert mesh.default_vertex_attributes == other.default_vertex_attributes - assert mesh.default_edge_attributes == other.default_edge_attributes - assert mesh.default_face_attributes == other.default_face_attributes - assert mesh.number_of_vertices() == other.number_of_vertices() - assert mesh.number_of_edges() == other.number_of_edges() - assert mesh.number_of_faces() == other.number_of_faces() - - if not compas.IPY: - assert HalfEdge.validate_data(mesh.data) - assert HalfEdge.validate_data(other.data) - - -# ============================================================================== -# Constructors -# ============================================================================== - -# ============================================================================== -# Properties -# ============================================================================== - -# ============================================================================== -# Accessors -# ============================================================================== - -# ============================================================================== -# Builders -# ============================================================================== - -# ============================================================================== -# Modifiers -# ============================================================================== - -# ============================================================================== -# Samples -# ============================================================================== - - -def test_vertex_sample(mesh): - for vertex in mesh.vertex_sample(): - assert mesh.has_vertex(vertex) - for vertex in mesh.vertex_sample(size=mesh.number_of_vertices()): - assert mesh.has_vertex(vertex) - - -def test_edge_sample(mesh): - for edge in mesh.edge_sample(): - assert mesh.has_edge(edge) - for edge in mesh.edge_sample(size=mesh.number_of_edges()): - assert mesh.has_edge(edge) - - -def test_face_sample(mesh): - for face in mesh.face_sample(): - assert mesh.has_face(face) - for face in mesh.face_sample(size=mesh.number_of_faces()): - assert mesh.has_face(face) - - -# ============================================================================== -# Vertex Attributes -# ============================================================================== - - -def test_default_vertex_attributes(): - he = HalfEdge(name="test", default_vertex_attributes={"a": 1, "b": 2}) - for vertex in he.vertices(): - assert he.vertex_attribute(vertex, name="a") == 1 - assert he.vertex_attribute(vertex, name="b") == 2 - he.vertex_attribute(vertex, name="a", value=3) - assert he.vertex_attribute(vertex, name="a") == 3 - - -def test_vertex_attributes_key_not_found(mesh): - with pytest.raises(KeyError): - mesh.vertex_attributes(mesh.number_of_vertices() + 1) - - -def test_vertex_attributes_from_defaults(mesh): - mesh.update_default_vertex_attributes({"foo": "bar"}) - assert mesh.vertex_attributes(mesh.vertex_sample(size=1)[0])["foo"] == "bar" - - -def test_vertex_attributes_not_in_defaults(mesh): - mesh.update_default_vertex_attributes({"foo": "bar"}) - attrs = mesh.vertex_attributes(mesh.vertex_sample(size=1)[0]) - with pytest.raises(KeyError): - attrs["baz"] - - -def test_get_vertex_attribute_from_view(mesh, vertex_key): - mesh.vertex_attribute(vertex_key, name="foo", value="bar") - attrs = mesh.vertex_attributes(vertex_key) - assert attrs["foo"] == "bar" - - -def test_set_vertex_attribute_in_view(mesh, vertex_key): - attrs = mesh.vertex_attributes(vertex_key) - attrs["foo"] = "bar" - assert mesh.vertex_attribute(vertex_key, name="foo") == "bar" - - -def test_del_vertex_attribute_in_view(mesh, vertex_key): - mesh.vertex_attribute(vertex_key, name="foo", value="bar") - attrs = mesh.vertex_attributes(vertex_key) - del attrs["foo"] - with pytest.raises(KeyError): - attrs["foo"] - - -# ============================================================================== -# Face Attributes -# ============================================================================== - - -def test_default_face_attributes(): - he = HalfEdge(name="test", default_face_attributes={"a": 1, "b": 2}) - for face in he.vertices(): - assert he.face_attribute(face, name="a") == 1 - assert he.face_attribute(face, name="b") == 2 - he.face_attribute(face, name="a", value=3) - assert he.face_attribute(face, name="a") == 3 - - -def test_face_attributes_is_empty(mesh): - assert mesh.face_attributes(mesh.face_sample(size=1)[0]) == {} - - -def test_face_attributes_from_defaults(mesh): - mesh.update_default_face_attributes({"foo": "bar"}) - assert mesh.face_attributes(mesh.face_sample(size=1)[0])["foo"] == "bar" - - -def test_face_attributes_not_in_defaults(mesh): - mesh.update_default_face_attributes({"foo": "bar"}) - attrs = mesh.face_attributes(mesh.face_sample(size=1)[0]) - with pytest.raises(KeyError): - attrs["baz"] - - -def test_get_face_attribute_from_view(mesh, face_key): - mesh.face_attribute(face_key, name="foo", value="bar") - attrs = mesh.face_attributes(face_key) - assert attrs["foo"] == "bar" - - -def test_set_face_attribute_in_view(mesh, face_key): - attrs = mesh.face_attributes(face_key) - attrs["foo"] = "bar" - assert mesh.face_attribute(face_key, name="foo") == "bar" - - -def test_del_face_attribute_in_view(mesh, face_key): - mesh.face_attribute(face_key, name="foo", value="bar") - attrs = mesh.face_attributes(face_key) - del attrs["foo"] - with pytest.raises(KeyError): - attrs["foo"] - - -# ============================================================================== -# Edge Attributes -# ============================================================================== - - -def test_default_edge_attributes(): - he = HalfEdge(name="test", default_edge_attributes={"a": 1, "b": 2}) - for edge in he.vertices(): - assert he.edge_attribute(edge, name="a") == 1 - assert he.edge_attribute(edge, name="b") == 2 - he.edge_attribute(edge, name="a", value=3) - assert he.edge_attribute(edge, name="a") == 3 - - -def test_edge_attributes_is_empty(mesh, edge_key): - assert mesh.edge_attributes(edge_key) == {} - - -def test_edge_attributes_from_defaults(mesh, edge_key): - mesh.update_default_edge_attributes({"foo": "bar"}) - assert mesh.edge_attributes(edge_key)["foo"] == "bar" - - -def test_edge_attributes_not_in_defaults(mesh, edge_key): - mesh.update_default_edge_attributes({"foo": "bar"}) - attrs = mesh.edge_attributes(edge_key) - with pytest.raises(KeyError): - attrs["baz"] - - -def test_get_edge_attribute_from_view(mesh, edge_key): - mesh.edge_attribute(edge_key, name="foo", value="bar") - attrs = mesh.edge_attributes(edge_key) - assert attrs["foo"] == "bar" - - -def test_set_edge_attribute_in_view(mesh, edge_key): - attrs = mesh.edge_attributes(edge_key) - attrs["foo"] = "bar" - assert mesh.edge_attribute(edge_key, name="foo") == "bar" - - -def test_del_edge_attribute_in_view(mesh, edge_key): - mesh.edge_attribute(edge_key, name="foo", value="bar") - attrs = mesh.edge_attributes(edge_key) - del attrs["foo"] - with pytest.raises(KeyError): - attrs["foo"] - - -# ============================================================================== -# Halfedges Before/After -# ============================================================================== - - -def test_halfedge_after_on_boundary(grid): - corners = list(grid.vertices_where(vertex_degree=2)) - corner = corners[0] - nbrs = grid.vertex_neighbors(corner, ordered=True) - nbr = nbrs[-1] - edge = grid.halfedge_after((nbr, corner)) - assert edge[0] == corner - assert grid.is_edge_on_boundary(edge) - assert grid.halfedge_face(edge) is None - - -def test_halfedge_before_on_boundary(grid): - corners = list(grid.vertices_where(vertex_degree=2)) - corner = corners[0] - nbrs = grid.vertex_neighbors(corner, ordered=True) - nbr = nbrs[0] - edge = grid.halfedge_before((corner, nbr)) - assert edge[1] == corner - assert grid.is_edge_on_boundary(edge) - assert grid.halfedge_face(edge) is None - - -# ============================================================================== -# Loops & Strip -# ============================================================================== - - -def test_loops_and_strips_closed(sphere): - poles = list(sphere.vertices_where({"vertex_degree": 16})) - - for nbr in sphere.vertex_neighbors(poles[0]): - meridian = sphere.edge_loop((poles[0], nbr)) - - assert len(meridian) == 16, meridian - assert meridian[0][0] == poles[0] - assert meridian[-1][1] == poles[1] - - for edge in meridian[1:-1]: - strip = sphere.edge_strip(edge) - - assert len(strip) == 17, strip - assert strip[0] == strip[-1] - - for edge in meridian[1:-1]: - ring = sphere.edge_loop(sphere.halfedge_before(edge)) - - assert len(ring) == 16, ring - assert ring[0][0] == ring[-1][1] - - -def test_loops_and_strips_open(grid): - assert grid.number_of_edges() == 220 - - edge = 47, 48 - strip = grid.edge_strip(edge) - loop = grid.edge_loop(edge) - - assert edge in strip - assert len(strip) == 11 - assert grid.is_edge_on_boundary(strip[0]) - assert grid.is_edge_on_boundary(strip[-1]) - - assert edge in loop - assert len(loop) == 10 - assert grid.is_vertex_on_boundary(loop[0][0]) - assert grid.is_vertex_on_boundary(loop[-1][1]) - - -def test_loops_and_strips_open_corner(grid): - assert grid.number_of_edges() == 220 - - edge = 0, 1 - loop = grid.edge_loop(edge) - strip = grid.edge_strip(edge) - - assert edge in strip - assert len(strip) == 11 - assert grid.is_edge_on_boundary(strip[0]) - assert grid.is_edge_on_boundary(strip[-1]) - assert edge == strip[-1] - - assert edge in loop - assert len(loop) == 10 - assert edge == loop[0] - - edge = 1, 0 - loop = grid.edge_loop(edge) - strip = grid.edge_strip(edge) - - assert edge in strip - assert len(strip) == 11 - assert grid.is_edge_on_boundary(strip[0]) - assert grid.is_edge_on_boundary(strip[-1]) - assert edge == strip[0] - - assert edge in loop - assert len(loop) == 10 - assert edge == loop[-1] - - -def test_loops_and_strips_open_boundary(grid): - assert grid.number_of_edges() == 220 - - edge = random.choice(grid.edges_on_boundary()) - u, v = edge - - loop = grid.edge_loop(edge) - strip = grid.edge_strip(edge) - - assert edge in strip - assert len(strip) == 11 - assert grid.is_edge_on_boundary(strip[0]) - assert grid.is_edge_on_boundary(strip[-1]) - - assert edge in loop - assert len(loop) == 10 - - if grid.halfedge[u][v] is None: - assert edge == strip[-1] - else: - assert edge == strip[0] - - -def test_split_strip_closed(box): - edge = box.edge_sample()[0] - - box.split_strip(edge) - - assert box.is_valid() - assert box.number_of_faces() == 10 - - -def test_split_strip_open(grid): - edge = grid.edge_sample()[0] - - grid.split_strip(edge) - - assert grid.is_valid() - assert grid.number_of_faces() == 110 - - -def test_split_strip_open_corner(grid): - corner = list(grid.vertices_where({"vertex_degree": 2}))[0] - - for edge in grid.vertex_edges(corner): - grid.split_strip(edge) - - assert grid.is_valid() - assert grid.number_of_faces() == 121 - - -def test_strip_faces_closed(box): - edge = box.edge_sample()[0] - - strip, faces = box.edge_strip(edge, return_faces=True) - - assert len(strip) == 5 - assert len(faces) == 4 - assert box.edge_faces(strip[0]) == box.edge_faces(strip[-1]) - - -def test_strip_faces_open(grid): - edge = grid.edge_sample()[0] - - strip, faces = grid.edge_strip(edge, return_faces=True) - - assert grid.is_face_on_boundary(faces[0]) - assert grid.is_face_on_boundary(faces[-1]) diff --git a/tests/compas/datastructures/test_mesh.py b/tests/compas/datastructures/test_mesh.py index 9d046c6f2225..eb26ebb61098 100644 --- a/tests/compas/datastructures/test_mesh.py +++ b/tests/compas/datastructures/test_mesh.py @@ -1,10 +1,11 @@ import tempfile import pytest import json +import random import compas - from compas.datastructures import Mesh +from compas.geometry import Sphere from compas.geometry import Box from compas.geometry import Polygon from compas.geometry import Polyhedron @@ -12,6 +13,58 @@ from compas.geometry import allclose +@pytest.fixture +def halfedge(): + vertices = [None, None, None, None] + faces = [[0, 1, 2], [0, 3, 1]] + he = Mesh() + for vertex in vertices: + he.add_vertex() + for face in faces: + he.add_face(face) + return he + + +@pytest.fixture +def vertex_key(): + return 2 + + +@pytest.fixture +def face_key(): + return 1 + + +@pytest.fixture +def edge_key(): + return (0, 1) + + +@pytest.fixture +def sphere(): + sphere = Sphere(radius=1.0) + mesh = Mesh.from_shape(sphere, u=16, v=16) + return mesh + + +def _box(): + box = Box.from_width_height_depth(2, 2, 2) + return Mesh.from_shape(box) + + +# @pytest.fixture +# def box(): +# box = Box.from_corner_corner_height([0, 0, 0], [1, 1, 0], 1.0) +# mesh = Mesh.from_shape(box) +# return mesh + + +@pytest.fixture +def grid(): + mesh = Mesh.from_meshgrid(dx=10, nx=10) + return mesh + + def _tet(): return Mesh.from_polyhedron(4) @@ -30,9 +83,9 @@ def cube(): return _cube() -def _box(): - box = Box.from_width_height_depth(2, 2, 2) - return Mesh.from_shape(box) +# def _box(): +# box = Box.from_width_height_depth(2, 2, 2) +# return Mesh.from_shape(box) @pytest.fixture @@ -211,7 +264,7 @@ def test_from_ploygons(): @pytest.mark.parametrize( - "mesh", + "halfedge", [ _tet(), _cube(), @@ -221,19 +274,19 @@ def test_from_ploygons(): _triangleboundarychain(), ], ) -def test_mesh_data(mesh): - other = Mesh.from_data(json.loads(json.dumps(mesh.data))) +def test_mesh_data(halfedge): + other = Mesh.from_data(json.loads(json.dumps(halfedge.data))) - assert mesh.data == other.data - assert mesh.default_vertex_attributes == other.default_vertex_attributes - assert mesh.default_edge_attributes == other.default_edge_attributes - assert mesh.default_face_attributes == other.default_face_attributes - assert mesh.number_of_vertices() == other.number_of_vertices() - assert mesh.number_of_edges() == other.number_of_edges() - assert mesh.number_of_faces() == other.number_of_faces() + assert halfedge.data == other.data + assert halfedge.default_vertex_attributes == other.default_vertex_attributes + assert halfedge.default_edge_attributes == other.default_edge_attributes + assert halfedge.default_face_attributes == other.default_face_attributes + assert halfedge.number_of_vertices() == other.number_of_vertices() + assert halfedge.number_of_edges() == other.number_of_edges() + assert halfedge.number_of_faces() == other.number_of_faces() if not compas.IPY: - assert Mesh.validate_data(mesh.data) + assert Mesh.validate_data(halfedge.data) assert Mesh.validate_data(other.data) @@ -307,6 +360,32 @@ def test_clear(): assert mesh.number_of_edges() == 0 +# -------------------------------------------------------------------------- +# samples +# -------------------------------------------------------------------------- + + +def test_vertex_sample(halfedge): + for vertex in halfedge.vertex_sample(): + assert halfedge.has_vertex(vertex) + for vertex in halfedge.vertex_sample(size=halfedge.number_of_vertices()): + assert halfedge.has_vertex(vertex) + + +def test_edge_sample(halfedge): + for edge in halfedge.edge_sample(): + assert halfedge.has_edge(edge) + for edge in halfedge.edge_sample(size=halfedge.number_of_edges()): + assert halfedge.has_edge(edge) + + +def test_face_sample(halfedge): + for face in halfedge.face_sample(): + assert halfedge.has_face(face) + for face in halfedge.face_sample(size=halfedge.number_of_faces()): + assert halfedge.has_face(face) + + # -------------------------------------------------------------------------- # builders # -------------------------------------------------------------------------- @@ -433,6 +512,157 @@ def test_genus(): pass +# -------------------------------------------------------------------------- +# vertex attributes +# -------------------------------------------------------------------------- + + +def test_default_vertex_attributes(): + he = Mesh(name="test", default_vertex_attributes={"a": 1, "b": 2}) + for vertex in he.vertices(): + assert he.vertex_attribute(vertex, name="a") == 1 + assert he.vertex_attribute(vertex, name="b") == 2 + he.vertex_attribute(vertex, name="a", value=3) + assert he.vertex_attribute(vertex, name="a") == 3 + + +def test_vertex_attributes_key_not_found(halfedge): + with pytest.raises(KeyError): + halfedge.vertex_attributes(halfedge.number_of_vertices() + 1) + + +def test_vertex_attributes_from_defaults(halfedge): + halfedge.update_default_vertex_attributes({"foo": "bar"}) + assert halfedge.vertex_attributes(halfedge.vertex_sample(size=1)[0])["foo"] == "bar" + + +def test_vertex_attributes_not_in_defaults(halfedge): + halfedge.update_default_vertex_attributes({"foo": "bar"}) + attrs = halfedge.vertex_attributes(halfedge.vertex_sample(size=1)[0]) + with pytest.raises(KeyError): + attrs["baz"] + + +def test_get_vertex_attribute_from_view(halfedge, vertex_key): + halfedge.vertex_attribute(vertex_key, name="foo", value="bar") + attrs = halfedge.vertex_attributes(vertex_key) + assert attrs["foo"] == "bar" + + +def test_set_vertex_attribute_in_view(halfedge, vertex_key): + attrs = halfedge.vertex_attributes(vertex_key) + attrs["foo"] = "bar" + assert halfedge.vertex_attribute(vertex_key, name="foo") == "bar" + + +def test_del_vertex_attribute_in_view(halfedge, vertex_key): + halfedge.vertex_attribute(vertex_key, name="foo", value="bar") + attrs = halfedge.vertex_attributes(vertex_key) + del attrs["foo"] + with pytest.raises(KeyError): + attrs["foo"] + + +# -------------------------------------------------------------------------- +# face attributes +# -------------------------------------------------------------------------- + + +def test_default_face_attributes(): + he = Mesh(name="test", default_face_attributes={"a": 1, "b": 2}) + for face in he.vertices(): + assert he.face_attribute(face, name="a") == 1 + assert he.face_attribute(face, name="b") == 2 + he.face_attribute(face, name="a", value=3) + assert he.face_attribute(face, name="a") == 3 + + +def test_face_attributes_is_empty(halfedge): + assert halfedge.face_attributes(halfedge.face_sample(size=1)[0]) == {} + + +def test_face_attributes_from_defaults(halfedge): + halfedge.update_default_face_attributes({"foo": "bar"}) + assert halfedge.face_attributes(halfedge.face_sample(size=1)[0])["foo"] == "bar" + + +def test_face_attributes_not_in_defaults(halfedge): + halfedge.update_default_face_attributes({"foo": "bar"}) + attrs = halfedge.face_attributes(halfedge.face_sample(size=1)[0]) + with pytest.raises(KeyError): + attrs["baz"] + + +def test_get_face_attribute_from_view(halfedge, face_key): + halfedge.face_attribute(face_key, name="foo", value="bar") + attrs = halfedge.face_attributes(face_key) + assert attrs["foo"] == "bar" + + +def test_set_face_attribute_in_view(halfedge, face_key): + attrs = halfedge.face_attributes(face_key) + attrs["foo"] = "bar" + assert halfedge.face_attribute(face_key, name="foo") == "bar" + + +def test_del_face_attribute_in_view(halfedge, face_key): + halfedge.face_attribute(face_key, name="foo", value="bar") + attrs = halfedge.face_attributes(face_key) + del attrs["foo"] + with pytest.raises(KeyError): + attrs["foo"] + + +# -------------------------------------------------------------------------- +# edge attributes +# -------------------------------------------------------------------------- + + +def test_default_edge_attributes(): + he = Mesh(name="test", default_edge_attributes={"a": 1, "b": 2}) + for edge in he.vertices(): + assert he.edge_attribute(edge, name="a") == 1 + assert he.edge_attribute(edge, name="b") == 2 + he.edge_attribute(edge, name="a", value=3) + assert he.edge_attribute(edge, name="a") == 3 + + +def test_edge_attributes_is_empty(halfedge, edge_key): + assert halfedge.edge_attributes(edge_key) == {} + + +def test_edge_attributes_from_defaults(halfedge, edge_key): + halfedge.update_default_edge_attributes({"foo": "bar"}) + assert halfedge.edge_attributes(edge_key)["foo"] == "bar" + + +def test_edge_attributes_not_in_defaults(halfedge, edge_key): + halfedge.update_default_edge_attributes({"foo": "bar"}) + attrs = halfedge.edge_attributes(edge_key) + with pytest.raises(KeyError): + attrs["baz"] + + +def test_get_edge_attribute_from_view(halfedge, edge_key): + halfedge.edge_attribute(edge_key, name="foo", value="bar") + attrs = halfedge.edge_attributes(edge_key) + assert attrs["foo"] == "bar" + + +def test_set_edge_attribute_in_view(halfedge, edge_key): + attrs = halfedge.edge_attributes(edge_key) + attrs["foo"] = "bar" + assert halfedge.edge_attribute(edge_key, name="foo") == "bar" + + +def test_del_edge_attribute_in_view(halfedge, edge_key): + halfedge.edge_attribute(edge_key, name="foo", value="bar") + attrs = halfedge.edge_attributes(edge_key) + del attrs["foo"] + with pytest.raises(KeyError): + attrs["foo"] + + # -------------------------------------------------------------------------- # accessors # -------------------------------------------------------------------------- @@ -460,9 +690,180 @@ def test_edges(cube): # -------------------------------------------------------------------------- -# special accessors +# halfedges before/after # -------------------------------------------------------------------------- + +def test_halfedge_after_on_boundary(grid): + corners = list(grid.vertices_where(vertex_degree=2)) + corner = corners[0] + nbrs = grid.vertex_neighbors(corner, ordered=True) + nbr = nbrs[-1] + edge = grid.halfedge_after((nbr, corner)) + assert edge[0] == corner + assert grid.is_edge_on_boundary(edge) + assert grid.halfedge_face(edge) is None + + +def test_halfedge_before_on_boundary(grid): + corners = list(grid.vertices_where(vertex_degree=2)) + corner = corners[0] + nbrs = grid.vertex_neighbors(corner, ordered=True) + nbr = nbrs[0] + edge = grid.halfedge_before((corner, nbr)) + assert edge[1] == corner + assert grid.is_edge_on_boundary(edge) + assert grid.halfedge_face(edge) is None + + +# -------------------------------------------------------------------------- +# loops and strips +# -------------------------------------------------------------------------- + + +def test_loops_and_strips_closed(sphere): + poles = list(sphere.vertices_where({"vertex_degree": 16})) + + for nbr in sphere.vertex_neighbors(poles[0]): + meridian = sphere.edge_loop((poles[0], nbr)) + + assert len(meridian) == 16, meridian + assert meridian[0][0] == poles[0] + assert meridian[-1][1] == poles[1] + + for edge in meridian[1:-1]: + strip = sphere.edge_strip(edge) + + assert len(strip) == 17, strip + assert strip[0] == strip[-1] + + for edge in meridian[1:-1]: + ring = sphere.edge_loop(sphere.halfedge_before(edge)) + + assert len(ring) == 16, ring + assert ring[0][0] == ring[-1][1] + + +def test_loops_and_strips_open(grid): + assert grid.number_of_edges() == 220 + + edge = 47, 48 + strip = grid.edge_strip(edge) + loop = grid.edge_loop(edge) + + assert edge in strip + assert len(strip) == 11 + assert grid.is_edge_on_boundary(strip[0]) + assert grid.is_edge_on_boundary(strip[-1]) + + assert edge in loop + assert len(loop) == 10 + assert grid.is_vertex_on_boundary(loop[0][0]) + assert grid.is_vertex_on_boundary(loop[-1][1]) + + +def test_loops_and_strips_open_corner(grid): + assert grid.number_of_edges() == 220 + + edge = 0, 1 + loop = grid.edge_loop(edge) + strip = grid.edge_strip(edge) + + assert edge in strip + assert len(strip) == 11 + assert grid.is_edge_on_boundary(strip[0]) + assert grid.is_edge_on_boundary(strip[-1]) + assert edge == strip[-1] + + assert edge in loop + assert len(loop) == 10 + assert edge == loop[0] + + edge = 1, 0 + loop = grid.edge_loop(edge) + strip = grid.edge_strip(edge) + + assert edge in strip + assert len(strip) == 11 + assert grid.is_edge_on_boundary(strip[0]) + assert grid.is_edge_on_boundary(strip[-1]) + assert edge == strip[0] + + assert edge in loop + assert len(loop) == 10 + assert edge == loop[-1] + + +def test_loops_and_strips_open_boundary(grid): + assert grid.number_of_edges() == 220 + + edge = random.choice(grid.edges_on_boundary()) + u, v = edge + + loop = grid.edge_loop(edge) + strip = grid.edge_strip(edge) + + assert edge in strip + assert len(strip) == 11 + assert grid.is_edge_on_boundary(strip[0]) + assert grid.is_edge_on_boundary(strip[-1]) + + assert edge in loop + assert len(loop) == 10 + + if grid.halfedge[u][v] is None: + assert edge == strip[-1] + else: + assert edge == strip[0] + + +def test_split_strip_closed(box): + edge = box.edge_sample()[0] + + box.split_strip(edge) + + assert box.is_valid() + assert box.number_of_faces() == 10 + + +def test_split_strip_open(grid): + edge = grid.edge_sample()[0] + + grid.split_strip(edge) + + assert grid.is_valid() + assert grid.number_of_faces() == 110 + + +def test_split_strip_open_corner(grid): + corner = list(grid.vertices_where({"vertex_degree": 2}))[0] + + for edge in grid.vertex_edges(corner): + grid.split_strip(edge) + + assert grid.is_valid() + assert grid.number_of_faces() == 121 + + +def test_strip_faces_closed(box): + edge = box.edge_sample()[0] + + strip, faces = box.edge_strip(edge, return_faces=True) + + assert len(strip) == 5 + assert len(faces) == 4 + assert box.edge_faces(strip[0]) == box.edge_faces(strip[-1]) + + +def test_strip_faces_open(grid): + edge = grid.edge_sample()[0] + + strip, faces = grid.edge_strip(edge, return_faces=True) + + assert grid.is_face_on_boundary(faces[0]) + assert grid.is_face_on_boundary(faces[-1]) + + # -------------------------------------------------------------------------- # vertex topology # --------------------------------------------------------------------------