Home

26. Penrose · 1974 · Retro techniques

Penrose tiling

Demos are animating. Following your system setting.
Colors and knobs

These rewrite the live file. Copy HTML picks up whatever you set.

About

A pentagon of acute golden triangles, subdivided by Robinson's rule. Two rhombs, no period. Fat and thin take the two tweak colours. Depth rebuilds the mesh. Nothing moves and there is nothing to pan to, so the tiling is drawn once and again on resize.

Reference

R. Penrose, "The role of aesthetics in pure and applied mathematical research", Bulletin of the IMA 10, 1974; Robinson-triangle subdivision

Browser APIs

  • Canvas 2D

If WebGL is missing, the demo draws a message on the canvas instead of a blank frame. By default a demo holds a still frame when your system asks for reduced motion; the Motion buttons override that, and a standalone copy animates with #motion=play on its URL.

Source

(function () {
  const PARAMS = {
    color: "#6ee7b7",
    accent: "#d2a85c",
    depth: 6
  };
  function hexToRgb(hex) {
    const n = parseInt(String(hex).replace("#", ""), 16);
    if (isNaN(n)) return [110, 231, 183];
    return [n >> 16 & 255, n >> 8 & 255, n & 255];
  }
  function css(rgb, a) {
    return "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + a + ")";
  }

  const canvas = document.getElementById("c");
  const fallback = document.getElementById("fallback");
  const ctx = canvas.getContext("2d");
  if (!ctx) {
    fallback.hidden = false;
    fallback.textContent = "Canvas 2D is not available in this browser.";
    return;
  }

  const PHI = (1 + Math.sqrt(5)) / 2;
  const INV = 1 / PHI;
  function lerp(a, b, t) {
    return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
  }

  function subdivide(tris) {
    const out = [];
    for (let i = 0; i < tris.length; i++) {
      const t = tris[i];
      const a = t.a, b = t.b, c = t.c;
      if (t.k === "A") {
        const p = lerp(a, b, INV);
        out.push({ k: "A", a: c, b: p, c: b });
        out.push({ k: "O", a: p, b: c, c: a });
      } else {
        const q = lerp(b, a, INV);
        out.push({ k: "O", a: q, b: c, c: a });
        out.push({ k: "A", a: q, b: c, c: b });
      }
    }
    return out;
  }

  function build(depth) {
    const seed = [];
    for (let i = 0; i < 5; i++) {
      const a0 = (i * 72 - 90) * Math.PI / 180;
      const a1 = ((i + 1) * 72 - 90) * Math.PI / 180;
      const b = [Math.cos(a0), Math.sin(a0)];
      const c = [Math.cos(a1), Math.sin(a1)];
      seed.push({ k: "A", a: [0, 0], b: b, c: c });
    }
    let t = seed;
    const d = Math.max(3, Math.min(7, depth | 0));
    for (let i = 0; i < d; i++) t = subdivide(t);
    return t;
  }

  const tiles = build(PARAMS.depth);

  // Nothing here moves: the tiling is drawn once and again on resize, so
  // prefers-reduced-motion needs no special case.
  function draw(w, h) {
    ctx.fillStyle = "#0b0c0e";
    ctx.fillRect(0, 0, w, h);
    const fat = hexToRgb(PARAMS.color);
    const thin = hexToRgb(PARAMS.accent);
    const s = Math.min(w, h) * 0.46;
    const cx = w * 0.5;
    const cy = h * 0.5;
    ctx.lineJoin = "round";
    for (let i = 0; i < tiles.length; i++) {
      const t = tiles[i];
      ctx.beginPath();
      ctx.moveTo(cx + t.a[0] * s, cy + t.a[1] * s);
      ctx.lineTo(cx + t.b[0] * s, cy + t.b[1] * s);
      ctx.lineTo(cx + t.c[0] * s, cy + t.c[1] * s);
      ctx.closePath();
      ctx.fillStyle = t.k === "A" ? css(fat, 0.55) : css(thin, 0.55);
      ctx.fill();
      ctx.strokeStyle = css(fat, 0.35);
      ctx.lineWidth = 0.8;
      ctx.stroke();
    }
  }

  function resize() {
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
    const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
    if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
  }

  function frame() {
    resize();
    draw(canvas.width, canvas.height);
  }

  window.addEventListener("resize", frame);
  frame();
})();