Invisible link to canonical for Microformats

Polygon vertices


All the ways regular polygons tile the plane


17 apr 2022

Introduction

There are only 21 ways to place regular polygons in gapless tilings. The point where the polygons meet is called a vertex. Vertices are named by the polygons meeting there. For instance, the vertex where four squares meet is 4.4.4.4. In this post, I will make code to find and draw all possible vertices.

Regular polygons

A regular polygon has \(n\) sides. The interior angle \(\alpha\) between two adjacent sides is \begin{equation} \alpha=\frac{n-2}{2n}. \end{equation} The turning angle \(\beta=1/n\) of a polygon is the angle to the centre between two adjacent points. For simplicity, I write angles as fractions of a complete turn. Multiply by \(2\pi\) or \(360^\circ\) to get radians or degrees.

Angles in a regular polygon.

Figure 1: Angles in a regular polygon.

The pointiest polygon is the regular triangle with \(\alpha=1/6\). When increasing \(n\), \(\alpha\) increases towards \(1/2\).

Searching for vertices

For a tiling to be gapless, the sum of interior angles \(\alpha\) at a vertex must be 1. Thus, the complete set of vertices can be found by finding all sets \(v_i\) so that: \begin{equation} \sum_{n_j\in v_i} \frac{n_j-2}{2n_j} = 1\,. \end{equation}

Before searching, it helps to have ranges on the possible number of polygons \(m\) and sides \(n\) in \(v_i\). The count \(m\) must be larger than 2, as no two regular polygons can add up to 1. Furthermore, the highest \(m\) is six as 3.3.3.3.3.3 as \(\sum_{n \in 3.3.3.3.3.3} \alpha=6\cdot1/6=1\). For \(m=6\) this is the only possible vertex.

For \(m=3\), the two shape combination leaving the smallest angle \(\alpha\lt1/2\) is 3 and 7, as that 3 and 6 leaves exactly \(\alpha=1/2\), which can’t be a polygon. The remaining angle then is

\begin{equation} \alpha=1-\left(\alpha_3+\alpha_2\right)= 1-\left(\frac{3-2}{2\cdot3}+ \frac{7-2}{2\cdot7}\right)= \frac{10}{21}\,, \end{equation} and with \((n-2)/(2n)=10/21\) we get \(n=42\). Similarly for \(m=4\), 3.3.4 leaves \(\alpha\le1-(2/6+2/8)=5/12\), corresponding to \(n=12\). Finally for \(m=5\), 3.3.3.3 leaves \(\alpha\le1-(4/6)=1/3\), corresponding to \(n_5=6\).

In summary:

\[ m:3 \quad 3\le n\le 42
\] \[ m:4 \quad 3\le n\le 12
\] \[ m:5 \quad 3\le n\le 6
\] \[ m:6 \quad n=3 \]

All possible vertices are found by searching for combinations of \(m\) elements with \(n\) inside the constrained range. Note that this brute force approach will trivial clones of vertices in the form of rotations and mirror images. Moreover, there are some cases of different vertices with the same polygon content. Thus, I first generate (sorted) combinations with replacements of polygons for each \(m\). If a set sums to 1, I yield permutations not already stored as a mirror or rotation.

Python search implementation

For the search I only rely on python 3.8+ and its built in libraries. You don’t need to read the code unless you’re interested in the deeper details of how I find vertices. There are likely more efficient solutions, but I think this works good enough for me :)

Click to expand the python code...
 

Imports:

from itertools import combinations_with_replacement, permutations
from fractions import Fraction as F

Constraints

# Range of polygon count at vertex
m_min = 3
m_max = 6
# Range of n-gon n at vertex
n_min = 3
n_max = [42, 12, 6, 3]

Fucntions

def n_gon(n: str) -> F:
    """Make n-gon interior angle as fraction of full turn"""
    return F(n-2, 2*n)


