← Physics Playground results

Physics Playground

Tiel Coder 35B A3B GGUF MTP · 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 34,317 bytes · SHA-256 bd496fd9ec8d
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>2D Soft-Body Physics Playground</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0c12; font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
  #c { display: block; position: absolute; top: 0; left: 0; cursor: crosshair; }

  #ui { position: absolute; inset: 0; pointer-events: none; }
  #toolbar {
    position: absolute; top: 10px; left: 50%; transform: translateX(-50%);
    display: flex; gap: 6px; align-items: center; padding: 6px 10px;
    background: rgba(18, 22, 32, 0.82); border: 1px solid rgba(120, 140, 180, 0.18);
    border-radius: 12px; backdrop-filter: blur(6px); pointer-events: auto;
    box-shadow: 0 6px 24px rgba(0,0,0,0.4);
  }
  #toolbar button, #toolbar select {
    background: rgba(40, 48, 66, 0.6); color: #cdd6e8; border: 1px solid rgba(120,140,180,0.2);
    padding: 6px 10px; border-radius: 8px; font: inherit; font-size: 12px; cursor: pointer;
    transition: background .12s, border-color .12s;
  }
  #toolbar button:hover, #toolbar select:hover { background: rgba(70, 82, 110, 0.8); }
  #toolbar button.active { background: #3a7bd5; border-color: #6fb0ff; color: #fff; }
  .sep { width: 1px; height: 22px; background: rgba(120,140,180,0.22); margin: 0 2px; }
  #toolbar label { color: #9fb0cc; font-size: 11px; display: flex; align-items: center; gap: 5px; white-space: nowrap; }
  #toolbar input[type=range] { width: 84px; accent-color: #3a7bd5; }
  #toolbar input[type=checkbox] { accent-color: #3a7bd5; }

  #readouts {
    position: absolute; top: 10px; right: 12px; pointer-events: none;
    background: rgba(18,22,32,0.72); border: 1px solid rgba(120,140,180,0.15);
    border-radius: 10px; padding: 8px 12px; color: #8fa6c8; font-size: 11px; line-height: 1.6;
    text-align: right;
  }
  #readouts b { color: #cfe0ff; font-weight: 600; }
  #hint {
    position: absolute; bottom: 10px; left: 50%; transform: translateX(-50%);
    color: #5c6c88; font-size: 11px; pointer-events: none; text-align: center;
    background: rgba(10,12,18,0.6); padding: 4px 12px; border-radius: 8px;
  }
  #hint span { color: #8fa6c8; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="ui">
  <div id="toolbar">
    <button data-tool="grab" class="active">Grab</button>
    <div class="sep"></div>
    <button data-tool="rope">Rope</button>
    <button data-tool="cloth">Cloth</button>
    <button data-tool="softbody">Soft Body</button>
    <button data-tool="ball">Ball</button>
    <button data-tool="pin">Pin</button>
    <div class="sep"></div>
    <label>Cloth pins
      <select id="pinPattern">
        <option value="edge">Full edge</option>
        <option value="corners">Corners</option>
      </select>
    </label>
    <div class="sep"></div>
    <button id="pauseBtn">Pause</button>
    <button id="stepBtn">Step</button>
    <button id="resetBtn">Reset</button>
    <div class="sep"></div>
    <label>Gravity <input type="range" id="gravity" min="0" max="4000" step="50" value="1500"></label>
    <label>Iter <input type="range" id="iters" min="1" max="10" value="5"></label>
    <label>Wind <input type="range" id="wind" min="-1200" max="1200" step="20" value="0"></label>
    <div class="sep"></div>
    <label><input type="checkbox" id="stress"> Stress</label>
    <label><input type="checkbox" id="trail"> Trail</label>
  </div>
  <div id="readouts">
    <div>FPS: <b id="fps">--</b></div>
    <div>Physics: <b id="ptime">--</b> ms</div>
    <div>Points: <b id="stats">0</b> · Constraints: <b id="conns">0</b></div>
    <div>Paused: <b id="pausedState">no</b></div>
  </div>
  <div id="hint">
    <span>Left-drag</span> spawn (or grab a point) · <span>Right-drag</span> cut · <span>Pin tool / Ctrl-click</span> toggle pin · <span>Space</span> pause · <span>←/→</span> step
  </div>
</div>

<script>
"use strict";

/* =========================================================================
   SECTION 1 — TUNABLE CONSTANTS
   ========================================================================= */
