import numpy as np
import iglAdjacency-based operators on half-edge meshes
Using the HeMesh data structure, we can efficiently “traverse” our mesh. Using such traversals, one can express many adjacency-based operators, for example:
- Sum over all half-edges “incoming” to a vertex (special case: count the incoming edges, i.e., compute the coordination number)
- Compute the finite-element gradient of a function defined on vertices
- Find the minimum of a per-vertex function across the neighbors of a vertex
These operations can be done efficiently using a “gather/scatter” approach, see jax.numpy.ndarray.at. There is no need to explicitly instantiate a matrix for the operators.
All operators defined in this notebook depend only on the mesh topology, not the geometry (vertex/face positions). All functions are compatible with jax.jit and jax.vmap.
Note on boundary half-edges: On meshes with boundary, some half-edges are boundary half-edges (
heface == -1). These are included in scatter-add operations to vertices (e.g.sum_he_to_vertex_incoming), which is correct for most use cases (e.g., coordination number counts all neighbors). If you need to exclude boundary contributions, mask withhemesh.is_bdry_hebefore calling.
from triangulax.triangular import TriMesh# load test data
mesh = TriMesh.read_obj("../test_meshes/disk.obj")
hemesh = msh.HeMesh.from_triangles(mesh.vertices.shape[0], mesh.faces)
geommesh = msh.GeomMesh(*hemesh.n_items, mesh.vertices, mesh.face_positions)
mesh_3d = TriMesh.read_obj("../test_meshes/disk.obj", dim=3)
geommesh_3d = msh.GeomMesh(*hemesh.n_items, mesh_3d.vertices, mesh_3d.face_positions)Warning: readOBJ() ignored non-comment line 3:
o flat_tri_ecmc
Warning: readOBJ() ignored non-comment line 3:
o flat_tri_ecmc
Discrete (exterior) derivative
On a triangular mesh, there are two natural “derivatives”: for a per-vertex field, the difference across half-edges, and for a per-half-edge field, the circulation around a face (this is the basis of discrete exterior calculus).
get_exterior_circulation
def get_exterior_circulation(
hemesh:HeMesh, he_field:Float[Array, 'n_hes ...']
)->Float[Array, 'n_faces ...']:
Discrete exterior derivative: circulation of a half-edge field around each face.
get_exterior_gradient
def get_exterior_gradient(
hemesh:HeMesh, v_field:Float[Array, 'n_vertices ...']
)->Float[Array, 'n_hes ...']:
Discrete exterior derivative: difference of a vertex field across each half-edge.
# define a random scalar field on vertices and compute its gradient on halfedges
v_field = jax.random.uniform(jax.random.PRNGKey(0), (hemesh.n_vertices,))
he_gradient = get_exterior_gradient(hemesh, v_field)
f_circulation = get_exterior_circulation(hemesh, he_gradient)
hemesh, he_gradient.shape, f_circulation.shape, jnp.allclose(f_circulation, 0 )(HeMesh(N_V=131, N_HE=708, N_F=224), (708,), (224,), Array(True, dtype=bool))
Summing over adjacent mesh elements
A second important class of operation is summing over adjacent mesh elements. For example, to get the coordination number of a vertex, you want to sum the value \(1\) over all incoming half-edges. For computing things like cell areas, it’s also useful to sum over half-edges opposite to a vertex.
sum_he_to_vertex_opposite
def sum_he_to_vertex_opposite(
hemesh:HeMesh, he_field:Float[Array, 'n_hes ...']
)->Float[Array, 'n_vertices ...']:
Sum a half-edge field onto opposite vertices (the vertex across the face from the half-edge).
Warning: includes boundary half-edges, whose “opposite vertex” may not be meaningful.
sum_he_to_vertex_outgoing
def sum_he_to_vertex_outgoing(
hemesh:HeMesh, he_field:Float[Array, 'n_hes ...']
)->Float[Array, 'n_vertices ...']:
Sum a half-edge field onto origin vertices (scatter-add over outgoing half-edges).
Includes boundary half-edges. To exclude them, zero out the field at boundary half-edges before calling.
sum_he_to_vertex_incoming
def sum_he_to_vertex_incoming(
hemesh:HeMesh, he_field:Float[Array, 'n_hes ...']
)->Float[Array, 'n_vertices ...']:
Sum a half-edge field onto destination vertices (scatter-add over incoming half-edges).
Includes boundary half-edges. To exclude them, zero out the field at boundary half-edges before calling.
# test: sum_he_to_vertex_outgoing should equal sum_he_to_vertex_incoming of the twin field
he_field = jax.random.normal(jax.random.PRNGKey(7), (hemesh.n_hes,))
assert jnp.allclose(sum_he_to_vertex_outgoing(hemesh, he_field),
sum_he_to_vertex_incoming(hemesh, he_field[hemesh.twin]))
print("sum_he_to_vertex_outgoing test passed")sum_he_to_vertex_outgoing test passed
sum_face_to_he
def sum_face_to_he(
hemesh:HeMesh, f_field:Float[Array, 'n_faces ...']
)->Float[Array, 'n_hes ...']:
Sum face-field to half-edges. Each half-edge receives the sum of its face and its twin’s face.
Boundary half-edges (heface == -1) contribute zero from the boundary side.
sum_he_to_face
def sum_he_to_face(
hemesh:HeMesh, he_field:Float[Array, 'n_hes ...']
)->Float[Array, 'n_faces ...']:
Sum over the three half-edges of each face. Same as get_exterior_circulation.
# test sum_face_to_he: each interior half-edge should get f[face] + f[twin's face]
f_field = jax.random.normal(jax.random.PRNGKey(42), (hemesh.n_faces,))
he_summed = sum_face_to_he(hemesh, f_field)
# verify on an interior half-edge
interior_he = jnp.where(~hemesh.is_bdry_he, size=1)[0][0]
expected = f_field[hemesh.heface[interior_he]] + f_field[hemesh.heface[hemesh.twin[interior_he]]]
assert jnp.allclose(he_summed[interior_he], expected), "sum_face_to_he mismatch on interior he"
# boundary half-edges: the boundary side contributes zero
if hemesh.is_bdry_he.any():
bdry_he = jnp.where(hemesh.is_bdry_he, size=1)[0][0]
# boundary he has heface == -1, so its contribution should be 0
twin_face = hemesh.heface[hemesh.twin[bdry_he]]
expected_bdry = f_field[twin_face] # only the twin's face contributes
assert jnp.allclose(he_summed[bdry_he], expected_bdry), "sum_face_to_he mismatch on boundary he"
print("boundary he test passed")
print("sum_face_to_he test passed, shape:", he_summed.shape)boundary he test passed
sum_face_to_he test passed, shape: (708,)
average_face_to_vertex
def average_face_to_vertex(
hemesh:HeMesh, f_field:Float[Array, 'n_faces ...']
)->Float[Array, 'n_vertices ...']:
Average face-field to vertices (uniform weights, i.e., divided by number of incident faces).
sum_face_to_vertex
def sum_face_to_vertex(
hemesh:HeMesh, f_field:Float[Array, 'n_faces ...']
)->Float[Array, 'n_vertices ...']:
Sum face-field to vertices. Each vertex receives the sum over its incident faces.
average_vertex_to_face
def average_vertex_to_face(
hemesh:HeMesh, v_field:Float[Array, 'n_vertices ...']
)->Float[Array, 'n_faces ...']:
Average vertex-field to faces (uniform 1/3 weights).
sum_vertex_to_face
def sum_vertex_to_face(
hemesh:HeMesh, v_field:Float[Array, 'n_vertices ...']
)->Float[Array, 'n_faces ...']:
Sum vertex-field to faces. Sums over the three vertices of each face.
# tests vs libigl
key = jax.random.PRNGKey(123)
u_v = jax.random.normal(key, (hemesh.n_vertices,))
faces_avg_jax = average_vertex_to_face(hemesh, u_v)
faces_avg_igl = igl.average_onto_faces(np.asarray(hemesh.faces), np.asarray(u_v))
rel_err_faces = jnp.linalg.norm(faces_avg_jax - faces_avg_igl) / jnp.linalg.norm(faces_avg_igl)
print("vertex->face rel. error:", rel_err_faces)vertex->face rel. error: 0.0
u_f = jax.random.normal(key, (hemesh.n_faces,))
verts_avg_jax = average_face_to_vertex(hemesh, u_f)
verts_avg_igl = igl.average_onto_vertices(mesh.vertices, np.asarray(hemesh.faces), np.asarray(u_f))
rel_err_verts = jnp.linalg.norm(verts_avg_jax-verts_avg_igl) / jnp.linalg.norm(verts_avg_igl)
print("face->vertex rel. error:", rel_err_verts)face->vertex rel. error: 8.339340577730768e-17
# also works for vector fields
u_f = jax.random.normal(key, (hemesh.n_faces, 10))
verts_avg_jax = average_face_to_vertex(hemesh, u_f)
verts_avg_jax.shape(131, 10)
get_coordination_number
def get_coordination_number(
hemesh:HeMesh
)->Float[Array, 'n_vertices']:
Coordination number (vertex degree): number of neighboring vertices.
Includes boundary half-edges, so boundary vertices count all their neighbors.
get_coordination_number(hemesh).mean()Array(5.40458015, dtype=float64)
Uniform/graph Laplacian
The uniform (or graph) Laplacian is \(L = D - A\), where \(D\) is the diagonal degree matrix and \(A\) is the adjacency matrix. It is positive semi-definite, with a zero eigenvalue for constant fields. This purely topological Laplacian ignores edge lengths and angles; for the geometry-aware cotangent Laplacian, see linops.
get_uniform_laplacian
def get_uniform_laplacian(
hemesh:HeMesh
)->BCOO:
Assemble the uniform Laplacian as a sparse BCOO matrix. Non-normalized, positive semi-definite.
compute_uniform_laplacian
def compute_uniform_laplacian(
hemesh:HeMesh, v_field:Float[Array, 'n_vertices ...']
)->Float[Array, 'n_vertices ...']:
Apply the uniform Laplacian to a vertex field. Non-normalized, positive semi-definite.
# test that the matrix and function versions are equivalent
laplace_mat = get_uniform_laplacian(hemesh)
jnp.allclose(laplace_mat @ v_field, compute_uniform_laplacian(hemesh, v_field)), jnp.dot(laplace_mat@v_field, v_field) > 0(Array(True, dtype=bool), Array(True, dtype=bool))