I needed to implement Constrained Delaunay Triangulation for some side project and I decided that I will try implementing it directly from a paper. I did that in the past with other algorithms but now I decided that I will try to describe how that process looked like.
What is the Delaunay triangulation? And first of all – what is triangulation?
Triangulation is a process of dividing a polygon into triangles. In the past I wrote an article about an algorithm called ear cut – Triangulation of Polygons.
And now, what is the Delaunay triangulation and why do we need more than one? It's a kind of triangulation which takes a list of points on a plane and creates a set of triangles which connect those points. No point is inside another triangle, and, more importantly, no point is inside the circumcircle of any triangle (circumcircle is a circle that passes through all three vertices of a triangle). So this is different from the ear cut algorithm where we generally want to slice a polygon into triangles. Here we are dealing with points spread on a plane.
Delaunay generally produces nicer looking triangulation (what does nicer mean? well, if you look at Delaunay and non-Delaunay triangulation – the former is likely going to intuitively look better). On the other hand, many other algorithms, including ear cut, can produce long and thin triangles (since they are optimizing for other properties such as how fast they can get rid of a point and move on).
Delaunay triangulation is mathematically proved to maximize the minimum angle of all the angles of the triangles in the triangulation – which intuitively feel like a property that can make it appear better. At the same time, it does not minimize the maximum angle. Also it does not minimize the length of the edges.
What does constrained mean here? That apart from the list of points, we provide a list of edges that must be in the triangulation. Meaning that no triangle edge will intersect with any of them. This is useful when triangulation is used to describe some real world situation where natural constraints occur and triangles should respect that.
There are several groups of algorithms for Delaunay triangulation that emerged over the years (this list is by no means exclusive, just what I happened to observe when researching):
Each of those methods have different properties. Divide and conquer algorithms potentially paralellize very well. Incremental algorithms usually first deal with regular triangulation and then work on enforcing constraints. This means that if constrains are dynamic, it's possible to cache the initial results. Sweep-line algorithms are the newest branch and offer interesting optimizations, such as radial sweep from a random point.
When researching NPM libraries implementing CDT, I found that most of them tend to follow one of those two papers:
There are some libraries which already do this:
If you are looking for ready working code – it might be a good idea to start with one of those. I just like to implement things myself but by no means it was dictated by having no existing solutions.
I tried implementing a divide and conquer algorithm using quad edge data structure but found it quite complicated and I was moving too slow. Then I gave a try to the sweep line algorithm but found some issues in the paper and realized that in my particular usecase I will probably prefer to cache the regular triangulation before resolving constraints. So in the end I went with Sloan's algorithm which is incremental and has a clear separation between regular and constrained triangulation phases.
I definitely recommend at least scrolling through the whole thing: A Fast Algorithm for Generating Constrained Delaunay Triangulations.
Here's a quote of the algorithm description from the paper (warning: this is quite long and dense, has a lot of mathematical jargon):
So this is how it looks like. The paper contains more figures showing different cases and a bit more context and explanations which I recommend even briefly checking. But this thing above is the core of what we need.
Next is the constrained part.
In my implementation I decided to skip bin sorting and start with implementations of helper functions. I just wanted to make it work first. What I found at some point is that sorting points does matter since depending on the order of input points, this algorithm might not be possible to execute – when next point turns out to be in the middle of existing triangle, there's no way to split it into three. With proper sorting it won't happen.
The point of having a super triangle is to ensure that all points are inside the triangulation since the beginning, allowing to skip more complicated setup steps. If we know that generally we are interested in rectangle-shaped areas, can't we just create two triangles that cover the bounding box of points? Not really, because then some points might be collinear with one of the edges (if a point lies exactly on the rectangle's diagonal) and then it breaks triangulation since there's no way to split triangle into three.
It's also worth noting that since vertices of the supertriangle are placed so unreasonably far from any other points, the triangulation will naturally close itself. This is because if any super triangle edge were to connect with not the most outer points closest to it, then those points would end up in the circumcircle. This also means that even though we impose the edges of the bounding box as constraints, those edges are very very likely to already exist.
Which means that we can actually move the step of removing supertriangle leftovers before the constrained triangulation step. This comes in handy if we need to first cache result of the Delaunay triangulation.
Somehow it turns out that all we need to do to ensure that Delaunay property is satisfied for all triangles is to just swap diagonals (thanks scientists!).
The step of fixing Delaunay triangulation (number 7 in the first part) is based on maintaing a stack of triangles to process. When we split a triangle, for each neighbor of the previous triangle we check the circumcircle condition and if it's not met, we swap the diagonal of the quadrilateral (four-sided polygon) formed by the new mini-triangle and the mentioned neighbor. We then consider two triangles that were neighbors of the previous neighbor and add them to the stack.
The paper explains the procedure of resolving constrained edges. For that we need to find all intersecting edges and then swap them. One unobvious part of the algorithm is that if it's not possible to swap a diagonal, we just continue and swap the others and somehow later on it always becomes possible.
It's hard to give it justice in a blog post, but after multiple attempts I wrote my own summary of the algorithm:
y
and if equal by x
.p
:
p
.p
to each of its vertices.p
to a stack.p
is inside the circumcircle of the triangle, swap the diagonal. Add the triangles (up to two) which are now neighboring the edge opposite to p
to the stack.e
defined as constrained:
e
is already in the triangulation, continue.e
.e
, let's call them i
:
i
from the list.i
form a convex quadrilateral. If they do, swap the diagonal.e
and the new diagonal are crossing, put the new diagonal to the list of intersecting. Else, add it to the list of created edges.The most inventive part of the whole process is coming up with precise data structures that will make our job easier not harder. As you might have noticed, scientific papers are very concise and try to skip all details that are not absolutely necessary for reproducing the results. This means that we need to fill in the gaps and it often means coming up ourselves with needed data structures.
Let's think what operations we need to support:
To achieve the first one, a spatial structure like quad tree could be utilized for fast search, but I will just loop over all triangles and check if the point is inside. Lawson's algorithm mentioned by the paper involves starting in the most recently created triangle and then marching in general direction of the point.
Splitting a triangle and swapping a diagonal are somewhat related. If we want to avoid searching all triangles every time, we can store neighboring triangle for each edge.
There are popular data structures like half edges or quad edges which store a lot of information about edges and faces (triangles) positioned next to them. I initially tried implementing them, but I found it to be a lot of overhead, which brings me to what I ended up with:
class Triangle {
n1: Triangle | null = null;
n2: Triangle | null = null;
n3: Triangle | null = null;
constructor(
public p1: Vec2,
public p2: Vec2,
public p3: Vec2,
) {}
}
Which means there's 6 fields: n1
, n2
, n3
for neighboring triangles and p1
, p2
, p3
for vertices.
I left out some helper methods. You can see them further in the interactive example's editor.
The implementation process involved sketching the main loop of the algorithm, isolating helper functions and then implementing them one by one and writing unit tests for them. Here's the main function:
export function cdt(
points: Array<Vec2>,
edges: Array<[Vec2, Vec2]>,
): Set<Triangle> {
// Left out:
// 1. Ensure that all edge vertices are also in the points list.
// 2. Sort points and edges.
// 3. Set up the supertriangle.
// 4. Loop over each point.
for (const p of points) {
// Find triangle `t` containing `p`.
const t = findTriangleWithPoint(triangles, p);
// Remove `t` and add three new triangles connecting `p` to the
// vertices of `t`.
const [t1, t2, t3] = splitTriangle(t, p);
triangles.add(t1);
triangles.add(t2);
triangles.add(t3);
triangles.delete(t);
const stack = [t.n1, t.n2, t.n3].filter(Boolean);
while (stack.length > 0) {
const tr = stack.pop()!;
if (isInCircumcircle(tr, p)) {
const trr = tr.findNeighborWithVertex(p);
const [nn1, nn2, swapped1, swapped2] = swapDiagonal(tr, trr, p);
triangles.delete(tr);
triangles.delete(trr);
triangles.add(swapped1);
triangles.add(swapped2);
// Add any triangles which are now opposite of P to the
// stack.
if (nn1) {
stack.push(nn1);
}
if (nn2) {
stack.push(nn2);
}
}
}
}
// 5. Loop over each constrained edge.
for (let i = 0; i < edges.length; i++) {
// If edge is already in the triangulation, skip it.
if (doesEdgeExist(triangles, edges[i]![0], edges[i]![1])) {
continue;
}
// Find intersecting edges.
const intersecting = findIntersectingEdges(
triangles,
edges[i]![0],
edges[i]![1],
);
const createdEdges: Array<[Vec2, Vec2]> = [];
while (intersecting.length > 0) {
const _e = intersecting.shift()!;
const e = getTrianglesForEdge(triangles, ..._e);
// If the two triangles that share the intersecting edge do
// not form a strictly convex quadrilateral, place the edge
// back on the list.
const v = getQuadrilateral(e);
if (!checkIfConvexQuadrilateral(v[0], v[1], v[2], v[3])) {
intersecting.push(_e);
continue;
}
const [, , n1, n2] = swapDiagonal(e[0]!, e[1]!, v[2]);
triangles.delete(e[0]!);
triangles.delete(e[1]!);
triangles.add(n1);
triangles.add(n2);
const newShared = findSharedEdge(n1, n2);
// If the new diagonal still intersects an edge, place it on
// the list of intersecting edges. Otherwise, add it to the
// list of created edges.
if (
areSegmentsCrossing(
edges[i]![0],
edges[i]![1],
newShared[0],
newShared[1],
)
) {
intersecting.push(newShared);
} else {
createdEdges.push(newShared);
}
}
// Restore delaunay triangulation.
let swapped = false;
do {
swapped = false;
for (let j = 0; j < createdEdges.length; j++) {
const _e = createdEdges[j]!;
const e = getTrianglesForEdge(triangles, ..._e);
if (!e) {
continue;
}
const shared = [e[0].p1, e[0].p2, e[0].p3].filter((p) =>
e[1].hasVertex(p),
);
// If the edge is equal to a constrained edge, skip it.
let skip = false;
for (let k = 0; k < edges.length; k++) {
if (
(equalsEpsilonVec2(edges[k]![0], shared[0]!) &&
equalsEpsilonVec2(edges[k]![1], shared[1]!)) ||
(equalsEpsilonVec2(edges[k]![0], shared[1]!) &&
equalsEpsilonVec2(edges[k]![1], shared[0]!))
) {
skip = true;
}
}
if (skip) {
continue;
}
const [p1, , p2] = getQuadrilateral(e);
// If two triangles that share this edge do not satisfy the
// delaunay condition then these triangles form a
// quadrilateral with the diagonal in the wrong direction.
if (isInCircumcircle(e[1], p1) || isInCircumcircle(e[0]!, p2)) {
// Due to how swapDiagonal works, the point `p` must come
// from the second triangle.
const p = e[1].hasVertex(p1) ? p1 : p2;
const [, , n1, n2] = swapDiagonal(e[0], e[1], p);
triangles.delete(e[0]);
triangles.delete(e[1]);
triangles.add(n1);
triangles.add(n2);
createdEdges[j] = findSharedEdge(n1, n2);
swapped = true;
}
}
} while (swapped);
}
// 6. Remove all triangles that have a vertex from the
// supertriangle.
for (const t of triangles) {
if (
t.hasVertex(superTriangle.p1) ||
t.hasVertex(superTriangle.p2) ||
t.hasVertex(superTriangle.p3)
) {
triangles.delete(t);
}
}
return Array.from(triangles);
}
Now time to implement everything that I skipped.
First by y
and then by x
.
function comparePoints(a: Vec2, b: Vec2) {
if (equalsEpsilon(a.x, b.x)) {
return a.y - b.y;
}
return a.x - b.x;
}
Inefficient function for checking if given edge exists in the triangulation.
One obvious optimization would be to find triangle containing any of the two and then just check the neighbors (as the point can't exist elsewhere).
function doesEdgeExist(triangles: Set<Triangle>, a: Vec2, b: Vec2): boolean {
for (const t of triangles) {
if (t.hasVertex(a) && t.hasVertex(b)) {
return true;
}
}
return false;
}
Again, very inefficient function for finding all edges that intersect a given edge. To ensure uniqueness I used a map but that's not very necessary since usually there won't be many of them and overhead might be not worth it. Plus collisions of custom hash function are definitely possible.
There are some tricks here but be careful since edges can start and stop anywhere and it's hard to predict where other points can be.
function findIntersectingEdges(
triangles: Set<Triangle>,
a: Vec2,
b: Vec2,
): Array<[Vec2, Vec2]> {
const intersecting = new Map<number, [Vec2, Vec2]>();
for (const t of triangles) {
if (areSegmentsCrossing(t.p1, t.p2, a, b)) {
intersecting.set(hashEdge(t.p1, t.p2), [t.p1, t.p2]);
}
if (areSegmentsCrossing(t.p2, t.p3, a, b)) {
intersecting.set(hashEdge(t.p2, t.p3), [t.p2, t.p3]);
}
if (areSegmentsCrossing(t.p3, t.p1, a, b)) {
intersecting.set(hashEdge(t.p3, t.p1), [t.p3, t.p1]);
}
}
return [...intersecting.values()];
}
Another badly-written function for finding a triangle that contains a point. It can be optimized by using a spatial structure like quad tree.
function findTriangleWithPoint(triangles: Set<Triangle>, p: Vec2) {
for (const t of triangles) {
if (containsPoint(t, p)) {
return t;
}
}
return null;
}
Or there's a "walking" algorithm. Starting randomly in any triangle (or in case of our specific loop, in the most recently created triangle). The idea is simple: we check if the point is inside current triangle. If not, we check which of the three edges crosses with a segment that connects centroid point of the triangle with the target point of the search and we move there. If the plane is fully triangulated, it is guaranteed to find the triangle (which has an interesting consequence: once we are done and we move on to remove triangles inside "holes" defined by constrained edges, this method will stop working).
function findTriangleWithPoint(p: Vec2, start: Triangle): Triangle | null {
let current = start;
let _i = 0;
while (true) {
_i++;
if (_i > MAX_ITERATIONS) {
throw new Error("Infinite loop.");
}
if (triangleHasPoint(current, p)) {
return current;
}
const p0 = centroid(current.p1, current.p2, current.p3);
if (areSegmentsCrossing(p0, p, current.p1, current.p2) && current.n3) {
current = current.n3!;
continue;
}
if (areSegmentsCrossing(p0, p, current.p2, current.p3) && current.n1) {
current = current.n1!;
continue;
}
if (areSegmentsCrossing(p0, p, current.p3, current.p1) && current.n2) {
current = current.n2!;
continue;
}
throw new Error("Triangle not found.");
}
}
One more function for finding a triangle that contains a given edge.
Could be similarly optimized.
function getTrianglesForEdge(
triangles: Set<Triangle>,
a: Vec2,
b: Vec2,
): [Triangle, Triangle] | null {
for (const t of triangles) {
if (t.hasVertex(a) && t.hasVertex(b)) {
const thirdPoint = [t.p1, t.p2, t.p3].filter(
(p) => !equalsEpsilonVec2(p, a) && !equalsEpsilonVec2(p, b),
);
invariant(thirdPoint.length === 1, "Third point must be found.");
return [t, t.getNeighbor(thirdPoint[0]!)!];
}
}
return null;
}
A utility function for finding a shared edge between two triangles.
function findSharedEdge(t1: Triangle, t2: Triangle): [Vec2, Vec2] {
const shared = [t1.p1, t1.p2, t1.p3].filter((p) => t2.hasVertex(p));
if (shared.length !== 2) {
throw new Error("Triangles do not share an edge.");
}
return shared as [Vec2, Vec2];
}
Given two triangles, finds a quadrilateral formed by them. Returns points in counter-clockwise order.
/**
* `[1]` and `[3]` are shared vertices.
* `[0]` is from `t1` and `[2]` is from `t2`.
*/
function getQuadrilateral(e: [Triangle, Triangle]): [Vec2, Vec2, Vec2, Vec2] {
const [t1, t2] = e;
const shared = [t1.p1, t1.p2, t1.p3].filter((p) => t2.hasVertex(p));
// Non-shared vertices.
const q1 = equalsEpsilonVec2(t1.nextPointCCW(shared[0]!), shared[1]!)
? t1.nextPointCCW(shared[1]!)
: t1.nextPointCCW(shared[0]!);
const q2 = equalsEpsilonVec2(t2.nextPointCCW(shared[0]!), shared[1]!)
? t2.nextPointCCW(shared[1]!)
: t2.nextPointCCW(shared[0]!);
return [q1, t1.nextPointCCW(q1), q2, t2.nextPointCCW(q2)];
}
Or in other words, if any internal angle is greater or equal to 180 degrees, the quadrilateral is not convex. It can be done by checking if cross product of all pairs of adjacent edges is of the same sign.
function checkIfConvexQuadrilateral(
a: Vec2,
b: Vec2,
c: Vec2,
d: Vec2,
): boolean {
const ab = new Vec2(b.x - a.x, b.y - a.y);
const bc = new Vec2(c.x - b.x, c.y - b.y);
const cd = new Vec2(d.x - c.x, d.y - c.y);
const da = new Vec2(a.x - d.x, a.y - d.y);
const crossABBC = ab.cross(bc);
const crossBCCD = bc.cross(cd);
const crossCDDA = cd.cross(da);
const crossDAAB = da.cross(ab);
return (
(crossABBC > 0 && crossBCCD > 0 && crossCDDA > 0 && crossDAAB > 0) ||
(crossABBC < 0 && crossBCCD < 0 && crossCDDA < 0 && crossDAAB < 0)
);
}
Thanks to storing neighbor pointers and having CCW access to triangle points, swapping a diagonal is relatively straightforward.
/**
* Triangle `t` and its neighbor containing point `p` form a convex quadrilateral. Swap its diagonal
* and return up to two triangles which are now opposite of `p` and two new triangles.
*
* @returns two triangles which are now opposite of `p` and two new triangles created by swapping
* the diagonal.
*/
function swapDiagonal(
t1: Triangle,
t2: Triangle,
p: Vec2,
): [Triangle | null, Triangle | null, Triangle, Triangle] {
const v0 = p;
const v1 = t2.nextPointCCW(p);
const v2 = t1.nextPointCCW(t2.nextPointCCW(p));
const v3 = t2.nextPointCCW(t2.nextPointCCW(p));
const n0 = t2.getNeighbor(v3);
const n1 = t1.getNeighbor(v3);
const n2 = t1.getNeighbor(v1);
const n3 = t2.getNeighbor(v1);
// Create new triangles.
const nn1 = new Triangle(v0, v1, v2);
const nn2 = new Triangle(v0, v2, v3);
// Set neighbors.
nn1.n1 = n1;
n1?.replaceNeighbor(t1, nn1);
nn1.n2 = nn2;
nn1.n3 = n0;
n0?.replaceNeighbor(t2, nn1);
nn2.n1 = n2;
n2?.replaceNeighbor(t1, nn2);
nn2.n2 = n3;
n3?.replaceNeighbor(t2, nn2);
nn2.n3 = nn1;
return [n1, n2, nn1, nn2];
}
TO split a triangle into three, we need to create three new triangles and update their neighbors and neighbors of the original triangle.
function splitTriangle(t: Triangle, p: Vec2): [Triangle, Triangle, Triangle] {
// Create new triangles.
const pab = new Triangle(p, t.p1, t.p2);
const pbc = new Triangle(p, t.p2, t.p3);
const pca = new Triangle(p, t.p3, t.p1);
// Update neighbors of T.
t.n1?.replaceNeighbor(t, pbc);
t.n2?.replaceNeighbor(t, pca);
t.n3?.replaceNeighbor(t, pab);
pab.n1 = t.n3;
pab.n2 = pbc;
pab.n3 = pca;
pbc.n1 = t.n1;
pbc.n2 = pca;
pbc.n3 = pab;
pca.n1 = t.n2;
pca.n2 = pab;
pca.n3 = pbc;
return [pab, pbc, pca];
}
Checks if point D
is inside the circumcircle of triangle ABC
.
function isInCircumcircle(t: Triangle, d: Vec2): boolean {
const ax = t.p1.x - d.x;
const ay = t.p1.y - d.y;
const bx = t.p2.x - d.x;
const by = t.p2.y - d.y;
const cx = t.p3.x - d.x;
const cy = t.p3.y - d.y;
return (
(ax * ax + ay * ay) * (bx * cy - cx * by) -
(bx * bx + by * by) * (ax * cy - cx * ay) +
(cx * cx + cy * cy) * (ax * by - bx * ay) >=
0
);
}
The helper sign
determines on which side of a half-plane defined by AB the point C is (it's a cross product of vectors AC and BC). If all signs are the same, the point is inside the triangle. Then the main function checks if D
is inside triangle ABC
by checking if it's on the same side of all edges.
function sign(a: Vec2, b: Vec2, c: Vec2): number {
return (a.x - c.x) * (b.y - c.y) - (b.x - c.x) * (a.y - c.y);
}
function containsPoint(t: Triangle, d: Vec2): boolean {
const d1 = sign(d, t.p1, t.p2);
const d2 = sign(d, t.p2, t.p3);
const d3 = sign(d, t.p3, t.p1);
return !((d1 < 0 || d2 < 0 || d3 < 0) && (d1 > 0 || d2 > 0 || d3 > 0));
}
Which is basically sign
but in a different order (mathematically speaking one of them is cross and the other dot product of two vectors).
function isCCW(a: Vec2, b: Vec2, c: Vec2): boolean {
return (b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y) > 0;
}
Here is an interactive example. Click anywhere within the boundaries to create a point. Hover any triangle to see its circumcircle. "Don't constrain edges" turns off enforcing the constraints. "Don't clear super triangle" shows the edges of the gigantic triangle that starts the process.
The algorithm uses several while
loops so there are ways to make it go into an infinite loop. Mostly unusual edge cases – but if you see the box freeze that's probably it. Algorithm itself runs in a matter of milliseconds even for hundreds or thousands of points.
Next step would be to implement all the improvements that we skipped before:
I hope that this gives a good overview of the process of implementing an algorithm from a scientific paper. Most computer science papers follow similar conventions so knowing how to read and implement one transfers well to others.
If anything is unclear or you have any questions, feel free to DM me on Twitter.
Sometimes I write blogposts. It doesn’t happen very often or in regular intervals, so subscribing to my newsletter might come in handy if you enjoy what I am writing about.
Never any spam, unsubscribe at any time.