← Physics Playground results

Physics Playground

muse spark 1.2 contributor · full

Initial view of Physics Playground

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 43,538 bytes · SHA-256 bf86b72bab23
<!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>
  :root{
    --bg:#0c0f14;
    --panel:#141a22;
    --panel2:#1a2330;
    --border:#233040;
    --text:#e6eef8;
    --muted:#8aa0b8;
    --accent:#4cc9f0;
    --rope:#f72585;
    --cloth:#4cc9f0;
    --soft:#ffb703;
    --ball:#90be6d;
    --pin:#ff006e;
  }
  *{box-sizing:border-box;margin:0;padding:0}
  body{
    background:var(--bg);
    color:var(--text);
    font:13px/1.4 ui-sans-system,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial;
    overflow:hidden;height:100vh;display:flex;flex-direction:column;
    user-select:none;
  }
  /* Top bar */
  #topbar{
    display:flex;align-items:center;gap:10px;
    padding:8px 12px;background:var(--panel);border-bottom:1px solid var(--border);
    flex-wrap:wrap;z-index:10;
  }
  #logo{font-weight:800;letter-spacing:.06em;font-size:13px;color:var(--accent);display:flex;align-items:center;gap:8px}
  #logo b{font-size:18px}
  .sep{width:1px;height:24px;background:var(--border);margin:0 4px}
  .tool-group{display:flex;gap:6px;align-items:center}
  .btn{
    background:var(--panel2);border:1px solid var(--border);color:var(--text);
    padding:6px 12px;border-radius:8px;cursor:pointer;font-weight:600;font-size:12px;
    transition:.15s; display:flex;align-items:center;gap:6px;
  }
  .btn:hover{border-color:#3a506b;transform:translateY(-1px)}
  .btn.active{background:var(--accent);color:#001018;border-color:var(--accent);box-shadow:0 4px 16px rgba(76,201,240,.35)}
  .btn.tool-rope.active{background:var(--rope);border-color:var(--rope);color:#fff}
  .btn.tool-cloth.active{background:var(--cloth);border-color:var(--cloth);color:#001018}
  .btn.tool-soft.active{background:var(--soft);border-color:var(--soft);color:#1a1200}
  .btn.tool-ball.active{background:var(--ball);border-color:var(--ball);color:#0a1400}
  .btn.small{padding:5px 9px;font-size:11px}
  .btn.ghost{background:transparent}
  .btn.danger{border-color:#ff3b5c;color:#ff6b8a}
  .btn.danger:hover{background:#ff3b5c;color:#fff}
  /* sliders */
  .ctrl{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted)}
  .ctrl label{white-space:nowrap}
  .ctrl input[type=range]{width:90px;accent-color:var(--accent);height:4px}
  .ctrl .val{color:var(--text);font-weight:700;min-width:28px;text-align:right;font-variant-numeric:tabular-nums}
  .toggle{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted);cursor:pointer}
  .toggle input{accent-color:var(--accent)}
  /* canvas wrap */
  #wrap{flex:1;position:relative;overflow:hidden;background:radial-gradient(1200px 600px at 50% -10%,#1a2a3d 0%,var(--bg) 60%)}
  canvas{display:block;width:100%;height:100%}
  #overlay{
    position:absolute;inset:0;pointer-events:none;
  }
  /* bottom stats */
  #stats{
    position:absolute;left:10px;bottom:10px;background:rgba(12,15,20,.85);border:1px solid var(--border);
    padding:8px 10px;border-radius:10px;font-size:11px;color:var(--muted);backdrop-filter:blur(8px);
    display:flex;gap:14px;align-items:center;pointer-events:none;
  }
  #stats b{color:var(--text)}
  #hint{
    position:absolute;right:10px;bottom:10px;background:rgba(12,15,20,.85);border:1px solid var(--border);
    padding:8px 10px;border-radius:10px;font-size:11px;color:var(--muted);max-width:360px;line-height:1.5;
  }
  #hint kbd{background:#1e2d40;border:1px solid #2a405c;border-bottom-width:2px;padding:1px 5px;border-radius:4px;color:var(--text);font-size:10px}
  /* cursor grab */
  .grabbing{cursor:grabbing !important}
  /* tooltip */
  #toast{
    position:absolute;left:50%;top:14px;transform:translateX(-50%);
    background:var(--accent);color:#001018;padding:6px 12px;border-radius:999px;font-weight:700;font-size:12px;
    opacity:0;pointer-events:none;transition:.3s;
  }
  #toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
</style>
</head>
<body>
<div id="topbar">
  <div id="logo"><b>◈</b> VERLET LAB</div>
  <div class="sep"></div>
  <div class="tool-group" id="spawnTools">
    <button class="btn tool-rope active" data-tool="rope" title="Click-drag to draw rope">〰 Rope</button>
    <button class="btn tool-cloth" data-tool="cloth" title="Click-drag to place cloth">▦ Cloth</button>
    <button class="btn tool-soft" data-tool="soft" title="Click to spawn soft body">⬢ Soft</button>
    <button class="btn tool-ball" data-tool="ball" title="Click to spawn ball">● Ball</button>
  </div>
  <div class="sep"></div>
  <div class="tool-group">
    <button class="btn small ghost" id="btnPause" title="Space">⏸ Pause</button>
    <button class="btn small ghost" id="btnStep" title="→ Step">⏭ Step</button>
    <button class="btn small danger" id="btnClear">↺ Reset</button>
  </div>
  <div class="sep"></div>
  <div class="tool-group">
    <div class="ctrl"><label>Gravity</label><input id="slGrav" type="range" min="0" max="2000" value="980"><span class="val" id="vGrav">980</span></div>
    <div class="ctrl"><label>Iterations</label><input id="slIter" type="range" min="1" max="12" value="5"><span class="val" id="vIter">5</span></div>
    <div class="ctrl"><label>Wind</label><input id="slWind" type="range" min="-400" max="400" value="0"><span class="val" id="vWind">0</span></div>
  </div>
  <div class="sep"></div>
  <label class="toggle"><input type="checkbox" id="chkStress"> Stress</label>
  <label class="toggle"><input type="checkbox" id="chkMesh" checked> Mesh</label>
  <label class="toggle"><input type="checkbox" id="chkPoints"> Points</label>
  <label class="toggle"><input type="checkbox" id="chkFloor" checked> Floor</label>
  <div class="sep"></div>
  <div class="ctrl"><label>Cloth pins</label>
    <select id="selPin" style="background:var(--panel2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:4px 6px;font-size:11px">
      <option value="corners">Corners</option>
      <option value="edge" selected>Top edge</option>
      <option value="none">None</option>
    </select>
  </div>
</div>

<div id="wrap">
  <canvas id="c"></canvas>
  <div id="toast">Torn!</div>
  <div id="stats">
    <span>FPS <b id="sFps">60</b></span>
    <span>Phys <b id="sPhys">0.0ms</b></span>
    <span>Points <b id="sPts">0</b></span>
    <span>Constraints <b id="sCons">0</b></span>
    <span>Energy <b id="sEng">0</b></span>
  </div>
  <div id="hint">
    <b style="color:var(--text)">Controls</b><br>
    <kbd>Drag</kbd> grab & throw &nbsp; <kbd>Right-drag</kbd> tear &nbsp; <kbd>Alt+Click</kbd> pin/unpin<br>
    <kbd>Space</kbd> pause &nbsp; <kbd>→</kbd> step &nbsp; <kbd>R</kbd> rope &nbsp; <kbd>C</kbd> cloth &nbsp; <kbd>B</kbd> ball &nbsp; <kbd>S</kbd> soft
  </div>
</div>

<script>
/* ============================================================
   CONFIG — tunable constants
   ============================================================ */
const CONFIG = {
  TIMESTEP: 1/60,
  MAX_DT: 1/30,          // clamp frame delta (tab switch safety)
  MAX_SUBSTEPS: 8,       // max physics steps per frame
  DAMPING: 0.995,        // velocity damping per step (energy decay)
  GRAVITY: 980,          // px/s²
  ITERATIONS: 5,         // constraint solver iterations
  TEAR_RADIUS: 18,       // px radius for cut
  GRAB_RADIUS: 22,
  POINT_RADIUS: 4,
  FRICTION: 0.92,        // wall friction
  BOUNCE: 0.55,
  WIND: 0,
  COLORS: {
    bg: "#0c0f14",
    rope: "#f72585",
    cloth: "#4cc9f0",
    clothFill: "rgba(76,201,240,0.18)",
    soft: "#ffb703",
    softFill: "rgba(255,183,3,0.22)",
    ball: "#90be6d",
    pin: "#ff006e",
    wall: "#1e2d40",
    stressLow: "#2ec4b6",
    stressHigh: "#e71d36"
  }
};

/* ============================================================
   MATH HELPERS
   ============================================================ */
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
const lerp=(a,b,t)=>a+(b-a)*t;
function dist2(ax,ay,bx,by){ const dx=ax-bx, dy=ay-by; return dx*dx+dy*dy; }

/* ============================================================
   PHYSICS CORE — Verlet + Distance Constraints
   ============================================================ */
class Point{
  constructor(x,y,pinned=false,radius=4){
    this.x=x; this.y=y;
    this.px=x; this.py=y; // previous
    this.ax=0; this.ay=0;
    this.pinned=pinned;
    this.radius=radius;
    this.mass=1;
    this.invMass=pinned?0:1;
    this.color=null;
    this.id=Math.random().toString(36).slice(2,7);
  }
  pin(){ this.pinned=true; this.invMass=0; }
  unpin(){ this.pinned=false; this.invMass=1; }
}

class Constraint{
  constructor(a,b,rest=null,stiffness=1, type='structural'){
    this.a=a; this.b=b;
    this.rest = (rest ?? Math.hypot(a.x-b.x, a.y-b.y)) || 1;
    this.stiffness=stiffness;
    this.type=type; // for rendering
    this.broken=false;
  }
  // returns stretch ratio
  stretch(){
    const d=Math.hypot(this.a.x-this.b.x, this.a.y-this.b.y);
    return Math.abs(d - this.rest)/this.rest;
  }
}

class Engine{
  constructor(){
    this.points=[];
    this.constraints=[];
    this.statics=[]; // {x,y,r} circles or {x,y,w,h} boxes
    this.gravity=CONFIG.GRAVITY;
    this.wind=CONFIG.WIND;
    this.iterations=CONFIG.ITERATIONS;
    this.damping=CONFIG.DAMPING;
    this.bounds={x:0,y:0,w:1200,h:700};
    this.floorY=0;
    this.paused=false;
  }
  clear(){
    this.points.length=0;
    this.constraints.length=0;
  }
  addPoint(p){ this.points.push(p); return p; }
  addConstraint(c){ this.constraints.push(c); return c; }

  // Verlet integration step + constraint relaxation + collisions
  step(dt){
    const g=this.gravity, w=this.wind, damp=this.damping;
    const dt2=dt*dt;
    // integrate
    for(const p of this.points){
      if(p.pinned) continue;
      const vx=(p.x - p.px)*damp;
      const vy=(p.y - p.py)*damp;
      const nx = p.x + vx + (p.ax + w*0.02)*dt2;
      const ny = p.y + vy + (p.ay + g)*dt2;
      p.px=p.x; p.py=p.y;
      p.x=nx; p.y=ny;
      p.ax=0; p.ay=0;
      // NaN guard
      if(!Number.isFinite(p.x)||!Number.isFinite(p.y)){
        p.x=p.px; p.y=p.py;
      }
    }
    // constraint relaxation
    for(let iter=0; iter<this.iterations; iter++){
      for(const c of this.constraints){
        if(c.broken) continue;
        const a=c.a, b=c.b;
        const dx=b.x - a.x, dy=b.y - a.y;
        let d=Math.hypot(dx,dy);
        if(d<1e-6) continue; // guard zero length
        if(!Number.isFinite(d)) continue;
        const diff=(c.rest - d)/d;
        // stiffness scaling per iteration
        const k=c.stiffness * (1 - 0.15*iter/this.iterations);
        const sx=dx * diff * 0.5 * k;
        const sy=dy * diff * 0.5 * k;
        const invA=a.invMass, invB=b.invMass;
        const sum=invA+invB;
        if(sum===0) continue;
        // distribute by inverse mass
        if(!a.pinned){
          a.x -= sx * (invA/sum)*2;
          a.y -= sy * (invA/sum)*2;
        }
        if(!b.pinned){
          b.x += sx * (invB/sum)*2;
          b.y += sy * (invB/sum)*2;
        }
      }
      // static shape collisions after each iteration for stability
      this.collidePoints();
    }
    // bounds / floor
    this.collideBounds();
    // point-point simple separation (cheap)
    // this.collidePointPoint(); // optional, disabled for perf
  }

  collidePoints(){
    for(const p of this.points){
      if(p.pinned) continue;
      for(const s of this.statics){
        if(s.type==='circle'){
          const dx=p.x - s.x, dy=p.y - s.y;
          let d=Math.hypot(dx,dy);
          const minD=s.r + p.radius;
          if(d < minD){
            if(d<1e-6){ p.y -= minD; continue; }
            const nx=dx/d, ny=dy/d;
            const overlap=minD - d;
            p.x += nx*overlap;
            p.y += ny*overlap;
            // friction on velocity
            const vx=p.x - p.px, vy=p.y - p.py;
            const dot=vx*nx + vy*ny;
            // reflect normal a bit
            if(dot<0){
              p.px += nx*dot*CONFIG.BOUNCE;
              p.py += ny*dot*CONFIG.BOUNCE;
            }
            // tangential friction
            const tx=-ny, ty=nx;
            const tdot=vx*tx + vy*ty;
            p.px += tx*tdot*(1-CONFIG.FRICTION)*0.3;
            p.py += ty*tdot*(1-CONFIG.FRICTION)*0.3;
          }
        } else if(s.type==='box'){
          // AABB
          const closestX=clamp(p.x, s.x, s.x+s.w);
          const closestY=clamp(p.y, s.y, s.y+s.h);
          const dx=p.x-closestX, dy=p.y-closestY;
          const d2=dx*dx+dy*dy;
          const r2=p.radius*p.radius;
          if(d2 < r2 && d2>1e-6){
            const d=Math.sqrt(d2);
            const nx=dx/d, ny=dy/d;
            const overlap=p.radius - d;
            p.x += nx*overlap;
            p.y += ny*overlap;
            const vx=p.x-p.px, vy=p.y-p.py;
            const dot=vx*nx+vy*ny;
            if(dot<0){ p.px+=nx*dot*CONFIG.BOUNCE; p.py+=ny*dot*CONFIG.BOUNCE; }
          } else if(d2<1e-6){
            // inside box — push out via smallest penetration
            const toLeft=p.x - s.x, toRight=(s.x+s.w)-p.x, toTop=p.y - s.y, toBottom=(s.y+s.h)-p.y;
            const m=Math.min(toLeft,toRight,toTop,toBottom);
            if(m===toLeft) p.x = s.x - p.radius;
            else if(m===toRight) p.x = s.x+s.w + p.radius;
            else if(m===toTop) p.y = s.y - p.radius;
            else p.y = s.y+s.h + p.radius;
          }
        }
      }
    }
  }

  collideBounds(){
    const b=this.bounds;
    const floor=this.floorY;
    for(const p of this.points){
      if(p.pinned) continue;
      // floor
      if(p.y + p.radius > floor){
        p.y = floor - p.radius;
        const vy=p.y - p.py;
        // bounce damping
        p.py = p.y + vy*CONFIG.BOUNCE*0.35;
        // friction
        const vx=p.x - p.px;
        p.px += vx*(1-CONFIG.FRICTION)*0.5;
        // clamp NaN
        if(!Number.isFinite(p.py)) p.py=p.y;
      }
      // walls
      if(p.x - p.radius < b.x){
        p.x = b.x + p.radius;
        const vx=p.x - p.px;
        p.px = p.x + vx*CONFIG.BOUNCE*0.4;
      }
      if(p.x + p.radius > b.x + b.w){
        p.x = b.x + b.w - p.radius;
        const vx=p.x - p.px;
        p.px = p.x + vx*CONFIG.BOUNCE*0.4;
      }
      // ceiling
      if(p.y - p.radius < b.y){
        p.y = b.y + p.radius;
        const vy=p.y - p.py;
        p.py = p.y + vy*CONFIG.BOUNCE*0.4;
      }
    }
  }

  // energetic measure
  kineticEnergy(){
    let e=0;
    for(const p of this.points){
      if(p.pinned) continue;
      const vx=p.x-p.px, vy=p.y-p.py;
      e+= (vx*vx+vy*vy)*0.5;
    }
    return e;
  }

  // tearing — break constraints whose segment intersects cut line
  tearLine(x0,y0,x1,y1,radius=CONFIG.TEAR_RADIUS){
    let broken=0;
    for(const c of this.constraints){
      if(c.broken) continue;
      // distance from segment c.a-c.b to cut segment
      const d = segSegDist(c.a.x,c.a.y,c.b.x,c.b.y, x0,y0,x1,y1);
      if(d < radius){
        c.broken=true; broken++;
      }
    }
    // remove broken constraints from array lazily (keep but skip), or filter occasionally
    if(broken>12){
      this.constraints = this.constraints.filter(c=>!c.broken);
    }
    return broken;
  }
}

// segment-segment distance
function segSegDist(ax,ay,bx,by, cx,cy,dx,dy){
  // closest distance between segments AB and CD
  // brute: sample + analytic
  function ptSegDist(px,py, x1,y1,x2,y2){
    const l2=(x2-x1)*(x2-x1)+(y2-y1)*(y2-y1);
    if(l2<1e-9) return Math.hypot(px-x1,py-y1);
    let t=((px-x1)*(x2-x1)+(py-y1)*(y2-y1))/l2;
    t=clamp(t,0,1);
    const projx=x1+(x2-x1)*t, projy=y1+(y2-y1)*t;
    return Math.hypot(px-projx,py-projy);
  }
  // check 4 point-to-segment combos as approximation (good enough for tear)
  return Math.min(
    ptSegDist(ax,ay,cx,cy,dx,dy),
    ptSegDist(bx,by,cx,cy,dx,dy),
    ptSegDist(cx,cy,ax,ay,bx,by),
    ptSegDist(dx,dy,ax,ay,bx,by)
  );
}

/* ============================================================
   SPAWN HELPERS
   ============================================================ */
function spawnRope(engine, x0,y0,x1,y1, segments=14){
  const pts=[];
  const len=Math.hypot(x1-x0,y1-y0);
  const n = segments;
  for(let i=0;i<n;i++){
    const t=i/(n-1);
    const x=lerp(x0,x1,t), y=lerp(y0,y1,t);
    const p=new Point(x,y, i===0, 3.5);
    p.color=CONFIG.COLORS.rope;
    engine.addPoint(p); pts.push(p);
  }
  for(let i=0;i<n-1;i++){
    engine.addConstraint(new Constraint(pts[i], pts[i+1], null, 1, 'rope'));
  }
  // second pass stiffness for less stretch
  return pts;
}

function spawnCloth(engine, x,y,w,h, cols=12, rows=8, pinMode='edge'){
  const pts=[];
  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;
      let pinned=false;
      if(pinMode==='edge' && r===0) pinned=true;
      else if(pinMode==='corners' && r===0 && (c===0 || c===cols)) pinned=true;
      const p=new Point(px,py,pinned, 3);
      p.color=CONFIG.COLORS.cloth;
      engine.addPoint(p);
      grid[r][c]=p; pts.push(p);
    }
  }
  // structural
  for(let r=0;r<=rows;r++){
    for(let c=0;c<=cols;c++){
      if(c<cols) engine.addConstraint(new Constraint(grid[r][c], grid[r][c+1], dx, 1, 'cloth'));
      if(r<rows) engine.addConstraint(new Constraint(grid[r][c], grid[r+1][c], dy, 1, 'cloth'));
      // shear
      if(c<cols && r<rows){
        engine.addConstraint(new Constraint(grid[r][c], grid[r+1][c+1], Math.hypot(dx,dy), 0.85, 'shear'));
        engine.addConstraint(new Constraint(grid[r][c+1], grid[r+1][c], Math.hypot(dx,dy), 0.85, 'shear'));
      }
      // bend (second neighbor) for stiffness
      if(c<cols-1) engine.addConstraint(new Constraint(grid[r][c], grid[r][c+2], dx*2, 0.45, 'bend'));
      if(r<rows-1) engine.addConstraint(new Constraint(grid[r][c], grid[r+2][c], dy*2, 0.45, 'bend'));
    }
  }
  return {pts, grid, cols, rows, x,y,w,h};
}

