← Physics Playground results

Physics Playground

Qwen 3.6 27b 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 36,220 bytes · SHA-256 06c1ad0dd09b
<!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; }
body { background: #1a1a2e; overflow: hidden; font-family: 'Segoe UI', system-ui, sans-serif; color: #eee; user-select: none; }
canvas { display: block; cursor: crosshair; }

/* Toolbar */
#toolbar {
  position: fixed; top: 0; left: 0; right: 0; height: 48px;
  background: rgba(16, 16, 32, 0.92); backdrop-filter: blur(8px);
  display: flex; align-items: center; padding: 0 12px; gap: 6px;
  border-bottom: 1px solid rgba(255,255,255,0.08); z-index: 10;
}
#toolbar .sep { width: 1px; height: 28px; background: rgba(255,255,255,0.1); margin: 0 4px; }
.tb-btn {
  padding: 6px 14px; border: 1px solid rgba(255,255,255,0.12); border-radius: 6px;
  background: rgba(255,255,255,0.05); color: #ccc; font-size: 13px; cursor: pointer;
  transition: all 0.15s; white-space: nowrap;
}
.tb-btn:hover { background: rgba(255,255,255,0.12); color: #fff; }
.tb-btn.active { background: rgba(100,140,255,0.25); border-color: rgba(100,140,255,0.5); color: #8ab4ff; }
.tb-label { font-size: 11px; color: #777; margin-right: 2px; }

/* Sliders */
.slider-group { display: flex; align-items: center; gap: 6px; }
.slider-group label { font-size: 11px; color: #888; white-space: nowrap; }
.slider-group input[type=range] { width: 80px; height: 4px; accent-color: #5a7fff; cursor: pointer; }

/* HUD */
#hud {
  position: fixed; bottom: 12px; left: 12px; font-size: 11px; color: rgba(255,255,255,0.4);
  font-family: 'Courier New', monospace; pointer-events: none; z-index: 10;
}

/* Help overlay */
#help {
  position: fixed; bottom: 12px; right: 12px; font-size: 11px; color: rgba(255,255,255,0.3);
  text-align: right; pointer-events: none; z-index: 10; line-height: 1.6;
}

/* Pin mode indicator */
#pinMode { font-size: 11px; color: #ff9f43; }
</style>
</head>
<body>

<div id="toolbar">
  <button class="tb-btn active" data-tool="rope">Rope</button>
  <button class="tb-btn" data-tool="cloth">Cloth</button>
  <button class="tb-btn" data-tool="softbody">Soft Body</button>
  <button class="tb-btn" data-tool="ball">Ball</button>
  <div class="sep"></div>
  <button class="tb-btn" id="btnPause">⏸ Pause</button>
  <button class="tb-btn" id="btnStep">⏭ Step</button>
  <button class="tb-btn" id="btnReset">↺ Reset</button>
  <div class="sep"></div>
  <div class="slider-group">
    <label>Gravity</label>
    <input type="range" id="slGravity" min="0" max="2000" step="10" value="980">
  </div>
  <div class="slider-group">
    <label>Damping</label>
    <input type="range" id="slDamping" min="900" max="1000" step="1" value="985">
  </div>
  <div class="slider-group">
    <label>Iterations</label>
    <input type="range" id="slIter" min="1" max="20" step="1" value="6">
  </div>
  <div class="sep"></div>
  <button class="tb-btn" id="btnStress">Stress</button>
  <span id="pinMode"></span>
</div>

<canvas id="canvas"></canvas>
<div id="hud"></div>
<div id="help">
  Left-drag: spawn / grab<br>
  Right-drag: tear constraints<br>
  Shift+click: pin/unpin point<br>
  Space: pause · ← →: step
</div>

<script>
// ============================================================
// TUNABLE CONSTANTS
// ============================================================
const CFG = {
  gravity: 980,           // pixels/s² downward
  damping: 0.985,         // velocity multiplier per step (1 - friction)
  iterations: 6,          // constraint relaxation passes per physics step
  timestep: 1 / 60,       // fixed physics dt in seconds
  maxFrameDelta: 0.2,     // clamp accumulator to prevent explosion on tab-switch
  grabRadius: 15,         // pixels — snap to nearest point within this radius
  tearRadius: 12,         // pixels — remove constraints whose midpoint is within this of the cut line
  ropeSegments: 30,       // number of segments in a spawned rope
  clothCols: 24,          // columns for cloth grid
  clothRows: 18,          // rows for cloth grid
  softBodyRadius: 60,     // radius of spawned soft body
  softBodyPoints: 16,     // points around the perimeter
  ballRadius: 20,         // radius of spawned ball
  wallRestitution: 0.15,   // velocity retained after bounce (very dissipative)
  sleepThreshold: 1.2,     // pixels/frame — below this, point "sleeps" (velocity zeroed)
  wallFriction: 0.2,       // tangential friction on walls
  staticCircleRadius: 50, // radius of static obstacle circles

  // Colors per object type (HSL base hues)
  colors: {
    rope:     { h: 210, s: 80, l: 60 },   // blue
    cloth:    { h: 340, s: 70, l: 55 },   // pink/magenta
    softbody: { h: 150, s: 70, l: 50 },   // green
    ball:     { h: 40,  s: 90, l: 60 },   // yellow/orange
  },

  stressColors: true,     // show stress visualization when enabled
};

// ============================================================
// SIMULATION STATE (exposed on window for probing)
// ============================================================
const sim = {
  points: [],
  constraints: [],
  staticCircles: [],
  staticBoxes: [],
  clothGrids: [],   // array of { grid: Point[][], cols, rows } for rendering filled mesh
  softBodyGroups: [], // array of Point[] for each spawned soft body (for grouped fill)
  paused: false,
  stepCount: 0,
};

// ============================================================
// POINT CLASS
// ============================================================
class Point {
  constructor(x, y, pinned = false) {
    this.x = x;
    this.y = y;
    this.px = x; // previous position (for Verlet)
    this.py = y;
    this.pinned = pinned;
    this.mass = 1;
    this.radius = 0; // for balls and collision
    this.type = 'default'; // rope, cloth, softbody, ball
    this._vx = 0; // cached velocity (computed on demand)
    this._vy = 0;
  }

  get vx() { return this.x - this.px; }
  get vy() { return this.y - this.py; }

  kineticEnergy() {
    const v2 = (this.x - this.x) * (this.x - this.px) + (this.y - this.py) * (this.y - this.py);
    // Actually compute properly:
    const vx = (this.x - this.px) / CFG.timestep;
    const vy = (this.y - this.py) / CFG.timestep;
    return 0.5 * this.mass * (vx * vx + vy * vy);
  }

  speed() {
    const vx = (this.x - this.px) / CFG.timestep;
    const vy = (this.y - this.py) / CFG.timestep;
    return Math.sqrt(vx * vx + vy * vy);
  }
}

// ============================================================
// CONSTRAINT CLASS
// ============================================================
class Constraint {
  constructor(p1, p2, restLength = null, stiffness = 1.0) {
    this.p1 = p1;
    this.p2 = p2;
    if (restLength === null) {
      const dx = p2.x - p1.x;
      const dy = p2.y - p1.y;
      restLength = Math.sqrt(dx * dx + dy * dy);
    }
    this.restLength = Math.max(restLength, 0.001); // guard zero-length
    this.stiffness = stiffness;
    this.broken = false;
    this.type = 'default';
  }

  get stretchRatio() {
    const dx = this.p2.x - this.p1.x;
    const dy = this.p2.y - this.p1.y;
    const dist = Math.sqrt(dx * dx + dy * dy);
    return dist / this.restLength;
  }
}

// ============================================================
// PHYSICS ENGINE
// ============================================================
function integrateVerlet(dt) {
  const dtSq = dt * dt;
  for (const p of sim.points) {
    if (p.pinned) continue;
    const vx = (p.x - p.px) * CFG.damping;
    const vy = (p.y - p.py) * CFG.damping;
    p.px = p.x;
    p.py = p.y;
    p.x += vx;
    p.y += vy + CFG.gravity * dtSq;
  }
}

function solveConstraints() {
  const restMult = CFG.wallRestitution; // velocity retained after bounce

  for (let iter = 0; iter < CFG.iterations; iter++) {
    // Distance constraints — with slight damping per iteration to dissipate energy
    for (const c of sim.constraints) {
      if (c.broken) continue;
      const dx = c.p2.x - c.p1.x;
      const dy = c.p2.y - c.p1.y;
      const distSq = dx * dx + dy * dy;
      if (distSq < 0.0001 || distSq > 1e8) continue; // guard degenerate cases
      const dist = Math.sqrt(distSq);
      const diff = (c.restLength - dist) / dist;
      const correction = diff * 0.5 * c.stiffness;

      if (!c.p1.pinned && !c.p2.pinned) {
        // Apply slight damping to relative velocity along constraint axis
        const nx = dx / dist, ny = dy / dist;
        const relVx = (c.p2.x - c.p2.px) - (c.p1.x - c.p1.px);
        const relVy = (c.p2.y - c.p2.py) - (c.p1.y - c.p1.py);
        const relVelAlong = relVx * nx + relVy * ny;
        const dampFactor = 0.985; // slight damping along constraint

        c.p1.x -= dx * correction + nx * relVelAlong * (1 - dampFactor) * 0.5;
        c.p1.y -= dy * correction + ny * relVelAlong * (1 - dampFactor) * 0.5;
        c.p2.x += dx * correction - nx * relVelAlong * (1 - dampFactor) * 0.5;
        c.p2.y += dy * correction - ny * relVelAlong * (1 - dampFactor) * 0.5;
      } else if (c.p1.pinned && !c.p2.pinned) {
        c.p2.x += dx * correction;
        c.p2.y += dy * correction;
      } else if (!c.p1.pinned && c.p2.pinned) {
        c.p1.x -= dx * correction;
        c.p1.y -= dy * correction;
      }
    }

    // Wall collisions — position correction + velocity damping via prev-pos manipulation
    for (const p of sim.points) {
      if (p.pinned) continue;
      const r = p.radius || 0;
      const vx = p.x - p.px;
      const vy = p.y - p.py;

      // Floor
      if (p.y + r > canvas.height) {
        p.y = canvas.height - r;
        // Dampen normal velocity, apply tangential friction
        p.py = p.y + Math.abs(vy) * restMult;
        p.px = p.x - vx * (1 - CFG.wallFriction);
      }

      // Ceiling
      if (p.y - r < 0) {
        p.y = r;
        p.py = p.y - Math.abs(vy) * restMult;
      }

      // Left wall
      if (p.x - r < 0) {
        p.x = r;
        p.px = p.x + Math.abs(vx) * restMult;
        p.py = p.y - vy * (1 - CFG.wallFriction);
      }

      // Right wall
      if (p.x + r > canvas.width) {
        p.x = canvas.width - r;
        p.px = p.x - Math.abs(vx) * restMult;
        p.py = p.y - vy * (1 - CFG.wallFriction);
      }

      // Static circles — push out + dampen velocity
      for (const sc of sim.staticCircles) {
        const cdx = p.x - sc.x;
        const cdy = p.y - sc.y;
        const cdistSq = cdx * cdx + cdy * cdy;
        const minDist = sc.radius + r;
        if (cdistSq < minDist * minDist && cdistSq > 0.0001) {
          const cdist = Math.sqrt(cdistSq);
          const nx = cdx / cdist;
          const ny = cdy / cdist;
          const overlap = minDist - cdist;

          // Position correction
          p.x += nx * overlap;
          p.y += ny * overlap;

          // Velocity damping: decompose into normal and tangential
          const cvx = p.x - p.px;
          const cvy = p.y - p.py;
          const nVel = cvx * nx + cvy * ny;

          if (nVel < 0) {
            // Moving toward obstacle — reflect with restitution + friction
            const tx = -ny, ty = nx;
            const tVel = cvx * tx + cvy * ty;

            p.px = p.x - (-nVel * restMult * nx + tVel * (1 - CFG.wallFriction) * tx);
            p.py = p.y - (-nVel * restMult * ny + tVel * (1 - CFG.wallFriction) * ty);
          }
        }
      }

      // Static boxes — SAT-style push out
      for (const sb of sim.staticBoxes) {
        const cx = Math.max(sb.x, Math.min(p.x, sb.x + sb.w));
        const cy = Math.max(sb.y, Math.min(p.y, sb.y + sb.h));
        const bdx = p.x - cx;
        const bdy = p.y - cy;
        const bdistSq = bdx * bdx + bdy * bdy;

        if (bdistSq < r * r && bdistSq > 0.001) {
          const bdist = Math.sqrt(bdistSq);
          const nx = bdx / bdist;
          const ny = bdy / bdist;
          p.x += nx * (r - bdist);
          p.y += ny * (r - bdist);

          // Dampen velocity on collision
          const cvx = p.x - p.px;
          const cvy = p.y - p.py;
          const nVel = cvx * nx + cvy * ny;
          if (nVel < 0) {
            p.px = p.x - cvx * restMult;
            p.py = p.y - cvy * restMult;
          }
        } else if (bdistSq <= 0.001) {
          // Inside box — push out along shortest axis
          const dLeft = p.x - sb.x;
          const dRight = sb.x + sb.w - p.x;
          const dTop = p.y - sb.y;
          const dBottom = sb.y + sb.h - p.y;
          const minD = Math.min(dLeft, dRight, dTop, dBottom);

          if (minD === dLeft) { p.x = sb.x - r; p.px = p.x + (p.x - p.px) * restMult; }
          else if (minD === dRight) { p.x = sb.x + sb.w + r; p.px = p.x + (p.x - p.px) * restMult; }
          else if (minD === dTop) { p.y = sb.y - r; p.py = p.y + (p.y - p.py) * restMult; }
          else { p.y = sb.y + sb.h + r; p.py = p.y + (p.y - p.py) * restMult; }
        }
      }
    }

    // Grab constraint: force grabbed point to follow mouse
    if (grabbedPoint && grabActive) {
      grabbedPoint.x = mouseX;
      grabbedPoint.y = mouseY;
    }
  }
}

function clampPositions() {
  // Final position clamp + sleep detection — ensures no point escapes bounds and kills micro-oscillations
  for (const p of sim.points) {
    if (p.pinned) continue;
    const r = p.radius || 0;

    // Sleep: zero out velocity below threshold to kill jitter
    const vx = p.x - p.px;
    const vy = p.y - p.py;
    const speed = Math.sqrt(vx * vx + vy * vy);
    if (speed < CFG.sleepThreshold) {
      p.px = p.x;
      p.py = p.y;
    }

    // Clamp to bounds
    if (p.y + r > canvas.height) {
      p.y = canvas.height - r;
      if ((p.y - p.py) < 0) p.py = p.y;
    }
    if (p.x - r < 0) { p.x = r; if ((p.x - p.px) > 0) p.px = p.x; }
    if (p.x + r > canvas.width) { p.x = canvas.width - r; if ((p.x - p.px) < 0) p.px = p.x; }
    if (p.y - r < 0) { p.y = r; if ((p.y - p.py) > 0) p.py = p.y; }
  }
}

function physicsStep() {
  integrateVerlet(CFG.timestep);
  solveConstraints();
  clampPositions();
  sim.stepCount++;
}

// ============================================================
// SPAWNING FUNCTIONS
// ============================================================
function spawnRope(x1, y1, x2, y2) {
  const n = CFG.ropeSegments;
  const dx = (x2 - x1) / n;
  const dy = (y2 - y1) / n;
  const pts = [];
  for (let i = 0; i <= n; i++) {
    const p = new Point(x1 + dx * i, y1 + dy * i);
    p.type = 'rope';
    if (i === 0) p.pinned = true;
    sim.points.push(p);
    pts.push(p);
  }
  for (let i = 0; i < n; i++) {
    const c = new Constraint(pts[i], pts[i + 1]);
    c.type = 'rope';
    sim.constraints.push(c);
  }
}

function spawnCloth(cx, cy, cols, rows) {
  cols = cols || CFG.clothCols;
  rows = rows || CFG.clothRows;
  const spacing = 12;
  const w = (cols - 1) * spacing;
  const h = (rows - 1) * spacing;
  const startX = cx - w / 2;
  const startY = cy - h / 2;

  const grid = [];
  for (let r = 0; r < rows; r++) {
    grid[r] = [];
    for (let c = 0; c < cols; c++) {
      const p = new Point(startX + c * spacing, startY + r * spacing);
      p.type = 'cloth';
      // Pin top edge: both corners or full edge
      if (r === 0 && (c === 0 || c === cols - 1)) p.pinned = true;
      sim.points.push(p);
      grid[r][c] = p;
    }
  }

  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      // Structural constraints (horizontal + vertical)
      if (c < cols - 1) {
        const con = new Constraint(grid[r][c], grid[r][c + 1]);
        con.type = 'cloth';
        sim.constraints.push(con);
      }
      if (r < rows - 1) {
        const con = new Constraint(grid[r][c], grid[r + 1][c]);
        con.type = 'cloth';
        sim.constraints.push(con);
      }
      // Shear constraints (diagonal) — every other cell for stability
      if (r < rows - 1 && c < cols - 1) {
        const con = new Constraint(grid[r][c], grid[r + 1][c + 1]);
        con.type = 'cloth';
        sim.constraints.push(con);
      }
    }
  }

  // Store grid reference for rendering filled mesh
  sim.clothGrids.push({ grid, cols, rows });
}

function spawnSoftBody(cx, cy, radius, nPoints) {
  radius = radius || CFG.softBodyRadius;
  nPoints = nPoints || CFG.softBodyPoints;
  const pts = [];
  for (let i = 0; i < nPoints; i++) {
    const angle = (2 * Math.PI * i) / nPoints - Math.PI / 2;
    const p = new Point(cx + radius * Math.cos(angle), cy + radius * Math.sin(angle));
    p.type = 'softbody';
    sim.points.push(p);
    pts.push(p);
  }

  // Edge constraints — these define the perimeter shape
  for (let i = 0; i < nPoints; i++) {
    const con = new Constraint(pts[i], pts[(i + 1) % nPoints]);
    con.type = 'softbody';
    sim.constraints.push(con);
  }

  // Internal brace constraints — cross-braces for shape retention (not too many to avoid rigidity)
  // Only connect opposite points, with lower stiffness so the body can deform
  const halfN = Math.floor(nPoints / 2);
  for (let i = 0; i < nPoints; i++) {
    const j = (i + halfN) % nPoints;
    const con = new Constraint(pts[i], pts[j]);
    con.type = 'softbody';
    con.stiffness = 0.5; // softer internal braces allow deformation
    sim.constraints.push(con);
  }

  // A few additional braces for stability (skip +2 to avoid over-constraining)
  if (nPoints >= 8) {
    const thirdN = Math.floor(nPoints / 3);
    for (let i = 0; i < nPoints; i++) {
      const j = (i + thirdN) % nPoints;
      const con = new Constraint(pts[i], pts[j]);
      con.type = 'softbody';
      con.stiffness = 0.35; // even softer for extra braces
      sim.constraints.push(con);
    }
  }

  // Store group reference for rendering filled polygon
  sim.softBodyGroups.push([...pts]);
}

function spawnBall(x, y) {
  const p = new Point(x, y);
  p.type = 'ball';
  p.radius = CFG.ballRadius;
  sim.points.push(p);
  return p;
}

// ============================================================
// STATIC OBSTACLES
// ============================================================
function initStaticObstacles() {
  const w = canvas.width;
  const h = canvas.height;
  sim.staticCircles = [
    { x: w * 0.5, y: h * 0.65, radius: CFG.staticCircleRadius },
    { x: w * 0.25, y: h * 0.75, radius: 35 },
    { x: w * 0.75, y: h * 0.78, radius: 40 },
  ];
  sim.staticBoxes = [
    { x: w * 0.15, y: h - 60, w: 120, h: 30 },
    { x: w * 0.7, y: h - 80, w: 100, h: 40 },
  ];
}

// ============================================================
// INTERACTION STATE
// ============================================================
let currentTool = 'rope';
let grabActive = false;
let grabbedPoint = null;
let tearActive = false;
let mouseX = 0, mouseY = 0;
let spawnStartX = 0, spawnStartY = 0;
let isSpawning = false;

// ============================================================
// CANVAS SETUP
// ============================================================
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

function resizeCanvas() {
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
}
resizeCanvas();
window.addEventListener('resize', () => {
  const oldW = canvas.width, oldH = canvas.height;
  resizeCanvas();
  // Reposition static obstacles on resize
  initStaticObstacles();
});

// ============================================================
// RENDERING
// ============================================================
function hsl(h, s, l) { return `hsl(${h}, ${s}%, ${l}%)`; }

function getConstraintColor(c) {
  if (!CFG.stressColors) {
    const col = CFG.colors[c.type] || CFG.colors.default;
    return hsl(col.h, col.s, col.l);
  }
  // Stress visualization: green (relaxed) → yellow → red (stretched)
  const ratio = c.stretchRatio;
  let r, g, b;
  if (ratio < 1.0) {
    // Compressed — blue-ish
    const t = Math.max(0, ratio);
    r = 50 * (1 - t);
    g = 180 * t + 50 * (1 - t);
    b = 255 * (1 - t);
  } else if (ratio < 1.3) {
    // Normal to slightly stretched — green to yellow
    const t = (ratio - 1.0) / 0.3;
    r = 80 + 175 * t;
    g = 220 - 40 * t;
    b = 80;
  } else {
    // Stretched — yellow to red
    const t = Math.min(1, (ratio - 1.3) / 0.7);
    r = 255;
    g = 180 * (1 - t);
    b = 40;
  }
  return `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`;
}

function render() {
  const w = canvas.width, h = canvas.height;

  // Background with slight trail effect (motion blur)
  ctx.fillStyle = 'rgba(16, 16, 32, 0.88)';
  ctx.fillRect(0, 0, w, h);

  // Draw static obstacles — circles
  for (const sc of sim.staticCircles) {
    const grad = ctx.createRadialGradient(sc.x, sc.y, 0, sc.x, sc.y, sc.radius);
    grad.addColorStop(0, 'rgba(80, 80, 120, 0.4)');
    grad.addColorStop(1, 'rgba(60, 60, 100, 0.2)');
    ctx.beginPath();
    ctx.arc(sc.x, sc.y, sc.radius, 0, Math.PI * 2);
    ctx.fillStyle = grad;
    ctx.fill();
    ctx.strokeStyle = 'rgba(140, 140, 200, 0.5)';
    ctx.lineWidth = 2;
    ctx.stroke();
  }

  // Draw static obstacles — boxes
  for (const sb of sim.staticBoxes) {
    const grad = ctx.createLinearGradient(sb.x, sb.y, sb.x, sb.y + sb.h);
    grad.addColorStop(0, 'rgba(80, 80, 120, 0.4)');
    grad.addColorStop(1, 'rgba(60, 60, 100, 0.25)');
    ctx.fillStyle = grad;
    ctx.fillRect(sb.x, sb.y, sb.w, sb.h);
    ctx.strokeStyle = 'rgba(140, 140, 200, 0.5)';
    ctx.lineWidth = 2;
    ctx.strokeRect(sb.x, sb.y, sb.w, sb.h);
  }

  // Floor line with subtle glow
  const floorGrad = ctx.createLinearGradient(0, h - 30, 0, h);
  floorGrad.addColorStop(0, 'rgba(100, 120, 255, 0)');
  floorGrad.addColorStop(1, 'rgba(100, 120, 255, 0.15)');
  ctx.fillStyle = floorGrad;
  ctx.fillRect(0, h - 30, w, 30);

  // Draw cloth fills (filled mesh from grid data)
  for (const cg of sim.clothGrids) {
    drawClothMeshFill(cg.grid, cg.cols, cg.rows);
  }

  // Draw soft body fills (from stored groups)
  for (const group of sim.softBodyGroups) {
    drawSoftBodyGroupFill(group);
  }

  // Draw constraints as lines (non-cloth types visible; cloth uses fill above)
  for (const c of sim.constraints) {
    if (c.broken || c.type === 'cloth') continue;
    const col = getConstraintColor(c);
    ctx.beginPath();
    ctx.moveTo(c.p1.x, c.p1.y);
    ctx.lineTo(c.p2.x, c.p2.y);
    ctx.strokeStyle = col;
    ctx.lineWidth = c.type === 'softbody' ? 1.5 : 2;
    ctx.globalAlpha = 0.85;
    ctx.stroke();
    ctx.globalAlpha = 1;
  }

  // Draw cloth constraint lines (subtle grid overlay)
  for (const c of sim.constraints) {
    if (c.broken || c.type !== 'cloth') continue;
    const col = getConstraintColor(c);
    ctx.beginPath();
    ctx.moveTo(c.p1.x, c.p1.y);
    ctx.lineTo(c.p2.x, c.p2.y);
    ctx.strokeStyle = col;
    ctx.lineWidth = 0.6;
    ctx.globalAlpha = 0.35;
    ctx.stroke();
    ctx.globalAlpha = 1;
  }

  // Draw balls with gradient fill
  for (const p of sim.points) {
    if (p.type === 'ball') {
      const col = CFG.colors.ball;
      ctx.beginPath();
      ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
      const grad = ctx.createRadialGradient(
        p.x - p.radius * 0.3, p.y - p.radius * 0.3, 0,
        p.x, p.y, p.radius
      );
      grad.addColorStop(0, hsl(col.h, col.s, Math.min(85, col.l + 20)));
      grad.addColorStop(1, hsl(col.h, col.s, col.l - 10));
      ctx.fillStyle = grad;
      ctx.fill();
      ctx.strokeStyle = hsl(col.h, col.s, col.l + 15);
      ctx.lineWidth = 2;
      ctx.stroke();
    }
  }

  // Draw pinned points as bright dots
  for (const p of sim.points) {
    if (p.pinned) {
      ctx.beginPath();
      ctx.arc(p.x, p.y, 5, 0, Math.PI * 2);
      const pinGrad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, 5);
      pinGrad.addColorStop(0, '#fff');
      pinGrad.addColorStop(1, 'rgba(255,255,255,0.3)');
      ctx.fillStyle = pinGrad;
      ctx.fill();
    }
  }

  // Draw grab highlight ring
  if (grabbedPoint && grabActive) {
    const pulseR = CFG.grabRadius + 8 + Math.sin(performance.now() * 0.01) * 3;
    ctx.beginPath();
    ctx.arc(grabbedPoint.x, grabbedPoint.y, pulseR, 0, Math.PI * 2);
    ctx.strokeStyle = 'rgba(100, 200, 255, 0.7)';
    ctx.lineWidth = 2;
    ctx.stroke();

    // Crosshair lines
    const ch = 6;
    ctx.beginPath();
    ctx.moveTo(grabbedPoint.x - pulseR - ch, grabbedPoint.y);
    ctx.lineTo(grabbedPoint.x + pulseR + ch, grabbedPoint.y);
    ctx.moveTo(grabbedPoint.x, grabbedPoint.y - pulseR - ch);
    ctx.lineTo(grabbedPoint.x, grabbedPoint.y + pulseR + ch);
    ctx.strokeStyle = 'rgba(100, 200, 255, 0.3)';
    ctx.lineWidth = 1;
    ctx.stroke();
  }

  // Draw spawn preview line
  if (isSpawning && currentTool === 'rope') {
    ctx.beginPath();
    ctx.moveTo(spawnStartX, spawnStartY);
    ctx.lineTo(mouseX, mouseY);
    ctx.strokeStyle = 'rgba(100, 200, 255, 0.4)';
    ctx.lineWidth = 2;
    ctx.setLineDash([6, 4]);
    ctx.stroke();
    ctx.setLineDash([]);

    // Preview pin point
    ctx.beginPath();
    ctx.arc(spawnStartX, spawnStartY, 5, 0, Math.PI * 2);
    ctx.fillStyle = 'rgba(100, 200, 255, 0.6)';
    ctx.fill();
  }

  // Spawn preview for cloth/softbody (show bounding box/radius)
  if (isSpawning && currentTool !== 'rope' && currentTool !== 'ball') {
    const dx = mouseX - spawnStartX;
    const dy = mouseY - spawnStartY;
    ctx.strokeStyle = 'rgba(100, 200, 255, 0.3)';
    ctx.lineWidth = 1;
    ctx.setLineDash([4, 4]);
    if (currentTool === 'cloth') {
      ctx.strokeRect(spawnStartX, spawnStartY, dx, dy);
    } else if (currentTool === 'softbody') {
      const r = Math.sqrt(dx * dx + dy * dy);
      ctx.beginPath();
      ctx.arc(spawnStartX, spawnStartY, r, 0, Math.PI * 2);
      ctx.stroke();
    }
    ctx.setLineDash([]);
  }
}

