The half-edge data structure
The half-edge data structure (HDS), also doubly-connected edge list
(DCEL),
is a data structure constructed to represent graphs, discrete surfaces and polytopes.
In particular it encodes all the structural and combinatorial information of a given graph or surface.
These information are stored in instances of vertices, edges and faces.
As the name suggests the edges play a special role.
Every combinatorial edge is represented by two directed half-edges, each pointing at one of the incident vertices.
Furthermore, each of the half-edges belongs to a unique face (or None if the edge is a boundary edge)
which allows simple and efficient ways to handle and modify surfaces.
Not all half-edge instances must be topological surfaces but since this is often desired and also needed for visualization
in Blender this can be verified within this package, as explained in the section Validating a surface.
Anyhow, by abuse of terminology, the main class of the half-edge package, and often instances of it, are called surfaces.
The very basics and a simple half-edge surface
We start by initializing an instance of the ddg.halfedge.Surface class that can be accessed
using
>>> import ddg
>>> s = ddg.halfedge.Surface()
The instance s is a surface with no edges, vertices or faces given yet.
Using the class methods add_face() and glue() allows a simple extension
of the surface s. New vertices and edges are automatically initialized and linked to the surface.
>>> f0 = s.add_face(3)
>>> f1 = s.add_face(5)
>>> s.glue(f0.edge, f1.edge)
The above results in a purely combinatoric surface with 2 faces, 14 edges and 6 vertices.
Faces, edges and vertices are represented as classes stored in s.faces, s.edges and s.verts.
The objects themself posses different attributes, depending on the cell type:
- faces:
edge
index
- edges:
opp
nex
pre
head
tail
face
index
- vertices:
edge
index
For a face f the attribute f.edge points to a random half-edge belonging to the face and for a vertex v the attribute
v.edge points to a random half-edge that points to the vertex.
Taking a closer look at the half-edges of a surface one sees, that the union of all half-edges consists of disjoint oriented loops
of half-edges. For every edge e the attributes e.pre and e.nex allow iteration though these loops.
The “inner” of such a loop either consists of a face of s or is empty. This is represented by each edge attribute e.face that either
returns the face that is enclosed by the corresponding edge loop or returns None if the edge is a boundary edge.
Eventually half-edges are still representing the connection between two vertices, two oppositely oriented half-edges
representing one combinatorial edge. The attribute e.opp allows switching between two such half-edges connecting the
vertices v1 = e.head and v2 = e.opp.head.
Creating half-edge surfaces
There are various ways to create surfaces using the half-edge datastructure.
Next to the halfedge that we will explain in the following one can use
conversion from file for the conversion of obj (OBJ to halfedge, Conversion to OBJ File) or json (
io) files to halfedge surfacesthe
indexedfacesetpackage with its conversion functionindexed_face_set_to_surface()
It also includes a convex hull algorithm that generates a half-edge surface from a given list of 3-dimensional coordinates.
We can simply create platonic solids:
>>> import numpy as np
>>> tetrahedron = ddg.halfedge.tetrahedron()
>>> cube = ddg.halfedge.cube()
>>> octahedron = ddg.halfedge.octahedron()
>>> dodecahedron = ddg.halfedge.dodecahedron()
>>> icosahedron = ddg.halfedge.icosahedron()
or any other polytope from given vertices
>>> conv = ddg.halfedge.convexhull_3d(np.random.random((8, 3)))
It also includes a generator function for an icosphere():
>>> icosphere = ddg.halfedge.icosphere(subdivision_steps=1, radius=1)
and a cylinder()
>>> cylinder = ddg.halfedge.cylinder(
... resolution=20,
... fill_caps=True,
... top_radius=1,
... bot_radius=1,
... length=1,
... center=(0, 0, 0),
... normal=(0, 0, 1),
... co_attr="co",
... )
A cone can be created in two ways. The first is to use
cone()
which will create a cone that has n + 1 vertices, where n is the given resolution. This means that there is only
one vertex at the tip of the cone.
>>> cone = ddg.halfedge.cone(
... resolution=20,
... fill_face=True,
... radius=1,
... length=1,
... center=(0, 0, 0),
... normal=(0, 0, 1),
... co_attr="co",
... )
The other way is to use cylinder()
and set one radius to zero.
This will create a cone with 2 * n vertices, meaning that the tip of the cone has actually n vertices instead
of just one. This is useful if you want to be able to change the radius of the tip (e.g. if you want to animate the
radius).
>>> cone_as_cylinder = ddg.halfedge.cylinder(
... resolution=20, top_radius=0, bot_radius=1
... )
Furthermore, the surface generator allows us to generate grids, which are planar meshes of faces with a fixed number of vertices, as half-edge surfaces:
>>> quad_grid = ddg.halfedge.grid((5, 6, 1))
>>> triangle_grid = ddg.halfedge.grid((5, 6, 1))
The grid() function for quadrilateral grids has an extra
keyword where vertices can be specified. If vertices are given they are copied to a new surface, keeping their old
attributes. If they have a coordinate attribute co the coordinates stored in this attribute are used, other wise the
generate_coordinate keyword holds. Note that the given dimension must coincide with the number of vertices. 4
For more, see Working with grids.
One can also create an arrow, useful for vector field visualization:
>>> arrow = ddg.halfedge.arrow(
... resolution=20,
... heights=(0, 0.7, 0.7, 1),
... radii=(0.05, 0.05, 0.125),
... co_attr="co",
... )
A disc or circle embedded in 3d Euclidean space can be created using
>>> disc = ddg.halfedge.disc(
... circle_subdivisions=20,
... fill_face=True,
... radius=1,
... center=(0, 0, 0),
... normal=(0, 0, 1),
... co_attr="co",
... )
>>> circle = ddg.halfedge.disc(
... circle_subdivisions=20,
... fill_face=False,
... radius=1,
... center=(0, 0, 0),
... normal=(0, 0, 1),
... co_attr="co",
... )
Accessing the surfaces’ data
All the information about a half-edge surface is stored in its vertices edges and faces.
Information can be accessed using the ddg.halfedge module.
We start with a cube:
>>> cube = ddg.halfedge.cube()
The cells of the surface are stored in classes and we can easily find the number of cells where the class name is extended by the memory address of the surface:
>>> cube.verts
<class 'ddg.halfedge._node.Verts...'>
>>> cube.edges
<class 'ddg.halfedge._node.Edges...'>
>>> cube.faces
<class 'ddg.halfedge._node.Faces...'>
>>> len(cube.verts), len(cube.edges), len(cube.faces)
(8, 24, 6)
These classes are not subscriptable but iterable:
>>> for v in cube.verts:
... pass
...
Sometimes it is handy to just pick out a single cell:
>>> ddg.halfedge.some_vertex(cube)
Verts(index=0, edge=2, surface=...)
>>> ddg.halfedge.some_edge(cube)
Edges(index=0, head=5, face=0, surface=...)
>>> ddg.halfedge.some_face(cube)
Faces(index=0, edge=0, surface=...)
Functions in the halfedge module that are built to yield multiple
instances of cells will return generators. For example, the following two generators will yield the same edges:
>>> f = ddg.halfedge.some_face(cube)
>>> ddg.halfedge.edge_loop_from_face(f)
<generator object edge_loop at 0x...>
>>> ddg.halfedge.edge_loop(f.edge)
<generator object edge_loop at 0x...>
If only the number of instances are of interest one can either use the implemented functions or work with list comprehensions:
>>> assert ddg.halfedge.number_of_edges(f) == ddg.halfedge.count_edges_in_loop(
... f.edge
... )
>>> assert len([e for e in ddg.halfedge.in_edges(v)]) == len(
... [e for e in ddg.halfedge.out_edges(v)]
... )
Another handy accessory of this module is dealing with interior and boundary edges.
The functions interior_vertices() and
boundary_vertices() return generators that yield all the desired
vertices. Similar functions exist for edges:
>>> [e.face for e in ddg.halfedge.boundary_edges(cube)]
[]
>>> len([e.face for e in ddg.halfedge.interior_edges(cube)])
24
They use is_boundary_vertex() and
is_boundary_edge() which return a bool.
Modifying a surface
A great part of working with half-edge sufaces is the modification of a given
surface. These modification can for example include joining, attaching and
glueing or anything about separating, splitting and removing. Next to the
Surface methods
add_vertex(),
add_edge(),
add_face() and
glue(), the half-edge
package allows much simpler and more efficient ways. The module
ddg.halfedge contains a variety of
functions to modify a given surface.
In the following we will give a variety of small code snippets and supply pictures of their effects on a given surface. For the creation of the pictures coordinates where explicitly set for vertices, see the next section Adding attributes to vertices, edges and faces. Thus they only serve as a rough orientation, essentially all surfaces in this section are purely combinatoric.
In order to apply various utility functions we always start with the simple surface from the beginning:
>>> s = ddg.halfedge.Surface()
>>> f0 = s.add_face(3)
>>> f1 = s.add_face(5)
>>> s.glue(f0.edge, f1.edge)
Removing and adding faces:
>>> ddg.halfedge.remove_face(f1)
>>> boundary = ddg.halfedge.boundary_edges(s)
>>> e = next(boundary)
>>> while e.face is not None or e.opp.face is not None:
... e = next(boundary)
...
>>> f1 = ddg.halfedge.fill_hole(e)
>>> f1
Faces(index=1, edge=6, surface=...)
Note
When calling remove_face() the edge loop that corresponds to the
input face is not deleted. Therefore after removing the face we can find edges of this loop and fill the hole of this
edge loop with a face using fill_hole().
Subdividing and joining faces by hand:
>>> edge = f1.edge.pre
>>> e0, e1, v = ddg.halfedge.subdivide_edge(edge, 2)
>>> new_edge = ddg.halfedge.split_face_at(f1, v[0], f1.edge.nex.head)
>>> ddg.halfedge.join_neighbouring_faces(new_edge)
Stellar subdivision using stellar_subdivide():
>>> v = ddg.halfedge.stellar_subdivide(f1)
>>> v
Verts(index=7, edge=11, surface=...)
Deleting a vertex along with all its connecting edges and faces using remove_vertex():
>>> v = ddg.halfedge.some_vertex(s)
>>> ddg.halfedge.remove_vertex(v)
Extending polygons to 3-dimensional polytopes:
>>> import ddg
>>> s = ddg.halfedge.Surface()
>>> f0 = s.add_face(5)
>>> v = ddg.halfedge.attach_pyramid(f0.edge.opp)
To extend a polygon to a 3-dimensional polytope we can for example use the functions
attach_pyramid() or
bridge_loops():
>>> s = ddg.halfedge.Surface()
>>> f0 = s.add_face(5)
>>> f1 = s.add_face(5)
>>> ddg.halfedge.bridge_loops(f0.edge, f1.edge)
The methods add_vertex()
just adds a single isolated vertex. The function
add_edge() connects two
vertices with a “whole” edge:
>>> s = ddg.halfedge.Surface()
>>> v1 = s.add_vertex()
>>> v2 = s.add_vertex()
>>> e = s.add_edge(v1, v2)
>>> e.head is v2
True
>>> e.opp.head is v1
True
>>> s.validate_curve()
In contrast to the functions mentioned above, these functions will not preserve the manifold property.
Adding attributes to vertices, edges and faces
Adding attributes to vertices, edges and faces of surfaces is an essential part.
New attributes can be initialized using add_attribute of the _node class.
The most commonly used attribute is the "co" attribute for vertices that stores 3-dimensional coordinates of
the corresponding vertices of the surface.
It is possible to name the vertex attribute storing the coordinates differently.
Functions that depend on the coordinate attribute support a keyword argument co_attr="co".
Coordinates can be assigned to the vertices in the following way.:
>>> import ddg
>>> s = ddg.halfedge.Surface()
>>> f0 = s.add_face(3)
>>> f1 = s.add_face(5)
>>> s.glue(f0.edge, f1.edge)
>>> co_attr = s.verts.add_attribute("co")
>>> l = [
... [0.5, 0, 0],
... [0.7, 0.4, 0],
... [0.5, 0.8, 0],
... [0, 0.8, 0],
... [-0.5, 0, 0],
... [0, 0, 0],
... ]
>>> for i, v in enumerate(s.verts):
... v.co = l[i]
... # Similarly one could assign the coordinates by
... # co_attr[v] = l[i]
...
Similarly attributes can be assigned to edges and faces:
>>> s.edges.add_attribute("len")
<ddg.halfedge._node.NodeAttribute object at 0x...>
>>> s.faces.add_attribute("is_triangle", False)
<ddg.halfedge._node.NodeAttribute object at 0x...>
After the initialization above all edges have a e.len attribute that is by default set to None and all faces have a
f.is_triangle attribute that, by default, is set to False.
Utilities for setting (common) attributes
The module set contains functions to compute and set useful attributes
of halfedge surfaces.
The most general example is setting an attribute computed by a custom function on the desired cells:
set_attr_by_function()
>>> hds = ddg.halfedge.triangle_grid((3, 3, 1))
>>> def neighbouring_faces(face):
... return len(
... list(
... [
... e
... for e in ddg.halfedge.edge_loop(face.edge)
... if e.opp.face is not None
... ]
... )
... )
...
>>> attribute = ddg.halfedge.set_attr_by_function(
... hds,
... neighbouring_faces,
... cell_type="faces",
... attr_name="number_of_neighbouring_faces",
... )
More specific examples are given by set_euclidean_length_attr()
that assigns the euclidean edge length to edges (also see Modifying surfaces with coordinates),
or by set_valency_attr(),
that assigns a valency attribute to either vertices or faces that stores the number of neighbouring cells of the same kind.
To get the same result as in the above example
one can use
>>> attribute = ddg.halfedge.set_valency_attr(
... hds, cell_type="faces", attr_name="number_of_neighbouring_faces"
... )
An even more specific example is given by
set_minimal_valency_attr(), that can be used to identify the
corners of regular halfedge surface:
>>> grid = ddg.halfedge.grid((5, 5, 1))
>>> diag_grid1, diag_grid2 = ddg.halfedge.diagonals(grid)
>>> attr1 = ddg.halfedge.set_minimal_valency_attr(
... grid, attr_name="is_corner_vertex", attr_values=[1, 0]
... )
>>> attr2 = ddg.halfedge.set_minimal_valency_attr(
... diag_grid1, attr_name="is_corner_vertex", attr_values=[1, 0]
... )
>>> attr3 = ddg.halfedge.set_minimal_valency_attr(
... diag_grid2, attr_name="is_corner_vertex", attr_values=[1, 0]
... )
>>> len([v for v in grid.verts if attr1[v]])
4
A different utility that can be found in the module
allows simple bi-coloring of hds using bicolor_vertices(),
bicolor_edges() or
bicolor_faces() for a bi-coloring of vertices, edges or
faces, respectively.:
>>> v0, v1 = ddg.halfedge.bicolor_vertices(
... grid, color_attr="color", colors=["white", "black"]
... )
>>> v0, v1 = ddg.halfedge.bicolor_vertices(
... diag_grid1, color_attr="color", colors=["white", "black"]
... )
>>> v0, v1 = ddg.halfedge.bicolor_vertices(
... diag_grid2, color_attr="color", colors=["white", "black"]
... )
>>> e0, e1 = ddg.halfedge.bicolor_edges(
... grid, color_attr="color", colors=["white", "black"]
... )
>>> e0, e1 = ddg.halfedge.bicolor_edges(
... diag_grid1, color_attr="color", colors=["white", "black"]
... )
>>> e0, e1 = ddg.halfedge.bicolor_edges(
... diag_grid2, color_attr="color", colors=["white", "black"]
... )
>>> diag_grid_3 = ddg.halfedge.diagonals(grid, boundary_face_incidence=3)[0]
>>> f0, f1 = ddg.halfedge.bicolor_faces(
... diag_grid_3, color_attr="color", colors=["white", "black"]
... )
>>> f0, f1 = ddg.halfedge.bicolor_faces(
... grid, color_attr="color", colors=["white", "black"]
... )
>>> f0, f1 = ddg.halfedge.bicolor_faces(
... diag_grid1, color_attr="color", colors=["white", "black"]
... )
>>> cylinder = ddg.halfedge.cylinder(fill_caps=False)
>>> f0, f1 = ddg.halfedge.bicolor_faces(
... hds, color_attr="color", colors=["black", "white"]
... )
Note
To visualize edges and points individually you can use ddg.blender.edges() and ddg.blender.scatter(). See the blender visualization guide
Modifying surfaces with coordinates
In addition to the previously mentioned utilities there are more utility functions that work with surfaces that have
3-dimensional coordinates set on the vertices.
The function set_euclidean_length_attr() computes the Euclidean length
of the edges:
>>> length_attr = ddg.halfedge.set_euclidean_length_attr(
... grid, co_attr="co", attr_name="length"
... )
>>> ddg.halfedge.some_edge(grid).length
1.0
By default, the grid possesses 2-dimensional coordinates stored in a vertex attribute with the name co.
With join_coplanar_faces() we can numerically test
whether two adjacent faces are coplanar and if so we can join them:
>>> s = ddg.halfedge.convexhull_3d(
... [
... [1, 1, 1],
... [1, -1, 1],
... [1, 1, -1],
... [1, -1, -1],
... [1, 0, 1.5],
... [1, 0, -1.5],
... [1, -1.5, 0],
... [1, 1.5, 0],
... [-1, 1, 1],
... [-1, -1, 1],
... [-1, 1, -1],
... [-1, -1, -1],
... ],
... False,
... )
>>> len(s.faces)
20
>>> ddg.halfedge.join_coplanar_faces(s)
>>> len(s.faces)
14
In some cases it might be convenient to reverse the orientation of a surface which can be done using the function
reverse_orientation().
Note
The function reverse_orientation() reverses the orientation of
all edges of a surface. It might be convenient to do this for single connected components but it does not make sense
for single faces or edges.
The reverse_orientation() function can, for example, be used to
simplify the visualization process of a surface created by bridge_loops():
>>> s = ddg.halfedge.Surface()
>>> f0 = s.add_face(5)
>>> co_attr = s.verts.add_attribute("co")
>>> l = [[0.5, 0, 0], [0.7, 0.4, 0], [0.5, 0.8, 0], [0, 0.8, 0], [0, 0, 0]]
>>> for k, v in enumerate(ddg.halfedge.face_vertices(f0)):
... v.co = l[k]
...
>>> ddg.halfedge.reverse_orientation(s)
>>> f1 = s.add_face(5)
>>> for k, v in enumerate(ddg.halfedge.face_vertices(f1)):
... l[k][2] += 1
... v.co = l[k]
...
>>> ddg.halfedge.bridge_loops(f0.edge, f1.edge)
Copy and unify surfaces
The module copy contains functions to copy and deepcopy a halfedge object and
a function to unify several connected components to a single object.
>>> cube = ddg.halfedge.cube()
>>> cube_copy = ddg.halfedge.copy(cube, verts_attr_list=["co"])
One can specify vertex, edge and face attributes to be copied by handing over lists
(verts_attr_list, edges_attr_list, faces_attr_list) of the string names of the
attributes to be copied.
The same keyword arguments can be used to take over attributes when unifying multiple surfaces
(union()).
All objects to be unified need to have an attribute of the given name.
>>> tetrahedron = ddg.halfedge.tetrahedron()
>>> cube_tetrahedron = ddg.halfedge.union(cube, tetrahedron, verts_attr_list=["co"])
The copy() function further allows to add attributes to the copied cells
that point to the original cells
>>> list(cube.verts)[0]
Verts(index=0, edge=2, surface=...)
>>> cube_copy_linked = ddg.halfedge.copy(
... cube,
... original_vertex_attr="original_vertex",
... original_edge_attr="original_edge",
... original_face_attr="original_face",
... )
>>> list(cube_copy_linked.verts)[0].original_vertex
Verts(index=0, edge=2, surface=...)
If only a combinatorial copy of a halfedge object is needed, i.e. without any attributes,
combinatorial_copy() can be used.
Working with grids
The halfedge contains a
grid() method that creates a grid of m times n quadrilaterals
and a triangle_grid() method that creates a grid of m times n
triangles.
Various functions to modify grids are located at ddg.halfedge.
Creating grids and bi-coloring a quadrilateral grid using bicolor_vertices():
>>> grid = ddg.halfedge.grid((7, 5, 1))
>>> triangle_grid = ddg.halfedge.triangle_grid((7, 5, 1))
>>> v0, v1 = ddg.halfedge.bicolor_vertices(grid)
>>> v0[0].color, v1[0].color
(0, 1)
The module ddg.halfedge includes functions to modify grids.
Finding parallel lines of a quadrilateral grid using coordinate_polylines()
and coordinate_polyline():
>>> x_lines, y_lines = ddg.halfedge.coordinate_polylines(grid)
>>> len(x_lines), len(y_lines)
(5, 7)
>>> x_line = ddg.halfedge.coordinate_polyline(ddg.halfedge.some_edge(grid))
>>> len(x_line)
6
Creating two grids of diagonal combinatoric to the given grid using
ddg.halfedge.diagonals():
>>> diagonal_grid0, diagonal_grid1 = ddg.halfedge.diagonals(
... grid, boundary_face_incidence=4, append_single_edges=True
... )
>>> diagonal_grid0
<ddg.halfedge._surface.Surface object at 0x...>
>>> grid0, grid1 = ddg.halfedge.diagonals(
... diagonal_grid0, boundary_face_incidence=4, append_single_edges=True
... )
>>> grid0
<ddg.halfedge._surface.Surface object at 0x...>
The keyword argument append_single_edges decides whether to attach edges in
the corners of a diagonal grid (making it lose the manifold property). The
keyword argument boundary_face_incidence states how many vertices of
another color must be adjacent to a given boundary vertex in order to create a
face.
>>> diagonal_grid0, diagonal_grid1 = ddg.halfedge.diagonals(
... grid, boundary_face_incidence=3, append_single_edges=True
... )
>>> diagonal_grid0, diagonal_grid1 = ddg.halfedge.diagonals(
... grid, boundary_face_incidence=4, append_single_edges=False
... )
Validating a surface
Especially when modifying a surface explicitly, for example when setting new
heads, predecessors and successors of edges, it makes sense to check from time
to time whether everything has been set correctly. For this, you can use the
methods validate(),
validate_surface(),
validate_curve() and
validate_graph(),
depending on what type of object you want to represent with your halfedge
object. Basically, validate just checks for consistence of nex, opp and
so on, while the others additionally check if the object is a 2d or 1d manifold
or has faces. See the api docs for details.
It is also a very handy tool to use in tests. It can be executed by:
>>> s.validate()
>>> s.validate_surface()
>>> s.validate_curve()
Traceback (most recent call last):
...
ddg.halfedge._surface.SurfaceError: Object has faces.
If everything was set up fine the code will continue and otherwise it will raise a SurfaceError. It is also possible to check whether a given surface consists of triangulated faces:
>>> ddg.halfedge.is_triangulation(cube)
False
There is also a function to check whether your object is connected:
>>> ddg.halfedge.is_connected(cube)
True
Visualization in Blender
A guide on how to visualize halfedge objects in Blender can be found here: Visualizing with blender.