const CONFIG = {
  timestep:      1 / 60,      // fixed physics step (60 Hz)
  iterations:    5,            // constraint relaxation iterations per step
  gravity:       1500,         // px/s^2 (downward; y grows downward)
  wind:          0,            // px/s^2 horizontal
  damping:       0.990,        // velocity retention per step (energy decay)
  friction:      0.80,         // tangential friction on collision
  restitution:   0.35,         // bounce energy retained on collision
  maxDelta:      0.25,         // clamp per-frame delta so tab-switch can't explode
  tearRatio:     3.0,          // constraint tears when stretched beyond rest*ratio
  grabRadius:    22,           // px — pickup radius for grabbing
  tearRadius:    14,           // px — cut proximity radius
  pointRadius:   2.2,
  colors: {
    rope:     '#ffcc33',
    cloth:    '#44ccff',
    clothPin: '#ffffff',
    softbody: '#ff66aa',
    softLine: 'rgba(255,102,170,0.35)',
    ball:     '#66ff88',
    grabbed:  '#ffaa00',
    pinned:   '#ffffff',
    wall:     '#2a3350',
    floor:    '#24304d',
    grid:     'rgba(90,110,150,0.06)',
  }
};

/* =========================================================================
   SECTION 2 — VECTOR / POINT / CONSTRAINT
   ========================================================================= */
class Point {
  constructor(x, y, opts = {}) {
    this.x = x; this.y = y;
    this.px = x; this.py = y;   // previous position (Verlet)
    this.pinned = !!opts.pinned;
    this.grabbed = false;
    this.mass = opts.mass ?? 1;
    this.massInv = this.pinned ? 0 : (1 / this.mass);
    this.radius = opts.radius ?? CONFIG.pointRadius;
    this.ax = 0; this.ay = 0;   // accumulated external accel (wind etc.)
  }
  release(vx, vy) {
    const dt = CONFIG.timestep;
    this.px = this.x - vx * dt;
    this.py = this.y - vy * dt;
  }
}

class Constraint {
  constructor(a, b, rest, opts = {}) {
    this.a = a; this.b = b;
    this.rest = rest;
    this.stiffness = opts.stiffness ?? 1;
    this.tear = opts.tear !== undefined ? opts.tear : rest * CONFIG.tearRatio;
    this.broken = false;
    this.stretch = 1;            // dist / rest (for stress viz)
    this.type = opts.type ?? 'dist';
  }
  solve() {
    if (this.broken) return;
    const dx = this.b.x - this.a.x;
    const dy = this.b.y - this.a.y;
    let dist = dx * dx + dy * dy;
    if (dist < 1e-8) { this.stretch = 1; return; }   // guard zero-length
    dist = Math.sqrt(dist);
    this.stretch = dist / this.rest;
    if (this.stretch > this.tear / this.rest) { this.broken = true; return; }

    const diff = (dist - this.rest) / dist * this.stiffness;
    const corrX = dx * diff;
    const corrY = dy * diff;
    const invA = this.a.massInv;
    const invB = this.b.massInv;
    if (invA <= 0 && invB <= 0) return;
    if (!this.a.pinned) { this.a.x += corrX * invA; this.a.y += corrY * invA; }
    if (!this.b.pinned) { this.b.x -= corrX * invB; this.b.y -= corrY * invB; }
  }
}

/* =========================================================================
   SECTION 3 — STATIC GEOMETRY + COLLISION
   ========================================================================= */
function makeSim(w, h) {
  const sim = {
    points: [], constraints: [], objects: [], particles: [],
    cutTrail: [], preview: null, dragging: null, grabPoint: null,
    w, h, floorY: h - 24, wallL: 24, wallR: w - 24,
    staticShapes: []
  };
  // Static obstacles: two circles + a ramp box, so cloth drapes & bounces.
  sim.staticShapes = [
    { type: 'circle', x: w * 0.66, y: h * 0.58, r: 66 },
    { type: 'circle', x: w * 0.34, y: h * 0.70, r: 48 },
    { type: 'box', cx: w * 0.50, cy: h * 0.74, w: 190, h: 28 },
  ];
  return sim;
}

// Point vs static shape resolution. Mutates the point to rest on the surface.
function collide(sim, p) {
  const r = p.radius;

  // Floor
  if (p.y > sim.floorY - r) {
    p.y = sim.floorY - r;
    applySurfaceFriction(p);
  }
  // Side walls
  if (p.x < sim.wallL + r) {
    p.x = sim.wallL + r; applySurfaceFriction(p);
  } else if (p.x > sim.wallR - r) {
    p.x = sim.wallR - r; applySurfaceFriction(p);
  }

  // Obstacles
  for (const s of sim.staticShapes) {
    if (s.type === 'circle') {
      const dx = p.x - s.x, dy = p.y - s.y;
      const d2 = dx * dx + dy * dy;
      const min = s.r + r;
      if (d2 < min * min && d2 > 1e-8) {
        const d = Math.sqrt(d2);
        const push = (min - d) / d;
        p.x += dx * push; p.y += dy * push;
      }
    } else if (s.type === 'box') {
      const hw = s.w / 2, hh = s.h / 2;
      const nx = p.x - s.cx, ny = p.y - s.cy;
      const ox = hw + r - Math.abs(nx);
      const oy = hh + r - Math.abs(ny);
      if (ox > 0 && oy > 0) {
        if (ox < oy) {
          p.x += Math.sign(nx) * ox; applySurfaceFriction(p, true);
        } else {
          p.y += Math.sign(ny) * oy; applySurfaceFriction(p);
        }
      }
    }
  }
}