function drawClothMeshFill(grid, cols, rows) {
  const col = CFG.colors.cloth;
  // Draw each quad cell as two triangles with subtle fill
  for (let r = 0; r < rows - 1; r++) {
    for (let c = 0; c < cols - 1; c++) {
      const p00 = grid[r][c];
      const p10 = grid[r][c + 1];
      const p01 = grid[r + 1][c];
      const p11 = grid[r + 1][c + 1];

      // Check if any constraint in this cell is broken — skip torn cells
      let torn = false;
      for (const con of sim.constraints) {
        if (con.type !== 'cloth' || !con.broken) continue;
        if ((con.p1 === p00 && con.p2 === p10) ||
            (con.p1 === p00 && con.p2 === p01) ||
            (con.p1 === p10 && con.p2 === p11) ||
            (con.p1 === p01 && con.p2 === p11)) {
          torn = true;
        }
      }

      if (!torn) {
        ctx.beginPath();
        ctx.moveTo(p00.x, p00.y);
        ctx.lineTo(p10.x, p10.y);
        ctx.lineTo(p11.x, p11.y);
        ctx.lineTo(p01.x, p01.y);
        ctx.closePath();
        ctx.fillStyle = hsl(col.h, col.s * 0.5, col.l - 5);
        ctx.globalAlpha = 0.2;
        ctx.fill();
      }
    }
  }
  ctx.globalAlpha = 1;
}