function spawnSoftBody(engine, cx,cy, radius=48, segments=12){
  const pts=[];
  const center=new Point(cx,cy,false,4);
  center.color=CONFIG.COLORS.soft;
  engine.addPoint(center); pts.push(center);
  const ring=[];
  for(let i=0;i<segments;i++){
    const ang=(i/segments)*Math.PI*2;
    const x=cx + Math.cos(ang)*radius;
    const y=cy + Math.sin(ang)*radius;
    const p=new Point(x,y,false,4);
    p.color=CONFIG.COLORS.soft;
    engine.addPoint(p); ring.push(p); pts.push(p);
  }
  // edge
  for(let i=0;i<segments;i++){
    const a=ring[i], b=ring[(i+1)%segments];
    engine.addConstraint(new Constraint(a,b,null,1,'soft'));
    // spoke to center
    engine.addConstraint(new Constraint(a,center,null,1,'soft'));
    // brace to next-next
    const c2=ring[(i+2)%segments];
    engine.addConstraint(new Constraint(a,c2,null,0.7,'soft'));
  }
  // cross braces
  for(let i=0;i<segments;i++){
    const a=ring[i], b=ring[(i+segments/2|0)%segments];
    if(i<segments/2) engine.addConstraint(new Constraint(a,b,null,0.35,'soft'));
  }
  return pts;
}

