← Physics Playground results

Physics Playground

Minimax m3 · 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 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"} -->