← Physics Playground results

Physics Playground

ox alpha · 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 21,088 bytes · SHA-256 5b4d12fa81ad
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Soft-Body Playground</title>
<style>
  html,body{margin:0;height:100%;overflow:hidden;background:#0b0e13;font-family:'Segoe UI',system-ui,sans-serif;}
  #cv{display:block;width:100vw;height:100vh;cursor:crosshair;}
  #hud{position:fixed;top:10px;left:10px;background:rgba(16,20,28,.85);border:1px solid #232c3b;
       border-radius:10px;padding:10px 12px;color:#cfd8e3;font-size:12px;user-select:none;
       backdrop-filter:blur(4px);min-width:190px;}
  #hud h1{font-size:12px;margin:0 0 8px;letter-spacing:.14em;color:#7fd1ff;font-weight:600;}
  .row{display:flex;align-items:center;gap:6px;margin:5px 0;}
  .row label{flex:1;color:#8fa1b5;}
  button{background:#1b2432;border:1px solid #2d3a4f;color:#cfe3ff;border-radius:6px;padding:4px 9px;
         font-size:11px;cursor:pointer;margin:2px 2px 2px 0;}
  button:hover{background:#26344a;}
  button.on{background:#14507a;border-color:#2f86c4;color:#fff;}
  input[type=range]{width:96px;accent-color:#4fc3f7;}
  #stats{margin-top:8px;color:#69d17a;font-size:11px;line-height:1.5;}
  #help{position:fixed;bottom:10px;left:10px;color:#5c6b7d;font-size:11px;user-select:none;}
</style>
</head>
<body>
<canvas id="cv"></canvas>
<div id="hud">
  <h1>SOFT-BODY LAB</h1>
  <div class="row">
    <button id="m-rope">Rope</button><button id="m-cloth">Cloth</button>
    <button id="m-blob">Blob</button><button id="m-ball">Ball</button><button id="m-grab" class="on">Grab</button>
  </div>
  <div class="row"><label>Gravity</label><input type="range" id="s-grav" min="0" max="3000" value="1200"><span id="v-grav">1200</span></div>
  <div class="row"><label>Iterations</label><input type="range" id="s-it" min="1" max="16" value="6"><span id="v-it">6</span></div>
  <div class="row"><label>Damping</label><input type="range" id="s-damp" min="990" max="1000" value="996"><span id="v-damp">0.996</span></div>
  <div class="row">
    <button id="b-grav" class="on">Gravity ON</button>
    <button id="b-wind">Wind</button>
    <button id="b-stress">Stress</button>
  </div>
  <div class="row">
    <select id="pinmode" style="background:#1b2432;color:#cfe3ff;border:1px solid #2d3a4f;border-radius:6px;padding:3px;font-size:11px;">
      <option value="corners">Pins: corners</option><option value="full">Pins: full edge</option><option value="none">Pins: none</option>
    </select>
    <button id="b-reset">Reset</button>
  </div>
  <div id="stats">fps -- | phys -- ms | pts 0 | con 0</div>
</div>
<div id="help">drag = use tool · right-drag = tear · shift+click = pin/unpin · Space = pause · → = step · G = gravity · R = reset</div>
<script>
'use strict';
/* ============================================================
   TUNABLE CONSTANTS
============================================================ */
const CFG = {
  DT: 1/60,            // fixed physics timestep
  MAX_FRAME: 0.05,     // clamp frame delta (tab-switch safety)
  ITER: 6,             // constraint relaxation iterations
  GRAVITY: 1200,
  DAMPING: 0.996,
  GRAB_R: 24,          // grab pick radius
  TEAR_R: 14,
  ROPE_SEG: 18,
  CLOTH_SPACING: 20,
  BALL_R: 14,
  BLOB_R: 46,
  RESTITUTION: 0.55,
  FRICTION: 0.82,      // tangential keep-ratio on contact
  MAX_V: 4000,         // per-step velocity clamp (stability)
  COLORS: { rope:'#4fc3f7', cloth:'#ba68c8', blob:'#66bb6a',
            ball:'#ffb74d', brace:'rgba(255,255,255,.25)', stat:'#3a4656' }
};

/* ============================================================
   ENGINE — Verlet points + distance constraints
============================================================ */
let points=[], constraints=[], bodies=[];
let nextId=1;

function addPoint(x,y,pinned=false,body=null,radius=0){
  const p={id:nextId++,x,y,px:x,py:y,pinned,body,radius,mass:1};
  points.push(p); return p;
}
function addCon(a,b,stiff=1,type='edge'){
  const dx=b.x-a.x, dy=b.y-a.y;
  const c={a,b,len:Math.hypot(dx,dy),stiff,type,tearable:true};
  constraints.push(c); return c;
}

function integrate(dt){
  const g = state.gravityOn ? state.gravity : 0;
  const ax = state.wind ? Math.sin(performance.now()*0.001)*260 : 0;
  const ay = g, dt2 = dt*dt, d = CFG.DAMPING;
  for(const p of points){
    if(p.pinned) continue;
    let vx=(p.x-p.px)*d, vy=(p.y-p.py)*d;
    // velocity clamp
    const sp=Math.hypot(vx,vy);
    if(sp>CFG.MAX_V){ vx*=CFG.MAX_V/sp; vy*=CFG.MAX_V/sp; }
    p.px=p.x; p.py=p.y;
    p.x+=vx+ax*dt2; p.y+=vy+ay*dt2;
    if(!isFinite(p.x)||!isFinite(p.y)){ p.x=p.px; p.y=p.py; }
  }
}

function solveConstraints(iters){
  for(let k=0;k<iters;k++){
    for(const c of constraints){
      const a=c.a,b=c.b;
      let dx=b.x-a.x, dy=b.y-a.y;
      let d2=dx*dx+dy*dy;
      if(d2<1e-12) continue;               // NaN guard: coincident points
      const d=Math.sqrt(d2);
      const diff=(d-c.len)/d*0.5*c.stiff;
      const ox=dx*diff, oy=dy*diff;
      if(!a.pinned){ if(b.pinned){a.x+=ox*2;a.y+=oy*2;} else {a.x+=ox;a.y+=oy;} }
      if(!b.pinned){ if(a.pinned){b.x-=ox*2;b.y-=oy*2;} else {b.x-=ox;b.y-=oy;} }
    }
    collideAll(true);
  }
  collideAll(false); // final pass: friction + restitution once per step
}

/* ---- collisions: world bounds + static shapes ---- */
const STATIC_CIRCLES=[]; // {x,y,r}
const STATIC_BOXES=[];   // {x,y,w,h}

function collidePoint(p,project){
  const W=view.w,H=view.h;
  const r=p.radius||0;
  // floor / walls
  if(p.y>H-r){ p.y=H-r; if(!project)groundFriction(p,true);  if(!project)bounceY(p); }
  if(p.y<r){  p.y=r;   if(!project)bounceY(p); }
  if(p.x<r){  p.x=r;   if(!project)bounceX(p); }
  if(p.x>W-r){p.x=W-r; if(!project)bounceX(p); }
  // static circles
  for(const s of STATIC_CIRCLES){
    const dx=p.x-s.x, dy=p.y-s.y, dd=Math.hypot(dx,dy), min=s.r+r;
    if(dd<min && dd>1e-9){
      const nx=dx/dd, ny=dy/dd;
      p.x=s.x+nx*min; p.y=s.y+ny*min;
      if(!project){ groundFriction(p,false,nx,ny); reflect(p,nx,ny); }
    }
  }
  // static boxes (treat as solid rects)
  for(const bx of STATIC_BOXES){
    if(p.x>bx.x-r&&p.x<bx.x+bx.w+r&&p.y>bx.y-r&&p.y<bx.y+bx.h+r){
      // push out along smallest penetration axis
      const dl=p.x-(bx.x-r), dr=(bx.x+bx.w+r)-p.x, dt_=p.y-(bx.y-r), db=(bx.y+bx.h+r)-p.y;
      const m=Math.min(dl,dr,dt_,db);
      if(m===dt_){ p.y=bx.y-r; if(!project){groundFriction(p,true);bounceY(p);} }
      else if(m===db){ p.y=bx.y+bx.h+r; if(!project)bounceY(p); }
      else if(m===dl){ p.x=bx.x-r; if(!project)bounceX(p); }
      else { p.x=bx.x+bx.w+r; if(!project)bounceX(p); }
    }
  }
}
function bounceY(p){ const v=p.y-p.py; if(v<0) p.py=p.y+v*CFG.RESTITUTION; else p.py=p.y; }
function bounceX(p){ const v=p.x-p.px; if(v<0) p.px=p.x+v*CFG.RESTITUTION; else p.px=p.x; }
function reflect(p,nx,ny){
  let vx=p.x-p.px, vy=p.y-p.py;
  const vn=vx*nx+vy*ny;
  if(vn<0){ vx-=vn*nx*(1+CFG.RESTITUTION); vy-=vn*ny*(1+CFG.RESTITUTION);
            p.px=p.x-vx; p.py=p.y-vy; }
}
function groundFriction(p,onFloor,nx,ny){
  // kill most tangential motion on contact
  let tx,ty;
  if(onFloor){ tx=1;ty=0; } else { tx=-ny; ty=nx; }
  const vt=(p.x-p.px)*tx+(p.y-p.py)*ty;
  p.px+=vt*(1-CFG.FRICTION)*tx; p.py+=vt*(1-CFG.FRICTION)*ty;
}
function collideAll(){ for(const p of points) collidePoint(p); }

/* ---- one fixed physics step ---- */
function physStep(){
  const t0=performance.now();
  if(grab.point && !grab.tearing){
    const p=grab.point;
    p.x=mouse.x; p.y=mouse.y; p.px=mouse.lx??mouse.x; p.py=mouse.ly??mouse.y;
  }
  integrate(CFG.DT);
  solveConstraints(state.iters);
  stats.phys = performance.now()-t0;
  mouse.lx=mouse.x; mouse.ly=mouse.y;
}

/* ============================================================
   SPAWNABLE BODIES
============================================================ */
function spawnRope(x0,y0,x1,y1){
  const body={type:'rope',pts:[],cons:[]};
  const n=Math.max(4,Math.round(Math.hypot(x1-x0,y1-y0)/CFG.ROPE_SEG));
  for(let i=0;i<=n;i++){
    const t=i/n;
    const p=addPoint(x0+(x1-x0)*t, y0+(y1-y0)*t, i===0, body);
    body.pts.push(p);
  }
  for(let i=0;i<n;i++) body.cons.push(addCon(body.pts[i],body.pts[i+1],1,'edge'));
  bodies.push(body); return body;
}
function spawnCloth(x0,y0,x1,y1,pinMode){
  const sp=CFG.CLOTH_SPACING;
  const cols=Math.max(3,Math.min(30,Math.round(Math.abs(x1-x0)/sp)));
  const rows=Math.max(3,Math.min(24,Math.round(Math.abs(y1-y0)/sp)));
  const Lx=Math.min(x0,x1), Ly=Math.min(y0,y1);
  const body={type:'cloth',pts:[],cons:[],grid:[],cols,rows};
  for(let r=0;r<=rows;r++){ const row=[];
    for(let c=0;c<=cols;c++){
      let pinned=false;
      if(r===0) pinned = pinMode==='full' || (pinMode==='corners' && (c===0||c===cols));
      const p=addPoint(Lx+c*sp*( (x1-x0)<0?-1:1 ), Ly+r*sp, pinned, body);
      row.push(p); body.pts.push(p);
    } body.grid.push(row);
  }
  for(let r=0;r<=rows;r++)for(let c=0;c<=cols;c++){
    if(c<cols) body.cons.push(addCon(body.grid[r][c],body.grid[r][c+1],0.9,'edge'));
    if(r<rows) body.cons.push(addCon(body.grid[r][c],body.grid[r+1][c],0.9,'edge'));
    if(c<cols&&r<rows){ // shear
      body.cons.push(addCon(body.grid[r][c],body.grid[r+1][c+1],0.6,'shear'));
      body.cons.push(addCon(body.grid[r][c+1],body.grid[r+1][c],0.6,'shear'));
    }
    if(r<rows-1) body.cons.push(addCon(body.grid[r][c],body.grid[r+2][c],0.35,'bend'));
    if(c<cols-1) body.cons.push(addCon(body.grid[r][c],body.grid[r][c+2],0.35,'bend'));
  }
  bodies.push(body); return body;
}
function spawnBlob(cx,cy,R=CFG.BLOB_R){
  const body={type:'blob',pts:[],cons:[],ring:[]};
  const n=14;
  const center=addPoint(cx,cy,false,body);
  body.center=center; body.pts.push(center);
  for(let i=0;i<n;i++){
    const a=i/n*Math.PI*2;
    const p=addPoint(cx+Math.cos(a)*R, cy+Math.sin(a)*R, false, body);
    body.ring.push(p); body.pts.push(p);
  }
  for(let i=0;i<n;i++){
    body.cons.push(addCon(body.ring[i],body.ring[(i+1)%n],1,'edge'));
    body.cons.push(addCon(body.ring[i],body.ring[(i+2)%n],0.8,'brace'));
    body.cons.push(addCon(body.ring[i],center,0.5,'brace'));
  }
  bodies.push(body); return body;
}
function spawnBall(x,y,r=CFG.BALL_R){
  const body={type:'ball',pts:[],cons:[]};
  body.pts.push(addPoint(x,y,false,body,r));
  bodies.push(body); return body;
}

/* ============================================================
   STATIC SCENE + RESET
============================================================ */
const view={w:0,h:0};
function buildScene(){
  points=[];constraints=[];bodies=[];nextId=1;
  STATIC_CIRCLES.length=0; STATIC_BOXES.length=0;
  const W=view.w,H=view.h;
  STATIC_CIRCLES.push({x:W*0.5,y:H*0.62,r:Math.min(W,H)*0.11});
  STATIC_BOXES.push({x:W*0.78,y:H*0.72,w:150,h:22});
  spawnRope(W*0.18,H*0.08,W*0.18,H*0.42);                 // pendulum
  spawnCloth(W*0.38,H*0.06,W*0.62,H*0.42,state.pinMode);  // drapes over circle
  spawnBlob(W*0.85,H*0.25);
  spawnBall(W*0.7,H*0.15);
}

/* ============================================================
   STATE / INPUT / INTERACTION
============================================================ */
const state={gravityOn:true,gravity:CFG.GRAVITY,iters:CFG.ITER,wind:false,
             stress:false,paused:false,pinMode:'corners',tool:'grab'};
const mouse={x:0,y:0,lx:null,ly:null,down:false,btn:0};
const grab={point:null};
let tearSparks=[];
const cv=document.getElementById('cv'), ctx=cv.getContext('2d');

function nearestPoint(x,y,maxR){
  let best=null,bd=maxR*maxR;
  for(const p of points){
    const d=(p.x-x)**2+(p.y-y)**2;
    if(d<bd){bd=d;best=p;}
  }
  return best;
}
/* segment-segment intersection for tearing */
function segInt(ax,ay,bx,by,cx,cy,dx,dy){
  const rX=bx-ax,rY=by-ay,sX=dx-cx,sY=dy-cy;
  const den=rX*sY-rY*sX; if(Math.abs(den)<1e-12)return false;
  const t=((cx-ax)*sY-(cy-ay)*sX)/den, u=((cx-ax)*rY-(cy-ay)*rX)/den;
  return t>=0&&t<=1&&u>=0&&u<=1;
}
function tearAlong(x0,y0,x1,y1){
  const dead=[];
  for(const c of constraints){
    if(!c.tearable) continue;
    if(segInt(x0,y0,x1,y1,c.a.x,c.a.y,c.b.x,c.b.y)){
      dead.push(c);
      tearSparks.push({x:(c.a.x+c.b.x)/2,y:(c.a.y+c.b.y)/2,life:1});
    }
  }
  for(const c of dead) removeConstraint(c);
}
function removeConstraint(c){
  const i=constraints.indexOf(c); if(i>=0) constraints.splice(i,1);
  if(c.a.body){ const j=c.a.body.cons.indexOf(c); if(j>=0)c.a.body.cons.splice(j,1); }
}
cv.addEventListener('contextmenu',e=>e.preventDefault());
cv.addEventListener('mousedown',e=>{
  const r=cv.getBoundingClientRect();
  mouse.x=e.clientX-r.left; mouse.y=e.clientY-r.top; mouse.lx=null;
  mouse.down=true; mouse.btn=e.button;
  if(e.button===2){ grab.point=null; return; }
  if(e.shiftKey){
    const p=nearestPoint(mouse.x,mouse.y,CFG.TEAR_R*2);
    if(p){ p.pinned=!p.pinned; p.px=p.x; p.py=p.y; }
    mouse.down=false; return;
  }
  if(state.tool==='grab'){
    grab.point=nearestPoint(mouse.x,mouse.y,CFG.GRAB_R);
  } else if(state.tool==='ball'){
    spawnBall(mouse.x,mouse.y); state.tool='grab'; syncToolUI();
  } else if(state.tool==='blob'){
    spawnBlob(mouse.x,mouse.y); state.tool='grab'; syncToolUI();
  }
});
window.addEventListener('mousemove',e=>{
  const r=cv.getBoundingClientRect();
  const nx=e.clientX-r.left, ny=e.clientY-r.top;
  if(mouse.down&&mouse.btn===2&&(mouse.lx!==null)) tearAlong(mouse.lx,mouse.ly,nx,ny);
  mouse.x=nx; mouse.y=ny;
});
window.addEventListener('mouseup',e=>{
  if(e.button===0&&state.tool!=='grab'&&mouse.down){
    if(state.tool==='rope') spawnRope(downPt.x,downPt.y,mouse.x,mouse.y);
    else if(state.tool==='cloth') spawnCloth(downPt.x,downPt.y,mouse.x,mouse.y,state.pinMode);
    state.tool='grab'; syncToolUI();
  }
  if(grab.point){
    // throw with drag velocity
    const p=grab.point;
    p.px=p.x-(mouse.x-(mouse.lx??mouse.x)); p.py=p.y-(mouse.y-(mouse.ly??mouse.y));
    grab.point=null;
  }
  mouse.down=false;
});
let downPt={x:0,y:0};
cv.addEventListener('mousedown',e=>{ downPt.x=mouse.x; downPt.y=mouse.y; });

window.addEventListener('keydown',e=>{
  if(e.code==='Space'){ e.preventDefault(); state.paused=!state.paused; }
  else if(e.code==='ArrowRight'&&state.paused) physStep();
  else if(e.key==='g'||e.key==='G') toggleGravity();
  else if(e.key==='r'||e.key==='R') buildScene();
});

/* ---- toolbar wiring ---- */
function setTool(t){ state.tool=t; syncToolUI(); }
function syncToolUI(){
  for(const t of ['rope','cloth','blob','ball','grab'])
    document.getElementById('m-'+t).classList.toggle('on',state.tool===t);
}
for(const t of ['rope','cloth','blob','ball','grab'])
  document.getElementById('m-'+t).onclick=()=>setTool(t);
const sGrav=document.getElementById('s-grav');
sGrav.oninput=()=>{ state.gravity=+sGrav.value; document.getElementById('v-grav').textContent=sGrav.value; };
const sIt=document.getElementById('s-it');
sIt.oninput=()=>{ state.iters=+sIt.value; document.getElementById('v-it').textContent=sIt.value; };
const sDamp=document.getElementById('s-damp');
sDamp.oninput=()=>{ CFG.DAMPING=+sDamp.value/1000; document.getElementById('v-damp').textContent=(+sDamp.value/1000).toFixed(3); };
function toggleGravity(){ state.gravityOn=!state.gravityOn;
  document.getElementById('b-grav').classList.toggle('on',state.gravityOn);
  document.getElementById('b-grav').textContent=state.gravityOn?'Gravity ON':'Gravity OFF'; }
document.getElementById('b-grav').onclick=toggleGravity;
document.getElementById('b-wind').onclick=function(){ state.wind=!state.wind; this.classList.toggle('on'); };
document.getElementById('b-stress').onclick=function(){ state.stress=!state.stress; this.classList.toggle('on'); };
document.getElementById('pinmode').onchange=function(){ state.pinMode=this.value; };
document.getElementById('b-reset').onclick=buildScene;

/* ============================================================
   RENDERING
============================================================ */
const stats={fps:60,phys:0};
function stressColor(c){
  const d=Math.hypot(c.b.x-c.a.x,c.b.y-c.a.y);
  const s=Math.min(1,Math.max(0,(d/c.len-1)*6));
  return `hsl(${120-s*120},90%,55%)`;
}
function draw(){
  ctx.fillStyle='rgba(11,14,19,0.55)';           // motion-blur trail
  ctx.fillRect(0,0,view.w,view.h);
  // static geometry
  ctx.strokeStyle='#3a4656'; ctx.lineWidth=2;
  for(const s of STATIC_CIRCLES){ ctx.beginPath();ctx.arc(s.x,s.y,s.r,0,7);ctx.stroke();
    ctx.fillStyle='rgba(58,70,86,.35)';ctx.fill(); }
  for(const b of STATIC_BOXES){ ctx.strokeRect(b.x,b.y,b.w,b.h);
    ctx.fillStyle='rgba(58,70,86,.35)';ctx.fillRect(b.x,b.y,b.w,b.h); }
  // floor line
  ctx.strokeStyle='#232c3b';ctx.beginPath();ctx.moveTo(0,view.h-1);ctx.lineTo(view.w,view.h-1);ctx.stroke();

  for(const body of bodies){
    if(body.type==='ball'){
      const p=body.pts[0];
      ctx.beginPath();ctx.arc(p.x,p.y,p.radius,0,7);
      ctx.fillStyle=CFG.COLORS.ball;ctx.fill();
      continue;
    }
    if(body.type==='blob'){
      ctx.beginPath();
      const ring=body.ring.filter(p=>connected(body,p));
      if(ring.length>2){
        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='rgba(102,187,106,.45)';ctx.fill();
      }
      continue;
    }
    if(body.type==='cloth'){
      for(const c of body.cons){
        if(c.type==='bend')continue;
        ctx.strokeStyle=c.type==='shear'?'rgba(186,104,200,.28)':CFG.COLORS.cloth;
        ctx.lineWidth=c.type==='shear'?0.5:1.2;
        ctx.beginPath();ctx.moveTo(c.a.x,c.a.y);ctx.lineTo(c.b.x,c.b.y);ctx.stroke();
      }
      continue;
    }
    // rope
    for(const c of body.cons){
      ctx.strokeStyle=CFG.COLORS.rope;ctx.lineWidth=2.5;
      ctx.beginPath();ctx.moveTo(c.a.x,c.a.y);ctx.lineTo(c.b.x,c.b.y);ctx.stroke();
    }
  }
  // stress overlay pass
  if(state.stress){
    ctx.lineWidth=2;
    for(const c of constraints){
      if(c.type==='bend'||c.type==='shear')continue;
      ctx.strokeStyle=stressColor(c);
      ctx.beginPath();ctx.moveTo(c.a.x,c.a.y);ctx.lineTo(c.b.x,c.b.y);ctx.stroke();
    }
  }
  // pins & points
  for(const p of points){
    if(p.pinned){ ctx.fillStyle='#ff5252';
      ctx.beginPath();ctx.arc(p.x,p.y,4,0,7);ctx.fill(); }
    else if(p.body&&(p.body.type==='rope'||p.body.type==='blob')){
      ctx.fillStyle='rgba(255,255,255,.5)';
      ctx.fillRect(p.x-1,p.y-1,2,2);
    }
  }
  // grab highlight
  if(grab.point){ ctx.strokeStyle='#fff';ctx.lineWidth=1.5;
    ctx.beginPath();ctx.arc(grab.point.x,grab.point.y,10,0,7);ctx.stroke(); }
  // tear sparks
  for(const s of tearSparks){ ctx.fillStyle=`rgba(255,120,80,${s.life})`;
    ctx.beginPath();ctx.arc(s.x,s.y,5*(s.life),0,7);ctx.fill(); }
  // tool preview
  if(mouse.down&&mouse.btn===0&&(state.tool==='rope'||state.tool==='cloth')){
    ctx.strokeStyle='rgba(255,255,255,.4)';ctx.setLineDash([5,5]);
    ctx.strokeRect(Math.min(downPt.x,mouse.x),Math.min(downPt.y,mouse.y),
                   Math.abs(mouse.x-downPt.x),Math.abs(mouse.y-downPt.y));
    ctx.setLineDash([]);
  }
  if(state.paused){ ctx.fillStyle='#ffd54f';ctx.font='600 13px sans-serif';
    ctx.fillText('PAUSED — → to step',view.w/2-70,26); }
}
function connected(body,p){ // is point still part of an edge in the ring?
  return constraints.some(c=>(c.a===p||c.b===p)&&c.type==='edge'&&c.a.body===body);
}

/* ============================================================
   MAIN LOOP — fixed timestep accumulator
============================================================ */
let acc=0,lastT=performance.now(),fpsE=60;
function frame(now){
  let dt=(now-lastT)/1000; lastT=now;
  fpsE+= (1/Math.max(dt,1e-4)-fpsE)*0.05;
  dt=Math.min(dt,CFG.MAX_FRAME);          // tab-switch clamp
  acc+=dt;
  while(acc>=CFG.DT){ physStep(); acc-=CFG.DT; }
  for(const s of tearSparks)s.life-=dt*3;
  tearSparks=tearSparks.filter(s=>s.life>0);
  draw();
  stats.fps=fpsE;
  document.getElementById('stats').textContent=
    `fps ${fpsE.toFixed(0)} | phys ${stats.phys.toFixed(2)} ms | pts ${points.length} | con ${constraints.length}`;
  requestAnimationFrame(frame);
}
function resize(){
  view.w=innerWidth; view.h=innerHeight;
  cv.width=view.w; cv.height=view.h;
}
window.addEventListener('resize',()=>{resize();});
resize(); buildScene();
requestAnimationFrame(frame);

/* ============================================================
   HEADLESS SELF-TEST API
============================================================ */
window.sim={
  get points(){return points}, get constraints(){return constraints},
  get bodies(){return bodies}, state,
  step:n=>{for(let i=0;i<n;i++)physStep();},
  physStep, spawnBlob,spawnBall,spawnRope,spawnCloth, buildScene,
  removeConstraint, tearAlong,
  ke:()=>points.reduce((s,p)=>{
        if(p.pinned)return s;
        const vx=(p.x-p.px)/CFG.DT,vy=(p.y-p.py)/CFG.DT;return s+0.5*(vx*vx+vy*vy);},0)
};
</script>
</body>
</html>
<!-- agent-meta {"model":"stealth/ox-alpha","provider":"openrouter","persona":"full","sessionId":"2bc8968b-8bd0-49ac-9599-2b6201d56239","tokensIn":462344,"tokensOut":15860,"tokensTotal":478204,"cost":0,"turns":22,"toolCalls":21,"failedToolCalls":0,"timestamp":"2026-08-21T21:51:21.537Z"} -->