Skip to the plate
FORMA PUBLIC DOMAIN GENERATIVE ATLAS / ED. 0.28
Plate 67, Penrose Tiling: a still of the tiling / aperiodic plate as the atlas renders it, in the lattices accent.

PL. 67  ·  LATTICES / TILING / APERIODIC

Penrose Tiling

Roger Penrose, 1974 · after Robinson and de Bruijn

OPEN THE LIVE PLATE ▸

DEFINITION

deflate each Robinson triangle by φ = (1+√5)/2
acute → one acute + one obtuse
obtuse → two obtuse + one acute

NOTES

Two rhombs that tile the plane and can never tile it periodically. Penrose found the set in 1974; the proof that no arrangement of them ever repeats is what made it famous, and Shechtman’s quasicrystals turned it from recreation into physics a decade later. This plate builds it by deflation — start with ten Robinson triangles around a point and subdivide each in the golden ratio, forever. Every vertex configuration you see is forced.

PROVENANCE

Origin
R. Penrose, "The Rôle of Aesthetics in Pure and Applied Mathematical Research", Bull. Inst. Math. Appl. 10, 1974; de Bruijn gave the pentagrid construction in 1981
Patent
US4133152, granted 1979 to Penrose, covering the tiles as a puzzle — long expired
Standing
Free to use. Built here by triangle deflation, from the published rule.

HOUDINI · VEX

The same published mathematics as a Detail Wrangle body. Paste it into a Wrangle with Run Over set to Detail; every constant is the published value plus a tweak channel, so Create Spare Parameters gives a slider that starts where the paper does.

// FORMA — PL. 67 · PENROSE TILING — Roger Penrose, 1974 · after Robinson and de Bruijn
//   deflate each Robinson triangle by φ = (1+√5)/2
//   acute → one acute + one obtuse
//   obtuse → two obtuse + one acute
// Paste into a Detail Wrangle (Run Over: Detail), no inputs needed.
// Written from the published mathematics, not adapted from any code.
// Constants arrive at their published values. Press the node's Create
// Spare Parameters button and every tweak becomes a slider — starting
// at 0, the published figure, and moving in the constant's own units.
// https://forma-gen.com/#plate=penrose

float p_depth = 5 + chf('depth_tweak');       // deflations · live 3 .. 6

// The plate's own colour: FORMA's LATTICES accent as a cosine ramp,
// brightest near t = 0 and t = 1, near-black around t = 0.5.
vector forma_ramp(float t){
  return set(
    0.46 + 0.5 * cos(6.28318530718 * (t + 0)),
    0.4185 + 0.4549 * cos(6.28318530718 * (t + 0.05)),
    0.1389 + 0.151 * cos(6.28318530718 * (t + 0.1)));
}

// Penrose's rhombs by triangle deflation, the plate's own construction: ten
// Robinson triangles around a point — the sun patch, alternating handedness
// so neighbours meet edge to edge — each subdivided in the golden ratio,
// depth times over, with the recursion carried as flat arrays since VEX has
// none. Each triangle is half a rhomb mirrored across its odd edge, so only
// the two matching sides are drawn and the internal bisectors are omitted;
// which edge is odd is found by length, as the plate finds it, so it cannot
// be got wrong. Acute and obtuse halves sit on the plate's two steps of the
// bright lobe. The reveal's underlay and HILITE head are the animation's.
// Unit circumradius; deterministic by construction — nothing is random.
float PHI = (1.0 + sqrt(5.0)) / 2.0;

int depth = int(rint(p_depth));

// the sun patch: type, then A, B, C per triangle, kept as flat arrays
int ty[];
float axs[], ays[], bxs[], bys[], cxs[], cys[];
for (int i = 0; i < 10; i++){
    float a0 = float(2 * i - 1) * M_PI / 10.0, a1 = float(2 * i + 1) * M_PI / 10.0;
    float Bx = cos(a0), By = sin(a0), Cx = cos(a1), Cy = sin(a1);
    if (i % 2 == 1){
        float t;
        t = Bx;  Bx = Cx;  Cx = t;
        t = By;  By = Cy;  Cy = t;
    }
    push(ty, 0);
    push(axs, 0.0);  push(ays, 0.0);
    push(bxs, Bx);   push(bys, By);
    push(cxs, Cx);   push(cys, Cy);
}