// Apply friction + restitution to the velocity implied by Verlet.
function applySurfaceFriction(p, normalX) {
  let vx = p.x - p.px;
  let vy = p.y - p.py;
  if (normalX) {
    // horizontal surface: friction on x, bounce on y
    p.px = p.x - vx * CONFIG.friction;
    p.py = p.y + vy * CONFIG.restitution;
  } else {
    // horizontal floor/wall-parallel: friction on y (vertical motion), bounce on x
    p.px = p.x + vx * CONFIG.restitution;
    p.py = p.y - vy * CONFIG.friction;
  }
}

/* =========================================================================
   SECTION 4 — SPAWNERS (rope / cloth / softbody / ball)
   ========================================================================= */
function addPoint(sim, p) { sim.points.push(p); return p; }

function spawnRope(sim, x0, y0, x1, y1) {
  const dx = x1 - x0, dy = y1 - y0;
  const len = Math.hypot(dx, dy) || 1;
  const n = Math.max(4, Math.round(len / 18));
  const pts = [];
  for (let i = 0; i <= n; i++) {
    const t = i / n;
    pts.push(addPoint(sim, new Point(x0 + dx * t, y0 + dy * t, { pinned: i === 0 })));
  }
  const obj = { type: 'rope', points: pts, constraints: [], color: CONFIG.colors.rope };
  for (let i = 0; i < pts.length - 1; i++) {
    const c = new Constraint(pts[i], pts[i + 1], dist(pts[i], pts[i + 1]));
    sim.constraints.push(c); obj.constraints.push(c);
  }
  sim.objects.push(obj);
}

function spawnCloth(sim, x, y, w, h) {
  const cols = Math.max(3, Math.round(w / 18));
  const rows = Math.max(3, Math.round(h / 18));
  const cellX = w / (cols - 1), cellY = h / (rows - 1);
  const grid = [];
  for (let r = 0; r < rows; r++) {
    const row = [];
    for (let c = 0; c < cols; c++) {
      const px = x + c * cellX, py = y + r * cellY;
      const pinned = (r === 0) && (pinPattern === 'edge' || c === 0 || c === cols - 1);
      row.push(addPoint(sim, new Point(px, py, { pinned })));
    }
    grid.push(row);
  }
  const struct = [];
  const shear = [];
  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      const p = grid[r][c];
      if (c < cols - 1) struct.push(link(sim, p, grid[r][c + 1]));
      if (r < rows - 1) struct.push(link(sim, p, grid[r + 1][c]));
      if (c < cols - 1 && r < rows - 1) {
        shear.push(link(sim, p, grid[r + 1][c + 1], { tear: (struct.length ? 1e9 : 1e9) }));
        shear.push(link(sim, grid[r + 1][c], grid[r][c + 1]));
      }
    }
  }
  sim.objects.push({ type: 'cloth', grid, rows, cols, points: [], struct, shear, color: CONFIG.colors.cloth });
  sim.objects[sim.objects.length - 1].points = grid.flat();
}

function link(sim, a, b, opts) {
  const c = new Constraint(a, b, dist(a, b), opts);
  sim.constraints.push(c);
  return c;
}

function spawnSoftBody(sim, cx, cy, size, kind) {
  const pts = [];
  const center = addPoint(sim, new Point(cx, cy, { mass: 1.4 }));
  const ringN = 14;
  const ring = [];
  for (let i = 0; i < ringN; i++) {
    const ang = (i / ringN) * Math.PI * 2;
    const px = cx + Math.cos(ang) * size;
    const py = cy + Math.sin(ang) * size;
    ring.push(addPoint(sim, new Point(px, py, { mass: 1 })));
  }
  const obj = { type: 'softbody', kind, points: [center, ...ring], constraints: [], color: CONFIG.colors.softbody, cx: 0, cy: 0, size };
  // structural: ring edges (soft — let the body deform)
  for (let i = 0; i < ringN; i++) {
    const a = ring[i], b = ring[(i + 1) % ringN];
    obj.constraints.push(link(sim, a, b, { stiffness: 0.25, tear: size * 1.8 }));
  }
  // radial braces to center (strong — define the radius)
  for (const p of ring) obj.constraints.push(link(sim, center, p, { stiffness: 0.35, tear: size * 2.2 }));
  // cross braces (opposite points) for shape retention
  for (let i = 0; i < ringN; i++) {
    const opp = ring[(i + Math.floor(ringN / 2)) % ringN];
    obj.constraints.push(link(sim, ring[i], opp, { stiffness: 0.3, tear: size * 3.5 }));
  }
  sim.objects.push(obj);
}

function spawnBall(sim, x, y) {
  const p = addPoint(sim, new Point(x, y, { mass: 1, radius: 16 }));
  sim.objects.push({ type: 'ball', point: p, radius: 16, color: CONFIG.colors.ball });
}

function dist(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); }

/* =========================================================================
   SECTION 5 — SIMULATION STEP
   ========================================================================= */
function safe(p) {
  if (!isFinite(p.x) || !isFinite(p.y)) { p.x = p.px; p.y = p.py; }
}

