home / posts


Overlapping Circles and the Flower of Life

My intention in this post is to discuss how to alogorithmically draw some geometric patterns that I find strikingly beautiful including the well-known Flower of Life. I will not comment on any significance such patterns may hold for certain people or how they have been used and interpreted. The focus here is on geometry and code. Once again, like my most recent posts, we will be using Python. First though I want to introduce a helpful building block that simplifies the process and allows us to focus more on the higher-level aspects.

The geom2d module

I have written a simple module called 'geom2d' and put it on GitHub. At the heart of geom2d is an abstraction called a Plane that allows you to draw basic geometric shapes to a 2D surface using floating-point coordinates. Here is the interface.

class Plane(object):
    def __init__(self, width, height):
        self._width = width
        self._height = height

    @property
    def width(self):
        return self._width

    @property
    def height(self):
        return self._height

    def draw_point(self, point, color):
        pass

    def draw_line(self, start, end, color):
        pass

    def draw_circle(self, circle, color):
        pass

    # start and end are normalized angles on the unit interval
    # moving in an anticlockwise direction
    def draw_arc(self, center, radius, start, end, color):
        pass

The GitHub respository contains some useful implementations of Plane. For vector graphics, there is SVGPlane. And for rasters, there is RasterPlane, which requires a Raster object (an implementation of Raster is provided: ImageRaster).

Tringular Lattice Form of Overlapping Circles

Now we turn to the common triangular lattice form of overlapping circles. We have a sequence of patterns of concentric hexagonal rings of circles. Each pattern in the sequence has a complete hexagonal ring of circles and contains $C(n)$ circles where the sequence $C(n)$ is 1, 1, 7, 19, 37, 61... (starting with $n = 0$); that is, in general, we have $C(n) = 3n(n - 1) + 1$. So how do we draw such patterns?

The construction is relatively straightforward. We begin with a center point $(c_x, c_y)$ and radius $r$ for the pattern. The circles then have radius $r' = r / (n - 1)$. Naturally, we have to handle the case $n \in \{0, 1\}$ separately and return a single circle centered at the given center point with radius $r$. For $n >= 2$, here is how we proceed. Note that we omit specifying the radius for the circles since each circle we draw will implicitly have radius $r'$ We start off with two circles, the first with center $(c_x, c_y)$ and the second with center $(c_x, c_y - r')$. So our list of circles contains both of these. Then we rely on a crucial function called perform_intersections. This function takes a list of circles, a centre $(c'_x, c'_y)$ and a bounding radius $b$. It finds the intersection points of every circle in the list with every other circle in the list and if the distance between each point and $(c'_x, c'_y)$ is less than or equal to $b$, it adds a circle to the list centered on that point. Then the process is repeated with the new circles until no more circles can be added. Here is the code, which admittedly needs some tidying up and optmization, but you will get a sketch of the algorithm.

def perform_intersections(circles, center, radius):
    if len(circles) == 0:
        return

    circle_radius = circles[0].radius
    points = set(map(lambda c: c.center, circles))
    index = 1

    while index < len(circles):
        sel_circle = circles[index]
        for i in range(index):
            other_circle = circles[i]
            inter = sel_circle.intersection(other_circle)
            for p in inter:
                pr = Point(round(p.x, PRECISION_DIGITS),
                           round(p.y, PRECISION_DIGITS))
                if not is_near_identical(pr, points) 
                   and pr.distance_to(center) <= radius:
                    circle = Circle(pr, circle_radius)
                    circles.append(circle)
                    points.add(pr)
        index += 1

Now we have completed the workhorse of the algorithm to draw these patterns. Our function to produce the patterns, which we name overlapping_circles, locates the pattern in local space with center $(0, 0)$ and sets the radius of the circles to $1.0$ so that it can be easily scaled and translated as required. We proceed by adding our two initial circles to the list as described above with radius $1.0$ and then we call perform_intersections with thist list of circles, center point $(0, 0)$ and bounding radius $n - 1$ (plus some epsilon to take care of floating point errors). Here is the code.

def overlapping_circles(n):
    if n < 0:
        return []

    if n == 0 or n == 1:
        return [Circle(Point(0, 0), 1.0)]

    init_circles = []
    center = Point(0, 0)
    radius = 1.0
    circle1 = Circle(center, radius)
    init_circles.append(circle1)

    circle2 = Circle(Point(center.x, center.y - radius), radius)
    init_circles.append(circle2)

    circles = list(init_circles)
    perform_intersections(circles, center, (n - 1) + FP_THRESHOLD))
    return circles

Observe that calling overlapping_circles with argument $n$, we obtain a list of circles $L_n$ such that $\mathsf{len}(L_n) = C(n)$. And we get the desired result. We can then iterate through the list of circles and draw each one to the Plane using the method Plane.draw_circle. Here is an example of an image generated by the program which shows overlapping circles for $n = 5$. Overlapping circles for n = 5

The Flower of Life

We have a lot of the hard work done. The function to create the flower of life is very simple. The fower of life consists of an outer circle with radius 3.0 in local space and 19 "full circles" inside the outer circle. Then the remaining circles we term "surrounding circles" and they intersect with the full circles and we have to draw arcs at the points of intersection inside the full circles. So put simply, the flower of life pattern in our code is a pair consisting of firstly a list of full circles, and secondly, a list of surrounding circles. We leverage overlapping_circles to obtain these circles. The full circles are obtained by calling overlapping_circles with $n = 3$ and the surrounding circles are obtained by calling overlapping_circles with $n = 5$ and then filtering out the full circles. We note that it is easy to generalize this code and extend the pattern to a greater number of circles, but here we just stick to the common flower of life pattern. The code is simple.