function spawnBall(engine,x,y,r=16){
  const p=new Point(x,y,false,r);
  p.color=CONFIG.COLORS.ball;
  engine.addPoint(p);
  return p;
}

/* ============================================================
   APP SETUP
   ============================================================ */
const canvas=document.getElementById('c');
const ctx=canvas.getContext('2d');
const wrap=document.getElementById('wrap');
const engine=new Engine();

// expose for probing — use a non-conflicting name to avoid overwriting Engine#step
window.sim = engine;
window.sim.physStep = function(dt){ return Engine.prototype.step.call(engine, dt===undefined?CONFIG.TIMESTEP:dt); };
// keep .step as alias but ensure it doesn't recurse via instance property shadowing
const _realStep = Engine.prototype.step;
Object.defineProperty(window.sim, 'step', {
  value: function(dt){ return _realStep.call(engine, dt===undefined?CONFIG.TIMESTEP:dt); },
  writable: true, configurable: true
});
window.CONFIG = CONFIG;

let W=0,H=0, DPR=1;
function resize(){
  DPR=Math.min(2, window.devicePixelRatio||1);
  W=wrap.clientWidth; H=wrap.clientHeight;
  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);
  engine.bounds={x:0,y:0,w:W,h:H};
  engine.floorY=H-28;
  // reposition statics relative to new size
  buildStatics();
}
window.addEventListener('resize',resize);

