diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b06fc4d8e6..21acc8aef7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,11 +25,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Added `compas.geometry.vector.__rsub__`. * Added `compas.geometry.vector.__rmul__`. * Added `compas.geometry.vector.__rtruediv__`. +* Added `VolMesh.cell_lines`, `VolMesh.cell_polygons`. +* Added `VolMesh.vertex_edges`. +* Added `VolMesh.from_meshes`. +* Added `VolMesh.from_polyhedrons`. ### Changed * Changed `compas_ghpython/utilities/drawing.py` to remove `System` dependency. * Fixed bug in `compas.geometry.ic_numpy`, which was caused by returning only the last transformation of the iteration process. +* Changed `VolMesh._plane` back to point to a cell for every triplet of vertices. +* Fixed `VolMesh.add_halfface`, `VolMesh.add_cell`, `VolMesh.vertex_halffaces`, `VolMesh.vertex_cells`, `VolMesh.edge_halffaces`, `VolMesh.halfface_cell`, `VolMesh.halfface_opposite_cell`, `VolMesh.halfface_opposite_halfface`, `VolMesh.cell_neighbors`. +* Changed ordering of `Volmesh.edges()` to be deterministic. +* Changed ordering and direction of `Volmesh.vertex_edges()` to be deterministic. * Changed check for empty vertices and faces to use `is None` to add support for `numpy` arrays. * Changed order of `u` and `v` of `compas.geometry.SphericalSurface` to the match the excpected parametrisation. * Changed `compas.geometry.Shape.to_vertices_and_faces` to use `Shape.vertices` and `Shape.faces` or `Shape.triangles`. diff --git a/docs/userguide/basics.datastructures.assemblies.rst b/docs/userguide/basics.datastructures.assemblies.rst deleted file mode 100644 index 7a15300a45b..00000000000 --- a/docs/userguide/basics.datastructures.assemblies.rst +++ /dev/null @@ -1,11 +0,0 @@ -******************************************************************************** -Assemblies -******************************************************************************** - -.. note:: - - The new (wip) version of the assembly data structure or model is available here - https://github.com/BRG-research/compas_assembly2. - - -More information coming soon... diff --git a/docs/userguide/basics.datastructures.cellnetworks.py b/docs/userguide/basics.datastructures.cellnetworks.py index 97ae63272a4..91206d53b18 100644 --- a/docs/userguide/basics.datastructures.cellnetworks.py +++ b/docs/userguide/basics.datastructures.cellnetworks.py @@ -5,13 +5,15 @@ import compas from compas.colors import Color from compas.datastructures import CellNetwork -from compas.geometry import Line, Polygon - -cell_network = CellNetwork.from_json(compas.get("cellnetwork_example.json")) +from compas.geometry import Line +from compas.geometry import Polygon +cell_network: CellNetwork = CellNetwork.from_json(compas.get("cellnetwork_example.json")) # type: ignore viewer = Viewer(show_grid=False) + no_cell = cell_network.faces_without_cell() + for face in cell_network.faces(): if cell_network.is_face_on_boundary(face) is True: color, opacity = Color.silver(), 0.5 @@ -19,7 +21,9 @@ color, opacity = Color.azure(), 0.3 else: color, opacity = Color.yellow(), 0.8 + viewer.scene.add(Polygon(cell_network.face_coordinates(face)), facecolor=color, opacity=opacity) + for edge in cell_network.edges_without_face(): line = Line(*cell_network.edge_coordinates(edge)) viewer.scene.add(line, linewidth=3) @@ -27,4 +31,4 @@ graph = cell_network.cells_to_graph() viewer.scene.add(graph) -viewer.show() \ No newline at end of file +viewer.show() diff --git a/docs/userguide/basics.datastructures.meshes.rst b/docs/userguide/basics.datastructures.meshes.rst index 552eae77aae..096ab1ddf08 100644 --- a/docs/userguide/basics.datastructures.meshes.rst +++ b/docs/userguide/basics.datastructures.meshes.rst @@ -207,7 +207,7 @@ Halfedge Data Structure The topology of a mesh is stored in a halfedge data structure. In this data structure, vertices are connected to other vertices, and faces to other faces, via edges. An edge has two connected vertices, and at most two connected faces. -Each each is split into two halfedges, one for each of the connected faces. +Each edge is split into two halfedges, one for each of the connected faces. If an edge has only one connected face, the edge is on the boundary. Note that in a mesh constructed using :meth:`compas.datastructures.Mesh.from_meshgrid`, the vertices are organised in a specific way. diff --git a/docs/userguide/basics.datastructures.rst b/docs/userguide/basics.datastructures.rst index 51e55c090f4..718780e5fe1 100644 --- a/docs/userguide/basics.datastructures.rst +++ b/docs/userguide/basics.datastructures.rst @@ -12,4 +12,3 @@ Datastructures basics.datastructures.cells basics.datastructures.cellnetwork basics.datastructures.trees - basics.datastructures.assemblies diff --git a/docs/userguide/basics.datastructures.volmeshes.rst b/docs/userguide/basics.datastructures.volmeshes.rst new file mode 100644 index 00000000000..5d25cdd0243 --- /dev/null +++ b/docs/userguide/basics.datastructures.volmeshes.rst @@ -0,0 +1,367 @@ +******************************************************************************** +Vol(umetric) Meshes +******************************************************************************** + +.. currentmodule:: compas.datastructures + +.. rst-class:: lead + +A :class:`compas.datastructures.VolMesh` uses a "halfface" data structure to represent the topology and geometry of a cellular mesh, +and to facilitate the application of topological and geometrical operations on it. +In addition, it provides a number of methods for storing arbitrary data on vertices, edges, faces, and cell, and on the overall mesh itself. + +.. note:: + + Please refer to the API for a complete overview of all functionality: + + * :class:`compas.datastructures.VolMesh` + +General Properties +================== + +A `VolMesh` consists of vertices, edges, faces, and cells. +Currenly, a `VolMesh` can only be constructed by providing information about the vertices and cells. +This means that edges and faces are created implicitly, and can only exist as part of the topology of a cell. + +An edge connects exactly two vertices. +All edges of a valid `VolMesh` belong to at least one cell. + +A face consists of three or vertices, which are ordered such that they form a closed cycle. +The faces of a cell form a closed mesh. +The structure of this mesh is equivalent to the structure of a halfedge mesh (:class:`compas.datastructures.Mesh`). +The cycle directions of the faces are such that the resulting normals point towards the interior of the cell. + +Neighbouring cells share at least one face. +A face can be shared by at most two neighbouring cells. +The cycle directions of the faces through which two neighbouring cells are connected are exactly opposite. + +Naked faces are faces without an opposite face, or with an opposite face that doesn"t belong to a cell. +Cells with naked faces are considered to be on the boundary of the `VolMesh`. + +An empty `VolMesh` is valid. +A `VolMesh` with only vertices, but no cells is also valid. + +VolMesh Construction +==================== + +VolMeshes can be constructed in a number of ways: + +* from scratch, by adding vertices and cells one by one, +* from geometric input, +* using a special constructor function, or +* from the data contained in a file. + +.. note:: + + During construction, the validity of the input is not verified. + The `VolMesh` is created with the input that is provided. + Wrong input means wrong result. + +From Scratch +------------ + +The following snippet constructs a `VolMesh` with one cell in the form of a unit cube. +Note that the vertices of the faces are ordered such that the corresponding normal vectors point toward the interior of the cube. + +>>> from compas.datastructures import VolMesh +>>> volmesh = VolMesh() + +>>> a = volmesh.add_vertex(x=0, y=0, z=0) +>>> b = volmesh.add_vertex(x=1, y=0, z=0) +>>> c = volmesh.add_vertex(x=1, y=1, z=0) +>>> d = volmesh.add_vertex(x=0, y=1, z=0) +>>> e = volmesh.add_vertex(x=0, y=0, z=1) +>>> f = volmesh.add_vertex(x=1, y=0, z=1) +>>> g = volmesh.add_vertex(x=1, y=1, z=1) +>>> h = volmesh.add_vertex(x=0, y=1, z=1) + +>>> faces = [[a, b, c, d], [e, h, g, f], [a, e, f, b], [b, f, g, c], [c, g, h, d], [a, d, h, e]] +>>> cell = volmesh.add_cell(faces) + +Using Constructors +------------------ + +Constructing `VolMesh` objects "from scratch", as shown above, obviously quickly becomes rather tedious. +Therefore, the `VolMesh` class provides class methods that can be used to construct volumetric meshes +more easily from various types of common inputs. + +* :meth:`VolMesh.from_vertices_and_cells` +* :meth:`VolMesh.from_meshgrid` +* :meth:`VolMesh.from_meshes` +* :meth:`VolMesh.from_polyhedrons` + +>>> from compas.datastructures import VolMesh +>>> volmesh = VolMesh.from_obj("meshes.obj") + +From Data in a File +------------------- + +Currently only OBJ files containing closed meshes are supported. + +* :meth:`VolMesh.from_obj` + +>>> from compas.datastructures import VolMesh +>>> volmesh = VolMesh.from_obj("volmeshring.obj") + +Equivalently, the geometry of a `VolMesh` can also be written to an OBJ file. +However, note that all additional data attributes (see :ref:`Vertex, Edges, Face, Cell Attributes`) are lost during this process. +Only the geometry survives this conversion process. + +>>> volmesh.to_obj("volmeshring.obj") + +Visualisation +============= + +Like all other COMPAS geometry objects and data structures, volumetric meshes can be visualised by placing them in a scene. +For more information about visualisation with :class:`compas.scene.Scene`, see :doc:`/userguide/basics.visualisation`. +The snippet below uses `volmesh.obj`, which is available here: :download:`volmeshring.obj`. + +>>> from compas.datastructures import VolMesh +>>> from compas.scene import Scene +>>> mesh = Mesh.from_obj("volmeshring.obj") +>>> scene = Scene() +>>> scene.add(mesh) +>>> scene.show() + +.. figure:: /_images/userguide/basics.datastructures.volmeshes.volmeshring.png + +Vertices, Edges, Faces, Cells +============================= + +A `VolMesh` can only be constructed by providing information about the vertices and cells. +Edges are created implicitly, and can only exist as part of the topology of a cell. +Faces are not stored explicitly, but are generated automatically based on the corresponding (pairs of) halffaces. + +Vertices, faces, and cells are identified by unique, positive and increasing integers. +Edges are identified by pairs of vertex identifiers. + +In this section, we will use a `VolMesh` constructed from a mesh grid, +because its highly structured nature allows for easy and transparent counting and verification. + +>>> volmesh = VolMesh.from_meshgrid(dx=3, nx=3) + +.. figure:: /_images/userguide/basics.datastructures.volmeshes.volmeshgrid_3x3.png + +>>> volmesh.number_of_vertices() +64 +>>> volmesh.number_of_edges() +144 +>>> volmesh.number_of_faces() +108 +>>> volmesh.number_of_cells() +27 + +To iterate over vertices, edges, faces, and cells the `VolMesh` provides corresponding generators. + +>>> volmesh.vertices() + +>>> volmesh.edges() + +>>> volmesh.faces() + +>>> volmesh.cells() + + +These generators are meant to be used in loops. + +>>> for vertex in volmesh.vertices(): +... print(vertex) +... +0 +1 +2 +3 +4 +# etc. + +>>> for edge in volmesh.edges(): +... print(edge) +(0, 1) +(0, 4) +(0, 16) +(1, 2) +(1, 5) +# etc. + +The edges are not stored explicitly in the data structure, +but instead generated automatically from the adjacency information of the vertices. +They are generated following the order of the vertex identifiers to make sure the order of edges is deterministic. + +>>> for face in volmesh.faces(): +... print(face) +0 +1 +2 +3 +4 +# etc + +>>> for cell in volmesh.cells(): +... print(cell) +0 +1 +2 +3 +4 +# etc + +Lists of vertices, edges, faces, and cells have to be constructed explicitly. + +>>> vertices = list(volmesh.vertices()) +>>> vertices +[0, 1, 2, 3, 4, ..., 63] + +>>> edges = list(volmesh.edges()) +>>> edges +[(0, 1), (0, 4), (0, 16), (1, 2), (1, 5), ..., (62, 63)] + +# NOTE: this is not ideal. + +>>> faces = list(volmesh.faces()) +>>> faces +[???] + +>>> cells = list(volmesh.cells()) +>>> cells +[0, 1, 2, 3, 4, ..., 27] + +Vertex, Edge, Face, Cell Attributes +=================================== + +Arbitrary data can be assigned to vertices, edges, faces, and cells as vertex/edge/face/cell attributes, and to the overall `VolMesh` itself. +To allow for serialisatin of the `VolMesh` and all the data associated with it, the data should be JSON serialisable. +See :ref:`Data Serialisation` for more information. + +The functionality is demonstrated here using vertex attributes. +The mechanism is exactly the same for edges, faces, and cells. + +It is good practice to declare default values for the added data attributes. + +>>> volmesh = VolMesh.from_meshgrid(dx=3, nx=10) +>>> volmesh.update_default_vertex_attributes(a=None, b=0.0, c=False) + +Get the value of one attribute of one vertex. + +>>> volmesh.vertex_attribute(vertex=0, name="a") +None + +Get the value of multiple attributes of one vertex. + +>>> volmesh.vertex_attributes(vertex=0, names=["a", "b"]) +(None, 0.0) + +Get the value of one attribute of all vertices. + +>>> volmesh.vertices_attribute(name="a") +[None, None, None, ... None] + +Get the value of multiple attributes of all vertices. + +>>> volmesh.vertices_attributes(names=["b", "c"]) +[(0.0, False), (0.0, False), (0.0, False), ..., (0.0, False)] + +Similarly, for a selection of vertices. + +>>> volmesh.vertices_attribute(name="b", vertices=[0, 1, 2, 3]) +[0.0, 0.0, 0.0, 0.0] + +>>> volmesh.vertices_attributes(names=["a", "c"], vertices=[0, 1, 2, 3]) +[(None, False), (None, False), (None, False), (None, False)] + +Updating attributes is currently only possible one vertex at a time. + +>>> volmesh.vertex_attribute(vertex=0, name="a", value=(1.0, 0.0, 0.0)) + +>>> for vertex in volmesh.vertices(): +... if volmesh.vertex_degree(vertex) == 2: +... volmesh.vertex_attribute(vertex=vertex, name="a", value=(1.0, 0.0, 0.0)) +... + +Overall Topology +================ + +More info coming... + +* vertex_neighbors +* vertex_cells +* vertex_edges +* vertex_faces +* edge_cells +* edge_faces +* edge_halffaces +* cell_neighbors + +Topology of a Cell +================== + +Within the context of a cell, the topology of a `VolMesh` is the same as the topology of a regular `Mesh`. +Vertices are connected to other vertices, and faces to other faces, via edges. +An edge has two connected vertices, and at most two connected faces. +Each edge is split into two halfedges, one for each of the connected faces. + +>>> volmesh = VolMesh.from_meshgrid(dx=3, nx=3) +>>> cell = volmesh.cell_sample(size=1).next() +>>> + +* cell_vertices +* cell_edges +* cell_halfedges +* cell_halffaces +* cell_faces +* cell_vertex_halffaces +* cell_vertex_faces +* cell_vertex_neighbors +* cell_edge_halffaces +* cell_edge_faces + +.. edges and faces are implicit, and only used to store additional data +.. their identifiers are also implicit +.. halfedge_face +.. face_attributes +.. + +Geometry +======== + +* vertex_point +* edge_vector +* edge_line +* edge_start +* edge_end +* face_centroid +* face_area +* halfface_centroid +* halfface_polygon +* halfface_normal +* cell_volume +* cell_centroid +* cell_polyhedron + +Filtering +========= + +* vertices_where +* edges_where +* faces_where +* cells_where + +Data Serialisation +================== + +>>> volmesh.to_json("volmesh.json") +>>> volmesh = VolMesh.from_json("volmesh.json") + +>>> s = volmesh.to_jsonstring() +>>> volmesh = VolMesh.from_jsonstring(s) + +>>> session = {"volmesh": volmesh, "a": 1, "b": 2} +>>> compas.json_dump(session, "session.json") +>>> session = compas.json_load("session.json") +>>> volmesh = session["volmesh"] + +Examples +======== + +.. literalinclude:: basics.datastructures.volmeshes_example-1.py + :language: python + diff --git a/docs/userguide/basics.datastructures.volmeshes_example-1.py b/docs/userguide/basics.datastructures.volmeshes_example-1.py new file mode 100644 index 00000000000..2fcaba2419b --- /dev/null +++ b/docs/userguide/basics.datastructures.volmeshes_example-1.py @@ -0,0 +1,80 @@ +from math import radians + +import numpy +from compas_viewer import Viewer +from compas_viewer.config import Config +from compas_viewer.scene import BufferGeometry + +from compas.colors import Color +from compas.datastructures import VolMesh +from compas.geometry import Box +from compas.geometry import Plane +from compas.tolerance import Tolerance + +tolerance = Tolerance() + +# ============================================================================= +# Base Box +# ============================================================================= + +box = Box(10).to_mesh() + +# ============================================================================= +# Cutting Planes +# ============================================================================= + +planes = [ + Plane.worldXY().rotated(radians(30), [0, 1, 0]), + Plane.worldYZ().rotated(radians(30), [0, 0, 1]), + Plane.worldZX().rotated(radians(30), [1, 0, 0]), + Plane.worldXY().translated([0, 0, +2.5]), + Plane.worldXY().translated([0, 0, -2.5]), +] + +# ============================================================================= +# Cuts +# ============================================================================= + +results = [box] +for plane in planes: + temp = [] + for box in results: + result = box.slice(plane) + if result: + temp += result + else: + temp.append(box) + results = temp + +# ============================================================================= +# VolMesh Construction +# ============================================================================= + +volmesh = VolMesh.from_meshes(results) + +volmesh.translate([5, 5, 5]) + +# ============================================================================= +# Visualisation +# ============================================================================= + +config = Config() +config.camera.target = [5, 5, 5] +config.camera.position = [-8, -15, 10] + +viewer = Viewer(config=config) + +wires = numpy.asarray([volmesh.edge_coordinates(edge) for edge in volmesh.edges()]) +viewer.scene.add(BufferGeometry(lines=wires), name="Wires") + +cell = list(volmesh.cell_sample(size=1))[0] +cell_color = {cell: Color.red()} +for nbr in volmesh.cell_neighbors(cell): + cell_color[nbr] = Color.green() + +for cell in cell_color: + color = cell_color[cell] + mesh = volmesh.cell_to_mesh(cell) + viewer.scene.add(mesh, facecolor=color) + +viewer.show() diff --git a/src/compas/datastructures/volmesh/volmesh.py b/src/compas/datastructures/volmesh/volmesh.py index d2184ccde78..a934b26b63c 100644 --- a/src/compas/datastructures/volmesh/volmesh.py +++ b/src/compas/datastructures/volmesh/volmesh.py @@ -5,6 +5,13 @@ from itertools import product from random import sample +import compas + +if compas.PY2: + from collections import Mapping # type: ignore +else: + from collections.abc import Mapping + from compas.datastructures import Mesh from compas.datastructures.attributes import CellAttributeView from compas.datastructures.attributes import EdgeAttributeView @@ -38,10 +45,14 @@ from compas.tolerance import TOL -def iter_edges_from_vertices(vertices): - for i, u in enumerate(vertices): - j = (i + 1) % len(vertices) - yield u, vertices[j] +def uv_from_vertices(vertices): + for i in range(-1, len(vertices) - 1): + yield vertices[i], vertices[i + 1] + + +def uvw_from_vertices(vertices): + for i in range(-2, len(vertices) - 2): + yield vertices[i], vertices[i + 1], vertices[i + 2] class VolMesh(Datastructure): @@ -140,6 +151,7 @@ class VolMesh(Datastructure): @property def __data__(self): + # type: () -> dict _cell = {} for c in self._cell: faces = [] @@ -166,6 +178,7 @@ def __data__(self): @classmethod def __from_data__(cls, data): + # type: (dict) -> VolMesh volmesh = cls( default_vertex_attributes=data.get("default_vertex_attributes"), default_edge_attributes=data.get("default_edge_attributes"), @@ -199,6 +212,7 @@ def __from_data__(cls, data): return volmesh def __init__(self, default_vertex_attributes=None, default_edge_attributes=None, default_face_attributes=None, default_cell_attributes=None, name=None, **kwargs): # fmt: skip + # type: (dict | None, dict | None, dict | None, dict | None, str | None, dict) -> None super(VolMesh, self).__init__(kwargs, name=name) self._max_vertex = -1 self._max_face = -1 @@ -224,6 +238,7 @@ def __init__(self, default_vertex_attributes=None, default_edge_attributes=None, self.default_cell_attributes.update(default_cell_attributes) def __str__(self): + # type: () -> str tpl = "" return tpl.format( self.number_of_vertices(), @@ -246,6 +261,7 @@ def __str__(self): @classmethod def from_meshgrid(cls, dx=10, dy=None, dz=None, nx=10, ny=None, nz=None): + # type: (float, float | None, float | None, int, int | None, int | None) -> VolMesh """Construct a volmesh from a 3D meshgrid. Parameters @@ -311,6 +327,7 @@ def from_meshgrid(cls, dx=10, dy=None, dz=None, nx=10, ny=None, nz=None): @classmethod def from_obj(cls, filepath, precision=None): + # type: (str, int | None) -> VolMesh """Construct a volmesh object from the data described in an OBJ file. Parameters @@ -335,21 +352,38 @@ def from_obj(cls, filepath, precision=None): obj = OBJ(filepath, precision) vertices = obj.parser.vertices or [] # type: ignore faces = obj.parser.faces or [] # type: ignore - groups = obj.parser.groups or [] # type: ignore - cells = [] - for name in groups: - group = groups[name] - cell = [] - for item in group: - if item[0] != "f": - continue - face = faces[item[1]] - cell.append(face) - cells.append(cell) - return cls.from_vertices_and_cells(vertices, cells) + groups = obj.parser.groups or {} # type: ignore + objects = obj.parser.objects or {} # type: ignore + + if groups: + cells = [] + for name, group in groups.items(): + cell = [] + for item in group: + if item[0] != "f": + continue + face = faces[item[1]] + cell.append(face) + cells.append(cell) + return cls.from_vertices_and_cells(vertices, cells) + + if objects: + polyhedrons = [] + for name in objects: + vertex_xyz = objects[name][0] + vertex_index = {vertex: index for index, vertex in enumerate(vertex_xyz)} + vertices = list(vertex_xyz.values()) + faces = [[vertex_index[vertex] for vertex in face] for face in objects[name][1]] + polyhedron = Polyhedron(vertices, faces) + polyhedrons.append(polyhedron) + return cls.from_polyhedrons(polyhedrons) + + cell = [faces] + return cls.from_vertices_and_cells(vertices, cell) @classmethod def from_vertices_and_cells(cls, vertices, cells): + # type: (list[list[float]], list[list[list[int]]]) -> VolMesh """Construct a volmesh object from vertices and cells. Parameters @@ -371,17 +405,105 @@ def from_vertices_and_cells(cls, vertices, cells): """ volmesh = cls() - for x, y, z in vertices: - volmesh.add_vertex(x=x, y=y, z=z) + + if isinstance(vertices, Mapping): + for key, xyz in vertices.items(): + volmesh.add_vertex(key=key, attr_dict=dict(zip(("x", "y", "z"), xyz))) + else: + for x, y, z in iter(vertices): + volmesh.add_vertex(x=x, y=y, z=z) + for cell in cells: volmesh.add_cell(cell) return volmesh + @classmethod + def from_meshes(cls, meshes): + # type: (list[Mesh]) -> VolMesh + """Construct a volmesh from a list of faces. + + Parameters + ---------- + meshes : list[:class:`Mesh`] + The input meshes. + + Returns + ------- + :class:`VolMesh` + + Notes + ----- + The cycle directions of the faces of the meshes are neither checked, nor changed. + This means that the cycle directions of the provided meshes have to be consistent. + + """ + gkey_xyz = {} + cells = [] + + for mesh in meshes: + for vertex in mesh.vertices(): + xyz = mesh.vertex_attributes(vertex, "xyz") + gkey = TOL.geometric_key(xyz) + gkey_xyz[gkey] = xyz + cell = [] + for face in mesh.faces(): + temp = [] + for vertex in mesh.face_vertices(face): + xyz = mesh.vertex_attributes(vertex, "xyz") + gkey = TOL.geometric_key(xyz) + temp.append(gkey) + cell.append(temp) + cells.append(cell) + + gkey_index = {gkey: index for index, gkey in enumerate(gkey_xyz)} + vertices = list(gkey_xyz.values()) + cells = [[[gkey_index[gkey] for gkey in face] for face in cell] for cell in cells] + + return cls.from_vertices_and_cells(vertices, cells) + + @classmethod + def from_polyhedrons(cls, polyhedrons): + # type: (list[Polyhedron]) -> VolMesh + """Construct a VolMesh from a list of polyhedrons. + + Parameters + ---------- + polyhedrons : list[:class:`Polyhedron`] + + Returns + ------- + :class:`VolMesh` + + """ + gkey_xyz = {} + cells = [] + + for polyhedron in polyhedrons: + for vertex in polyhedron.vertices: + gkey = TOL.geometric_key(vertex) + gkey_xyz[gkey] = vertex + cell = [] + for face in polyhedron.faces: + temp = [] + for index in face: + xyz = polyhedron.vertices[index] + gkey = TOL.geometric_key(xyz) + temp.append(gkey) + cell.append(temp) + cells.append(cell) + + gkey_index = {gkey: index for index, gkey in enumerate(gkey_xyz)} + vertices = list(gkey_xyz.values()) + cells = [[[gkey_index[gkey] for gkey in face] for face in cell] for cell in cells] + + return cls.from_vertices_and_cells(vertices, cells) + # -------------------------------------------------------------------------- # Conversions # -------------------------------------------------------------------------- def to_obj(self, filepath, precision=None, **kwargs): + # type: (str, int | None, dict) -> None """Write the volmesh to an OBJ file. Parameters @@ -409,10 +531,12 @@ def to_obj(self, filepath, precision=None, **kwargs): the faces to the file. """ + meshes = [self.cell_to_mesh(cell) for cell in self.cells()] obj = OBJ(filepath, precision=precision) - obj.write(self, **kwargs) + obj.write(meshes, **kwargs) def to_vertices_and_cells(self): + # type: () -> tuple[list[list[float]], list[list[list[int]]]] """Return the vertices and cells of a volmesh. Returns @@ -436,6 +560,7 @@ def to_vertices_and_cells(self): return vertices, cells def cell_to_mesh(self, cell): + # type: (int) -> Mesh """Construct a mesh object from from a cell of a volmesh. Parameters @@ -457,6 +582,7 @@ def cell_to_mesh(self, cell): return Mesh.from_vertices_and_faces(vertices, faces) def cell_to_vertices_and_faces(self, cell): + # type: (int) -> tuple[list[list[float]], list[list[int]]] """Return the vertices and faces of a cell. Parameters @@ -488,6 +614,7 @@ def cell_to_vertices_and_faces(self, cell): # -------------------------------------------------------------------------- def clear(self): + # type: () -> None """Clear all the volmesh data. Returns @@ -514,6 +641,7 @@ def clear(self): self._max_cell = -1 def vertex_sample(self, size=1): + # type: (int) -> list[int] """Get the identifiers of a set of random vertices. Parameters @@ -534,6 +662,7 @@ def vertex_sample(self, size=1): return sample(list(self.vertices()), size) def edge_sample(self, size=1): + # type: (int) -> list[tuple[int, int]] """Get the identifiers of a set of random edges. Parameters @@ -554,6 +683,7 @@ def edge_sample(self, size=1): return sample(list(self.edges()), size) def face_sample(self, size=1): + # type: (int) -> list[int] """Get the identifiers of a set of random faces. Parameters @@ -574,6 +704,7 @@ def face_sample(self, size=1): return sample(list(self.faces()), size) def cell_sample(self, size=1): + # type: (int) -> list[int] """Get the identifiers of a set of random cells. Parameters @@ -594,6 +725,7 @@ def cell_sample(self, size=1): return sample(list(self.cells()), size) def vertex_index(self): + # type: () -> dict[int, int] """Returns a dictionary that maps vertex dictionary keys to the corresponding index in a vertex list or array. @@ -610,6 +742,7 @@ def vertex_index(self): return {key: index for index, key in enumerate(self.vertices())} def index_vertex(self): + # type: () -> dict[int, int] """Returns a dictionary that maps the indices of a vertex list to keys in the vertex dictionary. @@ -626,6 +759,7 @@ def index_vertex(self): return dict(enumerate(self.vertices())) def vertex_gkey(self, precision=None): + # type: (int | None) -> dict[int, str] """Returns a dictionary that maps vertex dictionary keys to the corresponding *geometric key* up to a certain precision. @@ -650,6 +784,7 @@ def vertex_gkey(self, precision=None): return {vertex: gkey(xyz(vertex), precision) for vertex in self.vertices()} def gkey_vertex(self, precision=None): + # type: (int | None) -> dict[str, int] """Returns a dictionary that maps *geometric keys* of a certain precision to the keys of the corresponding vertices. @@ -678,6 +813,7 @@ def gkey_vertex(self, precision=None): # -------------------------------------------------------------------------- def add_vertex(self, key=None, attr_dict=None, **kwattr): + # type: (int | None, dict | None, dict) -> int """Add a vertex to the volmesh object. Parameters @@ -722,6 +858,7 @@ def add_vertex(self, key=None, attr_dict=None, **kwattr): return key def add_halfface(self, vertices, fkey=None, attr_dict=None, **kwattr): + # type: (list[int], int | None, dict | None, dict) -> int """Add a face to the volmesh object. Parameters @@ -741,6 +878,11 @@ def add_halfface(self, vertices, fkey=None, attr_dict=None, **kwattr): int The key of the face. + Raises + ------ + ValueError + If the number of vertices is less than 3. + See Also -------- :meth:`add_vertex`, :meth:`add_cell` @@ -756,27 +898,33 @@ def add_halfface(self, vertices, fkey=None, attr_dict=None, **kwattr): """ if len(vertices) < 3: - return + raise ValueError("A half-face should have at least 3 vertices: {}".format(vertices)) + if vertices[-1] == vertices[0]: vertices = vertices[:-1] vertices = [int(key) for key in vertices] + 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._halfface[fkey] = vertices + for name, value in attr.items(): self.face_attribute(fkey, name, value) - for u, v in pairwise(vertices + vertices[:1]): + + for u, v, w in uvw_from_vertices(vertices): if v not in self._plane[u]: self._plane[u][v] = {} - self._plane[u][v][fkey] = None - if u not in self._plane[v]: - self._plane[v][u] = {} - self._plane[v][u][fkey] = None + self._plane[u][v][w] = None + if v not in self._plane[w]: + self._plane[w][v] = {} + if u not in self._plane[w][v]: + self._plane[w][v][u] = None return fkey @@ -823,19 +971,23 @@ def add_cell(self, faces, ckey=None, attr_dict=None, **kwattr): ckey = int(ckey) if ckey > self._max_cell: self._max_cell = ckey + attr = attr_dict or {} attr.update(kwattr) self._cell[ckey] = {} + for name, value in attr.items(): self.cell_attribute(ckey, name, value) + for vertices in faces: fkey = self.add_halfface(vertices) vertices = self.halfface_vertices(fkey) - for u, v in pairwise(vertices + vertices[:1]): + + for u, v, w in uvw_from_vertices(vertices): if u not in self._cell[ckey]: self._cell[ckey][u] = {} - self._plane[u][v][fkey] = ckey self._cell[ckey][u][v] = fkey + self._plane[u][v][w] = ckey return ckey @@ -878,6 +1030,7 @@ def delete_cell(self, cell): """ cell_vertices = self.cell_vertices(cell) cell_faces = self.cell_faces(cell) + for face in cell_faces: for edge in self.halfface_halfedges(face): u, v = edge @@ -885,12 +1038,14 @@ def delete_cell(self, cell): del self._edge_data[u, v] if (v, u) in self._edge_data: del self._edge_data[v, u] + for vertex in cell_vertices: if len(self.vertex_cells(vertex)) == 1: del self._vertex[vertex] + for face in cell_faces: vertices = self.halfface_vertices(face) - for u, v in iter_edges_from_vertices(vertices): + for u, v in uv_from_vertices(vertices): self._plane[u][v][face] = None if self._plane[v][u][face] is None: del self._plane[u][v][face] @@ -899,6 +1054,7 @@ def delete_cell(self, cell): key = "-".join(map(str, sorted(vertices))) if key in self._face_data: del self._face_data[key] + del self._cell[cell] if cell in self._cell_data: del self._cell_data[cell] @@ -1547,6 +1703,22 @@ def vertex_max_degree(self): return 0 return max(self.vertex_degree(vertex) for vertex in self.vertices()) + def vertex_edges(self, vertex): + """Compute the edges connected to a given vertex. + + Parameters + ---------- + vertex : int + The vertex identifier. + + Returns + ------- + list[tuple[int, int]] + The connected edges. + + """ + return [(vertex, nbr) for nbr in sorted(self.vertex_neighbors(vertex))] + def vertex_halffaces(self, vertex): """Return all halffaces connected to a vertex. @@ -1565,15 +1737,13 @@ def vertex_halffaces(self, vertex): :meth:`vertex_neighbors`, :meth:`vertex_faces`, :meth:`vertex_cells` """ - cells = self.vertex_cells(vertex) - nbrs = self.vertex_neighbors(vertex) - halffaces = set() - for cell in cells: - for nbr in nbrs: - if nbr in self._cell[cell][vertex]: - halffaces.add(self._cell[cell][vertex][nbr]) - halffaces.add(self._cell[cell][nbr][vertex]) - return list(halffaces) + u = vertex + faces = [] + for v in self._plane[u]: + for face in self._plane[u][v]: + if face is not None: + faces.append(face) + return faces def vertex_cells(self, vertex): """Return all cells connected to a vertex. @@ -1593,12 +1763,14 @@ def vertex_cells(self, vertex): :meth:`vertex_neighbors`, :meth:`vertex_faces`, :meth:`vertex_halffaces` """ - cells = set() - for nbr in self._plane[vertex]: - for cell in self._plane[vertex][nbr].values(): + u = vertex + cells = [] + for v in self._plane[u]: + for w in self._plane[u][v]: + cell = self._plane[u][v][w] if cell is not None: - cells.add(cell) - return list(cells) + cells.append(cell) + return cells def is_vertex_on_boundary(self, vertex): """Verify that a vertex is on a boundary. @@ -1738,17 +1910,16 @@ def edges(self, data=False): """ seen = set() - for face in self._halfface: - vertices = self._halfface[face] - for u, v in pairwise(vertices + vertices[:1]): - if (u, v) in seen or (v, u) in seen: + for vertex in self.vertices(): + for nbr in sorted(self.vertex_neighbors(vertex)): + if (vertex, nbr) in seen or (nbr, vertex) in seen: continue - seen.add((u, v)) - seen.add((v, u)) + seen.add((vertex, nbr)) + seen.add((nbr, vertex)) if not data: - yield u, v + yield vertex, nbr else: - yield (u, v), self.edge_attributes((u, v)) + yield (vertex, nbr), self.edge_attributes((vertex, nbr)) def edges_where(self, conditions=None, data=False, **kwargs): """Get edges for which a certain condition or set of conditions is true. @@ -2125,18 +2296,12 @@ def edge_halffaces(self, edge): """ u, v = edge - cells = [cell for cell in self._plane[u][v].values() if cell is not None] - cell = cells[0] halffaces = [] - if self.is_edge_on_boundary(edge): - for cell in cells: - halfface = self._cell[cell][v][u] - if self.is_halfface_on_boundary(halfface): - break - for _ in cells: - halfface = self._cell[cell][u][v] - cell = self._plane[v][u][halfface] - halffaces.append(halfface) + for w in self._plane[u][v]: + cell = self._plane[u][v][w] + if cell is not None: + face = self._cell[cell][u][v] + halffaces.append(face) return halffaces def edge_cells(self, edge): @@ -2449,9 +2614,10 @@ def faces(self, data=False): faces = [] for face in self._halfface: key = "-".join(map(str, sorted(self.halfface_vertices(face)))) - if key not in seen: - seen.add(key) - faces.append(face) + if key in seen: + continue + seen.add(key) + faces.append(face) for face in faces: if not data: yield face @@ -2871,8 +3037,8 @@ def halfface_cell(self, halfface): :meth:`halfface_opposite_cell` """ - u, v = self._halfface[halfface][:2] - return self._plane[u][v][halfface] + u, v, w = self._halfface[halfface][:3] + return self._plane[u][v][w] def halfface_opposite_cell(self, halfface): """The cell to which the opposite halfface belongs to. @@ -2892,8 +3058,8 @@ def halfface_opposite_cell(self, halfface): :meth:`halfface_cell` """ - u, v = self._halfface[halfface][:2] - return self._plane[v][u][halfface] + u, v, w = self._halfface[halfface][:3] + return self._plane[w][v][u] def halfface_opposite_halfface(self, halfface): """The opposite face of a face. @@ -2918,9 +3084,9 @@ def halfface_opposite_halfface(self, halfface): For a boundary face, the opposite face is None. """ - u, v = self._halfface[halfface][:2] - nbr = self._plane[v][u][halfface] - return None if nbr is None else self._cell[nbr][v][u] + u, v, w = self._halfface[halfface][:3] + nbr = self._plane[w][v][u] + return None if nbr is None else self._cell[nbr][w][v] def halfface_adjacent_halfface(self, halfface, halfedge): """Return the halfface adjacent to the halfface across the halfedge. @@ -3713,7 +3879,7 @@ def cell_vertices(self, cell): Notes ----- - This method is similar to :meth:`~compas.datastructures.HalfEdge.vertices`, + This method is similar to :meth:`~compas.datastructures.Mesh.vertices`, but in the context of a cell of the `VolMesh`. """ @@ -3738,7 +3904,7 @@ def cell_halfedges(self, cell): Notes ----- - This method is similar to :meth:`~compas.datastructures.HalfEdge.halfedges`, + This method is similar to :meth:`~compas.datastructures.Mesh.halfedges`, but in the context of a cell of the `VolMesh`. """ @@ -3766,11 +3932,11 @@ def cell_edges(self, cell): Notes ----- - This method is similar to :meth:`~compas.datastructures.HalfEdge.edges`, + This method is similar to :meth:`~compas.datastructures.Mesh.edges`, but in the context of a cell of the `VolMesh`. """ - raise NotImplementedError + return list(set(self.cell_halfedges(cell))) def cell_faces(self, cell): """The faces of a cell. @@ -3791,7 +3957,7 @@ def cell_faces(self, cell): Notes ----- - This method is similar to :meth:`~compas.datastructures.HalfEdge.faces`, + This method is similar to :meth:`~compas.datastructures.Mesh.faces`, but in the context of a cell of the `VolMesh`. """ @@ -3823,7 +3989,7 @@ def cell_vertex_neighbors(self, cell, vertex): ----- All of the returned vertices are part of the cell. - This method is similar to :meth:`~compas.datastructures.HalfEdge.vertex_neighbors`, + This method is similar to :meth:`~compas.datastructures.Mesh.vertex_neighbors`, but in the context of a cell of the `VolMesh`. """ @@ -3861,7 +4027,7 @@ def cell_vertex_faces(self, cell, vertex): ----- All of the returned faces should are part of the same cell. - This method is similar to :meth:`~compas.datastructures.HalfEdge.vertex_faces`, + This method is similar to :meth:`~compas.datastructures.Mesh.vertex_faces`, but in the context of a cell of the `VolMesh`. """ @@ -3896,7 +4062,7 @@ def cell_halfedge_face(self, cell, halfedge): Notes ----- - This method is similar to :meth:`~compas.datastructures.HalfEdge.halfedge_face`, + This method is similar to :meth:`~compas.datastructures.Mesh.halfedge_face`, but in the context of a cell of the `VolMesh`. """ @@ -3947,7 +4113,7 @@ def cell_face_neighbors(self, cell, face): Notes ----- - This method is similar to :meth:`~compas.datastructures.HalfEdge.face_neighbors`, + This method is similar to :meth:`~compas.datastructures.Mesh.face_neighbors`, but in the context of a cell of the `VolMesh`. """ @@ -3977,10 +4143,12 @@ def cell_neighbors(self, cell): """ nbrs = [] - for face in self.cell_faces(cell): - nbr = self.halfface_opposite_cell(face) - if nbr is not None: - nbrs.append(nbr) + for u in self._cell[cell]: + for face in self._cell[cell][u].values(): + a, b, c = self._halfface[face][:3] + nbr = self._plane[c][b][a] + if nbr is not None: + nbrs.append(nbr) return nbrs def is_cell_on_boundary(self, cell): @@ -4027,11 +4195,51 @@ def cell_points(self, cell): See Also -------- - :meth:`cell_polygon`, :meth:`cell_centroid`, :meth:`cell_center` + :meth:`cell_lines`, :meth:`cell_polygons` """ return [self.vertex_point(vertex) for vertex in self.cell_vertices(cell)] + def cell_lines(self, cell): + """Compute the lines of the edges of a cell. + + Parameters + ---------- + cell : int + The identifier of the cell. + + Returns + ------- + list[:class:`compas.geometry.Line`] + The lines of the edges of the cell. + + See Also + -------- + :meth:`cell_points`, :meth:`cell_polygons` + + """ + return [self.edge_line(edge) for edge in self.cell_edges(cell)] + + def cell_polygons(self, cell): + """Compute the polygons of the faces of a cell. + + Parameters + ---------- + cell : int + The identifier of the cell. + + Returns + ------- + list[:class:`compas.geometry.Polygon`] + The polygons of the faces of the cell. + + See Also + -------- + :meth:`cell_points`, :meth:`cell_lines` + + """ + return [self.face_polygon(face) for face in self.cell_faces(cell)] + def cell_centroid(self, cell): """Compute the point at the centroid of a cell. diff --git a/tests/compas/datastructures/test_volmesh.py b/tests/compas/datastructures/test_volmesh.py index ac70832effe..d0100d4bcba 100644 --- a/tests/compas/datastructures/test_volmesh.py +++ b/tests/compas/datastructures/test_volmesh.py @@ -27,6 +27,7 @@ def halfface(): def test_halfface_data(halfface): + # type: (VolMesh) -> None other = VolMesh.__from_data__(json.loads(json.dumps(halfface.__data__))) assert halfface.__data__ == other.__data__ @@ -160,7 +161,7 @@ def test_edges_where(): hf.add_vertex(vkey) hf.add_halfface([0, 1, 2]) hf.edge_attribute((0, 1), "a", 5) - assert list(hf.edges_where({"a": 1})) == [(1, 2), (2, 0)] + assert list(hf.edges_where({"a": 1})) == [(0, 2), (1, 2)] def test_edges_where_predicate():