function drawSoftBodyGroupFill(group) {
  if (group.length < 3) return;

  // Sort by angle around centroid for proper polygon fill
  let cx = 0, cy = 0;
  for (const p of group) { cx += p.x; cy += p.y; }
  cx /= group.length;
  cy /= group.length;

  const sorted = [...group].sort((a, b) => {
    return Math.atan2(a.y - cy, a.x - cx) - Math.atan2(b.y - cy, b.x - cx);
  });

  const col = CFG.colors.softbody;
  ctx.beginPath();
  ctx.moveTo(sorted[0].x, sorted[0].y);
  for (let i = 1; i < sorted.length; i++) {
    ctx.lineTo(sorted[i].x, sorted[i].y);
  }
  ctx.closePath();

  const grad = ctx.createRadialGradient(cx - 15, cy - 15, 0, cx, cy, CFG.softBodyRadius * 1.2);
  grad.addColorStop(0, hsl(col.h, col.s * 0.7, Math.min(80, col.l + 15)));
  grad.addColorStop(1, hsl(col.h, col.s * 0.5, col.l - 10));
  ctx.fillStyle = grad;
  ctx.globalAlpha = 0.6;
  ctx.fill();

  // Edge highlight
  ctx.strokeStyle = hsl(col.h, col.s, col.l + 20);
  ctx.lineWidth = 2;
  ctx.stroke();
  ctx.globalAlpha = 1;
}

