Physics Playground
GLM 5.3 Flash · 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 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 · right-drag or hold X: tear · shift-click: pin/unpin point<br>
space: pause · →: step one frame · 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"} -->