{"slug":"fabric","number":"10","title":"Verlet fabric","era":"Jakobsen · 2001","summary":"A hanging cloth. Drag a point to tug the weave.","description":"Thomas Jakobsen's Verlet cloth: a grid of points, distance constraints, gravity. Two corners are pinned. A slow sine is the wind. Drag any point and the rest of the mesh follows.","apis":["Canvas 2D","Pointer Events","requestAnimationFrame","matchMedia"],"tags":["sim","cloth"],"hasGlsl":false,"interaction":"Drag a point on the cloth to tug it.","url":"https://3d-retro.com/experiments/fabric","example_url":"https://3d-retro.com/examples/fabric.html","api_url":"https://3d-retro.com/api/v1/experiments/fabric","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=\"A hanging Verlet cloth. Drag a point to tug the weave.\" />\n  <link rel=\"canonical\" href=\"https://3d-retro.com/experiments/fabric\" />\n  <title>Verlet fabric · 3d retro graphics</title>\n  <style>\n    html, body {\n      margin: 0;\n      width: 100%;\n      height: 100%;\n      overflow: hidden;\n      background: #0b0c0e;\n      color: #e8e6e1;\n      position: absolute;\n      inset: 0;\n    }\n    canvas {\n      display: block;\n      position: absolute;\n      inset: 0;\n      width: 100%;\n      height: 100%;\n      touch-action: none;\n    }\n    .fallback {\n      position: absolute;\n      inset: 0;\n      display: grid;\n      place-items: center;\n      padding: 24px;\n      font: 14px/1.5 ui-monospace, \"IBM Plex Mono\", monospace;\n      text-align: center;\n      color: #e8e6e1;\n      background: #0b0c0e;\n    }\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    wind: 1,\n    stiffness: 3\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\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 COLS = 16;\n  const ROWS = 12;\n  const pts = [];\n  const springs = [];\n  let builtW = 0;\n  let builtH = 0;\n  let grab = -1;\n  let t = 0;\n  let last = 0;\n  let raf = 0;\n  const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n  function link(a, b) {\n    const pa = pts[a], pb = pts[b];\n    springs.push({ a, b, rest: Math.hypot(pb.x - pa.x, pb.y - pa.y) });\n  }\n\n  function build(w, h) {\n    pts.length = 0;\n    springs.length = 0;\n    grab = -1;\n    const left = w * 0.18;\n    const top = h * 0.08;\n    const gw = w * 0.64;\n    const gh = h * 0.62;\n    const cs = gw / (COLS - 1);\n    const rs = gh / (ROWS - 1);\n    for (let j = 0; j < ROWS; j++) {\n      for (let i = 0; i < COLS; i++) {\n        const x = left + i * cs;\n        const y = top + j * rs;\n        pts.push({ x, y, ox: x, oy: y, pin: j === 0 && (i === 0 || i === COLS - 1) });\n      }\n    }\n    for (let j = 0; j < ROWS; j++) {\n      for (let i = 0; i < COLS; i++) {\n        const n = i + j * COLS;\n        if (i < COLS - 1) link(n, n + 1);\n        if (j < ROWS - 1) link(n, n + COLS);\n      }\n    }\n    builtW = w;\n    builtH = h;\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) {\n      canvas.width = w;\n      canvas.height = h;\n    }\n    if (Math.abs(w - builtW) > 4 || Math.abs(h - builtH) > 4) {\n      build(w, h);\n      for (let i = 0; i < 70; i++) step(0.016);\n    }\n  }\n\n  function pointer(ev) {\n    const r = canvas.getBoundingClientRect();\n    const sx = canvas.width / r.width;\n    const sy = canvas.height / r.height;\n    return { x: (ev.clientX - r.left) * sx, y: (ev.clientY - r.top) * sy };\n  }\n\n  canvas.addEventListener(\"pointerdown\", function (ev) {\n    canvas.setPointerCapture(ev.pointerId);\n    const p = pointer(ev);\n    let best = 1e9, idx = 0;\n    for (let i = 0; i < pts.length; i++) {\n      const d = Math.hypot(pts[i].x - p.x, pts[i].y - p.y);\n      if (d < best) { best = d; idx = i; }\n    }\n    grab = idx;\n    pts[idx].x = p.x;\n    pts[idx].y = p.y;\n    pts[idx].ox = p.x;\n    pts[idx].oy = p.y;\n  });\n  canvas.addEventListener(\"pointermove\", function (ev) {\n    if (grab < 0) return;\n    const p = pointer(ev);\n    pts[grab].x = p.x;\n    pts[grab].y = p.y;\n    pts[grab].ox = p.x;\n    pts[grab].oy = p.y;\n  });\n  function release() { grab = -1; }\n  canvas.addEventListener(\"pointerup\", release);\n  canvas.addEventListener(\"pointercancel\", release);\n\n  function step(dt) {\n    const g = 2800 * dt * dt;\n    const wind = Math.sin(t * 1.3) * 90 * PARAMS.wind * dt * dt;\n    for (let i = 0; i < pts.length; i++) {\n      const p = pts[i];\n      if (p.pin || i === grab) continue;\n      const vx = (p.x - p.ox) * 0.99;\n      const vy = (p.y - p.oy) * 0.99;\n      p.ox = p.x;\n      p.oy = p.y;\n      p.x += vx + wind;\n      p.y += vy + g;\n    }\n    const iter = Math.max(1, PARAMS.stiffness | 0);\n    for (let k = 0; k < iter; k++) {\n      for (let s = 0; s < springs.length; s++) {\n        const sp = springs[s];\n        const a = pts[sp.a];\n        const b = pts[sp.b];\n        let dx = b.x - a.x;\n        let dy = b.y - a.y;\n        const d = Math.hypot(dx, dy) || 1e-4;\n        const diff = (d - sp.rest) / d * 0.5;\n        dx *= diff;\n        dy *= diff;\n        const aLock = a.pin || sp.a === grab;\n        const bLock = b.pin || sp.b === grab;\n        if (!aLock) { a.x += dx; a.y += dy; }\n        if (!bLock) { b.x -= dx; b.y -= dy; }\n      }\n    }\n  }\n\n  function draw() {\n    const w = canvas.width;\n    const h = canvas.height;\n    ctx.fillStyle = \"#0b0c0e\";\n    ctx.fillRect(0, 0, w, h);\n    const rgb = hexToRgb(PARAMS.color);\n    const rest2 = springs.length ? springs[0].rest * springs[0].rest : 1;\n    for (let j = 0; j < ROWS - 1; j++) {\n      for (let i = 0; i < COLS - 1; i++) {\n        const a = pts[i + j * COLS];\n        const b = pts[i + 1 + j * COLS];\n        const c = pts[i + (j + 1) * COLS];\n        const d = pts[i + 1 + (j + 1) * COLS];\n        const nx = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);\n        const shade = 0.28 + 0.72 * Math.max(0, Math.min(1, nx / (rest2 * 1.6)));\n        ctx.fillStyle = \"rgba(\" + rgb[0] + \",\" + rgb[1] + \",\" + rgb[2] + \",\" + (0.22 + shade * 0.55) + \")\";\n        ctx.beginPath();\n        ctx.moveTo(a.x, a.y);\n        ctx.lineTo(b.x, b.y);\n        ctx.lineTo(d.x, d.y);\n        ctx.lineTo(c.x, c.y);\n        ctx.closePath();\n        ctx.fill();\n      }\n    }\n    ctx.strokeStyle = \"rgba(\" + rgb[0] + \",\" + rgb[1] + \",\" + rgb[2] + \",0.85)\";\n    ctx.lineWidth = Math.max(0.8, w / 520);\n    ctx.beginPath();\n    for (let s = 0; s < springs.length; s++) {\n      const a = pts[springs[s].a];\n      const b = pts[springs[s].b];\n      ctx.moveTo(a.x, a.y);\n      ctx.lineTo(b.x, b.y);\n    }\n    ctx.stroke();\n  }\n\n  function frame(now) {\n    const dt = Math.min(0.033, last ? (now - last) * 0.001 : 0.016);\n    last = now;\n    t += dt;\n    resize();\n    if (!reduced) step(dt);\n    draw();\n    if (!reduced) raf = requestAnimationFrame(frame);\n  }\n\n  if (reduced) {\n    resize();\n    for (let i = 0; i < 40; i++) step(0.016);\n    draw();\n  } else {\n    raf = requestAnimationFrame(frame);\n  }\n})();\n</script>\n</body>\n</html>\n"}