function buildStatics(){
  engine.statics.length=0;
  // floor is handled as bounds, but also add visual boxes
  // two circles to drape cloth over
  engine.statics.push({type:'circle', x:W*0.5, y:H-110, r:62});
  engine.statics.push({type:'circle', x:W*0.72, y:H-90, r:44});
  engine.statics.push({type:'box', x:W*0.12, y:H-120, w:120, h:18});
  engine.statics.push({type:'box', x:W*0.12, y:H-160, w:18, h:40});
}
resize();

/* initial demo scene */
function demo(){
  engine.clear();
  // rope pendulum
  spawnRope(engine, W*0.18, 90, W*0.18, 280, 16);
  // cloth
  spawnCloth(engine, W*0.32, 70, 260, 160, 12, 8, document.getElementById('selPin').value);
  // soft body
  spawnSoftBody(engine, W*0.78, 120, 46, 12);
  // balls
  spawnBall(engine, W*0.62, 80, 14);
  spawnBall(engine, W*0.66, 50, 18);
}
demo();

/* ============================================================
   INTERACTION
   ============================================================ */
let tool='rope';
let isDragging=false, dragStart=null, dragEnd=null;
let grabbed=null, grabOffset={x:0,y:0}, lastMouse={x:0,y:0}, mouseVel={x:0,y:0};
let isTearing=false, tearPrev=null;
let showStress=false, showMesh=true, showPoints=false, showFloor=true;

const toolsEl=document.getElementById('spawnTools');
toolsEl.addEventListener('click',e=>{
  const b=e.target.closest('[data-tool]');
  if(!b) return;
  tool=b.dataset.tool;
  toolsEl.querySelectorAll('.btn').forEach(x=>x.classList.remove('active'));
  b.classList.add('active');
});