function step(sim) {
  const dt = CONFIG.timestep;
  const dt2 = dt * dt;

  // --- Integrate (Verlet) ---
  for (const p of sim.points) {
    if (p.pinned || p.grabbed) { safe(p); continue; }
    const vx = (p.x - p.px) * CONFIG.damping;
    const vy = (p.y - p.py) * CONFIG.damping;
    p.px = p.x; p.py = p.y;
    p.x += vx;
    p.y += vy + CONFIG.gravity * dt2;
  }
  // Grabbed points follow the cursor (position set by interaction layer).

  // --- Solve constraints + collisions (relaxation) ---
  for (let it = 0; it < CONFIG.iterations; it++) {
    for (const c of sim.constraints) c.solve();
    for (const p of sim.points) {
      if (p.pinned || p.grabbed) continue;
      collide(sim, p);
      safe(p);
    }
  }
}

/* =========================================================================
   SECTION 6 — RENDERING
   ========================================================================= */
function stressColor(ratio) {
  // ratio = dist/rest  (1 = rest).  blue(compressed) -> green -> yellow -> red
  let r, g, b;
  if (ratio < 1) { const t = Math.max(0, ratio); r = 140 + t * 40; g = 190 + t * 40; b = 255; }
  else if (ratio < 1.4) { const t = (ratio - 1) / 0.4; r = 90 + t * 165; g = 230 - t * 70; b = 90 - t * 30; }
  else if (ratio < 1.9) { const t = (ratio - 1.4) / 0.5; r = 255; g = 160 - t * 120; b = 60 - t * 30; }
  else { r = 255; g = 40; b = 40; }
  return `rgb(${r | 0},${g | 0},${b | 0})`;
}

function render(sim, ctx, showStress, showTrail) {
  const { w, h } = sim;
  if (showTrail) {
    ctx.fillStyle = 'rgba(10,12,18,0.28)';
    ctx.fillRect(0, 0, w, h);
  } else {
    ctx.fillStyle = '#0a0c12';
    ctx.fillRect(0, 0, w, h);
  }

  // Background grid
  ctx.strokeStyle = CONFIG.colors.grid;
  ctx.lineWidth = 1;
  ctx.beginPath();
  for (let x = 0; x < w; x += 48) { ctx.moveTo(x + 0.5, 0); ctx.lineTo(x + 0.5, h); }
  for (let y = 0; y < h; y += 48) { ctx.moveTo(0, y + 0.5); ctx.lineTo(w, y + 0.5); }
  ctx.stroke();

  // Static geometry
  drawStatic(sim, ctx);

  // Objects
  for (const obj of sim.objects) drawObject(sim, ctx, obj, showStress);

  // Particles (tear sparks)
  drawParticles(ctx);

  // Grab highlight
  if (sim.grabPoint) {
    const g = sim.grabPoint;
    ctx.beginPath();
    ctx.arc(g.x, g.y, 12, 0, Math.PI * 2);
    ctx.strokeStyle = CONFIG.colors.grabbed;
    ctx.lineWidth = 2;
    ctx.stroke();
  }

  // Preview while spawning
  if (sim.preview) drawPreview(ctx, sim.preview);

  // Cut trail
  if (sim.cutTrail.length > 1) {
    ctx.beginPath();
    ctx.moveTo(sim.cutTrail[0].x, sim.cutTrail[0].y);
    for (let i = 1; i < sim.cutTrail.length; i++) ctx.lineTo(sim.cutTrail[i].x, sim.cutTrail[i].y);
    ctx.strokeStyle = 'rgba(255,90,90,0.7)';
    ctx.lineWidth = 2;
    ctx.stroke();
  }
}

function drawStatic(sim, ctx) {
  // Floor
  ctx.fillStyle = CONFIG.colors.floor;
  ctx.fillRect(sim.wallL, sim.floorY, sim.wallR - sim.wallL, sim.h - sim.floorY);
  ctx.strokeStyle = '#3a4a72';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.moveTo(sim.wallL, sim.floorY + 0.5);
  ctx.lineTo(sim.wallR, sim.floorY + 0.5);
  ctx.stroke();
  // Walls
  ctx.fillStyle = CONFIG.colors.wall;
  ctx.fillRect(sim.wallL - 12, 0, 12, sim.floorY);
  ctx.fillRect(sim.wallR, 0, 12, sim.floorY);

  // Obstacles
  for (const s of sim.staticShapes) {
    if (s.type === 'circle') {
      const grd = ctx.createRadialGradient(s.x - s.r * 0.3, s.y - s.r * 0.3, s.r * 0.2, s.x, s.y, s.r);
      grd.addColorStop(0, '#3a4668');
      grd.addColorStop(1, '#1c2438');
      ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
      ctx.fillStyle = grd; ctx.fill();
      ctx.strokeStyle = '#4a5a82'; ctx.lineWidth = 2; ctx.stroke();
    } else if (s.type === 'box') {
      ctx.fillStyle = '#26324f';
      ctx.fillRect(s.cx - s.w / 2, s.cy - s.h / 2, s.w, s.h);
      ctx.strokeStyle = '#4a5a82'; ctx.lineWidth = 2;
      ctx.strokeRect(s.cx - s.w / 2 + 0.5, s.cy - s.h / 2 + 0.5, s.w, s.h);
    }
  }
}