// ============================================================
// HUD UPDATE
// ============================================================
let fpsFrames = 0, fpsTime = 0, currentFPS = 60;
let physicsStepTime = 0;

function updateHUD(dt) {
  fpsFrames++;
  fpsTime += dt;
  if (fpsTime >= 0.5) {
    currentFPS = Math.round(fpsFrames / fpsTime);
    fpsFrames = 0;
    fpsTime = 0;
  }

  const totalKE = sim.points.reduce((sum, p) => sum + p.kineticEnergy(), 0);
  document.getElementById('hud').innerHTML =
    `FPS: ${currentFPS} | Physics: ${(physicsStepTime * 1000).toFixed(2)}ms | ` +
    `Points: ${sim.points.length} | Constraints: ${sim.constraints.filter(c => !c.broken).length} | ` +
    `Steps: ${sim.stepCount} | KE: ${totalKE.toFixed(0)}`;
}

// ============================================================
// INPUT HANDLING
// ============================================================
function findNearestPoint(x, y, radius) {
  let best = null;
  let bestDist = radius * radius;
  for (const p of sim.points) {
    const dx = p.x - x;
    const dy = p.y - y;
    const d2 = dx * dx + dy * dy;
    if (d2 < bestDist) {
      bestDist = d2;
      best = p;
    }
  }
  return best;
}

