Physics Playground
Minimax m3 · 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 36,414 bytes · SHA-256 0c32c4148877
<!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 Lab — Verlet Playground</title>
<style>
:root{color-scheme:dark;--bg:#070a12;--panel:rgba(13,18,31,.88);--line:rgba(255,255,255,.1);--text:#e7ecff;--muted:#8791ac;--cyan:#4de7ff;--violet:#a879ff;--lime:#75f0a4;--orange:#ffb255;--red:#ff5578}
*{box-sizing:border-box}
html,body{margin:0;width:100%;height:100%;overflow:hidden;background:var(--bg);font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif;color:var(--text)}
canvas{position:fixed;inset:0;width:100%;height:100%;display:block;touch-action:none;cursor:crosshair;background:radial-gradient(circle at 50% 12%,#11182b 0,#080b14 55%,#05070c 100%)}
.topbar{position:fixed;z-index:4;top:14px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:7px;padding:7px;border:1px solid rgba(255,255,255,.1);border-radius:14px;background:var(--panel);box-shadow:0 16px 45px #0008, inset 0 1px rgba(255,255,255,.04);backdrop-filter:blur(16px);max-width:calc(100vw - 28px)}
.brand{display:flex;align-items:center;gap:9px;padding:0 9px 0 6px;white-space:nowrap}
.brandmark{width:25px;height:25px;border-radius:8px;background:conic-gradient(from 210deg,var(--cyan),var(--violet),var(--red),var(--cyan));box-shadow:0 0 18px #4de7ff55;position:relative}
.brandmark:after{content:"";position:absolute;inset:6px;border:2px solid #08101c;border-radius:50%}
.brand strong{font-size:12px;letter-spacing:.11em;text-transform:uppercase}.brand span{display:block;color:var(--muted);font-size:9px;letter-spacing:.06em;margin-top:1px}
.divider{height:29px;width:1px;background:var(--line);margin:0 2px}
button,.select{border:1px solid transparent;color:var(--muted);background:transparent;height:34px;border-radius:9px;padding:0 10px;font:600 11px/1 inherit;letter-spacing:.02em;cursor:pointer;transition:.15s ease;white-space:nowrap}
button:hover{color:var(--text);background:#ffffff0d;border-color:#ffffff0d}button.active{color:#071019;background:var(--cyan);border-color:#9ff2ff;box-shadow:0 0 18px #4de7ff42}button.danger:hover{color:#ff9aaf;background:#ff557814}
.tool{display:flex;gap:2px}.tool button{min-width:48px;padding:0 8px}.tool button b{font-size:14px;margin-right:4px}.tool button[data-tool="rope"].active{background:var(--cyan)}.tool button[data-tool="cloth"].active{background:var(--violet)}.tool button[data-tool="blob"].active{background:var(--lime)}.tool button[data-tool="ball"].active{background:var(--orange)}.tool button[data-tool="cut"].active{background:var(--red);color:#1a0710;border-color:#ff9bb0}
.pause{width:36px;padding:0;font-size:15px}.reset{width:36px;padding:0;font-size:16px}
.panel{position:fixed;z-index:4;left:14px;top:88px;width:224px;padding:14px;border:1px solid rgba(255,255,255,.09);border-radius:14px;background:var(--panel);box-shadow:0 18px 50px #0008;backdrop-filter:blur(16px)}
.panel-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:13px}.panel-head b{font-size:10px;letter-spacing:.14em;text-transform:uppercase}.panel-head button{height:23px;padding:0 6px;font-size:12px}
.control{margin:11px 0}.control label{display:flex;justify-content:space-between;align-items:center;color:var(--muted);font-size:10px;margin-bottom:6px}.control output{color:var(--text);font-variant-numeric:tabular-nums}
input[type=range]{width:100%;height:3px;appearance:none;background:#2b3349;border-radius:5px;outline:none}input[type=range]::-webkit-slider-thumb{appearance:none;width:13px;height:13px;border-radius:50%;background:var(--cyan);border:2px solid #d9fbff;box-shadow:0 0 10px #4de7ff66}
.toggle-row{display:flex;gap:6px;margin-top:12px}.toggle-row button{flex:1;padding:0 6px;border-color:var(--line);background:#ffffff05}.toggle-row button.on{color:#071019;background:var(--cyan)}
select{width:100%;height:31px;border:1px solid var(--line);border-radius:8px;color:var(--text);background:#11182a;padding:0 8px;font-size:10px;outline:none}
.stats{position:fixed;z-index:3;right:14px;top:88px;padding:10px 12px;border:1px solid rgba(255,255,255,.08);border-radius:11px;background:rgba(9,13,23,.75);backdrop-filter:blur(10px);text-align:right;font:10px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--muted);min-width:126px}.stats b{color:var(--cyan);font-size:12px}.stats .paused{color:var(--orange)}
.hint{position:fixed;z-index:3;bottom:15px;left:50%;transform:translateX(-50%);padding:8px 13px;border:1px solid rgba(255,255,255,.08);border-radius:20px;background:rgba(8,12,21,.72);backdrop-filter:blur(10px);color:var(--muted);font-size:10px;letter-spacing:.02em;pointer-events:none;text-align:center;white-space:nowrap}.hint strong{color:var(--text)}
.toast{position:fixed;z-index:7;left:50%;bottom:54px;transform:translate(-50%,12px);padding:8px 13px;border-radius:9px;background:#eaf8ff;color:#08111d;font-size:10px;font-weight:700;opacity:0;transition:.22s;pointer-events:none}.toast.show{opacity:1;transform:translate(-50%,0)}
@media(max-width:850px){.brand,.divider{display:none}.topbar{top:8px}.panel{top:auto;bottom:46px;left:8px;width:182px;padding:10px}.stats{top:60px;right:8px}.tool button{font-size:0;min-width:35px}.tool button b{font-size:14px;margin:0}.hint{bottom:9px;font-size:9px}.panel-head{margin-bottom:8px}.control{margin:8px 0}}
</style>
</head>
<body>
<canvas id="world" aria-label="Interactive two-dimensional soft body physics playground"></canvas>
<div class="topbar">
<div class="brand"><div class="brandmark"></div><div><strong>Softbody Lab</strong><span>Verlet playground</span></div></div><div class="divider"></div>
<div class="tool" id="tools">
<button class="active" data-tool="grab" title="Grab / throw (1)"><b>◎</b>Grab</button>
<button data-tool="rope" title="Draw rope (2)"><b>╱</b>Rope</button>
<button data-tool="cloth" title="Draw cloth (3)"><b>▦</b>Cloth</button>
<button data-tool="blob" title="Draw soft body (4)"><b>⬡</b>Blob</button>
<button data-tool="ball" title="Spawn ball (5)"><b>●</b>Ball</button>
<button data-tool="cut" title="Slice constraints (X)"><b>✕</b>Cut</button>
</div><div class="divider"></div>
<button class="pause" id="pause" title="Pause / resume (Space)">Ⅱ</button>
<button class="reset danger" id="reset" title="Reset scene">↻</button>
</div>
<div class="panel" id="panel">
<div class="panel-head"><b>Simulation</b><button id="collapse" title="Collapse panel">−</button></div>
<div id="panelBody">
<div class="control"><label><span>Gravity</span><output id="gravityOut">980</output></label><input id="gravity" type="range" min="0" max="1800" step="20" value="980"></div>
<div class="control"><label><span>Solver iterations</span><output id="iterationsOut">6</output></label><input id="iterations" type="range" min="2" max="10" step="1" value="6"></div>
<div class="control"><label><span>Wind</span><output id="windOut">0</output></label><input id="wind" type="range" min="-500" max="500" step="25" value="0"></div>
<div class="control"><label><span>Cloth pins</span></label><select id="pinPattern"><option value="corners">Both corners</option><option value="edge">Full top edge</option><option value="alternating">Alternating</option></select></div>
<div class="toggle-row"><button id="gravityToggle" class="on">Gravity</button><button id="stressToggle">Stress</button></div>
</div>
</div>
<div class="stats" id="stats"><b>60 FPS</b><br>physics 0.00 ms<br>0 points · 0 links</div>
<div class="hint" id="hint"><strong>Drag</strong> a point to grab & throw · <strong>Right-drag</strong> to cut · <strong>Alt-click</strong> to pin</div>
<div class="toast" id="toast"></div>
<script>
'use strict';
/* ================================================================
TUNABLE CONSTANTS
================================================================ */
const CONFIG = {
timestep: 1 / 60,
gravity: 980,
damping: 0.982,
iterations: 6,
maxFrameDelta: 0.05,
maxSubsteps: 4,
maxVelocity: 34,
floorMargin: 31,
wallMargin: 12,
grabRadius: 24,
tearRadius: 10,
restitution: 0.12,
floorFriction: 0.82,
colors: { rope:'#4de7ff', cloth:'#a879ff', blob:'#75f0a4', ball:'#ffb255', cut:'#ff5578' }
};
/* ================================================================
CANVAS / STATE
================================================================ */
const canvas = document.getElementById('world');
const ctx = canvas.getContext('2d', { alpha: true });
let W = innerWidth, H = innerHeight, DPR = 1;
let nextObjectId = 1;
let tool = 'grab';
let paused = false;
let accumulator = 0;
let lastTime = performance.now();
let fpsSmooth = 60;
let physicsMs = 0;
let gravityEnabled = true;
let stressMode = false;
let wind = 0;
let panelCollapsed = false;
let toastTimer = 0;
const points = [];
const constraints = [];
const objects = [];
const statics = [];
const pointer = { x:0, y:0, down:false, button:0, startX:0, startY:0, prevX:0, prevY:0, prevT:0, vx:0, vy:0, grabbed:null, drawing:false, cutting:false };
const cutTrail = [];
let hoverPoint = null;
// Small, allocation-free math helpers used throughout the solver.
function clamp(v,lo,hi){ return Math.max(lo,Math.min(hi,v)); }
function lerp(a,b,t){ return a+(b-a)*t; }
function resize() {
const oldW = W, oldH = H;
W = innerWidth; H = innerHeight; DPR = Math.min(devicePixelRatio || 1, 2);
canvas.width = Math.round(W * DPR); canvas.height = Math.round(H * DPR);
canvas.style.width = W + 'px'; canvas.style.height = H + 'px';
ctx.setTransform(DPR,0,0,DPR,0,0);
buildStatics();
if (oldW && oldH && points.length) {
const dx = W - oldW, dy = H - oldH;
for (const p of points) {
if (p.y > oldH - 100) { p.y += dy; p.py += dy; if(p.pinned) p.pinY += dy; }
p.x = clamp(p.x, CONFIG.wallMargin+p.r, W-CONFIG.wallMargin-p.r);
p.y = Math.min(p.y, floorY()-p.r);
}
}
}
function floorY(){ return H - CONFIG.floorMargin; }
function buildStatics(){
statics.length = 0;
const compact = W < 700;
statics.push({type:'circle', x:compact?W*.54:W*.50, y:H*.57, r:compact?54:72});
if (!compact) {
statics.push({type:'box', x:W*.68, y:H*.70, w:170, h:18, angle:-0.12});
statics.push({type:'box', x:W*.18, y:H*.76, w:135, h:16, angle:0.10});
}
}
window.addEventListener('resize', resize);
/* ================================================================
MODEL CREATION
================================================================ */
function addPoint(x,y,opts={}) {
const p = {
x, y, px:x-(opts.vx||0)*CONFIG.timestep, py:y-(opts.vy||0)*CONFIG.timestep,
r:opts.r || 3, invMass:opts.invMass === 0 ? 0 : 1/(opts.mass||1),
pinned:!!opts.pinned, pinX:x, pinY:y, color:opts.color||'#fff',
objectId:opts.objectId||0, hidden:!!opts.hidden, grabbed:false,
restitution:Number.isFinite(opts.restitution)?opts.restitution:null
};
points.push(p); return p;
}
function addConstraint(a,b,len=null,stiffness=1,type='structural',objectId=0) {
if (!a || !b || a===b) return null;
const d = Math.hypot(b.x-a.x,b.y-a.y);
const c = {a,b,len:len==null?Math.max(d,.001):Math.max(len,.001),stiffness:clamp(stiffness,0,1),type,objectId,broken:false};
constraints.push(c); return c;
}
function makeObject(type,color,extra={}) {
const o = Object.assign({id:nextObjectId++,type,color,points:[],constraints:[]},extra);
objects.push(o); return o;
}
function link(o,a,b,len,stiffness,type){ const c=addConstraint(a,b,len,stiffness,type,o.id); if(c)o.constraints.push(c); return c; }
function spawnRope(x1,y1,x2,y2,segments) {
const dist=Math.hypot(x2-x1,y2-y1); segments=segments||clamp(Math.round(dist/18),5,36);
if(dist<15){x2=x1+130;y2=y1+70;}
const o=makeObject('rope',CONFIG.colors.rope);
for(let i=0;i<=segments;i++){
const t=i/segments, p=addPoint(lerp(x1,x2,t),lerp(y1,y2,t),{r:3.2,color:o.color,objectId:o.id,pinned:i===0});
o.points.push(p); if(i) link(o,o.points[i-1],p,null,1,'rope');
}
return o;
}
function spawnCloth(x1,y1,x2,y2,pinPattern=document.getElementById('pinPattern').value) {
let left=Math.min(x1,x2), right=Math.max(x1,x2), top=Math.min(y1,y2), bottom=Math.max(y1,y2);
if(right-left<70){right=left+220;} if(bottom-top<55){bottom=top+155;}
const cols=clamp(Math.round((right-left)/22)+1,5,18), rows=clamp(Math.round((bottom-top)/22)+1,4,14);
const sx=(right-left)/(cols-1), sy=(bottom-top)/(rows-1);
const o=makeObject('cloth',CONFIG.colors.cloth,{cols,rows,grid:[]});
for(let y=0;y<rows;y++){
const row=[];
for(let x=0;x<cols;x++){
const pin=y===0 && (pinPattern==='edge' || (pinPattern==='corners'&&(x===0||x===cols-1)) || (pinPattern==='alternating'&&x%2===0));
const p=addPoint(left+x*sx,top+y*sy,{r:2.4,color:o.color,objectId:o.id,pinned:pin});
row.push(p);o.points.push(p);
}o.grid.push(row);
}
for(let y=0;y<rows;y++)for(let x=0;x<cols;x++){
const p=o.grid[y][x];
if(x)link(o,o.grid[y][x-1],p,sx,1,'structural');
if(y)link(o,o.grid[y-1][x],p,sy,1,'structural');
if(x&&y){link(o,o.grid[y-1][x-1],p,Math.hypot(sx,sy),.72,'shear');link(o,o.grid[y-1][x],o.grid[y][x-1],Math.hypot(sx,sy),.72,'shear');}
}
return o;
}
function spawnBlob(cx,cy,r=55,sides) {
r=clamp(r,24,105); sides=sides||clamp(Math.round(r/7),10,18);
const o=makeObject('blob',CONFIG.colors.blob,{rim:[]});
const center=addPoint(cx,cy,{r:3,color:o.color,objectId:o.id,hidden:true,mass:2});o.center=center;o.points.push(center);
for(let i=0;i<sides;i++){
const a=-Math.PI/2+i*Math.PI*2/sides;
const p=addPoint(cx+Math.cos(a)*r,cy+Math.sin(a)*r,{r:3.2,color:o.color,objectId:o.id});o.rim.push(p);o.points.push(p);
}
for(let i=0;i<sides;i++){
const p=o.rim[i], n=o.rim[(i+1)%sides], n2=o.rim[(i+2)%sides];
link(o,p,n,null,1,'edge'); link(o,p,n2,null,.78,'brace'); link(o,center,p,r,.64,'radial');
}
return o;
}
function spawnBall(x,y,r=14,vx=0,vy=0){
r=clamp(r,7,34);const o=makeObject('ball',CONFIG.colors.ball,{radius:r});
const p=addPoint(x,y,{r,color:o.color,objectId:o.id,mass:Math.max(1,r/9),vx,vy,restitution:.38});o.points.push(p);return o;
}
function togglePin(p){
if(!p)return;p.pinned=!p.pinned;p.pinX=p.x;p.pinY=p.y;p.px=p.x;p.py=p.y;showToast(p.pinned?'Point pinned':'Point released');
}
function clearWorld(){points.length=0;constraints.length=0;objects.length=0;nextObjectId=1;pointer.grabbed=null;}
function resetScene(){
clearWorld();
const safeTop=W<700?82:105;
spawnRope(Math.max(42,W*.13),safeTop,Math.max(150,W*.28),Math.min(H*.47,safeTop+250),18);
// The lower cloth row starts just inside the obstacle's crown so it visibly
// settles around the circle instead of hanging in empty space.
const circle=statics.find(s=>s.type==='circle');
const clothBottom=circle?circle.y-circle.r+8:Math.min(H*.5,safeTop+280);
spawnCloth(W*.36,safeTop-5,W*.61,clothBottom,'corners');
if(W>700){spawnBlob(W*.78,Math.max(150,H*.25),48);spawnBall(W*.68,Math.max(90,H*.18),15,70,0);}
showToast('Scene reset');
}
/* ================================================================
VERLET PHYSICS + CONSTRAINT RELAXATION
================================================================ */
function integrate(dt){
const dt2=dt*dt, gy=gravityEnabled?CONFIG.gravity:0;
for(const p of points){
if(p.pinned){p.x=p.pinX;p.y=p.pinY;p.px=p.pinX;p.py=p.pinY;continue;}
if(p.grabbed){p.x=pointer.x;p.y=pointer.y;p.px=p.x;p.py=p.y;continue;}
let vx=(p.x-p.px)*CONFIG.damping, vy=(p.y-p.py)*CONFIG.damping;
const speed=Math.hypot(vx,vy);
if(speed>CONFIG.maxVelocity){const s=CONFIG.maxVelocity/speed;vx*=s;vy*=s;}
const ox=p.x, oy=p.y;p.x+=vx+wind*dt2;p.y+=vy+gy*dt2;p.px=ox;p.py=oy;
sanitizePoint(p);
}
}
function solveConstraint(c){
if(c.broken)return;
const a=c.a,b=c.b;let dx=b.x-a.x,dy=b.y-a.y;const d2=dx*dx+dy*dy;
if(!Number.isFinite(d2)||d2<1e-12)return;
const d=Math.sqrt(d2), wa=(a.pinned||a.grabbed)?0:a.invMass, wb=(b.pinned||b.grabbed)?0:b.invMass, sum=wa+wb;
if(sum<=0)return;
const correction=((d-c.len)/d)*c.stiffness;
const cx=dx*correction,cy=dy*correction;
a.x+=cx*(wa/sum);a.y+=cy*(wa/sum);b.x-=cx*(wb/sum);b.y-=cy*(wb/sum);
}
function enforceAnchors(){
for(const p of points){
if(p.pinned){p.x=p.pinX;p.y=p.pinY;}
else if(p.grabbed){p.x=pointer.x;p.y=pointer.y;}
}
}
function collidePoint(p,respond){
if(p.pinned||p.grabbed)return;
const oldX=p.x,oldY=p.y,r=p.r;
let nx=0,ny=0,hit=false;
const left=CONFIG.wallMargin+r,right=W-CONFIG.wallMargin-r,bottom=floorY()-r,top=r+3;
if(p.x<left){p.x=left;nx=1;ny=0;hit=true;}else if(p.x>right){p.x=right;nx=-1;ny=0;hit=true;}
if(p.y>bottom){p.y=bottom;nx=0;ny=-1;hit=true;}else if(p.y<top){p.y=top;nx=0;ny=1;hit=true;}
for(const s of statics){
if(s.type==='circle'){
const dx=p.x-s.x,dy=p.y-s.y,min=s.r+r,d2=dx*dx+dy*dy;
if(d2<min*min){
let d=Math.sqrt(d2);let ux,uy;if(d<1e-6){ux=0;uy=-1;d=1;}else{ux=dx/d;uy=dy/d;}
p.x=s.x+ux*min;p.y=s.y+uy*min;nx=ux;ny=uy;hit=true;
} else {
// Swept point test: a fast point cannot skip from one side to the
// other between fixed steps. The point radius expands the obstacle.
const sx0=p.px-s.x,sy0=p.py-s.y,vx=p.x-p.px,vy=p.y-p.py;
const qa=vx*vx+vy*vy,qb=2*(sx0*vx+sy0*vy),qc=sx0*sx0+sy0*sy0-min*min,disc=qb*qb-4*qa*qc;
if(qc>0&&qa>1e-9&&disc>=0){const t=(-qb-Math.sqrt(disc))/(2*qa);if(t>=0&&t<=1){const qx=sx0+vx*t,qy=sy0+vy*t,qd=Math.max(Math.hypot(qx,qy),1e-6);nx=qx/qd;ny=qy/qd;p.x=s.x+nx*min;p.y=s.y+ny*min;hit=true;}}
}
} else if(s.type==='box'){
const ca=Math.cos(-s.angle),sa=Math.sin(-s.angle),dx=p.x-s.x,dy=p.y-s.y;
let lx=dx*ca-dy*sa,ly=dx*sa+dy*ca;const hx=s.w/2+r,hy=s.h/2+r;let lnx=0,lny=0,boxHit=false;
if(Math.abs(lx)<hx&&Math.abs(ly)<hy){
const penX=hx-Math.abs(lx),penY=hy-Math.abs(ly);
if(penX<penY){lnx=lx<0?-1:1;lx=lnx*hx;}else{lny=ly<0?-1:1;ly=lny*hy;}boxHit=true;
} else {
// Segment-vs-expanded-local-AABB slab test prevents thin platforms
// from being tunneled through by a whipped or thrown point.
const pdx=p.px-s.x,pdy=p.py-s.y,plx=pdx*ca-pdy*sa,ply=pdx*sa+pdy*ca,svx=lx-plx,svy=ly-ply;
let enter=0,exit=1,valid=true,enx=0,eny=0;
if(Math.abs(svx)<1e-9){if(Math.abs(plx)>hx)valid=false;}else{let t1=(-hx-plx)/svx,t2=(hx-plx)/svx,n1=-1;if(t1>t2){[t1,t2]=[t2,t1];n1=1;}if(t1>enter){enter=t1;enx=n1;eny=0;}exit=Math.min(exit,t2);if(enter>exit)valid=false;}
if(Math.abs(svy)<1e-9){if(Math.abs(ply)>hy)valid=false;}else{let t1=(-hy-ply)/svy,t2=(hy-ply)/svy,n1=-1;if(t1>t2){[t1,t2]=[t2,t1];n1=1;}if(t1>enter){enter=t1;enx=0;eny=n1;}exit=Math.min(exit,t2);if(enter>exit)valid=false;}
if(valid&&enter>=0&&enter<=1&&Math.abs(plx)>hx||valid&&enter>=0&&enter<=1&&Math.abs(ply)>hy){lx=plx+svx*enter;ly=ply+svy*enter;lnx=enx;lny=eny;boxHit=true;}
}
if(boxHit){const cb=Math.cos(s.angle),sb=Math.sin(s.angle);p.x=s.x+lx*cb-ly*sb;p.y=s.y+lx*sb+ly*cb;nx=lnx*cb-lny*sb;ny=lnx*sb+lny*cb;hit=true;}
}
}
if(hit){p._stepHit=true;p._stepNx=nx;p._stepNy=ny;}
else if(respond&&p._stepHit){hit=true;nx=p._stepNx;ny=p._stepNy;}
if(hit&&respond){
p.contactFrames=(p.contactFrames||0)+1;
let vx=oldX-p.px,vy=oldY-p.py;const vn=vx*nx+vy*ny;
// Below the impact threshold contacts are inelastic. Persistent contacts
// are also settled, preventing constraint pressure from creating a buzz.
const resting=Math.hypot(vx,vy)<=40*CONFIG.timestep||p.contactFrames>6;
const bounce=resting?0:(p.restitution??CONFIG.restitution);
if(vn<0){vx-= (1+bounce)*vn*nx;vy-= (1+bounce)*vn*ny;}
if(resting){const settleNormal=vx*nx+vy*ny;vx-=settleNormal*nx;vy-=settleNormal*ny;}
const tx=vx-(vx*nx+vy*ny)*nx,ty=vy-(vx*nx+vy*ny)*ny;
const normal=(vx*nx+vy*ny);vx=normal*nx+tx*CONFIG.floorFriction;vy=normal*ny+ty*CONFIG.floorFriction;
p.px=p.x-vx;p.py=p.y-vy;
} else if(respond) {
p.contactFrames=0;
}
}
function sanitizePoint(p){
if(!Number.isFinite(p.x)||!Number.isFinite(p.y)||!Number.isFinite(p.px)||!Number.isFinite(p.py)){
p.x=Number.isFinite(p.pinX)?p.pinX:W/2;p.y=Number.isFinite(p.pinY)?p.pinY:80;p.px=p.x;p.py=p.y;
}
const bound=10000;p.x=clamp(p.x,-bound,bound);p.y=clamp(p.y,-bound,bound);p.px=clamp(p.px,-bound,bound);p.py=clamp(p.py,-bound,bound);
}
function physicsStep(dt=CONFIG.timestep){
const start=performance.now();integrate(dt);
for(const p of points)p._stepHit=false;
for(let k=0;k<CONFIG.iterations;k++){
enforceAnchors();
for(const c of constraints)solveConstraint(c);
for(const p of points)collidePoint(p,k===CONFIG.iterations-1);
}
enforceAnchors();for(const p of points)sanitizePoint(p);
physicsMs=physicsMs*.9+(performance.now()-start)*.1;
}
/* ================================================================
CUTTING / GEOMETRY HELPERS
================================================================ */
function pointSegmentDistance(px,py,ax,ay,bx,by){
const dx=bx-ax,dy=by-ay,l2=dx*dx+dy*dy;if(l2<1e-9)return Math.hypot(px-ax,py-ay);
const t=clamp(((px-ax)*dx+(py-ay)*dy)/l2,0,1),x=ax+t*dx,y=ay+t*dy;return Math.hypot(px-x,py-y);
}
function orient(ax,ay,bx,by,cx,cy){return (bx-ax)*(cy-ay)-(by-ay)*(cx-ax);}
function segmentsIntersect(a,b,c,d){
const o1=orient(a.x,a.y,b.x,b.y,c.x,c.y),o2=orient(a.x,a.y,b.x,b.y,d.x,d.y),o3=orient(c.x,c.y,d.x,d.y,a.x,a.y),o4=orient(c.x,c.y,d.x,d.y,b.x,b.y);
return ((o1>0)!=(o2>0))&&((o3>0)!=(o4>0));
}
function segmentDistance(a,b,c,d){
if(segmentsIntersect(a,b,c,d))return 0;
return Math.min(pointSegmentDistance(a.x,a.y,c.x,c.y,d.x,d.y),pointSegmentDistance(b.x,b.y,c.x,c.y,d.x,d.y),pointSegmentDistance(c.x,c.y,a.x,a.y,b.x,b.y),pointSegmentDistance(d.x,d.y,a.x,a.y,b.x,b.y));
}
function cutSegment(x1,y1,x2,y2,r=CONFIG.tearRadius){
const a={x:x1,y:y1},b={x:x2,y:y2};let count=0;
for(const c of constraints){if(!c.broken&&segmentDistance(a,b,c.a,c.b)<=r){c.broken=true;count++;}}
if(count)showToast(count+' link'+(count===1?'':'s')+' cut');return count;
}
function nearestPoint(x,y,r=CONFIG.grabRadius){
let best=null,bestD=r*r;for(const p of points){const d=(p.x-x)**2+(p.y-y)**2;if(d<bestD){bestD=d;best=p;}}return best;
}
/* ================================================================
RENDERING
================================================================ */
function drawBackground(){
ctx.clearRect(0,0,W,H);
ctx.save();ctx.strokeStyle='rgba(128,160,210,.045)';ctx.lineWidth=1;
const grid=42,ox=(performance.now()*.004)%grid;for(let x=ox;x<W;x+=grid){ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,H);ctx.stroke();}for(let y=0;y<H;y+=grid){ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(W,y);ctx.stroke();}ctx.restore();
}
function drawStatics(){
ctx.save();
const fy=floorY();const g=ctx.createLinearGradient(0,fy,0,H);g.addColorStop(0,'rgba(91,115,160,.22)');g.addColorStop(1,'rgba(27,34,53,.55)');ctx.fillStyle=g;ctx.fillRect(0,fy,W,H-fy);ctx.strokeStyle='rgba(141,173,226,.24)';ctx.beginPath();ctx.moveTo(0,fy+.5);ctx.lineTo(W,fy+.5);ctx.stroke();
ctx.fillStyle='rgba(64,82,119,.22)';ctx.fillRect(0,0,CONFIG.wallMargin,H);ctx.fillRect(W-CONFIG.wallMargin,0,CONFIG.wallMargin,H);
for(const s of statics){ctx.save();ctx.translate(s.x,s.y);if(s.type==='circle'){const rg=ctx.createRadialGradient(-s.r*.25,-s.r*.3,2,0,0,s.r);rg.addColorStop(0,'rgba(77,231,255,.13)');rg.addColorStop(1,'rgba(45,65,99,.36)');ctx.fillStyle=rg;ctx.beginPath();ctx.arc(0,0,s.r,0,Math.PI*2);ctx.fill();ctx.strokeStyle='rgba(116,203,234,.28)';ctx.stroke();ctx.beginPath();ctx.arc(-s.r*.22,-s.r*.25,s.r*.54,3.35,5.15);ctx.strokeStyle='rgba(255,255,255,.1)';ctx.stroke();}else{ctx.rotate(s.angle);ctx.fillStyle='rgba(51,69,103,.46)';roundRectPath(-s.w/2,-s.h/2,s.w,s.h,7);ctx.fill();ctx.strokeStyle='rgba(128,161,215,.25)';ctx.stroke();}ctx.restore();}
ctx.restore();
}
function constraintColor(c,base){
if(!stressMode)return base;const d=Math.hypot(c.b.x-c.a.x,c.b.y-c.a.y),ratio=Math.abs(d/c.len-1),t=clamp(ratio/.32,0,1);return `hsl(${lerp(145,350,t)} 88% ${lerp(62,64,t)}%)`;
}
function drawObjects(){
ctx.lineCap='round';ctx.lineJoin='round';
for(const o of objects){
if(o.type==='blob'&&o.rim.length>2){
ctx.save();ctx.beginPath();ctx.moveTo(o.rim[0].x,o.rim[0].y);for(let i=1;i<o.rim.length;i++)ctx.lineTo(o.rim[i].x,o.rim[i].y);ctx.closePath();const grad=ctx.createRadialGradient(o.center.x-18,o.center.y-18,3,o.center.x,o.center.y,80);grad.addColorStop(0,'rgba(180,255,206,.72)');grad.addColorStop(1,'rgba(64,219,122,.21)');ctx.fillStyle=grad;ctx.shadowColor='#75f0a4';ctx.shadowBlur=12;ctx.fill();ctx.shadowBlur=0;ctx.strokeStyle='rgba(135,255,176,.85)';ctx.lineWidth=2;ctx.stroke();ctx.restore();
} else if(o.type==='ball'){
const p=o.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,'#fff2c7');g.addColorStop(.25,'#ffca6e');g.addColorStop(1,'#dc6f32');ctx.fillStyle=g;ctx.shadowColor='#ffb255';ctx.shadowBlur=12;ctx.beginPath();ctx.arc(p.x,p.y,p.r,0,Math.PI*2);ctx.fill();ctx.shadowBlur=0;
}
}
for(const o of objects){
if(o.type==='ball'||o.type==='blob'&&!stressMode)continue;
ctx.lineWidth=o.type==='cloth'?1.15:2.1;ctx.globalAlpha=o.type==='cloth'?.78:.95;
for(const c of o.constraints){if(c.broken)continue;if(o.type==='blob'&&c.type==='radial'&&!stressMode)continue;ctx.strokeStyle=constraintColor(c,o.color);ctx.beginPath();ctx.moveTo(c.a.x,c.a.y);ctx.lineTo(c.b.x,c.b.y);ctx.stroke();}
ctx.globalAlpha=1;
}
// Points and pins
for(const p of points){
if(p.hidden)continue;const o=objects.find(q=>q.id===p.objectId);if(o&&o.type==='cloth'&&!p.pinned&&p!==hoverPoint&&!p.grabbed)continue;
ctx.fillStyle=p.pinned?'#f4fbff':p.color;ctx.shadowColor=p.color;ctx.shadowBlur=p.pinned?9:4;ctx.beginPath();ctx.arc(p.x,p.y,p.pinned?4.3:p.r,0,Math.PI*2);ctx.fill();ctx.shadowBlur=0;
if(p.pinned){ctx.strokeStyle=p.color;ctx.lineWidth=1;ctx.beginPath();ctx.arc(p.x,p.y,7,0,Math.PI*2);ctx.stroke();}
}
}
function drawInteraction(){
if(hoverPoint&&!pointer.down&&tool==='grab'){ctx.strokeStyle='rgba(255,255,255,.5)';ctx.lineWidth=1;ctx.beginPath();ctx.arc(hoverPoint.x,hoverPoint.y,10+Math.sin(performance.now()*.006)*2,0,Math.PI*2);ctx.stroke();}
if(pointer.grabbed){ctx.strokeStyle='rgba(77,231,255,.7)';ctx.lineWidth=1.5;ctx.beginPath();ctx.arc(pointer.grabbed.x,pointer.grabbed.y,14,0,Math.PI*2);ctx.stroke();ctx.beginPath();ctx.moveTo(pointer.grabbed.x,pointer.grabbed.y);ctx.lineTo(pointer.grabbed.x-pointer.vx*.035,pointer.grabbed.y-pointer.vy*.035);ctx.stroke();}
if(pointer.drawing){
const x=pointer.startX,y=pointer.startY,dx=pointer.x-x,dy=pointer.y-y;ctx.save();ctx.setLineDash([6,5]);ctx.lineWidth=1.5;ctx.strokeStyle=CONFIG.colors[tool]||CONFIG.colors.cut;ctx.fillStyle=(CONFIG.colors[tool]||'#fff')+'22';
if(tool==='cloth'){ctx.fillRect(Math.min(x,pointer.x),Math.min(y,pointer.y),Math.abs(dx),Math.abs(dy));ctx.strokeRect(Math.min(x,pointer.x),Math.min(y,pointer.y),Math.abs(dx),Math.abs(dy));}
else if(tool==='blob'||tool==='ball'){const r=clamp(Math.hypot(dx,dy),tool==='ball'?7:24,tool==='ball'?34:105);ctx.beginPath();ctx.arc(x,y,r,0,Math.PI*2);ctx.fill();ctx.stroke();}
else {ctx.beginPath();ctx.moveTo(x,y);ctx.lineTo(pointer.x,pointer.y);ctx.stroke();}
ctx.restore();
}
if(cutTrail.length>1){ctx.strokeStyle=CONFIG.colors.cut;ctx.shadowColor=CONFIG.colors.cut;ctx.shadowBlur=9;ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(cutTrail[0].x,cutTrail[0].y);for(let i=1;i<cutTrail.length;i++)ctx.lineTo(cutTrail[i].x,cutTrail[i].y);ctx.stroke();ctx.shadowBlur=0;}
}
function render(){drawBackground();drawStatics();drawObjects();drawInteraction();}
function roundRectPath(x,y,w,h,r){ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);ctx.arcTo(x+w,y+h,x,y+h,r);ctx.arcTo(x,y+h,x,y,r);ctx.arcTo(x,y,x+w,y,r);ctx.closePath();}
/* ================================================================
POINTER / KEYBOARD INTERACTION
================================================================ */
function eventPos(e){const rect=canvas.getBoundingClientRect();return{x:e.clientX-rect.left,y:e.clientY-rect.top};}
canvas.addEventListener('contextmenu',e=>e.preventDefault());
canvas.addEventListener('pointerdown',e=>{
e.preventDefault();canvas.setPointerCapture(e.pointerId);const q=eventPos(e),now=performance.now();Object.assign(pointer,{x:q.x,y:q.y,startX:q.x,startY:q.y,prevX:q.x,prevY:q.y,prevT:now,vx:0,vy:0,down:true,button:e.button});
if(e.button===2||tool==='cut'){pointer.cutting=true;cutTrail.length=0;cutTrail.push({x:q.x,y:q.y});return;}
const near=nearestPoint(q.x,q.y);
if(e.altKey&&near){togglePin(near);pointer.down=false;return;}
if(tool==='grab'){
if(near){pointer.grabbed=near;near.grabbed=true;near.px=near.x;near.py=near.y;}else showToast('No point in reach');
}else{pointer.drawing=true;}
});
canvas.addEventListener('pointermove',e=>{
const q=eventPos(e),now=performance.now(),dt=Math.max((now-pointer.prevT)/1000,.001);
pointer.vx=pointer.vx*.45+(q.x-pointer.prevX)/dt*.55;pointer.vy=pointer.vy*.45+(q.y-pointer.prevY)/dt*.55;pointer.prevX=q.x;pointer.prevY=q.y;pointer.prevT=now;pointer.x=q.x;pointer.y=q.y;
if(pointer.cutting){const last=cutTrail[cutTrail.length-1];if(!last||Math.hypot(q.x-last.x,q.y-last.y)>3){if(last)cutSegment(last.x,last.y,q.x,q.y);cutTrail.push({x:q.x,y:q.y});if(cutTrail.length>30)cutTrail.shift();}}
if(pointer.grabbed){pointer.grabbed.x=q.x;pointer.grabbed.y=q.y;}
if(!pointer.down)hoverPoint=nearestPoint(q.x,q.y);
});
function endPointer(e){
if(!pointer.down&&!pointer.cutting)return;const q=eventPos(e);pointer.x=q.x;pointer.y=q.y;
if(pointer.grabbed){const p=pointer.grabbed;p.grabbed=false;p.x=q.x;p.y=q.y;const vx=clamp(pointer.vx,-1800,1800),vy=clamp(pointer.vy,-1800,1800);p.px=p.x-vx*CONFIG.timestep;p.py=p.y-vy*CONFIG.timestep;pointer.grabbed=null;}
if(pointer.drawing){
const d=Math.hypot(q.x-pointer.startX,q.y-pointer.startY);
if(tool==='rope')spawnRope(pointer.startX,pointer.startY,q.x,q.y);
else if(tool==='cloth')spawnCloth(pointer.startX,pointer.startY,q.x,q.y);
else if(tool==='blob')spawnBlob(pointer.startX,pointer.startY,d||45);
else if(tool==='ball')spawnBall(pointer.startX,pointer.startY,d||14);
}
pointer.down=false;pointer.drawing=false;pointer.cutting=false;setTimeout(()=>cutTrail.length=0,90);
}
canvas.addEventListener('pointerup',endPointer);canvas.addEventListener('pointercancel',endPointer);
window.addEventListener('keydown',e=>{
if(e.code==='Space'){e.preventDefault();setPaused(!paused);}
else if((e.code==='ArrowRight'||e.code==='ArrowUp')&&paused){e.preventDefault();physicsStep();render();}
else if(e.key>='1'&&e.key<='5')setTool(['grab','rope','cloth','blob','ball'][+e.key-1]);
else if(e.key.toLowerCase()==='x')setTool('cut');
else if(e.key.toLowerCase()==='g')gravityToggle();
else if(e.key.toLowerCase()==='s')stressToggle();
});
/* ================================================================
UI
================================================================ */
function setTool(name){tool=name;document.querySelectorAll('[data-tool]').forEach(b=>b.classList.toggle('active',b.dataset.tool===name));canvas.style.cursor=name==='grab'?'grab':name==='cut'?'cell':'crosshair';const msg={grab:'Drag a point to grab & throw · Right-drag to cut · Alt-click to pin',rope:'Click-drag to draw a pinned rope',cloth:'Drag a rectangle to weave cloth',blob:'Drag from center to size a soft body',ball:'Click or drag to size a ball',cut:'Drag through links to slice them'}[name];document.getElementById('hint').innerHTML=msg;}
document.getElementById('tools').addEventListener('click',e=>{const b=e.target.closest('[data-tool]');if(b)setTool(b.dataset.tool);});
function setPaused(v){paused=v;document.getElementById('pause').textContent=paused?'▶':'Ⅱ';document.getElementById('pause').title=paused?'Resume (Space)':'Pause (Space)';showToast(paused?'Paused · arrows step':'Simulation running');}
document.getElementById('pause').onclick=()=>setPaused(!paused);document.getElementById('reset').onclick=resetScene;
const gravityInput=document.getElementById('gravity'),iterationsInput=document.getElementById('iterations'),windInput=document.getElementById('wind');
gravityInput.oninput=()=>{CONFIG.gravity=+gravityInput.value;document.getElementById('gravityOut').value=CONFIG.gravity;};
iterationsInput.oninput=()=>{CONFIG.iterations=+iterationsInput.value;document.getElementById('iterationsOut').value=CONFIG.iterations;};
windInput.oninput=()=>{wind=+windInput.value;document.getElementById('windOut').value=wind;};
function gravityToggle(){gravityEnabled=!gravityEnabled;document.getElementById('gravityToggle').classList.toggle('on',gravityEnabled);showToast('Gravity '+(gravityEnabled?'on':'off'));}
function stressToggle(){stressMode=!stressMode;document.getElementById('stressToggle').classList.toggle('on',stressMode);showToast('Stress colors '+(stressMode?'on':'off'));}
document.getElementById('gravityToggle').onclick=gravityToggle;document.getElementById('stressToggle').onclick=stressToggle;
document.getElementById('collapse').onclick=()=>{panelCollapsed=!panelCollapsed;document.getElementById('panelBody').style.display=panelCollapsed?'none':'block';document.getElementById('collapse').textContent=panelCollapsed?'+':'−';};
function showToast(text){const el=document.getElementById('toast');el.textContent=text;el.classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>el.classList.remove('show'),1000);}
function updateStats(){const live=constraints.reduce((n,c)=>n+!c.broken,0),el=document.getElementById('stats');el.innerHTML=`<b class="${paused?'paused':''}">${paused?'PAUSED':Math.round(fpsSmooth)+' FPS'}</b><br>physics ${physicsMs.toFixed(2)} ms<br>${points.length} points · ${live} links`;}
/* ================================================================
FIXED TIMESTEP LOOP
================================================================ */
let statClock=0;
function frame(now){
let frameDt=Math.min((now-lastTime)/1000,CONFIG.maxFrameDelta);if(!Number.isFinite(frameDt)||frameDt<0)frameDt=0;lastTime=now;fpsSmooth=fpsSmooth*.92+(frameDt>0?1/frameDt:60)*.08;
if(!paused){accumulator=Math.min(accumulator+frameDt,CONFIG.timestep*CONFIG.maxSubsteps);let steps=0;while(accumulator>=CONFIG.timestep&&steps<CONFIG.maxSubsteps){physicsStep(CONFIG.timestep);accumulator-=CONFIG.timestep;steps++;}}
else accumulator=0;
render();if(now-statClock>300){updateStats();statClock=now;}requestAnimationFrame(frame);
}
/* ================================================================
PUBLIC DEBUG / TEST API
================================================================ */
const sim = window.sim = {
points,constraints,objects,statics,config:CONFIG,
step(n=1){for(let i=0;i<n;i++)physicsStep(CONFIG.timestep);return this.metrics();},
reset:resetScene,clear:clearWorld,
spawnRope,spawnCloth,spawnBlob,spawnBall,
cutConstraint(c){const target=typeof c==='number'?constraints[c]:c;if(target)target.broken=true;return !!target;},
cutSegment,togglePin,setPaused,
metrics(){
let kinetic=0,maxSpeed=0,finite=true,minY=Infinity,maxY=-Infinity;
for(const p of points){const vx=(p.x-p.px)/CONFIG.timestep,vy=(p.y-p.py)/CONFIG.timestep,s2=vx*vx+vy*vy;kinetic+=.5*(1/Math.max(p.invMass,.0001))*s2;maxSpeed=Math.max(maxSpeed,Math.sqrt(s2));finite=finite&&Number.isFinite(p.x)&&Number.isFinite(p.y);minY=Math.min(minY,p.y-p.r);maxY=Math.max(maxY,p.y+p.r);}
return{points:points.length,constraints:constraints.filter(c=>!c.broken).length,kinetic,maxSpeed,finite,minY,maxY,floor:floorY()};
},
get gravityEnabled(){return gravityEnabled;},set gravityEnabled(v){gravityEnabled=!!v;},
get paused(){return paused;}
};
resize();resetScene();requestAnimationFrame(t=>{lastTime=t;requestAnimationFrame(frame);});
</script>
</body>
</html>
<!-- agent-meta {"model":"minimax/minimax-m3","provider":"openrouter","persona":"full","sessionId":"aa70838e-e294-462f-81cf-d555a2464adf","tokensIn":1844272,"tokensOut":27480,"tokensTotal":1871752,"turns":50,"toolCalls":52,"failedToolCalls":0,"timestamp":"2026-07-30T04:18:19.440Z"} -->