function canvasPos(e){
  const rect=canvas.getBoundingClientRect();
  const x=(e.clientX - rect.left);
  const y=(e.clientY - rect.top);
  return {x,y};
}
function findNearest(x,y, radius=CONFIG.GRAB_RADIUS){
  let best=null, bestD= radius*radius;
  for(const p of engine.points){
    const d2=dist2(p.x,p.y,x,y);
    if(d2 < bestD){ bestD=d2; best=p; }
  }
  return best;
}

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

canvas.addEventListener('pointerdown',e=>{
  const pos=canvasPos(e);
  lastMouse={...pos};
  // Alt to pin
  if(e.altKey){
    const p=findNearest(pos.x,pos.y, 24);
    if(p){ p.pinned? p.unpin(): p.pin(); toast(p.pinned?'Pinned':'Unpinned'); }
    return;
  }
  // right button or shift -> tear
  if(e.button===2 || e.shiftKey){
    isTearing=true; tearPrev={...pos};
    canvas.setPointerCapture(e.pointerId);
    return;
  }
  // left button: try grab first
  const near=findNearest(pos.x,pos.y);
  if(near){
    grabbed=near;
    grabOffset.x=near.x - pos.x;
    grabOffset.y=near.y - pos.y;
    // pin grabbed while dragging
    grabbed._wasPinned=grabbed.pinned;
    grabbed.pin();
    canvas.setPointerCapture(e.pointerId);
    wrap.classList.add('grabbing');
    return;
  }
  // otherwise start spawn drag
  isDragging=true;
  dragStart={...pos};
  dragEnd={...pos};
  canvas.setPointerCapture(e.pointerId);
});

canvas.addEventListener('pointermove',e=>{
  const pos=canvasPos(e);
  // velocity
  mouseVel.x=pos.x-lastMouse.x;
  mouseVel.y=pos.y-lastMouse.y;
  lastMouse={...pos};

  if(grabbed){
    grabbed.x = pos.x + grabOffset.x;
    grabbed.y = pos.y + grabOffset.y;
    // keep prev in sync to avoid huge velocity on release
    // we set px to previous frame's position for throw
    return;
  }
  if(isTearing && tearPrev){
    const broken=engine.tearLine(tearPrev.x,tearPrev.y,pos.x,pos.y, CONFIG.TEAR_RADIUS);
    if(broken>0) showToast('✂ Tear');
    tearPrev={...pos};
    return;
  }
  if(isDragging && dragStart){
    dragEnd={...pos};
  }
});

function endPointer(e){
  const pos= e? canvasPos(e): lastMouse;
  if(grabbed){
    // throw: set previous position opposite to drag velocity
    grabbed.unpin();
    if(grabbed._wasPinned) grabbed.pin(); // if it was pinned before, keep pinned? No, toggle off for throw
    // Actually if it was pinned and alt wasn't used, we want to keep it pinned? For UX, release as unpinned unless it was originally pinned
    if(grabbed._wasPinned){
      // keep pinned if originally pinned, but give velocity by adjusting px
      // don't unpin
      grabbed.pin();
    } else {
      grabbed.unpin();
      // apply throw velocity: px = x - vx
      grabbed.px = grabbed.x - mouseVel.x*0.9;
      grabbed.py = grabbed.y - mouseVel.y*0.9;
    }
    delete grabbed._wasPinned;
    grabbed=null;
    wrap.classList.remove('grabbing');
  }
  if(isTearing){
    isTearing=false; tearPrev=null;
  }
  if(isDragging && dragStart && dragEnd){
    const dx=dragEnd.x - dragStart.x, dy=dragEnd.y - dragStart.y;
    const len=Math.hypot(dx,dy);
    if(len>8){
      if(tool==='rope'){
        const segs = clamp(Math.round(len/14), 6, 28);
        spawnRope(engine, dragStart.x, dragStart.y, dragEnd.x, dragEnd.y, segs);
      } else if(tool==='cloth'){
        const w=Math.abs(dx), h=Math.abs(dy);
        const x=Math.min(dragStart.x,dragEnd.x), y=Math.min(dragStart.y,dragEnd.y);
        const cols=clamp(Math.round(w/22), 4, 16), rows=clamp(Math.round(h/22), 3, 12);
        spawnCloth(engine, x,y, Math.max(80,w), Math.max(60,h), cols, rows, document.getElementById('selPin').value);
      }
    } else {
      // click spawn for ball/soft
      if(tool==='ball') spawnBall(engine, pos.x,pos.y, 14+Math.random()*10);
      else if(tool==='soft') spawnSoftBody(engine, pos.x,pos.y, 36+Math.random()*18, 10+Math.floor(Math.random()*4));
      else if(tool==='rope') spawnRope(engine, pos.x,pos.y, pos.x, pos.y+120, 12);
      else if(tool==='cloth') spawnCloth(engine, pos.x-80,pos.y-40, 160,110, 10,6, document.getElementById('selPin').value);
    }
    isDragging=false; dragStart=null; dragEnd=null;
  }
}
canvas.addEventListener('pointerup',endPointer);
canvas.addEventListener('pointercancel',endPointer);
canvas.addEventListener('pointerleave',()=>{ if(grabbed) endPointer(); });

/* keyboard */
window.addEventListener('keydown',e=>{
  if(e.code==='Space'){ e.preventDefault(); togglePause(); }
  else if(e.code==='ArrowRight' && engine.paused){ engine.step(CONFIG.TIMESTEP); }
  else if(e.key.toLowerCase()==='r'){ setTool('rope'); }
  else if(e.key.toLowerCase()==='c'){ setTool('cloth'); }
  else if(e.key.toLowerCase()==='b'){ setTool('ball'); }
  else if(e.key.toLowerCase()==='s'){ setTool('soft'); }
  else if(e.key==='Delete' || e.key.toLowerCase()==='x'){ engine.clear(); }
});
function setTool(t){
  tool=t;
  toolsEl.querySelectorAll('.btn').forEach(b=>b.classList.toggle('active', b.dataset.tool===t));
}
function togglePause(){
  engine.paused=!engine.paused;
  document.getElementById('btnPause').textContent= engine.paused? '▶ Resume':'⏸ Pause';
}
document.getElementById('btnPause').addEventListener('click',togglePause);
document.getElementById('btnStep').addEventListener('click',()=>{ if(engine.paused) engine.step(CONFIG.TIMESTEP); else { engine.paused=true; engine.step(CONFIG.TIMESTEP); togglePause(); togglePause(); } });
document.getElementById('btnClear').addEventListener('click',()=>{ demo(); });

