← Physics Playground results

Physics Playground

Qwen 3.8 · full

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 43,153 bytes · SHA-256 dde3b79ed86b
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Soft-Body Physics Playground</title>
<style>
  :root {
    --bg: #0a0e16;
    --panel: #0d1424;
    --line: #1e2a44;
    --text: #cbd5e1;
    --dim: #64748b;
    --rope: #ffb02e;
    --cloth: #38bdf8;
    --blob: #34d399;
    --box: #a3e635;
    --ball: #f472b6;
    --cut: #fb7185;
  }
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body { height: 100%; overflow: hidden; }
  body {
    background: var(--bg);
    color: var(--text);
    font: 12px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
    display: flex; flex-direction: column;
    user-select: none; -webkit-user-select: none;
  }
  #bar {
    display: flex; align-items: center; flex-wrap: wrap; gap: 4px 10px;
    padding: 7px 12px;
    background: var(--panel);
    border-bottom: 1px solid var(--line);
    z-index: 5;
  }
  .brand { font-size: 12px; font-weight: 700; letter-spacing: .14em; color: #e2e8f0; margin-right: 4px; white-space: nowrap; }
  .brand em { font-style: normal; font-weight: 400; color: var(--dim); letter-spacing: .04em; }
  .group { display: flex; align-items: center; gap: 5px; padding: 0 9px; border-left: 1px solid var(--line); }
  button {
    font: inherit; font-size: 11px;
    color: #94a3b8; background: #131c31; border: 1px solid #243252;
    border-radius: 6px; padding: 4px 9px; cursor: pointer;
    transition: background .12s, color .12s, border-color .12s;
  }
  button:hover { color: #e2e8f0; border-color: #33456e; }
  button.tool.active { color: var(--c, #e2e8f0); border-color: var(--c, #e2e8f0); }
  button.tool.active { box-shadow: inset 0 0 14px 0 color-mix(in srgb, var(--c, #fff) 20%, transparent); }
  button.ctrl { min-width: 32px; }
  label.sl { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--dim); white-space: nowrap; }
  label.sl b { color: #94a3b8; font-weight: 600; font-variant-numeric: tabular-nums; min-width: 32px; text-align: right; }
  input[type=range] { width: 72px; accent-color: #38bdf8; cursor: pointer; }
  input[type=checkbox] { accent-color: #38bdf8; cursor: pointer; }
  select {
    font: inherit; font-size: 11px; color: #94a3b8;
    background: #131c31; border: 1px solid #243252; border-radius: 6px; padding: 3px 4px; cursor: pointer;
  }
  #wrap { position: relative; flex: 1; min-height: 0; }
  canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
  #hint {
    position: absolute; left: 12px; bottom: 10px;
    font-size: 11px; color: rgba(148,163,184,.65);
    pointer-events: none; letter-spacing: .02em;
  }
  #hint b { color: rgba(226,232,240,.8); font-weight: 600; }
</style>
</head>
<body>
  <div id="bar">
    <div class="brand">SOFT·BODY <em>playground</em></div>
    <div class="group" id="tools">
      <button class="tool active" data-tool="grab" style="--c:#e2e8f0" title="Grab &amp; throw (V)">grab</button>
      <button class="tool" data-tool="rope" style="--c:var(--rope)" title="Draw a rope (1)">rope</button>
      <button class="tool" data-tool="cloth" style="--c:var(--cloth)" title="Place cloth (2)">cloth</button>
      <button class="tool" data-tool="blob" style="--c:var(--blob)" title="Soft blob (3)">blob</button>
      <button class="tool" data-tool="box" style="--c:var(--box)" title="Soft box (4)">box</button>
      <button class="tool" data-tool="ball" style="--c:var(--ball)" title="Drop a ball (5)">ball</button>
      <button class="tool" data-tool="cut" style="--c:var(--cut)" title="Slice constraints (X)">&#9986; cut</button>
    </div>
    <div class="group">
      <label class="sl">gravity <input type="range" id="sGrav" min="0" max="3000" step="50" value="1600"><b id="vGrav">1600</b></label>
      <label class="sl">iter <input type="range" id="sIter" min="1" max="12" step="1" value="5"><b id="vIter">5</b></label>
      <label class="sl">wind <input type="range" id="sWind" min="-800" max="800" step="20" value="0"><b id="vWind">0</b></label>
    </div>
    <div class="group">
      <label class="sl"><input type="checkbox" id="cGrav" checked> g</label>
      <label class="sl"><input type="checkbox" id="cStress"> stress</label>
      <label class="sl"><input type="checkbox" id="cTrails"> trails</label>
      <label class="sl">pins <select id="selPin"><option value="edge">top edge</option><option value="corners">corners</option></select></label>
    </div>
    <div class="group">
      <button class="ctrl" id="bPause" title="Pause / resume (Space)">&#9208;</button>
      <button class="ctrl" id="bStep" title="Step one frame (&rarr;)">&#9197;</button>
      <button class="ctrl" id="bReset" title="Reset scene (R)">&#8634;</button>
    </div>
  </div>
  <div id="wrap">
    <canvas id="cv"></canvas>
    <div id="hint"></div>
  </div>
<script>
'use strict';

/* ================================================================
   1. TUNABLE CONSTANTS
   ================================================================ */
const CFG = {
  DT: 1 / 60,              // fixed physics timestep (s)
  MAX_FRAME_DELTA: 0.25,   // frame-delta clamp (tab-switch safety)
  MAX_STEPS_PER_FRAME: 8,  // spiral-of-death guard
  DAMPING: 0.995,          // velocity retained per step
  MAX_VEL: 60,             // px/step velocity clamp (stability guard)
  REST_EPS: 0.05,          // px/step; velocities below this are zeroed (anti-jitter)
  ITERATIONS: 5,           // constraint relaxation passes per step
  GRAVITY: 1600,           // px/s^2
  WIND: 0,                 // px/s^2 horizontal
  FRICTION: 0.8,           // tangential velocity lost on soft-point contact (high = kills rolling)
  STICK_EPS: 1.5,          // px/step; tangential motion below this is zeroed on contact (anti-creep/anti-roll)
  FLOOR_BAND: 8,           // px; friction-only band above floor/top surfaces (wider contact patch, kills rolling)
  BALL_FRICTION: 0.12,     // tangential velocity lost on ball contact
  RESTITUTION: 0.5,        // ball bounce
  POINT_RADIUS: 2,         // collision radius for non-ball points
  GRAB_RADIUS: 28,         // px; nearest-point grab distance
  TEAR_CLOTH: 2.3,         // stretch ratio that rips cloth
  TEAR_ROPE: 3.2,          // stretch ratio that snaps rope
  TEAR_SOFT: 2.6,          // stretch ratio that tears soft bodies
  FLOOR_H: 36,             // floor thickness (px)
  COLORS: {
    bg: '#0a0e16',
    grid: 'rgba(148,163,184,0.045)',
    floorEdge: 'rgba(148,163,184,0.30)',
    shape: '#101a2e',
    shapeEdge: 'rgba(148,163,184,0.35)',
    rope: '#ffb02e',
    cloth: '#38bdf8',
    blob: '#34d399',
    box: '#a3e635',
    ball: '#f472b6',
    pin: '#e2e8f0',
    tear: '#fb7185',
  },
};

/* Live, user-tunable state (toolbar bound to this) */
const state = {
  tool: 'grab',
  paused: false,
  gravityOn: true,
  gravity: CFG.GRAVITY,
  iterations: CFG.ITERATIONS,
  wind: 0,
  stress: false,
  trails: false,
  pinMode: 'edge',
};

/* ================================================================
   2. WORLD / STATIC GEOMETRY
   ================================================================ */
const canvas = document.getElementById('cv');
const ctx = canvas.getContext('2d');
const hintEl = document.getElementById('hint');

let W = 800, H = 600;
let staticCircles = [], staticBoxes = [];

/* Simulation data */
let points = [], constraints = [], objects = [], particles = [];
let nextId = 1;

/* ================================================================
   3. UTILS
   ================================================================ */
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
function hexA(hex, a) {
  const n = parseInt(hex.slice(1), 16);
  return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + a + ')';
}
function mix(a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; }

/* Stress color: compression -> blue, rest -> green, stretched -> yellow -> red */
const stressCache = new Map();
function stressColor(s) {
  const key = Math.round(clamp(s, 0.4, 1.8) * 50);
  let col = stressCache.get(key);
  if (col) return col;
  let c;
  if (s <= 1) {
    const t = clamp((1 - s) / 0.45, 0, 1);
    c = mix([56, 189, 248], [96, 165, 250], t);
  } else {
    const t = clamp((s - 1) / 0.65, 0, 1);
    c = t < 0.5 ? mix([52, 211, 153], [250, 204, 21], t * 2) : mix([250, 204, 21], [244, 63, 94], (t - 0.5) * 2);
  }
  col = 'rgb(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ')';
  stressCache.set(key, col);
  return col;
}

function resize() {
  const dpr = Math.min(2, window.devicePixelRatio || 1);
  W = canvas.clientWidth || window.innerWidth;
  H = canvas.clientHeight || window.innerHeight;
  canvas.width = Math.round(W * dpr);
  canvas.height = Math.round(H * dpr);
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  buildStatics();
  for (const p of points) {
    p.x = clamp(p.x, 2, W - 2);
    p.y = Math.min(p.y, H - CFG.FLOOR_H - 2);
  }
}

function buildStatics() {
  staticCircles = [
    { x: W * 0.40, y: H * 0.52, r: clamp(H * 0.13, 40, 100) },
    { x: W * 0.63, y: H * 0.72, r: clamp(H * 0.07, 26, 55) },
  ];
  staticBoxes = [
    { x: Math.min(W * 0.78, W - 165), y: H * 0.80, w: 150, h: 60 },
    { x: Math.max(12, W * 0.16), y: H * 0.84, w: 120, h: 40 },
  ];
}

/* ================================================================
   4. POINTS & CONSTRAINTS
   ================================================================ */
function makePoint(x, y, type, obj, radius, pinned) {
  return { id: nextId++, x, y, px: x, py: y, pinned: !!pinned, grabbed: false, radius: radius || 0, type, obj };
}
function makeConstraint(a, b, type, obj, tearAt) {
  const rest = Math.hypot(b.x - a.x, b.y - a.y) || 1e-6;
  return { a, b, rest, type, obj, broken: false, stretch: 1, tearAt: tearAt || 0 };
}
function addObject(obj) {
  objects.push(obj);
  for (const p of obj.points) points.push(p);
  for (const c of obj.constraints) constraints.push(c);
  return obj;
}

/* ================================================================
   5. OBJECT FACTORIES
   ================================================================ */
function spawnRopeBetween(a, b, n) {
  const dist = Math.hypot(b.x - a.x, b.y - a.y);
  const count = n || clamp(Math.round(dist / 13), 6, 90);
  const obj = { type: 'rope', points: [], constraints: [], color: CFG.COLORS.rope, hasBroken: false };
  let prev = null;
  for (let i = 0; i <= count; i++) {
    const t = i / count;
    const p = makePoint(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, 'rope', obj, 0, i === 0);
    obj.points.push(p);
    if (prev) obj.constraints.push(makeConstraint(prev, p, 'link', obj, CFG.TEAR_ROPE));
    prev = p;
  }
  return addObject(obj);
}

function spawnCloth(x, y, cols, rows, sx, sy, pinMode) {
  cols = clamp(cols, 3, 30); rows = clamp(rows, 2, 24);
  pinMode = pinMode || 'edge';
  const obj = { type: 'cloth', points: [], constraints: [], color: CFG.COLORS.cloth, hasBroken: false, grid: null };
  const grid = [];
  for (let r = 0; r < rows; r++) {
    grid[r] = [];
    for (let c = 0; c < cols; c++) {
      const pinned = pinMode === 'edge' ? r === 0 : (r === 0 && (c === 0 || c === cols - 1));
      grid[r][c] = makePoint(x + c * sx, y + r * sy, 'cloth', obj, 0, pinned);
      obj.points.push(grid[r][c]);
    }
  }
  for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
    const p = grid[r][c];
    if (c < cols - 1) obj.constraints.push(makeConstraint(p, grid[r][c + 1], 'structural', obj, CFG.TEAR_CLOTH));
    if (r < rows - 1) obj.constraints.push(makeConstraint(p, grid[r + 1][c], 'structural', obj, CFG.TEAR_CLOTH));
    if (c < cols - 1 && r < rows - 1) {
      obj.constraints.push(makeConstraint(p, grid[r + 1][c + 1], 'shear', obj, CFG.TEAR_CLOTH));
      obj.constraints.push(makeConstraint(grid[r][c + 1], grid[r + 1][c], 'shear', obj, CFG.TEAR_CLOTH));
    }
  }
  obj.grid = grid;
  return addObject(obj);
}

function spawnSoftCircle(cx, cy, r, n) {
  n = clamp(n || 14, 8, 24);
  const obj = { type: 'soft', points: [], constraints: [], color: CFG.COLORS.blob, hasBroken: false, outline: null };
  const ring = [];
  for (let i = 0; i < n; i++) {
    const a = (i / n) * Math.PI * 2 - Math.PI / 2;
    const p = makePoint(cx + Math.cos(a) * r, cy + Math.sin(a) * r, 'soft', obj);
    ring.push(p); obj.points.push(p);
  }
  const center = makePoint(cx, cy, 'soft', obj);
  obj.points.push(center);
  for (let i = 0; i < n; i++) {
    obj.constraints.push(makeConstraint(ring[i], ring[(i + 1) % n], 'edge', obj, CFG.TEAR_SOFT));
    obj.constraints.push(makeConstraint(center, ring[i], 'brace', obj, CFG.TEAR_SOFT));
  }
  const q = Math.round(n / 4);
  for (let i = 0; i < n; i += q) obj.constraints.push(makeConstraint(ring[i], ring[(i + q) % n], 'brace', obj, CFG.TEAR_SOFT));
  obj.outline = ring;
  return addObject(obj);
}

function spawnSoftBox(x, y, w, h) {
  const cols = 5, rows = 4;
  const sx = w / (cols - 1), sy = h / (rows - 1);
  const obj = { type: 'soft', points: [], constraints: [], color: CFG.COLORS.box, hasBroken: false, outline: null };
  const grid = [];
  for (let r = 0; r < rows; r++) {
    grid[r] = [];
    for (let c = 0; c < cols; c++) {
      grid[r][c] = makePoint(x + c * sx, y + r * sy, 'soft', obj);
      obj.points.push(grid[r][c]);
    }
  }
  for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
    const p = grid[r][c];
    if (c < cols - 1) obj.constraints.push(makeConstraint(p, grid[r][c + 1], 'structural', obj, CFG.TEAR_SOFT));
    if (r < rows - 1) obj.constraints.push(makeConstraint(p, grid[r + 1][c], 'structural', obj, CFG.TEAR_SOFT));
    if (c < cols - 1 && r < rows - 1) {
      obj.constraints.push(makeConstraint(p, grid[r + 1][c + 1], 'brace', obj, CFG.TEAR_SOFT));
      obj.constraints.push(makeConstraint(grid[r][c + 1], grid[r + 1][c], 'brace', obj, CFG.TEAR_SOFT));
    }
  }
  obj.outline = perimeter(grid);
  return addObject(obj);
}

function spawnBall(x, y, r) {
  const obj = { type: 'ball', points: [], constraints: [], color: CFG.COLORS.ball, hasBroken: false };
  obj.points.push(makePoint(x, y, 'ball', obj, r || 14, false));
  return addObject(obj);
}

function perimeter(grid) {
  const rows = grid.length, cols = grid[0].length;
  const out = [];
  for (let c = 0; c < cols; c++) out.push(grid[0][c]);
  for (let r = 1; r < rows; r++) out.push(grid[r][cols - 1]);
  for (let c = cols - 2; c >= 0; c--) out.push(grid[rows - 1][c]);
  for (let r = rows - 2; r >= 1; r--) out.push(grid[r][0]);
  return out;
}

/* ================================================================
   6. PHYSICS STEP  (Verlet + constraint relaxation)
   ================================================================ */
let grab = null;        // { p, x, y }
let cut = null;         // { x, y } current cut point while slicing
let cutTrail = [];
let cutTrailLife = 0;

function stepPhysics(dt) {
  const dt2 = dt * dt;
  const g = state.gravityOn ? state.gravity : 0;
  const wind = state.wind;

  /* --- 6.1 Verlet integration: x' = x + (x - xPrev)*damping + a*dt^2 --- */
  for (let i = 0; i < points.length; i++) {
    const p = points[i];
    if (p.pinned) { p.px = p.x; p.py = p.y; continue; }
    if (p.grabbed) {
      p.px = p.x; p.py = p.y;
      p.x = grab.x; p.y = grab.y;
      let vx = p.x - p.px, vy = p.y - p.py;
      const v2 = vx * vx + vy * vy;
      if (v2 > CFG.MAX_VEL * CFG.MAX_VEL) {
        const s = CFG.MAX_VEL / Math.sqrt(v2);
        p.px = p.x - vx * s; p.py = p.y - vy * s;
      }
      continue;
    }
    let vx = (p.x - p.px) * CFG.DAMPING;
    let vy = (p.y - p.py) * CFG.DAMPING;
    const v2 = vx * vx + vy * vy;
    if (v2 > CFG.MAX_VEL * CFG.MAX_VEL) { const s = CFG.MAX_VEL / Math.sqrt(v2); vx *= s; vy *= s; }
    if (v2 < CFG.REST_EPS * CFG.REST_EPS) { vx = 0; vy = 0; }  // anti-jitter: settle micro-velocity
    p.px = p.x; p.py = p.y;
    p.x += vx + wind * dt2;
    p.y += vy + g * dt2;
  }

  /* --- 6.2 Constraint relaxation (distance constraints, N passes) --- */
  const iters = state.iterations;
  for (let it = 0; it < iters; it++) {
    for (let i = 0; i < constraints.length; i++) {
      const c = constraints[i];
      if (c.broken) 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;                 // NaN guard: coincident points
      const d = Math.sqrt(d2);
      const wa = (a.pinned || a.grabbed) ? 0 : 1;
      const wb = (b.pinned || b.grabbed) ? 0 : 1;
      if (!wa && !wb) continue;
      const diff = (d - c.rest) / d;
      const s = 1 / (wa + wb);
      a.x += dx * diff * s * wa; a.y += dy * diff * s * wa;
      b.x -= dx * diff * s * wb; b.y -= dy * diff * s * wb;
    }
  }

  /* --- 6.3 Collisions with static geometry (walls, floor, circles, boxes) --- */
  for (let i = 0; i < points.length; i++) collidePoint(points[i]);

  /* --- 6.4 Ball vs ball --- */
  ballBall();

  /* --- 6.5 Stress readout + auto-tear --- */
  for (let i = 0; i < constraints.length; i++) {
    const c = constraints[i];
    if (c.broken) continue;
    const dx = c.b.x - c.a.x, dy = c.b.y - c.a.y;
    const d = Math.sqrt(dx * dx + dy * dy);
    c.stretch = c.rest > 1e-9 ? d / c.rest : 1;
    if (c.tearAt > 0 && c.stretch > c.tearAt) tearConstraint(c);
  }
}

/* Point vs static shapes, with friction (and restitution for balls) */
function collidePoint(p) {
  if (p.pinned || p.grabbed) return;
  const r = p.radius || CFG.POINT_RADIUS;
  const isBall = p.type === 'ball';
  const floorY = H - CFG.FLOOR_H;

  if (p.y > floorY - r) { p.y = floorY - r; surfaceResponse(p, 0, -1, isBall); }
  else if (p.y > floorY - r - CFG.FLOOR_BAND) frictionOnly(p, 0, -1, isBall);
  if (p.x < r) { p.x = r; surfaceResponse(p, 1, 0, isBall); }
  else if (p.x > W - r) { p.x = W - r; surfaceResponse(p, -1, 0, isBall); }

  for (let i = 0; i < staticCircles.length; i++) {
    const c = staticCircles[i];
    const rr = c.r + r;
    const dx = p.x - c.x, dy = p.y - c.y;
    const d2 = dx * dx + dy * dy;
    if (d2 < rr * rr) {
      let d = Math.sqrt(d2);
      let nx, ny;
      if (d < 1e-9) { nx = 0; ny = -1; }        // NaN guard: point at circle center
      else { nx = dx / d; ny = dy / d; }
      p.x = c.x + nx * rr; p.y = c.y + ny * rr;
      surfaceResponse(p, nx, ny, isBall);
    } else if (d2 < (rr + CFG.FLOOR_BAND) * (rr + CFG.FLOOR_BAND)) {
      const d = Math.sqrt(d2);
      frictionOnly(p, dx / d, dy / d, isBall);
    }
  }

  for (let i = 0; i < staticBoxes.length; i++) boxCollide(p, staticBoxes[i], isBall, r);
}

/* After projecting p onto a surface with outward normal (nx,ny):
   kill normal velocity (restitution for balls), damp tangential (friction) */
function surfaceResponse(p, nx, ny, isBall) {
  const vx = p.x - p.px, vy = p.y - p.py;
  const vn = vx * nx + vy * ny;
  const tx = vx - vn * nx, ty = vy - vn * ny;
  const e = (isBall && vn < -0.8) ? CFG.RESTITUTION : 0;  // micro-bounces -> inelastic (no jitter)
  const vn2 = vn < 0 ? -vn * e : vn;
  const keep = 1 - (isBall ? CFG.BALL_FRICTION : CFG.FRICTION);
  /* stick threshold: kill tiny tangential motion so resting bodies don't creep */
  const t2 = tx * tx + ty * ty;
  const tkeep = t2 < CFG.STICK_EPS * CFG.STICK_EPS ? 0 : keep;
  p.px = p.x - (vn2 * nx + tx * tkeep);
  p.py = p.y - (vn2 * ny + ty * tkeep);
}

function frictionOnly(p, nx, ny, isBall) {
  const vx = p.x - p.px, vy = p.y - p.py;
  const vn = vx * nx + vy * ny;
  if (vn > 0) return;            // moving away from the surface
  const tx = vx - vn * nx, ty = vy - vn * ny;
  const t2 = tx * tx + ty * ty;
  if (t2 < CFG.STICK_EPS * CFG.STICK_EPS) {
    p.px = p.x - vn * nx;        // kill all tangential motion -> stick
    p.py = p.y - vn * ny;
  } else {
    const keep = 1 - (isBall ? CFG.BALL_FRICTION : CFG.FRICTION);
    p.px = p.x - (vn * nx + tx * keep);
    p.py = p.y - (vn * ny + ty * keep);
  }
}

function boxCollide(p, b, isBall, r) {
  /* friction band above the top face */
  if (p.x > b.x - r && p.x < b.x + b.w + r && p.y > b.y - r - CFG.FLOOR_BAND && p.y < b.y - r) {
    frictionOnly(p, 0, -1, isBall);
    return;
  }
  const cx = clamp(p.x, b.x, b.x + b.w);
  const cy = clamp(p.y, b.y, b.y + b.h);
  const dx = p.x - cx, dy = p.y - cy;
  const d2 = dx * dx + dy * dy;
  if (d2 > r * r) return;
  if (d2 > 1e-12) {
    const d = Math.sqrt(d2);
    const nx = dx / d, ny = dy / d;
    p.x = cx + nx * r; p.y = cy + ny * r;
    surfaceResponse(p, nx, ny, isBall);
  } else {
    /* point inside the box: push out along the axis of least penetration */
    const left = p.x - b.x, right = b.x + b.w - p.x;
    const top = p.y - b.y, bottom = b.y + b.h - p.y;
    const m = Math.min(left, right, top, bottom);
    if (m === top) { p.y = b.y - r; surfaceResponse(p, 0, -1, isBall); }
    else if (m === bottom) { p.y = b.y + b.h + r; surfaceResponse(p, 0, 1, isBall); }
    else if (m === left) { p.x = b.x - r; surfaceResponse(p, -1, 0, isBall); }
    else { p.x = b.x + b.w + r; surfaceResponse(p, 1, 0, isBall); }
  }
}

function ballBall() {
  const balls = [];
  for (let i = 0; i < points.length; i++) if (points[i].type === 'ball') balls.push(points[i]);
  for (let i = 0; i < balls.length; i++) for (let j = i + 1; j < balls.length; j++) {
    const a = balls[i], b = balls[j];
    const min = a.radius + b.radius;
    const dx = b.x - a.x, dy = b.y - a.y;
    const d2 = dx * dx + dy * dy;
    if (d2 >= min * min) continue;
    let d = Math.sqrt(d2);
    let nx, ny;
    if (d < 1e-9) { nx = 1; ny = 0; } else { nx = dx / d; ny = dy / d; }
    const wa = a.pinned ? 0 : 1, wb = b.pinned ? 0 : 1;
    const ws = wa + wb;
    if (!ws) continue;
    const push = (min - d) / ws;
    a.x -= nx * push * wa; a.y -= ny * push * wa;
    b.x += nx * push * wb; b.y += ny * push * wb;
    const avx = a.x - a.px, avy = a.y - a.py;
    const bvx = b.x - b.px, bvy = b.y - b.py;
    const rel = (bvx - avx) * nx + (bvy - avy) * ny;
    if (rel < 0) {
      const e = -rel > 0.8 ? CFG.RESTITUTION : 0;
      const jimp = (1 + e) * -rel / ws;
      a.px += nx * jimp * wa; a.py += ny * jimp * wa;
      b.px -= nx * jimp * wb; b.py -= ny * jimp * wb;
    }
  }
}

/* ================================================================
   7. TEARING & CUTTING
   ================================================================ */
function tearConstraint(c) {
  if (c.broken) return;
  c.broken = true;
  if (c.obj) c.obj.hasBroken = true;
  spawnTearSparks((c.a.x + c.b.x) / 2, (c.a.y + c.b.y) / 2);
}

function spawnTearSparks(x, y) {
  for (let i = 0; i < 6; i++) {
    const a = Math.random() * Math.PI * 2;
    const s = 40 + Math.random() * 100;
    particles.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, life: 0.35 + Math.random() * 0.25, max: 0.6 });
  }
  if (particles.length > 300) particles.splice(0, particles.length - 300);
}

/* Slice every constraint whose segment crosses the cut segment (x1,y1)-(x2,y2) */
function cutSegment(x1, y1, x2, y2) {
  if (Math.hypot(x2 - x1, y2 - y1) < 0.5) return 0;
  let n = 0;
  for (let i = 0; i < constraints.length; i++) {
    const c = constraints[i];
    if (c.broken) continue;
    if (segSeg(x1, y1, x2, y2, c.a.x, c.a.y, c.b.x, c.b.y)) { tearConstraint(c); n++; }
  }
  return n;
}

function segSeg(ax, ay, bx, by, cx, cy, dx, dy) {
  const d1 = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
  const d2 = (bx - ax) * (dy - ay) - (by - ay) * (dx - ax);
  const d3 = (dx - cx) * (ay - cy) - (dy - cy) * (ax - cx);
  const d4 = (dx - cx) * (by - cy) - (dy - cy) * (bx - cx);
  if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) return true;
  if (Math.abs(d1) < 1e-9 && onSeg(ax, ay, bx, by, cx, cy)) return true;
  if (Math.abs(d2) < 1e-9 && onSeg(ax, ay, bx, by, dx, dy)) return true;
  if (Math.abs(d3) < 1e-9 && onSeg(cx, cy, dx, dy, ax, ay)) return true;
  if (Math.abs(d4) < 1e-9 && onSeg(cx, cy, dx, dy, bx, by)) return true;
  return false;
}
function onSeg(ax, ay, bx, by, px, py) {
  return Math.min(ax, bx) - 1e-9 <= px && px <= Math.max(ax, bx) + 1e-9 &&
         Math.min(ay, by) - 1e-9 <= py && py <= Math.max(ay, by) + 1e-9;
}
</script>
<script>
/* ================================================================
   8. INTERACTION  (grab / throw / cut / pin / spawn)
   ================================================================ */
let mouse = { x: -999, y: -999, down: false };
let grabToolDrag = null;   // { tool, sx, sy } while dragging to place
let cutActive = false, cutPrev = null;
let shiftHeld = false;

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

function nearestPoint(x, y, maxD, includePinned) {
  let best = null, bd = maxD * maxD;
  for (let i = 0; i < points.length; i++) {
    const p = points[i];
    if (!includePinned && p.pinned) continue;
    const dx = p.x - x, dy = p.y - y, d2 = dx * dx + dy * dy;
    if (d2 < bd) { bd = d2; best = p; }
  }
  return best;
}

function startGrab(x, y) {
  const p = nearestPoint(x, y, CFG.GRAB_RADIUS, false);
  if (p) {
    p.grabbed = true;
    p.px = p.x; p.py = p.y;
    grab = { p, x, y };
  }
  return !!p;
}
function endGrab() {
  if (grab) { grab.p.grabbed = false; grab = null; }
}

function togglePin(x, y) {
  const p = nearestPoint(x, y, CFG.GRAB_RADIUS, true);
  if (p) {
    p.pinned = !p.pinned;
    if (p.pinned) { p.px = p.x; p.py = p.y; if (grab && grab.p === p) endGrab(); }
  }
}

function beginCut(x, y) {
  cutActive = true;
  cutPrev = { x, y };
}
function moveCut(x, y) {
  if (cutPrev) cutSegment(cutPrev.x, cutPrev.y, x, y);
  cutPrev = { x, y };
  cutTrail.push({ x, y, t: 1 });
  if (cutTrail.length > 240) cutTrail.shift();
}
function endCut() { cutActive = false; cutPrev = null; }

/* --- placement --- */
function placeObject(tool, sx, sy, ex, ey) {
  const dx = ex - sx, dy = ey - sy;
  const dist = Math.hypot(dx, dy);
  const cx = (sx + ex) / 2, cy = (sy + ey) / 2;
  let obj = null;
  if (tool === 'rope') {
    if (dist < 12) obj = spawnRopeBetween({ x: sx, y: sy }, { x: sx, y: Math.min(sy + 150, H - CFG.FLOOR_H - 4) });
    else obj = spawnRopeBetween({ x: sx, y: sy }, { x: ex, y: ey });
  } else if (tool === 'cloth') {
    const w = Math.max(dist < 12 ? 130 : Math.abs(dx), 60), h = Math.max(dist < 12 ? 100 : Math.abs(dy), 44);
    const cols = clamp(Math.round(w / 20), 4, 26), rows = clamp(Math.round(h / 20), 3, 18);
    obj = spawnCloth(cx - w / 2, cy - h / 2, cols, rows, w / (cols - 1), h / (rows - 1), state.pinMode);
  } else if (tool === 'blob') {
    obj = spawnSoftCircle(cx, cy, clamp(dist < 12 ? 42 : dist / 2, 18, 95));
  } else if (tool === 'box') {
    const w = Math.max(dist < 12 ? 96 : Math.abs(dx), 56), h = Math.max(dist < 12 ? 72 : Math.abs(dy), 44);
    obj = spawnSoftBox(cx - w / 2, cy - h / 2, w, h);
  } else if (tool === 'ball') {
    obj = spawnBall(cx, cy, 16);
  }
  return obj;
}

/* --- pointer events --- */
function onDown(x, y, button, shift) {
  mouse.x = x; mouse.y = y; mouse.down = true;
  const wantCut = button === 2 || state.tool === 'cut';
  if (wantCut) { beginCut(x, y); return; }
  if (shift && state.tool === 'grab') { togglePin(x, y); return; }
  if (state.tool === 'grab') { startGrab(x, y); return; }
  grabToolDrag = { tool: state.tool, sx: x, sy: y, ex: x, ey: y };
}
function onMove(x, y, shift) {
  mouse.x = x; mouse.y = y; shiftHeld = shift;
  if (cutActive) { moveCut(x, y); return; }
  if (grab) { grab.x = x; grab.y = y; return; }
  if (grabToolDrag) { grabToolDrag.ex = x; grabToolDrag.ey = y; }
}
function onUp(x, y) {
  mouse.down = false;
  endCut();
  if (grabToolDrag) {
    placeObject(grabToolDrag.tool, grabToolDrag.sx, grabToolDrag.sy, x, y);
    grabToolDrag = null;
    setTool('grab');
  }
  endGrab();
}

canvas.addEventListener('mousedown', e => { e.preventDefault(); onDown(canvasPos(e).x, canvasPos(e).y, e.button, e.shiftKey); });
window.addEventListener('mousemove', e => { const p = canvasPos(e); onMove(p.x, p.y, e.shiftKey); });
window.addEventListener('mouseup', e => { const p = canvasPos(e); onUp(p.x, p.y); });
canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('touchstart', e => {
  e.preventDefault();
  const t = e.touches[0], p = canvasPos(t);
  onDown(p.x, p.y, 0, false);
}, { passive: false });
canvas.addEventListener('touchmove', e => {
  e.preventDefault();
  const t = e.touches[0], p = canvasPos(t);
  onMove(p.x, p.y, false);
}, { passive: false });
canvas.addEventListener('touchend', e => { e.preventDefault(); onUp(mouse.x, mouse.y); }, { passive: false });

/* --- keyboard --- */
window.addEventListener('keydown', e => {
  if (e.repeat && e.code !== 'ArrowRight') return;
  switch (e.code) {
    case 'Space': e.preventDefault(); setPaused(!state.paused); break;
    case 'ArrowRight': e.preventDefault(); if (state.paused) { stepPhysics(CFG.DT); } break;
    case 'KeyR': resetScene(); break;
    case 'Digit1': setTool('rope'); break;
    case 'Digit2': setTool('cloth'); break;
    case 'Digit3': setTool('blob'); break;
    case 'Digit4': setTool('box'); break;
    case 'Digit5': setTool('ball'); break;
    case 'KeyX': setTool('cut'); break;
    case 'KeyV': case 'Escape': setTool('grab'); break;
  }
});

/* ================================================================
   9. SCENE SETUP
   ================================================================ */
function resetScene() {
  points = []; constraints = []; objects = []; particles = [];
  grab = null; grabToolDrag = null; cutTrail = []; cutActive = false;
  nextId = 1;
  /* pendulum rope, pinned at the left end */
  spawnRopeBetween({ x: W * 0.09, y: H * 0.10 }, { x: W * 0.29, y: H * 0.10 });
  /* cloth draped over the big static circle */
  const cw = W * 0.24, ch = Math.min(H * 0.36, 270);
  const cols = 14, rows = 9;
  spawnCloth(W * 0.30, H * 0.09, cols, rows, cw / (cols - 1), ch / (rows - 1), 'edge');
  /* soft blob + box + ball */
  spawnSoftCircle(W * 0.70, H * 0.20, clamp(H * 0.07, 30, 52));
  spawnSoftBox(W * 0.84, H * 0.26, 92, 68);
  spawnBall(W * 0.18, H * 0.34, 15);
}

/* ================================================================
   10. RENDERING
   ================================================================ */
function strokeMap() { return new Map(); }
function addSeg(m, key, x1, y1, x2, y2) {
  let p = m.get(key);
  if (!p) { p = new Path2D(); m.set(key, p); }
  p.moveTo(x1, y1); p.lineTo(x2, y2);
}
function flushMap(m) {
  ctx.lineCap = 'round';
  for (const [key, path] of m) {
    const bar = key.lastIndexOf('|');
    ctx.strokeStyle = key.slice(0, bar);
    ctx.lineWidth = parseFloat(key.slice(bar + 1));
    ctx.stroke(path);
  }
}

function drawObject(o, m) {
  if (o.type === 'ball') {
    const p = o.points[0], r = p.radius;
    const g = ctx.createRadialGradient(p.x - r * 0.35, p.y - r * 0.4, r * 0.1, p.x, p.y, r);
    g.addColorStop(0, 'rgba(255,255,255,0.85)');
    g.addColorStop(0.3, o.color);
    g.addColorStop(1, hexA(o.color, 0.5));
    ctx.fillStyle = g;
    ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, 7); ctx.fill();
    return;
  }

  if (o.type === 'cloth' && !o.hasBroken && o.grid) {
    const g = o.grid, path = new Path2D();
    for (let r = 0; r < g.length - 1; r++) for (let c = 0; c < g[0].length - 1; c++) {
      const p1 = g[r][c], p2 = g[r][c + 1], p3 = g[r + 1][c + 1], p4 = g[r + 1][c];
      path.moveTo(p1.x, p1.y); path.lineTo(p2.x, p2.y); path.lineTo(p3.x, p3.y); path.lineTo(p4.x, p4.y); path.closePath();
    }
    ctx.fillStyle = hexA(o.color, 0.13);
    ctx.fill(path);
  }
  if (o.type === 'soft' && !o.hasBroken && o.outline) {
    const ol = o.outline, path = new Path2D();
    path.moveTo(ol[0].x, ol[0].y);
    for (let i = 1; i < ol.length; i++) path.lineTo(ol[i].x, ol[i].y);
    path.closePath();
    ctx.fillStyle = hexA(o.color, 0.18);
    ctx.fill(path);
  }

  for (let i = 0; i < o.constraints.length; i++) {
    const c = o.constraints[i];
    if (c.broken) continue;
    let col = o.color;
    if (state.stress) col = stressColor(c.stretch);
    const w = c.type === 'brace' ? 1 : (c.type === 'shear' ? 0.75 : 2);
    const alpha = c.type === 'brace' ? 0.45 : (c.type === 'shear' ? 0.35 : 0.95);
    addSeg(m, (state.stress ? col : hexA(col, alpha)) + '|' + w, c.a.x, c.a.y, c.b.x, c.b.y);
  }

  /* point dots (cloth dots hidden unless pinned/grabbed) */
  const showDots = o.type !== 'cloth' || state.stress;
  if (showDots) {
    const m2 = strokeMap();
    for (let i = 0; i < o.points.length; i++) {
      const p = o.points[i];
      if (o.type === 'cloth' && !p.pinned && !p.grabbed) continue;
      addSeg(m2, (p.pinned ? CFG.COLORS.pin : hexA(o.color, 0.9)) + '|1.5', p.x, p.y, p.x + 0.01, p.y);
    }
    flushMap(m2);
  }
}

function render() {
  ctx.fillStyle = CFG.COLORS.bg;
  ctx.fillRect(0, 0, W, H);

  /* faint grid */
  ctx.strokeStyle = CFG.COLORS.grid; ctx.lineWidth = 1;
  ctx.beginPath();
  for (let x = 80; x < W; x += 80) { ctx.moveTo(x + 0.5, 0); ctx.lineTo(x + 0.5, H); }
  for (let y = 80; y < H; y += 80) { ctx.moveTo(0, y + 0.5); ctx.lineTo(W, y + 0.5); }
  ctx.stroke();

  const floorY = H - CFG.FLOOR_H;
  ctx.fillStyle = 'rgba(148,163,184,0.05)';
  ctx.fillRect(0, floorY, W, CFG.FLOOR_H);
  ctx.strokeStyle = CFG.COLORS.floorEdge;
  ctx.beginPath(); ctx.moveTo(0, floorY + 0.5); ctx.lineTo(W, floorY + 0.5); ctx.stroke();

  for (const c of staticCircles) {
    ctx.fillStyle = CFG.COLORS.shape; ctx.strokeStyle = CFG.COLORS.shapeEdge; ctx.lineWidth = 1.5;
    ctx.beginPath(); ctx.arc(c.x, c.y, c.r, 0, 7); ctx.fill(); ctx.stroke();
  }
  for (const b of staticBoxes) {
    ctx.fillStyle = CFG.COLORS.shape; ctx.strokeStyle = CFG.COLORS.shapeEdge; ctx.lineWidth = 1.5;
    ctx.fillRect(b.x, b.y, b.w, b.h); ctx.strokeRect(b.x + 0.5, b.y + 0.5, b.w, b.h);
  }

  /* motion-blur trails */
  if (state.trails) {
    const tm = strokeMap();
    for (let i = 0; i < points.length; i++) {
      const p = points[i];
      const vx = p.x - p.px, vy = p.y - p.py;
      if (vx * vx + vy * vy > 3) addSeg(tm, hexA(p.obj.color, 0.3) + '|1.5', p.px, p.py, p.x, p.y);
    }
    flushMap(tm);
  }

  const m = strokeMap();
  for (let i = 0; i < objects.length; i++) drawObject(objects[i], m);
  flushMap(m);

  /* pin rings */
  ctx.strokeStyle = CFG.COLORS.pin; ctx.lineWidth = 1.5;
  for (let i = 0; i < points.length; i++) {
    const p = points[i];
    if (!p.pinned) continue;
    ctx.beginPath(); ctx.arc(p.x, p.y, 4.5, 0, 7); ctx.stroke();
  }

  /* tear particles */
  if (particles.length) {
    ctx.fillStyle = CFG.COLORS.tear;
    for (let i = 0; i < particles.length; i++) {
      const q = particles[i];
      ctx.globalAlpha = clamp(q.life / q.max, 0, 1);
      ctx.fillRect(q.x - 1.5, q.y - 1.5, 3, 3);
    }
    ctx.globalAlpha = 1;
  }

  /* cut trail */
  if (cutTrail.length) {
    for (let i = 1; i < cutTrail.length; i++) {
      const a = cutTrail[i - 1], b = cutTrail[i];
      ctx.strokeStyle = hexA(CFG.COLORS.tear, clamp(b.t, 0, 1) * 0.9);
      ctx.lineWidth = 2;
      ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
    }
  }

  /* grab highlight */
  if (grab) {
    ctx.strokeStyle = CFG.COLORS.pin; ctx.lineWidth = 1.5;
    ctx.setLineDash([4, 4]);
    ctx.beginPath(); ctx.arc(grab.p.x, grab.p.y, CFG.GRAB_RADIUS * 0.55, 0, 7); ctx.stroke();
    ctx.setLineDash([]);
    ctx.strokeStyle = 'rgba(226,232,240,0.35)';
    ctx.beginPath(); ctx.moveTo(grab.p.x, grab.p.y); ctx.lineTo(grab.x, grab.y); ctx.stroke();
  }

  /* spawn preview */
  if (grabToolDrag) {
    const d = grabToolDrag;
    ctx.strokeStyle = hexA(CFG.COLORS[d.tool] || '#e2e8f0', 0.7);
    ctx.setLineDash([6, 5]); ctx.lineWidth = 1.5;
    if (d.tool === 'rope') { ctx.beginPath(); ctx.moveTo(d.sx, d.sy); ctx.lineTo(d.ex, d.ey); ctx.stroke(); }
    else if (d.tool === 'cloth' || d.tool === 'box') {
      ctx.strokeRect(Math.min(d.sx, d.ex), Math.min(d.sy, d.ey), Math.abs(d.ex - d.sx), Math.abs(d.ey - d.sy));
    } else {
      const dist = Math.hypot(d.ex - d.sx, d.ey - d.sy);
      const r = d.tool === 'blob' ? clamp(dist / 2, 18, 95) : 16;
      ctx.beginPath(); ctx.arc((d.sx + d.ex) / 2, (d.sy + d.ey) / 2, r, 0, 7); ctx.stroke();
    }
    ctx.setLineDash([]);
  }

  /* cursor crosshair for cut tool */
  if (state.tool === 'cut' && mouse.x > 0) {
    ctx.strokeStyle = hexA(CFG.COLORS.tear, 0.8); ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(mouse.x - 9, mouse.y); ctx.lineTo(mouse.x + 9, mouse.y);
    ctx.moveTo(mouse.x, mouse.y - 9); ctx.lineTo(mouse.x, mouse.y + 9);
    ctx.stroke();
  }

  /* HUD */
  let active = 0;
  for (let i = 0; i < constraints.length; i++) if (!constraints[i].broken) active++;
  ctx.font = '11px ui-monospace, SFMono-Regular, Menlo, monospace';
  ctx.textAlign = 'right';
  ctx.fillStyle = 'rgba(148,163,184,0.75)';
  ctx.fillText('fps ' + fps.toFixed(0) + '   step ' + stepMs.toFixed(2) + 'ms   pts ' + points.length + '   con ' + active, W - 12, 20);
  if (state.paused) {
    ctx.fillStyle = 'rgba(251,113,133,0.9)';
    ctx.fillText('PAUSED — → to step', W - 12, 36);
  }
}

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

function frame(now) {
  const raw = (now - last) / 1000;
  last = now;
  if (raw > 0) fps = fps * 0.92 + (1 / raw) * 0.08;
  const delta = Math.min(raw, CFG.MAX_FRAME_DELTA);

  if (!state.paused) {
    acc += delta;
    const t0 = performance.now();
    let n = 0;
    while (acc >= CFG.DT && n < CFG.MAX_STEPS_PER_FRAME) { stepPhysics(CFG.DT); acc -= CFG.DT; n++; }
    if (n === CFG.MAX_STEPS_PER_FRAME) acc = 0;   // drop backlog instead of spiraling
    stepMs = stepMs * 0.9 + (performance.now() - t0) * 0.1;
  } else {
    acc = 0;
  }

  /* age tear particles + cut trail */
  for (let i = particles.length - 1; i >= 0; i--) {
    const q = particles[i];
    q.life -= 0.016; q.x += q.vx * 0.016; q.y += q.vy * 0.016; q.vy += 300 * 0.016;
    if (q.life <= 0) particles.splice(i, 1);
  }
  for (let i = cutTrail.length - 1; i >= 0; i--) {
    cutTrail[i].t -= 0.04;
    if (cutTrail[i].t <= 0) cutTrail.splice(i, 1);
  }

  render();
  requestAnimationFrame(frame);
}

function setPaused(v) {
  state.paused = v;
  const b = document.getElementById('bPause');
  b.innerHTML = v ? '&#9654;' : '&#9208;';
  b.title = (v ? 'Resume (Space)' : 'Pause (Space)');
}

/* ================================================================
   12. TOOLBAR BINDINGS
   ================================================================ */
const HINTS = {
  grab: '<b>drag</b> point to throw &nbsp;·&nbsp; <b>shift+click</b> pin/unpin &nbsp;·&nbsp; <b>right-drag</b> cut &nbsp;·&nbsp; <b>space</b> pause &nbsp;·&nbsp; <b>→</b> step &nbsp;·&nbsp; <b>1–5</b> spawn',
  rope: 'click-drag to draw a rope (pinned at start) &nbsp;·&nbsp; <b>esc</b> back to grab',
  cloth: 'click-drag to place cloth &nbsp;·&nbsp; <b>esc</b> back to grab',
  blob: 'click-drag to size a soft blob &nbsp;·&nbsp; <b>esc</b> back to grab',
  box: 'click-drag to size a soft box &nbsp;·&nbsp; <b>esc</b> back to grab',
  ball: 'click to drop a ball &nbsp;·&nbsp; <b>esc</b> back to grab',
  cut: 'drag to slice constraints &nbsp;·&nbsp; <b>esc</b> back to grab',
};
function setTool(t) {
  state.tool = t;
  document.querySelectorAll('#tools .tool').forEach(b => b.classList.toggle('active', b.dataset.tool === t));
  canvas.style.cursor = t === 'grab' ? 'default' : 'crosshair';
  hintEl.innerHTML = HINTS[t] || HINTS.grab;
}
document.querySelectorAll('#tools .tool').forEach(b => b.addEventListener('click', () => setTool(b.dataset.tool)));

const sGrav = document.getElementById('sGrav'), vGrav = document.getElementById('vGrav');
const sIter = document.getElementById('sIter'), vIter = document.getElementById('vIter');
const sWind = document.getElementById('sWind'), vWind = document.getElementById('vWind');
sGrav.addEventListener('input', () => { state.gravity = +sGrav.value; vGrav.textContent = sGrav.value; });
sIter.addEventListener('input', () => { state.iterations = +sIter.value; vIter.textContent = sIter.value; });
sWind.addEventListener('input', () => { state.wind = +sWind.value; vWind.textContent = sWind.value; });
document.getElementById('cGrav').addEventListener('change', e => { state.gravityOn = e.target.checked; });
document.getElementById('cStress').addEventListener('change', e => { state.stress = e.target.checked; });
document.getElementById('cTrails').addEventListener('change', e => { state.trails = e.target.checked; });
document.getElementById('selPin').addEventListener('change', e => { state.pinMode = e.target.value; });
document.getElementById('bPause').addEventListener('click', () => setPaused(!state.paused));
document.getElementById('bStep').addEventListener('click', () => { if (state.paused) stepPhysics(CFG.DT); });
document.getElementById('bReset').addEventListener('click', resetScene);

/* ================================================================
   13. HEADLESS API  (window.sim)
   ================================================================ */
window.sim = {
  get points() { return points; },
  get constraints() { return constraints; },
  get objects() { return objects; },
  get cfg() { return CFG; },
  get state() { return state; },
  step(n) { n = n || 1; for (let i = 0; i < n; i++) stepPhysics(CFG.DT); },
  stepOnce() { stepPhysics(CFG.DT); },
  reset() { resetScene(); },
  setPaused(v) { setPaused(!!v); },
  isPaused() { return state.paused; },
  floorY() { return H - CFG.FLOOR_H; },
  spawnRope(a, b, n) { return spawnRopeBetween(a, b, n); },
  spawnCloth(x, y, cols, rows, sx, sy, mode) { return spawnCloth(x, y, cols, rows, sx, sy, mode); },
  spawnBlob(x, y, r) { return spawnSoftCircle(x, y, r); },
  spawnBox(x, y, w, h) { return spawnSoftBox(x, y, w, h); },
  spawnBall(x, y, r) { return spawnBall(x, y, r); },
  tear(c) { tearConstraint(c); },
  kineticEnergy() {
    let ke = 0;
    for (let i = 0; i < points.length; i++) {
      const p = points[i];
      const vx = (p.x - p.px) / CFG.DT, vy = (p.y - p.py) / CFG.DT;
      ke += 0.5 * (vx * vx + vy * vy);
    }
    return ke;
  },
  maxCoord() {
    let m = 0;
    for (let i = 0; i < points.length; i++) m = Math.max(m, Math.abs(points[i].x), Math.abs(points[i].y));
    return m;
  },
};

/* ================================================================
   14. BOOT
   ================================================================ */
window.addEventListener('resize', resize);
resize();
resetScene();
setTool('grab');
requestAnimationFrame(frame);
</script>
</body>
</html>
<!-- agent-meta {"model":"Qwen3.8","provider":"brigid","persona":"full","sessionId":"0f376a26-d6a4-41a3-a3d5-84dda6329a21","tokensIn":2955323,"tokensOut":123615,"tokensTotal":3078938,"cost":0,"turns":50,"toolCalls":55,"failedToolCalls":0,"timestamp":"2026-09-01T23:35:57.213Z"} -->