← Physics Playground results

Physics Playground

Claude haiku 5.5 · full

Initial view of Physics Playground

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Task text captured before this run. Effective runtime system instructions were not captured.

Generation prompt · 645374a2d6
# Task: Build a 2D Soft-Body Physics Playground

Build an interactive 2D physics playground (vanilla JS, no external assets, no CDN, no libraries) based on **Verlet integration with constraint relaxation** — ropes, cloth, and soft bodies the user can grab, tear, and spawn. The top priority is **numerical stability** — a physics toy that explodes, jitters, or tunnels is a failed physics toy.

## Core Specification

**Engine (write it yourself — position-based dynamics, not a rigid-body library clone)**
- Verlet integration: `x' = x + (x - xPrev) * damping + a * dt²` with a **fixed timestep** accumulator (e.g. 60 Hz physics) decoupled from render rate
- Constraint solving via relaxation: distance constraints iterated N times per step (N configurable, ~3–8)
- Collision: points vs. static shapes (bounding walls/floor, a few static circles/boxes) with friction; simple point-vs-point collision optional
- Gravity toggle and strength slider; optional wind
- No `NaN` escapes: guard division-by-zero in constraint resolution (zero-length links, coincident points)

**Spawnable Objects (toolbar + click/drag to place)**
- **Rope:** click-drag to draw a hanging chain of N points pinned at the start
- **Cloth:** draggable rectangle of points with structural + shear constraints, pinned along the top edge (pin pattern selectable: both corners / full edge)
- **Soft body:** circle/blob or box of points with edge + internal brace constraints that holds shape but deforms on impact
- **Ball:** single point with radius that bounces
- Spawn several objects; they coexist and interact with static geometry

**Interaction**
- **Grab:** click near any point to grab it (nearest point within a radius); drag to move it, it follows the cursor with the constraint solver reacting live; release to throw with the drag velocity
- **Tear/cut:** right-drag (or hold a modifier) to slice through constraints — cloth rips along the cut line, ropes split
- **Pin/unpin:** click a point with a modifier to toggle a pin in place
- Pause/step controls (Space to pause, arrow to step one physics frame while paused), reset button, gravity and iterations sliders

**Rendering**
- Canvas 2D: constraints as lines, points as small dots (hidden by default for cloth — render cloth as a filled mesh or grid lines), soft bodies as filled polygons
- Optional stress visualization: color constraints by stretch ratio (green → red)
- FPS counter and physics-step time readout

## Visual Style
- Dark playground with vibrant materials (each object type a distinct hue), subtle floor/wall rendering
- Satisfying feel: slight motion blur trail optional, cursor grab highlight, tear effect
- Toolbar minimal and out of the way — the canvas is the app

## Technical Requirements
- Vanilla JS, Canvas 2D; all code organized with clear section comments
- Fixed-timestep accumulator with a maximum frame delta clamp (tab-switch must not explode the sim)
- Tunable constants block at the top (gravity, damping, iterations, timestep, tear radius, colors)
- Target: stable 60 FPS with ~1000 constraint points

## Quality Bar (must hit all)
- A pendulum rope swings smoothly and settles; cloth hangs from pins and drapes over a static circle without exploding or self-intersecting into chaos
- Leaving the tab for 30 seconds and returning does not detonate the simulation
- Grabbing and whipping a cloth corner produces a believable wave; releasing throws it
- Tearing a rope in the middle leaves two independently swinging halves
- No visible jitter on resting stacks; energy visibly decays (nothing oscillates forever)

