About this technique →
penrose/sketch.js
// Penrose Tiling (P3 — Rhombus tiling)
//
// Generated via Robinson triangle decomposition.
// Two triangle types:
//   thick = golden triangle   (36-72-72°, apex angle 36°, ratio φ:φ:1)
//   thin  = golden gnomon     (36-36-108°, apex angle 108°, ratio 1:1:φ)
//
// Subdivision rules (each type produces exactly 2 children):
//   thick → 1 thick + 1 thin
//   thin  → 1 thick + 1 thin
//
// Seed: 10 thick triangles arranged sun-style around the center (10-fold symmetry).
// At depth 5+ the tiling exhibits the iconic aperiodic Penrose pattern.

export const PARAMS = {
  depth:     { value: 5, min: 2, max: 7, step: 1, label: "Subdivision Depth", folder: "Structure", rebuildOnChange: true },
  showFills: { value: false, label: "Show Fills", folder: "Appearance" },
  thickFill: { value: "#d97757", type: "color", label: "Thick Fill", folder: "Appearance" },
  thinFill:  { value: "#2a3f5f", type: "color", label: "Thin Fill", folder: "Appearance" },
  bg:        { value: "#0a0a0a", type: "color", label: "Background", folder: "Appearance" },
  stroke:    { value: "#c8b87a", type: "color", label: "Stroke", folder: "Appearance" },
  strokeW:   { value: 1.0, min: 0.2, max: 4, step: 0.1, label: "Stroke Width", folder: "Appearance" },
};

const PHI = (1 + Math.sqrt(5)) / 2; // golden ratio ≈ 1.618

// Parse hex color string
function hexToRgbStr(hex) {
  const h = hex.replace('#', '');
  const r = parseInt(h.substring(0, 2), 16);
  const g = parseInt(h.substring(2, 4), 16);
  const b = parseInt(h.substring(4, 6), 16);
  return `rgb(${r},${g},${b})`;
}

// 2D point arithmetic
function add(a, b) { return [a[0] + b[0], a[1] + b[1]]; }
function lerp(a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; }

// ── Robinson triangle decomposition ────────────────────────────────────────────
//
// Each triangle is { type: 'thick'|'thin', A, B, C } with vertex A always at
// the apex (the unique-angle corner).
//
// thick (A-tile, acute golden, 36-72-72°):
//   A = apex (36°), B and C = base corners (72° each)
//   |AB| = |AC| = φ × |BC| — long equal sides meet at apex; short side opposite.
//   Decomposition: P on AB with AP = |BC| (the short length), so AP/AB = 1/φ.
//   The two children are:
//     thin (apex P, 108°):  {P, A, C}    — sides PA=1, PC=1, AC=φ
//     thick (apex C, 36°):  {C, P, B}    — sides CP=1, CB=1, PB=1/φ
//
// thin (O-tile, obtuse golden gnomon, 108-36-36°):
//   A = apex (108°), B and C = base corners (36° each)
//   |BC| = φ × |AB| — short equal sides meet at apex; long side opposite.
//   Decomposition: Q on BC with BQ = |AB| (the short length), so BQ/BC = 1/φ.
//   The two children are:
//     thick (apex B, 36°):  {B, A, Q}    — sides BA=1, BQ=1, AQ=1/φ
//     thin  (apex Q, 108°): {Q, A, C}    — sides QA=1/φ, QC=1/φ, AC=1
//
// References: Wikipedia "Penrose tiling § Robinson triangle decompositions"

function subdivide(tri) {
  const { type, A, B, C } = tri;

  if (type === 'thick') {
    // P on long side AB at distance |BC| from A
    const P = lerp(A, B, 1 / PHI);
    return [
      { type: 'thin',  A: P, B: A, C: C },   // apex at P (108°)
      { type: 'thick', A: C, B: P, C: B },   // apex at C (36°)
    ];
  } else {
    // Q on long base BC at distance |AB| from B
    const Q = lerp(B, C, 1 / PHI);
    return [
      { type: 'thick', A: B, B: A, C: Q },   // apex at B (36°)
      { type: 'thin',  A: Q, B: A, C: C },   // apex at Q (108°)
    ];
  }
}

// Build initial "sun" seed: 10 thick triangles fanned around the center.
// Each thick triangle has its apex at the center, base on the outer ring.
function buildSeed(cx, cy, radius) {
  const triangles = [];
  for (let i = 0; i < 10; i++) {
    const a1 = (2 * Math.PI * i)       / 10 - Math.PI / 2;
    const a2 = (2 * Math.PI * (i + 1)) / 10 - Math.PI / 2;
    const B = [cx + radius * Math.cos(a1), cy + radius * Math.sin(a1)];
    const C = [cx + radius * Math.cos(a2), cy + radius * Math.sin(a2)];
    // Alternate orientation so edge-pairs share edges correctly
    if (i % 2 === 0) {
      triangles.push({ type: 'thick', A: [cx, cy], B, C });
    } else {
      triangles.push({ type: 'thick', A: [cx, cy], B: C, C: B });
    }
  }
  return triangles;
}

// Subdivide N times
function buildPenrose(cx, cy, radius, depth) {
  let tris = buildSeed(cx, cy, radius);
  for (let d = 0; d < depth; d++) {
    const next = [];
    for (const tri of tris) {
      for (const child of subdivide(tri)) {
        next.push(child);
      }
    }
    tris = next;
  }
  return tris;
}

export function sketchSetup(ctx, w, h, tng) {
  return {};
}

export function sketchDraw(ctx, w, h, params, tng) {
  // Background
  ctx.fillStyle = hexToRgbStr(params.bg);
  ctx.fillRect(0, 0, w, h);

  const cx = w / 2;
  const cy = h / 2;
  const radius = Math.max(w, h) * 0.85;

  const tris = buildPenrose(cx, cy, radius, params.depth);

  const thickFill  = hexToRgbStr(params.thickFill);
  const thinFill   = hexToRgbStr(params.thinFill);
  const strokeCol  = hexToRgbStr(params.stroke);
  const lw         = params.strokeW;
  const showFills  = params.showFills;

  if (showFills) {
    for (const tri of tris) {
      ctx.beginPath();
      ctx.moveTo(tri.A[0], tri.A[1]);
      ctx.lineTo(tri.B[0], tri.B[1]);
      ctx.lineTo(tri.C[0], tri.C[1]);
      ctx.closePath();
      ctx.fillStyle = tri.type === 'thick' ? thickFill : thinFill;
      ctx.fill();
      if (lw > 0) {
        ctx.strokeStyle = strokeCol;
        ctx.lineWidth = lw;
        ctx.stroke();
      }
    }
  } else {
    // Stroke-only: the iconic 10-fold symmetric line drawing
    ctx.strokeStyle = strokeCol;
    ctx.lineWidth   = lw;
    ctx.lineJoin    = 'round';
    for (const tri of tris) {
      ctx.beginPath();
      ctx.moveTo(tri.A[0], tri.A[1]);
      ctx.lineTo(tri.B[0], tri.B[1]);
      ctx.lineTo(tri.C[0], tri.C[1]);
      ctx.closePath();
      ctx.stroke();
    }
  }
}