Physics Playground
ox alpha · full

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"} -->