Poisson on a Delaunay mesh: end-to-end

Finite Elements

Nine chapters of FEM machinery. One chapter for the mesher. Now we run them together.


The point

The chapter-5 assembly loop never knew it was running on a structured grid. Every line of it took an array of nodes and an array of elements and did its work without asking how those came to exist. Swap in a Delaunay triangulation and the same code solves Poisson on whatever shape your points happen to enclose.

That genericity is the whole reason FEM exists. Finite-difference methods are wedded to grids; FEM works on any triangulation. We wrote a structured mesher first because it was easy. We wrote a Delaunay mesher because it's what real problems need. They both plug into the same downstream pipeline.


Identifying the boundary

The chapter-4 mesher gave us boundary nodes for free — they were the nodes on the edges of the unit square, identifiable by index. A Delaunay triangulation has no such structure. We have to extract the boundary from the triangulation itself.

The trick: a triangulation edge sits on the boundary if and only if it appears in exactly one triangle. Internal edges are shared by two triangles (the two on either side); boundary edges have no triangle on the outside. Walk all the edges with a counter:

def find_boundary_nodes(triangles: list[tuple[int, int, int]]) -> set[int]:
    """The boundary of a triangulation is the set of edges that appear in
    exactly one triangle. The nodes on those edges are boundary nodes.

    For a Delaunay triangulation of a point set with no constraints, the
    boundary equals the convex hull of the points."""
    from collections import Counter
    edge_count = Counter()
    for a, b, c in triangles:
        for u, v in [(a, b), (b, c), (c, a)]:
            edge_count[(min(u, v), max(u, v))] += 1

    boundary = set()
    for a, b, c in triangles:
        for u, v in [(a, b), (b, c), (c, a)]:
            if edge_count[(min(u, v), max(u, v))] == 1:
                boundary.add(u)
                boundary.add(v)
    return boundary

For an unconstrained Delaunay triangulation of a point set, the boundary nodes form exactly the convex hull. So this routine is also a roundabout way to compute the convex hull — a side benefit. If the point cloud has any concavity in it (an L-shape, say), Delaunay alone produces the convex hull anyway, filling in what should have been the concave part with extra triangles. Real production code uses constrained Delaunay to honor a specified domain boundary; that's its own algorithm and the natural follow-up to this chapter.


The pipeline

Wire it together: triangulate, find the boundary, assemble, apply boundary conditions, solve. The middle three steps are untouched chapter-5 code:

def poisson_on_delaunay(points: list[tuple[float, float]],
                       f: callable) -> tuple[np.ndarray, list, set]:
    """End-to-end pipeline: triangulate the point cloud, identify the
    boundary, assemble the Poisson system, apply Dirichlet u=0, solve."""
    triangles = bowyer_watson(points)              # chapter 9
    boundary = find_boundary_nodes(triangles)

    # The chapter-5 assembly loop, unchanged. The only thing that's different
    # is where 'nodes' and 'elements' came from. Same per-element 3x3
    # stiffness, same 3-point quadrature, same row-replacement BCs.
    nodes = np.asarray(points)
    elements = np.asarray(triangles)
    K, b = assemble_2d(nodes, elements, f)
    apply_dirichlet_2d(K, b, list(boundary))

    u = np.linalg.solve(K, b)
    return u, triangles, boundary

Less than ten lines of new logic on top of everything we'd already written. The architectural payoff: the FEM library and the mesher have nothing to say to each other besides "here are nodes and elements." Either side can be swapped without touching the other.


See it

Click anywhere to add a point. Each click triggers the whole pipeline — re-triangulate, find the new boundary, re-assemble, re-solve, re-color. The forcing-function buttons switch the right-hand side of the PDE. Boundary nodes are drawn hollow, interior nodes are drawn filled.

2.1e-20.0e+0
forcing
add
presets
28 nodes · 46 triangles · 8 boundarymax u: 2.11e-2

Things to try:


What's still missing

Compare what's running on this page to a real FEM library (FEniCS, deal.II, libMesh):

None of those changes the conceptual recipe. Every production FEM solver, at its core, is the loop you've now seen end-to-end: define a mesh, identify the boundary, assemble per-element contributions, apply boundary conditions, solve. Everything else is engineering for scale, robustness, and accuracy — important work, but downstream of the ideas this series is about.


Where this leaves the series

The series has gone from "here's the weak form" to "here's a working mesher driving a working solver, end-to-end, on arbitrary point clouds." That's a complete arc — every concept has been introduced, made code, made visible, and then exercised. Whatever you build next on top of this — a particular physics problem, a particular geometry, a faster solver — will be a refinement of what's here, not a different game.