Skip to the plate
FORMA PUBLIC DOMAIN GENERATIVE ATLAS / ED. 0.28
Plate 98, Ammann–Beenker Tiling: a still of the tiling / cut and project plate as the atlas renders it, in the lattices accent.

PL. 98  ·  LATTICES / TILING / CUT AND PROJECT

Ammann–Beenker Tiling

Robert Ammann, 1977 · F. P. M. Beenker, 1982, independently

OPEN THE LIVE PLATE ▸

DEFINITION

V = { Σ nₖeₖ : n ∈ Z⁴, Σ nₖeₖ* ∈ Ω }
eₖ = (cos kπ/4, sin kπ/4),  eₖ* = (cos 3kπ/4, sin 3kπ/4)
Ω = π⊥([0,1]⁴), the regular octagon of inradius (1+√2)/2

NOTES

The eight-fold quasicrystal: squares and 45° rhombs, in a pattern that never repeats. Ammann found the tile set — his A5 — in 1977 and published none of it; Beenker derived the same tiling in 1982 from algebra, not knowing of him, and the double name is the record of that. This plate takes Beenker's route. Z⁴ carries a natural eight-fold star, and the map k → 3k on it is the Galois conjugation ζ₈ → ζ₈³: it splits four dimensions into the plane you see and a second plane you do not. Keep the lattice points whose shadow in that hidden plane lands inside the octagon the unit hypercube casts there, and what falls out is the tiling. Rhombs outnumber squares by exactly √2 : 1 in the infinite tiling — 1.33 to 1.46 on the finite patches this plate draws — and 1 + √2, the silver mean, is also the factor the tiling inflates by: the first irrational inflation found that had nothing to do with the golden ratio. The slow motion is the octagon sliding through the hidden plane. That is a phason, the freedom a quasicrystal has and a crystal does not: every position is a legal tiling, and as a lattice point crosses the octagon's edge the tiles around it rearrange rather than shift.

PROVENANCE

Origin
R. Ammann found the set (his A5) in 1977 and wrote nothing up. The first published account is B. Grünbaum & G. C. Shephard, "Tilings and Patterns", W. H. Freeman, 1987, chapter 10 — ten years later — and the aperiodicity proofs only in R. Ammann, B. Grünbaum & G. C. Shephard, "Aperiodic Tiles", Discrete & Computational Geometry 8 (1992), 1–25.
Independent
F. P. M. Beenker, "Algebraic theory of non-periodic tilings of the plane by two simple building blocks: a square and a rhombus", TH-Report 82-WSK-04, Technische Hogeschool Eindhoven, September 1982. Beenker did not know of Ammann; his projection construction is the one used here.
Patent
None ever applied. Penrose patented his own tiles as a puzzle (US4133152, long expired); no equivalent was ever filed on this set. Ammann worked entirely outside academia and sought neither patent nor publication.
Method
Cut-and-project from Z⁴ rather than the R → RrR substitution, for two reasons: it is Beenker's own published construction, and it is written from the algebra alone, where transcribing a subdivision diagram would be copying a picture. It also puts the acceptance window on the plate as the thing that moves. Checked rather than assumed: the drawn quads carry only 90° and 45° corners, the vertex coordinations are Ammann–Beenker's own 3 to 8, and no vertex-to-vertex vector maps the central patch onto itself — the best candidate covers 64%, where a periodic tiling would cover all of it.
Constants
Measured, not declared. Every window position is a legal tiling, so nothing here is fragile — but the tiling is a step function of that position, and the two window sliders had to be coarsened until each step actually moved something. At 0.01 the offset slider repeated its neighbour on 52 of 101 steps; at 0.05 it repeats on 1 of 21. The swing was floored at 0.04 for the same reason: below that, sampled across the offset range, a whole cycle crossed no lattice point at all at the eight-fold position and the plate was static. Over a cycle at the default swing, 94 of ~913 tile corners move.
Standing
Public domain. Built from the published mathematics.

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. 98 · AMMANN–BEENKER TILING — Robert Ammann, 1977 · F. P. M. Beenker, 1982, independently
//   V = { Σ nₖeₖ : n ∈ Z⁴, Σ nₖeₖ* ∈ Ω }
//   eₖ = (cos kπ/4, sin kπ/4),  eₖ* = (cos 3kπ/4, sin 3kπ/4)
//   Ω = π⊥([0,1]⁴), the regular octagon of inradius (1+√2)/2
// 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=ammann