// deflation: the subdivision point sits one golden section along an edge —
// the only place it can go if the children are to be Robinson triangles
for (int it = 0; it < depth; it++){
    int nty[];
    float nax[], nay[], nbx[], nby[], ncx[], ncy[];
    for (int i = 0; i < len(ty); i++){
        float Ax = axs[i], Ay = ays[i], Bx = bxs[i], By = bys[i], Cx = cxs[i], Cy = cys[i];
        if (ty[i] == 0){
            float Px = Ax + (Bx - Ax) / PHI, Py = Ay + (By - Ay) / PHI;
            push(nty, 0);  push(nax, Cx);  push(nay, Cy);  push(nbx, Px);  push(nby, Py);  push(ncx, Bx);  push(ncy, By);
            push(nty, 1);  push(nax, Px);  push(nay, Py);  push(nbx, Cx);  push(nby, Cy);  push(ncx, Ax);  push(ncy, Ay);
        } else {
            float Qx = Bx + (Ax - Bx) / PHI, Qy = By + (Ay - By) / PHI;
            float Rx = Bx + (Cx - Bx) / PHI, Ry = By + (Cy - By) / PHI;
            push(nty, 1);  push(nax, Rx);  push(nay, Ry);  push(nbx, Cx);  push(nby, Cy);  push(ncx, Ax);  push(ncy, Ay);
            push(nty, 1);  push(nax, Qx);  push(nay, Qy);  push(nbx, Rx);  push(nby, Ry);  push(ncx, Bx);  push(ncy, By);
            push(nty, 0);  push(nax, Rx);  push(nay, Ry);  push(nbx, Qx);  push(nby, Qy);  push(ncx, Ax);  push(ncy, Ay);
        }
    }
    ty = nty;
    axs = nax;  ays = nay;  bxs = nbx;  bys = nby;  cxs = ncx;  cys = ncy;
}

// the two matching sides of each half-rhomb, the odd edge found by length
for (int i = 0; i < len(ty); i++){
    float Ax = axs[i], Ay = ays[i], Bx = bxs[i], By = bys[i], Cx = cxs[i], Cy = cys[i];
    float ab = sqrt((Ax - Bx) * (Ax - Bx) + (Ay - By) * (Ay - By));
    float bc = sqrt((Bx - Cx) * (Bx - Cx) + (By - Cy) * (By - Cy));
    float ca = sqrt((Cx - Ax) * (Cx - Ax) + (Cy - Ay) * (Cy - Ay));
    float e0x, e0y, e1x, e1y, f0x, f0y, f1x, f1y;
    if (abs(ab - bc) < 1e-6){ e0x = Cx; e0y = Cy; e1x = Ax; e1y = Ay;  f0x = Ax; f0y = Ay; f1x = Bx; f1y = By; }
    else if (abs(bc - ca) < 1e-6){ e0x = Ax; e0y = Ay; e1x = Bx; e1y = By;  f0x = Bx; f0y = By; f1x = Cx; f1y = Cy; }
    else { e0x = Bx; e0y = By; e1x = Cx; e1y = Cy;  f0x = Cx; f0y = Cy; f1x = Ax; f1y = Ay; }
    vector col = forma_ramp(ty[i] ? 1.04 : 0.86);
    for (int e = 0; e < 2; e++){
        float x0 = e ? f0x : e0x, y0 = e ? f0y : e0y;
        float x1 = e ? f1x : e1x, y1 = e ? f1y : e1y;
        // canvas y runs down; negated so the patch turns as the plate shows it
        int q0 = addpoint(0, set(x0, -y0, 0.0));
        int q1 = addpoint(0, set(x1, -y1, 0.0));
        setpointattrib(0, "Cd", q0, col);  setpointattrib(0, "Cd", q1, col);
        setpointattrib(0, "Alpha", q0, 0.85);  setpointattrib(0, "Alpha", q1, 0.85);
        addprim(0, "polyline", q0, q1);
    }
}

AFTER EFFECTS · EXPRESSION

The same published mathematics as a Shape Layer path expression. Paste it onto a Path property; every constant is the published value plus a Slider Control named _tweak, so a bare paste already draws the figure and each slider moves one constant in its own units. Trim Paths is the comet.