function drawObject(sim, ctx, obj, showStress) {
  if (obj.type === 'rope') {
    ctx.lineCap = 'round';
    // constraints
    ctx.lineWidth = 3;
    for (const c of obj.constraints) {
      if (c.broken) continue;
      ctx.strokeStyle = showStress ? stressColor(c.stretch) : CONFIG.colors.rope;
      ctx.globalAlpha = showStress ? 0.9 : 0.85;
      ctx.beginPath();
      ctx.moveTo(c.a.x, c.a.y); ctx.lineTo(c.b.x, c.b.y);
      ctx.stroke();
    }
    ctx.globalAlpha = 1;
    // points
    for (const p of obj.points) {
      ctx.beginPath();
      ctx.arc(p.x, p.y, p.pinned ? 4 : 2.6, 0, Math.PI * 2);
      ctx.fillStyle = p.pinned ? CONFIG.colors.pinned : '#ffe08a';
      ctx.fill();
    }
  } else if (obj.type === 'cloth') {
    // Filled mesh (two triangles per quad) colored by stress
    const { grid, rows, cols, struct } = obj;
    if (showStress) {
      for (let r = 0; r < rows - 1; r++) {
        for (let c = 0; c < cols - 1; c++) {
          const p00 = grid[r][c], p01 = grid[r][c + 1];
          const p10 = grid[r + 1][c], p11 = grid[r + 1][c + 1];
          const s0 = stressColor(Math.max(p00.stretch || 1, p01.stretch || 1, p10.stretch || 1, p11.stretch || 1));
          ctx.fillStyle = s0;
          tri(ctx, p00, p01, p10);
          tri(ctx, p01, p11, p10);
        }
      }
    } else {
      // subtle shaded mesh
      for (let r = 0; r < rows - 1; r++) {
        for (let c = 0; c < cols - 1; c++) {
          const p00 = grid[r][c], p01 = grid[r][c + 1];
          const p10 = grid[r + 1][c], p11 = grid[r + 1][c + 1];
          const shade = 0.55 + 0.45 * (r / (rows - 1));
          ctx.fillStyle = `rgba(68,204,255,${shade * 0.35})`;
          tri(ctx, p00, p01, p10);
          tri(ctx, p01, p11, p10);
        }
      }
    }
    // structural lines
    ctx.lineWidth = 1.4;
    for (const c of struct) {
      if (c.broken) continue;
      ctx.strokeStyle = showStress ? stressColor(c.stretch) : 'rgba(140,215,255,0.75)';
      ctx.beginPath(); ctx.moveTo(c.a.x, c.a.y); ctx.lineTo(c.b.x, c.b.y); ctx.stroke();
    }
    if (showStress) {
      ctx.lineWidth = 1;
      for (const c of obj.shear) {
        if (c.broken) continue;
        ctx.strokeStyle = stressColor(c.stretch);
        ctx.globalAlpha = 0.4;
        ctx.beginPath(); ctx.moveTo(c.a.x, c.a.y); ctx.lineTo(c.b.x, c.b.y); ctx.stroke();
        ctx.globalAlpha = 1;
      }
    }
    // pinned markers
    for (let r = 0; r < rows; r++)
      for (let c = 0; c < cols; c++)
        if (grid[r][c].pinned) {
          ctx.fillStyle = CONFIG.colors.clothPin;
          ctx.fillRect(grid[r][c].x - 3, grid[r][c].y - 3, 6, 6);
        }
  } else if (obj.type === 'softbody') {
    const ring = obj.points.slice(1);
    // filled polygon
    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();
    const grd = ctx.createRadialGradient(
      obj.points[0].x, obj.points[0].y, ring[0].radius * 0.2,
      obj.points[0].x, obj.points[0].y, obj.size * 1.3);
    grd.addColorStop(0, '#ffa8cc');
    grd.addColorStop(1, 'rgba(255,102,170,0.55)');
    ctx.fillStyle = grd; ctx.fill();
    ctx.strokeStyle = CONFIG.colors.softbody; ctx.lineWidth = 2.5; ctx.stroke();
    // internal braces faint
    ctx.strokeStyle = CONFIG.colors.softLine; ctx.lineWidth = 1;
    for (const c of obj.constraints) {
      if (c.broken || c.type === undefined) continue;
    }
  } else if (obj.type === 'ball') {
    const p = obj.point;
    const grd = ctx.createRadialGradient(p.x - p.radius * 0.3, p.y - p.radius * 0.3, 2, p.x, p.y, p.radius);
    grd.addColorStop(0, '#ccffdd');
    grd.addColorStop(1, CONFIG.colors.ball);
    ctx.beginPath(); ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
    ctx.fillStyle = grd; ctx.fill();
    ctx.strokeStyle = '#2a8f4a'; ctx.lineWidth = 2; ctx.stroke();
  }
}