def rotrev(s) -> tuple[int]:
    """Generate all rotations and mirrors of s"""
    d = deque(s)
    for i in range(len(d)):
        d.rotate(1)
        yield tuple(d)
        yield tuple(reversed(d))

        
def find_vertices() -> list[tuple[int]]:
    """Find vertices"""
    found = []
    # Loop over number of n-gons
    for i, m in enumerate(range(m_min, m_max+1)):
        # Loop over all possible vertex candidates
        for comb in combinations_with_replacement(range(m_min, n_max[m-n_min]+1), r=m):            
            # Only consider proper vertices
            if sum(n_gon(c) for c in comb) != 1:
                continue
            # Add permutations 
            for p in permutations(comb):
                # Reject rotated or reversed forms
                if any(d in found for d in rotrev(p)):
                    continue
                found.append(p)
    return found

Running the search takes less than a seconds on my laptop

vertices = find_vertices()

Search result

This table was made processing the resulting vertex list. Here \(m\) still is the number of polygons, each integer column the counts for a particular shape, and \(u\) the number of unique shapes.

vertex \(m\) 3 4 5 6 7 8 9 10 12 15 18 20 24 42 \(u\)
3.7.42 3 1 0 0 0 1 0 0 0 0 0 0 0 0 1 3
3.8.24 3 1 0 0 0 0 1 0 0 0 0 0 0 1 0 3
3.9.18 3 1 0 0 0 0 0 1 0 0 0 1 0 0 0 3
3.10.15 3 1 0 0 0 0 0 0 1 0 1 0 0 0 0 3
3.12.12 3 1 0 0 0 0 0 0 0 2 0 0 0 0 0 2
4.5.20 3 0 1 1 0 0 0 0 0 0 0 0 1 0 0 3
4.6.12 3 0 1 0 1 0 0 0 0 1 0 0 0 0 0 3
4.8.8 3 0 1 0 0 0 2 0 0 0 0 0 0 0 0 2
5.5.10 3 0 0 2 0 0 0 0 1 0 0 0 0 0 0 2
6.6.6 3 0 0 0 3 0 0 0 0 0 0 0 0 0 0 1
3.3.4.12 4 2 1 0 0 0 0 0 0 1 0 0 0 0 0 3
3.4.3.12 4 2 1 0 0 0 0 0 0 1 0 0 0 0 0 3
3.3.6.6 4 2 0 0 2 0 0 0 0 0 0 0 0 0 0 2
3.6.3.6 4 2 0 0 2 0 0 0 0 0 0 0 0 0 0 2
3.4.4.6 4 1 2 0 1 0 0 0 0 0 0 0 0 0 0 3
3.4.6.4 4 1 2 0 1 0 0 0 0 0 0 0 0 0 0 3
4.4.4.4 4 0 4 0 0 0 0 0 0 0 0 0 0 0 0 1
3.3.3.3.6 5 4 0 0 1 0 0 0 0 0 0 0 0 0 0 2
3.3.3.4.4 5 3 2 0 0 0 0 0 0 0 0 0 0 0 0 2
3.3.4.3.4 5 3 2 0 0 0 0 0 0 0 0 0 0 0 0 2
3.3.3.3.3.3 6 6 0 0 0 0 0 0 0 0 0 0 0 0 0 1
total - 15 10 2 7 1 2 1 2 4 1 1 1 1 1 -

Noteworthy observations

  • There are a total of 21 vertices (10 \(m=3\), 7 \(m=4\), 3 \(m=5\), 1 \(m=6\))
  • There are 1-3 polygon types in a vertex (10 \(u=3\), 8 \(u=2\), 3 \(u=1\))
  • The three single polygon type vertices are triangles, squares, and hexagons - the only three ways to tile the plane with a single polygon type!
  • Only 3-, 4-, 5-, 6-, 8-, 10- and 12-gons occur in more than one vertex
    • 5 and 10 only ever occurr together
    • 15 vertices contains triangles, 10 squares, 7 hexagons, and 4 dodecagons