def create_flower_of_life():
    full_circles = overlapping_circles(3)
    all_circles = overlapping_circles(5)
    surr_circles = list(set(all_circles).difference(set(full_circles)))
    return (full_circles, surr_circles)

The next challenge is to draw these circles to the plane. The tricky part is the arcs. The first point I want to make is to recall the prototype of Plane.draw_arc. This function is passed a center point and a radius, which describes a particular circle, along with parameters called start and end. The parameters start and end are normalized angles in the interval $[0, 1]$. So 0 corresponds to zero radians and 1.0 corresponds to $2\pi$ radians. And the function draws the arc in an anti-clockwise direction. Therefore, if we have start=0 and end=0.25, we will get an arc around the upper-right quadrant of the circle. However, if we have start=0.25 and end=0, we will get an arc around the other three quadrants of the circle.

First though, we will look at the high-level code to draw the flower of life.

def draw_flower_of_life2(plane, flower_pattern, center, radius, color):
    full_circles, surr_circles = flower_pattern

    circle_radius = radius / 3.0

    outer_circle = Circle(center, radius)
    plane.draw_circle(outer_circle, color)

    def transform(circle):
        circle_center = Point(circle.center.x*circle_radius + center.x,
                              circle.center.y*circle_radius + center.y)
        return Circle(circle_center, circle_radius)

    full_circles = map(transform, full_circles)
    surr_circles = map(transform, surr_circles)

    for circle in full_circles:
        plane.draw_circle(circle, color)

    for circle in surr_circles:
        for full_circle in full_circles:
            draw_circle_filtered(plane, circle, full_circle, color)

Observe that for the surrounding cicles, we iterate through the list of full ciircles and pass each one as an argument to a function called draw_circle_filtered. So the next step is to inspect this function and see what it does. In this function, the circle argument is the surrounding circle and the enclosing_circle argument is the full circle that we have passed in the function above.

The draw_circle_filtered function looks for intersection points between the circle argument and the enclosing_circle argument. If it finds none, it simply returns. But if two intersection points are found, it proceeds to work out the normalized angles from where the intersection points are on the circle. Then it has to figure out the order i.e. which one is start and which one is end. There is helper function called is_arc_outside_circle (which you can see in the repository on GitHub) that determines this order. Finally, we invoke Plane.draw_arc wit the correct start and end values. Here is the code.

def draw_circle_filtered(plane, circle, enclosing_circle, color):
    inter = enclosing_circle.intersection(circle)
    if len(inter) != 2:
        return
    else:
        rangles = []
        for p in inter:
            # Work out the angle from where the intersection point
            # is on the circle
            rad = acos((p.x - circle.center.x) / circle.radius)

            # Convert this angle in radians to a normalized angle in
            # the interval [0, 1]
            rangle = 0
            if p.y > circle.center.y:
                rangle = 0.5 + ((pi - rad) / (2*pi))
            else:
                rangle = rad / (2*pi)
            rangles.append(rangle)
        if is_arc_outside_circle(circle.center, circle.radius,
                                 rangles[0], rangles[1], enclosing_circle):
            rangles.reverse()
        plane.draw_arc(circle.center, circle.radius, rangles[0],
                       rangles[1], color)

Now I want to remark that there is room for plenty of optimization in various parts of the code and at times simplicity is favored over efficiecy. But now, we have completed all the steps to algorithmically draw the traingular lattice form of overlapping circles in addition to the flower of life. Putting it all together, we have code that sets up two planes: a raster plane corresponding to our raster, namely ImageRaster (where will will produce a PNG) and an SVGPlane. Here is a code sniipet.

flower_pattern = create_flower_of_life()

planes = []

image_raster = ImageRaster(400, 300, geom2d.WHITE)
image_plane = RasterPlane(PLANE_WIDTH, PLANE_HEIGHT, image_screen)
planes.append(image_plane)

svg_plane = SVGPlane(PLANE_WIDTH, PLANE_HEIGHT, 'floweroflife.svg')
planes.append(svg_plane)

for plane in planes:
    draw_flower_of_life(plane, flower_pattern,
                        Point(PLANE_WIDTH / 2.0, PLANE_HEIGHT / 2.0),
                        FLOWER_RADIUS, geom2d.BLACK)

image_raster.save('floweroflife.png')
svg_plane.save()

You can find all the code in the GitHub respository. Finally, here is the SVG this code produces of the flower of life. Flower of Life

Here is the PNG for completeness. Flower of Life

Raw form (ExtMarkdown)

Updates

Update 1

This post and its associated code (geom2d and floweroflife repositories) rely only on elementary methods in geometry. It was written without consulting more advanced techniques in computational geomtry. I endeavored to keep things simple at every stage, often sacrifying efficiency and accuracy along with way. Re-reading the post now, I realize that it is not as accessible as I had hoped, so if time permits, I will try to return to it in the future. It would be nice to approach it again with a view to purposefully finding optimizations and writing more efficient and accurate code. Rounding errors in the current code compound to give some values that are off, and most of these are easily addressed. As always, this post and associated code is all worthwhile if somebody finds any portion of it useful or otherwise informative.

Update 2

Since this post was written, I have modified the geom2d.Plane interface on GitHub so there are some minor differences between the current interface and the code in the post that uses it. Also, a new implementation of geom2d.Plane for Tkinter has been added. See the repositories on GitHub.