function tri(ctx, a, b, c) {
  ctx.beginPath();
  ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(c.x, c.y); ctx.closePath();
  ctx.fill();
}

function drawParticles(ctx) {
  for (const pt of sim.particles) {
    ctx.globalAlpha = Math.max(0, pt.life);
    ctx.fillStyle = '#ffcc66';
    ctx.fillRect(pt.x - 1.5, pt.y - 1.5, 3, 3);
  }
  ctx.globalAlpha = 1;
}

function drawPreview(ctx, pv) {
  ctx.setLineDash([5, 5]);
  ctx.strokeStyle = 'rgba(200,220,255,0.5)';
  ctx.lineWidth = 1.5;
  if (pv.type === 'rope') {
    ctx.beginPath(); ctx.moveTo(pv.x0, pv.y0); ctx.lineTo(pv.x1, pv.y1); ctx.stroke();
    const dx = pv.x1 - pv.x0, dy = pv.y1 - pv.y0, len = Math.hypot(dx, dy) || 1;
    const n = Math.max(4, Math.round(len / 18));
    for (let i = 0; i <= n; i++) {
      const t = i / n;
      ctx.beginPath(); ctx.arc(pv.x0 + dx * t, pv.y0 + dy * t, 2.5, 0, Math.PI * 2);
      ctx.fillStyle = i === 0 ? '#fff' : 'rgba(255,224,138,0.7)'; ctx.fill();
    }
  } else if (pv.type === 'cloth') {
    ctx.strokeRect(pv.x, pv.y, pv.w, pv.h);
    ctx.fillStyle = 'rgba(68,204,255,0.12)';
    ctx.fillRect(pv.x, pv.y, pv.w, pv.h);
  } else if (pv.type === 'softbody') {
    ctx.beginPath(); ctx.arc(pv.cx, pv.cy, pv.size, 0, Math.PI * 2);
    ctx.stroke(); ctx.fillStyle = 'rgba(255,102,170,0.12)'; ctx.fill();
  } else if (pv.type === 'ball') {
    ctx.beginPath(); ctx.arc(pv.x, pv.y, pv.r, 0, Math.PI * 2); ctx.stroke();
  }
  ctx.setLineDash([]);
}

/* =========================================================================
   SECTION 7 — INTERACTION
   ========================================================================= */
let tool = 'grab';
let pinPattern = 'corners';
let paused = false;