function tearConstraintsAlongLine(x1, y1, x2, y2) {
  // Remove constraints whose midpoint is within tearRadius of the line segment
  for (const c of sim.constraints) {
    if (c.broken) continue;
    const mx = (c.p1.x + c.p2.x) / 2;
    const my = (c.p1.y + c.p2.y) / 2;

    // Distance from point to line segment
    const dx = x2 - x1;
    const dy = y2 - y1;
    const lenSq = dx * dx + dy * dy;
    if (lenSq < 0.001) continue;

    let t = ((mx - x1) * dx + (my - y1) * dy) / lenSq;
    t = Math.max(0, Math.min(1, t));
    const px = x1 + t * dx;
    const py = y1 + t * dy;

    const distSq = (mx - px) * (mx - px) + (my - py) * (my - py);
    if (distSq < CFG.tearRadius * CFG.tearRadius) {
      c.broken = true;
    }
  }
}

let lastMouseX = 0, lastMouseY = 0;

canvas.addEventListener('mousedown', (e) => {
  const rect = canvas.getBoundingClientRect();
  mouseX = e.clientX - rect.left;
  mouseY = e.clientY - rect.top;
  lastMouseX = mouseX;
  lastMouseY = mouseY;

  if (e.button === 2 || e.shiftKey && !e.ctrlKey) {
    // Right click or shift+click for tear/pin
    if (e.shiftKey && !e.ctrlKey) {
      // Pin/unpin toggle
      const p = findNearestPoint(mouseX, mouseY, CFG.grabRadius);
      if (p) {
        p.pinned = !p.pinned;
        document.getElementById('pinMode').textContent = p.pinned ? '📌 Pinned' : '';
        setTimeout(() => { document.getElementById('pinMode').textContent = ''; }, 1000);
      }
    } else if (e.button === 2) {
      tearActive = true;
    }
    return;
  }

  // Left click: grab or spawn
  const p = findNearestPoint(mouseX, mouseY, CFG.grabRadius);
  if (p && !isSpawning) {
    grabbedPoint = p;
    grabActive = true;
    isSpawning = false;
  } else {
    // Start spawning
    isSpawning = true;
    spawnStartX = mouseX;
    spawnStartY = mouseY;

    if (currentTool === 'ball') {
      spawnBall(mouseX, mouseY);
      isSpawning = false;
    }
  }
});