## Self-Check Before Finishing
Verify with the browser tools (expose the sim on `window` — e.g. `sim.points`, `sim.step()` — so it can be probed headlessly):
- Drop a soft body from height with gravity on: after settling (~3s of stepped time), its total kinetic energy is near zero and no point is below the floor
- Step the sim 1000 frames and assert no coordinate is `NaN` or beyond a sane bound
- Pin two cloth corners, step 600 frames, assert the cloth's lowest point stays above the floor and the top pins haven't moved
- Tear a rope constraint via the exposed model, step, and assert the halves move independently
Inspect original source 30,529 bytes · SHA-256 8a2b45175330
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Soft-Body Playground</title>
<style>
html,body{margin:0;height:100%;background:#0a0c11;overflow:hidden;font:12px/1.35 system-ui,-apple-system,sans-serif;color:#cfd6e4}
canvas{position:fixed;inset:0;width:100%;height:100%;display:block;cursor:crosshair;touch-action:none}
#bar{position:fixed;left:10px;top:10px;display:flex;flex-direction:column;gap:6px;background:rgba(16,19,27,.85);border:1px solid #242a38;border-radius:8px;padding:8px;width:188px;user-select:none;backdrop-filter:blur(4px)}
#bar h1{font-size:11px;margin:0 0 2px;letter-spacing:.08em;text-transform:uppercase;color:#8d97ab}
.row{display:flex;gap:4px;flex-wrap:wrap;align-items:center}
button,select{background:#1b2130;color:#cfd6e4;border:1px solid #2c3446;border-radius:5px;padding:4px 7px;font:inherit;cursor:pointer}
button:hover,select:hover{border-color:#4a5577}
button.on{border-color:#7aa2ff;background:#243355;color:#fff}
label{display:flex;justify-content:space-between;width:100%;gap:6px;align-items:center}
input[type=range]{width:95px}
.hint{color:#56607a;font-size:10.5px;line-height:1.4}
.sw{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:4px}
#stats{position:fixed;right:10px;top:10px;font:11px/1.45 ui-monospace,monospace;color:#7d879b;background:rgba(0,0,0,.35);padding:5px 8px;border-radius:5px;pointer-events:none;text-align:right}
#help{position:fixed;left:50%;bottom:8px;transform:translateX(-50%);color:#4d566e;font-size:11px;pointer-events:none;white-space:nowrap}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="bar">
  <h1>Soft-Body Playground</h1>
  <div class="row" id="tools">
    <button data-tool="grab" class="on">Grab</button>
    <button data-tool="rope"><span class="sw" style="background:#ffb347"></span>Rope</button>
    <button data-tool="cloth"><span class="sw" style="background:#4fd1d9"></span>Cloth</button>
    <button data-tool="soft"><span class="sw" style="background:#b78cff"></span>Soft</button>
    <button data-tool="ball"><span class="sw" style="background:#ff5f7e"></span>Ball</button>
  </div>
  <label>Soft shape
    <select id="shape"><option value="circle">circle</option><option value="box">box</option></select></label>
  <label>Cloth pins
    <select id="pins"><option value="corners">both corners</option><option value="edge">full edge</option></select></label>
  <div class="row">
    <button id="pause">Pause</button><button id="step">Step</button><button id="reset">Reset</button>
  </div>
  <label>Gravity <input type="checkbox" id="grav" checked></label>
  <label>Strength <input type="range" id="gstr" min="0" max="2000" value="900"></label>
  <label>Iterations <input type="range" id="iters" min="2" max="10" value="6"></label>
  <label>Wind <input type="range" id="wind" min="0" max="1500" value="0"></label>
  <label>Stress view <input type="checkbox" id="stress"></label>
  <label>Motion trail <input type="checkbox" id="trail" checked></label>
  <div class="hint">Left-drag: grab / throw<br>Shift+click: pin / unpin<br>Right-drag (or Alt+drag): tear<br>Spawn tools: click (or drag for rope/ball)</div>
</div>
<div id="stats"></div>
<div id="help">Space pause · ←/→/↑/↓ step one frame while paused</div>

<script>
/* =====================================================================
   1. TUNABLE CONSTANTS
   ===================================================================== */
const CFG = {
  dt: 1 / 60,            // fixed physics timestep (60 Hz)
  maxFrameDt: 0.05,      // clamp render delta (tab switch must not explode)
  maxStepsPerFrame: 8,   // spiral-of-death guard
  gravity: 900,          // px/s^2
  damping: 0.995,        // Verlet velocity retention per step
  iterations: 6,         // default constraint relaxation passes (3-8 typical)
  maxStep: 25,           // max displacement per step (px) — stability clamp
  friction: 0.25,        // tangential velocity loss on contact
  restitution: 0.3,      // default bounce (per-point override possible)
  tearRadius: 6,         // cut reach around cut-line midpoint (px)
  grabRadius: 28,        // pick radius (px)
  grabStiffness: 0.5,    // how strongly grabbed point follows cursor per step
  throwScale: 1.0,       // release velocity multiplier
  ropeSeg: 10,           // rope link length
  clothCols: 20, clothRows: 14, clothSpacing: 12,
  softRadius: 46,
  softBoxSize: 110,
  ballMinR: 12, ballMaxR: 24,
  colors: {
    rope: [255, 179, 71],
    cloth: [79, 209, 217],
    soft: [183, 140, 255],
    ball: [255, 95, 126],
    shear: [79, 209, 217],
    static: '#2b3345',
    staticEdge: '#55627f',
    floor: '#3a4560',
    grid: 'rgba(120,140,190,0.045)',
    tear: '#ff5252'
  }
};

/* =====================================================================
   2. ENGINE — Verlet points + distance constraints + PBD relaxation
   ===================================================================== */
const TAU = Math.PI * 2;

// Segment-segment intersection (orientation test)
function segX(ax, ay, bx, by, cx, cy, dx, dy) {
  const o1 = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
  const o2 = (bx - ax) * (dy - ay) - (by - ay) * (dx - ax);
  if ((o1 > 0) === (o2 > 0) && o1 !== 0 && o2 !== 0) return false;
  const o3 = (dx - cx) * (ay - cy) - (dy - cy) * (ax - cx);
  const o4 = (dx - cx) * (by - cy) - (dy - cy) * (bx - cx);
  if ((o3 > 0) === (o4 > 0) && o3 !== 0 && o4 !== 0) return false;
  return true;
}
// Distance from point to segment
function distPtSeg(px, py, ax, ay, bx, by) {
  const vx = bx - ax, vy = by - ay;
  const L = vx * vx + vy * vy;
  let t = L > 1e-9 ? ((px - ax) * vx + (py - ay) * vy) / L : 0;
  t = Math.max(0, Math.min(1, t));
  return Math.hypot(px - (ax + vx * t), py - (ay + vy * t));
}

const sim = {
  W: innerWidth, H: innerHeight,
  points: [], constraints: [], bodies: [], statics: [], sparks: [], balls: [],
  gravOn: true, gravity: CFG.gravity, wind: 0,
  iterations: CFG.iterations,
  time: 0, frame: 0, stepMs: 0,
  grabbed: null, grabTarget: { x: 0, y: 0 },
  dirty: false,

  /* ---------- construction helpers ---------- */
  addPoint(x, y, rad = 0, bounce = CFG.restitution) {
    const p = { x, y, px: x, py: y, inv: 1, pinned: false, grab: false, rad, bounce, body: null };
    this.points.push(p);
    return p;
  },
  addCons(a, b, kind, body) {
    const rest = Math.max(Math.hypot(b.x - a.x, b.y - a.y), 1e-3);
    const c = { a, b, rest, kind, alive: true, body };
    this.constraints.push(c);
    return c;
  },
  pin(p, on) {
    p.pinned = on;
    if (!p.grab) p.inv = on ? 0 : 1;
  },

  /* ---------- fixed-step physics ---------- */
  step() {
    const t0 = performance.now();
    const dt = CFG.dt, dt2 = dt * dt;
    this.time += dt;
    const ay = this.gravOn ? this.gravity : 0;
    const ax = this.wind * (0.6 + 0.4 * Math.sin(this.time * 1.7));
    const pts = this.points;

    // 1) Integrate (Verlet): x' = x + (x - xPrev)*damping + a*dt²
    for (const p of pts) {
      if (p.grab) {                       // kinematic follow of cursor
        p.px = p.x; p.py = p.y;
        const t = this.grabTarget;
        p.x += (t.x - p.x) * CFG.grabStiffness;
        p.y += (t.y - p.y) * CFG.grabStiffness;
        continue;
      }
      if (p.inv === 0) { p.px = p.x; p.py = p.y; continue; } // pinned: no velocity
      let vx = (p.x - p.px) * CFG.damping;
      let vy = (p.y - p.py) * CFG.damping;
      if (!Number.isFinite(vx)) vx = 0;
      if (!Number.isFinite(vy)) vy = 0;
      p.px = p.x; p.py = p.y;
      vx += ax * dt2; vy += ay * dt2;
      const sp = Math.hypot(vx, vy);
      if (sp > CFG.maxStep) { const s = CFG.maxStep / sp; vx *= s; vy *= s; }
      p.x += vx; p.y += vy;
    }

    // 2) Constraint relaxation interleaved with collisions
    for (let k = 0; k < this.iterations; k++) {
      this.solveConstraints();
      for (const p of pts) this.collide(p);
    }

    // 3) Sanitize (no NaN escapes)
    for (const p of pts) {
      if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) {
        p.x = p.px = Number.isFinite(p.px) ? p.px : 0;
        p.y = p.py = Number.isFinite(p.py) ? p.py : 0;
      }
    }

    if (this.dirty) { this.constraints = this.constraints.filter(c => c.alive); this.dirty = false; }
    this.frame++;
    this.stepMs = performance.now() - t0;
  },

  solveConstraints() {
    for (const c of this.constraints) {
      if (!c.alive) continue;
      const a = c.a, b = c.b;
      const w = a.inv + b.inv;
      if (w === 0) continue;                       // both pinned / held
      let dx = b.x - a.x, dy = b.y - a.y;
      let d = Math.sqrt(dx * dx + dy * dy);
      if (d < 1e-6) {                              // coincident: nudge, avoid /0
        dx = (Math.random() - 0.5) * 1e-3; dy = (Math.random() - 0.5) * 1e-3;
        d = Math.hypot(dx, dy) || 1e-6;
      }
      const k = ((d - c.rest) / d) / w;
      const ux = dx * k, uy = dy * k;
      a.x += ux * a.inv; a.y += uy * a.inv;
      b.x -= ux * b.inv; b.y -= uy * b.inv;
    }
  },

  /* ---------- collision: walls, floor, ceiling, static shapes ---------- */
  collide(p) {
    if (p.pinned && !p.grab) return;
    const r = p.rad, W = this.W, H = this.H;
    if (p.x < r)     { p.x = r;     this.contact(p, 1, 0); }
    if (p.x > W - r) { p.x = W - r; this.contact(p, -1, 0); }
    if (p.y < r)     { p.y = r;     this.contact(p, 0, 1); }
    if (p.y > H - r) { p.y = H - r; this.contact(p, 0, -1); }

    for (const s of this.statics) {
      if (s.type === 'circle') {
        const dx = p.x - s.x, dy = p.y - s.y;
        const d = Math.sqrt(dx * dx + dy * dy);
        const R = s.r + r;
        if (d < R) {
          let nx = 0, ny = -1;
          if (d > 1e-9) { nx = dx / d; ny = dy / d; }
          p.x = s.x + nx * R; p.y = s.y + ny * R;
          this.contact(p, nx, ny);
        }
      } else { // axis-aligned box (center s.x,s.y; half sizes hw,hh)
        const x0 = s.x - s.hw - r, x1 = s.x + s.hw + r;
        const y0 = s.y - s.hh - r, y1 = s.y + s.hh + r;
        if (p.x > x0 && p.x < x1 && p.y > y0 && p.y < y1) {
          const m = Math.min(p.x - x0, x1 - p.x, p.y - y0, y1 - p.y);
          if (m === p.x - x0)      { p.x = x0; this.contact(p, -1, 0); }
          else if (m === x1 - p.x) { p.x = x1; this.contact(p, 1, 0); }
          else if (m === p.y - y0) { p.y = y0; this.contact(p, 0, -1); }
          else                     { p.y = y1; this.contact(p, 0, 1); }
        }
      }
    }
  },

  // Position already projected onto surface with outward normal (nx,ny).
  // Reflect normal velocity (with restitution) and damp tangential (friction).
  contact(p, nx, ny) {
    const vx = p.x - p.px, vy = p.y - p.py;
    const vn = vx * nx + vy * ny;
    const vtx = vx - vn * nx, vty = vy - vn * ny;
    const nvn = vn < 0 ? -vn * p.bounce : vn;
    const f = 1 - CFG.friction;
    const nvx = vtx * f + nvn * nx, nvy = vty * f + nvn * ny;
    p.px = p.x - nvx; p.py = p.y - nvy;
  },

  /* ---------- tearing ---------- */
  cut(x0, y0, x1, y1) {
    for (const c of this.constraints) {
      if (!c.alive) continue;
      const ax = c.a.x, ay = c.a.y, bx = c.b.x, by = c.b.y;
      const mx = (ax + bx) / 2, my = (ay + by) / 2;
      if (segX(x0, y0, x1, y1, ax, ay, bx, by) || distPtSeg(mx, my, x0, y0, x1, y1) < CFG.tearRadius) {
        c.alive = false;
        this.dirty = true;
        for (let i = 0; i < 3; i++) this.sparks.push({
          x: mx, y: my, vx: (Math.random() - 0.5) * 120, vy: (Math.random() - 0.5) * 120 - 40, life: 1
        });
      }
    }
  },

  /* ---------- diagnostics ---------- */
  kinetic() {
    let s = 0;
    for (const p of this.points) {
      if (p.pinned) continue;
      const vx = p.x - p.px, vy = p.y - p.py;
      s += vx * vx + vy * vy;
    }
    return s;   // proportional to KE (unit mass, px²/step²)
  },
  aliveLinks() { let n = 0; for (const c of this.constraints) if (c.alive) n++; return n; },

  /* ---------- reset ---------- */
  reset() {
    this.points = []; this.constraints = []; this.bodies = [];
    this.statics = []; this.sparks = []; this.balls = []; this.grabbed = null;
    this.time = 0; this.dirty = false;
    this.buildStatics();
  },
  buildStatics() {
    const W = this.W, H = this.H;
    this.statics.push({ type: 'circle', x: W * 0.5, y: H * 0.62, r: 70 });
    this.statics.push({ type: 'box', x: W * 0.2, y: H * 0.78, hw: 120, hh: 10 });
    this.statics.push({ type: 'box', x: W * 0.84, y: H * 0.5, hw: 80, hh: 10 });
    this.statics.push({ type: 'box', x: W * 0.62, y: H * 0.86, hw: 60, hh: 10 });
  }
};
window.sim = sim;
window.CFG = CFG;

/* =====================================================================
   3. SPAWNERS — build bodies out of points + constraints
   ===================================================================== */
let bodySeq = 0;
function makeBody(type, color) {
  const b = { id: ++bodySeq, type, color, pts: [], cons: [], quads: null, ring: null };
  sim.bodies.push(b);
  return b;
}
function addPt(b, x, y, rad, bounce) {
  const p = sim.addPoint(x, y, rad, bounce);
  p.body = b; b.pts.push(p);
  return p;
}
function link(b, a, c, kind) {
  const k = sim.addCons(a, c, kind, b);
  b.cons.push(k);
  return k;
}

// Rope: chain of N points pinned at the start. Click (no drag) = hanging rope.
function spawnRope(x0, y0, x1, y1) {
  let dx = x1 - x0, dy = y1 - y0, L = Math.hypot(dx, dy);
  let ux, uy;
  if (L < 20) { ux = 0; uy = 1; L = 300; }   // click without drag: hang straight down
  else { ux = dx / L; uy = dy / L; }
  const n = Math.max(4, Math.round(L / CFG.ropeSeg)), seg = L / n;
  const b = makeBody('rope', CFG.colors.rope);
  for (let i = 0; i <= n; i++) addPt(b, x0 + ux * seg * i, y0 + uy * seg * i, 2.5, 0.2);
  for (let i = 0; i < n; i++) link(b, b.pts[i], b.pts[i + 1], 'rope');
  sim.pin(b.pts[0], true);
  return b;
}

// Cloth: grid with structural (h/v) + shear (diagonal) links. Quads drive rendering so tears rip cleanly.
function spawnCloth(cx, cy, pinMode) {
  const cols = CFG.clothCols, rows = CFG.clothRows, s = CFG.clothSpacing;
  const x0 = cx - (cols - 1) * s / 2, y0 = cy;
  const b = makeBody('cloth', CFG.colors.cloth);
  const g = [];
  for (let j = 0; j < rows; j++) {
    g[j] = [];
    for (let i = 0; i < cols; i++) g[j][i] = addPt(b, x0 + i * s, y0 + j * s, 2, 0.1);
  }
  const Hl = [], Vl = [];
  for (let j = 0; j < rows; j++) {
    Hl[j] = []; Vl[j] = [];
    for (let i = 0; i < cols; i++) {
      if (i + 1 < cols) Hl[j][i] = link(b, g[j][i], g[j][i + 1], 'cloth');
      if (j + 1 < rows) Vl[j][i] = link(b, g[j][i], g[j + 1][i], 'cloth');
      if (i + 1 < cols && j + 1 < rows) {
        link(b, g[j][i], g[j + 1][i + 1], 'shear');
        link(b, g[j][i + 1], g[j + 1][i], 'shear');
      }
    }
  }
  b.quads = [];
  for (let j = 0; j + 1 < rows; j++) for (let i = 0; i + 1 < cols; i++) {
    b.quads.push({
      p: [g[j][i], g[j][i + 1], g[j + 1][i + 1], g[j + 1][i]],
      L: [Hl[j][i], Hl[j + 1][i], Vl[j][i], Vl[j][i + 1]]
    });
  }
  if (pinMode === 'edge') for (let i = 0; i < cols; i++) sim.pin(g[0][i], true);
  else { sim.pin(g[0][0], true); sim.pin(g[0][cols - 1], true); }
  return b;
}

// Soft body: circle = outer ring + inner ring + center with braces; box = 4x4 grid with diagonals.
function spawnSoft(cx, cy, shape) {
  const b = makeBody('soft', CFG.colors.soft);
  if (shape === 'box') {
    const S = CFG.softBoxSize, n = 4, sp = S / (n - 1), x0 = cx - S / 2, y0 = cy - S / 2;
    const g = [];
    for (let j = 0; j < n; j++) {
      g[j] = [];
      for (let i = 0; i < n; i++) g[j][i] = addPt(b, x0 + i * sp, y0 + j * sp, 3, 0.2);
    }
    for (let j = 0; j < n; j++) for (let i = 0; i < n; i++) {
      if (i + 1 < n) link(b, g[j][i], g[j][i + 1], 'soft');
      if (j + 1 < n) link(b, g[j][i], g[j + 1][i], 'soft');
      if (i + 1 < n && j + 1 < n) {
        link(b, g[j][i], g[j + 1][i + 1], 'brace');
        link(b, g[j][i + 1], g[j + 1][i], 'brace');
      }
    }
    const ring = [];
    for (let i = 0; i < n; i++) ring.push(g[0][i]);
    for (let j = 1; j < n; j++) ring.push(g[j][n - 1]);
    for (let i = n - 2; i >= 0; i--) ring.push(g[n - 1][i]);
    for (let j = n - 2; j >= 1; j--) ring.push(g[j][0]);
    b.ring = ring;
  } else {
    const R = CFG.softRadius, N = 16, M = 8;
    const outer = [], inner = [];
    for (let i = 0; i < N; i++) {
      const a = i / N * TAU;
      outer.push(addPt(b, cx + Math.cos(a) * R, cy + Math.sin(a) * R, 4, 0.2));
    }
    for (let i = 0; i < M; i++) {
      const a = i / M * TAU;
      inner.push(addPt(b, cx + Math.cos(a) * R * 0.5, cy + Math.sin(a) * R * 0.5, 3, 0.2));
    }
    const center = addPt(b, cx, cy, 3, 0.2);
    for (let i = 0; i < N; i++) {
      link(b, outer[i], outer[(i + 1) % N], 'soft');
      link(b, outer[i], outer[(i + 2) % N], 'brace');
      link(b, outer[i], inner[Math.floor(i / 2) % M], 'brace');
      link(b, outer[i], inner[(Math.floor(i / 2) + 1) % M], 'brace');
    }
    for (let i = 0; i < M; i++) {
      link(b, inner[i], inner[(i + 1) % M], 'soft');
      link(b, inner[i], center, 'brace');
    }
    b.ring = outer;
  }
  return b;
}

// Ball: single radial particle; interacts with other points via collideBalls().
function spawnBall(x, y, vx, vy) {
  const r = CFG.ballMinR + Math.random() * (CFG.ballMaxR - CFG.ballMinR);
  const b = makeBody('ball', CFG.colors.ball);
  const p = addPt(b, x, y, r, 0.6);
  p.px = x - vx * CFG.dt; p.py = y - vy * CFG.dt;   // vx,vy in px/s
  sim.balls.push(p);
  return b;
}

/* =====================================================================
   4. INPUT — grab / throw, tear, pin, spawn
   ===================================================================== */
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let dpr = 1;
const ui = { tool: 'grab', paused: false, stress: false, trail: true, pinMode: 'corners', shape: 'circle' };
const ptr = { x: 0, y: 0, down: false, mode: null, sx: 0, sy: 0, lx: 0, ly: 0, hover: null };

function nearestPoint(x, y, R) {
  let best = null, bd = R * R;
  for (const p of sim.points) {
    const dx = p.x - x, dy = p.y - y, d = dx * dx + dy * dy;
    if (d < bd) { bd = d; best = p; }
  }
  return best;
}

canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('pointerdown', e => {
  if (e.button > 2) return;
  canvas.setPointerCapture(e.pointerId);
  const x = e.clientX, y = e.clientY;
  ptr.down = true; ptr.sx = ptr.lx = ptr.x = x; ptr.sy = ptr.ly = ptr.y = y;
  if (e.button === 2 || (e.altKey && e.button === 0)) { ptr.mode = 'cut'; sim.cut(x, y, x + 0.01, y); return; }
  if (ui.tool !== 'grab') {
    ptr.mode = 'spawn';
    if (ui.tool === 'cloth') spawnCloth(x, y, ui.pinMode);
    else if (ui.tool === 'soft') spawnSoft(x, y, ui.shape);
    return;
  }
  if (e.shiftKey) {
    const p = nearestPoint(x, y, CFG.grabRadius);
    if (p) sim.pin(p, !p.pinned);
    ptr.mode = 'pinned';
    return;
  }
  const p = nearestPoint(x, y, CFG.grabRadius);
  if (p) {
    ptr.mode = 'grab';
    sim.grabbed = p; p.grab = true; p.inv = 0;      // kinematic while held
    sim.grabTarget.x = x; sim.grabTarget.y = y;
  } else ptr.mode = null;
});

canvas.addEventListener('pointermove', e => {
  const x = e.clientX, y = e.clientY;
  ptr.x = x; ptr.y = y;
  if (!ptr.down) { ptr.hover = nearestPoint(x, y, CFG.grabRadius); return; }
  if (ptr.mode === 'grab') { sim.grabTarget.x = x; sim.grabTarget.y = y; }
  else if (ptr.mode === 'cut') sim.cut(ptr.lx, ptr.ly, x, y);
  ptr.lx = x; ptr.ly = y;
});

function endPointer(e) {
  if (!ptr.down) return;
  ptr.down = false;
  ptr.x = e.clientX; ptr.y = e.clientY;
  if (ptr.mode === 'grab' && sim.grabbed) {
    const g = sim.grabbed;
    g.grab = false; g.inv = g.pinned ? 0 : 1;      // release: Verlet velocity carries the throw
    sim.grabbed = null;
  }
  if (ptr.mode === 'spawn') {
    if (ui.tool === 'rope') spawnRope(ptr.sx, ptr.sy, ptr.x, ptr.y);
    else if (ui.tool === 'ball') spawnBall(ptr.sx, ptr.sy, (ptr.x - ptr.sx) * 3, (ptr.y - ptr.sy) * 3);
  }
  ptr.mode = null;
}
canvas.addEventListener('pointerup', endPointer);
canvas.addEventListener('pointercancel', endPointer);

/* =====================================================================
   5. RENDERING — canvas 2D
   ===================================================================== */
function ratioOf(c) { return Math.hypot(c.b.x - c.a.x, c.b.y - c.a.y) / c.rest; }
function stressColor(r, a) {
  // 0 deviation = green, >=20% stretch/compress = red
  const s = Math.min(1, Math.abs(r - 1) / 0.2);
  return `rgba(${(90 + 165 * s) | 0},${(230 - 170 * s) | 0},${(140 - 100 * s) | 0},${a})`;
}
function rgba(arr, a) { return `rgba(${arr[0]},${arr[1]},${arr[2]},${a})`; }
function seg(c) {
  ctx.beginPath(); ctx.moveTo(c.a.x, c.a.y); ctx.lineTo(c.b.x, c.b.y); ctx.stroke();
}

function drawCloth(b) {
  const C = CFG.colors.cloth;
  for (const q of b.quads) {
    const L = q.L;
    if (!(L[0].alive && L[1].alive && L[2].alive && L[3].alive)) continue;
    const p = q.p;
    ctx.beginPath();
    ctx.moveTo(p[0].x, p[0].y); ctx.lineTo(p[1].x, p[1].y);
    ctx.lineTo(p[2].x, p[2].y); ctx.lineTo(p[3].x, p[3].y);
    ctx.closePath();
    ctx.fillStyle = ui.stress
      ? stressColor((ratioOf(L[0]) + ratioOf(L[1]) + ratioOf(L[2]) + ratioOf(L[3])) / 4, 0.4)
      : rgba(C, 0.16);
    ctx.fill();
  }
  ctx.lineWidth = 1;
  for (const c of b.cons) {
    if (!c.alive) continue;
    if (c.kind === 'shear' && !ui.stress) continue;
    ctx.strokeStyle = ui.stress ? stressColor(ratioOf(c), 0.95) : rgba(C, 0.6);
    seg(c);
  }
}

function drawRope(b) {
  const C = CFG.colors.rope;
  ctx.lineCap = 'round';
  for (const c of b.cons) {
    if (!c.alive) continue;
    ctx.lineWidth = 2.6;
    ctx.strokeStyle = ui.stress ? stressColor(ratioOf(c), 0.95) : rgba(C, 0.9);
    seg(c);
  }
  ctx.fillStyle = rgba(C, 1);
  for (const p of b.pts) { ctx.beginPath(); ctx.arc(p.x, p.y, 1.9, 0, TAU); ctx.fill(); }
}

function drawSoft(b) {
  const C = CFG.colors.soft, ring = b.ring;
  ctx.beginPath();
  ctx.moveTo(ring[0].x, ring[0].y);
  for (let i = 1; i < ring.length; i++) ctx.lineTo(ring[i].x, ring[i].y);
  ctx.closePath();
  ctx.fillStyle = rgba(C, 0.22);
  ctx.fill();
  for (const c of b.cons) {
    if (!c.alive) continue;
    ctx.lineWidth = c.kind === 'brace' ? 0.9 : 1.6;
    ctx.strokeStyle = ui.stress ? stressColor(ratioOf(c), 0.95) : rgba(C, c.kind === 'brace' ? 0.35 : 0.95);
    seg(c);
  }
  ctx.fillStyle = rgba(C, 1);
  for (const p of b.pts) { ctx.beginPath(); ctx.arc(p.x, p.y, 1.8, 0, TAU); ctx.fill(); }
}

function drawBall(b) {
  const p = b.pts[0], r = p.rad;
  const g = ctx.createRadialGradient(p.x - r * 0.35, p.y - r * 0.35, r * 0.1, p.x, p.y, r);
  g.addColorStop(0, '#ffd3dc'); g.addColorStop(0.45, CFG.colors.ball.length ? '#ff5f7e' : '#ff5f7e'); g.addColorStop(1, '#7d1f36');
  ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, TAU);
  ctx.fillStyle = g; ctx.fill();
}

function render() {
  const W = sim.W, H = sim.H, C = CFG.colors;
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  if (ui.trail) { ctx.fillStyle = 'rgba(10,12,17,0.3)'; ctx.fillRect(0, 0, W, H); }
  else { ctx.fillStyle = '#0a0c11'; ctx.fillRect(0, 0, W, H); }

  // subtle grid
  ctx.strokeStyle = C.grid; ctx.lineWidth = 1; ctx.beginPath();
  for (let x = 0; x < W; x += 40) { ctx.moveTo(x + 0.5, 0); ctx.lineTo(x + 0.5, H); }
  for (let y = 0; y < H; y += 40) { ctx.moveTo(0, y + 0.5); ctx.lineTo(W, y + 0.5); }
  ctx.stroke();

  // floor + frame
  const fg = ctx.createLinearGradient(0, H - 50, 0, H);
  fg.addColorStop(0, 'rgba(58,69,96,0)'); fg.addColorStop(1, 'rgba(58,69,96,0.55)');
  ctx.fillStyle = fg; ctx.fillRect(0, H - 50, W, 50);
  ctx.fillStyle = C.floor; ctx.fillRect(0, H - 3, W, 3);
  ctx.strokeStyle = 'rgba(85,98,127,0.35)'; ctx.lineWidth = 1; ctx.strokeRect(0.5, 0.5, W - 1, H - 1);

  // static geometry
  for (const s of sim.statics) {
    ctx.fillStyle = C.static; ctx.strokeStyle = C.staticEdge; ctx.lineWidth = 2;
    if (s.type === 'circle') {
      ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, TAU); ctx.fill(); ctx.stroke();
    } else {
      ctx.fillRect(s.x - s.hw, s.y - s.hh, s.hw * 2, s.hh * 2);
      ctx.strokeRect(s.x - s.hw, s.y - s.hh, s.hw * 2, s.hh * 2);
    }
  }

  // dynamic bodies
  for (const b of sim.bodies) {
    if (b.type === 'cloth') drawCloth(b);
    else if (b.type === 'rope') drawRope(b);
    else if (b.type === 'soft') drawSoft(b);
    else if (b.type === 'ball') drawBall(b);
  }

  // pin markers
  ctx.fillStyle = '#e8ecf5';
  for (const p of sim.points) {
    if (!p.pinned) continue;
    ctx.fillRect(p.x - 3, p.y - 3, 6, 6);
  }

  // tear sparks
  for (const s of sim.sparks) {
    ctx.fillStyle = rgba([255, 82, 82], Math.max(0, s.life));
    ctx.beginPath(); ctx.arc(s.x, s.y, 1.6, 0, TAU); ctx.fill();
  }

  // interaction overlays
  if (ptr.mode === 'cut') {
    ctx.strokeStyle = C.tear; ctx.lineWidth = 1.5; ctx.setLineDash([4, 4]);
    ctx.beginPath(); ctx.arc(ptr.x, ptr.y, CFG.tearRadius, 0, TAU); ctx.stroke();
    ctx.setLineDash([]);
  } else if (ptr.mode === 'spawn' && ui.tool === 'rope') {
    ctx.strokeStyle = 'rgba(255,179,71,0.6)'; ctx.setLineDash([3, 4]); ctx.lineWidth = 1.5;
    ctx.beginPath(); ctx.moveTo(ptr.sx, ptr.sy); ctx.lineTo(ptr.x, ptr.y); ctx.stroke(); ctx.setLineDash([]);
  } else if (ptr.mode === 'spawn' && ui.tool === 'ball') {
    ctx.strokeStyle = 'rgba(255,95,126,0.6)'; ctx.lineWidth = 1.5;
    ctx.beginPath(); ctx.moveTo(ptr.sx, ptr.sy); ctx.lineTo(ptr.x, ptr.y); ctx.stroke();
  }
  const hl = sim.grabbed || (!ptr.down ? ptr.hover : null);
  if (hl) {
    ctx.strokeStyle = sim.grabbed ? 'rgba(255,255,255,0.85)' : 'rgba(255,255,255,0.3)';
    ctx.lineWidth = 1.5;
    ctx.beginPath(); ctx.arc(hl.x, hl.y, sim.grabbed ? 13 : 9, 0, TAU); ctx.stroke();
  }
}

/* =====================================================================
   6. SCENE, UI WIRING, MAIN LOOP
   ===================================================================== */
function spawnDemo() {
  const W = sim.W, H = sim.H;
  spawnRope(W * 0.12, 60, W * 0.12, 60 + 260);            // hanging rope (pendulum)
  spawnRope(W * 0.24, 60, W * 0.24 + 200, 60);            // horizontal rope
  spawnCloth(W * 0.5, 110, 'corners');                    // draped over static circle
  spawnSoft(W * 0.2, H * 0.3, 'circle');
  spawnSoft(W * 0.74, H * 0.2, 'box');
  spawnBall(W * 0.66, H * 0.4, 0, 0);
}

function resetScene() {
  sim.reset();
  spawnDemo();
}

function sizeCanvas() {
  dpr = Math.min(2, window.devicePixelRatio || 1);
  canvas.width = Math.round(innerWidth * dpr);
  canvas.height = Math.round(innerHeight * dpr);
}
window.addEventListener('resize', () => {
  const ox = sim.W, oy = sim.H;
  sim.W = innerWidth; sim.H = innerHeight;
  sizeCanvas();
  const sx = sim.W / ox, sy = sim.H / oy;
  for (const s of sim.statics) { s.x *= sx; s.y *= sy; }
});

// UI
const toolBtns = document.querySelectorAll('[data-tool]');
toolBtns.forEach(btn => btn.addEventListener('click', () => {
  ui.tool = btn.dataset.tool;
  toolBtns.forEach(b => b.classList.toggle('on', b === btn));
  btn.blur();
}));
const pauseBtn = document.getElementById('pause');
function setPaused(v) {
  ui.paused = v;
  pauseBtn.textContent = v ? 'Resume' : 'Pause';
  pauseBtn.classList.toggle('on', v);
}
pauseBtn.addEventListener('click', () => { setPaused(!ui.paused); pauseBtn.blur(); });
document.getElementById('step').addEventListener('click', e => {
  setPaused(true); sim.step(); e.currentTarget.blur();
});
document.getElementById('reset').addEventListener('click', e => { resetScene(); e.currentTarget.blur(); });
document.getElementById('grav').addEventListener('change', e => { sim.gravOn = e.target.checked; });
document.getElementById('gstr').addEventListener('input', e => { sim.gravity = +e.target.value; });
document.getElementById('iters').addEventListener('input', e => { sim.iterations = +e.target.value; });
document.getElementById('wind').addEventListener('input', e => { sim.wind = +e.target.value; });
document.getElementById('shape').addEventListener('change', e => { ui.shape = e.target.value; });
document.getElementById('pins').addEventListener('change', e => { ui.pinMode = e.target.value; });
document.getElementById('stress').addEventListener('change', e => { ui.stress = e.target.checked; });
document.getElementById('trail').addEventListener('change', e => { ui.trail = e.target.checked; });

addEventListener('keydown', e => {
  if (e.code === 'Space') { e.preventDefault(); setPaused(!ui.paused); }
  else if (ui.paused && ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(e.code)) {
    e.preventDefault(); sim.step();
  }
});

// Main loop: fixed-step accumulator, clamped frame delta
let acc = 0, last = performance.now();
let fpsFrames = 0, fpsT = 0, fps = 60, stepAvg = 0, statsT = 0;
function frame(now) {
  let dt = (now - last) / 1000; last = now;
  if (!(dt > 0)) dt = 0;
  dt = Math.min(dt, CFG.maxFrameDt);          // tab switch / hitch: never big catch-up

  if (!ui.paused) {
    acc += dt;
    let n = 0;
    while (acc >= CFG.dt && n < CFG.maxStepsPerFrame) {
      sim.step();
      stepAvg = stepAvg * 0.9 + sim.stepMs * 0.1;
      acc -= CFG.dt; n++;
    }
    if (n >= CFG.maxStepsPerFrame) acc = 0;   // drop backlog instead of spiraling
  } else acc = 0;

  // sparks (render-side particles)
  for (const s of sim.sparks) {
    s.x += s.vx * dt; s.y += s.vy * dt; s.vy += 500 * dt; s.life -= dt * 2.5;
  }
  sim.sparks = sim.sparks.filter(s => s.life > 0);

  render();

  fpsFrames++; fpsT += dt;
  if (fpsT >= 0.5) { fps = fpsFrames / fpsT; fpsFrames = 0; fpsT = 0; }
  statsT += dt;
  if (statsT > 0.2) {
    statsT = 0;
    document.getElementById('stats').innerHTML =
      `${fps.toFixed(0)} fps<br>step ${stepAvg.toFixed(2)} ms<br>` +
      `${sim.points.length} pts · ${sim.aliveLinks()} links<br>` +
      (ui.paused ? 'PAUSED' : '');
  }
  requestAnimationFrame(frame);
}

sizeCanvas();
resetScene();
requestAnimationFrame(frame);
document.addEventListener('visibilitychange', () => { last = performance.now(); });
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"87041808-cde4-4539-bb91-6cd8cecd8de6","tokensIn":371459,"tokensOut":144594,"tokensTotal":516053,"cost":0.08077518999999998,"turns":13,"toolCalls":16,"failedToolCalls":1,"timestamp":"2026-10-08T02:52:16.969Z"} -->