Meandering Triangles
An in-depth explanation of the Meandering Triangles algorithm for drawing contour lines.
Estimated reading time: 5 mins
February 14, 2017Contour lines, also known as isolines, are lines drawn along along paths of constant elevation. They are often used when drawing maps to indicate the slopes of hills and mountains.
But they can also be used to visualize functions that map (x,y) values to z values, or to find the boundaries of implicit surfaces. One of the common algorithms for finding contour lines is Marching Squares. The algorithm assumes that you have a set of elevation data points spaced out at regular intervals on a grid, and generates contour lines for each square of the grid. A simpler algorithm exists, called Meandering Triangles, and it divides the data points into triangles, rather than squares, to reduce the number of cases the algorithm has to handle. Here, I’ll give a full explanation of how the Meandering Triangles algorithm works, with some examples to show it in action.
Meandering Triangles in Action
First, let’s see how the meandering triangles algorithm looks:
Step 0: Get Some Data
In order to generate contour lines, we need some data whose contours we want to find.
import math
# some arbitrary function that maps (x,y) to an elevation between 0 and 1
def elevation_function(x,y):
return 1/(2+math.sin(2*math.sqrt(x*x + y*y))) * (.75+.5*math.sin(x*2))
= dict()
elevation_data = 100, 100
WIDTH, HEIGHT = 1
SPACING for x in range(0,width, SPACING):
for y in range(0,height, SPACING):
= elevation_function(x,y) elevation_data[(x,y)]
Step 1: Triangulate
The first step is to divide the regularly spaced grid of elevation data into triangles. In this example, I divide each set of 4 adjacent points into the top-left and bottom-right halves of a square, which forms two triangles.
import collections
= collections.namedtuple("Triangle", "v1 v2 v3")
Triangle = []
triangles for x in range(0,width-1, SPACING):
for y in range(0,height-1, SPACING):
= Triangle((x,y), (x+SPACING,y), (x,y+SPACING))
t1
triangles.append(t1)= Triangle((x+SPACING,y), (x,y+SPACING), (x+SPACING,y+SPACING))
t2 triangles.append(t2)
Step 2: Find Triangles That Intersect Contour Lines
If we assume the elevation data is sampled at a high enough resolution, then each triangle is pretty close to a small, nearly-flat bit of terrain. If we’re drawing a contour line at elevation = 0.5, then the triangle will have a contour line going through it if some part of that triangle is below 0.5, and some part of it is above 0.5. The same is true for any threshold, but let’s assume we want to draw the contour line for 0.5 in this example. There are four possible cases here:
- All the vertices are below the threshold (no contour line)
- All the vertices are above the threshold (no contour line)
- Two of the vertices are below, and one is above the threshold (has a contour line)
- One of the vertices is below, and two are above the threshold (has a contour line)
Here’s the code:
= 0.5
threshold = collections.namedtuple("Edge", "e1 e2")
Edge = []
contour_segments for triangle in triangles:
= [v for v in triangle if elevation_data[v] < threshold]
below = [v for v in triangle if elevation_data[v] >= threshold]
above # All above or all below means no contour line here
if len(below) == 0 or len(above) == 0:
continue
# We have a contour line, let's find it
Step 3: Find the Contour Line Segments
Once we know that a contour line passes through a triangle, we also know that it passes between the vertices that are above the threshold and those that are below the threshold. Since it’s a triangle, we have 1 vertex on one side, and 2 on the other side, and the contour line will pass through 2 of the 3 edges of the triangle. Where along that edge the contour line will cross can be determined by linearly interpolating between the triangle vertices to find where the threshold’s exact value should fall.
= 0.5
threshold = collections.namedtuple("Edge", "e1 e2")
Edge = []
contour_segments for triangle in triangles:
= [v for v in triangle if elevation_data[v] < threshold]
below = [v for v in triangle if elevation_data[v] >= threshold]
above # All above or all below means no contour line here
if len(below) == 0 or len(above) == 0:
continue
# We have a contour line, let's find it
= above if len(above) < len(below) else below
minority = above if len(above) > len(below) else below
majority
= []
contour_points = (Edge(minority[0], majority[0]),
crossed_edges 0], majority[1]))
Edge(minority[for triangle_edge in crossed_edges:
# how_far is a number between 0 and 1 indicating what percent
# of the way along the edge (e1,e2) the crossing point is
= triangle_edge.e1, triangle_edge.e2
e1, e2 = ((threshold - elevation_data[e2])
how_far / (elevation_data[e1] - elevation_data[e2]))
= (
crossing_point * e1[0] + (1-how_far) * e2[0],
how_far * e1[1] + (1-how_far) * e2[1])
how_far
contour_points.append(crossing_point)
0], contour_points[1])) contour_segments.append(Edge(contour_points[
Step 4: Joining Up
All that’s left to do is join up all the adjacent line segments into lines. My implementation builds up contour lines by adding segments to the head and tail of a line until it runs out of connected segments.
= set(contour_segments)
unused_segments = collections.defaultdict(set)
segments_by_point for segment in contour_segments:
segments_by_point[segment.e1].add(segment)
segments_by_point[segment.e2].add(segment)
= []
contour_lines while unused_segments:
# Start with a random segment
= collections.deque(unused_segments.pop())
line while True:
= segments_by_point[line[-1]] & unused_segments
tail_candidates if tail_candidates:
= tail_candidates.pop()
tail if tail.e2 == line[-1] else tail.e2)
line.append(tail.e1
unused_segments.remove(tail)= segments_by_point[line[0]] & unused_segments
head_candidates if head_candidates:
= head_candidates.pop()
head if head.e2 == line[0] else head.e2)
line.appendleft(head.e1
unused_segments.remove(head)if not tail_candidates and not head_candidates:
# There are no more segments touching this line,
# so we're done with it.
list(line))
contour_lines.append(break
And that’s it! The example code I’ve shown here assumes a single fixed threshold, but it’s easy to run multiple passes to find contour lines at multiple different thresholds. I think this algorithm is quite elegant in its simplicity, and it runs quickly on arbitrary data. There are certainly better algorithms if you care about the level of detail and have an elevation function that is slow to compute, but this algorithm makes an excellent first draft for finding contour lines.
You can get the full code here.