← Physics Playground results

Physics Playground

GLM 5.3 Flash · 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 29,303 bytes · SHA-256 81ce0149a8cb
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Soft-Body Physics Playground</title>
<style>
  :root{ --panel:#141a22; --accent:#54a0ff; }
  *{ box-sizing:border-box; margin:0; padding:0; }
  html,body{ height:100%; overflow:hidden; background:#0b0e13; font-family:ui-monospace,Menlo,Consolas,monospace; }
  #app{ position:relative; width:100vw; height:100vh; }
  canvas{ display:block; width:100%; height:100%; cursor:crosshair; }
  #toolbar{
    position:absolute; top:12px; left:12px; display:flex; flex-direction:column; gap:8px;
    background:rgba(20,26,34,.92); border:1px solid #2a3442; border-radius:10px;
    padding:10px 12px; color:#cfd8e3; font-size:12px; user-select:none; width:190px;
  }
  .row{ display:flex; gap:6px; align-items:center; }
  .row.wrap{ flex-wrap:wrap; }
  button{
    background:#1d2733; color:#cfd8e3; border:1px solid #33414f; border-radius:6px;
    padding:5px 9px; font:inherit; cursor:pointer; transition:background .12s;
  }
  button:hover{ background:#28374a; }
  button.active{ background:#2f6fb0; border-color:#54a0ff; color:#fff; }
  input[type=range]{ width:100%; accent-color:var(--accent); }
  label{ display:flex; justify-content:space-between; gap:8px; color:#8fa1b5; }
  label b{ color:#e8eef5; font-weight:600; }
  select{ background:#1d2733; color:#cfd8e3; border:1px solid #33414f; border-radius:6px; padding:3px 5px; font:inherit; }
  #hud{ position:absolute; top:12px; right:12px; text-align:right; color:#7f93a8; font-size:11px;
        background:rgba(20,26,34,.8); padding:6px 10px; border-radius:8px; border:1px solid #2a3442; line-height:1.6; }
  #help{ position:absolute; bottom:10px; left:12px; color:#5d6f83; font-size:11px; line-height:1.6; }
  .sep{ height:1px; background:#2a3442; margin:2px 0; }
</style>
</head>
<body>
<div id="app">
  <canvas id="cv"></canvas>
  <div id="toolbar">
    <div class="row wrap" id="tools">
      <button data-tool="grab" class="active">Grab</button>
      <button data-tool="rope">Rope</button>
      <button data-tool="cloth">Cloth</button>
      <button data-tool="soft">Blob</button>
      <button data-tool="box">Box</button>
      <button data-tool="ball">Ball</button>
    </div>
    <div class="sep"></div>
    <label>gravity <b id="gv">1200</b></label>
    <input type="range" id="gravity" min="0" max="3000" value="1200" step="50">
    <label>iterations <b id="iv">6</b></label>
    <input type="range" id="iters" min="1" max="16" value="6" step="1">
    <label>damping <b id="dv">0.995</b></label>
    <input type="range" id="damp" min="0.90" max="1.00" value="0.995" step="0.005">
    <label>wind <b id="wv">0</b></label>
    <input type="range" id="wind" min="0" max="600" value="0" step="20">
    <div class="row">
      <label style="flex:1">stress <input type="checkbox" id="stress"></label>
      <label style="flex:1">points <input type="checkbox" id="showPts"></label>
    </div>
    <div class="row">
      <select id="pinMode"><option value="corners">pin corners</option><option value="edge">pin full edge</option></select>
      <button id="clearBtn" title="Remove all objects">clear</button>
    </div>
    <div class="row">
      <button id="pauseBtn">pause (space)</button>
      <button id="stepBtn">step (→)</button>
      <button id="resetBtn">reset</button>
    </div>
  </div>
  <div id="hud">FPS --<br>physics -- ms<br>pts -- / cons --</div>
  <div id="help">
    left-drag: grab / draw object &nbsp;·&nbsp; right-drag or hold X: tear &nbsp;·&nbsp; shift-click: pin/unpin point<br>
    space: pause &nbsp;·&nbsp; →: step one frame &nbsp;·&nbsp; G: toggle gravity
  </div>
</div>
<script>
'use strict';
/* ============================================================
   TUNABLE CONSTANTS
   ============================================================ */
const CFG = {
  dt: 1/60,                 // fixed physics timestep (60 Hz)
  gravity: 1200,            // px/s^2
  damping: 0.995,           // velocity retention per step
  iterations: 6,            // constraint relaxation iterations
  wind: 0,                  // px/s^2 horizontal (sinusoidal)
  tearRadius: 14,           // slice kill radius (px)
  grabRadius: 28,           // grab search radius (px)
  maxFrameDt: 0.05,         // clamp for tab-switch / lag spikes
  maxSubSteps: 5,           // max physics steps per rendered frame
  maxSpeed: 45,             // max px per step (anti-explosion clamp)
  floorFriction: 0.55,      // tangential velocity retained on contact
  restitution: 0.25,        // bounce on static contacts
  ballRadius: 16,
  ropeSeg: 26,              // rope segment length
  ropeCount: 14,
  clothCols: 14, clothRows: 10, clothSpacing: 22,
  softRadius: 55, softCount: 14,
  colors: {
    bg0:'#0b0e13', bg1:'#10151d', floor:'#1b2430', floorLine:'#2c3a4c',
    static:'#41546b', rope:'#ffa94d', cloth:'#4dabf7', soft:'#38d9a9',
    box:'#e599f7', ball:'#ff6bcb', pin:'#ffd43b', grab:'#ffffff',
    stressLo:'#38d9a9', stressHi:'#ff5252'
  }
};

/* ============================================================
   MATH HELPERS
   ============================================================ */
const clamp = (v,a,b)=> v<a?a : v>b?b : v;
const dist  = (ax,ay,bx,by)=> Math.hypot(bx-ax, by-ay);

/* ============================================================
   SIM ENTITIES
   ============================================================ */
class Point {
  constructor(x,y,r=3){
    this.x=x; this.y=y; this.px=x; this.py=y;
    this.r=r;              // collision radius
    this.pinned=false; this.pinX=x; this.pinY=y;
    this.obj=null;         // owning object
  }
  integrate(dt, gx, gy){
    if (this.pinned){ this.x=this.pinX; this.y=this.pinY; this.px=this.x; this.py=this.y; return; }
    let vx = (this.x - this.px) * CFG.damping;
    let vy = (this.y - this.py) * CFG.damping;
    // anti-explosion speed clamp
    const sp = Math.hypot(vx,vy);
    if (sp > CFG.maxSpeed){ const s = CFG.maxSpeed/sp; vx*=s; vy*=s; }
    this.px = this.x; this.py = this.y;
    this.x += vx + gx*dt*dt;
    this.y += vy + gy*dt*dt;
    if (!isFinite(this.x) || !isFinite(this.y)){ this.x=this.px; this.y=this.py; }
  }
  grabTo(tx,ty,vx,vy){
    this.x = tx; this.y = ty;
    this.px = tx - vx*CFG.dt; this.py = ty - vy*CFG.dt;
  }
  togglePin(){
    if (this.pinned){ this.pinned=false; }
    else { this.pinned=true; this.pinX=this.x; this.pinY=this.y; }
  }
}

class Constraint {
  constructor(a,b,stiff=1,tearable=true){
    this.a=a; this.b=b;
    const d = dist(a.x,a.y,b.x,b.y);
    this.rest = d>1e-6 ? d : 1e-6;
    this.stiff = stiff;
    this.tearable = tearable;
    this.alive = true;
    this.stretch = 1; // for stress coloring
  }
  solve(){
    const a=this.a, b=this.b;
    let dx = b.x-a.x, dy = b.y-a.y;
    let d = Math.hypot(dx,dy);
    if (d < 1e-9){ // coincident points: nudge apart deterministically
      dx = 0.01; dy = 0; d = 0.01;
    }
    this.stretch = d / this.rest;
    const diff = ((d - this.rest) / d) * 0.5 * this.stiff;
    const ox = dx*diff, oy = dy*diff;
    if (!a.pinned && !b.pinned){
      a.x += ox; a.y += oy;
      b.x -= ox; b.y -= oy;
    } else if (a.pinned && !b.pinned){
      b.x -= 2*ox; b.y -= 2*oy;
    } else if (!a.pinned && b.pinned){
      a.x += 2*ox; a.y += 2*oy;
    }
  }
}

/* A spawned object groups points + constraints and knows how to render */
class SimObject {
  constructor(type, color){ this.type=type; this.color=color; this.points=[]; this.cons=[]; this.ring=null; }
  add(p){ p.obj=this; this.points.push(p); return p; }
  link(a,b,stiff=1,tearable=true){
    const c = new Constraint(a,b,stiff,tearable); this.cons.push(c); return c;
  }
}

/* ============================================================
   WORLD  (points, constraints, static geometry, stepping)
   ============================================================ */
const world = {
  points: [], cons: [], objects: [],
  staticCircles: [],  // {x,y,r}
  staticBoxes: [],    // {x,y,w,h}
  bounds: {left:0, top:0, right:800, bottom:600},
  gravityOn: true,
  windPhase: 0,

  addPoint(p){ this.points.push(p); return p; },
  addObject(o){ this.objects.push(o); for(const p of o.points) this.addPoint(p); for(const c of o.cons) this.cons.push(c); return o; },
  removeObject(o){
    for(const p of o.points){ const i=this.points.indexOf(p); if(i>=0) this.points.splice(i,1); }
    for(const c of o.cons){ const i=this.cons.indexOf(c); if(i>=0) this.cons.splice(i,1); }
    const i=this.objects.indexOf(o); if(i>=0) this.objects.splice(i,1);
  },
  clear(){ this.points.length=0; this.cons.length=0; this.objects.length=0; },

  step(dt){
    this.windPhase += dt;
    const gx = 0;
    const gy = this.gravityOn ? CFG.gravity : 0;
    const wx = CFG.wind>0 ? CFG.wind * (0.6 + 0.4*Math.sin(this.windPhase*0.9) * Math.sin(this.windPhase*0.23)) : 0;

    // integrate
    for (const p of this.points) p.integrate(dt, gx+wx, gy);

    // relax constraints + resolve collisions, interleaved
    for (let it=0; it<CFG.iterations; it++){
      for (const c of this.cons) if (c.alive) c.solve();
      this.collide();
    }
    // tear over-stretched constraints
    for (const c of this.cons){
      if (c.alive && c.tearable && c.stretch > 6.5) c.alive = false;
    }
  },

  collide(){
    const b = this.bounds, rest = CFG.restitution, fr = CFG.floorFriction;
    for (const p of this.points){
      if (p.pinned) continue;
      let hit = false;
      // walls & floor
      if (p.x - p.r < b.left){ p.x = b.left + p.r; hit=true; }
      if (p.x + p.r > b.right){ p.x = b.right - p.r; hit=true; }
      if (p.y - p.r < b.top){ p.y = b.top + p.r; hit=true; }
      if (p.y + p.r > b.bottom){
        p.y = b.bottom - p.r; hit=true;
        // floor friction on tangential velocity
        p.px = p.x + (p.px - p.x) * fr;
      }
      // static circles
      for (const c of this.staticCircles){
        let dx = p.x-c.x, dy = p.y-c.y;
        let d = Math.hypot(dx,dy);
        const R = c.r + p.r;
        if (d < R){
          if (d < 1e-9){ dx=0; dy=-1; d=1; }
          const nx=dx/d, ny=dy/d;
          p.x = c.x + nx*R; p.y = c.y + ny*R;
          // reflect velocity along normal
          let vx = p.x - p.px, vy = p.y - p.py;
          const vn = vx*nx + vy*ny;
          if (vn < 0){
            vx -= (1+rest)*vn*nx; vy -= (1+rest)*vn*ny;
          }
          // tangential friction
          const tx=-ny, ty=nx;
          const vt = vx*tx + vy*ty;
          vx -= vt*(1-fr)*tx; vy -= vt*(1-fr)*ty;
          p.px = p.x - vx; p.py = p.y - vy;
          hit = true;
        }
      }
      // static boxes (treat as AABB, resolve from closest point / inside)
      for (const bx of this.staticBoxes){
        const cx = clamp(p.x, bx.x, bx.x+bx.w), cy = clamp(p.y, bx.y, bx.y+bx.h);
        let dx = p.x-cx, dy = p.y-cy;
        let d = Math.hypot(dx,dy);
        if (d < p.r){
          if (d < 1e-9){
            // point inside box: push out along smallest penetration axis
            const l = p.x-bx.x, r = bx.x+bx.w-p.x, t = p.y-bx.y, bo = bx.y+bx.h-p.y;
            const m = Math.min(l,r,t,bo);
            if (m===l){ dx=-1; dy=0; p.x = bx.x - p.r; }
            else if (m===r){ dx=1; dy=0; p.x = bx.x+bx.w + p.r; }
            else if (m===t){ dx=0; dy=-1; p.y = bx.y - p.r; }
            else { dx=0; dy=1; p.y = bx.y+bx.h + p.r; }
            d = 1;
          } else {
            const nx=dx/d, ny=dy/d;
            p.x = cx + nx*p.r; p.y = cy + ny*p.r;
          }
          let vx = p.x - p.px, vy = p.y - p.py;
          const nx=dx/d, ny=dy/d;
          const vn = vx*nx + vy*ny;
          if (vn < 0){ vx -= (1+rest)*vn*nx; vy -= (1+rest)*vn*ny; }
          p.px = p.x - vx; p.py = p.y - vy;
        }
      }
      if (hit){
        // generic bounce damping for wall hits (floor handled above)
        if (p.y + p.r <= b.bottom + 0.01){
          let vx = p.x - p.px, vy = p.y - p.py;
          p.px = p.x - vx*rest; p.py = p.y - vy*rest;
        }
      }
    }
  },

  // remove constraints whose segment passes near (x,y) within radius
  tearAt(x,y,r){
    let n=0;
    const r2 = r*r;
    for (const c of this.cons){
      if (!c.alive || !c.tearable) continue;
      const ax=c.a.x, ay=c.a.y, bx=c.b.x, by=c.b.y;
      const dx=bx-ax, dy=by-ay;
      const len2 = dx*dx+dy*dy;
      let t = len2>1e-9 ? ((x-ax)*dx + (y-ay)*dy)/len2 : 0;
      t = clamp(t,0,1);
      const px = ax+dx*t - x, py = ay+dy*t - y;
      if (px*px+py*py < r2){ c.alive=false; n++; }
    }
    return n;
  },

  nearestPoint(x,y,r){
    let best=null, bd=r*r;
    for (const p of this.points){
      const dx=p.x-x, dy=p.y-y, d2=dx*dx+dy*dy;
      if (d2<bd){ bd=d2; best=p; }
    }
    return best;
  },

  kineticEnergy(){
    let e=0;
    for (const p of this.points){
      if (p.pinned) continue;
      const vx=p.x-p.px, vy=p.y-p.py;
      e += 0.5*(vx*vx+vy*vy);
    }
    return e;
  },

  sanity(){
    const b=this.bounds, m=2000;
    for (const p of this.points){
      if (!isFinite(p.x)||!isFinite(p.y)) return false;
      if (p.x<b.left-m||p.x>b.right+m||p.y<b.top-m||p.y>b.bottom+m) return false;
    }
    return true;
  }
};
</script>
<script>
/* ============================================================
   SPAWNERS
   ============================================================ */
function spawnRope(x0,y0,x1,y1){
  const o = new SimObject('rope', CFG.colors.rope);
  const n = CFG.ropeCount, seg = CFG.ropeSeg;
  const dx=x1-x0, dy=y1-y0, L=Math.hypot(dx,dy)||1;
  const ux=dx/L*seg, uy=dy/L*seg; // direction scaled
  let px=x0, py=y0;
  const first = o.add(new Point(px,py));
  first.togglePin();
  for (let i=1;i<=n;i++){
    px = x0 + dx/L*seg*i; py = y0 + dy/L*seg*i;
    const p = o.add(new Point(px,py,3));
    o.link(o.points[i-1], p, 1, true);
  }
  // slight bend resistance
  for (let i=0;i<n-1;i++) o.link(o.points[i], o.points[i+2], 0.12, true);
  return world.addObject(o);
}
function segLen(x0,y0,x1,y1,n){ return dist(x0,y0,x1,y1)/n; }

function spawnCloth(x0,y0,cols,rows,spacing,pinMode){
  const o = new SimObject('cloth', CFG.colors.cloth);
  const grid = [];
  for (let r=0;r<rows;r++){
    grid[r]=[];
    for (let c=0;c<cols;c++){
      const p = o.add(new Point(x0+c*spacing, y0+r*spacing, 2.5));
      grid[r][c]=p;
    }
  }
  // structural
  for (let r=0;r<rows;r++) for (let c=0;c<cols;c++){
    if (c<cols-1) o.link(grid[r][c], grid[r][c+1], 1, true);
    if (r<rows-1) o.link(grid[r][c], grid[r+1][c], 1, true);
  }
  // shear
  for (let r=0;r<rows-1;r++) for (let c=0;c<cols-1;c++){
    o.link(grid[r][c], grid[r+1][c+1], 0.35, true);
    o.link(grid[r][c+1], grid[r+1][c], 0.35, true);
  }
  // bend (skip-2) light
  for (let r=0;r<rows;r++) for (let c=0;c<cols-2;c++)
    o.link(grid[r][c], grid[r][c+2], 0.08, true);
  for (let r=0;r<rows-2;r++) for (let c=0;c<cols;c++)
    o.link(grid[r][c], grid[r+2][c], 0.08, true);
  // pins
  for (let c=0;c<cols;c++){
    if (pinMode==='edge' || c===0 || c===cols-1) grid[0][c].togglePin();
  }
  o.grid = grid; o.cols=cols; o.rows=rows;
  return world.addObject(o);
}

function spawnBlob(cx,cy,R,count){
  const o = new SimObject('soft', CFG.colors.soft);
  const ring=[];
  for (let i=0;i<count;i++){
    const a = i/count*Math.PI*2 - Math.PI/2;
    ring.push(o.add(new Point(cx+Math.cos(a)*R, cy+Math.sin(a)*R, 4)));
  }
  const center = o.add(new Point(cx,cy,4));
  o.ring = ring; o.center = center;
  for (let i=0;i<count;i++){
    o.link(ring[i], ring[(i+1)%count], 1, true);      // edge
    o.link(ring[i], ring[(i+2)%count], 0.5, true);    // brace
    o.link(ring[i], center, 0.55, true);              // spoke
  }
  o.link(ring[0], center, 0.55, true);
  return world.addObject(o);
}

function spawnSoftBox(cx,cy,w,h){
  const o = new SimObject('box', CFG.colors.box);
  const hw=w/2, hh=h/2;
  const c=[[-hw,-hh],[0,-hh],[hw,-hh],[hw,0],[hw,hh],[0,hh],[-hw,hh],[-hw,0]].map(([dx,dy])=> o.add(new Point(cx+dx,cy+dy,4)));
  const center = o.add(new Point(cx,cy,4));
  o.ring = c; o.center = center;
  for (let i=0;i<8;i++){
    o.link(c[i], c[(i+1)%8], 1, true);
    o.link(c[i], c[(i+2)%8], 0.6, true);
    o.link(c[i], center, 0.5, true);
  }
  o.link(c[0], c[4], 0.5, true); o.link(c[2], c[6], 0.5, true);
  return world.addObject(o);
}

function spawnBall(x,y,r){
  const o = new SimObject('ball', CFG.colors.ball);
  const p = o.add(new Point(x,y, CFG.ballRadius));
  o.ball = true;
  return world.addObject(o);
}

/* ============================================================
   STATIC SCENERY
   ============================================================ */
function buildStatic(){
  world.staticCircles = [
    {x: 620, y: 380, r: 60},
    {x: 300, y: 520, r: 45}
  ];
  world.staticBoxes = [
    {x: 480, y: 470, w: 160, h: 26}
  ];
}

/* ============================================================
   INTERACTION (mouse / tools)
   ============================================================ */
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
let tool='grab', shiftDown=false, xDown=false;
let mouse={x:0,y:0,down:false,button:0,lastX:0,lastY:0,vx:0,vy:0};
let grabbed=null;
let drawing=null;   // {type, x0,y0, x1,y1, obj?} preview
let tears=[];       // tear flash effects {x1,y1,x2,y2,ttl}

function resize(){
  const dpr = Math.min(window.devicePixelRatio||1, 2);
  cv.width = innerWidth*dpr; cv.height = innerHeight*dpr;
  ctx.setTransform(dpr,0,0,dpr,0,0);
  world.bounds = {left:0, top:0, right:innerWidth, bottom:innerHeight-10};
}
window.addEventListener('resize', resize);
resize();

function mousePos(e){ const r=cv.getBoundingClientRect(); return {x:e.clientX-r.left, y:e.clientY-r.top}; }

cv.addEventListener('contextmenu', e=> e.preventDefault());
cv.addEventListener('mousedown', e=>{
  const m = mousePos(e);
  mouse.x=m.x; mouse.y=m.y; mouse.down=true; mouse.button=e.button;
  mouse.lastX=m.x; mouse.lastY=m.y; mouse.vx=0; mouse.vy=0;
  if (e.shiftKey){ // pin toggle
    const p = world.nearestPoint(m.x,m.y,CFG.grabRadius);
    if (p) p.togglePin();
    return;
  }
  if (tool==='grab' || e.button===0 && tool==='grab'){
    const p = world.nearestPoint(m.x,m.y,CFG.grabRadius);
    if (p){ grabbed = p; return; }
  }
  if (tool!=='grab'){ drawing={x0:m.x,y0:m.y,x1:m.x,y1:m.y}; }
});

window.addEventListener('mousemove', e=>{
  const m = mousePos(e);
  mouse.vx = (m.x - mouse.x)/ (1/60); // px/s approx
  mouse.vy = (m.y - mouse.y)/ (1/60);
  mouse.x=m.x; mouse.y=m.y;
  if (drawing){ drawing.x1=m.x; drawing.y1=m.y; }
});

window.addEventListener('mouseup', e=>{
  if (grabbed){ grabbed=null; }
  if (drawing){
    commitDrawing(drawing);
    drawing=null;
  }
  mouse.down=false;
});

cv.addEventListener('contextmenu', e=>e.preventDefault());
window.addEventListener('keydown', e=>{
  if (e.key==='x'||e.key==='X') xDown=true;
  if (e.key==='Shift') shiftDown=true;
  if (e.code==='Space'){ e.preventDefault(); togglePause(); }
  if (e.code==='ArrowRight'){ e.preventDefault(); stepOnce(); }
  if (e.key==='g'||e.key==='G'){ world.gravityOn=!world.gravityOn; }
});
window.addEventListener('keyup', e=>{
  if (e.key==='x'||e.key==='X') xDown=false;
  if (e.key==='Shift') shiftDown=false;
});

function commitDrawing(d){
  if (tool==='rope'){
    spawnRope(d.x0,d.y0,d.x1,d.y1);
  } else if (tool==='cloth'){
    const pinMode = document.getElementById('pinMode').value;
    const cols = clamp(Math.round(Math.abs(d.x1-d.x0)/CFG.clothSpacing)+1, 3, 24);
    const rows = clamp(Math.round(Math.abs(d.y1-d.y0)/CFG.clothSpacing)+1, 3, 20);
    spawnCloth(Math.min(d.x0,d.x1), Math.min(d.y0,d.y1), cols, rows, CFG.clothSpacing, pinMode);
  } else if (tool==='soft'){
    const R = clamp(dist(d.x0,d.y0,d.x1,d.y1), 25, 120);
    spawnBlob(d.x0,d.y0, Math.max(R,30), CFG.softCount);
  } else if (tool==='box'){
    spawnSoftBox((d.x0+d.x1)/2, (d.y0+d.y1)/2, Math.abs(d.x1-d.x0)||80, Math.abs(d.y1-d.y0)||80);
  } else if (tool==='ball'){
    spawnBall(d.x0,d.y0);
  }
}

/* tearing: right-drag or X held while dragging */
let tearing=false, tearPrev=null;
cv.addEventListener('mousedown', e=>{
  if (e.button===2 || xDown){ tearing=true; tearPrev={x:mouse.x,y:mouse.y}; }
});
window.addEventListener('mousemove', e=>{
  if (tearing && (mouse.down || xDown)){
    const n = world.tearAt(mouse.x, mouse.y, CFG.tearRadius);
    if (n>0) tears.push({x1:tearPrev.x,y1:tearPrev.y,x2:mouse.x,y2:mouse.y,ttl:0.35});
    tearPrev={x:mouse.x,y:mouse.y};
  }
});
window.addEventListener('mouseup', ()=>{ tearing=false; tearPrev=null; });

/* ============================================================
   UI WIRING
   ============================================================ */
const $ = id=>document.getElementById(id);
document.querySelectorAll('#tools button').forEach(b=>{
  b.addEventListener('click', ()=>{
    document.querySelectorAll('#tools button').forEach(x=>x.classList.remove('active'));
    b.classList.add('active');
    tool = b.dataset.tool;
  });
});
$('gravity').oninput = e=>{ CFG.gravity=+e.target.value; $('gv').textContent=CFG.gravity; };
$('iters').oninput  = e=>{ CFG.iterations=+e.target.value; $('iv').textContent=CFG.iterations; };
$('damp').oninput   = e=>{ CFG.damping=+e.target.value; $('dv').textContent=CFG.damping.toFixed(3); };
$('wind').oninput   = e=>{ CFG.wind=+e.target.value; $('wv').textContent=CFG.wind; };
$('clearBtn').onclick = ()=>{ world.clear(); };
$('resetBtn').onclick = ()=>{ initScene(); };
$('pauseBtn').onclick = ()=> togglePause();
$('stepBtn').onclick  = ()=> stepOnce();

let paused=false, stepRequest=false;
function togglePause(){ paused=!paused; $('pauseBtn').textContent = paused?'resume (space)':'pause (space)'; }
function stepOnce(){ stepRequest=true; }

/* ============================================================
   RENDERING
   ============================================================ */
function stressColor(c){
  const s = clamp((c.stretch-1)/0.35, 0, 1);
  const lo = CFG.colors.stressLo, hi = CFG.colors.stressHi;
  // lerp hex
  const lerp=(a,b,t)=>Math.round(a+(b-a)*t);
  const pa=[1,3,5].map(i=>parseInt(lo.substr(i,2),16));
  const pb=[1,3,5].map(i=>parseInt(hi.substr(i,2),16));
  return `rgb(${lerp(pa[0],pb[0],s)},${lerp(pa[1],pb[1],s)},${lerp(pa[2],pb[2],s)})`;
}

function render(){
  const w=world.bounds.right, h=world.bounds.bottom+10;
  // background
  const g = ctx.createLinearGradient(0,0,0,h);
  g.addColorStop(0, CFG.colors.bg1); g.addColorStop(1, CFG.colors.bg0);
  ctx.fillStyle=g; ctx.fillRect(0,0,w,h);
  // floor
  ctx.fillStyle=CFG.colors.floor;
  ctx.fillRect(0, world.bounds.bottom, w, h-world.bounds.bottom);
  ctx.strokeStyle=CFG.colors.floorLine; ctx.lineWidth=2;
  ctx.beginPath(); ctx.moveTo(0,world.bounds.bottom); ctx.lineTo(w,world.bounds.bottom); ctx.stroke();
  // static shapes
  ctx.fillStyle=CFG.colors.static;
  for (const c of world.staticCircles){
    ctx.beginPath(); ctx.arc(c.x,c.y,c.r,0,Math.PI*2); ctx.fill();
    ctx.strokeStyle='#5b7391'; ctx.lineWidth=1.5; ctx.stroke();
  }
  for (const b of world.staticBoxes){
    ctx.fillRect(b.x,b.y,b.w,b.h);
    ctx.strokeStyle='#5b7391'; ctx.strokeRect(b.x,b.y,b.w,b.h);
  }
  const useStress = $('stress').checked;
  const showPts = $('showPts').checked;

  // constraints grouped by object for color
  for (const o of world.objects){
    if (o.type==='cloth' && !showPts){
      // grid lines
      ctx.strokeStyle = useStress ? null : o.color;
      ctx.lineWidth = 1;
      for (const c of o.cons){
        if (!c.alive) continue;
        if (c.stiff < 0.3) continue; // skip braces for clean cloth look
        ctx.strokeStyle = useStress ? stressColor(c) : o.color;
        ctx.beginPath(); ctx.moveTo(c.a.x,c.a.y); ctx.lineTo(c.b.x,c.b.y); ctx.stroke();
      }
  } else if (o.type==='soft' || o.type==='box'){
      // filled polygon from ring
      const ring = o.ring.filter(p=>true);
      ctx.beginPath();
      let started=false;
      for (const p of o.ring){
        if (!p) continue;
        if (!started){ ctx.moveTo(p.x,p.y); started=true; } else ctx.lineTo(p.x,p.y);
      }
      ctx.closePath();
      ctx.fillStyle = o.color+'44';
      ctx.fill();
      ctx.strokeStyle = o.color; ctx.lineWidth=2; ctx.stroke();
      if (showPts){ ctx.fillStyle=o.color; for(const p of o.points){ ctx.beginPath(); ctx.arc(p.x,p.y,2.5,0,7); ctx.fill(); } }
    } else {
      // rope / other: lines
      ctx.lineWidth = o.type==='rope'?3:2;
      for (const c of o.cons){
        if (!c.alive) continue;
        ctx.strokeStyle = useStress ? stressColor(c) : o.color;
        ctx.beginPath(); ctx.moveTo(c.a.x,c.a.y); ctx.lineTo(c.b.x,c.b.y); ctx.stroke();
      }
      if (showPts || o.type!=='rope'){
        ctx.fillStyle=o.color;
        for (const p of o.points){ ctx.beginPath(); ctx.arc(p.x,p.y,2.5,0,7); ctx.fill(); }
      }
    }
  }
  // ball objects render as circles
  for (const o of world.objects){
    if (o.type==='ball'){
      const p=o.points[0];
      const grd = ctx.createRadialGradient(p.x-4,p.y-4,2, p.x,p.y,CFG.ballRadius);
      grd.addColorStop(0,'#ffd6f3'); grd.addColorStop(1,o.color);
      ctx.fillStyle=grd;
      ctx.beginPath(); ctx.arc(p.x,p.y,CFG.ballRadius,0,Math.PI*2); ctx.fill();
    }
  }
  // pins
  ctx.fillStyle=CFG.colors.pin;
  for (const p of world.points){
    if (p.pinned){ ctx.beginPath(); ctx.arc(p.x,p.y,4.5,0,Math.PI*2); ctx.fill(); }
  }
  // grab highlight
  if (grabbed){
    ctx.strokeStyle=CFG.colors.grab; ctx.lineWidth=1.5;
    ctx.beginPath(); ctx.arc(grabbed.x,grabbed.y,CFG.grabRadius*0.5,0,Math.PI*2); ctx.stroke();
  }
  // tear flashes
  for (let i=tears.length-1;i>=0;i--){
    const t=tears[i]; t.ttl-=1/60;
    if (t.ttl<=0){ tears.splice(i,1); continue; }
    ctx.strokeStyle=`rgba(255,120,120,${t.ttl*2.5})`;
    ctx.lineWidth=2;
    ctx.beginPath(); ctx.moveTo(t.x1,t.y1); ctx.lineTo(t.x2,t.y2); ctx.stroke();
  }
  // drag preview
  if (drawing){
    ctx.strokeStyle='rgba(255,255,255,0.5)'; ctx.setLineDash([6,5]); ctx.lineWidth=1.5;
    ctx.beginPath(); ctx.moveTo(drawing.x0,drawing.y0); ctx.lineTo(drawing.x1,drawing.y1); ctx.stroke();
    ctx.setLineDash([]);
    if (tool==='cloth'){
      const dw=Math.abs(drawing.x1-drawing.x0), dh=Math.abs(drawing.y1-drawing.y0);
      ctx.strokeRect(Math.min(drawing.x0,drawing.x1), Math.min(drawing.y0,drawing.y1), dw, dh);
    }
    if (tool==='soft'||tool==='ball'){
      const R=dist(drawing.x0,drawing.y0,drawing.x1,drawing.y1);
      ctx.beginPath(); ctx.arc(drawing.x0,drawing.y0, tool==='ball'?CFG.ballRadius:Math.max(R,30),0,Math.PI*2); ctx.stroke();
    }
  }
  // cursor highlight when hovering near a grabbable point
  if (tool==='grab' && !grabbed){
    const p = world.nearestPoint(mouse.x,mouse.y,CFG.grabRadius);
    if (p){ ctx.strokeStyle='rgba(255,255,255,0.35)'; ctx.beginPath(); ctx.arc(p.x,p.y,8,0,Math.PI*2); ctx.stroke(); }
  }
}

/* ============================================================
   MAIN LOOP — fixed timestep accumulator
   ============================================================ */
let last=performance.now(), fpsAcc=0, fpsN=0, fpsShown=0, physMs=0;
function frame(now){
  requestAnimationFrame(frame);
  let dt = (now-last)/1000; last=now;
  dt = clamp(dt, 0, CFG.maxFrameDt); // tab-switch clamp

  const t0 = performance.now();
  if (!paused){
    let steps = 0;
    acc += dt;
    const stepDt = CFG.dt;
    while (acc >= stepDt && steps < CFG.maxSubSteps){
      world.step(stepDt);
      acc -= stepDt; steps++;
    }
    if (acc > stepDt*CFG.maxSubSteps) acc = 0; // shed backlog
  } else if (stepRequest){
    world.step(CFG.dt);
    stepRequest=false;
  }
  physMs = physMs*0.9 + (performance.now()-t0)*0.1;

  // grab follows cursor during physics (so solver reacts live)
  if (grabbed){
    grabbed.grabTo(mouse.x, mouse.y, mouse.vx, mouse.vy);
  }

  render();

  // hud
  fpsAcc += dt; fpsN++;
  if (fpsAcc > 0.5){ fpsShown = Math.round(fpsN/fpsAcc); fpsAcc=0; fpsN=0; }
  $('hud').innerHTML = `FPS ${fpsShown}<br>physics ${physMs.toFixed(2)} ms<br>pts ${world.points.length} / cons ${world.cons.length}`;
}
let acc=0;

/* ============================================================
   SCENE / BOOT
   ============================================================ */
function initScene(){
  world.clear();
  buildStatic();
  // pendulum rope
  spawnRope(280, 60, 280, 150+CFG.ropeCount*CFG.ropeSeg);
  // cloth pinned both corners, drapes over the static circle
  spawnCloth(480, 60, CFG.clothCols, CFG.clothRows, CFG.clothSpacing, 'corners');
  // soft blob + box resting
  spawnBlob(250, 200, CFG.softRadius, CFG.softCount);
  spawnSoftBox(700, 150, 110, 90);
  spawnBall(400, 100);
}

/* expose for headless probing */
window.sim = {
  world, CFG, points: world.points, cons: world.cons,
  step(n=1){ for(let i=0;i<n;i++) world.step(CFG.dt); },
  spawnRope, spawnCloth, spawnBlob, spawnSoftBox, spawnBall,
  initScene, kineticEnergy: ()=>world.kineticEnergy(),
  sanity: ()=>world.sanity()
};

initScene();
requestAnimationFrame(frame);
</script>
</body>
</html>
<!-- agent-meta {"model":"z-ai/glm-5.3-flash","provider":"openrouter","persona":"full","sessionId":"8becd10f-94f5-4537-b374-22756948855b","tokensIn":456616,"tokensOut":12506,"tokensTotal":469122,"cost":0.018326824999999998,"turns":14,"toolCalls":17,"failedToolCalls":1,"timestamp":"2026-08-28T03:21:45.649Z"} -->