float p_span  = 9 + chf('span_tweak');        // edge lengths across · live 6 .. 14
float p_phase = 0 + chf('phase_tweak');       // window offset · live -0.5 .. 0.5

// 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)));
}

// Beenker's cut-and-project, the plate's own route: Z⁴ maps to the plane
// through the eight-fold star eₖ = (cos kπ/4, sin kπ/4), the same star read
// through k → 3k spans the hidden internal plane, and a lattice point is a
// vertex exactly when its internal shadow lands inside Ω, the regular
// octagon the unit hypercube casts there. A square 2-face projects to a
// tile when all four corners survive — |j−k| = 2 is the square, 1 and 3 the
// 45° rhomb. Squares carry the fill and rhombs their outline, the plate's
// own split (squares are the minority tile at 1 : √2), each in its three
// window-depth bands. The acceptance set lives in a dense 4-D flag array
// where the plate packs a Set key — VEX has neither sets nor bit shifts.
// The plate's page-phase bearing on the window offset is the page's; here
// the offset slides along the first internal axis. Deterministic by
// construction — nothing here is random.
// waived: phason — it is the swing of the window on the plate's clock, and
// a cook holds the window at its offset
function int forma_ab_idx(int a, b, c, d, B, DIM){
    return ((a + B) * DIM + (b + B)) * DIM * DIM + (c + B) * DIM + (d + B);
}

float HALF = (1.0 + sqrt(2.0)) / 2.0;          // Ω's inradius — the silver mean, halved
float RW = HALF / cos(M_PI / 8.0);             // and its circumradius

float ex[], ey[], ixs[], iys[];
for (int k = 0; k < 4; k++){
    push(ex, cos(float(k) * M_PI / 4.0));       push(ey, sin(float(k) * M_PI / 4.0));
    push(ixs, cos(3.0 * float(k) * M_PI / 4.0)); push(iys, sin(3.0 * float(k) * M_PI / 4.0));
}

float span = p_span;
float R = min(26.0, span * sqrt(2.0) + 1.5);   // the plate's own patch bound at a square frame
float gg = p_phase;                             // the window offset, at rest
float gx = gg, gy = 0.0;

int M = int(ceil((R + RW + abs(gg)) / 2.0)) + 1;
int B = M + 6, DIM = 2 * B + 1;
int acc[];
resize(acc, DIM * DIM * DIM * DIM);

int pa[], pb[], pc[], pd[];
float ppx[], ppy[], piu[], piv[];
for (int a = -M; a <= M; a++){
    for (int b = -M; b <= M; b++){
        for (int c = -M; c <= M; c++){
            float u0 = a * ixs[0] + b * ixs[1] + c * ixs[2] - gx;
            float v0 = a * iys[0] + b * iys[1] + c * iys[2] - gy;
            float along = u0 * ixs[3] + v0 * iys[3];
            float perp  = u0 * iys[3] - v0 * ixs[3];
            if (perp > RW || perp < -RW) continue;
            float reach = sqrt(RW * RW - perp * perp);
            int lo = int(ceil(-along - reach)), hi = int(floor(-along + reach));
            for (int d = max(lo, -B); d <= min(hi, B); d++){
                float iu = u0 + d * ixs[3], iv = v0 + d * iys[3];
                // Ω is four slabs of equal half-width, normals on the star again
                int inside = 1;
                for (int m = 0; m < 4; m++){
                    float dd = iu * ex[m] + iv * ey[m];
                    if (dd > HALF || dd < -HALF){ inside = 0;  break; }
                }
                if (!inside) continue;
                float x = a * ex[0] + b * ex[1] + c * ex[2] + d * ex[3];
                float y = a * ey[0] + b * ey[1] + c * ey[2] + d * ey[3];
                if (x * x + y * y > R * R) continue;
                acc[forma_ab_idx(a, b, c, d, B, DIM)] = 1;
                push(pa, a);  push(pb, b);  push(pc, c);  push(pd, d);
                push(ppx, x);  push(ppy, y);  push(piu, iu);  push(piv, iv);
            }
        }
    }
}

