trianglewins / in context

Triangulation in context: earcut, GEOS, CGAL and the hypertidy stack

11 October 2026. Why triangles turn up everywhere, what decido's earcut and the GEOS functions do and do not promise, what silicate and anglr tried before, and why laridae is built on CGAL, with one set of small examples run through all three. New to triangulation? Start here first; the full package list is in the survey.

Figures and numbers come from survey/context/figures.R (laridae 0.1.0 7495e75, cdtr 0.1.0, decido 0.4.1, geos 0.2.5 on GEOS 3.11.1, RTriangle 1.6-0.15, R 4.3.3), which writes survey/context/results.csv.

Why triangles

Triangles are the simplest shape that tiles any polygon, and a triangle carries a linear function exactly, so triangulation sits under a lot of everyday work:

These jobs need different guarantees: no new vertices, every input edge kept, good angles, varying size, or editing after the build. Packages differ much more in what they promise than in how fast they are.

A few words

One shape, three triangulators

Here are three inputs: a square with a square hole, two squares sharing an edge, and the square with a polyline that leaves it. Each goes through decido's earcut, GEOS's polygon triangulation and laridae. Thick lines are the input; the orange dot is a crossing vertex.

Three inputs (rows) through decido::earcut, GEOS and laridae (columns)

Ear cutting (decido). decido::earcut(xy, holes = 5): holes gives the 1-based row where each hole ring starts. The result is a vector of vertex indices into your own coordinates, three per triangle. No vertex is added and nothing is copied. decido vendors Mapbox's earcut.hpp (MIT, no dependencies) and is very fast. It takes one polygon (a shell plus its holes) per call. That is why the two squares are two calls (shaded differently), and why their common edge is a coincidence of coordinates rather than shared topology. A line or a loose point cannot be given at all. Ear cutting makes no promise about triangle shape and is not Delaunay. On the North Carolina counties below, 38.3% of earcut's triangles have an angle under 10 degrees, and some are degenerate (minimum angle 0), because collinear vertices on a ring become flat ears. It is what silicate's TRI() and TRI0() use.

GEOS (geos, sf, terra, gdalraster). GEOS has two functions that are easy to confuse:

GEOS point Delaunay ignores the edges and fills the hole; the polygon triangulation respects them

The result is geometry (triangle POLYGONs), not an index. To get a mesh back, the script matches coordinates exactly to rebuild a shared vertex table. That works because GEOS copies input coordinates unchanged, but exact floating-point equality is the weakest way to know two vertices are the same. sf, terra and gdalraster reach the same GEOS code.

laridae (CGAL). One call each, with the same function: lari_new(x, y, s0, s1). In the figure, triangles are coloured by depth, with the convex hull kept (erase = "hull") so every triangle is visible:

decido::earcut GEOS polygon triangulation laridae
input one polygon: shell and holes polygons any PSLG: points, lines, rings
loose segments and points no no (lines dropped) yes
crossing segments not valid input not valid input crossing vertex inserted
edges shared between polygons per polygon per polygon one mesh edge, with count
inside and outside ring nesting ring nesting depth
new vertices never never crossings, and Steiner points on request
angle, area and sizing control no no yes
output index into your vertices triangle polygons vertex, triangle and segment tables
edit after building no no yes
licence ISC (earcut), MIT (decido) LGPL (GEOS), MIT (geos) GPL-3 (CGAL Mesh_2)

earcut and GEOS are the right tools for drawing and quick per-polygon jobs, and far lighter to install. laridae is for when the segments matter: kept exactly, shared, allowed to cross, traced back to their input, refined around, or edited.

North Carolina as a coverage

The harness's nc_rings case holds sf's 100 North Carolina counties as 108 rings given in full: 2421 vertices and 2421 segments, with every shared boundary appearing twice. The nc case is the same data with exact duplicate vertices and segments removed: 1255 vertices and 1357 segments. Both use longitude and latitude as stored.

Depth on nc: rings in full count a shared boundary twice (1, 3, 5); with shared segments once it is 1, 2, 3

laridae counts an edge covered by k input segments k times. With the rings in full, depth is 1, 3 and 5 (1205, 707 and 293 triangles); with shared segments once it is 1, 2 and 3 (the same counts). Either way, every county triangle has depth > 0, so erase = "outer" keeps the whole state. For a coverage, depth measures distance from the state boundary in shared-edge crossings. A parity rule would punch holes in it. A per-polygon method avoids that, but only because it never sees the coverage as one mesh.

Running the same rings through earcut ring by ring, GEOS ring by ring, and laridae as one CDT of all the segments gives the following:

triangles min angle (min, 5%, median), degrees under 10 degrees
decido::earcut, per ring 2205 0, 1.22, 13.42 38.3%
GEOS polygon triangulation, per ring 2205 0.77, 5.62, 20.32 15.1%
laridae CDT, no refinement 2205 0.77, 5.62, 20.32 15.1%

