← Physics Playground results

Physics Playground

ling 3.0 tiny:free · 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 23,843 bytes · SHA-256 95e85d871d14
<!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:#0d0d0f;overflow:hidden;font-family:'Segoe UI',sans-serif;user-select:none}
#toolbar{position:fixed;top:0;left:0;right:0;background:rgba(10,10,12,0.92);border-bottom:1px solid rgba(255,255,255,0.08);z-index:10;display:flex;align-items:center;gap:8px;padding:6px 12px;flex-wrap:wrap;backdrop-filter:blur(10px)}
.toolbar label{color:#8899aa;font-size:11px;display:flex;align-items:center;gap:3px}
.toolbar input[type=range]{width:70px;accent-color:#FF8C00;cursor:pointer}
.toolbar select{background:#1a1a1e;color:#ccddee;border:1px solid #333;border-radius:4px;padding:2px 4px;font-size:11px;cursor:pointer}
.toolbar button{background:#1e1e24;color:#ccddee;border:1px solid #333;border-radius:4px;padding:4px 10px;font-size:11px;cursor:pointer;transition:background .15s}
.toolbar button:hover{background:#2a2a32}
.toolbar button.active{background:#2a2a3a;color:#FF8C00;border-color:#FF8C00}
.toolbar button.danger{background:#3a1a1a;color:#ff6666;border-color:#551111}
.toolbar button.danger:hover{background:#551111}
.toolbar button.green{background:#1a3a1a;color:#66ff66;border-color:#2a5a2a}
.toolbar button.green:hover{background:#2a5a2a}
#canvasWrap{position:fixed;top:0;left:0;width:100vw;height:100vh}
canvas{display:block;width:100%;height:100%}
#fps{position:fixed;bottom:8px;left:8px;color:#556677;font-size:12px;background:rgba(0,0,0,0.5);padding:3px 8px;border-radius:4px;pointer-events:none;z-index:10}
#status{position:fixed;bottom:8px;right:8px;color:#556677;font-size:11px;background:rgba(0,0,0,0.5);padding:3px 8px;border-radius:4px;pointer-events:none;z-index:10}
</style>
</head>
<body>
<div id="toolbar">
  <label>Gravity <input type="range" id="gSlider" min="0" max="20" step="0.1" value="980"></label>
  <label>Iter. <input type="range" id="iSlider" min="2" max="8" step="1" value="4"></label>
  <label>Damping <input type="range" id="dSlider" min="0.9" max="1" step="0.001" value="0.995"></label>
  <button id="btnPlay" class="active">Play</button>
  <button id="btnStep">Step</button>
  <button id="btnReset">Reset</button>
  <button id="btnSpawnRope">Rope</button>
  <button id="btnSpawnCloth">Cloth</button>
  <button id="btnSpawnSoft">Soft Body</button>
  <button id="btnSpawnBall">Ball</button>
  <button id="btnTear" class="danger">Tear</button>
  <button id="btnPin" class="green">Pin</button>
</div>
<div id="canvasWrap"><canvas id="c"></canvas></div>
<div id="fps">-- FPS</div>
<div id="status">Ready</div>
<script>
// ============================================================
// 2D Soft-Body Physics Playground
// Verlet integration with constraint relaxation
// ============================================================

// ---- Configuration ----
const CFG = {
  gravity: 980,
  damping: 0.995,
  iterations: 4,
  timestep: 1 / 60,
  maxDelta: 1 / 10,
  maxVelocity: 600,
  wallMargin: 50,
  grabRadius: 14,
  pinRadius: 10,
  tearRadius: 25,
  staticCircle: { x: 600, y: 400, radius: 130 },
  wallColor: '#2a2a30',
  objColors: {
    rope: '#FF8C00',
    cloth: '#00BFFF',
    softBody: '#FF3366',
    ball: '#00FF88',
  },
  pinColor: '#888899',
  constraintColor: '#666688',
  meshFillAlpha: 0.15,
  meshEdgeAlpha: 0.6,
  trailAlpha: 0.12,
};

// ---- Canvas ----
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let W = 0, H = 0;

function resize() {
  const wrap = document.getElementById('canvasWrap');
  const rect = wrap.getBoundingClientRect();
  W = rect.width; H = rect.height;
  canvas.width = W; canvas.height = H;
}
window.addEventListener('resize', resize);
resize();

// ---- Simulation State ----
const sim = {
  points: [],
  constraints: [],
  objects: [],
  mouse: { x: -999, y: -999, down: false, rightDown: false },
  grabbed: null,
  dragging: null,
  paused: false,
  frameCount: 0,
  fps: 0,
  fpsTimer: 0,
  accumulator: 0,
  lastTime: 0,
  staticShapes: [],
  particles: [],
  staticCircle: { x: 600, y: 400, radius: 130, mass: 0 },
};

// ---- Utility ----
function dist(x1, y1, x2, y2) { return Math.sqrt((x1-x2)**2 + (y1-y2)**2); }
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function rand(a, b) { return a + Math.random() * (b - a); }

// ---- Point ----
let _pid = 0;
class Point {
  constructor(x, y, pinned) {
    this.x = x; this.y = y;
    this.xPrev = x; this.yPrev = y;
    this.pinned = pinned; this.id = _pid++;
    this.meshIdx = -1;
  }
  velocity() { return { x: (this.x - this.xPrev), y: (this.y - this.yPrev) }; }
  setFrom(p) { this.x = p.x; this.y = p.y; this.xPrev = p.x; this.yPrev = p.y; }
  isPinned() { return this.pinned; }
}

// ---- Constraint ----
class Constraint {
  constructor(a, b, restLength) {
    this.a = a; this.b = b; this.restLength = restLength;
    this.active = true;
    this.removed = false;
  }
  get midpoint() {
    return { x: (this.a.x + this.b.x) / 2, y: (this.a.y + this.b.y) / 2 };
  }
  get length() { return dist(this.a.x, this.a.y, this.b.x, this.b.y); }
  get stretch() { return this.length / Math.max(1, this.restLength); }
}

// ---- Object Factory ----
function createRope(startX, startY, numPoints, restLength) {
  const pts = [];
  const conns = [];
  let x = startX, y = startY;
  for (let i = 0; i < numPoints; i++) {
    pts.push(new Point(x, y, i === 0));
  }
  for (let i = 0; i < numPoints - 1; i++) {
    const r = restLength || 30;
    const dx = pts[i+1].x - pts[i].x;
    const dy = pts[i+1].y - pts[i].y;
    conns.push(new Constraint(pts[i], pts[i+1], r));
  }
  // For the first point, it's pinned
  return { type: 'rope', points: pts, constraints: conns, pinned: [true, ...Array(numPoints-1).fill(false)] };
}

function createCloth(width, height, restLength, pinPattern) {
  // pinPattern: 'corners', 'edge'
  const pts = [];
  const conns = [];
  const pinMap = [];
  const margin = 40;
  const spacing = restLength || 35;
  const cx = 300, cy = 400;
  for (let j = 0; j <= height; j++) {
    for (let i = 0; i <= width; i++) {
      const x = cx - width * spacing / 2 + i * spacing;
      const y = cy - height * spacing / 2 + j * spacing;
      const pinned = (j === 0 && pinPattern === 'corners') ||
                     (j === 0 && pinPattern === 'edge') ||
                     (pinPattern === 'full' && j === 0);
      pts.push(new Point(x, y, pinned));
      pinMap.push(pinned);
    }
  }
  // Add constraints
  for (let j = 0; j <= height; j++) {
    for (let i = 0; i < width; i++) {
      const idx = j * (width + 1) + i;
      // horizontal
      if (i < width) conns.push(new Constraint(pts[idx], pts[idx + 1], spacing));
      // vertical
      if (j < height) conns.push(new Constraint(pts[idx], pts[idx + width + 1], spacing));
      // shear
      if (i < width && j < height) conns.push(new Constraint(pts[idx], pts[idx + width + 1], spacing));
    }
  }
  return { type: 'cloth', points: pts, constraints: conns, pinMap: pinMap };
}

function createSoftBody(cx, cy, radius, numPoints) {
  const pts = [];
  const conns = [];
  const r = radius || 60;
  const num = numPoints || 64;
  for (let i = 0; i < num; i++) {
    const angle = (i / num) * Math.PI * 2;
    const x = cx + Math.cos(angle) * radius;
    const y = cy + Math.sin(angle) * radius;
    pts.push(new Point(x, y, i === 0));
  }
  // Edge constraints
  for (let i = 0; i < num; i++) {
    const next = (i + 1) % num;
    conns.push(new Constraint(pts[i], pts[next], radius));
  }
  // Internal brace constraints (diagonals for stability)
  const half = Math.floor(num / 2);
  for (let i = 0; i < half; i++) {
    const next = (i + half) % num;
    conns.push(new Constraint(pts[i], pts[next], radius));
  }
  return { type: 'softBody', points: pts, constraints: conns, pinned: Array(num).fill(false) };
}

function createBall(x, y) {
  return { type: 'ball', points: [new Point(x, y, false)], constraints: [], pinned: [] };
}

// ---- Static Shapes ----
function createStaticCircle(cx, cy, r) {
  return { type: 'circle', cx, cy, radius: r, mass: 0 };
}

// ---- Physics Engine ----
function verletStep(dt, damping) {
  for (const p of sim.points) {
    if (p.pinned) continue;
    const vx = (p.x - p.xPrev) * damping;
    const vy = (p.y - p.yPrev) * damping;
    p.x += vx + CFG.gravity * dt * dt;
    p.y += vy + CFG.gravity * dt * dt;
    p.xPrev = p.x;
    p.yPrev = p.y;
  }
}

function solveConstraints(iterations) {
  for (let iter = 0; iter < iterations; iter++) {
    for (const c of sim.constraints) {
      if (!c.active) continue;
      const p1 = c.a, p2 = c.b;
      const dx = p2.x - p1.x;
      const dy = p2.y - p1.y;
      const dist = Math.sqrt(dx * dx + dy * dy);
      if (dist < 1e-6) continue;
      const diff = (c.restLength - dist) / dist;
      const offset = diff * 0.5;
      if (!p1.pinned) { p1.x += dx * offset; p1.y += dy * offset; }
      if (!p2.pinned) { p2.x -= dx * offset; p2.y -= dy * offset; }
    }
  }
}

function clampPositions() {
  for (const p of sim.points) {
    if (p.pinned) continue;
    p.x = clamp(p.x, CFG.wallMargin, W - CFG.wallMargin);
    p.y = clamp(p.y, CFG.wallMargin, H - CFG.wallMargin);
  }
}

function resolveCollisions() {
  // Wall collisions
  const wm = CFG.wallMargin;
  for (const p of sim.points) {
    if (p.pinned) continue;
    // Floor
    if (p.y > H - wm) { p.y = H - wm; }
    // Ceiling
    if (p.y < wm) { p.y = wm; }
    // Left wall
    if (p.x < wm) { p.x = wm; }
    // Right wall
    if (p.x > W - wm) { p.x = W - wm; }
  }
  // Static circle collision
  const sc = CFG.staticCircle;
  for (const p of sim.points) {
    if (p.pinned) continue;
    const dx = p.x - sc.cx;
    const dy = p.y - sc.cy;
    const d = Math.sqrt(dx * dx + dy * dy);
    if (d < sc.radius) {
      const overlap = sc.radius - d;
      if (overlap > 1e-6) {
        const nx = dx / d;
        const ny = dy / d;
        p.x += nx * overlap;
        p.y += ny * overlap;
      }
    }
  }
  // Static box collision
  const bw = 200, bh = 150, bx = 500, by = 250;
  for (const p of sim.points) {
    if (p.pinned) continue;
    if (p.x < bx + bw && p.x > bx && p.y < by + bh && p.y > by) {
      const dx = p.x - (bx + bw / 2);
      const dy = p.y - (by + bh / 2);
      const d = Math.sqrt(dx * dx + dy * dy);
      if (d < 0.01) continue;
      const overlap = Math.max(bw / 2 - Math.abs(dx), bh / 2 - Math.abs(dy));
      if (overlap > 1e-6) {
        const nx = dx / d;
        const ny = dy / d;
        p.x += nx * overlap;
        p.y += ny * overlap;
      }
    }
  }
}

function stepPhysics(dt) {
  verletStep(dt, CFG.damping);
  solveConstraints(CFG.iterations);
  clampPositions();
  resolveCollisions();
}

// ---- Simulation Update ----
function updateSimulation() {
  const dt = CFG.timestep;
  sim.accumulator += dt;
  let steps = 0;
  while (sim.accumulator >= dt && steps < 8) {
    stepPhysics(dt);
    sim.accumulator -= dt;
    steps++;
  }
  if (sim.accumulator > dt) sim.accumulator = 0;
}

// ---- Point Search ----
function findNearestPoint(mx, my, radius) {
  let best = null, bestDist = radius || CFG.grabRadius;
  for (const p of sim.points) {
    const d = dist(mx, my, p.x, p.y);
    if (d < bestDist) { bestDist = d; best = p; }
  }
  return best;
}

// ---- Interaction ----
function onMouseDown(e) {
  const rect = canvas.getBoundingClientRect();
  const mx = e.clientX - rect.left;
  const my = e.clientY - rect.top;
  sim.mouse.down = true;
  sim.mouse.x = mx; sim.mouse.y = my;

  if (e.button === 2) {
    sim.mouse.rightDown = true;
    const p = findNearestPoint(mx, my, CFG.tearRadius);
    if (p) {
      // Tear constraint to this point
      for (const c of sim.constraints) {
        if (!c.active) continue;
        const mid = c.midpoint;
        if (dist(mx, my, mid.x, mid.y) < CFG.tearRadius) {
          c.active = false;
          c.removed = true;
        }
      }
    }
  } else {
    const p = findNearestPoint(mx, my, CFG.grabRadius);
    if (p) {
      sim.grabbed = p;
      sim.dragging = p;
      // Save previous position for velocity
      p.xPrev = p.x;
      p.yPrev = p.y;
    } else {
      // Try to spawn a new object
      spawnObject(mx, my);
    }
  }
}

function onMouseMove(e) {
  const rect = canvas.getBoundingClientRect();
  const mx = e.clientX - rect.left;
  const my = e.clientY - rect.top;
  sim.mouse.x = mx; sim.mouse.y = my;

  if (sim.dragging && sim.grabbed) {
    // Move grabbed point to cursor
    sim.dragging.x = mx;
    sim.dragging.y = my;
    sim.dragging.xPrev = mx - (sim.dragging.x - sim.dragging.xPrev) * 0.5;
    sim.dragging.yPrev = my - (sim.dragging.y - sim.dragging.yPrev) * 0.5;
  }
}

function onMouseUp(e) {
  const rect = canvas.getBoundingClientRect();
  const mx = e.clientX - rect.left;
  const my = e.clientY - rect.top;
  sim.mouse.down = false;
  sim.mouse.rightDown = false;

  if (sim.dragging) {
    // Release: compute velocity and let physics take over
    const vx = sim.dragging.x - sim.dragging.xPrev;
    const vy = sim.dragging.y - sim.dragging.yPrev;
    sim.dragging.xPrev = sim.dragging.x;
    sim.dragging.yPrev = sim.dragging.y;
    // Add velocity to Verlet
    // (we'll handle this by keeping the previous position slightly behind)
    sim.dragging = null;
  }
  sim.grabbed = null;
}

function spawnObject(mx, my) {
  // Spawn at click position
  const types = ['rope', 'cloth', 'softBody', 'ball'];
  const type = types[Math.floor(Math.random() * types.length)];
  let obj;
  switch (type) {
    case 'rope':
      obj = createRope(mx, my, 20, 35);
      break;
    case 'cloth':
      obj = createCloth(16, 16, 35, 'corners');
      break;
    case 'softBody':
      obj = createSoftBody(mx, my, 50, 64);
      break;
    case 'ball':
      obj = createBall(mx, my);
      break;
  }
  sim.objects.push(obj);
  // Add points to sim.points
  for (const p of obj.points) {
    sim.points.push(p);
  }
  // Rebuild constraints
  sim.constraints = [];
  for (const c of obj.constraints) {
    sim.constraints.push(c);
  }
}

// ---- Pin Toggle ----
function togglePin(point) {
  if (point && !point.pinned) {
    point.pinned = true;
  } else if (point && point.pinned) {
    point.pinned = false;
  }
}

// ---- Event Listeners ----
canvas.addEventListener('mousedown', onMouseDown);
canvas.addEventListener('mousemove', onMouseMove);
canvas.addEventListener('mouseup', onMouseUp);
canvas.addEventListener('mouseleave', onMouseUp);
canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('touchstart', e => { e.preventDefault(); const t = e.touches[0]; onMouseDown({ clientX: t.clientX, clientY: t.clientY, button: 0 }); }, { passive: false });
canvas.addEventListener('touchmove', e => { e.preventDefault(); const t = e.touches[0]; onMouseMove({ clientX: t.clientX, clientY: t.clientY }); }, { passive: false });
canvas.addEventListener('touchend', e => { e.preventDefault(); onMouseUp({ clientX: e.changedTouches[0].clientX, clientY: e.changedTouches[0].clientY, button: 0 }); }, { passive: false });

// ---- Toolbar Buttons ----
document.getElementById('btnPlay').addEventListener('click', () => { sim.paused = !sim.paused; document.getElementById('btnPlay').textContent = sim.paused ? 'Pause' : 'Play'; document.getElementById('btnPlay').classList.toggle('active', !sim.paused); });
document.getElementById('btnStep').addEventListener('click', () => { if (sim.paused) stepPhysics(CFG.timestep); });
document.getElementById('btnReset').addEventListener('click', () => {
  sim.points = []; sim.constraints = []; sim.objects = []; sim.particles = [];
  sim.accumulator = 0;
  // Remove all objects
  sim.objects = [];
  // Clear the canvas
  ctx.clearRect(0, 0, W, H);
});

document.getElementById('btnSpawnRope').addEventListener('click', () => spawnObject(W / 2, H / 2));
document.getElementById('btnSpawnCloth').addEventListener('click', () => spawnObject(W / 2, H / 2));
document.getElementById('btnSpawnSoft').addEventListener('click', () => spawnObject(W / 2, H / 2));
document.getElementById('btnSpawnBall').addEventListener('click', () => spawnObject(W / 2, H / 2));
document.getElementById('btnTear').addEventListener('click', () => {
  // Right-click to tear
  const p = findNearestPoint(sim.mouse.x, sim.mouse.y, CFG.tearRadius);
  if (p) {
    for (const c of sim.constraints) {
      if (!c.active) continue;
      const mid = c.midpoint;
      if (dist(sim.mouse.x, sim.mouse.y, mid.x, mid.y) < CFG.tearRadius) {
        c.active = false;
        c.removed = true;
      }
    }
  }
});
document.getElementById('btnPin').addEventListener('click', () => {
  if (sim.grabbed) {
    togglePin(sim.grabbed);
  }
});

// ---- Gravity & Iterations Sliders ----
document.getElementById('gSlider').addEventListener('input', e => { CFG.gravity = parseFloat(e.target.value); });
document.getElementById('iSlider').addEventListener('input', e => { CFG.iterations = parseInt(e.target.value); });
document.getElementById('dSlider').addEventListener('input', e => { CFG.damping = parseFloat(e.target.value); });

// ---- Rendering ----
function drawPoint(p) {
  if (p.pinned) {
    ctx.fillStyle = CFG.pinColor;
    ctx.beginPath();
    ctx.arc(p.x, p.y, 2, 0, Math.PI * 2);
    ctx.fill();
    return;
  }
  ctx.fillStyle = '#ffffff';
  ctx.beginPath();
  ctx.arc(p.x, p.y, 2, 0, Math.PI * 2);
  ctx.fill();
}

function drawConstraint(c, color) {
  ctx.strokeStyle = color;
  ctx.lineWidth = 1.5;
  ctx.beginPath();
  ctx.moveTo(c.a.x, c.a.y);
  ctx.lineTo(c.b.x, c.b.y);
  ctx.stroke();
}

function drawMesh(points, constraints) {
  if (points.length < 3) return;
  // Draw edge lines
  for (const c of constraints) {
    if (!c.active) continue;
    const isH = (c.a.y === c.b.y);
    const isV = (c.a.x === c.b.x);
    const isDiag = !isH && !isV;
    const isEdge = isH || isV;
    const isShear = isDiag;
    const alpha = isEdge ? CFG.meshEdgeAlpha : 0.2;
    ctx.strokeStyle = `rgba(255,255,255,${alpha})`;
    ctx.lineWidth = 0.5;
    ctx.beginPath();
    ctx.moveTo(c.a.x, c.a.y);
    ctx.lineTo(c.b.x, c.b.y);
    ctx.stroke();
  }
  // Draw filled mesh
  ctx.fillStyle = `rgba(255,255,255,${CFG.meshFillAlpha})`;
  ctx.beginPath();
  ctx.moveTo(points[0].x, points[0].y);
  for (const p of points) ctx.lineTo(p.x, p.y);
  ctx.closePath();
  ctx.fill();
}

function drawObject(obj) {
  if (obj.type === 'rope') {
    ctx.strokeStyle = CFG.objColors.rope;
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(obj.points[0].x, obj.points[0].y);
    for (let i = 1; i < obj.points.length; i++) {
      ctx.lineTo(obj.points[i].x, obj.points[i].y);
    }
    ctx.stroke();
    // Draw points
    for (const p of obj.points) drawPoint(p);
  } else if (obj.type === 'cloth') {
    drawMesh(obj.points, obj.constraints);
    for (const p of obj.points) drawPoint(p);
  } else if (obj.type === 'softBody') {
    drawMesh(obj.points, obj.constraints);
    for (const p of obj.points) drawPoint(p);
  } else if (obj.type === 'ball') {
    // Draw ball as circle
    const r = 8;
    ctx.fillStyle = CFG.objColors.ball;
    ctx.beginPath();
    ctx.arc(obj.points[0].x, obj.points[0].y, r, 0, Math.PI * 2);
    ctx.fill();
    // Inner ring
    ctx.strokeStyle = '#ffffff';
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.arc(obj.points[0].x, obj.points[0].y, r * 0.7, 0, Math.PI * 2);
    ctx.stroke();
    drawPoint(obj.points[0]);
  }
}

function drawStatic() {
  // Walls
  ctx.strokeStyle = CFG.wallColor;
  ctx.lineWidth = 3;
  ctx.strokeRect(0, 0, W, H);
  ctx.strokeRect(0, H - 50, W, 50);
  ctx.strokeRect(W - 50, 0, 50, H);
  // Static circle
  const sc = CFG.staticCircle;
  ctx.fillStyle = '#333344';
  ctx.beginPath();
  ctx.arc(sc.cx, sc.cy, sc.radius, 0, Math.PI * 2);
  ctx.fill();
  ctx.strokeStyle = '#555566';
  ctx.lineWidth = 2;
  ctx.stroke();
  // Label
  ctx.fillStyle = '#556677';
  ctx.font = '11px monospace';
  ctx.fillText('Static Circle', sc.cx - 40, sc.cy - sc.radius - 8);
}

function drawTearEffect() {
  // Draw fading lines for torn constraints
  for (const c of sim.constraints) {
    if (!c.active) continue;
    ctx.strokeStyle = 'rgba(255,100,100,0.3)';
    ctx.lineWidth = 1;
    ctx.setLineDash([4, 4]);
    ctx.beginPath();
    ctx.moveTo(c.a.x, c.a.y);
    ctx.lineTo(c.b.x, c.b.y);
    ctx.stroke();
    ctx.setLineDash([]);
  }
}

function drawFPS() {
  sim.fpsTimer++;
  if (sim.fpsTimer >= 30) {
    sim.fps = Math.round(sim.frameCount / (sim.fpsTimer / 16.67));
    sim.frameCount = 0;
    sim.fpsTimer = 0;
  }
}

// ---- Main Render ----
function render() {
  ctx.clearRect(0, 0, W, H);

  // Background
  ctx.fillStyle = '#0d0d0f';
  ctx.fillRect(0, 0, W, H);

  // Subtle grid
  ctx.strokeStyle = 'rgba(255,255,255,0.03)';
  ctx.lineWidth = 0.5;
  const gridSize = 50;
  for (let x = 0; x < W; x += gridSize) {
    ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
  }
  for (let y = 0; y < H; y += gridSize) {
    ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
  }

  // Draw static shapes
  drawStatic();

  // Draw objects
  for (const obj of sim.objects) drawObject(obj);

  // Draw torn constraints
  drawTearEffect();

  // Draw points
  for (const p of sim.points) {
    if (p.pinned) continue;
    // Trail effect for fast-moving points
    const vel = p.velocity();
    const speed = Math.sqrt(vel.x * vel.x + vel.y * vel.y);
    if (speed > 5) {
      ctx.fillStyle = `rgba(255,255,255,${CFG.trailAlpha * Math.min(speed / 100, 1)})`;
      ctx.beginPath();
      ctx.arc(p.x, p.y, 1.5, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  // Draw grabbed point
  if (sim.grabbed) {
    const g = sim.grabbed;
    ctx.fillStyle = '#FF8C00';
    ctx.beginPath();
    ctx.arc(g.x, g.y, 5, 0, Math.PI * 2);
    ctx.fill();
    ctx.strokeStyle = '#ffffff';
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    ctx.arc(g.x, g.y, 8, 0, Math.PI * 2);
    ctx.stroke();
  }

  // Draw constraint stretch visualization (color by stretch)
  // Draw all active constraints with color
  for (const c of sim.constraints) {
    if (!c.active) continue;
    const stretch = c.stretch;
    let r, g, b;
    if (stretch < 0.85) {
      r = 0; g = 180; b = 120; // green
    } else if (stretch < 1.0) {
      r = 255; g = 220; b = 100; // yellow
    } else if (stretch < 1.2) {
      r = 255; g = 100; b = 50; // orange
    } else {
      r = 255; g = 50; b = 50; // red
    }
    ctx.strokeStyle = `rgba(${r},${g},${b},${CFG.meshEdgeAlpha})`;
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(c.a.x, c.a.y);
    ctx.lineTo(c.b.x, c.b.y);
    ctx.stroke();
  }

  // Draw mouse cursor
  if (sim.mouse.down || sim.mouse.rightDown) {
    ctx.fillStyle = 'rgba(255,255,255,0.4)';
    ctx.beginPath();
    ctx.arc(sim.mouse.x, sim.mouse.y, 6, 0, Math.PI * 2);
    ctx.fill();
    ctx.strokeStyle = '#ffffff';
    ctx.lineWidth = 1;
    ctx.stroke();
  }

  // FPS
  drawFPS();
}

// ---- Main Loop ----
let lastTimestamp = 0;
function loop(timestamp) {
  if (!lastTimestamp) lastTimestamp = timestamp;
  const dt = Math.min((timestamp - lastTimestamp) / 1000, CFG.maxDelta);
  lastTimestamp = timestamp;

  sim.frameCount++;

  if (!sim.paused) {
    updateSimulation();
  }

  render();
  requestAnimationFrame(loop);
}

// ---- Expose API ----
window.sim = sim;
window.sim.step = function() { stepPhysics(CFG.timestep); };
window.sim.points = sim.points;

// ---- Initialize ----
// Pre-spawn a few objects for visual interest
sim.objects.push(createRope(300, 700, 25, 30));
sim.objects.push(createSoftBody(600, 400, 50, 64));
sim.objects.push(createBall(300, 600));
sim.objects.push(createCloth(16, 16, 35, 'corners'));

// Start loop
requestAnimationFrame(loop);
</script>
</body>
</html>