← Physics Playground results

Physics Playground

Qwen 3.8 max · middle

Initial view of Physics Playground

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 50,557 bytes · SHA-256 cad73e8fc14d
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Soft-Body Physics Playground — Verlet + Constraint Relaxation</title>
<style>
  html,body{margin:0;height:100%;overflow:hidden;background:#0a0d13;
    font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;user-select:none;-webkit-user-select:none;}
  #cv{display:block;position:fixed;inset:0;cursor:crosshair;touch-action:none;}

  /* ---------- control panel ---------- */
  #panel{position:fixed;top:12px;left:12px;width:236px;z-index:10;
    background:rgba(13,17,26,.86);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);
    border:1px solid rgba(255,255,255,.09);border-radius:13px;color:#cfd8e6;
    box-shadow:0 10px 40px rgba(0,0,0,.5);font-size:12px;}
  #phead{display:flex;align-items:center;justify-content:space-between;padding:10px 12px 8px;}
  #ptitle{font-weight:700;letter-spacing:.14em;font-size:11.5px;color:#eaf3ff;}
  #ptitle span{display:block;font-weight:400;letter-spacing:.02em;font-size:9.5px;color:#5f6d85;margin-top:2px;}
  #fold{background:#1a2130;border:1px solid rgba(255,255,255,.1);color:#9fb0c8;border-radius:6px;
    width:22px;height:20px;line-height:1;cursor:pointer;font-size:12px;}
  #fold:hover{background:#242d40;}
  #pbody{padding:0 12px 12px;max-height:calc(100vh - 90px);overflow-y:auto;}
  #panel.min #pbody{display:none;}
  #tools{display:grid;grid-template-columns:repeat(3,1fr);gap:5px;margin-bottom:8px;}
  .tbtn{background:#181f2d;border:1px solid rgba(255,255,255,.08);border-radius:8px;color:#dfe8f5;
    padding:6px 0 4px;cursor:pointer;font-size:15px;line-height:1.1;text-align:center;}
  .tbtn i{display:block;font-style:normal;font-size:8.5px;letter-spacing:.08em;color:#7f8ea6;margin-top:2px;text-transform:uppercase;}
  .tbtn:hover{background:#222c40;}
  .tbtn.active{background:#123c4d;border-color:#3fd0ff;color:#7ce4ff;box-shadow:0 0 10px rgba(63,208,255,.25);}
  .tbtn.active i{color:#59b8d8;}
  #chips{display:flex;gap:6px;margin-bottom:10px;}
  .chip{background:#161d2b;border:1px solid rgba(255,255,255,.08);color:#9fb0c8;border-radius:20px;
    font-size:10px;padding:3px 10px;cursor:pointer;}
  .chip:hover{border-color:#3fd0ff;color:#7ce4ff;}
  .srow{margin:7px 0;}
  .srow label{display:flex;justify-content:space-between;font-size:10.5px;color:#8fa0b8;margin-bottom:2px;letter-spacing:.04em;}
  .srow label b{color:#dfe8f5;font-weight:600;font-variant-numeric:tabular-nums;}
  .srow input[type=range]{width:100%;accent-color:#3fd0ff;height:16px;margin:0;}
  #checks{display:grid;grid-template-columns:1fr 1fr;gap:3px 8px;margin:9px 0;}
  #checks label{display:flex;align-items:center;gap:5px;font-size:11px;color:#9fb0c8;cursor:pointer;}
  #checks input{accent-color:#3fd0ff;margin:0;}
  #actions{display:grid;grid-template-columns:repeat(4,1fr);gap:5px;margin:4px 0 8px;}
  #actions button{background:#181f2d;border:1px solid rgba(255,255,255,.09);border-radius:7px;color:#cfd8e6;
    font-size:10px;padding:6px 0;cursor:pointer;letter-spacing:.02em;}
  #actions button:hover{background:#232e44;border-color:#3fd0ff66;}
  #stats{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10px;color:#6d7d96;
    background:rgba(0,0,0,.25);border-radius:7px;padding:5px 8px;margin-bottom:8px;white-space:pre;}
  #help{font-size:9.5px;line-height:1.65;color:#5f6d85;}
  #help b{color:#93a4bd;font-weight:600;}
  #pbody::-webkit-scrollbar{width:6px;} #pbody::-webkit-scrollbar-thumb{background:#2a3550;border-radius:3px;}
</style>
</head>
<body>
<canvas id="cv"></canvas>

<div id="panel">
  <div id="phead">
    <div id="ptitle">SOFT-BODY LAB<span>verlet integration · constraint relaxation</span></div>
    <button id="fold" title="collapse panel">–</button>
  </div>
  <div id="pbody">
    <div id="tools">
      <button class="tbtn active" data-tool="grab"  title="Grab &amp; throw (1)">✋<i>grab</i></button>
      <button class="tbtn" data-tool="rope"  title="Rope — drag from anchor (2)">〰<i>rope</i></button>
      <button class="tbtn" data-tool="cloth" title="Cloth — drag a rectangle (3)">▦<i>cloth</i></button>
      <button class="tbtn" data-tool="blob"  title="Soft blob — drag radius (4)">●<i>blob</i></button>
      <button class="tbtn" data-tool="jelly" title="Soft box — drag a rectangle (5)">▣<i>jelly</i></button>
      <button class="tbtn" data-tool="ball"  title="Ball — drag to fling (6)">◉<i>ball</i></button>
    </div>
    <div id="chips">
      <button class="chip" id="pinChip" title="Cloth pin pattern (click to cycle)">cloth pins: full edge</button>
    </div>
    <div class="srow"><label>gravity <b id="gravVal">1200</b></label><input type="range" id="grav" min="0" max="2400" step="25" value="1200"></div>
    <div class="srow"><label>solver iterations <b id="iterVal">5</b></label><input type="range" id="iter" min="1" max="10" step="1" value="5"></div>
    <div class="srow"><label>wind strength <b id="windVal">220</b></label><input type="range" id="wind" min="0" max="600" step="10" value="220"></div>
    <div id="checks">
      <label><input type="checkbox" id="cGrav" checked> gravity</label>
      <label><input type="checkbox" id="cWind"> wind</label>
      <label><input type="checkbox" id="cStress" checked> stress view</label>
      <label><input type="checkbox" id="cPts"> show points</label>
      <label><input type="checkbox" id="cColl" checked> point collide</label>
      <label><input type="checkbox" id="cTrail"> motion trails</label>
    </div>
    <div id="actions">
      <button id="bPause" title="Space">⏸ pause</button>
      <button id="bStep"  title="→ while paused">step →</button>
      <button id="bReset" title="R">reset</button>
      <button id="bClear" title="C">clear</button>
    </div>
    <div id="stats">booting…</div>
    <div id="help">
      <b>drag</b> grab / whip &nbsp;·&nbsp; <b>R-drag / Alt-drag</b> slice<br>
      <b>Shift-click</b> pin / unpin a point<br>
      <b>Space</b> pause · <b>→</b> step frame · <b>1–6</b> tools<br>
      <b>R</b> reset · <b>C</b> clear · <b>V</b> stress · <b>G</b> gravity
    </div>
  </div>
</div>

<script>
'use strict';
/* ================================================================
   1. TUNABLE CONSTANTS — the whole feel of the sim lives here
   ================================================================ */
const CFG = {
  DT:            1 / 60,   // fixed physics timestep (s)
  MAX_STEPS:     5,        // max physics steps per render frame (death-spiral guard)
  MAX_FRAME_MS:  120,      // clamp on frame delta (tab-switch must not detonate)
  GRAVITY:       1200,     // px / s^2
  DAMPING:       0.995,    // verlet velocity retention per step (energy decay)
  MAX_VEL:       3600,     // px/s speed clamp — anti-explosion / anti-tunnel
  ITERATIONS:    5,        // constraint-relaxation passes per step
  FRICTION:      0.14,     // tangential velocity removed on contact (final pass)
  WIND:          220,      // horizontal accel amplitude when wind enabled
  GRAB_RADIUS:   30,       // px — nearest-point capture radius
  TEAR_RADIUS:   9,        // px — click-to-cut proximity radius
  ROPE_SEG:      11,       // px per rope segment
  CLOTH_SPACING: 13,       // px cloth grid spacing
  MAX_POINTS:    3200,     // global budget for dynamic points
  PAIR_CELL:     32,       // spatial-hash cell for point/point collision
  TRAIL_FADE:    0.30,     // alpha of the fade wash when trails are on
};

/* ================================================================
   2. SMALL MATH UTILITIES
   ================================================================ */
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;

function segIntersect(ax, ay, bx, by, cx, cy, dx, dy) {
  const d = (bx - ax) * (dy - cy) - (by - ay) * (dx - cx);
  if (Math.abs(d) < 1e-12) return false;
  const t = ((cx - ax) * (dy - cy) - (cy - ay) * (dx - cx)) / d;
  const u = ((cx - ax) * (by - ay) - (cy - ay) * (bx - ax)) / d;
  return t >= 0 && t <= 1 && u >= 0 && u <= 1;
}
function pointSegDist2(px, py, ax, ay, bx, by) {
  const dx = bx - ax, dy = by - ay;
  const l2 = dx * dx + dy * dy;
  let t = l2 > 1e-9 ? ((px - ax) * dx + (py - ay) * dy) / l2 : 0;
  t = clamp(t, 0, 1);
  const qx = ax + dx * t - px, qy = ay + dy * t - py;
  return qx * qx + qy * qy;
}
function strainColor(strain) {
  const t = clamp(strain / 0.6, 0, 1);      // 0 = relaxed … 0.6+ = max red
  return 'hsl(' + (130 - 130 * t) + ',92%,55%)';
}
// roundRect polyfill for older canvases
if (!CanvasRenderingContext2D.prototype.roundRect) {
  CanvasRenderingContext2D.prototype.roundRect = function (x, y, w, h, r) {
    r = Math.min(r, w / 2, h / 2);
    this.moveTo(x + r, y);
    this.arcTo(x + w, y, x + w, y + h, r);
    this.arcTo(x + w, y + h, x, y + h, r);
    this.arcTo(x, y + h, x, y, r);
    this.arcTo(x, y, x + w, y, r);
    this.closePath();
    return this;
  };
}

/* ================================================================
   3. WORLD — Verlet points + distance constraints (PBD style)
   ================================================================ */
class World {
  constructor(w, h) {
    this.w = w; this.h = h;
    this.points = [];        // {x,y,px,py,r,pinned,grabbed,rest,nodot,obj}
    this.constraints = [];   // {a,b,rest,stiff,active,obj}
    this.statics = [];       // {type:'circle',x,y,r} | {type:'box',x,y,hw,hh}
    this.objects = [];       // render metadata {kind:'rope|cloth|blob|jelly|ball',...}
    this.time = 0;
    this.gravity = CFG.GRAVITY;
    this.gravityOn = true;
    this.windOn = false;
    this.wind = CFG.WIND;
    this.iterations = CFG.ITERATIONS;
    this.collide = true;     // point-vs-point collision toggle
    this.grabbed = null; this.grabX = 0; this.grabY = 0;
    this.onCut = null;       // callback(x,y) when a constraint is sliced
  }

  /* ---------- construction ---------- */
  addPoint(x, y, o = {}) {
    const p = {
      x, y, px: x, py: y,
      r: o.r !== undefined ? o.r : 2,
      pinned: !!o.pinned, grabbed: false,
      rest: o.rest !== undefined ? o.rest : 0,   // restitution
      nodot: !!o.nodot, obj: o.obj || null,
    };
    this.points.push(p);
    return p;
  }
  link(a, b, o = {}) {
    const dx = b.x - a.x, dy = b.y - a.y;
    const c = {
      a, b,
      rest: o.rest !== undefined ? o.rest : Math.sqrt(dx * dx + dy * dy),
      stiff: o.stiff !== undefined ? o.stiff : 1,
      active: true, obj: o.obj || null,
    };
    this.constraints.push(c);
    return c;
  }
  addCircle(x, y, r) { this.statics.push({ type: 'circle', x, y, r }); }
  addBox(x, y, hw, hh) { this.statics.push({ type: 'box', x, y, hw, hh }); }

  /* ---------- one fixed physics step ---------- */
  step(dt) {
    if (dt === undefined) dt = CFG.DT;
    this.time += dt;
    const g = this.gravityOn ? this.gravity : 0;
    const windAmp = this.windOn ? this.wind : 0;
    const damp = CFG.DAMPING;
    const maxV = CFG.MAX_VEL * dt;
    const maxV2 = maxV * maxV;
    const t = this.time;

    /* -- integrate (Verlet) -- */
    for (let i = 0; i < this.points.length; i++) {
      const p = this.points[i];
      if (p.pinned || p.grabbed) { p.px = p.x; p.py = p.y; continue; }
      let vx = (p.x - p.px) * damp;
      let vy = (p.y - p.py) * damp;
      const v2 = vx * vx + vy * vy;
      if (v2 > maxV2) { const s = maxV / Math.sqrt(v2); vx *= s; vy *= s; } // speed clamp = stability
      p.px = p.x; p.py = p.y;
      let ax = 0;
      if (windAmp) ax = windAmp * (0.55 + 0.45 * Math.sin(t * 2.3 + p.y * 0.006 + p.x * 0.002));
      p.x += vx + ax * dt * dt;
      p.y += vy + g * dt * dt;
    }
    const gp = this.grabbed;
    if (gp) { gp.x = this.grabX; gp.y = this.grabY; }

    /* -- constraint relaxation + static collision, per iteration -- */
    const iter = this.iterations;
    for (let it = 0; it < iter; it++) {
      const last = it === iter - 1;
      const cs = this.constraints;
      for (let i = 0; i < cs.length; i++) {
        const c = cs[i];
        if (!c.active) continue;
        const a = c.a, b = c.b;
        const dx = b.x - a.x, dy = b.y - a.y;
        const d2 = dx * dx + dy * dy;
        if (d2 < 1e-12) continue;                    // coincident points — NaN guard
        const d = Math.sqrt(d2);
        const diff = (d - c.rest) / d * c.stiff;
        const aP = a.pinned || a.grabbed, bP = b.pinned || b.grabbed;
        if (!aP && !bP) {
          const ox = dx * diff * 0.5, oy = dy * diff * 0.5;
          a.x += ox; a.y += oy; b.x -= ox; b.y -= oy;
        } else if (aP && !bP) { b.x -= dx * diff; b.y -= dy * diff; }
        else if (!aP && bP)   { a.x += dx * diff; a.y += dy * diff; }
      }
      if (gp) { gp.x = this.grabX; gp.y = this.grabY; } // keep grabbed point welded to cursor
      this.collideStatics(last);                        // friction only on final pass
    }

    /* -- point vs point (one pass, spatial hash) -- */
    if (this.collide) this.collidePoints();

    /* -- belt & suspenders: numeric sanitiser (no NaN may escape) -- */
    const M = 1e5;
    for (let i = 0; i < this.points.length; i++) {
      const p = this.points[i];
      if (!Number.isFinite(p.x) || !Number.isFinite(p.y) ||
          !Number.isFinite(p.px) || !Number.isFinite(p.py) ||
          p.x > M || p.x < -M || p.y > M || p.y < -M) {
        p.x = clamp(Number.isFinite(p.x) ? p.x : this.w * 0.5, 4, this.w - 4);
        p.y = clamp(Number.isFinite(p.y) ? p.y : this.h * 0.5, 4, this.h - 4);
        p.px = p.x; p.py = p.y;
      }
    }
  }

  /* ---------- static geometry collision (walls + shapes) ---------- */
  collideStatics(withFriction) {
    const F = CFG.FRICTION;
    for (let i = 0; i < this.points.length; i++) {
      const p = this.points[i];
      if (p.pinned || p.grabbed) continue;
      const r = p.r;

      // world bounds (floor / walls / ceiling)
      if (p.y > this.h - r) {
        const vx = p.x - p.px, vy = p.y - p.py;
        p.y = this.h - r;
        this.respond(p, 0, -1, vx, vy, withFriction);
      } else if (p.y < r) {
        const vx = p.x - p.px, vy = p.y - p.py;
        p.y = r;
        this.respond(p, 0, 1, vx, vy, withFriction);
      }
      if (p.x < r) {
        const vx = p.x - p.px, vy = p.y - p.py;
        p.x = r;
        this.respond(p, 1, 0, vx, vy, withFriction);
      } else if (p.x > this.w - r) {
        const vx = p.x - p.px, vy = p.y - p.py;
        p.x = this.w - r;
        this.respond(p, -1, 0, vx, vy, withFriction);
      }

      // static circles & boxes
      for (let s = 0; s < this.statics.length; s++) {
        const sh = this.statics[s];
        if (sh.type === 'circle') {
          const dx = p.x - sh.x, dy = p.y - sh.y;
          const R = sh.r + r, d2 = dx * dx + dy * dy;
          if (d2 < R * R) {
            let d = Math.sqrt(d2), nx, ny;
            if (d < 1e-6) { nx = 0; ny = -1; d = 1e-6; } else { nx = dx / d; ny = dy / d; }
            const vx = p.x - p.px, vy = p.y - p.py;
            p.x = sh.x + nx * R; p.y = sh.y + ny * R;
            this.respond(p, nx, ny, vx, vy, withFriction);
          }
        } else { // box
          const lx = p.x - sh.x, ly = p.y - sh.y;
          const cx = clamp(lx, -sh.hw, sh.hw), cy = clamp(ly, -sh.hh, sh.hh);
          const dx = lx - cx, dy = ly - cy;
          const d2 = dx * dx + dy * dy;
          if (d2 < r * r) {
            const vx = p.x - p.px, vy = p.y - p.py;
            let nx, ny, push;
            if (d2 > 1e-9) {
              const d = Math.sqrt(d2);
              nx = dx / d; ny = dy / d; push = r - d;
            } else { // point center inside the box — eject along smallest penetration
              const pxr = sh.hw - Math.abs(lx), pyb = sh.hh - Math.abs(ly);
              if (pxr < pyb) { nx = lx >= 0 ? 1 : -1; ny = 0; push = pxr + r; }
              else           { nx = 0; ny = ly >= 0 ? 1 : -1; push = pyb + r; }
            }
            p.x += nx * push; p.y += ny * push;
            this.respond(p, nx, ny, vx, vy, withFriction);
          }
        }
      }
    }
  }
  // set previous-position so implied velocity = reflected/damped contact response
  respond(p, nx, ny, vx, vy, withFriction) {
    const vn = vx * nx + vy * ny;
    let tx = vx - vn * nx, ty = vy - vn * ny;
    if (withFriction) { tx *= (1 - CFG.FRICTION); ty *= (1 - CFG.FRICTION); }
    const bn = vn < 0 ? -vn * p.rest : 0;      // restitution only when moving into surface
    p.px = p.x - (tx + nx * bn);
    p.py = p.y - (ty + ny * bn);
  }

  /* ---------- point vs point collision via spatial hash ---------- */
  collidePoints() {
    const cell = CFG.PAIR_CELL, pts = this.points, n = pts.length;
    if (n < 2) return;
    const grid = new Map();
    for (let i = 0; i < n; i++) {
      const p = pts[i];
      const key = (Math.floor(p.x / cell) + 2048) * 8192 + (Math.floor(p.y / cell) + 2048);
      let arr = grid.get(key);
      if (!arr) grid.set(key, arr = []);
      arr.push(i);
    }
    const NB = [0, 1, 8192, 8191, 8193]; // self + 4 neighbours (each pair seen once)
    for (const [key, arr] of grid) {
      for (let k = 0; k < NB.length; k++) {
        const other = k === 0 ? arr : grid.get(key + NB[k]);
        if (!other) continue;
        for (let i = 0; i < arr.length; i++) {
          const a = pts[arr[i]];
          const jStart = k === 0 ? i + 1 : 0;
          for (let j = jStart; j < other.length; j++) {
            const b = pts[other[j]];
            const minD = a.r + b.r;
            const dx = b.x - a.x, dy = b.y - a.y;
            const d2 = dx * dx + dy * dy;
            if (d2 >= minD * minD) continue;
            const aP = a.pinned || a.grabbed, bP = b.pinned || b.grabbed;
            if (aP && bP) continue;
            let d = Math.sqrt(d2), nx, ny;
            if (d < 1e-6) { nx = 1; ny = 0; d = 1e-6; } else { nx = dx / d; ny = dy / d; }
            const push = (minD - d);
            if (!aP && !bP) {
              const h = push * 0.5;
              a.x -= nx * h; a.y -= ny * h; b.x += nx * h; b.y += ny * h;
            } else if (aP) { b.x += nx * push; b.y += ny * push; }
            else           { a.x -= nx * push; a.y -= ny * push; }
          }
        }
      }
    }
  }

  /* ---------- queries / interaction helpers ---------- */
  nearestPoint(x, y, maxD) {
    let best = null, bd = maxD * maxD;
    for (let i = 0; i < this.points.length; i++) {
      const p = this.points[i];
      const dx = p.x - x, dy = p.y - y, d2 = dx * dx + dy * dy;
      if (d2 < bd) { bd = d2; best = p; }
    }
    return best;
  }
  togglePinAt(x, y) {
    const p = this.nearestPoint(x, y, 24);
    if (!p) return null;
    p.pinned = !p.pinned;
    if (p.pinned) { p.px = p.x; p.py = p.y; }
    return p;
  }
  slice(x0, y0, x1, y1) {
    const degenerate = (x1 - x0) * (x1 - x0) + (y1 - y0) * (y1 - y0) < 0.5;
    const tr2 = CFG.TEAR_RADIUS * CFG.TEAR_RADIUS;
    let cuts = 0;
    for (let i = 0; i < this.constraints.length; i++) {
      const c = this.constraints[i];
      if (!c.active) continue;
      const a = c.a, b = c.b;
      const hit = degenerate
        ? pointSegDist2(x0, y0, a.x, a.y, b.x, b.y) < tr2
        : segIntersect(x0, y0, x1, y1, a.x, a.y, b.x, b.y);
      if (hit) {
        c.active = false; cuts++;
        if (this.onCut) this.onCut((a.x + b.x) / 2, (a.y + b.y) / 2);
      }
    }
    return cuts;
  }
  kinetic() {
    let e = 0;
    for (let i = 0; i < this.points.length; i++) {
      const p = this.points[i];
      const vx = p.x - p.px, vy = p.y - p.py;
      e += vx * vx + vy * vy;
    }
    return e;
  }
  clearDynamic() {
    this.points.length = 0;
    this.constraints.length = 0;
    this.objects.length = 0;
    this.grabbed = null;
  }
}

/* ================================================================
   4. SPAWNABLE OBJECTS — rope, cloth, soft blob, jelly box, ball
   ================================================================ */
const HUES = { rope: [38, 22, 55], cloth: [176, 199, 152], blob: [330, 300, 348], jelly: [140, 96, 164], ball: [208, 188, 228] };
const hueIdx = {};
function nextHue(kind) {
  const list = HUES[kind];
  hueIdx[kind] = (hueIdx[kind] || 0) + 1;
  return list[(hueIdx[kind] - 1) % list.length];
}

World.prototype.spawnRope = function (x0, y0, x1, y1, opts = {}) {
  let dx = x1 - x0, dy = y1 - y0;
  let len = Math.sqrt(dx * dx + dy * dy);
  if (len < 8) { dx = 0; dy = 180; len = 180; }               // tiny drag → default hanging rope
  const n = clamp(Math.round(len / CFG.ROPE_SEG), 5, 90);
  if (this.points.length + n + 1 > CFG.MAX_POINTS) return null;
  const meta = { kind: 'rope', hue: nextHue('rope'), pts: [], cons: [] };
  for (let i = 0; i <= n; i++) {
    const t = i / n;
    meta.pts.push(this.addPoint(x0 + dx * t, y0 + dy * t, { r: 2.2, obj: meta }));
  }
  for (let i = 0; i < n; i++) meta.cons.push(this.link(meta.pts[i], meta.pts[i + 1], { obj: meta }));
  if (opts.pinStart !== false) meta.pts[0].pinned = true;
  this.objects.push(meta);
  return meta;
};

World.prototype.spawnCloth = function (x0, y0, w, h, pinMode) {
  const sp = CFG.CLOTH_SPACING;
  let cols = clamp(Math.round(w / sp), 3, 44);
  let rows = clamp(Math.round(h / sp), 3, 30);
  const budget = CFG.MAX_POINTS - this.points.length;
  let total = (cols + 1) * (rows + 1);
  if (total > budget) {
    const f = Math.sqrt(Math.max(0, budget) / total);
    cols = Math.max(3, Math.floor(cols * f));
    rows = Math.max(3, Math.floor(rows * f));
    total = (cols + 1) * (rows + 1);
  }
  if (total > budget || total < 16) return null;
  const cw = w / cols, ch = h / rows, cp = cols + 1;
  const meta = { kind: 'cloth', hue: nextHue('cloth'), cols, rows, pts: [], hcs: [], vcs: [], restArea: cw * ch };
  for (let j = 0; j <= rows; j++)
    for (let i = 0; i <= cols; i++) {
      const p = this.addPoint(x0 + i * cw, y0 + j * ch, { r: 2, obj: meta, nodot: true });
      meta.pts.push(p);
    }
  for (let j = 0; j <= rows; j++)
    for (let i = 0; i < cols; i++)
      meta.hcs.push(this.link(meta.pts[j * cp + i], meta.pts[j * cp + i + 1], { obj: meta }));
  for (let j = 0; j < rows; j++)
    for (let i = 0; i <= cols; i++)
      meta.vcs.push(this.link(meta.pts[j * cp + i], meta.pts[(j + 1) * cp + i], { obj: meta }));
  for (let j = 0; j < rows; j++)
    for (let i = 0; i < cols; i++) {   // shear diagonals keep the weave from collapsing
      this.link(meta.pts[j * cp + i], meta.pts[(j + 1) * cp + i + 1], { stiff: 0.55, obj: meta });
      this.link(meta.pts[j * cp + i + 1], meta.pts[(j + 1) * cp + i], { stiff: 0.55, obj: meta });
    }
  if (pinMode === 'corners') {
    meta.pts[0].pinned = true;
    meta.pts[cols].pinned = true;
  } else {                              // full edge
    for (let i = 0; i <= cols; i++) meta.pts[i].pinned = true;
  }
  this.objects.push(meta);
  return meta;
};

World.prototype.spawnBlob = function (cx, cy, R) {
  R = clamp(R, 14, 150);
  const K = clamp(Math.round((2 * Math.PI * R) / 15), 9, 26);
  if (this.points.length + K + 1 > CFG.MAX_POINTS) return null;
  const meta = { kind: 'blob', hue: nextHue('blob'), ring: [], cons: [], center: null };
  meta.center = this.addPoint(cx, cy, { r: 2, nodot: true, obj: meta });
  for (let k = 0; k < K; k++) {
    const ang = (k / K) * Math.PI * 2 - Math.PI / 2;
    meta.ring.push(this.addPoint(cx + Math.cos(ang) * R, cy + Math.sin(ang) * R, { r: 2.5, obj: meta }));
  }
  for (let k = 0; k < K; k++) meta.cons.push(this.link(meta.ring[k], meta.ring[(k + 1) % K], { obj: meta }));
  for (let k = 0; k < K; k++) this.link(meta.ring[k], meta.ring[(k + 2) % K], { stiff: 0.55, obj: meta }); // bending
  for (let k = 0; k < K; k++) this.link(meta.center, meta.ring[k], { stiff: 0.45, obj: meta });            // spokes
  this.objects.push(meta);
  return meta;
};

World.prototype.spawnJelly = function (x0, y0, w, h) {
  w = Math.max(46, w); h = Math.max(46, h);
  const n = 4, cp = n + 1;                       // 5×5 points
  if (this.points.length + cp * cp > CFG.MAX_POINTS) return null;
  const meta = { kind: 'jelly', hue: nextHue('jelly'), pts: [], cons: [], perim: [] };
  const cw = w / n, ch = h / n;
  for (let j = 0; j <= n; j++)
    for (let i = 0; i <= n; i++)
      meta.pts.push(this.addPoint(x0 + i * cw, y0 + j * ch, { r: 3, obj: meta }));
  const at = (i, j) => meta.pts[j * cp + i];
  for (let j = 0; j <= n; j++)
    for (let i = 0; i < n; i++) meta.cons.push(this.link(at(i, j), at(i + 1, j), { stiff: 0.9, obj: meta }));
  for (let j = 0; j < n; j++)
    for (let i = 0; i <= n; i++) meta.cons.push(this.link(at(i, j), at(i, j + 1), { stiff: 0.9, obj: meta }));
  for (let j = 0; j < n; j++)
    for (let i = 0; i < n; i++) {
      this.link(at(i, j), at(i + 1, j + 1), { stiff: 0.45, obj: meta });
      this.link(at(i + 1, j), at(i, j + 1), { stiff: 0.45, obj: meta });
    }
  // perimeter in order (for the filled outline)
  for (let i = 0; i <= n; i++) meta.perim.push(at(i, 0));
  for (let j = 1; j <= n; j++) meta.perim.push(at(n, j));
  for (let i = n - 1; i >= 0; i--) meta.perim.push(at(i, n));
  for (let j = n - 1; j >= 1; j--) meta.perim.push(at(0, j));
  // perimeter bending springs → holds its squareness
  const P = meta.perim.length;
  for (let k = 0; k < P; k++) this.link(meta.perim[k], meta.perim[(k + 2) % P], { stiff: 0.5, obj: meta });
  this.objects.push(meta);
  return meta;
};

World.prototype.spawnBall = function (x, y, r) {
  if (this.points.length + 1 > CFG.MAX_POINTS) return null;
  r = clamp(r || 13, 6, 16);
  const meta = { kind: 'ball', hue: nextHue('ball'), p: null, r };
  meta.p = this.addPoint(x, y, { r, rest: 0.55, obj: meta });
  this.objects.push(meta);
  return meta;
};

/* ================================================================
   5. DEFAULT SCENE
   ================================================================ */
function buildDefaultScene(w) {
  w.statics.length = 0;
  // static circle for the cloth to drape over
  w.addCircle(w.w * 0.36, w.h * 0.66, clamp(w.w * 0.07, 46, 92));
  // static box on the right for the blob to land on
  const hh = clamp(w.h * 0.11, 60, 96), hw = clamp(w.w * 0.085, 60, 115);
  w.addBox(w.w * 0.80, w.h - hh, hw, hh);

  w.spawnCloth(w.w * 0.20, w.h * 0.10, Math.min(w.w * 0.34, 430), Math.min(w.h * 0.36, 300), 'edge');
  w.spawnRope(w.w * 0.62, w.h * 0.08, w.w * 0.62, w.h * 0.08 + Math.min(w.h * 0.42, 360));
  w.spawnBlob(w.w * 0.80, w.h * 0.28, 44);
  w.spawnBall(w.w * 0.52, w.h * 0.16, 13);
}
World.prototype.reset = function () {
  this.clearDynamic();
  buildDefaultScene(this);
};

/* ================================================================
   6. SELF-TEST — headless checks on scratch worlds
   ================================================================ */
function selfTest() {
  const out = {};
  // 1. drop a soft blob from height → settles, nothing below floor
  {
    const w = new World(world.w, world.h);
    w.spawnBlob(w.w / 2, w.h * 0.2, 42);
    for (let i = 0; i < 180; i++) w.step();
    const n = w.points.length, ke = w.kinetic();
    let below = 0, bad = 0;
    for (const p of w.points) {
      if (p.y > w.h + 0.5) below++;
      if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) bad++;
    }
    out.dropBlob = { pass: bad === 0 && below === 0 && ke / n < 0.03, kePerPoint: +(ke / n).toFixed(5), pointsBelowFloor: below, nan: bad };
  }
  // 2. 1000 frames of a mixed scene → no NaN, no runaway coordinates
  {
    const w = new World(world.w, world.h);
    w.spawnRope(w.w * 0.3, 40, w.w * 0.3, 300);
    w.spawnCloth(w.w * 0.45, 40, 220, 150, 'corners');
    w.spawnBlob(w.w * 0.7, 120, 36);
    w.spawnBall(w.w * 0.55, 60, 13);
    for (let i = 0; i < 1000; i++) w.step();
    let bad = 0;
    for (const p of w.points)
      if (!Number.isFinite(p.x) || !Number.isFinite(p.y) || Math.abs(p.x) > 1e5 || Math.abs(p.y) > 1e5) bad++;
    out.stability1000 = { pass: bad === 0, badCoords: bad, points: w.points.length };
  }
  // 3. corner-pinned cloth, 600 frames → pins immovable, cloth above floor
  {
    const w = new World(world.w, world.h);
    const m = w.spawnCloth(w.w / 2 - 110, 60, 220, 150, 'corners');
    const p0 = m.pts[0], pN = m.pts[m.cols];
    const a0 = { x: p0.x, y: p0.y }, aN = { x: pN.x, y: pN.y };
    for (let i = 0; i < 600; i++) w.step();
    let maxY = -1e9;
    for (const p of m.pts) if (p.y > maxY) maxY = p.y;
    const pinsHold = Math.hypot(p0.x - a0.x, p0.y - a0.y) < 1e-3 && Math.hypot(pN.x - aN.x, pN.y - aN.y) < 1e-3;
    out.clothPins600 = { pass: pinsHold && maxY <= w.h + 0.5, pinsHold, lowestY: +maxY.toFixed(1), floorY: w.h };
  }
  // 4. tear a rope mid-air → halves move independently
  {
    const w = new World(world.w, world.h);
    const m = w.spawnRope(w.w / 2, 60, w.w / 2, 60 + 22 * CFG.ROPE_SEG);
    for (let i = 0; i < 120; i++) w.step();
    const mid = Math.floor(m.cons.length / 2);
    const upperProbe = m.pts[3], lowerProbe = m.pts[m.pts.length - 4];
    const u0 = upperProbe.y, l0 = lowerProbe.y;
    m.cons[mid].active = false;
    for (let i = 0; i < 90; i++) w.step();
    // connectivity: lower probe must be unreachable from the anchor now
    const adj = new Map();
    for (const c of m.cons) if (c.active) {
      if (!adj.has(c.a)) adj.set(c.a, []);
      if (!adj.has(c.b)) adj.set(c.b, []);
      adj.get(c.a).push(c.b); adj.get(c.b).push(c.a);
    }
    const seen = new Set([m.pts[0]]), stack = [m.pts[0]];
    while (stack.length) {
      const q = stack.pop();
      for (const nb of (adj.get(q) || [])) if (!seen.has(nb)) { seen.add(nb); stack.push(nb); }
    }
    const independent = !seen.has(lowerProbe);
    const lowerFell = lowerProbe.y - l0 > 20;
    const upperNear = Math.abs(upperProbe.y - u0) < 40;
    out.ropeTear = { pass: independent && lowerFell && upperNear, independent, lowerDrop: +(lowerProbe.y - l0).toFixed(1), upperDrift: +Math.abs(upperProbe.y - u0).toFixed(1) };
  }
  out.allPass = out.dropBlob.pass && out.stability1000.pass && out.clothPins600.pass && out.ropeTear.pass;
  return out;
}

/* ================================================================
   7. CANVAS SETUP
   ================================================================ */
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
let dpr = 1;
const world = new World(window.innerWidth, window.innerHeight);
buildDefaultScene(world);

function resize() {
  dpr = Math.min(window.devicePixelRatio || 1, 2);
  cv.width = Math.floor(window.innerWidth * dpr);
  cv.height = Math.floor(window.innerHeight * dpr);
  cv.style.width = window.innerWidth + 'px';
  cv.style.height = window.innerHeight + 'px';
  world.w = window.innerWidth;
  world.h = window.innerHeight;
}
window.addEventListener('resize', resize);
resize();

/* ================================================================
   8. COSMETIC STATE — particles, slice trail, UI flags
   ================================================================ */
const particles = [];
function burst(x, y, hue, n, speed) {
  for (let i = 0; i < n && particles.length < 500; i++) {
    const a = Math.random() * Math.PI * 2, s = (0.3 + Math.random()) * (speed || 160);
    particles.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s - 40, life: 0.45 + Math.random() * 0.3, t: 0, hue });
  }
}
world.onCut = (x, y) => burst(x, y, 18, 7, 220);

const sliceTrail = [];
function pushSlice(x, y) {
  sliceTrail.push({ x, y, t: performance.now() });
  if (sliceTrail.length > 40) sliceTrail.shift();
}

const ui = {
  tool: 'grab',
  clothPins: 'edge',      // 'edge' | 'corners'
  stress: true,
  showPoints: false,
  trails: false,
  paused: false,
};

/* ================================================================
   9. INPUT — grab / slice / pin / spawn
   ================================================================ */
const mouse = { x: 0, y: 0, over: false };
let action = null;        // {type:'slice',lx,ly} | {type:'place',sx,sy,ex,ey}
let activePointer = null;

function toLocal(e) {
  const r = cv.getBoundingClientRect();
  return { x: e.clientX - r.left, y: e.clientY - r.top };
}

cv.addEventListener('contextmenu', e => e.preventDefault());

cv.addEventListener('pointerdown', e => {
  const m = toLocal(e);
  mouse.x = m.x; mouse.y = m.y; mouse.over = true;
  if (activePointer !== null) return;
  activePointer = e.pointerId;
  cv.setPointerCapture(e.pointerId);

  // right-drag or Alt+left-drag → slice
  if (e.button === 2 || (e.button === 0 && e.altKey)) {
    action = { type: 'slice', lx: m.x, ly: m.y };
    pushSlice(m.x, m.y);
    world.slice(m.x, m.y, m.x, m.y);   // click-cut via tear radius
    return;
  }
  if (e.button !== 0) return;

  // Shift-click → toggle pin
  if (e.shiftKey) {
    const p = world.togglePinAt(m.x, m.y);
    if (p) burst(p.x, p.y, p.pinned ? 45 : 200, 8, 120);
    return;
  }

  if (ui.tool === 'grab') {
    const p = world.nearestPoint(m.x, m.y, CFG.GRAB_RADIUS);
    if (p && !p.pinned) {
      p.grabbed = true;
      world.grabbed = p;
      world.grabX = m.x; world.grabY = m.y;
    }
  } else {
    action = { type: 'place', sx: m.x, sy: m.y, ex: m.x, ey: m.y };
  }
});

cv.addEventListener('pointermove', e => {
  const m = toLocal(e);
  mouse.x = m.x; mouse.y = m.y; mouse.over = true;
  if (!action || e.pointerId !== activePointer) return;
  if (action.type === 'slice') {
    world.slice(action.lx, action.ly, m.x, m.y);
    pushSlice(m.x, m.y);
    action.lx = m.x; action.ly = m.y;
  } else if (action.type === 'place') {
    action.ex = m.x; action.ey = m.y;
  }
});

function endPointer(e) {
  if (e.pointerId !== activePointer) return;
  activePointer = null;
  if (action && action.type === 'place') commitSpawn(action);
  action = null;
  if (world.grabbed) { world.grabbed.grabbed = false; world.grabbed = null; }  // throw with drag velocity
}
cv.addEventListener('pointerup', endPointer);
cv.addEventListener('pointercancel', endPointer);
cv.addEventListener('pointerleave', () => { mouse.over = false; });

function commitSpawn(a) {
  const dx = a.ex - a.sx, dy = a.ey - a.sy;
  let made = null, hue = 0;
  switch (ui.tool) {
    case 'rope':
      made = world.spawnRope(a.sx, a.sy, a.ex, a.ey); hue = 40; break;
    case 'cloth': {
      const x0 = Math.min(a.sx, a.ex), y0 = Math.min(a.sy, a.ey);
      const w = Math.max(60, Math.abs(dx)), h = Math.max(60, Math.abs(dy));
      made = world.spawnCloth(x0, y0, w, h, ui.clothPins); hue = 180; break;
    }
    case 'blob': {
      const R = Math.hypot(dx, dy);
      made = world.spawnBlob(a.sx, a.sy, R < 12 ? 42 : R); hue = 320; break;
    }
    case 'jelly': {
      const x0 = Math.min(a.sx, a.ex), y0 = Math.min(a.sy, a.ey);
      const w = Math.max(50, Math.abs(dx)), h = Math.max(50, Math.abs(dy));
      made = world.spawnJelly(x0, y0, w, h); hue = 130; break;
    }
    case 'ball': {
      made = world.spawnBall(a.sx, a.sy, 13); hue = 210;
      if (made) { made.p.px = made.p.x - dx * 0.06; made.p.py = made.p.y - dy * 0.06; } // fling
      break;
    }
  }
  if (made) burst(a.sx, a.sy, hue, 10, 140);
}

/* ---------- keyboard ---------- */
const TOOL_ORDER = ['grab', 'rope', 'cloth', 'blob', 'jelly', 'ball'];
function setTool(t) {
  ui.tool = t;
  document.querySelectorAll('.tbtn').forEach(b => b.classList.toggle('active', b.dataset.tool === t));
  cv.style.cursor = t === 'grab' ? 'crosshair' : 'cell';
}
window.addEventListener('keydown', e => {
  if (e.target && (e.target.tagName === 'INPUT' && e.target.type !== 'range')) return;
  switch (e.code) {
    case 'Space': e.preventDefault(); togglePause(); return;
    case 'ArrowRight': case 'Period':
      if (ui.paused) world.step();
      e.preventDefault(); return;
  }
  const k = e.key.toLowerCase();
  if (k >= '1' && k <= '6') setTool(TOOL_ORDER[+k - 1]);
  else if (k === 'r') world.reset();
  else if (k === 'c') world.clearDynamic();
  else if (k === 'escape') setTool('grab');
  else if (k === 'v') { cStress.click(); }
  else if (k === 'g') { cGrav.click(); }
});

/* ================================================================
   10. RENDERING
   ================================================================ */
function drawBackground() {
  ctx.fillStyle = '#0a0d13';
  ctx.fillRect(0, 0, world.w, world.h);
  // faint grid
  ctx.strokeStyle = 'rgba(255,255,255,0.028)';
  ctx.lineWidth = 1;
  ctx.beginPath();
  for (let x = 0.5; x < world.w; x += 64) { ctx.moveTo(x, 0); ctx.lineTo(x, world.h); }
  for (let y = 0.5; y < world.h; y += 64) { ctx.moveTo(0, y); ctx.lineTo(world.w, y); }
  ctx.stroke();
  // floor / wall accents
  const fg = ctx.createLinearGradient(0, world.h - 60, 0, world.h);
  fg.addColorStop(0, 'rgba(63,208,255,0)');
  fg.addColorStop(1, 'rgba(63,208,255,0.06)');
  ctx.fillStyle = fg;
  ctx.fillRect(0, world.h - 60, world.w, 60);
  ctx.fillStyle = 'rgba(120,210,255,0.20)';
  ctx.fillRect(0, world.h - 1.5, world.w, 1.5);
  ctx.fillStyle = 'rgba(120,210,255,0.07)';
  ctx.fillRect(0, 0, 1.5, world.h);
  ctx.fillRect(world.w - 1.5, 0, 1.5, world.h);
}

function drawStatics() {
  for (const s of world.statics) {
    if (s.type === 'circle') {
      const g = ctx.createRadialGradient(s.x - s.r * 0.35, s.y - s.r * 0.4, s.r * 0.1, s.x, s.y, s.r);
      g.addColorStop(0, '#212a3d'); g.addColorStop(1, '#141a28');
      ctx.fillStyle = g;
      ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill();
      ctx.strokeStyle = 'rgba(140,175,225,0.22)'; ctx.lineWidth = 1.5; ctx.stroke();
      ctx.strokeStyle = 'rgba(140,175,225,0.05)'; ctx.lineWidth = 7; ctx.stroke();
    } else {
      ctx.beginPath();
      ctx.roundRect(s.x - s.hw, s.y - s.hh, s.hw * 2, s.hh * 2, 9);
      const g = ctx.createLinearGradient(0, s.y - s.hh, 0, s.y + s.hh);
      g.addColorStop(0, '#222b3f'); g.addColorStop(1, '#131926');
      ctx.fillStyle = g; ctx.fill();
      ctx.strokeStyle = 'rgba(140,175,225,0.22)'; ctx.lineWidth = 1.5; ctx.stroke();
    }
  }
}

function drawCloth(o) {
  const { pts, cols, rows, hue, hcs, vcs, restArea } = o;
  const cp = cols + 1;
  for (let j = 0; j < rows; j++) {
    for (let i = 0; i < cols; i++) {
      if (!(hcs[j * cols + i].active && hcs[(j + 1) * cols + i].active &&
            vcs[j * cp + i].active && vcs[j * cp + i + 1].active)) continue;
      const a = pts[j * cp + i], b = pts[j * cp + i + 1],
            c = pts[(j + 1) * cp + i], d = pts[(j + 1) * cp + i + 1];
      const ux = b.x - a.x, uy = b.y - a.y, vx = c.x - a.x, vy = c.y - a.y;
      const s = (ux * vy - uy * vx) / restArea;   // signed area ratio → fake lighting
      let L;
      if (s <= 0.04) L = 9;                        // backside of a fold
      else L = 13 + 36 * Math.min(1.05, s);
      ctx.fillStyle = 'hsl(' + hue + ',60%,' + L + '%)';
      ctx.beginPath();
      ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(d.x, d.y); ctx.lineTo(c.x, c.y);
      ctx.closePath(); ctx.fill();
    }
  }
}

function drawRope(o) {
  ctx.lineCap = 'round';
  ctx.beginPath();
  for (const c of o.cons) if (c.active) { ctx.moveTo(c.a.x, c.a.y); ctx.lineTo(c.b.x, c.b.y); }
  ctx.strokeStyle = 'rgba(0,0,0,0.55)'; ctx.lineWidth = 4.6; ctx.stroke();
  if (ui.stress) {
    ctx.lineWidth = 2.3;
    for (const c of o.cons) {
      if (!c.active) continue;
      const dx = c.b.x - c.a.x, dy = c.b.y - c.a.y;
      const strain = (Math.sqrt(dx * dx + dy * dy) - c.rest) / c.rest;
      ctx.strokeStyle = strainColor(strain);
      ctx.beginPath(); ctx.moveTo(c.a.x, c.a.y); ctx.lineTo(c.b.x, c.b.y); ctx.stroke();
    }
  } else {
    ctx.strokeStyle = 'hsl(' + o.hue + ',85%,62%)'; ctx.lineWidth = 2.3;
    ctx.beginPath();
    for (const c of o.cons) if (c.active) { ctx.moveTo(c.a.x, c.a.y); ctx.lineTo(c.b.x, c.b.y); }
    ctx.stroke();
  }
}

function smoothPath(pts, closed) {
  const n = pts.length;
  ctx.beginPath();
  let mx = (pts[0].x + pts[n - 1].x) / 2, my = (pts[0].y + pts[n - 1].y) / 2;
  ctx.moveTo(mx, my);
  for (let i = 0; i < n; i++) {
    const p = pts[i], q = pts[(i + 1) % n];
    ctx.quadraticCurveTo(p.x, p.y, (p.x + q.x) / 2, (p.y + q.y) / 2);
  }
  if (closed) ctx.closePath();
}

function drawBlob(o) {
  const R = o.ring;
  let cx = 0, cy = 0;
  for (const p of R) { cx += p.x; cy += p.y; }
  cx /= R.length; cy /= R.length;
  smoothPath(R, true);
  const g = ctx.createRadialGradient(cx, cy, 2, cx, cy, Math.max(...R.map(p => Math.hypot(p.x - cx, p.y - cy))));
  g.addColorStop(0, 'hsl(' + o.hue + ',78%,62%)');
  g.addColorStop(1, 'hsl(' + o.hue + ',70%,38%)');
  ctx.fillStyle = g; ctx.fill();
  ctx.strokeStyle = 'hsla(' + o.hue + ',88%,72%,0.85)'; ctx.lineWidth = 2; ctx.stroke();
  if (ui.stress) {
    ctx.lineWidth = 1.4;
    for (const c of o.cons) {
      if (!c.active) continue;
      const dx = c.b.x - c.a.x, dy = c.b.y - c.a.y;
      ctx.strokeStyle = strainColor((Math.sqrt(dx * dx + dy * dy) - c.rest) / c.rest);
      ctx.beginPath(); ctx.moveTo(c.a.x, c.a.y); ctx.lineTo(c.b.x, c.b.y); ctx.stroke();
    }
  }
}

function drawJelly(o) {
  smoothPath(o.perim, true);
  ctx.fillStyle = 'hsla(' + o.hue + ',62%,46%,0.82)'; ctx.fill();
  ctx.strokeStyle = 'hsla(' + o.hue + ',85%,68%,0.9)'; ctx.lineWidth = 2; ctx.stroke();
  if (ui.stress) {
    ctx.lineWidth = 1.2;
    for (const c of o.cons) {
      if (!c.active) continue;
      const dx = c.b.x - c.a.x, dy = c.b.y - c.a.y;
      ctx.strokeStyle = strainColor((Math.sqrt(dx * dx + dy * dy) - c.rest) / c.rest);
      ctx.beginPath(); ctx.moveTo(c.a.x, c.a.y); ctx.lineTo(c.b.x, c.b.y); ctx.stroke();
    }
  }
}

function drawBall(o) {
  const p = o.p, r = o.r;
  const g = ctx.createRadialGradient(p.x - r * 0.35, p.y - r * 0.4, r * 0.15, p.x, p.y, r);
  g.addColorStop(0, 'hsl(' + o.hue + ',85%,70%)');
  g.addColorStop(0.65, 'hsl(' + o.hue + ',75%,48%)');
  g.addColorStop(1, 'hsl(' + o.hue + ',70%,30%)');
  ctx.fillStyle = g;
  ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); ctx.fill();
  ctx.strokeStyle = 'rgba(255,255,255,0.28)'; ctx.lineWidth = 1.2; ctx.stroke();
}

function drawPointsAndPins() {
  if (ui.showPoints) {
    ctx.fillStyle = 'rgba(210,228,255,0.85)';
    for (const p of world.points) {
      if (p.nodot) continue;
      ctx.beginPath(); ctx.arc(p.x, p.y, Math.max(1.4, p.r * 0.6), 0, Math.PI * 2); ctx.fill();
    }
  }
  // pins — always visible
  for (const p of world.points) {
    if (!p.pinned) continue;
    ctx.fillStyle = '#ffb454';
    ctx.beginPath(); ctx.arc(p.x, p.y, 3.6, 0, Math.PI * 2); ctx.fill();
    ctx.strokeStyle = 'rgba(255,180,84,0.35)'; ctx.lineWidth = 1.5;
    ctx.beginPath(); ctx.arc(p.x, p.y, 7, 0, Math.PI * 2); ctx.stroke();
  }
  // grabbed point highlight
  if (world.grabbed) {
    const p = world.grabbed;
    ctx.strokeStyle = 'rgba(90,225,255,0.9)'; ctx.lineWidth = 2;
    ctx.beginPath(); ctx.arc(p.x, p.y, 9 + Math.sin(performance.now() * 0.01) * 1.5, 0, Math.PI * 2); ctx.stroke();
  }
}

function drawParticles(dt) {
  for (let i = particles.length - 1; i >= 0; i--) {
    const q = particles[i];
    q.t += dt;
    if (q.t > q.life) { particles.splice(i, 1); continue; }
    q.vy += 900 * dt;
    q.x += q.vx * dt; q.y += q.vy * dt;
    const a = 1 - q.t / q.life;
    ctx.fillStyle = 'hsla(' + q.hue + ',95%,65%,' + a.toFixed(3) + ')';
    ctx.fillRect(q.x - 1.5, q.y - 1.5, 3, 3);
  }
}

function drawSliceTrail() {
  const now = performance.now();
  while (sliceTrail.length && now - sliceTrail[0].t > 180) sliceTrail.shift();
  if (sliceTrail.length < 2) return;
  ctx.lineCap = 'round';
  for (let i = 1; i < sliceTrail.length; i++) {
    const a = sliceTrail[i - 1], b = sliceTrail[i];
    const age = (now - b.t) / 180;
    ctx.strokeStyle = 'rgba(255,140,100,' + (0.7 * (1 - age)).toFixed(3) + ')';
    ctx.lineWidth = 2.5 * (1 - age) + 0.5;
    ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
  }
}

function drawCursorFx() {
  if (!mouse.over) return;
  if (ui.tool === 'grab' && !world.grabbed && !action) {
    const p = world.nearestPoint(mouse.x, mouse.y, CFG.GRAB_RADIUS);
    if (p && !p.pinned) {
      ctx.strokeStyle = 'rgba(120,220,255,0.45)'; ctx.lineWidth = 1.5;
      ctx.beginPath(); ctx.arc(p.x, p.y, 8, 0, Math.PI * 2); ctx.stroke();
    }
  }
}

function drawGhost() {
  if (!action || action.type !== 'place') return;
  const { sx, sy, ex, ey } = action;
  ctx.setLineDash([6, 6]);
  ctx.strokeStyle = 'rgba(255,255,255,0.4)';
  ctx.lineWidth = 1.4;
  switch (ui.tool) {
    case 'rope':
      ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(ex, ey); ctx.stroke();
      ctx.fillStyle = 'rgba(255,180,84,0.9)';
      ctx.beginPath(); ctx.arc(sx, sy, 3.5, 0, Math.PI * 2); ctx.fill();
      break;
    case 'cloth': case 'jelly': {
      const x0 = Math.min(sx, ex), y0 = Math.min(sy, ey);
      ctx.strokeRect(x0, y0, Math.abs(ex - sx), Math.abs(ey - sy));
      break;
    }
    case 'blob':
      ctx.beginPath(); ctx.arc(sx, sy, Math.max(12, Math.hypot(ex - sx, ey - sy)), 0, Math.PI * 2); ctx.stroke();
      break;
    case 'ball':
      ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(ex, ey); ctx.stroke();
      ctx.beginPath(); ctx.arc(sx, sy, 13, 0, Math.PI * 2); ctx.stroke();
      break;
  }
  ctx.setLineDash([]);
}

function render(dtSec) {
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  if (ui.trails && !ui.paused) {
    ctx.fillStyle = 'rgba(10,13,19,' + CFG.TRAIL_FADE + ')';
    ctx.fillRect(0, 0, world.w, world.h);
  } else {
    drawBackground();
  }
  drawStatics();
  for (const o of world.objects) if (o.kind === 'cloth') drawCloth(o);
  for (const o of world.objects) if (o.kind === 'blob') drawBlob(o);
  for (const o of world.objects) if (o.kind === 'jelly') drawJelly(o);
  for (const o of world.objects) if (o.kind === 'rope') drawRope(o);
  for (const o of world.objects) if (o.kind === 'ball') drawBall(o);
  drawPointsAndPins();
  drawParticles(dtSec);
  drawSliceTrail();
  drawCursorFx();
  drawGhost();
}

/* ================================================================
   11. MAIN LOOP — fixed-timestep accumulator
   ================================================================ */
let last = performance.now();
let acc = 0;
let fpsEma = 60, physMsEma = 0;

function frame(now) {
  requestAnimationFrame(frame);
  let dms = now - last; last = now;
  if (dms > CFG.MAX_FRAME_MS) dms = CFG.MAX_FRAME_MS;      // tab-switch clamp — never detonate
  if (dms > 0) fpsEma += (1000 / dms - fpsEma) * 0.08;

  if (!ui.paused) {
    acc += dms / 1000;
    let n = 0;
    const t0 = performance.now();
    while (acc >= CFG.DT && n < CFG.MAX_STEPS) { world.step(CFG.DT); acc -= CFG.DT; n++; }
    if (n === CFG.MAX_STEPS) acc = 0;                      // drop backlog — stay real-time
    if (n > 0) physMsEma += ((performance.now() - t0) - physMsEma) * 0.15;
  }
  render(dms / 1000);
  statsTick(now);
}
requestAnimationFrame(frame);

/* ================================================================
   12. UI WIRING
   ================================================================ */
const $ = id => document.getElementById(id);
const cGrav = $('cGrav'), cWind = $('cWind'), cStress = $('cStress'),
      cPts = $('cPts'), cColl = $('cColl'), cTrail = $('cTrail');

document.querySelectorAll('.tbtn').forEach(b => b.addEventListener('click', () => setTool(b.dataset.tool)));

$('pinChip').addEventListener('click', () => {
  ui.clothPins = ui.clothPins === 'edge' ? 'corners' : 'edge';
  $('pinChip').textContent = 'cloth pins: ' + (ui.clothPins === 'edge' ? 'full edge' : 'two corners');
});

$('grav').addEventListener('input', e => { world.gravity = +e.target.value; $('gravVal').textContent = e.target.value; });
$('iter').addEventListener('input', e => { world.iterations = +e.target.value; $('iterVal').textContent = e.target.value; });
$('wind').addEventListener('input', e => { world.wind = +e.target.value; $('windVal').textContent = e.target.value; });

cGrav.addEventListener('change', () => { world.gravityOn = cGrav.checked; });
cWind.addEventListener('change', () => { world.windOn = cWind.checked; });
cStress.addEventListener('change', () => { ui.stress = cStress.checked; });
cPts.addEventListener('change', () => { ui.showPoints = cPts.checked; });
cColl.addEventListener('change', () => { world.collide = cColl.checked; });
cTrail.addEventListener('change', () => { ui.trails = cTrail.checked; });

function togglePause() {
  ui.paused = !ui.paused;
  $('bPause').textContent = ui.paused ? '▶ resume' : '⏸ pause';
  if (!ui.paused) { last = performance.now(); acc = 0; }
}
$('bPause').addEventListener('click', togglePause);
$('bStep').addEventListener('click', () => { if (!ui.paused) togglePause(); world.step(); });
$('bReset').addEventListener('click', () => world.reset());
$('bClear').addEventListener('click', () => world.clearDynamic());
$('fold').addEventListener('click', () => {
  const p = $('panel');
  p.classList.toggle('min');
  $('fold').textContent = p.classList.contains('min') ? '+' : '–';
});

let lastStats = 0;
function statsTick(now) {
  if (now - lastStats < 250) return;
  lastStats = now;
  $('stats').textContent =
    fpsEma.toFixed(0) + ' fps · phys ' + physMsEma.toFixed(2) + ' ms\n' +
    world.points.length + ' pts · ' + world.constraints.length + ' links · ' + world.objects.length + ' bodies' +
    (ui.paused ? ' · PAUSED (→ steps)' : '');
}

/* ================================================================
   13. PUBLIC API + HEADLESS SELF-CHECK
   ================================================================ */
window.sim = world;                 // sim.points, sim.constraints, sim.step(), …
window.CFG = CFG;
world.selfTest = selfTest;
world.spawn = {
  rope: (x0, y0, x1, y1) => world.spawnRope(x0, y0, x1, y1),
  cloth: (x, y, w, h, pins) => world.spawnCloth(x, y, w, h, pins || 'edge'),
  blob: (x, y, r) => world.spawnBlob(x, y, r),
  jelly: (x, y, w, h) => world.spawnJelly(x, y, w, h),
  ball: (x, y, r) => world.spawnBall(x, y, r),
};

console.log('%csoft-body playground ready — probe window.sim (sim.points, sim.step(), sim.selfTest())',
  'color:#4dd7ff;font-weight:bold');
if (location.hash.includes('test')) {
  setTimeout(() => {
    const r = selfTest();
    console.log('[selfTest]', JSON.stringify(r, null, 2));
  }, 60);
}
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen/qwen3.8-max","provider":"openrouter","persona":"middle","sessionId":"96f5adad-ed94-4a28-820d-ad0474703c49","tokensIn":701089,"tokensOut":57392,"tokensTotal":758481,"cost":0.6677459999999998,"turns":13,"toolCalls":18,"failedToolCalls":0,"timestamp":"2026-08-03T21:33:51.596Z"} -->