float SQFILL[] = {0.14, 0.28, 0.50};
float RHLINE[] = {0.38, 0.58, 0.90};
vector sqc = forma_ramp(1.06), rhc = forma_ramp(0.86);
for (int i = 0; i < len(pa); i++){
    for (int j = 0; j < 4; j++){
        for (int k = j + 1; k < 4; k++){
            // the three other corners of the 2-face, by unit steps in j and k
            int na[] = {0, 0, 0, 0};
            na[0] = pa[i];  na[1] = pb[i];  na[2] = pc[i];  na[3] = pd[i];
            int ja[] = {0, 0, 0, 0};
            ja[0] = na[0];  ja[1] = na[1];  ja[2] = na[2];  ja[3] = na[3];
            ja[j] += 1;
            int ka[] = {0, 0, 0, 0};
            ka[0] = na[0];  ka[1] = na[1];  ka[2] = na[2];  ka[3] = na[3];
            ka[k] += 1;
            if (!acc[forma_ab_idx(ja[0], ja[1], ja[2], ja[3], B, DIM)]) continue;
            if (!acc[forma_ab_idx(ka[0], ka[1], ka[2], ka[3], B, DIM)]) continue;
            ja[k] += 1;
            if (!acc[forma_ab_idx(ja[0], ja[1], ja[2], ja[3], B, DIM)]) continue;
            // the tile's depth in the hidden plane — how close the window is
            // to letting one of its corners go
            float u = piu[i] + (ixs[j] + ixs[k]) / 2.0;
            float v = piv[i] + (iys[j] + iys[k]) / 2.0;
            float g = 0.0;
            for (int m = 0; m < 4; m++) g = max(g, abs(u * ex[m] + v * ey[m]));
            float gz = g / HALF;
            int bd = gz < 0.577 ? 0 : (gz < 0.816 ? 1 : 2);   // thirds by area
            // corners in the physical plane — the plate's own y-up chart
            float x0 = ppx[i], y0 = ppy[i];
            int q0 = addpoint(0, set(x0, y0, 0.0));
            int q1 = addpoint(0, set(x0 + ex[j], y0 + ey[j], 0.0));
            int q2 = addpoint(0, set(x0 + ex[j] + ex[k], y0 + ey[j] + ey[k], 0.0));
            int q3 = addpoint(0, set(x0 + ex[k], y0 + ey[k], 0.0));
            int sq = (k - j == 2) ? 1 : 0;
            vector col = sq ? sqc : rhc;
            float al = sq ? SQFILL[bd] : RHLINE[bd];
            setpointattrib(0, "Cd", q0, col);  setpointattrib(0, "Alpha", q0, al);
            setpointattrib(0, "Cd", q1, col);  setpointattrib(0, "Alpha", q1, al);
            setpointattrib(0, "Cd", q2, col);  setpointattrib(0, "Alpha", q2, al);
            setpointattrib(0, "Cd", q3, col);  setpointattrib(0, "Alpha", q3, al);
            if (sq){
                addprim(0, "poly", q0, q1, q2, q3);            // the fill
            } else {
                int prim = addprim(0, "polyline");
                addvertex(0, prim, q0);  addvertex(0, prim, q1);
                addvertex(0, prim, q2);  addvertex(0, prim, q3);
                addvertex(0, prim, q0);                        // closed outline
            }
        }
    }
}

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. 98 · AMMANN–BEENKER TILING — Robert Ammann, 1977 · F. P. M. Beenker, 1982, independently
//   V = { Σ nₖeₖ : n ∈ Z⁴, Σ nₖeₖ* ∈ Ω }
//   eₖ = (cos kπ/4, sin kπ/4),  eₖ* = (cos 3kπ/4, sin 3kπ/4)
//   Ω = π⊥([0,1]⁴), the regular octagon of inradius (1+√2)/2
// 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=ammann

// 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_span   = 9 + forma_tweak("span_tweak");       // edge lengths across · live 6 .. 14
var p_phase  = 0 + forma_tweak("phase_tweak");      // window offset · live -0.5 .. 0.5
var p_phason = 0.13 + forma_tweak("phason_tweak");  // phason swing · live 0.04 .. 0.4

// 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.14041559677571058;   // 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;
}