// FORMA — PL. 67 · PENROSE TILING — Roger Penrose, 1974 · after Robinson and de Bruijn
//   deflate each Robinson triangle by φ = (1+√5)/2
//   acute → one acute + one obtuse
//   obtuse → two obtuse + one acute
// After Effects port — paste onto a Shape Layer's Path property
// (Contents › Shape › Path). Written from the published mathematics, not
// adapted from any code. Constants arrive at their published values; add a
// Slider Control (Effect › Expression Controls) named <k>_tweak and that
// constant moves in its own units, starting at 0 — the published figure.
// The plate's comet and its reveal are Trim Paths; the stroke colour is
// FORMA's LATTICES accent, #FFE84D. Animation runs on time.
// https://forma-gen.com/#plate=penrose

// A missing slider reads 0, so a bare paste already draws the figure.
function forma_tweak(n){ try { return effect(n)("Slider"); } catch (e){ return 0; } }
var p_depth = 5 + forma_tweak("depth_tweak");     // deflations · live 3 .. 6

// The frame: the plate's W × H canvas is this comp, with the origin at the
// layer's anchor; canvas y already runs down, as After Effects' does.
var forma_W = thisComp.width, forma_H = thisComp.height, forma_t = time;
var forma_phase = 0.47748878179118037;   // this plate's own fixed phase, as the page has it
function forma_pt(x, y){ return [x - forma_W / 2, y - forma_H / 2]; }
// One path through a connected set of segments (each [x0, y0, x1, y1]):
// vertices within eps are one vertex, a vertex on the inside of a segment
// splits it, duplicate segments are one segment, and a depth-first walk takes
// every segment out and back, so each is drawn on itself and the pen never
// leaves the figure.
function forma_graphPath(segs, eps){
  var cell = eps * 4, verts = [], grid = {}, edges = [], ekey = {};
  function vid(x, y){
    var gx = Math.floor(x / cell), gy = Math.floor(y / cell);
    for (var i = -1; i <= 1; i++) for (var j = -1; j <= 1; j++){
      var list = grid[(gx + i) + "," + (gy + j)];
      if (!list) continue;
      for (var k = 0; k < list.length; k++){
        var v = verts[list[k]];
        if (Math.abs(v[0] - x) <= eps && Math.abs(v[1] - y) <= eps) return list[k];
      }
    }
    var id = verts.length;
    verts.push([x, y, []]);
    (grid[gx + "," + gy] = grid[gx + "," + gy] || []).push(id);
    return id;
  }
  for (var e = 0; e < segs.length; e++) edges.push([vid(segs[e][0], segs[e][1]), vid(segs[e][2], segs[e][3])]);
  // T-junctions: a vertex on the inside of an edge splits it there
  var split = [];
  for (var g = 0; g < edges.length; g++){
    var A = verts[edges[g][0]], B = verts[edges[g][1]], dx = B[0] - A[0], dy = B[1] - A[1], L2 = dx * dx + dy * dy;
    var on = [];
    if (L2 > eps * eps){
      var lo0 = Math.min(A[0], B[0]) - eps, hi0 = Math.max(A[0], B[0]) + eps;
      var lo1 = Math.min(A[1], B[1]) - eps, hi1 = Math.max(A[1], B[1]) + eps;
      for (var w = 0; w < verts.length; w++){
        var V = verts[w];
        if (w === edges[g][0] || w === edges[g][1] || V[0] < lo0 || V[0] > hi0 || V[1] < lo1 || V[1] > hi1) continue;
        var u = ((V[0] - A[0]) * dx + (V[1] - A[1]) * dy) / L2;
        if (u <= 0 || u >= 1) continue;
        var px = A[0] + u * dx - V[0], py = A[1] + u * dy - V[1];
        if (px * px + py * py <= eps * eps) on.push([u, w]);
      }
    }
    on.sort(function (p, q){ return p[0] - q[0]; });
    var prev = edges[g][0];
    for (var o = 0; o < on.length; o++){ split.push([prev, on[o][1]]); prev = on[o][1]; }
    split.push([prev, edges[g][1]]);
  }
  var used = [];
  for (var s = 0; s < split.length; s++){
    var a = split[s][0], b = split[s][1];
    if (a === b) continue;
    var key = a < b ? a + ":" + b : b + ":" + a;
    if (ekey[key]) continue;
    ekey[key] = 1;
    verts[a][2].push([b, used.length]); verts[b][2].push([a, used.length]);
    used.push(0);
  }
  var out = [];
  for (var st = 0; st < verts.length; st++){
    var fresh = false;
    for (var q = 0; q < verts[st][2].length; q++) if (!used[verts[st][2][q][1]]) fresh = true;
    if (!fresh) continue;
    out.push([verts[st][0], verts[st][1]]);
    var stack = [[st, 0]];
    while (stack.length){
      var top = stack[stack.length - 1], adj = verts[top[0]][2], found = -1;
      while (top[1] < adj.length){
        var ed = adj[top[1]++];
        if (!used[ed[1]]){ used[ed[1]] = 1; found = ed[0]; break; }
      }
      if (found >= 0){ out.push([verts[found][0], verts[found][1]]); stack.push([found, 0]); }
      else { stack.pop(); if (stack.length){ var bk = verts[stack[stack.length - 1][0]]; out.push([bk[0], bk[1]]); } }
    }
  }
  return out;
}

