{"slug":"penrose","number":"26","title":"Penrose tiling","era":"Penrose · 1974","summary":"Robinson triangles, aperiodic. Drag to pan.","description":"A pentagon of acute golden triangles, subdivided by Robinson's rule. Two rhombs, no period. Fat and thin take the two tweak colors. Drag to pan. Depth rebuilds the mesh.","apis":["Canvas 2D","Pointer Events","requestAnimationFrame"],"tags":["tiling","geometry"],"hasGlsl":false,"interaction":"Drag on the preview to pan.","url":"https://3d-retro.com/experiments/penrose","example_url":"https://3d-retro.com/examples/penrose.html","api_url":"https://3d-retro.com/api/v1/experiments/penrose","html":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n  <meta name=\"description\" content=\"Robinson-triangle Penrose tiling. Drag to pan.\" />\n  <link rel=\"canonical\" href=\"https://3d-retro.com/experiments/penrose\" />\n  <title>Penrose tiling · 3d retro graphics</title>\n  <style>\n    html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; background: #0b0c0e; color: #e8e6e1; position: absolute; inset: 0; }\n    canvas { display: block; position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; }\n    .fallback { position: absolute; inset: 0; display: grid; place-items: center; padding: 24px; font: 14px/1.5 ui-monospace, \"IBM Plex Mono\", monospace; text-align: center; color: #e8e6e1; background: #0b0c0e; }\n    .fallback[hidden] { display: none; }\n  </style>\n</head>\n<body>\n<canvas id=\"c\"></canvas>\n<div id=\"fallback\" class=\"fallback\" hidden></div>\n<script>\n(function () {\n  const PARAMS = {\n    color: \"#6ee7b7\",\n    accent: \"#d2a85c\",\n    depth: 6\n  };\n  function hexToRgb(hex) {\n    const n = parseInt(String(hex).replace(\"#\", \"\"), 16);\n    if (isNaN(n)) return [110, 231, 183];\n    return [n >> 16 & 255, n >> 8 & 255, n & 255];\n  }\n  function css(rgb, a) {\n    return \"rgba(\" + rgb[0] + \",\" + rgb[1] + \",\" + rgb[2] + \",\" + a + \")\";\n  }\n\n  const canvas = document.getElementById(\"c\");\n  const fallback = document.getElementById(\"fallback\");\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) {\n    fallback.hidden = false;\n    fallback.textContent = \"Canvas 2D is not available in this browser.\";\n    return;\n  }\n\n  const PHI = (1 + Math.sqrt(5)) / 2;\n  const INV = 1 / PHI;\n  function lerp(a, b, t) {\n    return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];\n  }\n\n  function subdivide(tris) {\n    const out = [];\n    for (let i = 0; i < tris.length; i++) {\n      const t = tris[i];\n      const a = t.a, b = t.b, c = t.c;\n      if (t.k === \"A\") {\n        const p = lerp(a, b, INV);\n        out.push({ k: \"A\", a: c, b: p, c: b });\n        out.push({ k: \"O\", a: p, b: c, c: a });\n      } else {\n        const q = lerp(b, a, INV);\n        out.push({ k: \"O\", a: q, b: c, c: a });\n        out.push({ k: \"A\", a: q, b: c, c: b });\n      }\n    }\n    return out;\n  }\n\n  function build(depth) {\n    const seed = [];\n    for (let i = 0; i < 5; i++) {\n      const a0 = (i * 72 - 90) * Math.PI / 180;\n      const a1 = ((i + 1) * 72 - 90) * Math.PI / 180;\n      const b = [Math.cos(a0), Math.sin(a0)];\n      const c = [Math.cos(a1), Math.sin(a1)];\n      seed.push({ k: \"A\", a: [0, 0], b: b, c: c });\n    }\n    let t = seed;\n    const d = Math.max(3, Math.min(7, depth | 0));\n    for (let i = 0; i < d; i++) t = subdivide(t);\n    return t;\n  }\n\n  let tiles = build(PARAMS.depth);\n  let lastDepth = PARAMS.depth;\n  let panX = 0, panY = 0, dragging = false, lastX = 0, lastY = 0;\n  const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n  canvas.addEventListener(\"pointerdown\", function (e) {\n    dragging = true; lastX = e.clientX; lastY = e.clientY;\n    canvas.setPointerCapture(e.pointerId);\n  });\n  canvas.addEventListener(\"pointerup\", function () { dragging = false; });\n  canvas.addEventListener(\"pointermove\", function (e) {\n    if (!dragging) return;\n    panX += e.clientX - lastX;\n    panY += e.clientY - lastY;\n    lastX = e.clientX; lastY = e.clientY;\n  });\n\n  function draw(w, h) {\n    if (PARAMS.depth !== lastDepth) {\n      tiles = build(PARAMS.depth);\n      lastDepth = PARAMS.depth;\n    }\n    ctx.fillStyle = \"#0b0c0e\";\n    ctx.fillRect(0, 0, w, h);\n    const fat = hexToRgb(PARAMS.color);\n    const thin = hexToRgb(PARAMS.accent);\n    const s = Math.min(w, h) * 0.46;\n    const cx = w * 0.5 + panX;\n    const cy = h * 0.5 + panY;\n    ctx.lineJoin = \"round\";\n    for (let i = 0; i < tiles.length; i++) {\n      const t = tiles[i];\n      ctx.beginPath();\n      ctx.moveTo(cx + t.a[0] * s, cy + t.a[1] * s);\n      ctx.lineTo(cx + t.b[0] * s, cy + t.b[1] * s);\n      ctx.lineTo(cx + t.c[0] * s, cy + t.c[1] * s);\n      ctx.closePath();\n      ctx.fillStyle = t.k === \"A\" ? css(fat, 0.55) : css(thin, 0.55);\n      ctx.fill();\n      ctx.strokeStyle = css(fat, 0.35);\n      ctx.lineWidth = 0.8;\n      ctx.stroke();\n    }\n  }\n\n  function resize() {\n    const dpr = Math.min(window.devicePixelRatio || 1, 2);\n    const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));\n    const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));\n    if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }\n  }\n\n  function frame() {\n    resize();\n    draw(canvas.width, canvas.height);\n    if (!reduced) requestAnimationFrame(frame);\n  }\n\n  if (reduced) { resize(); draw(canvas.width, canvas.height); }\n  else requestAnimationFrame(frame);\n})();\n</script>\n</body>\n</html>\n"}