/* sliders */
const slGrav=document.getElementById('slGrav'), vGrav=document.getElementById('vGrav');
const slIter=document.getElementById('slIter'), vIter=document.getElementById('vIter');
const slWind=document.getElementById('slWind'), vWind=document.getElementById('vWind');
slGrav.addEventListener('input',()=>{ engine.gravity=parseInt(slGrav.value); vGrav.textContent=slGrav.value; });
slIter.addEventListener('input',()=>{ engine.iterations=parseInt(slIter.value); vIter.textContent=slIter.value; });
slWind.addEventListener('input',()=>{ engine.wind=parseInt(slWind.value); vWind.textContent=slWind.value; });
document.getElementById('chkStress').addEventListener('change',e=>showStress=e.target.checked);
document.getElementById('chkMesh').addEventListener('change',e=>showMesh=e.target.checked);
document.getElementById('chkPoints').addEventListener('change',e=>showPoints=e.target.checked);
document.getElementById('chkFloor').addEventListener('change',e=>showFloor=e.target.checked);
document.getElementById('selPin').addEventListener('change',()=>{});

/* toast */
let toastT=0;
function toast(msg){
  const el=document.getElementById('toast');
  el.textContent=msg; el.classList.add('show');
  clearTimeout(toastT); toastT=setTimeout(()=>el.classList.remove('show'),900);
}
function showToast(m){ toast(m); }

/* ============================================================
   RENDERING
   ============================================================ */
function drawStatics(){
  // floor
  if(showFloor){
    ctx.fillStyle='#0f1e2e';
    ctx.fillRect(0, engine.floorY, W, H-engine.floorY);
    ctx.fillStyle='#1a2f4a';
    ctx.fillRect(0, engine.floorY, W, 2);
    // grid
    ctx.strokeStyle='rgba(255,255,255,0.04)';
    ctx.lineWidth=1;
    for(let x=0;x<W;x+=40){ ctx.beginPath(); ctx.moveTo(x,engine.floorY); ctx.lineTo(x,H); ctx.stroke(); }
  }
  // circles
  for(const s of engine.statics){
    if(s.type==='circle'){
      ctx.beginPath(); ctx.arc(s.x,s.y,s.r,0,Math.PI*2);
      const g=ctx.createRadialGradient(s.x-12,s.y-18,6, s.x,s.y,s.r);
      g.addColorStop(0,'#2a4a6b'); g.addColorStop(1,'#0f1e2e');
      ctx.fillStyle=g; ctx.fill();
      ctx.strokeStyle='rgba(76,201,240,0.25)'; ctx.lineWidth=2; ctx.stroke();
      // highlight
      ctx.beginPath(); ctx.arc(s.x-10,s.y-14,s.r*0.28,0,Math.PI*2);
      ctx.fillStyle='rgba(255,255,255,0.07)'; ctx.fill();
    } else if(s.type==='box'){
      ctx.fillStyle='#1c2e45';
      ctx.strokeStyle='rgba(76,201,240,0.2)';
      ctx.lineWidth=2;
      ctx.beginPath(); ctx.roundRect(s.x,s.y,s.w,s.h,6); ctx.fill(); ctx.stroke();
    }
  }
}

function constraintColor(c, stretch){
  if(!showStress) {
    if(c.type==='rope') return CONFIG.COLORS.rope;
    if(c.type==='cloth' || c.type==='shear' || c.type==='bend') return 'rgba(76,201,240,0.9)';
    if(c.type==='soft') return 'rgba(255,183,3,0.9)';
    return 'rgba(200,220,255,0.7)';
  }
  const t=clamp(stretch*6,0,1); // 0..1
  // green -> yellow -> red
  const r= Math.round(46 + (231-46)*t);
  const g= Math.round(196 + (29-196)*t);
  const b= Math.round(182 + (54-182)*t);
  return `rgb(${r},${g},${b})`;
}