canvas.addEventListener('mousemove', (e) => {
  const rect = canvas.getBoundingClientRect();
  lastMouseX = mouseX;
  lastMouseY = mouseY;
  mouseX = e.clientX - rect.left;
  mouseY = e.clientY - rect.top;

  if (tearActive) {
    tearConstraintsAlongLine(lastMouseX, lastMouseY, mouseX, mouseY);
  }
});

canvas.addEventListener('mouseup', (e) => {
  const rect = canvas.getBoundingClientRect();
  mouseX = e.clientX - rect.left;
  mouseY = e.clientY - rect.top;

  if (tearActive && e.button === 2) {
    tearActive = false;
    return;
  }

  if (grabActive && grabbedPoint) {
    // Throw with drag velocity
    const throwVx = mouseX - lastMouseX;
    const throwVy = mouseY - lastMouseY;
    grabbedPoint.px = grabbedPoint.x - throwVx * 0.5;
    grabbedPoint.py = grabbedPoint.y - throwVy * 0.5;
    grabActive = false;
    grabbedPoint = null;
    return;
  }

  if (isSpawning) {
    if (currentTool === 'rope') {
      spawnRope(spawnStartX, spawnStartY, mouseX, mouseY);
    } else if (currentTool === 'cloth') {
      const cols = Math.max(4, Math.floor(Math.abs(mouseX - spawnStartX) / 10));
      const rows = Math.max(3, Math.floor(Math.abs(mouseY - spawnStartY) / 10));
      spawnCloth((spawnStartX + mouseX) / 2, (spawnStartY + mouseY) / 2, cols, rows);
    } else if (currentTool === 'softbody') {
      const radius = Math.max(20, Math.sqrt((mouseX - spawnStartX) ** 2 + (mouseY - spawnStartY) ** 2));
      spawnSoftBody(spawnStartX, spawnStartY, radius);
    }
    isSpawning = false;
  }
});

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

