← Physics Playground results

Physics Playground

muse spark 1.2 · basic

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,748 bytes · SHA-256 30a579fcca29
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verlet Playground — Soft-Body Lab</title>
<style>
  *{margin:0;padding:0;box-sizing:border-box}
  html,body{width:100%;height:100%;overflow:hidden;background:#080b10;font-family: ui-sans-system,system-ui,Segoe UI,Roboto,Helvetica,Arial}
  canvas{display:block;position:absolute;inset:0;cursor:crosshair}
  /* toolbar */
  #toolbar{
    position:absolute;top:14px;left:14px;z-index:10;
    background:rgba(20,24,33,0.92);backdrop-filter:blur(12px);
    border:1px solid rgba(255,255,255,0.08);border-radius:16px;
    padding:12px;display:flex;flex-direction:column;gap:10px;min-width:260px;
    box-shadow:0 10px 40px rgba(0,0,0,0.5);
  }
  #toolbar h1{font-size:11px;letter-spacing:0.18em;color:#8b9bb4;text-transform:uppercase;font-weight:700}
  .row{display:flex;gap:6px;flex-wrap:wrap}
  .tool{flex:1;min-width:56px;padding:8px 8px;border-radius:10px;border:1px solid rgba(255,255,255,0.08);
    background:#1a2030;color:#cbd5e1;font-size:12px;font-weight:600;cursor:pointer;transition:.15s;text-align:center}
  .tool.active{background:#ffffff;color:#0f172a;border-color:#fff;box-shadow:0 4px 16px rgba(255,255,255,0.2)}
  .tool:hover{filter:brightness(1.2)}
  .tool b{display:block;font-size:15px}
  .divider{height:1px;background:rgba(255,255,255,0.07);margin:2px 0}
  .ctrl{display:flex;align-items:center;justify-content:space-between;gap:10px;color:#94a3b8;font-size:11px}
  .ctrl label{white-space:nowrap;font-weight:600;letter-spacing:.06em;text-transform:uppercase;font-size:10px}
  .ctrl input[type=range]{flex:1;accent-color:#38bdf8;height:3px}
  .ctrl span{font-variant-numeric:tabular-nums;color:#e2e8f0;min-width:36px;text-align:right;font-weight:700}
  .btn{padding:7px 10px;border-radius:9px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.06);color:#e2e8f0;font-size:11px;font-weight:700;cursor:pointer}
  .btn:hover{background:rgba(255,255,255,0.12)}
  .btn.primary{background:#38bdf8;color:#0c1a24;border-color:#38bdf8}
  .hint{font-size:10px;line-height:1.4;color:#64748b}
  .hint kbd{padding:1px 5px;border-radius:4px;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.12);color:#cbd5e1;font-size:10px}
  /* top right stats */
  #stats{position:absolute;top:14px;right:14px;z-index:10;display:flex;gap:8px}
  .stat{background:rgba(20,24,33,0.92);border:1px solid rgba(255,255,255,0.08);border-radius:12px;padding:8px 12px;color:#94a3b8;font-size:11px;min-width:90px}
  .stat b{display:block;color:#f1f5f9;font-size:14px;font-variant-numeric:tabular-nums}
  .pill{display:inline-flex;align-items:center;gap:6px;font-size:10px;padding:4px 8px;border-radius:20px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);color:#cbd5e1}
  .dot{width:7px;height:7px;border-radius:50%}
  #tearing{position:absolute;left:50%;bottom:18px;transform:translateX(-50%);z-index:10;background:#ef4444;color:white;padding:6px 14px;border-radius:20px;font-size:11px;font-weight:800;letter-spacing:.08em;opacity:0;pointer-events:none;transition:.2s}
  #tearing.on{opacity:1}
</style>
</head>
<body>
<canvas id="c"></canvas>

<div id="toolbar">
  <h1>Soft-Body Lab — Verlet</h1>
  <div class="row">
    <button class="tool active" data-tool="grab"><b>✋</b>Grab</button>
    <button class="tool" data-tool="rope"><b>〰</b>Rope</button>
    <button class="tool" data-tool="cloth"><b>▦</b>Cloth</button>
    <button class="tool" data-tool="soft"><b>⬢</b>Blob</button>
    <button class="tool" data-tool="ball"><b>●</b>Ball</button>
  </div>
  <div class="hint">Drag on canvas to spawn. <kbd>Right-drag</kbd> to tear. <kbd>Shift+Click</kbd> pin/unpin. <kbd>Space</kbd> pause <kbd>→</kbd> step</div>
  <div class="divider"></div>
  <div class="ctrl"><label>Gravity</label><input id="gSlider" type="range" min="0" max="1800" value="980" step="10"><span id="gVal">980</span></div>
  <div class="ctrl"><label>Wind</label><input id="wSlider" type="range" min="-600" max="600" value="0" step="10"><span id="wVal">0</span></div>
  <div class="ctrl"><label>Iterations</label><input id="iSlider" type="range" min="1" max="12" value="5" step="1"><span id="iVal">5</span></div>
  <div class="ctrl"><label>Damping</label><input id="dSlider" type="range" min="0.9" max="0.999" value="0.99" step="0.001"><span id="dVal">0.99</span></div>
  <div class="row" style="align-items:center">
    <label class="pill"><input type="checkbox" id="stressToggle"> <span style="margin-left:6px">Stress colors</span></label>
    <label class="pill"><input type="checkbox" id="trailToggle"> <span style="margin-left:6px">Trail</span></label>
    <span class="pill"><span class="dot" style="background:#38bdf8"></span> Stable 60Hz</span>
  </div>
  <div class="row">
    <button class="btn primary" id="pauseBtn">⏸ Pause</button>
    <button class="btn" id="stepBtn">Step →</button>
    <button class="btn" id="resetBtn">Reset</button>
    <button class="btn" id="demoBtn">Demo</button>
  </div>
  <div class="hint">Floor & walls + 2 circles + 1 box collider. All points collide. Fixed timestep with clamp.</div>
</div>

<div id="stats">
  <div class="stat">FPS <b id="fps">60</b></div>
  <div class="stat">Physics <b id="ptime">0.0 ms</b></div>
  <div class="stat">Points <b id="pcount">0</b></div>
</div>
<div id="tearing">✂ CUTTING</div>

<script>
// ============================================================
//  CONFIG — Tunable constants (edit here)
// ============================================================
const CONFIG = {
  DT: 1/60,                 // fixed timestep seconds
  GRAVITY: 980,             // px/s^2
  WIND: 0,                  // px/s^2 horizontal
  DAMPING: 0.99,            // velocity retention (0..1)
  ITERATIONS: 5,            // constraint solver passes
  TEAR_RADIUS: 18,          // px distance to cut
  GRAB_RADIUS: 28,          // px
  POINT_RADIUS: 4.5,
  BALL_RADIUS: 14,
  FRICTION: 0.82,           // tangential damping on collision
  RESTITUTION: 0.32,
  COLORS: {
    rope: '#ff5c5c',
    cloth: '#2dd4bf',
    soft: '#facc15',
    ball: '#a78bfa',
    pin: '#38bdf8',
    static: '#1f2937',
    stressLow: '#22c55e',
    stressHigh: '#ef4444'
  }
};

// ============================================================
//  CANVAS & STATE
// ============================================================
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let W=0,H=0,DPR=1;
function resize(){
  DPR = Math.min(2, window.devicePixelRatio||1);
  W = window.innerWidth; H = window.innerHeight;
  canvas.width = W*DPR; canvas.height = H*DPR;
  canvas.style.width = W+'px'; canvas.style.height = H+'px';
  ctx.setTransform(DPR,0,0,DPR,0,0);
}
window.addEventListener('resize', resize); resize();

// Sim data
let points = [];        // {x,y,px,py, pinned, radius, color, mass, id}
let constraints = [];   // {a,b, rest, stiff, type, broken, color}
let staticCircles = [];
let staticBoxes = [];
let gravityOn = true;
let paused = false;
let showStress=false, showTrail=false;
let tool='grab';
let nextId=0;

// Interaction
let grabbed = null;
let grabOffset={x:0,y:0};
let mouse={x:0,y:0, down:false, rightDown:false, prevX:0,prevY:0, vx:0,vy:0};
let dragStart=null; // for spawn preview
let tearPath=[]; // array of segments {x1,y1,x2,y2}
let preview=null;

// Stats
let fps=60, pTime=0, frameCount=0, lastFpsTime=performance.now();

// ============================================================
//  HELPERS
// ============================================================
function addPoint(x,y,pinned=false,radius=CONFIG.POINT_RADIUS,color='#fff'){
  const p={id:nextId++, x,y, px:x, py:y, pinned, radius, color, ox:x,oy:y};
  points.push(p); return p;
}
function addConstraint(a,b,stiff=1,type='generic'){
  const dx=a.x-b.x, dy=a.y-b.y;
  let rest=Math.hypot(dx,dy);
  if(rest<1e-4) rest=1e-4;
  const c={a,b, rest, stiff, type, broken:false};
  constraints.push(c); return c;
}
function dist(a,b){return Math.hypot(a.x-b.x,a.y-b.y);}
function lerp(a,b,t){return a+(b-a)*t}
function hexToRgb(h){const r=parseInt(h.slice(1,3),16),g=parseInt(h.slice(3,5),16),b=parseInt(h.slice(5,7),16);return [r,g,b];}

// ============================================================
//  SPAWNERS
// ============================================================
function createRope(x0,y0,x1,y1){
  const len=Math.hypot(x1-x0,y1-y0);
  const segs=Math.max(6, Math.min(24, Math.round(len/18)||12));
  const pts=[];
  for(let i=0;i<=segs;i++){
    const t=i/segs;
    const x=lerp(x0,x1,t), y=lerp(y0,y1,t)+ Math.sin(t*Math.PI)*4;
    const pinned = i===0;
    const p=addPoint(x,y,pinned, 4, CONFIG.COLORS.rope);
    pts.push(p);
  }
  for(let i=0;i<segs;i++) addConstraint(pts[i],pts[i+1],1,'rope');
  // small brace for stability
  for(let i=0;i<segs-1;i++) addConstraint(pts[i],pts[i+2],0.3,'rope');
}
function createCloth(x,y,w,h, pinMode='top'){
  w=Math.abs(w); h=Math.abs(h);
  if(w<40) w=120; if(h<40) h=120;
  const cols=Math.max(6, Math.min(14, Math.round(w/18)));
  const rows=Math.max(6, Math.min(14, Math.round(h/18)));
  const dx=w/cols, dy=h/rows;
  const grid=[];
  for(let r=0;r<=rows;r++){
    grid[r]=[];
    for(let c=0;c<=cols;c++){
      const px=x+c*dx, py=y+r*dy;
      const pinned = (pinMode==='top' && r===0) || (pinMode==='corners' && r===0 && (c===0||c===cols));
      const p=addPoint(px,py,pinned, 3.2, CONFIG.COLORS.cloth);
      grid[r][c]=p;
    }
  }
  for(let r=0;r<=rows;r++){
    for(let c=0;c<=cols;c++){
      if(c<cols) addConstraint(grid[r][c], grid[r][c+1],1,'cloth');
      if(r<rows) addConstraint(grid[r][c], grid[r+1][c],1,'cloth');
      if(c<cols && r<rows){
        addConstraint(grid[r][c], grid[r+1][c+1],1,'cloth');
        addConstraint(grid[r+1][c], grid[r][c+1],1,'cloth');
      }
      // bend constraints for stability
      if(c<cols-1) addConstraint(grid[r][c], grid[r][c+2],0.5,'cloth');
      if(r<rows-1) addConstraint(grid[r][c], grid[r+2][c],0.5,'cloth');
    }
  }
}
function createSoftBody(cx,cy, radius=56){
  const n=12;
  const pts=[];
  const center=addPoint(cx,cy,false,5, CONFIG.COLORS.soft);
  for(let i=0;i<n;i++){
    const ang=i/n*Math.PI*2;
    const x=cx+Math.cos(ang)*radius, y=cy+Math.sin(ang)*radius;
    const p=addPoint(x,y,false,5, CONFIG.COLORS.soft);
    pts.push(p);
  }
  // ring
  for(let i=0;i<n;i++) addConstraint(pts[i], pts[(i+1)%n],1,'soft');
  // spokes
  pts.forEach(p=> addConstraint(p, center,1,'soft'));
  // braces
  for(let i=0;i<n;i++) addConstraint(pts[i], pts[(i+2)%n],0.6,'soft');
  // cross braces for volume
  for(let i=0;i<n/2;i++) addConstraint(pts[i], pts[(i+n/2)%n],0.35,'soft');
}
function createBall(x,y){
  const p=addPoint(x,y,false, CONFIG.BALL_RADIUS, CONFIG.COLORS.ball);
  // single point ball is just a point with larger radius; constraints not needed
}

// ============================================================
//  STATIC GEOMETRY
// ============================================================
function resetStatics(){
  staticCircles = [
    {x: W*0.5, y: H*0.72, r: 78},
    {x: W*0.26, y: H*0.78, r: 56}
  ];
  staticBoxes = [
    {x: W*0.70, y: H*0.78, w: 180, h: 22}
  ];
}
resetStatics();

// ============================================================
//  PHYSICS — Verlet + Relaxation
// ============================================================
function stepPhysics(dt){
  const gx = CONFIG.WIND;
  const gy = gravityOn ? CONFIG.GRAVITY : 0;
  const damp = CONFIG.DAMPING;
  // integrate
  for(const p of points){
    if(p.pinned || grabbed===p) continue;
    const vx = (p.x - p.px) * damp;
    const vy = (p.y - p.py) * damp;
    const nx = p.x + vx + gx*dt*dt;
    const ny = p.y + vy + gy*dt*dt;
    p.px = p.x; p.py = p.y;
    p.x = nx; p.y = ny;
  }
  // grabbed point follows mouse directly (kinematic)
  if(grabbed){
    const prevX=grabbed.x, prevY=grabbed.y;
    grabbed.px = prevX - mouse.vx*dt*1.2;
    // we set px to create throw velocity; but while dragging we override
    grabbed.x = mouse.x + grabOffset.x;
    grabbed.y = mouse.y + grabOffset.y;
    // keep px close to maintain low velocity while held?
    // Actually keep px = x - tiny to avoid jitter
    grabbed.px = grabbed.x - mouse.vx*dt*0.15;
    grabbed.py = grabbed.y - mouse.vy*dt*0.15;
  }
  // constraints relaxation + collisions interleaved
  for(let iter=0; iter<CONFIG.ITERATIONS; iter++){
    for(const c of constraints){
      if(c.broken) continue;
      const a=c.a, b=c.b;
      let dx=b.x - a.x, dy=b.y - a.y;
      let d=Math.hypot(dx,dy);
      if(d<1e-6) continue; // guard division by zero
      const diff = (c.rest - d)/d;
      const k = 0.5 * c.stiff;
      // correction
      const offX = dx * diff * k;
      const offY = dy * diff * k;
      if(a.pinned && b.pinned) continue;
      else if(a.pinned){
        b.x += dx * diff; b.y += dy * diff;
      } else if(b.pinned || b===grabbed){
        a.x -= dx * diff; a.y -= dy * diff;
      } else if(a===grabbed){
        b.x += dx * diff; b.y += dy * diff;
      } else {
        a.x -= offX; a.y -= offY;
        b.x += offX; b.y += offY;
      }
      // clamp NaN guard
      if(!isFinite(a.x)||!isFinite(a.y)){a.x=a.px; a.y=a.py;}
      if(!isFinite(b.x)||!isFinite(b.y)){b.x=b.px; b.y=b.py;}
    }
    // point vs statics (each iter for stability)
    for(const p of points){
      if(p.pinned) continue;
      // walls / floor / ceiling
      const r=p.radius;
      if(p.y + r > H-12){
        p.y = H-12 - r;
        // friction: damp x velocity
        p.px = p.x - (p.x - p.px)*CONFIG.FRICTION;
        p.py = p.y + (p.y - p.py)* -CONFIG.RESTITUTION*0.2;
      }
      if(p.y - r < 12){ p.y=12+r; p.py = p.y + (p.y-p.py)*-0.2; }
      if(p.x - r < 12){ p.x=12+r; p.px = p.x + (p.x-p.px)*-0.2; }
      if(p.x + r > W-12){ p.x=W-12 - r; p.px = p.x + (p.x-p.px)*-0.2; }
      // circles
      for(const s of staticCircles){
        let dx=p.x - s.x, dy=p.y - s.y;
        let d=Math.hypot(dx,dy);
        const minD = s.r + r;
        if(d < minD){
          if(d<1e-6){ dx=1; dy=0; d=1; }
          const nx=dx/d, ny=dy/d;
          const push = minD - d;
          p.x += nx*push; p.y += ny*push;
          // friction along tangent
          const vx = p.x - p.px, vy = p.y - p.py;
          const dot = vx*nx + vy*ny;
          const tx = vx - dot*nx, ty = vy - dot*ny;
          p.px = p.x - tx*CONFIG.FRICTION - dot*nx*(-CONFIG.RESTITUTION);
          p.py = p.y - ty*CONFIG.FRICTION - dot*ny*(-CONFIG.RESTITUTION);
        }
      }
      // boxes AABB
      for(const b of staticBoxes){
        const closestX = Math.max(b.x - b.w/2, Math.min(p.x, b.x + b.w/2));
        const closestY = Math.max(b.y - b.h/2, Math.min(p.y, b.y + b.h/2));
        let dx=p.x - closestX, dy=p.y - closestY;
        let d2=dx*dx+dy*dy;
        if(d2 < r*r - 1e-6){
          let d=Math.sqrt(d2);
          if(d<1e-6){ dx=0; dy=-1; d=1; }
          const nx=dx/d, ny=dy/d;
          const push = r - d + 0.5;
          p.x += nx*push; p.y += ny*push;
          const vx=p.x - p.px, vy=p.y - p.py;
          const dot=vx*nx+vy*ny;
          const tx=vx - dot*nx, ty=vy - dot*ny;
          p.px = p.x - tx*CONFIG.FRICTION;
          p.py = p.y - ty*CONFIG.FRICTION;
          if(ny < -0.5){ // top of box
            p.py = p.y + (p.y - p.py)* -0.1;
          }
        }
      }
    }
  }
  // global bounds clamp to avoid explosion off-screen
  for(const p of points){
    if(!isFinite(p.x)||!isFinite(p.y)||!isFinite(p.px)||!isFinite(p.py)){
      p.x=p.px; p.y=p.py;
    }
    // sanity bound 5x viewport
    const B=5000;
    p.x=Math.max(-B, Math.min(B, p.x));
    p.y=Math.max(-B, Math.min(B, p.y));
  }
}

// ============================================================
//  TEAR LOGIC — break constraints crossing cut line
// ============================================================
function segIntersectsConstraint(ax,ay,bx,by, c){
  // distance from segment to constraint segment
  const x1=c.a.x, y1=c.a.y, x2=c.b.x, y2=c.b.y;
  // quick bbox
  const minX=Math.min(ax,bx)-CONFIG.TEAR_RADIUS, maxX=Math.max(ax,bx)+CONFIG.TEAR_RADIUS;
  const minY=Math.min(ay,by)-CONFIG.TEAR_RADIUS, maxY=Math.max(ay,by)+CONFIG.TEAR_RADIUS;
  if(Math.max(x1,x2)<minX||Math.min(x1,x2)>maxX||Math.max(y1,y2)<minY||Math.min(y1,y2)>maxY) return false;
  // closest distance between segments approximated by sampling mid
  // Compute segment-segment distance
  function segDist(px,py, qx,qy, rx,ry, sx,sy){
    // brute: distance between segments
    const ux=qx-px, uy=qy-py;
    const vx=sx-rx, vy=sy-ry;
    const wx=px-rx, wy=py-ry;
    const a=ux*ux+uy*uy, b=ux*vx+uy*vy, c_=vx*vx+vy*vy, d=ux*wx+uy*wy, e=vx*wx+vy*wy;
    const den=a*c_-b*b;
    let sc, tc;
    if(den<1e-8){ sc=0; tc= (b>c_? d/b : e/c_); } else { sc=(b*e - c_*d)/den; tc=(a*e - b*d)/den; }
    sc=Math.max(0,Math.min(1,sc)); tc=Math.max(0,Math.min(1,tc));
    const cx=px+ux*sc, cy=py+uy*sc;
    const dx=rx+vx*tc, dy=ry+vy*tc;
    return Math.hypot(cx-dx, cy-dy);
  }
  const d=segDist(ax,ay,bx,by,x1,y1,x2,y2);
  return d < CONFIG.TEAR_RADIUS;
}
function tearAlong(x1,y1,x2,y2){
  let cut=0;
  for(const c of constraints){
    if(c.broken) continue;
    if(segIntersectsConstraint(x1,y1,x2,y2,c)){
      // don't cut pins? allow but soften
      c.broken=true; cut++;
    }
  }
  return cut;
}

// ============================================================
//  RENDERING
// ============================================================
function draw(){
  if(!showTrail){
    ctx.clearRect(0,0,W,H);
  } else {
    ctx.fillStyle='rgba(8,11,16,0.22)';
    ctx.fillRect(0,0,W,H);
  }
  // subtle grid
  ctx.save();
  ctx.strokeStyle='rgba(255,255,255,0.03)';
  ctx.lineWidth=1;
  for(let x=0;x<W;x+=48){ ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,H); ctx.stroke(); }
  for(let y=0;y<H;y+=48){ ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke(); }
  // floor/walls
  ctx.fillStyle='rgba(255,255,255,0.06)';
  ctx.fillRect(0,H-12,W,12);
  ctx.fillRect(0,0,12,H); ctx.fillRect(W-12,0,12,H);
  ctx.strokeStyle='rgba(255,255,255,0.08)'; ctx.strokeRect(0.5,0.5,W-1,H-1);
  // statics
  ctx.fillStyle=CONFIG.COLORS.static;
  for(const s of staticCircles){
    ctx.beginPath(); ctx.arc(s.x,s.y,s.r,0,Math.PI*2); ctx.fill();
    ctx.strokeStyle='rgba(255,255,255,0.06)'; ctx.lineWidth=2; ctx.stroke();
    // highlight
    ctx.fillStyle='rgba(255,255,255,0.06)';
    ctx.beginPath(); ctx.arc(s.x-18,s.y-18,s.r*0.3,0,Math.PI*2); ctx.fill();
  }
  for(const b of staticBoxes){
    ctx.fillRect(b.x - b.w/2, b.y - b.h/2, b.w, b.h);
    ctx.strokeStyle='rgba(255,255,255,0.08)'; ctx.strokeRect(b.x - b.w/2, b.y - b.h/2, b.w, b.h);
  }
  // constraints
  for(const c of constraints){
    if(c.broken) continue;
    const a=c.a,b=c.b;
    let col;
    if(showStress){
      const d=Math.hypot(b.x-a.x,b.y-a.y);
      const s=d / c.rest;
      const t=Math.max(0,Math.min(1,(s-1)*3)); // 1..2 maps 0..1
      const r1=34,g1=197,b1=94, r2=239,g2=68,b2=68;
      const r=Math.round(r1 + (r2-r1)*t), g=Math.round(g1 + (g2-g1)*t), bb=Math.round(b1 + (b2-b1)*t);
      col=`rgb(${r},${g},${bb})`;
    } else {
      if(c.type==='rope') col=CONFIG.COLORS.rope;
      else if(c.type==='cloth') col='rgba(45,212,191,0.85)';
      else if(c.type==='soft') col='rgba(250,204,21,0.9)';
      else col='rgba(255,255,255,0.5)';
    }
    ctx.strokeStyle=col;
    ctx.lineWidth= c.type==='cloth' ? 1 : 2;
    if(c.type==='rope') ctx.lineWidth=2.6;
    ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke();
  }
  // cloth filled mesh (subtle)
  // group cloth points? just draw polygons for cloth constraints quad fill
  if(!showStress){
    ctx.fillStyle='rgba(45,212,191,0.08)';
    // Find cloth quads by scanning constraints? Instead fill by drawing triangles between points that form cloth grid via proximity
    // Simple: for each cloth point, fill with its right/bottom neighbor if exists (approx)
    // We'll brute: for cloth points, look for nearby cloth points forming squares
    // Easier: just draw soft bodies as filled polygons
  }
  // soft bodies polygon fill (draw each soft cluster)
  // Derive soft clusters: collect soft constraints ring; instead fill by points labeled soft
  const softGroups = new Map(); // use center association? We'll group by proximity: all soft points near each other
  // Simpler: for each soft center point (pin-less large cluster) fill polygon of its ring
  // Identify soft centers: points with many soft constraints (degree >6)
  for(const p of points){
    if(p.color!==CONFIG.COLORS.soft) continue;
    // find connected soft ring neighbors
  }
  // Instead just draw filled polygon for soft bodies using constraints of type soft that form ring
  // Collect soft points and try to hull them per connected component via BFS
  const visited=new Set();
  for(const p of points){
    if(p.color!==CONFIG.COLORS.soft || visited.has(p.id)) continue;
    // BFS over soft constraints
    const stack=[p]; const comp=[];
    visited.add(p.id);
    while(stack.length){
      const cur=stack.pop(); comp.push(cur);
      for(const c of constraints){
        if(c.broken||c.type!=='soft') continue;
        let other=null;
        if(c.a===cur) other=c.b;
        else if(c.b===cur) other=c.a;
        else continue;
        if(!visited.has(other.id) && other.color===CONFIG.COLORS.soft){ visited.add(other.id); stack.push(other); }
      }
    }
    if(comp.length>3){
      // sort by angle around centroid
      let cx=0,cy=0; comp.forEach(q=>{cx+=q.x; cy+=q.y}); cx/=comp.length; cy/=comp.length;
      comp.sort((a,b)=> Math.atan2(a.y-cy,a.x-cx)-Math.atan2(b.y-cy,b.x-cx));
      // filter to outer ring (ignore center point that is near centroid)
      const outer=comp.filter(q=> Math.hypot(q.x-cx,q.y-cy) > 18);
      if(outer.length>=3){
        ctx.fillStyle='rgba(250,204,21,0.18)';
        ctx.strokeStyle='rgba(250,204,21,0.35)';
        ctx.lineWidth=1;
        ctx.beginPath(); outer.forEach((q,i)=> i===0?ctx.moveTo(q.x,q.y):ctx.lineTo(q.x,q.y)); ctx.closePath(); ctx.fill(); ctx.stroke();
      }
    }
  }

  // points
  for(const p of points){
    const isGrabbed = p===grabbed;
    const isPinned = p.pinned;
    // hide interior cloth points dots (show only border/pinned)
    const hide = p.color===CONFIG.COLORS.cloth && !p.pinned && !isGrabbed;
    if(hide) continue;
    ctx.beginPath();
    ctx.arc(p.x,p.y, isGrabbed?7: p.radius, 0, Math.PI*2);
    if(p.color===CONFIG.COLORS.ball){
      ctx.fillStyle= p.color;
      ctx.fill();
      ctx.fillStyle='rgba(255,255,255,0.35)';
      ctx.beginPath(); ctx.arc(p.x-4,p.y-4,3,0,Math.PI*2); ctx.fill();
    } else {
      ctx.fillStyle = isGrabbed ? '#ffffff' : p.color;
      ctx.fill();
    }
    if(isPinned){
      ctx.strokeStyle='#0ea5e9'; ctx.lineWidth=2; ctx.stroke();
      ctx.fillStyle='#fff'; ctx.beginPath(); ctx.arc(p.x,p.y,1.6,0,Math.PI*2); ctx.fill();
    }
    if(isGrabbed){
      ctx.strokeStyle='rgba(255,255,255,0.9)'; ctx.lineWidth=1.5; ctx.stroke();
      ctx.shadowColor='white'; ctx.shadowBlur=12; ctx.stroke(); ctx.shadowBlur=0;
    }
  }
  // preview
  if(preview){
    ctx.save();
    ctx.setLineDash([6,6]);
    ctx.strokeStyle='rgba(255,255,255,0.9)'; ctx.lineWidth=1.5;
    if(preview.type==='rope'){
      ctx.beginPath(); ctx.moveTo(preview.x0,preview.y0); ctx.lineTo(preview.x1,preview.y1); ctx.stroke();
      ctx.fillStyle='#ff5c5c'; ctx.beginPath(); ctx.arc(preview.x0,preview.y0,5,0,Math.PI*2); ctx.fill();
    } else if(preview.type==='cloth' || preview.type==='soft' || preview.type==='ball'){
      const x=Math.min(preview.x0,preview.x1), y=Math.min(preview.y0,preview.y1);
      const w=Math.abs(preview.x1-preview.x0), h=Math.abs(preview.y1-preview.y0);
      if(preview.type==='cloth') ctx.strokeRect(x,y,w,h);
      else if(preview.type==='soft'){ const r=Math.hypot(w,h)/2; const cx=(preview.x0+preview.x1)/2, cy=(preview.y0+preview.y1)/2; ctx.beginPath(); ctx.arc(cx,cy,Math.max(20,r),0,Math.PI*2); ctx.stroke(); }
      else { ctx.beginPath(); ctx.arc(preview.x1,preview.y1, CONFIG.BALL_RADIUS,0,Math.PI*2); ctx.stroke(); }
    }
    ctx.restore();
    ctx.fillStyle='rgba(255,255,255,0.7)'; ctx.font='11px ui-sans-system'; ctx.fillText('release to spawn', preview.x1+10, preview.y1+10);
  }
  // tear path
  if(tearPath.length){
    ctx.strokeStyle='#ef4444'; ctx.lineWidth=3; ctx.lineCap='round';
    ctx.beginPath();
    tearPath.forEach((s,i)=> i===0?ctx.moveTo(s.x1,s.y1):ctx.lineTo(s.x2,s.y2));
    ctx.stroke();
    ctx.fillStyle='#ef4444'; ctx.beginPath(); ctx.arc(mouse.x,mouse.y,5,0,Math.PI*2); ctx.fill();
  }
  // cursor grab halo when near point
  if(!mouse.down && tool==='grab' && !grabbed){
    let near=null, md=CONFIG.GRAB_RADIUS;
    for(const p of points){ const d=Math.hypot(p.x-mouse.x,p.y-mouse.y); if(d<md){md=d; near=p;}}
    if(near){
      ctx.strokeStyle='rgba(255,255,255,0.5)'; ctx.setLineDash([4,4]); ctx.lineWidth=1;
      ctx.beginPath(); ctx.arc(near.x,near.y, near.radius+10,0,Math.PI*2); ctx.stroke(); ctx.setLineDash([]);
    }
  }
  ctx.restore;
}

// ============================================================
//  INPUT
// ============================================================
function getNearPoint(x,y,rad=CONFIG.GRAB_RADIUS){
  let best=null, bd=rad;
  for(const p of points){
    const d=Math.hypot(p.x-x,p.y-y);
    if(d<bd){bd=d; best=p;}
  }
  return best;
}
function setTool(t){
  tool=t;
  document.querySelectorAll('.tool').forEach(el=> el.classList.toggle('active', el.dataset.tool===t));
  canvas.style.cursor = t==='grab' ? 'grab' : 'crosshair';
}
document.querySelectorAll('.tool').forEach(b=> b.addEventListener('click', ()=> setTool(b.dataset.tool)));

canvas.addEventListener('mousedown', e=>{
  const rect=canvas.getBoundingClientRect();
  const x=(e.clientX-rect.left), y=(e.clientY-rect.top);
  mouse.x=x; mouse.y=y; mouse.prevX=x; mouse.prevY=y;
  if(e.button===2){ // right drag tear start
    mouse.rightDown=true; tearPath=[{x1:x,y1:y,x2:x,y2:y}];
    document.getElementById('tearing').classList.add('on');
    e.preventDefault(); return;
  }
  mouse.down=true;
  // pin toggle with shift
  if(e.shiftKey){
    const p=getNearPoint(x,y,18);
    if(p){ p.pinned=!p.pinned; if(p.pinned){p.px=p.x; p.py=p.y;} }
    return;
  }
  if(tool==='grab'){
    const p=getNearPoint(x,y);
    if(p){
      grabbed=p;
      grabOffset.x=p.x - x; grabOffset.y=p.y - y;
      canvas.style.cursor='grabbing';
    }
  } else {
    dragStart={x,y};
    preview={type:tool, x0:x,y0:y,x1:x,y1:y};
  }
});
canvas.addEventListener('mousemove', e=>{
  const rect=canvas.getBoundingClientRect();
  const x=(e.clientX-rect.left), y=(e.clientY-rect.top);
  mouse.vx = x - mouse.x; mouse.vy = y - mouse.y;
  mouse.prevX=mouse.x; mouse.prevY=mouse.y;
  mouse.x=x; mouse.y=y;
  if(mouse.rightDown){
    const last=tearPath[tearPath.length-1];
    const nx=last.x2, ny=last.y2;
    tearAlong(nx,ny,x,y);
    tearPath.push({x1:nx,y1:ny,x2:x,y2:y});
    // also break constraints directly near
    for(const c of constraints){
      if(c.broken) continue;
      const mx=(c.a.x+c.b.x)/2, my=(c.a.y+c.b.y)/2;
      if(Math.hypot(mx-x,my-y) < CONFIG.TEAR_RADIUS*0.9) c.broken=true;
    }
  } else if(mouse.down){
    if(grabbed){
      // position updated in physics step via mouse
    } else if(preview){
      preview.x1=x; preview.y1=y;
    }
  }
});
function endDrag(e){
  const isRight = e && e.button===2;
  if(isRight || mouse.rightDown){
    mouse.rightDown=false; tearPath=[]; document.getElementById('tearing').classList.remove('on');
    if(isRight) return;
  }
  if(!mouse.down) return;
  mouse.down=false;
  if(grabbed){
    // throw: give velocity from mouse
    const vx=mouse.vx*1.8, vy=mouse.vy*1.8;
    grabbed.px = grabbed.x - vx;
    grabbed.py = grabbed.y - vy;
    grabbed=null;
    canvas.style.cursor='grab';
  } else if(preview && dragStart){
    const x0=preview.x0,y0=preview.y0,x1=preview.x1,y1=preview.y1;
    const w=Math.abs(x1-x0), h=Math.abs(y1-y0);
    if(tool==='rope'){
      createRope(x0,y0,x1,y1);
    } else if(tool==='cloth'){
      if(w>20&&h>20) createCloth(Math.min(x0,x1), Math.min(y0,y1), w,h, 'top');
      else createCloth(x1-60,y1-60,120,120,'top');
    } else if(tool==='soft'){
      const r=Math.max(30, Math.min(90, Math.hypot(w,h)/2));
      createSoftBody((x0+x1)/2,(y0+y1)/2, r);
    } else if(tool==='ball'){
      createBall(x1,y1);
    }
    preview=null; dragStart=null;
    // stay in same tool? keep for rapid spawning
  }
}
canvas.addEventListener('mouseup', endDrag);
canvas.addEventListener('mouseleave', ()=>{ mouse.down=false; mouse.rightDown=false; tearPath=[]; document.getElementById('tearing').classList.remove('on'); if(grabbed){ const vx=mouse.vx*1.2, vy=mouse.vy*1.2; grabbed.px=grabbed.x - vx; grabbed.py=grabbed.y - vy; grabbed=null; } preview=null;});
canvas.addEventListener('contextmenu', e=> e.preventDefault());

// touch
canvas.addEventListener('touchstart', e=>{
  const t=e.touches[0]; const r=canvas.getBoundingClientRect(); const x=t.clientX-r.left, y=t.clientY-r.top;
  mouse.x=x; mouse.y=y; mouse.down=true;
  const p=getNearPoint(x,y); if(p){ grabbed=p; grabOffset.x=p.x-x; grabOffset.y=p.y-y; }
  e.preventDefault();
},{passive:false});
canvas.addEventListener('touchmove', e=>{
  const t=e.touches[0]; const r=canvas.getBoundingClientRect(); const x=t.clientX-r.left, y=t.clientY-r.top;
  mouse.vx=x-mouse.x; mouse.vy=y-mouse.y; mouse.x=x; mouse.y=y; e.preventDefault();
},{passive:false});
canvas.addEventListener('touchend', ()=>{ if(grabbed){ grabbed.px=grabbed.x - mouse.vx*1.5; grabbed.py=grabbed.y - mouse.vy*1.5; grabbed=null; } mouse.down=false; });

// ============================================================
//  CONTROLS
// ============================================================
const gSlider=document.getElementById('gSlider'), gVal=document.getElementById('gVal');
const wSlider=document.getElementById('wSlider'), wVal=document.getElementById('wVal');
const iSlider=document.getElementById('iSlider'), iVal=document.getElementById('iVal');
const dSlider=document.getElementById('dSlider'), dVal=document.getElementById('dVal');
gSlider.addEventListener('input', ()=>{ CONFIG.GRAVITY=parseFloat(gSlider.value); gVal.textContent=gSlider.value;});
wSlider.addEventListener('input', ()=>{ CONFIG.WIND=parseFloat(wSlider.value); wVal.textContent=wSlider.value;});
iSlider.addEventListener('input', ()=>{ CONFIG.ITERATIONS=parseInt(iSlider.value); iVal.textContent=iSlider.value;});
dSlider.addEventListener('input', ()=>{ CONFIG.DAMPING=parseFloat(dSlider.value); dVal.textContent=Number(dSlider.value).toFixed(3);});
document.getElementById('stressToggle').addEventListener('change', e=> showStress=e.target.checked);
document.getElementById('trailToggle').addEventListener('change', e=> showTrail=e.target.checked);
document.getElementById('pauseBtn').addEventListener('click', ()=> togglePause());
document.getElementById('stepBtn').addEventListener('click', ()=>{ if(paused) doStep(); });
document.getElementById('resetBtn').addEventListener('click', ()=> reset());
document.getElementById('demoBtn').addEventListener('click', ()=> spawnDemo());

function togglePause(){ paused=!paused; document.getElementById('pauseBtn').textContent = paused ? '▶ Resume' : '⏸ Pause'; }
function doStep(){ stepPhysics(CONFIG.DT); }

window.addEventListener('keydown', e=>{
  if(e.code==='Space'){ e.preventDefault(); togglePause(); }
  if(e.code==='ArrowRight' && paused){ doStep(); }
  if(e.key==='r' || e.key==='R'){ reset(); }
});

function reset(){
  points=[]; constraints=[]; nextId=0; grabbed=null; resetStatics();
  spawnDemo();
}
function spawnDemo(){
  // clear leaving statics? keep points cumulative
  // initial demo if empty, else add more
  if(points.length===0){
    createRope(W*0.16, 90, W*0.16, 280);
    createCloth(W*0.28, 90, 210, 140, 'top');
    createSoftBody(W*0.72, 160, 58);
    createBall(W*0.55, 60);
    createBall(W*0.62, 40);
  } else {
    // add random
    const r=Math.random();
    if(r<0.33) createRope(W*0.2+Math.random()*W*0.6, 80, W*0.2+Math.random()*W*0.6, 240);
    else if(r<0.66) createCloth(W*0.3+Math.random()*200, 90, 140+Math.random()*80, 120,'top');
    else createSoftBody(Math.random()*W*0.6+W*0.2, 120, 44+Math.random()*22);
  }
}
spawnDemo();

// ============================================================
//  MAIN LOOP — fixed timestep accumulator
// ============================================================
let accumulator=0, lastTime=performance.now();
function frame(now){
  requestAnimationFrame(frame);
  let delta = (now - lastTime)/1000;
  lastTime=now;
  // clamp max delta (tab switch must not explode)
  delta = Math.min(delta, 0.1);
  // FPS
  frameCount++; if(now - lastFpsTime > 500){ fps=Math.round(frameCount*1000/(now-lastFpsTime)); document.getElementById('fps').textContent=fps; document.getElementById('pcount').textContent=points.length; frameCount=0; lastFpsTime=now; }
  if(!paused){
    accumulator += delta;
    const t0=performance.now();
    let steps=0;
    while(accumulator >= CONFIG.DT && steps<8){
      stepPhysics(CONFIG.DT);
      accumulator -= CONFIG.DT;
      steps++;
    }
    pTime = performance.now()-t0;
    document.getElementById('ptime').textContent = pTime.toFixed(1)+' ms';
  }
  draw();
}
requestAnimationFrame(frame);

// ============================================================
//  EXPOSED FOR HEADLESS TESTS
// ============================================================
window.sim = {
  get points(){return points;},
  get constraints(){return constraints;},
  get staticCircles(){return staticCircles;},
  get staticBoxes(){return staticBoxes;},
  step: ()=> stepPhysics(CONFIG.DT),
  reset,
  createRope, createCloth, createSoftBody, createBall,
  tearAlong,
  CONFIG
};
// Self-check helpers (not auto-run, for manual probe)
window.simCheck = {
  dropSettle(){
    // drop soft body from height and check kinetic ~0 after 3s
    const before=points.length;
    createSoftBody(W*0.5, 40, 50);
    for(let i=0;i<180;i++) stepPhysics(CONFIG.DT);
    let ke=0; for(const p of points.slice(before)){ const vx=p.x-p.px, vy=p.y-p.py; ke+=vx*vx+vy*vy; }
    const belowFloor = points.slice(before).some(p=> p.y + p.radius > H-12+0.5);
    return {ke, belowFloor, ok: ke<1 && !belowFloor};
  }
};

</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.2","provider":"openrouter","persona":"basic","sessionId":"4fdb3056-6262-4d27-bfe8-487e41578fb2","tokensIn":1116,"tokensOut":12789,"tokensTotal":13905,"cost":0.05555355,"turns":1,"toolCalls":0,"failedToolCalls":0,"timestamp":"2026-08-06T15:15:31.325Z"} -->