// The Ammann–Beenker tiling by cut and project from Z⁴, as the page builds
// it: the physical plane is spanned by the star eₖ = (cos kπ/4, sin kπ/4),
// the internal plane by the same star read through k → 3k, and a lattice
// point is a vertex exactly when its internal image lands inside Ω, the
// regular octagon the unit hypercube casts there. A tile is a pair of star
// directions (j, k) all four of whose corners survived: |j − k| = 2 is the
// square, 1 or 3 the 45° rhomb. The window slides by `phase` plus a phason
// swing of `phason` over 46 s from this plate's bearing, which is a motion
// the tiling itself permits. One path: the tile edges walked depth first,
// each out and back, so the pen never leaves the tiling. The page's square
// fill and its three depth tones are the stroke's.
var HALF = (1 + Math.SQRT2) / 2, RW = HALF / Math.cos(Math.PI / 8);
var ex = [], ey = [], ix = [], iy = [];
for (var k = 0; k < 4; k++){
  ex.push(Math.cos(k * Math.PI / 4)); ey.push(Math.sin(k * Math.PI / 4));
  ix.push(Math.cos(3 * k * Math.PI / 4)); iy.push(Math.sin(3 * k * Math.PI / 4));
}
var unit = Math.min(forma_W, forma_H) / (2 * p_span), cx = forma_W / 2, cy = forma_H / 2;
var R = Math.min(26, Math.sqrt(forma_W * forma_W + forma_H * forma_H) / (2 * unit) + 1.5);
var bear = 2 * Math.PI * forma_phase;
var u46 = ((forma_t / 46) + forma_phase) % 1, tri = u46 < 0.5 ? u46 * 2 : 2 - u46 * 2;
var gg = p_phase + p_phason * tri, gx = gg * Math.cos(bear), gy = gg * Math.sin(bear);
function forma_inWindow(u, v){
  for (var m = 0; m < 4; m++){ var d = u * ex[m] + v * ey[m]; if (d > HALF || d < -HALF) return false; }
  return true;
}
var M = Math.ceil((R + RW + Math.abs(gg)) / 2) + 1, SH = [21, 14, 7, 0];
function forma_key(a, b, c, d){ return ((a + 40) << 21) | ((b + 40) << 14) | ((c + 40) << 7) | (d + 40); }
var acc = {}, pts4 = [];
for (var a = -M; a <= M; a++) for (var b = -M; b <= M; b++) for (var c = -M; c <= M; c++){
  var u0 = a * ix[0] + b * ix[1] + c * ix[2] - gx, v0 = a * iy[0] + b * iy[1] + c * iy[2] - gy;
  var along = u0 * ix[3] + v0 * iy[3], perp = u0 * iy[3] - v0 * ix[3];
  if (perp > RW || perp < -RW) continue;
  var reach = Math.sqrt(RW * RW - perp * perp);
  var lo = Math.ceil(-along - reach), hi = Math.floor(-along + reach);
  for (var d = lo; d <= hi; d++){
    if (!forma_inWindow(u0 + d * ix[3], v0 + d * iy[3])) continue;
    var x = a * ex[0] + b * ex[1] + c * ex[2] + d * ex[3], y = a * ey[0] + b * ey[1] + c * ey[2] + d * ey[3];
    if (x * x + y * y > R * R) continue;
    acc[forma_key(a, b, c, d)] = 1;
    pts4.push(a, b, c, d, x, y);
  }
}
var segs = [];
for (var i = 0; i < pts4.length; i += 6){
  var k0 = forma_key(pts4[i], pts4[i + 1], pts4[i + 2], pts4[i + 3]);
  for (var j = 0; j < 4; j++) for (var kk = j + 1; kk < 4; kk++){
    var dj = 1 << SH[j], dk = 1 << SH[kk];
    if (!(acc[k0 + dj] && acc[k0 + dk] && acc[k0 + dj + dk])) continue;
    var x0 = cx + pts4[i + 4] * unit, y0 = cy - pts4[i + 5] * unit;    // canvas y runs down
    var x1 = x0 + ex[j] * unit, y1 = y0 - ey[j] * unit;
    var x2 = x0 + (ex[j] + ex[kk]) * unit, y2 = y0 - (ey[j] + ey[kk]) * unit;
    var x3 = x0 + ex[kk] * unit, y3 = y0 - ey[kk] * unit;
    segs.push([x0, y0, x1, y1], [x1, y1, x2, y2], [x2, y2, x3, y3], [x3, y3, x0, y0]);
  }
}

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);