We often need to not only move the vertices of a mesh, but modify the connectivity. In half-edge meshes, there are several “elementary” mesh modifications. For triangulax, by far the most important one is the edge flip (see below). It is the only modification that preserves the number of all mesh elements, and is thus relatively easy to make compatible with JAX and differentiable programming.
Edge flips / T1s
In our simulations, cells will exchange neighbors (T1-event). In the triangulation, this corresponds to an edge flip. We now implement the edge flip algorithm for HeMeshes. We basically edit the various connectivity arrays (in a JAX-compatible way).
The algorithm (and the naming conventions in flip_edge) are from here.
See https://jerryyin.info/geometry-processing-algorithms/half-edge/. The algorithm is slightly modified since we keep track of the origin and destination of a half-edge, and use arrays instead of pointers. Returns a new HeMesh, does not modify in-place.
# flip edge and recompute face positionsflipped_hemesh = flip_edge(hemesh, e=335)flipped_geommesh = msh.set_voronoi_face_positions(geommesh, flipped_hemesh)
# connectivity is still validigl.is_edge_manifold(hemesh.faces)[0], igl.is_edge_manifold(flipped_hemesh.faces)[0], flipped_hemesh.iterate_around_vertex(100)
# you can see the flipped edge between vertices 126-117 in the plot below (middle right)fig = plt.figure(figsize=(8,8))plt.triplot(*geommesh.vertices.T, hemesh.faces)plt.triplot(*flipped_geommesh.vertices.T, flipped_hemesh.faces)ax = plt.gca()p1 = msh.cellplot(hemesh, geommesh.face_positions, cell_colors=np.array([0.,0.,0.,0.]), mpl_polygon_kwargs={"lw": 1, "ec": "k"})p2 = msh.cellplot(flipped_hemesh, flipped_geommesh.face_positions, cell_colors=np.array([0.,0.,0.,0.]), mpl_polygon_kwargs={"lw": 1, "ec": "tab:orange"})ax.add_collection(p1)ax.add_collection(p2)plt.axis("equal")msh.label_plot(geommesh.vertices, hemesh.faces, fontsize=10, face_labels=False)
Repeated flips
In a simulation, we need to carry out edge flips at every time step. The function flip_edge(hemesh: HeMesh, e: int) -> HeMesh does a single edge flip by modifying the connectivity arrays. Luckily, it is already JAX-compatible (we can JIT-compile it).
To carry out multiple flips, we must do the flips in sequence (otherwise, you risk leaving the mesh in an invalid state). To make things JAX-compatible, we do a jax.lax.scan scan over all half-edges.
Warning: readOBJ() ignored non-comment line 3:
o flat_tri_ecmc
# let's detect all edges with negative dual length, and flip them.dual_lengths = msh.get_signed_dual_he_length(geommesh.vertices, geommesh.face_positions, hemesh)edges = jnp.where((dual_lengths <-0.05) &~hemesh.is_bdry_edge & hemesh.is_unique)[0]# we only want to flip unique hes!edges, edges.size
The edge flip is the only topological modification of a half-edge mesh that leaves the number of vertices, edges, and faces constant. This makes it especially easy, and compatible with JAX’s “static array size” paradigm.
However, we may also want to simulate processes (like cell division or death) where the number of cells does change. Let’s implement two elementary operation, which are inverses of another: edge collapse and vertex split. Let’s start with edge collapse.
Merge the vertices hemesh.orig[e], hemesh.dest[e] We must be careful to preserve the manifold structure of the mesh, deal with edge cases. We can test the resulting half-edge mesh via plots, and use libigl to verify that the mesh is in a valid state.
Finally, we also need a data structure to keep track of the map from the vertices/edges/faces of the initial mesh to that of the modified one.
Collapse half-edge e in a half-edge mesh. Keeps the origin vertex of e.
Returns a new HeMesh (does not modify in-place), and three arrays for remapping vertex, half-edge, and face indices from the original mesh to the new mesh.
JIT-compatible, but calling with different numbers of vertices/edges/faces will cause recompilation.
# test on the existing example mesh. pick some interior, unique half-edgecandidates = np.where(np.asarray(hemesh.is_unique & (~hemesh.is_bdry_edge)))[0]e_collapse = candidates[40]print("Collapsing half-edge", e_collapse, "with vertices", int(hemesh.orig[e_collapse]), int(hemesh.dest[e_collapse]))hemesh_collapsed, remap = collapse_edge(hemesh, e_collapse,)vertices_collapsed = geommesh.vertices[remap.v_reverse]
Next, let’s create the opposite operation - splitting a vertex into two. To do so, we need to specify two half-edges, the “splitting axis”, which meet at a common vertex. Like before, we also need a map of how hold and new mesh elements are related. We append the newly created mesh elements at the end of the different arrays.
edge manifold: True
vertex manifold: True
Valid HE mesh: True
# inverse consistency check: split then collapse the inserted edgee_join = hemesh.n_hes+1# error for 2*hemesh.n_vertices+1 ?hemesh_back, back_map = collapse_edge(hemesh_split, e_join)F0 = msh._canonical_faces_np(hemesh.faces)F_back = msh._canonical_faces_np(hemesh_back.faces)print("back to original counts?", hemesh_back.n_items == hemesh.n_items)print("back to original faces?", np.array_equal(F0, F_back))print("Valid HE mesh after collapse:", msh.test_mesh_validity(hemesh_back))
back to original counts? True
back to original faces? True
Valid HE mesh after collapse: True
Looks good - the JAX-compatible triangular-mesh data structures seem to work. In particular, the tricky T1/edge-flip function. Next steps: linear operators on meshes and geometry processing.