// Penrose's P2 rhombs by Robinson-triangle deflation, as the page builds
// them: ten acute triangles round a point, alternating hands so neighbours
// meet edge to edge, and at every level each triangle cut at the golden
// section of its edges into Robinson triangles again. Each triangle is half a
// rhomb, so only the two matching sides are drawn and the rhomb outlines
// are what remain. A tiling is one connected figure, and so one path: the
// edges are walked depth first from a vertex, each walked out and back, so
// every edge is stroked (twice, on itself) and the pen never leaves the
// tiling — no connector is ever drawn. Trim Paths then lays the tiling down
// edge by edge. The page's two rhomb tones and its reveal are the stroke's.
var PHI = (1 + Math.sqrt(5)) / 2, R = Math.min(forma_W, forma_H) * 0.52;
var cx = forma_W / 2, cy = forma_H / 2, depth = Math.round(p_depth);
var tris = [];
for (var i = 0; i < 10; i++){
  var a0 = (2 * i - 1) * Math.PI / 10, a1 = (2 * i + 1) * Math.PI / 10;
  var B = [cx + R * Math.cos(a0), cy + R * Math.sin(a0)], C = [cx + R * Math.cos(a1), cy + R * Math.sin(a1)];
  if (i % 2){ var sw = B; B = C; C = sw; }
  tris.push([0, [cx, cy], B, C]);
}
function forma_cut(P, Q){ return [P[0] + (Q[0] - P[0]) / PHI, P[1] + (Q[1] - P[1]) / PHI]; }
for (var it = 0; it < depth; it++){
  var next = [];
  for (var n = 0; n < tris.length; n++){
    var tr = tris[n], A = tr[1], Bv = tr[2], Cv = tr[3];
    if (tr[0] === 0){
      var P = forma_cut(A, Bv);
      next.push([0, Cv, P, Bv], [1, P, Cv, A]);
    } else {
      var Q = forma_cut(Bv, A), Rr = forma_cut(Bv, Cv);
      next.push([1, Rr, Cv, A], [1, Q, Rr, Bv], [0, Rr, Q, A]);
    }
  }
  tris = next;
}
// The rhomb edges: each triangle's two equal sides, found by length.
var segs = [];
for (var m = 0; m < tris.length; m++){
  var t3 = tris[m], A2 = t3[1], B2 = t3[2], C2 = t3[3];
  var ab = Math.sqrt((A2[0] - B2[0]) * (A2[0] - B2[0]) + (A2[1] - B2[1]) * (A2[1] - B2[1]));
  var bc = Math.sqrt((B2[0] - C2[0]) * (B2[0] - C2[0]) + (B2[1] - C2[1]) * (B2[1] - C2[1]));
  var ca = Math.sqrt((C2[0] - A2[0]) * (C2[0] - A2[0]) + (C2[1] - A2[1]) * (C2[1] - A2[1]));
  if (Math.abs(ab - bc) < 1e-6){ segs.push([C2[0], C2[1], A2[0], A2[1]], [A2[0], A2[1], B2[0], B2[1]]); }
  else if (Math.abs(bc - ca) < 1e-6){ segs.push([A2[0], A2[1], B2[0], B2[1]], [B2[0], B2[1], C2[0], C2[1]]); }
  else { segs.push([B2[0], B2[1], C2[0], C2[1]], [C2[0], C2[1], A2[0], A2[1]]); }
}

var walk = forma_graphPath(segs, 0.05), pts = [];
for (var w = 0; w < walk.length; w++) pts.push(forma_pt(walk[w][0], walk[w][1]));
createPath(pts, [], [], false);