Below is the code I used to generate the table and insights

Click to expand the python code...
 

Imports (in addition to above code)

import pandas as pd

Functions

def vertex_str(vtx: tuple[int]) -> str:
    """Make vertex notation from ints."""
    return '.'.join(map(str, vtx))    

def make_df(vertices: list[tuple[int]]) -> pd.DataFrame:
    """Make data frame with vertex summary."""
    vertices = find_vertices()
    ngons = {i for j in vertices for i in j}
    df = pd.DataFrame({"m": len(f), "vertex": f} for f in vertices)
    for ngon in {i for j in vertices for i in j}:
        df[ngon] = df["vertex"].apply(lambda x: list(x).count(ngon))
    df["vertex"] = df["vertex"].apply(vertex_str)
    df = df.set_index("vertex")[["m"]+sorted(list(ngons))]
    df["u"] = (df[list(ngons)]>0).sum(axis=1)
    df.loc["n_vertices"]=(df>0).sum()
    return df

Running

df=make_df(vertices)
print(df)
print(df[:-1]["m"].value_counts())
print(df[:-1]["u"].value_counts())

Drawing vertices

To draw vertices correctly, I define a polygon by \(n\) and one side. Swapping the side start and end points mirrors the polygon.

Click to expand the python code...
 

Imports

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.collections import PatchCollection

Functions

def make_patch(points: np.ndarray, ax, fc="white"):
    """Helper to draw polygon patch to ax"""
    ax.add_collection(
         PatchCollection([mpatches.Polygon(points)], fc=fc, ec='k',lw=1)
    )
    
def coords(n:int, line: np.ndarray) -> np.ndarray:
    """
    Args:
        * n (int) - n in n-gon
        * line (np.array) - ((x0,y0), (x1,y1)) line segment
    Returns:
        np.ndarray: shape (n, 2) x,y coordinate pairs
        
    """
    d = np.diff(line.T, axis=1)
    r = np.linalg.norm(d)
    a = np.arange(n)*(
        np.pi-np.pi*2*float(n_gon(n)))+\
        np.arctan2(d[0], d[1])
    p = line[0][:, None]
    return np.hstack(((p+np.cumsum(
        r*np.array([np.sin(a), np.cos(a)]),
        axis=1)), p)).T

Colors

colors = {
    3: "royalblue", 
    4: "orangered", 
    5: "hotpink", 
    6: "gold", 
    7: "magenta", 
    8: "darkorange", 
    9: "yellowgreen", 
    10: "lightsteelblue", 
    12: "limegreen",
    15: "lightcoral",
    18: "khaki",
    20: "orchid",
    24: "plum",
    42: "palegreen",
}

Running

fig, axs = plt.subplots(5,5,figsize=(10,10))
axs = axs.flatten()  # all subplits as list

g0 = np.array([[1,0],[0,0]])  # line to start with
for i, vertex in enumerate(vertices):
    ax = axs[i]
    ps = [] # save coordinates for bounding box
    ax.plot(0, 0,"ko", ms=4) # Draw vertex
    # Add polygons
    for gon in vertex:
        p = coords(gon, g0)
        ps.extend(p.tolist())
        make_patch(p, ax, colors[gon])
        g0 = p[:2][::-1] # move starting line for next iteration
    # Bounding box
    xmin, ymin = np.min(ps, axis=0)*1.05
    xmax, ymax = np.max(ps, axis=0)*1.05
    ax.set(title=vertex_str(vertex), xlim=(xmin,xmax), ylim=(ymin,ymax));

# Ensure equal aspect and no axes for all plots
for i in range(i, 5*5):
    ax = axs[i]
    ax.set_aspect('equal', adjustable='box')
    ax.axis('off')

# Save result
fig.savefig("vertices.png", facecolor='white', transparent=False, bbox_inches="tight")


All possible vertices!

Figure 2: All possible vertices!

Related