function render(){
  ctx.clearRect(0,0,W,H);
  // subtle vignette
  const vg=ctx.createRadialGradient(W/2,H/2, Math.min(W,H)*0.4, W/2,H/2, Math.max(W,H));
  vg.addColorStop(0,'transparent'); vg.addColorStop(1,'rgba(0,0,0,0.35)');
  ctx.fillStyle=vg; ctx.fillRect(0,0,W,H);

  drawStatics();

  // draw constraints
  // For cloth, optionally fill mesh polygons
  if(showMesh){
    // collect cloth quads for fill
    // Instead, draw filled mesh by grouping constraints? Simpler: fill each quad if we can detect grid
    // We'll fill soft bodies as polygons, and cloth as translucent mesh via per-constraint lines + quad fills
    // Soft bodies fill
    // Identify soft bodies by constraints type soft — group points
    // Quick: draw soft polygons by finding centers
    // We'll just draw all constraints as lines first, then overlay fills
  }

  // Soft body fills (convex-ish)
  // Find soft centers (points with many soft constraints)
  // Instead, collect ring groups: each soft body has a center point; find points that have >6 soft constraints to center
  // Simpler: for each point that is center of soft body (we flagged by being central), draw polygon of its ring
  // Detect by: points that have many connections and are near centroid of a cluster
  // Easier: during spawn we didn't tag, so we approximate: draw all soft constraints as filled fan from heuristic
  // Let's draw soft fills by scanning constraints of type soft and building polygons per connected component
  // Quick approach: draw filled shape for each soft constraint loop by using all soft points' convex hull per cluster
  // For simplicity, just draw each soft triangle fan around its center if we can find center
  // Let's attempt: group soft points by proximity clustering
  const softPoints = engine.points.filter(p=>p.color===CONFIG.COLORS.soft);
  if(softPoints.length){
    // cluster by distance
    const visited=new Set();
    const clusters=[];
    for(const p of softPoints){
      if(visited.has(p)) continue;
      const cluster=[p]; visited.add(p);
      // BFS via soft constraints
      const queue=[p];
      while(queue.length){
        const cur=queue.pop();
        for(const c of engine.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) && other.color===CONFIG.COLORS.soft){
            visited.add(other); cluster.push(other); queue.push(other);
          }
        }
      }
      if(cluster.length>3) clusters.push(cluster);
    }
    for(const cl of clusters){
      // find center as point with most connections (likely center)
      let center=cl[0], maxC=0;
      for(const p of cl){
        let cnt=0;
        for(const c of engine.constraints) if(!c.broken && c.type==='soft' && (c.a===p||c.b===p)) cnt++;
        if(cnt>maxC){maxC=cnt; center=p;}
      }
      const ring=cl.filter(p=>p!==center);
      if(ring.length<3) continue;
      // sort ring by angle around center
      ring.sort((a,b)=>Math.atan2(a.y-center.y,a.x-center.x)-Math.atan2(b.y-center.y,b.x-center.x));
      ctx.beginPath();
      ctx.moveTo(ring[0].x, ring[0].y);
      for(let i=1;i<ring.length;i++) ctx.lineTo(ring[i].x, ring[i].y);
      ctx.closePath();
      ctx.fillStyle=CONFIG.COLORS.softFill;
      ctx.fill();
      ctx.strokeStyle='rgba(255,183,3,0.35)'; ctx.lineWidth=1.2; ctx.stroke();
    }
  }

  // Cloth mesh fill — draw quads with subtle fill
  if(showMesh){
    // find cloth points
    const clothPts=engine.points.filter(p=>p.color===CONFIG.COLORS.cloth);
    // To fill, we can draw each quad where 4 points form a cell: check constraints existence
    // Build map from position to point for quick lookup — but points move, so use constraint adjacency
    // Instead, just draw lines with alpha and add a translucent overlay by drawing all cloth constraints as mesh
    // We'll fill by drawing each small triangle (two per quad) if both shear constraints exist
    // Simpler: draw a translucent polygon covering all cloth points via alpha shape? Skip and just draw grid lines with fill per quad detection via brute
    // Brute: for each cloth point, try to find right, down, diag neighbors via constraints
    // We'll just draw cloth fill as series of quads detected via constraints
    // Quick fill: iterate constraints of type cloth (horizontal/vertical) and fill quads where we find a 4-cycle
    // For performance, do lightweight: draw cloth as connected mesh with fill using global composite
    // We'll draw cloth mesh fill by triangulating: for each shear constraint, fill its triangle
    ctx.lineWidth=1;
    // First, draw cloth filled triangles
    for(const c of engine.constraints){
      if(c.broken) continue;
      if(c.type==='shear'){
        // shear connects diagonal of a quad — the triangle is c.a, c.b plus one of the structural neighbors
        // find common neighbor
        // Instead, just fill triangle formed by c.a, c.b and midpoint? Not accurate
        // We'll just draw faint fill for each cloth structural quad by checking grid adjacency via distance
        // Skip complex fill, draw lines only but with background fill per point cloud hull
      }
    }
    // Hull fill for cloth clusters
    const clothClusters=[];
    {
      const visited=new Set();
      const clothSet=new Set(clothPts);
      for(const p of clothPts){
        if(visited.has(p)) continue;
        const cl=[p]; visited.add(p);
        const q=[p];
        while(q.length){
          const cur=q.pop();
          for(const c of engine.constraints){
            if(c.broken) continue;
            if(c.type!=='cloth' && c.type!=='shear' && c.type!=='bend') continue;
            let o=null;
            if(c.a===cur) o=c.b; else if(c.b===cur) o=c.a; else continue;
            if(clothSet.has(o) && !visited.has(o)){ visited.add(o); cl.push(o); q.push(o); }
          }
        }
        if(cl.length>4) clothClusters.push(cl);
      }
    }
    for(const cl of clothClusters){
      // compute hull (simple: sort by angle around centroid and fill)
      let cx=0,cy=0; for(const p of cl){cx+=p.x; cy+=p.y;} cx/=cl.length; cy/=cl.length;
      const sorted=[...cl].sort((a,b)=>Math.atan2(a.y-cy,a.x-cx)-Math.atan2(b.y-cy,b.x-cx));
      // Only fill if cluster is cloth-like (not too scattered) — check area
      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();
      ctx.fillStyle='rgba(76,201,240,0.08)';
      ctx.fill();
    }
  }

  // draw constraints lines
  for(const c of engine.constraints){
    if(c.broken) continue;
    // hide bend constraints visually unless stress
    if(c.type==='bend' && !showStress) continue;
    const stretch = Math.abs(Math.hypot(c.a.x-c.b.x,c.a.y-c.b.y)-c.rest)/c.rest;
    ctx.strokeStyle=constraintColor(c, stretch);
    ctx.lineWidth= (c.type==='rope')? 2.2 : (c.type==='bend'? 0.7 : 1.1);
    if(c.type==='shear') ctx.globalAlpha=0.55;
    else ctx.globalAlpha=1;
    ctx.beginPath(); ctx.moveTo(c.a.x,c.a.y); ctx.lineTo(c.b.x,c.b.y); ctx.stroke();
  }
  ctx.globalAlpha=1;

  // points
  if(showPoints){
    for(const p of engine.points){
      ctx.beginPath(); ctx.arc(p.x,p.y,p.radius,0,Math.PI*2);
      ctx.fillStyle= p.pinned? CONFIG.COLORS.pin : (p.color||'#fff');
      ctx.fill();
      if(p.pinned){
        ctx.strokeStyle='#fff'; ctx.lineWidth=1.5; ctx.stroke();
        ctx.beginPath(); ctx.arc(p.x,p.y,p.radius+3,0,Math.PI*2); ctx.strokeStyle='rgba(255,0,110,0.35)'; ctx.lineWidth=2; ctx.stroke();
      }
    }
  } else {
    // only pinned + balls + soft centers + rope points
    for(const p of engine.points){
      const isBall = p.color===CONFIG.COLORS.ball;
      const isRope = p.color===CONFIG.COLORS.rope;
      const isSoft = p.color===CONFIG.COLORS.soft;
      if(p.pinned || isBall || (isSoft && p.radius>3) ){
        ctx.beginPath(); ctx.arc(p.x,p.y,p.radius+ (isBall?0:0),0,Math.PI*2);
        ctx.fillStyle= p.pinned? CONFIG.COLORS.pin : p.color;
        ctx.fill();
        if(p.pinned){ ctx.strokeStyle='#fff'; ctx.lineWidth=1.2; ctx.stroke(); }
        if(isBall){
          // highlight
          ctx.beginPath(); ctx.arc(p.x-3,p.y-3, p.radius*0.35,0,Math.PI*2);
          ctx.fillStyle='rgba(255,255,255,0.45)'; ctx.fill();
        }
      } else if(p.pinned){
        ctx.beginPath(); ctx.arc(p.x,p.y,5,0,Math.PI*2); ctx.fillStyle=CONFIG.COLORS.pin; ctx.fill();
      }
    }
    // pinned cloth points
    for(const p of engine.points){
      if(p.pinned && p.color===CONFIG.COLORS.cloth){
        ctx.beginPath(); ctx.arc(p.x,p.y,4,0,Math.PI*2); ctx.fillStyle=CONFIG.COLORS.pin; ctx.fill();
        ctx.strokeStyle='#fff'; ctx.lineWidth=1; ctx.stroke();
      }
    }
  }

  // grabbed highlight
  if(grabbed){
    ctx.beginPath(); ctx.arc(grabbed.x,grabbed.y, CONFIG.GRAB_RADIUS,0,Math.PI*2);
    ctx.strokeStyle='rgba(76,201,240,0.35)'; ctx.lineWidth=2; ctx.setLineDash([6,6]); ctx.stroke(); ctx.setLineDash([]);
    ctx.beginPath(); ctx.arc(grabbed.x,grabbed.y,6,0,Math.PI*2); ctx.fillStyle='#fff'; ctx.fill();
  }

  // drag preview
  if(isDragging && dragStart && dragEnd){
    ctx.setLineDash([6,6]);
    ctx.strokeStyle='rgba(255,255,255,0.7)'; ctx.lineWidth=1.5;
    if(tool==='rope'){
      ctx.beginPath(); ctx.moveTo(dragStart.x,dragStart.y); ctx.lineTo(dragEnd.x,dragEnd.y); ctx.stroke();
      ctx.fillStyle=CONFIG.COLORS.rope; ctx.beginPath(); ctx.arc(dragStart.x,dragStart.y,5,0,Math.PI*2); ctx.fill();
    } else if(tool==='cloth'){
      const x=Math.min(dragStart.x,dragEnd.x), y=Math.min(dragStart.y,dragEnd.y);
      const w=Math.abs(dragEnd.x-dragStart.x), h=Math.abs(dragEnd.y-dragStart.y);
      ctx.strokeRect(x,y,w,h);
      ctx.fillStyle='rgba(76,201,240,0.12)'; ctx.fillRect(x,y,w,h);
    } else {
      ctx.beginPath(); ctx.arc(dragEnd.x,dragEnd.y,18,0,Math.PI*2); ctx.stroke();
    }
    ctx.setLineDash([]);
    ctx.fillStyle='rgba(255,255,255,0.9)'; ctx.font='11px system-ui'; ctx.fillText(tool, dragEnd.x+8, dragEnd.y-8);
  }

  // tear line
  if(isTearing && tearPrev){
    ctx.strokeStyle='#ff3b5c'; ctx.lineWidth=3; ctx.beginPath();
    ctx.moveTo(tearPrev.x, tearPrev.y); ctx.lineTo(lastMouse.x,lastMouse.y); ctx.stroke();
    ctx.fillStyle='#ff3b5c'; ctx.beginPath(); ctx.arc(lastMouse.x,lastMouse.y,4,0,Math.PI*2); ctx.fill();
  }
}

