Penrose Tiling #1

(0 comments)

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.

Penrose tiling

One pair of Penrose tiles, known as P3 are the following rhombuses:

P3 Penrose tiles

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:

P3 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:

P3 Robinson triangles

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.

Current rating: 5

Comments

There are currently no comments

New Comment

required

required (not published)

optional

required