function setupInteraction(canvas, sim) {
  const getPos = (e) => {
    const rect = canvas.getBoundingClientRect();
    return { x: (e.clientX - rect.left), y: (e.clientY - rect.top) };
  };

  let mouse = { x: 0, y: 0 };
  let samples = [];
  let grabPoint = null;
  let cutting = false;
  let cutTrail = [];
  let lastSample = { x: 0, y: 0, t: 0 };

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

  canvas.addEventListener('mousedown', (e) => {
    const p = getPos(e);
    mouse = p;
    const now = performance.now();
    samples = [{ x: p.x, y: p.y, t: now }];
    lastSample = { x: p.x, y: p.y, t: now };

    // Right button => start cutting
    if (e.button === 2) {
      cutting = true; cutTrail = [p];
      return;
    }

    // Pin tool: toggle pin under cursor
    if (tool === 'pin' || (e.ctrlKey || e.metaKey)) {
      const near = nearestPoint(sim, p.x, p.y, CONFIG.grabRadius * 1.4);
      if (near) { near.pinned = !near.pinned; near.massInv = near.pinned ? 0 : 1 / near.mass; spawnTear(sim, near.x, near.y); }
      return;
    }

    // Grab: nearest point within radius
    const np = nearestPoint(sim, p.x, p.y, CONFIG.grabRadius);
    if (np) {
      grabPoint = np;
      np.grabbed = true;
      np.x = p.x; np.y = p.y; np.px = p.x; np.py = p.y;
      return;
    }

    // Spawn drag
    if (tool === 'rope' || tool === 'cloth' || tool === 'softbody' || tool === 'ball') {
      if (tool === 'rope') sim.dragging = { type: 'rope', x0: p.x, y0: p.y, x1: p.x, y1: p.y };
      else if (tool === 'cloth') sim.dragging = { type: 'cloth', x: p.x, y: p.y, w: 0, h: 0 };
      else if (tool === 'softbody') sim.dragging = { type: 'softbody', cx: p.x, cy: p.y, size: 0 };
      else if (tool === 'ball') sim.dragging = { type: 'ball', x: p.x, y: p.y, r: 16 };
    }
  });

  canvas.addEventListener('mousemove', (e) => {
    const p = getPos(e);
    mouse = p;
    const now = performance.now();

    if (grabPoint) {
      grabPoint.x = p.x; grabPoint.y = p.y;
      samples.push({ x: p.x, y: p.y, t: now });
      if (samples.length > 12) samples.shift();
      lastSample = { x: p.x, y: p.y, t: now };
    } else if (cutting) {
      cutTrail.push(p);
      if (cutTrail.length > 40) cutTrail.shift();
      cutAlong(sim, lastSample, p);
      lastSample = { x: p.x, y: p.y, t: now };
    } else if (sim.dragging) {
      if (sim.dragging.type === 'rope') { sim.dragging.x1 = p.x; sim.dragging.y1 = p.y; }
      else if (sim.dragging.type === 'cloth') {
        sim.dragging.w = p.x - sim.dragging.x; sim.dragging.h = p.y - sim.dragging.y;
        if (e.shiftKey) { const s = Math.max(Math.abs(sim.dragging.w), Math.abs(sim.dragging.h)); sim.dragging.w = Math.sign(sim.dragging.w) * s; sim.dragging.h = Math.sign(sim.dragging.h) * s; }
      } else if (sim.dragging.type === 'softbody') { sim.dragging.size = Math.hypot(p.x - sim.dragging.cx, p.y - sim.dragging.cy); }
    }
  });

  const endInteraction = () => {
    if (grabPoint) {
      // Throw: estimate velocity from recent samples
      let vx = 0, vy = 0;
      const recent = samples.slice(-6);
      if (recent.length >= 2) {
        const first = recent[0], last = recent[recent.length - 1];
        const dt = (last.t - first.t) / 1000;
        if (dt > 1e-4) {
          vx = (last.x - first.x) / dt;
          vy = (last.y - first.y) / dt;
        }
      }
      const g = grabPoint;
      g.grabbed = false;
      const sp = Math.hypot(vx, vy);
      if (sp > 4000) { const s = 4000 / sp; vx *= s; vy *= s; }   // clamp throw speed
      g.release(vx, vy);
      grabPoint = null;
    }
    cutting = false; cutTrail = [];
    dragging = null;
  };
  canvas.addEventListener('mouseup', endInteraction);
  canvas.addEventListener('mouseleave', endInteraction);

  // Expose for probing
  sim._cutAlong = cutAlong;
  sim._togglePin = (x, y) => {
    const np = nearestPoint(sim, x, y, CONFIG.grabRadius * 1.4);
    if (np) { np.pinned = !np.pinned; np.massInv = np.pinned ? 0 : 1 / np.mass; }
  };
  sim._nearestPoint = (x, y) => nearestPoint(sim, x, y, CONFIG.grabRadius);
  sim._grab = (x, y) => {
    const np = nearestPoint(sim, x, y, CONFIG.grabRadius);
    if (np) { grabPoint = np; np.grabbed = true; np.x = x; np.y = y; np.px = x; np.py = y; return true; }
    return false;
  };
  sim._release = () => endInteraction();
}

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

// Break any constraint whose segment passes within tearRadius of the mouse path.
function cutAlong(sim, a, b) {
  const rr = CONFIG.tearRadius;
  const r2 = rr * rr;
  for (const c of sim.constraints) {
    if (c.broken) continue;
    if (segDist2(c.a.x, c.a.y, c.b.x, c.b.y, a.x, a.y, b.x, b.y) < r2) {
      c.broken = true;
      spawnTear(sim, (c.a.x + c.b.x) / 2, (c.a.y + c.b.y) / 2);
    }
  }
}

// squared distance from point (px,py) to segment (ax,ay)-(bx,by)
function segDist2(px, py, ax, ay, bx, by) {
  const dx = bx - ax, dy = by - ay;
  const l2 = dx * dx + dy * dy;
  let t = l2 > 0 ? ((px - ax) * dx + (py - ay) * dy) / l2 : 0;
  t = Math.max(0, Math.min(1, t));
  const cx = ax + t * dx, cy = ay + t * dy;
  const ex = px - cx, ey = py - cy;
  return ex * ex + ey * ey;
}

// Tiny spark burst for tear feedback
function spawnTear(sim, x, y) {
  for (let i = 0; i < 5; i++) {
    const ang = Math.random() * Math.PI * 2;
    const sp = 40 + Math.random() * 120;
    sim.particles.push({ x, y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp, life: 1 });
  }
}

/* =========================================================================
   SECTION 8 — MAIN LOOP + CONTROLS
   ========================================================================= */