/* ============================================================
   MAIN LOOP — fixed timestep accumulator
   ============================================================ */
let acc=0, lastT=performance.now(), fps=60, frameCount=0, fpsTime=0;
let physTime=0;

function frame(now){
  requestAnimationFrame(frame);
  let dt=(now - lastT)/1000;
  lastT=now;
  dt=Math.min(dt, CONFIG.MAX_DT);
  // FPS
  frameCount++; fpsTime+=dt;
  if(fpsTime>0.3){ fps=Math.round(frameCount/fpsTime); frameCount=0; fpsTime=0; }

  if(!engine.paused){
    acc+=dt;
    let steps=0;
    const t0=performance.now();
    while(acc >= CONFIG.TIMESTEP && steps < CONFIG.MAX_SUBSTEPS){
      engine.step(CONFIG.TIMESTEP);
      acc-=CONFIG.TIMESTEP;
      steps++;
    }
    // if still has leftover due to clamp, drop it (prevents spiral)
    if(steps>=CONFIG.MAX_SUBSTEPS) acc=0;
    physTime=performance.now()-t0;
  }
  render();
  // stats
  document.getElementById('sFps').textContent=fps;
  document.getElementById('sPhys').textContent=physTime.toFixed(1)+'ms';
  document.getElementById('sPts').textContent=engine.points.length;
  document.getElementById('sCons').textContent=engine.constraints.filter(c=>!c.broken).length;
  document.getElementById('sEng').textContent=engine.kineticEnergy().toFixed(1);
}
requestAnimationFrame(frame);

/* ============================================================
   SELF-CHECKS (exposed helpers)
   ============================================================ */
window.Point = Point;
window.Constraint = Constraint;
window.spawnRope = spawnRope;
window.spawnCloth = spawnCloth;
window.spawnSoftBody = spawnSoftBody;
window.spawnBall = spawnBall;
window.engine = engine;
window.simCheck = {
  dropSoftSettle(){
    engine.clear();
    const pts=spawnSoftBody(engine, W*0.5, 80, 44, 10);
    engine.gravity=980;
    for(let i=0;i<180;i++) Engine.prototype.step.call(engine, CONFIG.TIMESTEP);
    const ke=engine.kineticEnergy();
    const belowFloor=engine.points.some(p=>p.y + p.radius > engine.floorY + 1);
    const hasNaN=engine.points.some(p=>!Number.isFinite(p.x)||!Number.isFinite(p.y));
    return {ke, belowFloor, hasNaN, points:engine.points.length};
  }
};

</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.2-contributor","provider":"openrouter","persona":"full","sessionId":"b5f5d6c2-2a06-46fd-8844-065a1d242b8e","tokensIn":765145,"tokensOut":22321,"tokensTotal":787466,"cost":0.025463660000000003,"turns":20,"toolCalls":19,"failedToolCalls":0,"timestamp":"2026-08-23T01:09:42.565Z"} -->