// Keyboard controls
document.addEventListener('keydown', (e) => {
  if (e.code === 'Space') {
    e.preventDefault();
    sim.paused = !sim.paused;
    document.getElementById('btnPause').textContent = sim.paused ? '▶ Play' : '⏸ Pause';
    document.getElementById('btnPause').classList.toggle('active', sim.paused);
  }
  if (e.code === 'ArrowRight' && sim.paused) {
    e.preventDefault();
    physicsStep();
  }
});

// ============================================================
// TOOLBAR CONTROLS
// ============================================================
document.querySelectorAll('.tb-btn[data-tool]').forEach(btn => {
  btn.addEventListener('click', () => {
    document.querySelectorAll('.tb-btn[data-tool]').forEach(b => b.classList.remove('active'));
    btn.classList.add('active');
    currentTool = btn.dataset.tool;
  });
});

document.getElementById('btnPause').addEventListener('click', () => {
  sim.paused = !sim.paused;
  document.getElementById('btnPause').textContent = sim.paused ? '▶ Play' : '⏸ Pause';
  document.getElementById('btnPause').classList.toggle('active', sim.paused);
});

document.getElementById('btnStep').addEventListener('click', () => {
  if (sim.paused) physicsStep();
});

document.getElementById('btnReset').addEventListener('click', () => {
  sim.points = [];
  sim.constraints = [];
  sim.stepCount = 0;
  initStaticObstacles();
});