function main() {
  const canvas = document.getElementById('c');
  const ctx = canvas.getContext('2d');
  let dpr = Math.min(window.devicePixelRatio || 1, 2);

  function resize() {
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    canvas.width = Math.floor(window.innerWidth * dpr);
    canvas.height = Math.floor(window.innerHeight * dpr);
    canvas.style.width = window.innerWidth + 'px';
    canvas.style.height = window.innerHeight + 'px';
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    if (sim) { sim.w = window.innerWidth; sim.h = window.innerHeight; sim.floorY = window.innerHeight - 24; sim.wallR = window.innerWidth - 24; }
  }
  window.addEventListener('resize', resize);

  let sim = makeSim(window.innerWidth, window.innerHeight);
  window._liveSim = sim;
  resize();
  setupInteraction(canvas, sim);

  // --- Controls ---
  const $ = (id) => document.getElementById(id);
  $('pauseBtn').addEventListener('click', () => togglePause());
  $('stepBtn').addEventListener('click', () => { if (paused) { step(sim); } });
  $('resetBtn').addEventListener('click', () => { sim = makeSim(window.innerWidth, window.innerHeight); window._liveSim = sim; resize(); setupInteraction(canvas, sim); populate(sim); });

  $('gravity').addEventListener('input', (e) => { CONFIG.gravity = +e.target.value; });
  $('iters').addEventListener('input', (e) => { CONFIG.iterations = +e.target.value; });
  $('wind').addEventListener('input', (e) => { CONFIG.wind = +e.target.value; });
  $('pinPattern').addEventListener('change', (e) => { pinPattern = e.target.value; });
  $('stress').addEventListener('change', (e) => { CONFIG.showStress = e.target.checked; });
  $('trail').addEventListener('change', (e) => { CONFIG.showTrail = e.target.checked; });

  document.querySelectorAll('#toolbar [data-tool]').forEach((btn) => {
    btn.addEventListener('click', () => {
      tool = btn.dataset.tool;
      document.querySelectorAll('#toolbar [data-tool]').forEach(b => b.classList.toggle('active', b === btn));
      canvas.style.cursor = (tool === 'grab') ? 'grab' : (tool === 'pin') ? 'pointer' : 'crosshair';
    });
  });

  // Keyboard
  window.addEventListener('keydown', (e) => {
    if (e.code === 'Space') { e.preventDefault(); togglePause(); }
    else if (e.code === 'ArrowRight' && paused) { step(sim); }
    else if (e.code === 'ArrowLeft' && paused) { /* step back not supported; noop */ }
  });

  function togglePause() {
    paused = !paused;
    $('pauseBtn').textContent = paused ? 'Play' : 'Pause';
    $('pauseBtn').classList.toggle('active', paused);
    $('pausedState').textContent = paused ? 'yes' : 'no';
  }

  // --- Timing ---
  let last = performance.now();
  let accumulator = 0;
  let fps = 60, fpsTimer = 0, frameCount = 0;
  let ptime = 0;

  function loop(now) {
    let delta = (now - last) / 1000;
    last = now;
    if (delta > CONFIG.maxDelta) delta = CONFIG.maxDelta;   // tab-switch guard

    // FPS
    frameCount++; fpsTimer += delta;
    if (fpsTimer >= 0.25) { fps = frameCount / fpsTimer; frameCount = 0; fpsTimer = 0; }

    if (!paused) {
      const t0 = performance.now();
      accumulator += delta;
      while (accumulator >= CONFIG.timestep) {
        step(sim);
        accumulator -= CONFIG.timestep;
      }
      ptime = performance.now() - t0;
    } else {
      // still integrate particles + grab follow so cut/trail stay alive
    }

    // Update particles
    for (const pt of sim.particles) {
      pt.x += pt.vx * CONFIG.timestep; pt.y += pt.vy * CONFIG.timestep;
      pt.vy += CONFIG.gravity * 0.3 * CONFIG.timestep;
      pt.life -= CONFIG.timestep * 2.2;
    }
    sim.particles = sim.particles.filter(p => p.life > 0);

    // Preview follows drag state
    sim.preview = sim.dragging;

    render(sim, ctx, CONFIG.showStress, CONFIG.showTrail);

    // Readouts
    $('fps').textContent = fps.toFixed(0);
    $('ptime').textContent = ptime.toFixed(2);
    $('stats').textContent = sim.points.length;
    $('conns').textContent = sim.constraints.filter(c => !c.broken).length;

    requestAnimationFrame(loop);
  }

  // Populate with a starter scene BEFORE starting the loop
  populate(sim);

  requestAnimationFrame(loop);
}

function populate(sim) {
  spawnRope(sim, sim.w * 0.30, sim.h * 0.28, sim.w * 0.30, sim.h * 0.28 + 160);
  spawnCloth(sim, sim.w * 0.55, sim.h * 0.26, 220, 180);
  spawnSoftBody(sim, sim.w * 0.28, sim.h * 0.5, 44, 'blob');
  spawnBall(sim, sim.w * 0.72, sim.h * 0.32);
}

/* =========================================================================
   SECTION 9 — WINDOW EXPOSURE (headless probing)
   ========================================================================= */
Object.defineProperty(window, 'sim', {
  get() { return this._simRef; },
  set(v) { this._simRef = v; }
});
window.CONFIG = CONFIG;
window.__makeSim = makeSim;
window.__spawn = {
  rope: (x0, y0, x1, y1) => spawnRope(window.sim, x0, y0, x1, y1),
  cloth: (x, y, w, h) => spawnCloth(window.sim, x, y, w, h),
  softbody: (cx, cy, size, kind) => spawnSoftBody(window.sim, cx, cy, size, kind),
  ball: (x, y) => spawnBall(window.sim, x, y),
};

// Kick off and expose the live sim for headless probing.
main();
window.sim = window._liveSim;
</script>
</body>
</html>