Penrose tiles are any of a set of plane figures which can be combined to tile the plane aperiodically (without translational symmetry). They were described by the British mathematician Roger Penrose in the 1970s.
The code described in this post is available under the GPL licence on github. Some examples are given in the next blog post.
One pair of Penrose tiles, known as P3 are the following rhombuses:
To ensure an aperiodic tiling, the tiles must be arranged so that the coloured arcs match up. For the purposes of constructing such a tiling, each rhombus may be considered to consist of a pair of mirror-image Robinson triangles:
The larger triangle on the left, referred to here as BL has sides in the ratio $1:1:\phi$. The smaller one on the right, BS has sides in the ratio $1:1:\psi$ where $\phi = (\sqrt{5}+1)/2$ and $\psi = 1/\phi = (\sqrt{5}-1)/2$ are the Golden ratio and its inverse respectively. To build a tiling one starts with an arrangement of such triangles and repeatedly applies the process of "inflation", replacing each triangle with the following arrangement of smaller triangles in the indicated orientation:
That is, a BL triangle, ABC, is replaced with two scaled-down BL triangles (EDA and CEB) and a BS triangle (DEB); in the lefthand above figure, D divides AC in the ratio $1:(\phi-1)\equiv 1:\psi$ and E divides AB in the ratio $\psi:(1-\psi) \equiv \psi:\psi^2$. Each BS triangle, A'B'C', is replaced with a BL triangle (C'D'B') and a BS triangle (D'C'A'); in the righthand figure, D' divides A'B' in the ratio $(1-\psi):\psi\equiv \psi^2:\psi$.
The code defines a class, Penrose
, which can be used to create SVG images of Penrose tilings. The base class, RobinsonTriangle
defines a generic Robinson triangle with its vertices given as complex numbers, $x + iy$ (note that the orientation of the SVG canvas (increasing $y$-index down the page) results in the $y$-axis appearing flipped in the resulting image). Methods are defined for producing the SVG paths for the rhombus associated with a Robinson triangle and for drawing the matching arcs if requested. We also define two more useful methods for returning the centre of the rhombus and for returning a "conjugate" triangle from the existing one with its $y$-coordinates inverted.
class RobinsonTriangle:
"""
A class representing a Robinson triangle and the rhombus formed from it.
"""
def __init__(self, A, B, C):
"""
Initialize the triangle with the ordered vertices. A and C are the
vertices at the equal base angles; B is at the vertex angle.
"""
self.A, self.B, self.C = A, B, C
def centre(self):
"""
Return the position of the centre of the rhombus formed from two
triangles joined by their bases.
"""
return (self.A + self.C) / 2
def path(self):
"""
Return the SVG "d" path element specifier for the rhombus formed
by this triangle and its mirror image joined along their bases.
"""
AB, BC = self.B - self.A, self.C - self.B
xy = lambda v: (v.real, v.imag)
return 'm{},{} l{},{} l{},{} l{},{}z'.format(*xy(self.A) + xy(AB)
+ xy(BC) + xy(-AB))
def get_arc_d(self, U, V, W):
"""
Return the SVG "d" path element specifier for the circular arc between
sides UV and UW, joined at half-distance along these sides.
"""
start = (U + V) / 2
end = (U + W) / 2
# ensure we draw the arc for the angular component < 180 deg
cross = lambda u, v: u.real*v.imag - u.imag*v.real
US, UE = start - U, end - U
if cross(US, UE) > 0:
start, end = end, start
# arc radius
r = abs((V - U) / 2)
return 'M {} {} A {} {} 0 0 0 {} {}'.format(start.real, start.imag,
r, r, end.real, end.imag)
def arcs(self):
"""
Return the SVG "d" path element specifiers for the two circular arcs
about vertices A and C.
"""
D = self.A + self.C - self.B
arc1_d = self.get_arc_d(self.A, self.B, D)
arc2_d = self.get_arc_d(self.C, self.B, D)
return arc1_d, arc2_d
def conjugate(self):
"""
Return the vertices of the reflection of this triangle about the
x-axis. Since the vertices are stored as complex numbers, we simply
need the complex conjugate values of their values.
"""
return self.__class__(self.A.conjugate(), self.B.conjugate(),
self.C.conjugate())
From this base class we derive a class for each of the two types of P3 triangle, BtileL
and BtileS
:
class BtileL(RobinsonTriangle):
"""
A class representing a "B_L" Penrose tile in the P3 tiling scheme as
a "large" Robinson triangle (sides in ratio 1:1:phi).
"""
def inflate(self):
"""
"Inflate" this tile, returning the three resulting Robinson triangles
in a list.
"""
# D and E divide sides AC and AB respectively
D = psi2 * self.A + psi * self.C
E = psi2 * self.A + psi * self.B
# Take care to order the vertices here so as to get the right
# orientation for the resulting triangles.
return [BtileL(D, E, self.A),
BtileS(E, D, self.B),
BtileL(self.C, D, self.B)]
class BtileS(RobinsonTriangle):
"""
A class representing a "B_S" Penrose tile in the P3 tiling scheme as
a "small" Robinson triangle (sides in ratio 1:1:psi).
"""
def inflate(self):
"""
"Inflate" this tile, returning the two resulting Robinson triangles
in a list.
"""
D = psi * self.A + psi2 * self.B
return [BtileS(D, self.C, self.A),
BtileL(self.C, D, self.B)]
The method inflate
simply returns a list of new triangles with vertices determined by the "inflation" process described above. Note that the orientation of these triangles is important: the vertices are given in the order A, B, C using the notation of the above figures.
Finally, the main Penrose
class is responsible for generating the tiling out of the P3 rhombuses. The appearance can be altered by passing key,value pairs to the config
dictionary on initialization.
class PenroseP3:
""" A class representing the P3 Penrose tiling. """
def __init__(self, scale=200, ngen=4, config={}):
"""
Initialise the PenroseP3 instance with a scale determining the size
of the final image and the number of generations, ngen, to inflate
the initial triangles. Further configuration is provided through the
key, value pairs of the optional config dictionary.
"""
self.scale = scale
self.ngen = ngen
# Default configuration
self.config = {'stroke-colour': '#fff',
'base-stroke-width': 0.05,
'margin': 1.05,
'tile-opacity': 0.6,
'random-tile-colours': False,
'Stile-colour': '#08f',
'Ltile-colour': '#0035f3',
'Aarc-colour': '#f00',
'Carc-colour': '#00f',
'draw-tiles': True,
'draw-arcs': False,
'reflect-x': True
}
self.config.update(config)
self.elements = []
def set_initial_tiles(self, tiles):
self.elements = tiles
def inflate(self):
""" "Inflate" each triangle in the tiling ensemble."""
new_elements = []
for element in self.elements:
new_elements.extend(element.inflate())
self.elements = new_elements
def remove_dupes(self):
"""
Remove triangles giving rise to identical rhombuses from the
ensemble.
"""
# Triangles give rise to identical rhombuses if these rhombuses have
# the same centre.
selements = sorted(self.elements, key=lambda e: (e.centre().real,
e.centre().imag))
self.elements = [selements[0]]
for i, element in enumerate(selements[1:], start=1):
if abs(element.centre() - selements[i-1].centre()) > TOL:
self.elements.append(element)
def add_conjugate_elements(self):
""" Extend the tiling by reflection about the x-axis. """
self.elements.extend([e.conjugate() for e in self.elements])
def make_tiling(self):
""" Make the Penrose tiling by inflating ngen times. """
for gen in range(self.ngen):
self.inflate()
self.remove_dupes()
if self.config['reflect-x']:
self.add_conjugate_elements()
self.remove_dupes()
def get_tile_colour(self, e):
if self.config['random-tile-colours']:
return '#' + hex(random.randint(0,0xfff))[2:]
if isinstance(e, BtileL):
return self.config['Ltile-colour']
return self.config['Stile-colour']
def make_svg(self):
""" Make and return the SVG for the tiling as a str. """
xmin = ymin = -self.scale * self.config['margin']
width = height = 2*self.scale * self.config['margin']
viewbox ='{} {} {} {}'.format(xmin, ymin, width, height)
svg = ['<?xml version="1.0" encoding="utf-8"?>',
'<svg width="100%" height="100%" viewBox="{}"'
' preserveAspectRatio="xMidYMid meet" version="1.1"'
' baseProfile="full" xmlns="http://www.w3.org/2000/svg">'
.format(viewbox)]
# The tiles' stroke widths scale with ngen
stroke_width = str(psi**self.ngen * self.scale *
self.config['base-stroke-width'])
svg.append('<g style="stroke:{}; stroke-width: {};">'
.format(self.config['stroke-colour'], stroke_width))
for e in self.elements:
if self.config['draw-tiles']:
svg.append('<path fill="{}" opacity="{}" d="{}"/>'
.format(self.get_tile_colour(e),
self.config['tile-opacity'], e.path()))
if self.config['draw-arcs']:
arc1_d, arc2_d = e.arcs()
svg.append('<path fill="none" stroke="{}" d="{}"/>'
.format(self.config['Aarc-colour'], arc1_d))
svg.append('<path fill="none" stroke="{}" d="{}"/>'
.format(self.config['Carc-colour'], arc2_d))
svg.append('</g>\n</svg>')
return '\n'.join(svg)
def write_svg(self, filename):
""" Make and write the SVG for the tiling to filename. """
svg = self.make_svg()
with open(filename, 'w') as fo:
fo.write(svg)
The method responsible for generating the tiling is make_tiling
and it simply inflates the initial configuration of triangles ngen
times. Since the rhombuses are generated from Robinson triangles, it is possible for the inflation of adjacent triangles to produce the same rhombus: the method remove_dupes
removes such duplicate rhombuses from the ensemble: two rhombuses are the same if they have the same centre (this is established using the RobinsonTriangle
centre
method).
Finally, a careful choice of initial conditions (such that the initial triangles all have edges along the $x$-axis) enables the tiling area to be doubled simply by reflection in the $x$-axis (equivalent to taking the complex conjugate of all triangles' vertex coordinates). This functionality is implemented with the method add_conjugate_elements
.
Comments
Comments are pre-moderated. Please be patient and your comment will appear soon.
There are currently no comments
New Comment