linops: discrete gradient, divergence, and Laplacian
Building on the mesh geometry and adjacency-based operators, we can now define two important linear operators that depend both on mesh connectivity and on mesh geometry. They are the (discrete, triangulation-based) equivalent of the gradient and Laplace-Beltrami operator. The latter is known as the cotan Laplacian.
We implement gradient (per-vertex scalar field -> per-face vector field) and the cotan-Laplacian (vertex -> vertex). Both cases start with a scalar field \(u_i\) defined per vertex \(i\) of the triangulation. The finite-element gradient is defined for each face \(ijk\), like so: \[
(\nabla u)_{ijk} = \sum_{l\in \{i,j,k\}} u_l \nabla\phi_l
\] where \(\phi_i\) is a linear finite element test function (linear Lagrange element) and has gradient \[
\nabla\phi_i = \frac{1}{2a_{ijk}} (\mathbf{v}_k-\mathbf{v}_j)^\perp
\] plus cyclic permutations. Here, \(a_{ijk}\) is the triangle area, \(\mathbf{v}_i\) are the vertex positions, and \(()^\perp\) denotes rotation by 90 degrees (in 3D, you rotate about the triangle normal).
The cot-Laplacian computes the following per-vertex field: \[
(\Delta u)_i = \frac{1}{2} \sum_{j} (\cot\alpha_j +\cot\beta_j) (u_j-u_i)
\] The sum is over adjacent vertices, and \(\alpha_j, \beta_j\) are the two triangle angles “opposite” to the edge \(ij\).
To check for correctness, we can compare with this libigl tutorial, using the test mesh and some random test fields.
def scipy_to_bcoo( A, # Input sparse matrix (CSR or CSC recommended))->BCOO: # Equivalent JAX sparse matrix
Convert a SciPy sparse matrix (CSC or CSR) to a JAX BCOO sparse matrix without converting to dense.
Cotan-Laplacian
The cotangent Laplacian is the standard discretization of the Laplace-Beltrami operator on triangle meshes. It is negative semi-definite in our sign convention: for any field \(u\),
where \(w_{ij} = \frac{1}{2}(\cot\alpha_{ij} + \cot\beta_{ij})\) are the cotangent edge weights. On boundary edges only one opposite angle contributes.
The weights depend on the vertex positions only through the edge lengths, so the operator is intrinsic (see notebook 05): the implementation takes edge lengths, and the position-based version simply feeds it geometry.get_he_length. Passing edge lengths measured with a periodic displacement function instead gives the Laplacian on a torus, at no extra implementation cost - see “Periodic boundary conditions” below.
def compute_cotan_laplace_intrinsic( he_lengths:Float[Array, 'n_hes'], # Edge length per half-edge, e.g. from `geometry.get_he_length` or, under periodicboundary conditions, `periodic.get_periodic_he_lengths`. hemesh:HeMesh, # Half-edge mesh connectivity. vertex_field:Float[Array, 'n_vertices ...'], # Per-vertex scalar, vector, or tensor field. normalize:bool=False, # If True, return the area-normalized Laplace-Beltrami operator $M^{-1} L u$,dividing by the robust Voronoi cell area at each vertex.)->Float[Array, 'n_vertices ...']: # Cotangent Laplacian applied to the field, same shape as ``vertex_field``.
Compute the cotangent Laplacian of a per-vertex field from edge lengths (natural boundary conditions).
# test sparse cotan Laplacian vs apply functionkey = jax.random.PRNGKey(0)u_test = jax.random.normal(key, (hemesh.n_vertices,))L_sparse = cotan_laplace_sparse(geommesh.vertices, hemesh)lap_sparse = L_sparse @ u_testlap_apply = compute_cotan_laplace(geommesh.vertices, hemesh, u_test)rel_err_sparse = jnp.linalg.norm(lap_sparse - lap_apply) / jnp.linalg.norm(lap_apply)print("cotan sparse vs apply rel. error:", rel_err_sparse)assert rel_err_sparse <1e-10, rel_err_sparse# the sparse operator must be symmetric with vanishing row sumsassert jnp.allclose(L_sparse.todense(), L_sparse.todense().T, atol=1e-12)assert jnp.allclose(L_sparse.todense().sum(axis=1), 0, atol=1e-10)
cotan sparse vs apply rel. error: 1.7496118652518946e-16
# scipy <-> BCOO round trip must be exactassert (scipy_to_bcoo(bcoo_to_scipy(L_sparse)).todense() == L_sparse.todense()).all()assert bcoo_to_scipy(L_sparse).shape == L_sparse.shape# diag_jsparse must match np.diag for zero and non-zero offsets, and be jittablefor k in [0, 1, -1, 2]:assert jnp.allclose(diag_jsparse(jnp.arange(1., 6.), k).todense(), jnp.diag(jnp.arange(1., 6.), k))assert jnp.allclose(jax.jit(diag_jsparse)(jnp.arange(1., 6.)).todense(), jnp.diag(jnp.arange(1., 6.)))
Mass matrix (lumped)
The finite-element mass matrix \(M\) appears whenever we discretize a time-dependent PDE. For a lumped (diagonal) mass matrix, \(M_{ii} = A_i\) where \(A_i\) is the Voronoi area associated with vertex \(i\).
def mass_matrix_sparse_intrinsic( he_lengths:Float[Array, 'n_hes'], # Edge length per half-edge, e.g. from `geometry.get_he_length` or, under periodicboundary conditions, `periodic.get_periodic_he_lengths`. hemesh:HeMesh, # Half-edge mesh connectivity. area_type:str='voronoi', # Choice of dual-cell area definition used on the diagonal.``"voronoi"`` is the *mixed* Voronoi area of Meyer et al., which is what``igl.massmatrix(..., MASSMATRIX_TYPE_VORONOI)`` computes andis alwayspositive; this is the right choice for the cotangent Laplacian.``"voronoi_exact"`` is the exact (signed) circumcentric area, which can beNEGATIVE on obtuse triangles and then destroys positive-definiteness of``M - dt*L``. ``"barycentric"`` is a simpler always-positive approximation.)->BCOO: # Diagonal sparse mass matrix.
Assemble the lumped (diagonal) mass matrix from edge lengths as a sparse matrix (BCOO).
The lumped mass matrix is diagonal with entries equal to the dual area of each vertex: \(M_{ii} = A_i\).
# Test mass matrix against igl.massmatrix (Voronoi type)M_jax = mass_matrix_sparse(geommesh.vertices, hemesh)M_igl = igl.massmatrix(np.asarray(geommesh.vertices), np.asarray(hemesh.faces), igl.MASSMATRIX_TYPE_VORONOI)rel_err_mass = np.linalg.norm(M_jax.todense() - M_igl.todense()) / np.linalg.norm(M_igl.todense())print("mass matrix rel. error:", rel_err_mass)# Test inverseM_inv_jax = mass_matrix_inv_sparse(geommesh.vertices, hemesh)identity_check = M_jax @ M_inv_jax.todense()print("M @ M_inv ~ I error:", np.linalg.norm(identity_check - np.eye(hemesh.n_vertices)))assert rel_err_mass <1e-12, rel_err_massassert np.linalg.norm(identity_check - np.eye(hemesh.n_vertices)) <1e-10# The default 'voronoi' area is Meyer's robust Voronoi area, which is what igl computes and is# always positive. On a mesh with obtuse triangles the exact circumcentric area differs.mesh_obtuse = TriMesh.read_obj("../test_meshes/sphere_fine_poor.obj", dim=3)hemesh_obtuse = msh.HeMesh.from_triangles(mesh_obtuse.vertices.shape[0], mesh_obtuse.faces)M_obtuse = mass_matrix_sparse(mesh_obtuse.vertices, hemesh_obtuse).todense().diagonal()M_obtuse_igl = igl.massmatrix(np.asarray(mesh_obtuse.vertices), np.asarray(hemesh_obtuse.faces), igl.MASSMATRIX_TYPE_VORONOI).diagonal()assert np.abs(M_obtuse - M_obtuse_igl).max() <1e-12assert (M_obtuse >0).all()try: mass_matrix_sparse(geommesh.vertices, hemesh, area_type='nonsense')raiseAssertionError('expected ValueError')exceptValueError:pass
mass matrix rel. error: 1.53180380201344e-16
M @ M_inv ~ I error: 4.839349969133127e-16
Warning: readOBJ() ignored non-comment line 3:
o Icosphere
Periodic boundary conditions
On a periodic domain (a torus, possibly sheared - see notebook 05b) a difference of vertex coordinates across the boundary is meaningless, the position-based operators above cannot be used directly. Their intrinsic forms can: the cotangent Laplacian, the mass matrix and the normal derivative all depend on the geometry only through edge lengths, and edge lengths are well-defined periodically:
The same applies to the intrinsic geometry functions of the geometry module, so an implicit diffusion step, a curvature computation, or a vertex-model energy can be assembled on a periodic mesh with the code used everywhere else. Only genuinely position-dependent operators - the finite-element gradient and divergence below, which return per-face vectors - have no intrinsic form and are not available this way.
# Operators on an actual periodic mesh, via edge lengths measured with a displacement functionmesh_per = TriMesh.read_obj("../test_meshes/torus_2d.obj", dim=2)hemesh_per = msh.HeMesh.from_triangles(mesh_per.vertices.shape[0], mesh_per.faces)L_box = jnp.array([1., 1.])disp =lambda r_1, r_2: per.displacement_periodic(r_1, r_2, L_box)l_per = per.get_periodic_he_lengths(mesh_per.vertices, hemesh_per, disp)u_per = jax.random.normal(jax.random.PRNGKey(5), (hemesh_per.n_vertices,))# constants are in the kernel, and a Fourier mode is an approximate eigenfunction# with eigenvalue -2*(2*pi)^2 -- the boundary wrap is handled correctlyones = jnp.ones(hemesh_per.n_vertices)assert jnp.allclose(compute_cotan_laplace_intrinsic(l_per, hemesh_per, ones), 0, atol=1e-10)f = jnp.sin(2*jnp.pi*mesh_per.vertices[:, 0]) * jnp.cos(2*jnp.pi*mesh_per.vertices[:, 1])lap_f = compute_cotan_laplace_intrinsic(l_per, hemesh_per, f, normalize=True)exact =-2* (2*jnp.pi)**2* frel_err_per =float(jnp.linalg.norm(lap_f - exact) / jnp.linalg.norm(exact))print("periodic Laplacian rel. error vs -2k^2 f:", rel_err_per)assert rel_err_per <0.05, rel_err_per# sparse form: agrees with the matrix-free one, symmetric, negative semi-definiteLp = cotan_laplace_sparse_intrinsic(l_per, hemesh_per)assert jnp.allclose(Lp @ u_per, compute_cotan_laplace_intrinsic(l_per, hemesh_per, u_per), atol=1e-10)dense_p = Lp.todense()assert jnp.allclose(dense_p, dense_p.T, atol=1e-12)assert jnp.allclose(dense_p.sum(axis=1), 0, atol=1e-10)assert jnp.linalg.eigvalsh(dense_p).max() <1e-10# mass matrix: positive, sums to the total area, and (M - dt*L) is positive definite,# so an implicit diffusion step can be assembled on a periodic meshMp = mass_matrix_sparse_intrinsic(l_per, hemesh_per)assert jnp.all(Mp.data >0)assert jnp.allclose(Mp.data.sum(), geom.get_area_intrinsic(l_per, hemesh_per), rtol=1e-10)Mp_inv = mass_matrix_inv_sparse_intrinsic(l_per, hemesh_per)assert jnp.allclose(Mp.todense() @ Mp_inv.todense(), jnp.eye(hemesh_per.n_vertices), atol=1e-10)assert jnp.linalg.eigvalsh(Mp.todense() -0.01* dense_p).min() >0# with a box much larger than the mesh the wrap never triggers, so every periodic operator# must reduce exactly to its position-based counterpart on an ordinary meshdisp_big =lambda r_1, r_2: per.displacement_periodic(r_1, r_2, jnp.array([1e6, 1e6]))l_big = per.get_periodic_he_lengths(geommesh.vertices, hemesh, disp_big)u_big = jax.random.normal(jax.random.PRNGKey(3), (hemesh.n_vertices,))assert jnp.allclose(compute_cotan_laplace_intrinsic(l_big, hemesh, u_big), compute_cotan_laplace(geommesh.vertices, hemesh, u_big), atol=1e-8)assert jnp.allclose(cotan_laplace_sparse_intrinsic(l_big, hemesh).todense(), cotan_laplace_sparse(geommesh.vertices, hemesh).todense(), atol=1e-8)assert jnp.allclose(mass_matrix_sparse_intrinsic(l_big, hemesh).data, mass_matrix_sparse(geommesh.vertices, hemesh).data, atol=1e-8)print("periodic Laplacian and mass matrix OK; (M - dt L) is positive definite")
periodic Laplacian rel. error vs -2k^2 f: 0.012032811490690486
periodic Laplacian and mass matrix OK; (M - dt L) is positive definite
Finite-element gradient
Not to be confused with the discrete-exterior-calculus operators, which only depend on mesh connectivity, not geometry.
Assemble FE gradient in 3D as a sparse matrix (BCOO).
Returns a matrix G with shape (3n_faces, n_vertices) such that for a scalar per-vertex field u (n_vertices,), the per-face gradients are obtained via: g_flat = G @ u # (3n_faces,) g = g_flat.reshape((3, n_faces)).T # (n_faces, 3)
This row layout matches libigl’s grad operator convention (component blocks).
Assemble FE gradient in 2D as a sparse matrix (BCOO).
Returns a matrix G with shape (2n_faces, n_vertices) such that for a scalar per-vertex field u (n_vertices,), the per-face gradients are obtained via: g_flat = G @ u # (2n_faces,) g = g_flat.reshape((2, n_faces)).T # (n_faces, 2)
This row layout matches libigl’s grad operator convention (component blocks).
def reshape_face_gradient( grad_flat:Float[Array, 'dim_n_faces ...'], # Output of `G @ u`, with shape `(dim*n_faces, ...)`. n_faces:int, # Number of mesh faces. dim:int, # Spatial dimension (2 or 3).)->Float[Array, 'n_faces dim ...']: # Reshaped gradient with shape `(n_faces, dim, ...)`, matching the output conventionof `compute_gradient_2d/3d`.
Reshape a flattened FE gradient into per-face vectors.
This is meant to be used with gradient_sparse_2d/3d (and any similar operator that stacks components in blocks), where applying the sparse matrix yields an array of shape (dim*n_faces, ...) (for scalar/vector/tensor per-vertex fields).
# here's how to compute the gradient in libiglgrad_matrix = igl.grad(np.asarray(geommesh.vertices), np.asarray(hemesh.faces))# calculate the gradient of field by matrix multiplicationgrad_igl = grad_matrix @ np.asarray(u)# order='F' copied from igl tutorialgrad_igl = grad_igl.reshape((hemesh.n_faces, geommesh.dim), order='F')
# test jax and libigl implementationsgrad_jax = compute_gradient_2d(geommesh.vertices, hemesh, u)rel_err_grad = np.linalg.norm(np.asarray(grad_jax) - grad_igl) / np.linalg.norm(grad_igl)print("gradient rel. error:", rel_err_grad)assert rel_err_grad <1e-10, rel_err_grad# the FE gradient is exact on a linear fieldlin = geommesh.vertices @ jnp.array([2., -3.])assert jnp.allclose(compute_gradient_2d(geommesh.vertices, hemesh, lin), jnp.array([2., -3.]), atol=1e-10)
# Test sparse gradient operators vs apply functionskey = jax.random.PRNGKey(123)u_test = jax.random.normal(key, (hemesh.n_vertices,))G2 = gradient_sparse_2d(geommesh.vertices, hemesh)g2 = reshape_face_gradient(G2 @ u_test, hemesh.n_faces, dim=2)g2_apply = compute_gradient_2d(geommesh.vertices, hemesh, u_test)rel_err_g2 = jnp.linalg.norm(g2 - g2_apply) / jnp.linalg.norm(g2_apply)print("2D grad sparse vs apply rel. error:", rel_err_g2)G3 = gradient_sparse_3d(geommesh_3d.vertices, hemesh)g3 = reshape_face_gradient(G3 @ u_test, hemesh.n_faces, dim=3)g3_apply = compute_gradient_3d(geommesh_3d.vertices, hemesh, u_test)rel_err_g3 = jnp.linalg.norm(g3 - g3_apply) / jnp.linalg.norm(g3_apply)print("3D grad sparse vs apply rel. error:", rel_err_g3)# quick sanity check for vector/tensor fields: u has extra axesu_vec = jax.random.normal(key, (hemesh.n_vertices, 3))g2_vec = reshape_face_gradient(G2 @ u_vec, hemesh.n_faces, dim=2)g2_vec_apply = compute_gradient_2d(geommesh.vertices, hemesh, u_vec)rel_err_g2_vec = jnp.linalg.norm(g2_vec - g2_vec_apply) / jnp.linalg.norm(g2_vec_apply)print("2D grad (vector field) sparse vs apply rel. error:", rel_err_g2_vec)assert rel_err_g2 <1e-10, rel_err_g2assert rel_err_g3 <1e-10, rel_err_g3assert rel_err_g2_vec <1e-10, rel_err_g2_vec
2D grad sparse vs apply rel. error: 8.71017994729607e-17
3D grad sparse vs apply rel. error: 9.602668379845331e-17
2D grad (vector field) sparse vs apply rel. error: 8.285943150518157e-17
Divergence
The discrete divergence maps a per-face vector field to a per-vertex scalar field. It is the negative adjoint of the gradient with respect to the area-weighted inner product:
where \(A_f\) is the face area. With this sign convention, the composed operator satisfies \(L = \mathrm{div} \circ \nabla\) (the negative semi-definite cotan-Laplacian). In matrix form, div = -G^T @ diag(face_areas).
Compute the (integrated) FE divergence of a per-face vector field (2D).
Maps a per-face vector field to a per-vertex scalar field. This is the negative adjoint of the FE gradient weighted by face areas. Satisfies compute_cotan_laplace(v, h, u) ≈ compute_divergence_2d(v, h, compute_gradient_2d(v, h, u)).
# Test: div(grad u) == cotan_laplace(u)key = jax.random.PRNGKey(42)u_div = jax.random.normal(key, (hemesh.n_vertices,))lap_direct = compute_cotan_laplace(geommesh.vertices, hemesh, u_div)grad_u = compute_gradient_2d(geommesh.vertices, hemesh, u_div)lap_via_div = compute_divergence_2d(geommesh.vertices, hemesh, grad_u)rel_err_div = jnp.linalg.norm(lap_direct - lap_via_div) / jnp.linalg.norm(lap_direct)print("div(grad u) vs L u rel. error:", rel_err_div)# same in 3Dgrad_u_3d = compute_gradient_3d(geommesh_3d.vertices, hemesh, u_div)lap_3d_direct = compute_cotan_laplace(geommesh_3d.vertices, hemesh, u_div)lap_3d_via_div = compute_divergence_3d(geommesh_3d.vertices, hemesh, grad_u_3d)rel_err_div_3d = jnp.linalg.norm(lap_3d_direct - lap_3d_via_div) / jnp.linalg.norm(lap_3d_direct)print("div(grad u) vs L u rel. error (3D):", rel_err_div_3d)assert rel_err_div <1e-10, rel_err_divassert rel_err_div_3d <1e-10, rel_err_div_3d# divergence must also accept the vector/tensor face fields that the gradient producesu_vec_div = geommesh.verticesgrad_vec = compute_gradient_2d(geommesh.vertices, hemesh, u_vec_div)div_vec = compute_divergence_2d(geommesh.vertices, hemesh, grad_vec)assert div_vec.shape == u_vec_div.shapeassert jnp.allclose(div_vec, compute_cotan_laplace(geommesh.vertices, hemesh, u_vec_div), atol=1e-10)# adjointness: <grad u, grad u>_A == -<u, div grad u>areas = geom.get_triangle_areas(geommesh.vertices, hemesh)lhs =float(((grad_u * grad_u).sum(-1) * areas).sum())rhs =-float((u_div * lap_via_div).sum())assertabs(lhs - rhs) /abs(lhs) <1e-10, (lhs, rhs)
div(grad u) vs L u rel. error: 2.1665711288568335e-16
div(grad u) vs L u rel. error (3D): 1.8799961803543857e-16
Normal derivative (flux across an edge)
The normal derivative gives the flux of \(\nabla u\) across each half-edge, integrated along the dual edge segment inside the corresponding face. It is the per-half-edge quantity from which the cotangent Laplacian is assembled. Use the normal derivative to impose Neumann boundary conditions or to evaluate a discrete divergence theorem over a sub-region.
def compute_normal_derivative_intrinsic( he_lengths:Float[Array, 'n_hes'], # Edge length per half-edge, e.g. from `geometry.get_he_length` or, under periodicboundary conditions, `periodic.get_periodic_he_lengths`. hemesh:HeMesh, # Half-edge mesh. vertex_field:Float[Array, 'n_vertices ...'], # Per-vertex scalar, vector, or tensor field.)->Float[Array, 'n_hes ...']: # Integrated normal derivative per half-edge, 0 on boundary half-edges.
Integrated normal derivative (flux) of a vertex field across each half-edge, from edge lengths.
For half-edge he inside a face, this is the flux of the piecewise-linear gradient through the dual edge segment of that face: D[he] = cot(theta_opposite(he)) / 2 * (u[dest[he]] - u[orig[he]]), where theta_opposite is the corner angle opposite he. It is 0 on boundary half-edges, which have no face.
This is the per-half-edge quantity the cotangent Laplacian is assembled from. The twin half-edge measures the same edge from the adjacent face and with the opposite sign of u[dest] - u[orig], so the total flux across an edge is D - D[twin] and
``compute_cotan_laplace_intrinsic(l, h, u) == -sum_he_to_vertex_incoming(h, D - D[twin])``
holds to machine precision. Note the raw sum of D over all half-edges is not zero: the two half-edges of an edge carry different opposite angles, and it is the combination D - D[twin] that cancels pairwise. Summing that over a set of vertices gives the net flux across the set’s boundary (the discrete divergence theorem); over a closed mesh it is zero.
# the normal derivative is the per-half-edge quantity the Laplacian is assembled fromu_nd = jax.random.normal(jax.random.PRNGKey(11), (hemesh.n_vertices,))D = compute_normal_derivative(geommesh.vertices, hemesh, u_nd)assert D.shape == (hemesh.n_hes,)assert jnp.all(D[hemesh.is_bdry_he] ==0.0) # no face -> no flux# combining the two face contributions of each edge recovers the cotan Laplacian.# D[twin] carries the opposite sign of (u[dest] - u[orig]), hence the minus.lap_from_flux =-adj.sum_he_to_vertex_incoming(hemesh, D - D[hemesh.twin])assert jnp.allclose(lap_from_flux, compute_cotan_laplace(geommesh.vertices, hemesh, u_nd), atol=1e-10)# the sparse form agrees with the matrix-free one, for scalar and vector fieldsDm = normal_derivative_sparse(geommesh.vertices, hemesh)assert Dm.shape == (hemesh.n_hes, hemesh.n_vertices)assert jnp.allclose(Dm @ u_nd, D, atol=1e-12)u_nd_vec = jax.random.normal(jax.random.PRNGKey(12), (hemesh.n_vertices, 3))assert jnp.allclose(Dm @ u_nd_vec, compute_normal_derivative(geommesh.vertices, hemesh, u_nd_vec), atol=1e-12)# a constant field has zero flux everywhereassert jnp.allclose(compute_normal_derivative(geommesh.vertices, hemesh, jnp.ones(hemesh.n_vertices)), 0, atol=1e-12)# divergence theorem on a CLOSED mesh: the total integrated Laplacian vanishes, because# each edge's flux enters its two endpoints with opposite signs._cm = TriMesh.read_obj("../test_meshes/sphere.obj", dim=3)_ch = msh.HeMesh.from_triangles(_cm.vertices.shape[0], _cm.faces)u_c = jax.random.normal(jax.random.PRNGKey(13), (_ch.n_vertices,))D_c = compute_normal_derivative(_cm.vertices, _ch, u_c)flux_div =-adj.sum_he_to_vertex_incoming(_ch, D_c - D_c[_ch.twin])assert jnp.allclose(flux_div.sum(), 0.0, atol=1e-10)assert jnp.allclose(flux_div, compute_cotan_laplace(_cm.vertices, _ch, u_c), atol=1e-10)print("normal derivative: reproduces the cotan Laplacian and the sparse form matches")
Warning: readOBJ() ignored non-comment line 3:
o Icosphere
normal derivative: reproduces the cotan Laplacian and the sparse form matches
Wrapping as linear operators
It’s often useful to think of functions like compute_cotan_laplace() as a linear operator on fields on meshes. For example, imagine you want to solve the Laplace equation on a mesh with fixed vertex positions and connectivity. You will want to use a linear solver. Luckily, most such solvers only need to be able to compute the action of a linear operator on an input vector, and don’t need an explicit matrix representation.
In the JAX ecosystem, the lineax library defines linear solvers. We can wrapcompute_cotan_laplace() as a linear operator, which allows us to pass it into iterative linear algebra algorithms.
# "bake in" the connectivity and vertex positionslaplace_op = functools.partial(compute_cotan_laplace, geommesh.vertices, hemesh)_ = laplace_op(u) # you can apply this to vertex-fields# define the linear operatorlaplace_op_lx = lineax.FunctionLinearOperator(laplace_op, input_structure=jax.eval_shape(laplace_op, u))# now you can use the linear operator to compute matrix representations, solve linear systems, etc.mat = laplace_op_lx.as_matrix()mat.shape
(131, 131)
Example: implicit diffusion step
A common use case in simulation is the implicit time step for the heat equation on a mesh. Given per-vertex temperatures \(u^n\) and a time step \(\Delta t\), one solves
\[
(M - \Delta t \, L)\, u^{n+1} = M\, u^n
\]
where \(M\) is the mass matrix and \(L\) the cotan-Laplacian. Since \(L\) is negative semi-definite, the system matrix \(M - \Delta t\, L\) is positive definite and can be solved with a conjugate-gradient solver from lineax.
# Implicit diffusion step on the disk meshdt =0.01M = mass_matrix_sparse(geommesh.vertices, hemesh)L = cotan_laplace_sparse(geommesh.vertices, hemesh)# Initial condition: random temperature fieldkey = jax.random.PRNGKey(7)u0 = jax.random.normal(key, (hemesh.n_vertices,))# Right-hand side: M u^nrhs = M @ u0# System matrix A = M - dt * L (positive definite)# Wrap as a lineax operator so we can use an iterative solver.def apply_A(x):return M @ x - dt * (L @ x)A_op = lineax.FunctionLinearOperator(apply_A, input_structure=jax.eval_shape(apply_A, u0), tags=lineax.positive_semidefinite_tag)# Solve with conjugate gradientu1 = lineax.linear_solve(A_op, rhs, solver=lineax.CG(rtol=1e-6, atol=1e-10)).valueprint("implicit Euler step completed, |u1|_2 =", jnp.linalg.norm(u1))
def linear_op_to_sparse( op:Callable, # Linear map taking and returning 1d arrays. in_shape:tuple, out_shape:tuple, dtype:Union=None, # Output dtype. Inferred from ``op`` if None. chunk_size:int=256, # Number of one-hot probes evaluated per batch. tol:float=0.0, # Entries with ``|value| <= tol`` are dropped.)->BCOO: # Sparse matrix of shape ``(n_out, n_in)``.
Build a sparse matrix for a linear map using batched one-hot probes.
## now let's try with a large meshmesh = TriMesh.read_obj("../test_meshes/torus_high_resolution.obj")hemesh = msh.HeMesh.from_triangles(mesh.vertices.shape[0], mesh.faces)geommesh = msh.GeomMesh(mesh.vertices, mesh.face_positions)laplace_op = jax.jit(functools.partial(compute_cotan_laplace, geommesh.vertices, hemesh))
Warning: readOBJ() ignored non-comment line 3:
o Torus
# ~25 s on this 36k-vertex meshsparse_laplace_op = linear_op_to_sparse(laplace_op, (hemesh.n_vertices,), (hemesh.n_vertices,))