A window on central North Carolina: earcut, GEOS and laridae's CDT, and laridae refined

The GEOS triangles are identical to laridae's CDT, as sets of triangles, while only 32% of earcut's appear in it. That is expected: for valid, non-overlapping polygons the CDT of each polygon is the CDT of the whole, and GEOS's flips get there. The difference is everything around the triangles: GEOS needs valid polygons, works one at a time, returns geometry and stops. laridae takes any segment set, returns one mesh with shared vertices and provenance, and can refine and edit it.

Refined with the harness's real-data setting (maximum area of the bounding box area / 5000, minimum angle 20 degrees), the three Ruppert-style engines give laridae 6280 triangles, cdtr 6525 and RTriangle 6047. All of them keep every segment; see the benchmark and inst/results/results.md for the measured comparison. After that refine, adding one short constraint to the laridae handle and refining again inserted 5 Steiner points around the edit and left the rest of the mesh alone.

What came before in R

This work started around 2017 with a simple need: triangles that respect every edge of real spatial data, including coverages with shared boundaries and lines and polygons together, from a package with a free licence. The discussion is in r-spatial/discuss#6. The first laridae (2017 to 2022, Rcpp over the cgal4h and cgalh header packages, with point triangulation and segment insertion) was the first attempt at it with CGAL; it is kept on the legacy-rcpp branch. Its README already gave the reason for wanting CGAL: being able to update a triangulation, add segments to a point triangulation, and work at the level of shared edges.

Between them, these established what the current engines provide as a contract:

silicate's models and verbs remain the abstraction worth keeping; what changes is the machinery underneath: cdtr (CDT, MPL-2.0), the lightweight default; laridae (CGAL), the full-contract engine; and trowel (spade, in Rust), a third live handle. All three take and return the same tables, and this harness judges them. Those tables are the triangle form of the hypertidy mesh spec v0.1: input vertices first, origin, counter-clockwise triangles, 1-based rows in R, and depth counted per copy.

Why CGAL

Exact decisions. A triangulation rests on two questions: which side of a line a point is on (orientation), and whether a point is inside a circle (in-circle). In floating point these can give inconsistent answers for nearly collinear or nearly cocircular points, and an inconsistent triangulation can loop, crash or overlap itself. CGAL answers them exactly using filtering: fast floating point first, and exact arithmetic only when the filter cannot decide. laridae uses the Exact_predicates_inexact_constructions_kernel (Epick), which makes every decision exactly but stores new points in floating point, so GMP is not needed. The cost is stated in laridae's plan: a constructed point, such as a Steiner point on a convex-hull edge, can sit about 1e-10 off the exact line, and laridae handles that explicitly with hidden hull constraints.

A data structure that remembers the input. Constrained_triangulation_plus_2 keeps a hierarchy of constraints: each input segment is remembered after crossings split it into several edges. That hierarchy is where laridae's segment and count columns (the map from constraints back to input) come from, and it is why lari_remove_segments() can undo an insertion exactly. Crossing constraints are resolved by inserting the intersection (Exact_predicates_tag).

Fully dynamic. Points and constraints can be inserted and removed at any time, and vertex ids stay stable. Nothing on CRAN offers an editable triangulation, and refining again after a small edit is local (above: 5 new vertices).

A complete mesher. Delaunay_mesher_2 (CGAL's 2D meshing package) does Ruppert/Shewchuk-style refinement with angle and size criteria, seeds and step-by-step insertion. laridae extends it with an edge-length floor and a sizing field, a local maximum area given as a function of position or as a grid.

lari_step(): Ruppert refinement one batch at a time, on the square with a hole (hole not meshed)

A sizing field: triangles small on the left, large on the right

One practical note from making that figure. With only a sizing field, the default edge-length floor comes from the segments (0.3 of the median segment length, so 0.3 on the unit square). That floor stops refinement at 32 triangles, and the report says all 32 still-bad triangles were stopped by short edges. Setting min_edge_length = 0.002 gives the 1544 triangles shown. Attributes ride along through all of this: refining the square with the polyline at z = x + 2y added 180 vertices, and the largest error in z was 4.4e-16.

An institution behind it. CGAL has been developed since the mid 1990s, starting as a European research project. Submissions are reviewed by an editorial board, every algorithm has a written specification, it is released regularly (the 6.x series now), and it is widely used in both research and industry. The same library has periodic and spherical triangulations, 3D Delaunay and constrained 3D triangulations, alpha shapes, straight skeletons, arrangements, Boolean operations on polygons and surface mesh processing. Those are directions laridae could take, not promises.

The costs, plainly.

Choosing

Quick answers by kind of problem, CRAN first, are in Start here. In short: decido to draw polygons, the GEOS functions for quick per-polygon jobs, deldir for points, fmesher for SPDE meshes, cdtr for a general segment set under a permissive licence, and laridae for the full contract.

References