document.getElementById('slGravity').addEventListener('input', (e) => {
  CFG.gravity = parseFloat(e.target.value);
});

document.getElementById('slDamping').addEventListener('input', (e) => {
  CFG.damping = parseFloat(e.target.value) / 1000;
});

document.getElementById('slIter').addEventListener('input', (e) => {
  CFG.iterations = parseInt(e.target.value);
});

document.getElementById('btnStress').addEventListener('click', () => {
  CFG.stressColors = !CFG.stressColors;
  document.getElementById('btnStress').classList.toggle('active', CFG.stressColors);
});

// ============================================================
// MAIN LOOP
// ============================================================
let accumulator = 0;
let lastTime = performance.now();

function mainLoop(now) {
  requestAnimationFrame(mainLoop);

  let dt = (now - lastTime) / 1000;
  lastTime = now;

  // Clamp delta to prevent explosion on tab-switch
  if (dt > CFG.maxFrameDelta) dt = CFG.maxFrameDelta;

  if (!sim.paused) {
    accumulator += dt;
    const stepStart = performance.now();
    while (accumulator >= CFG.timestep) {
      physicsStep();
      accumulator -= CFG.timestep;
    }
    physicsStepTime = (performance.now() - stepStart) / 1000;
  }

  render();
  updateHUD(dt);
}

// ============================================================
// INITIALIZATION
// ============================================================
function init() {
  resizeCanvas();
  initStaticObstacles();

  // Spawn some demo objects
  spawnRope(canvas.width * 0.3, 60, canvas.width * 0.3 + 150, 200);
  spawnCloth(canvas.width * 0.7, 120, CFG.clothCols, CFG.clothRows);
  spawnSoftBody(canvas.width * 0.5, 150, CFG.softBodyRadius);
  spawnBall(canvas.width * 0.85, 100);

  // Expose sim on window for probing
  window.sim = sim;
  window.physicsStep = physicsStep;
  window.CFG = CFG;

  requestAnimationFrame(mainLoop);
}

init();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"full","sessionId":"14af1b45-ea39-4cd8-8aee-ff9a7e38d0ea","tokensIn":1898093,"tokensOut":35138,"tokensTotal":1933231,"turns":41,"toolCalls":40,"failedToolCalls":1,"timestamp":"2026-07-29T23:09:53.788Z"} -->