← Physics Playground results

Physics Playground

GPT 6 Luna · 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 25,118 bytes · SHA-256 ae8c91530373
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>Softbody / Physics Playground</title>
<style>
:root{--bg:#10151d;--panel:rgba(23,31,43,.91);--line:rgba(185,209,239,.12);--muted:#8493a8;--text:#e9f0fa;--cyan:#66e4d1;--blue:#5ca9ff;--purple:#c58aff;--gold:#ffca68}
*{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;overflow:hidden;background:var(--bg);color:var(--text);font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif}canvas{position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none;cursor:crosshair}
header{position:absolute;z-index:3;left:20px;top:17px;display:flex;align-items:center;gap:11px;pointer-events:none}.mark{width:32px;height:32px;border:1px solid #3b5f75;border-radius:10px;display:grid;place-items:center;color:#76f1dd;background:linear-gradient(145deg,#19343a,#1c2736);box-shadow:0 6px 24px #05090e}.mark svg{width:20px}.brand{font-size:13px;font-weight:760;letter-spacing:.16em}.subbrand{font-size:10px;color:#8997a9;letter-spacing:.14em;margin-top:3px}
.panel{position:absolute;z-index:2;background:var(--panel);border:1px solid var(--line);box-shadow:0 14px 44px #05091066,inset 0 1px #ffffff09;backdrop-filter:blur(18px);border-radius:14px}.toolbar{left:19px;top:70px;padding:10px;width:170px}.section-label{color:#75869e;font-size:9px;letter-spacing:.17em;font-weight:750;margin:2px 7px 8px}.tool{width:100%;display:flex;align-items:center;gap:10px;padding:9px 10px;background:transparent;border:1px solid transparent;border-radius:9px;color:#b7c4d6;text-align:left;font:500 12px inherit;cursor:pointer;transition:.16s}.tool:hover{background:#ffffff09;color:white}.tool.active{background:#5ce3ce14;border-color:#55dcc940;color:#8df4e2}.tool .ico{width:21px;height:20px;display:grid;place-items:center;color:currentColor;font-size:16px}.key{margin-left:auto;color:#627186;font-size:9px;font-weight:700}.divider{height:1px;background:var(--line);margin:8px 3px}.control-row{display:flex;align-items:center;justify-content:space-between;margin:10px 7px 5px;font-size:11px;color:#9eacc0}.value{color:#d5e3f5;font-variant-numeric:tabular-nums}.range{width:100%;height:3px;margin:4px 0 7px;accent-color:#65e5d2;cursor:pointer}.small-select{background:#202b3a;border:1px solid #344154;border-radius:6px;color:#cbd7e7;padding:4px 6px;font-size:10px;outline:none}.toggle-row{display:flex;align-items:center;justify-content:space-between;margin:10px 7px;color:#a4b1c3;font-size:11px}.switch{width:29px;height:16px;background:#344152;border-radius:12px;position:relative;cursor:pointer;transition:.2s}.switch:after{content:'';position:absolute;width:10px;height:10px;left:3px;top:3px;background:#8794a5;border-radius:50%;transition:.2s}.switch.on{background:#227d75}.switch.on:after{left:16px;background:#9effe7}
.top-right{top:20px;right:20px;display:flex;align-items:center;gap:10px}.status{padding:9px 12px;color:#99a9bd;font-size:10px;letter-spacing:.05em}.status b{color:#72e7cf;font-weight:700}.actions{display:flex;gap:5px;padding:5px}.icon-btn{height:32px;min-width:34px;border:0;border-radius:8px;background:transparent;color:#aab9cb;font-size:14px;cursor:pointer}.icon-btn:hover{background:#ffffff12;color:white}.bottom-hint{position:absolute;z-index:2;bottom:15px;left:20px;color:#8391a4;font-size:10px;letter-spacing:.02em;pointer-events:none}.bottom-hint kbd{border:1px solid #425063;background:#1d2632;border-radius:4px;padding:2px 5px;color:#c3cfde;font:10px inherit;margin:0 2px}.stats{position:absolute;z-index:2;bottom:15px;right:20px;color:#8b9aae;font:10px ui-monospace,SFMono-Regular,monospace;text-align:right;line-height:1.7;pointer-events:none}.toast{position:absolute;z-index:4;bottom:51px;left:50%;transform:translate(-50%,12px);background:#1b2936;border:1px solid #46616d;color:#bdeee3;padding:9px 14px;border-radius:8px;font-size:11px;opacity:0;transition:.2s;pointer-events:none}.toast.show{opacity:1;transform:translate(-50%,0)}
@media(max-width:650px){header{left:12px;top:12px}.toolbar{left:10px;top:58px;width:148px;padding:7px}.tool{padding:7px 8px}.top-right{top:10px;right:10px}.status{display:none}.bottom-hint{left:12px;bottom:9px}.stats{right:12px;bottom:9px}.section-label{margin-top:4px}.toggle-row{margin:8px 5px}.control-row{margin-left:5px;margin-right:5px}}
</style></head><body>
<canvas id="world"></canvas>
<header><div class="mark"><svg viewBox="0 0 24 24" fill="none"><path d="M4 7c4-5 12-5 16 0M4 17c4 5 12 5 16 0M4 7v10m16-10v10M4 7l8 5 8-5M4 17l8-5 8 5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg></div><div><div class="brand">TENSILE</div><div class="subbrand">SOFTBODY LAB</div></div></header>
<aside class="panel toolbar"><div class="section-label">CREATE / INTERACT</div>
<button class="tool active" data-tool="grab"><span class="ico">✥</span>Grab & move <span class="key">G</span></button>
<button class="tool" data-tool="rope"><span class="ico">⌁</span>Rope <span class="key">R</span></button>
<button class="tool" data-tool="cloth"><span class="ico">▦</span>Cloth <span class="key">C</span></button>
<button class="tool" data-tool="soft"><span class="ico">◉</span>Soft body <span class="key">B</span></button>
<button class="tool" data-tool="ball"><span class="ico">●</span>Ball <span class="key">O</span></button>
<div class="divider"></div><div class="section-label">SIMULATION</div>
<div class="control-row"><span>Gravity</span><span class="value" id="gravityVal">720</span></div><input class="range" id="gravity" type="range" min="0" max="1400" value="720">
<div class="control-row"><span>Relaxation</span><span class="value" id="iterVal">6</span></div><input class="range" id="iterations" type="range" min="2" max="10" value="6">
<div class="control-row"><span>Cloth pins</span><select class="small-select" id="pinMode"><option value="corners">Corners</option><option value="edge">Full edge</option></select></div>
<div class="toggle-row"><span>Stress colors</span><div class="switch" id="stressToggle"></div></div>
<div class="toggle-row"><span>Wind</span><div class="switch" id="windToggle"></div></div>
<div class="toggle-row"><span>Gravity on</span><div class="switch on" id="gravityToggle"></div></div>
</aside>
<div class="panel top-right"><div class="status"><b id="runState">● LIVE</b> &nbsp;·&nbsp; <span id="objectCount">0</span> OBJECTS</div><div class="actions"><button class="icon-btn" id="pauseBtn" title="Pause (Space)">Ⅱ</button><button class="icon-btn" id="stepBtn" title="Step one frame (←/→)">▸</button><button class="icon-btn" id="resetBtn" title="Reset scene">↻</button></div></div>
<div class="bottom-hint"><kbd>DRAG</kbd> place / grab &nbsp; <kbd>RIGHT DRAG</kbd> cut &nbsp; <kbd>SHIFT + CLICK</kbd> pin</div><div class="stats" id="stats">60 FPS &nbsp;·&nbsp; 0.0 ms<br>0 points · 0 links</div><div class="toast" id="toast"></div>
<script>
'use strict';
// ==================== Tunable constants ====================
const CFG={DT:1/60,MAX_FRAME:.05,DAMPING:.985,ITERATIONS:6,GRAVITY:720,TEAR_RATIO:2.35,TEAR_RADIUS:11,BOUNDS_PAD:18,FLOOR_PAD:44,MAX_SPEED:1600,CONTACT_FRICTION:.84,BOUNCE:.035,ROPE_SPACING:15,POINT_RADIUS:3.3};
// ==================== Canvas and simulation model ====================
const canvas=document.getElementById('world'),ctx=canvas.getContext('2d');let W=innerWidth,H=innerHeight,DPR=1;
function resize(){DPR=Math.min(devicePixelRatio||1,2);W=innerWidth;H=innerHeight;canvas.width=Math.round(W*DPR);canvas.height=Math.round(H*DPR);ctx.setTransform(DPR,0,0,DPR,0,0);if(sim)sim.resize();}window.addEventListener('resize',resize);
let nextPid=1,nextOid=1;
const colors={rope:'#67dfcc',cloth:'#6ba9ff',soft:'#c18bff',ball:'#ffc76c'};
class Particle{constructor(x,y,r=CFG.POINT_RADIUS,mass=1){this.id=nextPid++;this.x=x;this.y=y;this.px=x;this.py=y;this.r=r;this.mass=mass;this.invMass=mass?1/mass:0;this.pinX=x;this.pinY=y;this.grabbed=false;this.owner=null;}}
class Link{constructor(a,b,stiff=1,kind='struct'){this.a=a;this.b=b;this.rest=Math.hypot(b.x-a.x,b.y-a.y)||.001;this.stiff=stiff;this.kind=kind;this.active=true;this.owner=null;}}
class Body{constructor(type,color){this.id=nextOid++;this.type=type;this.color=color;this.points=[];this.links=[];this.rows=0;this.cols=0;this.meta={};}}
class Sim{constructor(){this.points=[];this.links=[];this.bodies=[];this.gravity=CFG.GRAVITY;this.gravityOn=true;this.wind=false;this.stress=false;this.iterations=CFG.ITERATIONS;this.acc=0;this.paused=false;this.time=0;this.stepMs=0;this.obstacles=[];this.lastNow=0;this.frameCount=0;this.fps=60;this.lastFps=performance.now();this.resize();}
resize(){this.floor=H-CFG.FLOOR_PAD;this.obstacles=[{type:'circle',x:W*.69,y:this.floor-91,r:65},{type:'box',x:W*.39,y:this.floor-18,w:110,h:18}];}
addParticle(p,body){p.owner=body;body.points.push(p);this.points.push(p);return p;}
addLink(a,b,body,stiff=1,kind='struct'){let l=new Link(a,b,stiff,kind);l.owner=body;body.links.push(l);this.links.push(l);return l;}
addBody(b){this.bodies.push(b);return b;}
spawnRope(x1,y1,x2,y2,count){let b=this.addBody(new Body('rope',colors.rope));let n=Math.max(3,Math.min(80,count||Math.ceil(Math.hypot(x2-x1,y2-y1)/CFG.ROPE_SPACING)));for(let i=0;i<n;i++){let t=i/(n-1),p=this.addParticle(new Particle(x1+(x2-x1)*t,y1+(y2-y1)*t,3.1),b);if(i===0){p.mass=0;p.invMass=0;p.pinX=p.x;p.pinY=p.y;}if(i)b.points[i-1]&&this.addLink(b.points[i-1],p,b,1,'struct');}return b;}
spawnCloth(x,y,w,h,pinMode='corners'){w=Math.max(65,w);h=Math.max(55,h);let b=this.addBody(new Body('cloth',colors.cloth));b.cols=Math.max(4,Math.min(18,Math.round(w/24)+1));b.rows=Math.max(4,Math.min(15,Math.round(h/23)+1));b.meta.pinMode=pinMode;
for(let j=0;j<b.rows;j++)for(let i=0;i<b.cols;i++){let p=this.addParticle(new Particle(x+w*i/(b.cols-1),y+h*j/(b.rows-1),2.5),b);if(j===0&&(pinMode==='edge'||i===0||i===b.cols-1)){p.mass=0;p.invMass=0;p.pinX=p.x;p.pinY=p.y;}}
const at=(i,j)=>b.points[j*b.cols+i];for(let j=0;j<b.rows;j++)for(let i=0;i<b.cols;i++){if(i<b.cols-1)this.addLink(at(i,j),at(i+1,j),b,1,'struct');if(j<b.rows-1)this.addLink(at(i,j),at(i,j+1),b,1,'struct');if(i<b.cols-1&&j<b.rows-1){this.addLink(at(i,j),at(i+1,j+1),b,.68,'shear');this.addLink(at(i+1,j),at(i,j+1),b,.68,'shear');if(i<b.cols-2)this.addLink(at(i,j),at(i+2,j),b,.22,'brace');if(j<b.rows-2)this.addLink(at(i,j),at(i,j+2),b,.22,'brace');}}return b;}
spawnSoft(x,y,size=46,shape='blob'){let b=this.addBody(new Body('soft',colors.soft));if(shape==='box'){let n=4; b.rows=n;b.cols=n;for(let j=0;j<n;j++)for(let i=0;i<n;i++)this.addParticle(new Particle(x-size+i*size*2/(n-1),y-size+j*size*2/(n-1),4),b);let at=(i,j)=>b.points[j*n+i];for(let j=0;j<n;j++)for(let i=0;i<n;i++){if(i<n-1)this.addLink(at(i,j),at(i+1,j),b,1);if(j<n-1)this.addLink(at(i,j),at(i,j+1),b,1);if(i<n-1&&j<n-1){this.addLink(at(i,j),at(i+1,j+1),b,.7,'shear');this.addLink(at(i+1,j),at(i,j+1),b,.7,'shear');}}for(let i=0;i<n;i++)this.addLink(at(i,0),at(n-1-i,n-1),b,.18,'brace');b.meta.shape='box';}
else{let n=16,c=this.addParticle(new Particle(x,y,4),b);for(let i=0;i<n;i++){let a=Math.PI*2*i/n,p=this.addParticle(new Particle(x+Math.cos(a)*size,y+Math.sin(a)*size,4),b);if(i===0)b.meta.ring=[];b.meta.ring.push(p);this.addLink(c,p,b,.72,'brace');}for(let i=0;i<n;i++){let a=b.meta.ring[i],q=b.meta.ring[(i+1)%n];this.addLink(a,q,b,1,'struct');this.addLink(a,b.meta.ring[(i+2)%n],b,.28,'brace');}}return b;}
spawnBall(x,y,r=17){let b=this.addBody(new Body('ball',colors.ball)),p=this.addParticle(new Particle(x,y,r,1),b);b.meta.radius=r;return b;}
removeLink(link){link.active=false;this.links=this.links.filter(x=>x!==link);if(link.owner)link.owner.links=link.owner.links.filter(x=>x!==link);}
cutAt(x,y,rad=CFG.TEAR_RADIUS){let cut=0;for(const l of [...this.links]){if(!l.active)continue;const d=pointSegDist(x,y,l.a.x,l.a.y,l.b.x,l.b.y);if(d<rad){this.removeLink(l);cut++;}}return cut;}
cutLink(link){if(!link||!link.active)return false;this.removeLink(link);return true;}
togglePin(p){if(!p)return false;if(p.invMass===0){p.invMass=1/p.mass;p.pinX=p.x;p.pinY=p.y;}else{p.invMass=0;p.pinX=p.x;p.pinY=p.y;p.px=p.x;p.py=p.y;}return true;}
step(){const start=performance.now(),dt=CFG.DT,g=this.gravityOn?this.gravity:0;for(const p of this.points){if(p.invMass===0){p.x=p.pinX;p.y=p.pinY;p.px=p.pinX;p.py=p.pinY;continue;}if(p.grabbed){p.px=p.x;p.py=p.y;continue;}let vx=(p.x-p.px)*CFG.DAMPING,vy=(p.y-p.py)*CFG.DAMPING;const speed=Math.hypot(vx,vy);if(speed>CFG.MAX_SPEED*dt){vx*=CFG.MAX_SPEED*dt/speed;vy*=CFG.MAX_SPEED*dt/speed;}p.px=p.x;p.py=p.y;p.x+=vx+(this.wind?Math.sin(this.time*1.7+p.y*.01)*35:0)*dt*dt;p.y+=vy+g*dt*dt;}
for(let k=0;k<this.iterations;k++){for(const l of this.links){if(!l.active)continue;let dx=l.b.x-l.a.x,dy=l.b.y-l.a.y,d2=dx*dx+dy*dy;if(d2<1e-12)continue;let d=Math.sqrt(d2),err=(d-l.rest)/d*l.stiff,wa=l.a.invMass,wb=l.b.invMass,sum=wa+wb;if(sum===0)continue;let cx=dx*err,cy=dy*err;l.a.x+=cx*wa/sum;l.a.y+=cy*wa/sum;l.b.x-=cx*wb/sum;l.b.y-=cy*wb/sum;}
for(const p of this.points)if(p.invMass>0&&!p.grabbed)this.collide(p);}
for(const p of this.points){if(p.invMass===0){p.x=p.pinX;p.y=p.pinY;}else if(!p.grabbed&&(!Number.isFinite(p.x)||!Number.isFinite(p.y))){p.x=W*.5;p.y=H*.3;p.px=p.x;p.py=p.y;}else if(!p.grabbed){const vx=(p.x-p.px)*.90,vy=(p.y-p.py)*.90;p.px=p.x-vx;p.py=p.y-vy;}}
if(this.time%0.25<dt){for(const l of [...this.links])if(l.active&&l.kind!=='brace'&&Math.hypot(l.b.x-l.a.x,l.b.y-l.a.y)>l.rest*CFG.TEAR_RATIO)this.removeLink(l);}
this.time+=dt;this.stepMs=performance.now()-start;}
collide(p){const min=CFG.BOUNDS_PAD+p.r,maxX=W-CFG.BOUNDS_PAD-p.r,floor=this.floor-p.r;let vx=p.x-p.px,vy=p.y-p.py;
if(p.x<min){p.x=min;p.px=p.x+vx*CFG.CONTACT_FRICTION;}if(p.x>maxX){p.x=maxX;p.px=p.x+vx*CFG.CONTACT_FRICTION;}if(p.y<min){p.y=min;p.py=p.y+vy*CFG.CONTACT_FRICTION;}if(p.y>floor){p.y=floor;p.py=p.y+Math.max(0,vy)*CFG.BOUNCE; p.px=p.x+vx*CFG.CONTACT_FRICTION;}
for(const o of this.obstacles){if(o.type==='circle'){let dx=p.x-o.x,dy=p.y-o.y,d=Math.hypot(dx,dy),need=o.r+p.r;if(d<need){if(d<1e-7){dx=0;dy=-1;d=1;}let nx=dx/d,ny=dy/d;p.x=o.x+nx*need;p.y=o.y+ny*need;let vn=vx*nx+vy*ny;p.px=p.x-(vx-vn*nx)*CFG.CONTACT_FRICTION+Math.max(0,vn)*nx*CFG.BOUNCE;p.py=p.y-(vy-vn*ny)*CFG.CONTACT_FRICTION+Math.max(0,vn)*ny*CFG.BOUNCE;}}
else{let lx=o.x-o.w/2,rx=o.x+o.w/2,ty=o.y-o.h/2,by=o.y+o.h/2,cx=Math.max(lx,Math.min(rx,p.x)),cy=Math.max(ty,Math.min(by,p.y)),dx=p.x-cx,dy=p.y-cy;if(dx*dx+dy*dy<p.r*p.r){if(Math.abs(dx)>Math.abs(dy)){let s=dx<0?-1:1;p.x=cx+s*p.r;p.px=p.x-vx*CFG.CONTACT_FRICTION;}else{let s=dy<0?-1:1;p.y=cy+s*p.r;p.py=p.y-vy*CFG.CONTACT_FRICTION;}}}}
}
advance(realDelta){if(this.paused)return;this.acc+=Math.min(realDelta,CFG.MAX_FRAME);let n=0;while(this.acc>=CFG.DT&&n<8){this.step();this.acc-=CFG.DT;n++;}if(n===8)this.acc=0;}
kineticEnergy(){let e=0;for(const p of this.points)if(p.invMass>0&&!p.grabbed){let vx=(p.x-p.px)/CFG.DT,vy=(p.y-p.py)/CFG.DT;e+=.5*p.mass*(vx*vx+vy*vy);}return e;}
}
function pointSegDist(px,py,x1,y1,x2,y2){let dx=x2-x1,dy=y2-y1,t=((px-x1)*dx+(py-y1)*dy)/(dx*dx+dy*dy||1);t=Math.max(0,Math.min(1,t));return Math.hypot(px-x1-t*dx,py-y1-t*dy)}
const sim=window.sim=new Sim();
// ==================== Scene setup and render ====================
function resetScene(){sim.points.length=0;sim.links.length=0;sim.bodies.length=0;sim.time=0;sim.acc=0;sim.paused=false;sim.spawnRope(Math.max(300,W*.38),105,Math.max(330,W*.43),265,13);sim.spawnCloth(Math.max(360,W*.48),145,Math.min(230,W*.29),175,'corners');sim.spawnSoft(W*.55,H*.28,34);sim.spawnBall(W*.31,H*.26);}
function render(){ctx.clearRect(0,0,W,H);const bg=ctx.createLinearGradient(0,0,0,H);bg.addColorStop(0,'#101722');bg.addColorStop(1,'#121a24');ctx.fillStyle=bg;ctx.fillRect(0,0,W,H);
// subtle drafting grid
ctx.save();ctx.strokeStyle='#9db7d00b';ctx.lineWidth=1;for(let x=0;x<W;x+=40){ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,sim.floor);ctx.stroke()}for(let y=20;y<sim.floor;y+=40){ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(W,y);ctx.stroke()}ctx.restore();
// geometry
for(const o of sim.obstacles){if(o.type==='circle'){ctx.beginPath();ctx.arc(o.x,o.y,o.r,0,Math.PI*2);let gr=ctx.createLinearGradient(o.x-o.r,o.y-o.r,o.x+o.r,o.y+o.r);gr.addColorStop(0,'#344253');gr.addColorStop(1,'#202b39');ctx.fillStyle=gr;ctx.fill();ctx.strokeStyle='#6a7d9244';ctx.lineWidth=1;ctx.stroke();ctx.beginPath();ctx.arc(o.x-17,o.y-20,22,.2,2.4);ctx.strokeStyle='#ffffff10';ctx.lineWidth=3;ctx.stroke();}else{ctx.fillStyle='#263343';ctx.fillRect(o.x-o.w/2,o.y-o.h/2,o.w,o.h);ctx.strokeStyle='#8293a326';ctx.strokeRect(o.x-o.w/2,o.y-o.h/2,o.w,o.h);}}
ctx.fillStyle='#1a2531';ctx.fillRect(0,sim.floor,W,H-sim.floor);ctx.fillStyle='#42bfae';ctx.globalAlpha=.42;ctx.fillRect(0,sim.floor,W,1);ctx.globalAlpha=1;for(let x=0;x<W;x+=16){ctx.fillStyle='#9db1c01b';ctx.fillRect(x,sim.floor+12,7,1)}
for(const b of sim.bodies)drawBody(b);
if(gesture&&gesture.kind!=='grab'){ctx.save();ctx.setLineDash([5,5]);ctx.lineWidth=1.5;ctx.strokeStyle=gesture.kind==='cut'?'#ff7777':'#80f3da';ctx.fillStyle='#70e6d51a';ctx.beginPath();ctx.moveTo(gesture.x,gesture.y);ctx.lineTo(gesture.cx,gesture.cy);ctx.stroke();if(gesture.kind==='cloth'||gesture.kind==='soft'){ctx.fillRect(Math.min(gesture.x,gesture.cx),Math.min(gesture.y,gesture.cy),Math.abs(gesture.cx-gesture.x),Math.abs(gesture.cy-gesture.y));}ctx.restore();}
if(grabbed){ctx.beginPath();ctx.arc(grabbed.x,grabbed.y,grabbed.r+8,0,Math.PI*2);ctx.strokeStyle='#9dffe6bb';ctx.lineWidth=1.5;ctx.stroke();ctx.beginPath();ctx.arc(mouse.x,mouse.y,3,0,Math.PI*2);ctx.fillStyle='#d8fff3';ctx.fill();}updateReadout();}
function drawBody(b){let c=b.color;ctx.save();if(b.type==='cloth'){for(let j=0;j<b.rows-1;j++)for(let i=0;i<b.cols-1;i++){let a=b.points[j*b.cols+i],q=b.points[j*b.cols+i+1],d=b.points[(j+1)*b.cols+i+1],e=b.points[(j+1)*b.cols+i];ctx.beginPath();ctx.moveTo(a.x,a.y);ctx.lineTo(q.x,q.y);ctx.lineTo(d.x,d.y);ctx.lineTo(e.x,e.y);ctx.closePath();let shade=(i+j)%2?'.105':'.17';ctx.fillStyle=hexAlpha(c,shade);ctx.fill();ctx.strokeStyle=sim.stress?'#fff2':hexAlpha(c,'.36');ctx.lineWidth=.75;ctx.stroke();}for(const p of b.points)if(p.invMass===0){ctx.beginPath();ctx.arc(p.x,p.y,3.2,0,7);ctx.fillStyle='#d5e9ff';ctx.fill();ctx.strokeStyle='#5182b0';ctx.stroke();}}
else if(b.type==='soft'){let poly=b.meta.shape==='box'?b.points: b.meta.ring;if(poly&&poly.length){ctx.beginPath();ctx.moveTo(poly[0].x,poly[0].y);for(let i=1;i<poly.length;i++)ctx.lineTo(poly[i].x,poly[i].y);ctx.closePath();ctx.fillStyle=hexAlpha(c,'.25');ctx.fill();ctx.strokeStyle=hexAlpha(c,'.92');ctx.lineWidth=2;ctx.stroke();}drawLinks(b,true);}
else if(b.type==='ball'){let p=b.points[0],g=ctx.createRadialGradient(p.x-p.r*.3,p.y-p.r*.35,1,p.x,p.y,p.r);g.addColorStop(0,'#fff1c8');g.addColorStop(.23,c);g.addColorStop(1,'#c38232');ctx.beginPath();ctx.arc(p.x,p.y,p.r,0,7);ctx.fillStyle=g;ctx.fill();ctx.strokeStyle='#ffe0a466';ctx.stroke();}
else{drawLinks(b,false);for(const p of b.points){ctx.beginPath();ctx.arc(p.x,p.y,p.r,0,7);ctx.fillStyle=p.invMass===0?'#e2fff7':c;ctx.fill();if(p.invMass===0){ctx.strokeStyle='#254b52';ctx.lineWidth=1.5;ctx.stroke();}}}
ctx.restore();}
function drawLinks(b,internal){ctx.lineWidth=internal?1:2;for(const l of b.links){if(!l.active)continue;let color=b.color;if(sim.stress){let ratio=Math.hypot(l.b.x-l.a.x,l.b.y-l.a.y)/l.rest,t=Math.max(0,Math.min(1,(ratio-1)*2));color=`rgb(${Math.round(75+180*t)},${Math.round(220-150*t)},${Math.round(130-85*t)})`;}ctx.beginPath();ctx.moveTo(l.a.x,l.a.y);ctx.lineTo(l.b.x,l.b.y);ctx.strokeStyle=hexAlpha(color,internal?'.48':'.85');ctx.stroke();}}
function hexAlpha(hex,a){let n=parseInt(hex.slice(1),16);return`rgba(${n>>16},${(n>>8)&255},${n&255},${a})`}
// ==================== Input: tools, grab, draw, tear, pin ====================
let activeTool='grab',gesture=null,grabbed=null,mouse={x:0,y:0},rightDown=false,lastGrab=[];
function pointFrom(e){let r=canvas.getBoundingClientRect();return{x:e.clientX-r.left,y:e.clientY-r.top}}
function nearest(x,y,r=22){let found=null,best=r*r;for(const p of sim.points){let d=(p.x-x)**2+(p.y-y)**2;if(d<best){best=d;found=p;}}return found}
canvas.addEventListener('pointerdown',e=>{canvas.setPointerCapture(e.pointerId);let q=pointFrom(e);mouse=q;if(e.button===2||e.altKey){rightDown=true;gesture={kind:'cut',...q,cx:q.x,cy:q.y};sim.cutAt(q.x,q.y);return;}if(e.shiftKey){let p=nearest(q.x,q.y,24);if(p){sim.togglePin(p);notify(p.invMass===0?'Point pinned':'Point released');}return;}
if(activeTool==='grab'){let p=nearest(q.x,q.y,25);if(p){grabbed=p;p.grabbed=true;p.px=p.x;p.py=p.y;lastGrab=[{x:q.x,y:q.y,t:performance.now()}];}return;}gesture={kind:activeTool,x:q.x,y:q.y,cx:q.x,cy:q.y};});
canvas.addEventListener('pointermove',e=>{let q=pointFrom(e);mouse=q;if(gesture){gesture.cx=q.x;gesture.cy=q.y;if(gesture.kind==='cut')sim.cutAt(q.x,q.y);}if(grabbed){grabbed.x=q.x;grabbed.y=q.y;lastGrab.push({x:q.x,y:q.y,t:performance.now()});if(lastGrab.length>6)lastGrab.shift();}});
canvas.addEventListener('pointerup',e=>{if(gesture){let g=gesture,dx=g.cx-g.x,dy=g.cy-g.y;if(g.kind==='rope'&&Math.hypot(dx,dy)>20)sim.spawnRope(g.x,g.y,g.cx,g.cy);else if(g.kind==='cloth'&&Math.abs(dx)>40&&Math.abs(dy)>35)sim.spawnCloth(Math.min(g.x,g.cx),Math.min(g.y,g.cy),Math.abs(dx),Math.abs(dy),document.getElementById('pinMode').value);else if(g.kind==='soft')sim.spawnSoft(g.x+dx*.5,g.y+dy*.5,Math.max(20,Math.min(68,Math.hypot(dx,dy)*.25||37)),Math.abs(dx)>Math.abs(dy)*1.5?'box':'blob');else if(g.kind==='ball')sim.spawnBall(g.x,g.y,Math.max(10,Math.min(30,Math.hypot(dx,dy)*.18||17)));gesture=null;}
if(grabbed){let p=grabbed;if(lastGrab.length>1){let a=lastGrab[Math.max(0,lastGrab.length-4)],z=lastGrab[lastGrab.length-1],dt=Math.max(12,z.t-a.t);let vx=(z.x-a.x)/dt*1000,vy=(z.y-a.y)/dt*1000;let speed=Math.hypot(vx,vy),lim=1100;if(speed>lim){vx*=lim/speed;vy*=lim/speed;}p.px=p.x-vx*CFG.DT;p.py=p.y-vy*CFG.DT;}p.grabbed=false;grabbed=null;lastGrab=[];}rightDown=false;});canvas.addEventListener('contextmenu',e=>e.preventDefault());
document.querySelectorAll('.tool').forEach(btn=>btn.addEventListener('click',()=>{activeTool=btn.dataset.tool;document.querySelectorAll('.tool').forEach(x=>x.classList.toggle('active',x===btn));}));
window.addEventListener('keydown',e=>{if(e.code==='Space'){e.preventDefault();togglePause();}if(e.code==='ArrowRight'||e.code==='ArrowLeft'){if(sim.paused){sim.step();render();}}if(!e.repeat){let key={KeyG:'grab',KeyR:'rope',KeyC:'cloth',KeyB:'soft',KeyO:'ball'}[e.code];if(key)document.querySelector(`[data-tool="${key}"]`).click();}});
// ==================== Controls and animation loop ====================
function togglePause(){sim.paused=!sim.paused;document.getElementById('runState').innerHTML=sim.paused?'Ⅱ PAUSED':'● LIVE';document.getElementById('pauseBtn').textContent=sim.paused?'▶':'Ⅱ';}
document.getElementById('pauseBtn').onclick=togglePause;document.getElementById('stepBtn').onclick=()=>{if(sim.paused){sim.step();render();}};document.getElementById('resetBtn').onclick=()=>resetScene();
document.getElementById('gravity').oninput=e=>{sim.gravity=+e.target.value;document.getElementById('gravityVal').textContent=sim.gravity};document.getElementById('iterations').oninput=e=>{sim.iterations=+e.target.value;document.getElementById('iterVal').textContent=sim.iterations};
function bindSwitch(id,key){document.getElementById(id).onclick=e=>{sim[key]=!sim[key];e.currentTarget.classList.toggle('on',sim[key]);};}bindSwitch('stressToggle','stress');bindSwitch('windToggle','wind');bindSwitch('gravityToggle','gravityOn');
let toastTimer;function notify(s){let el=document.getElementById('toast');el.textContent=s;el.classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>el.classList.remove('show'),1100)}
function updateReadout(){document.getElementById('objectCount').textContent=sim.bodies.length;document.getElementById('stats').innerHTML=`${Math.round(sim.fps)} FPS &nbsp;·&nbsp; ${sim.stepMs.toFixed(2)} ms<br>${sim.points.length} points · ${sim.links.length} links`;}
let prev=performance.now();function frame(now){let dt=(now-prev)/1000;prev=now;if(!sim.paused)sim.advance(dt);sim.frameCount++;if(now-sim.lastFps>500){sim.fps=sim.frameCount*1000/(now-sim.lastFps);sim.frameCount=0;sim.lastFps=now;}render();requestAnimationFrame(frame)}
// Convenient programmatic hooks for deterministic headless checks.
sim.stepFrames=n=>{for(let i=0;i<n;i++)sim.step();};sim.spawn={rope:(...a)=>sim.spawnRope(...a),cloth:(...a)=>sim.spawnCloth(...a),soft:(...a)=>sim.spawnSoft(...a),ball:(...a)=>sim.spawnBall(...a)};sim.cut=sim.cutAt.bind(sim);sim.pin=sim.togglePin.bind(sim);sim.reset=resetScene;sim.energy=sim.kineticEnergy.bind(sim);
resize();resetScene();requestAnimationFrame(frame);
</script></body></html>
<!-- agent-meta {"model":"openai/gpt-6-luna","provider":"openrouter","persona":"full","sessionId":"7158f86c-350c-4eb5-a93a-379ce0e8fad6","tokensIn":321351,"tokensOut":13468,"tokensTotal":334819,"cost":0.012331185000000001,"turns":19,"toolCalls":18,"failedToolCalls":1,"timestamp":"2026-09-23T00:22:37.406Z"} -->