Physics Playground
muse spark 1.2 contributor · 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 43,538 bytes · SHA-256 bf86b72bab23
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verlet Playground — Soft-Body Lab</title>
<style>
:root{
--bg:#0c0f14;
--panel:#141a22;
--panel2:#1a2330;
--border:#233040;
--text:#e6eef8;
--muted:#8aa0b8;
--accent:#4cc9f0;
--rope:#f72585;
--cloth:#4cc9f0;
--soft:#ffb703;
--ball:#90be6d;
--pin:#ff006e;
}
*{box-sizing:border-box;margin:0;padding:0}
body{
background:var(--bg);
color:var(--text);
font:13px/1.4 ui-sans-system,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial;
overflow:hidden;height:100vh;display:flex;flex-direction:column;
user-select:none;
}
/* Top bar */
#topbar{
display:flex;align-items:center;gap:10px;
padding:8px 12px;background:var(--panel);border-bottom:1px solid var(--border);
flex-wrap:wrap;z-index:10;
}
#logo{font-weight:800;letter-spacing:.06em;font-size:13px;color:var(--accent);display:flex;align-items:center;gap:8px}
#logo b{font-size:18px}
.sep{width:1px;height:24px;background:var(--border);margin:0 4px}
.tool-group{display:flex;gap:6px;align-items:center}
.btn{
background:var(--panel2);border:1px solid var(--border);color:var(--text);
padding:6px 12px;border-radius:8px;cursor:pointer;font-weight:600;font-size:12px;
transition:.15s; display:flex;align-items:center;gap:6px;
}
.btn:hover{border-color:#3a506b;transform:translateY(-1px)}
.btn.active{background:var(--accent);color:#001018;border-color:var(--accent);box-shadow:0 4px 16px rgba(76,201,240,.35)}
.btn.tool-rope.active{background:var(--rope);border-color:var(--rope);color:#fff}
.btn.tool-cloth.active{background:var(--cloth);border-color:var(--cloth);color:#001018}
.btn.tool-soft.active{background:var(--soft);border-color:var(--soft);color:#1a1200}
.btn.tool-ball.active{background:var(--ball);border-color:var(--ball);color:#0a1400}
.btn.small{padding:5px 9px;font-size:11px}
.btn.ghost{background:transparent}
.btn.danger{border-color:#ff3b5c;color:#ff6b8a}
.btn.danger:hover{background:#ff3b5c;color:#fff}
/* sliders */
.ctrl{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted)}
.ctrl label{white-space:nowrap}
.ctrl input[type=range]{width:90px;accent-color:var(--accent);height:4px}
.ctrl .val{color:var(--text);font-weight:700;min-width:28px;text-align:right;font-variant-numeric:tabular-nums}
.toggle{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted);cursor:pointer}
.toggle input{accent-color:var(--accent)}
/* canvas wrap */
#wrap{flex:1;position:relative;overflow:hidden;background:radial-gradient(1200px 600px at 50% -10%,#1a2a3d 0%,var(--bg) 60%)}
canvas{display:block;width:100%;height:100%}
#overlay{
position:absolute;inset:0;pointer-events:none;
}
/* bottom stats */
#stats{
position:absolute;left:10px;bottom:10px;background:rgba(12,15,20,.85);border:1px solid var(--border);
padding:8px 10px;border-radius:10px;font-size:11px;color:var(--muted);backdrop-filter:blur(8px);
display:flex;gap:14px;align-items:center;pointer-events:none;
}
#stats b{color:var(--text)}
#hint{
position:absolute;right:10px;bottom:10px;background:rgba(12,15,20,.85);border:1px solid var(--border);
padding:8px 10px;border-radius:10px;font-size:11px;color:var(--muted);max-width:360px;line-height:1.5;
}
#hint kbd{background:#1e2d40;border:1px solid #2a405c;border-bottom-width:2px;padding:1px 5px;border-radius:4px;color:var(--text);font-size:10px}
/* cursor grab */
.grabbing{cursor:grabbing !important}
/* tooltip */
#toast{
position:absolute;left:50%;top:14px;transform:translateX(-50%);
background:var(--accent);color:#001018;padding:6px 12px;border-radius:999px;font-weight:700;font-size:12px;
opacity:0;pointer-events:none;transition:.3s;
}
#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
</style>
</head>
<body>
<div id="topbar">
<div id="logo"><b>◈</b> VERLET LAB</div>
<div class="sep"></div>
<div class="tool-group" id="spawnTools">
<button class="btn tool-rope active" data-tool="rope" title="Click-drag to draw rope">〰 Rope</button>
<button class="btn tool-cloth" data-tool="cloth" title="Click-drag to place cloth">▦ Cloth</button>
<button class="btn tool-soft" data-tool="soft" title="Click to spawn soft body">⬢ Soft</button>
<button class="btn tool-ball" data-tool="ball" title="Click to spawn ball">● Ball</button>
</div>
<div class="sep"></div>
<div class="tool-group">
<button class="btn small ghost" id="btnPause" title="Space">⏸ Pause</button>
<button class="btn small ghost" id="btnStep" title="→ Step">⏭ Step</button>
<button class="btn small danger" id="btnClear">↺ Reset</button>
</div>
<div class="sep"></div>
<div class="tool-group">
<div class="ctrl"><label>Gravity</label><input id="slGrav" type="range" min="0" max="2000" value="980"><span class="val" id="vGrav">980</span></div>
<div class="ctrl"><label>Iterations</label><input id="slIter" type="range" min="1" max="12" value="5"><span class="val" id="vIter">5</span></div>
<div class="ctrl"><label>Wind</label><input id="slWind" type="range" min="-400" max="400" value="0"><span class="val" id="vWind">0</span></div>
</div>
<div class="sep"></div>
<label class="toggle"><input type="checkbox" id="chkStress"> Stress</label>
<label class="toggle"><input type="checkbox" id="chkMesh" checked> Mesh</label>
<label class="toggle"><input type="checkbox" id="chkPoints"> Points</label>
<label class="toggle"><input type="checkbox" id="chkFloor" checked> Floor</label>
<div class="sep"></div>
<div class="ctrl"><label>Cloth pins</label>
<select id="selPin" style="background:var(--panel2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:4px 6px;font-size:11px">
<option value="corners">Corners</option>
<option value="edge" selected>Top edge</option>
<option value="none">None</option>
</select>
</div>
</div>
<div id="wrap">
<canvas id="c"></canvas>
<div id="toast">Torn!</div>
<div id="stats">
<span>FPS <b id="sFps">60</b></span>
<span>Phys <b id="sPhys">0.0ms</b></span>
<span>Points <b id="sPts">0</b></span>
<span>Constraints <b id="sCons">0</b></span>
<span>Energy <b id="sEng">0</b></span>
</div>
<div id="hint">
<b style="color:var(--text)">Controls</b><br>
<kbd>Drag</kbd> grab & throw <kbd>Right-drag</kbd> tear <kbd>Alt+Click</kbd> pin/unpin<br>
<kbd>Space</kbd> pause <kbd>→</kbd> step <kbd>R</kbd> rope <kbd>C</kbd> cloth <kbd>B</kbd> ball <kbd>S</kbd> soft
</div>
</div>
<script>
/* ============================================================
CONFIG — tunable constants
============================================================ */
const CONFIG = {
TIMESTEP: 1/60,
MAX_DT: 1/30, // clamp frame delta (tab switch safety)
MAX_SUBSTEPS: 8, // max physics steps per frame
DAMPING: 0.995, // velocity damping per step (energy decay)
GRAVITY: 980, // px/s²
ITERATIONS: 5, // constraint solver iterations
TEAR_RADIUS: 18, // px radius for cut
GRAB_RADIUS: 22,
POINT_RADIUS: 4,
FRICTION: 0.92, // wall friction
BOUNCE: 0.55,
WIND: 0,
COLORS: {
bg: "#0c0f14",
rope: "#f72585",
cloth: "#4cc9f0",
clothFill: "rgba(76,201,240,0.18)",
soft: "#ffb703",
softFill: "rgba(255,183,3,0.22)",
ball: "#90be6d",
pin: "#ff006e",
wall: "#1e2d40",
stressLow: "#2ec4b6",
stressHigh: "#e71d36"
}
};
/* ============================================================
MATH HELPERS
============================================================ */
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
const lerp=(a,b,t)=>a+(b-a)*t;
function dist2(ax,ay,bx,by){ const dx=ax-bx, dy=ay-by; return dx*dx+dy*dy; }
/* ============================================================
PHYSICS CORE — Verlet + Distance Constraints
============================================================ */
class Point{
constructor(x,y,pinned=false,radius=4){
this.x=x; this.y=y;
this.px=x; this.py=y; // previous
this.ax=0; this.ay=0;
this.pinned=pinned;
this.radius=radius;
this.mass=1;
this.invMass=pinned?0:1;
this.color=null;
this.id=Math.random().toString(36).slice(2,7);
}
pin(){ this.pinned=true; this.invMass=0; }
unpin(){ this.pinned=false; this.invMass=1; }
}
class Constraint{
constructor(a,b,rest=null,stiffness=1, type='structural'){
this.a=a; this.b=b;
this.rest = (rest ?? Math.hypot(a.x-b.x, a.y-b.y)) || 1;
this.stiffness=stiffness;
this.type=type; // for rendering
this.broken=false;
}
// returns stretch ratio
stretch(){
const d=Math.hypot(this.a.x-this.b.x, this.a.y-this.b.y);
return Math.abs(d - this.rest)/this.rest;
}
}
class Engine{
constructor(){
this.points=[];
this.constraints=[];
this.statics=[]; // {x,y,r} circles or {x,y,w,h} boxes
this.gravity=CONFIG.GRAVITY;
this.wind=CONFIG.WIND;
this.iterations=CONFIG.ITERATIONS;
this.damping=CONFIG.DAMPING;
this.bounds={x:0,y:0,w:1200,h:700};
this.floorY=0;
this.paused=false;
}
clear(){
this.points.length=0;
this.constraints.length=0;
}
addPoint(p){ this.points.push(p); return p; }
addConstraint(c){ this.constraints.push(c); return c; }
// Verlet integration step + constraint relaxation + collisions
step(dt){
const g=this.gravity, w=this.wind, damp=this.damping;
const dt2=dt*dt;
// integrate
for(const p of this.points){
if(p.pinned) continue;
const vx=(p.x - p.px)*damp;
const vy=(p.y - p.py)*damp;
const nx = p.x + vx + (p.ax + w*0.02)*dt2;
const ny = p.y + vy + (p.ay + g)*dt2;
p.px=p.x; p.py=p.y;
p.x=nx; p.y=ny;
p.ax=0; p.ay=0;
// NaN guard
if(!Number.isFinite(p.x)||!Number.isFinite(p.y)){
p.x=p.px; p.y=p.py;
}
}
// constraint relaxation
for(let iter=0; iter<this.iterations; iter++){
for(const c of this.constraints){
if(c.broken) continue;
const a=c.a, b=c.b;
const dx=b.x - a.x, dy=b.y - a.y;
let d=Math.hypot(dx,dy);
if(d<1e-6) continue; // guard zero length
if(!Number.isFinite(d)) continue;
const diff=(c.rest - d)/d;
// stiffness scaling per iteration
const k=c.stiffness * (1 - 0.15*iter/this.iterations);
const sx=dx * diff * 0.5 * k;
const sy=dy * diff * 0.5 * k;
const invA=a.invMass, invB=b.invMass;
const sum=invA+invB;
if(sum===0) continue;
// distribute by inverse mass
if(!a.pinned){
a.x -= sx * (invA/sum)*2;
a.y -= sy * (invA/sum)*2;
}
if(!b.pinned){
b.x += sx * (invB/sum)*2;
b.y += sy * (invB/sum)*2;
}
}
// static shape collisions after each iteration for stability
this.collidePoints();
}
// bounds / floor
this.collideBounds();
// point-point simple separation (cheap)
// this.collidePointPoint(); // optional, disabled for perf
}
collidePoints(){
for(const p of this.points){
if(p.pinned) continue;
for(const s of this.statics){
if(s.type==='circle'){
const dx=p.x - s.x, dy=p.y - s.y;
let d=Math.hypot(dx,dy);
const minD=s.r + p.radius;
if(d < minD){
if(d<1e-6){ p.y -= minD; continue; }
const nx=dx/d, ny=dy/d;
const overlap=minD - d;
p.x += nx*overlap;
p.y += ny*overlap;
// friction on velocity
const vx=p.x - p.px, vy=p.y - p.py;
const dot=vx*nx + vy*ny;
// reflect normal a bit
if(dot<0){
p.px += nx*dot*CONFIG.BOUNCE;
p.py += ny*dot*CONFIG.BOUNCE;
}
// tangential friction
const tx=-ny, ty=nx;
const tdot=vx*tx + vy*ty;
p.px += tx*tdot*(1-CONFIG.FRICTION)*0.3;
p.py += ty*tdot*(1-CONFIG.FRICTION)*0.3;
}
} else if(s.type==='box'){
// AABB
const closestX=clamp(p.x, s.x, s.x+s.w);
const closestY=clamp(p.y, s.y, s.y+s.h);
const dx=p.x-closestX, dy=p.y-closestY;
const d2=dx*dx+dy*dy;
const r2=p.radius*p.radius;
if(d2 < r2 && d2>1e-6){
const d=Math.sqrt(d2);
const nx=dx/d, ny=dy/d;
const overlap=p.radius - d;
p.x += nx*overlap;
p.y += ny*overlap;
const vx=p.x-p.px, vy=p.y-p.py;
const dot=vx*nx+vy*ny;
if(dot<0){ p.px+=nx*dot*CONFIG.BOUNCE; p.py+=ny*dot*CONFIG.BOUNCE; }
} else if(d2<1e-6){
// inside box — push out via smallest penetration
const toLeft=p.x - s.x, toRight=(s.x+s.w)-p.x, toTop=p.y - s.y, toBottom=(s.y+s.h)-p.y;
const m=Math.min(toLeft,toRight,toTop,toBottom);
if(m===toLeft) p.x = s.x - p.radius;
else if(m===toRight) p.x = s.x+s.w + p.radius;
else if(m===toTop) p.y = s.y - p.radius;
else p.y = s.y+s.h + p.radius;
}
}
}
}
}
collideBounds(){
const b=this.bounds;
const floor=this.floorY;
for(const p of this.points){
if(p.pinned) continue;
// floor
if(p.y + p.radius > floor){
p.y = floor - p.radius;
const vy=p.y - p.py;
// bounce damping
p.py = p.y + vy*CONFIG.BOUNCE*0.35;
// friction
const vx=p.x - p.px;
p.px += vx*(1-CONFIG.FRICTION)*0.5;
// clamp NaN
if(!Number.isFinite(p.py)) p.py=p.y;
}
// walls
if(p.x - p.radius < b.x){
p.x = b.x + p.radius;
const vx=p.x - p.px;
p.px = p.x + vx*CONFIG.BOUNCE*0.4;
}
if(p.x + p.radius > b.x + b.w){
p.x = b.x + b.w - p.radius;
const vx=p.x - p.px;
p.px = p.x + vx*CONFIG.BOUNCE*0.4;
}
// ceiling
if(p.y - p.radius < b.y){
p.y = b.y + p.radius;
const vy=p.y - p.py;
p.py = p.y + vy*CONFIG.BOUNCE*0.4;
}
}
}
// energetic measure
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+= (vx*vx+vy*vy)*0.5;
}
return e;
}
// tearing — break constraints whose segment intersects cut line
tearLine(x0,y0,x1,y1,radius=CONFIG.TEAR_RADIUS){
let broken=0;
for(const c of this.constraints){
if(c.broken) continue;
// distance from segment c.a-c.b to cut segment
const d = segSegDist(c.a.x,c.a.y,c.b.x,c.b.y, x0,y0,x1,y1);
if(d < radius){
c.broken=true; broken++;
}
}
// remove broken constraints from array lazily (keep but skip), or filter occasionally
if(broken>12){
this.constraints = this.constraints.filter(c=>!c.broken);
}
return broken;
}
}
// segment-segment distance
function segSegDist(ax,ay,bx,by, cx,cy,dx,dy){
// closest distance between segments AB and CD
// brute: sample + analytic
function ptSegDist(px,py, x1,y1,x2,y2){
const l2=(x2-x1)*(x2-x1)+(y2-y1)*(y2-y1);
if(l2<1e-9) return Math.hypot(px-x1,py-y1);
let t=((px-x1)*(x2-x1)+(py-y1)*(y2-y1))/l2;
t=clamp(t,0,1);
const projx=x1+(x2-x1)*t, projy=y1+(y2-y1)*t;
return Math.hypot(px-projx,py-projy);
}
// check 4 point-to-segment combos as approximation (good enough for tear)
return Math.min(
ptSegDist(ax,ay,cx,cy,dx,dy),
ptSegDist(bx,by,cx,cy,dx,dy),
ptSegDist(cx,cy,ax,ay,bx,by),
ptSegDist(dx,dy,ax,ay,bx,by)
);
}
/* ============================================================
SPAWN HELPERS
============================================================ */
function spawnRope(engine, x0,y0,x1,y1, segments=14){
const pts=[];
const len=Math.hypot(x1-x0,y1-y0);
const n = segments;
for(let i=0;i<n;i++){
const t=i/(n-1);
const x=lerp(x0,x1,t), y=lerp(y0,y1,t);
const p=new Point(x,y, i===0, 3.5);
p.color=CONFIG.COLORS.rope;
engine.addPoint(p); pts.push(p);
}
for(let i=0;i<n-1;i++){
engine.addConstraint(new Constraint(pts[i], pts[i+1], null, 1, 'rope'));
}
// second pass stiffness for less stretch
return pts;
}
function spawnCloth(engine, x,y,w,h, cols=12, rows=8, pinMode='edge'){
const pts=[];
const dx=w/cols, dy=h/rows;
const grid=[];
for(let r=0;r<=rows;r++){
grid[r]=[];
for(let c=0;c<=cols;c++){
const px=x + c*dx, py=y + r*dy;
let pinned=false;
if(pinMode==='edge' && r===0) pinned=true;
else if(pinMode==='corners' && r===0 && (c===0 || c===cols)) pinned=true;
const p=new Point(px,py,pinned, 3);
p.color=CONFIG.COLORS.cloth;
engine.addPoint(p);
grid[r][c]=p; pts.push(p);
}
}
// structural
for(let r=0;r<=rows;r++){
for(let c=0;c<=cols;c++){
if(c<cols) engine.addConstraint(new Constraint(grid[r][c], grid[r][c+1], dx, 1, 'cloth'));
if(r<rows) engine.addConstraint(new Constraint(grid[r][c], grid[r+1][c], dy, 1, 'cloth'));
// shear
if(c<cols && r<rows){
engine.addConstraint(new Constraint(grid[r][c], grid[r+1][c+1], Math.hypot(dx,dy), 0.85, 'shear'));
engine.addConstraint(new Constraint(grid[r][c+1], grid[r+1][c], Math.hypot(dx,dy), 0.85, 'shear'));
}
// bend (second neighbor) for stiffness
if(c<cols-1) engine.addConstraint(new Constraint(grid[r][c], grid[r][c+2], dx*2, 0.45, 'bend'));
if(r<rows-1) engine.addConstraint(new Constraint(grid[r][c], grid[r+2][c], dy*2, 0.45, 'bend'));
}
}
return {pts, grid, cols, rows, x,y,w,h};
}
function spawnSoftBody(engine, cx,cy, radius=48, segments=12){
const pts=[];
const center=new Point(cx,cy,false,4);
center.color=CONFIG.COLORS.soft;
engine.addPoint(center); pts.push(center);
const ring=[];
for(let i=0;i<segments;i++){
const ang=(i/segments)*Math.PI*2;
const x=cx + Math.cos(ang)*radius;
const y=cy + Math.sin(ang)*radius;
const p=new Point(x,y,false,4);
p.color=CONFIG.COLORS.soft;
engine.addPoint(p); ring.push(p); pts.push(p);
}
// edge
for(let i=0;i<segments;i++){
const a=ring[i], b=ring[(i+1)%segments];
engine.addConstraint(new Constraint(a,b,null,1,'soft'));
// spoke to center
engine.addConstraint(new Constraint(a,center,null,1,'soft'));
// brace to next-next
const c2=ring[(i+2)%segments];
engine.addConstraint(new Constraint(a,c2,null,0.7,'soft'));
}
// cross braces
for(let i=0;i<segments;i++){
const a=ring[i], b=ring[(i+segments/2|0)%segments];
if(i<segments/2) engine.addConstraint(new Constraint(a,b,null,0.35,'soft'));
}
return pts;
}
function spawnBall(engine,x,y,r=16){
const p=new Point(x,y,false,r);
p.color=CONFIG.COLORS.ball;
engine.addPoint(p);
return p;
}
/* ============================================================
APP SETUP
============================================================ */
const canvas=document.getElementById('c');
const ctx=canvas.getContext('2d');
const wrap=document.getElementById('wrap');
const engine=new Engine();
// expose for probing — use a non-conflicting name to avoid overwriting Engine#step
window.sim = engine;
window.sim.physStep = function(dt){ return Engine.prototype.step.call(engine, dt===undefined?CONFIG.TIMESTEP:dt); };
// keep .step as alias but ensure it doesn't recurse via instance property shadowing
const _realStep = Engine.prototype.step;
Object.defineProperty(window.sim, 'step', {
value: function(dt){ return _realStep.call(engine, dt===undefined?CONFIG.TIMESTEP:dt); },
writable: true, configurable: true
});
window.CONFIG = CONFIG;
let W=0,H=0, DPR=1;
function resize(){
DPR=Math.min(2, window.devicePixelRatio||1);
W=wrap.clientWidth; H=wrap.clientHeight;
canvas.width=W*DPR; canvas.height=H*DPR;
canvas.style.width=W+'px'; canvas.style.height=H+'px';
ctx.setTransform(DPR,0,0,DPR,0,0);
engine.bounds={x:0,y:0,w:W,h:H};
engine.floorY=H-28;
// reposition statics relative to new size
buildStatics();
}
window.addEventListener('resize',resize);
function buildStatics(){
engine.statics.length=0;
// floor is handled as bounds, but also add visual boxes
// two circles to drape cloth over
engine.statics.push({type:'circle', x:W*0.5, y:H-110, r:62});
engine.statics.push({type:'circle', x:W*0.72, y:H-90, r:44});
engine.statics.push({type:'box', x:W*0.12, y:H-120, w:120, h:18});
engine.statics.push({type:'box', x:W*0.12, y:H-160, w:18, h:40});
}
resize();
/* initial demo scene */
function demo(){
engine.clear();
// rope pendulum
spawnRope(engine, W*0.18, 90, W*0.18, 280, 16);
// cloth
spawnCloth(engine, W*0.32, 70, 260, 160, 12, 8, document.getElementById('selPin').value);
// soft body
spawnSoftBody(engine, W*0.78, 120, 46, 12);
// balls
spawnBall(engine, W*0.62, 80, 14);
spawnBall(engine, W*0.66, 50, 18);
}
demo();
/* ============================================================
INTERACTION
============================================================ */
let tool='rope';
let isDragging=false, dragStart=null, dragEnd=null;
let grabbed=null, grabOffset={x:0,y:0}, lastMouse={x:0,y:0}, mouseVel={x:0,y:0};
let isTearing=false, tearPrev=null;
let showStress=false, showMesh=true, showPoints=false, showFloor=true;
const toolsEl=document.getElementById('spawnTools');
toolsEl.addEventListener('click',e=>{
const b=e.target.closest('[data-tool]');
if(!b) return;
tool=b.dataset.tool;
toolsEl.querySelectorAll('.btn').forEach(x=>x.classList.remove('active'));
b.classList.add('active');
});
function canvasPos(e){
const rect=canvas.getBoundingClientRect();
const x=(e.clientX - rect.left);
const y=(e.clientY - rect.top);
return {x,y};
}
function findNearest(x,y, radius=CONFIG.GRAB_RADIUS){
let best=null, bestD= radius*radius;
for(const p of engine.points){
const d2=dist2(p.x,p.y,x,y);
if(d2 < bestD){ bestD=d2; best=p; }
}
return best;
}
canvas.addEventListener('contextmenu',e=>e.preventDefault());
canvas.addEventListener('pointerdown',e=>{
const pos=canvasPos(e);
lastMouse={...pos};
// Alt to pin
if(e.altKey){
const p=findNearest(pos.x,pos.y, 24);
if(p){ p.pinned? p.unpin(): p.pin(); toast(p.pinned?'Pinned':'Unpinned'); }
return;
}
// right button or shift -> tear
if(e.button===2 || e.shiftKey){
isTearing=true; tearPrev={...pos};
canvas.setPointerCapture(e.pointerId);
return;
}
// left button: try grab first
const near=findNearest(pos.x,pos.y);
if(near){
grabbed=near;
grabOffset.x=near.x - pos.x;
grabOffset.y=near.y - pos.y;
// pin grabbed while dragging
grabbed._wasPinned=grabbed.pinned;
grabbed.pin();
canvas.setPointerCapture(e.pointerId);
wrap.classList.add('grabbing');
return;
}
// otherwise start spawn drag
isDragging=true;
dragStart={...pos};
dragEnd={...pos};
canvas.setPointerCapture(e.pointerId);
});
canvas.addEventListener('pointermove',e=>{
const pos=canvasPos(e);
// velocity
mouseVel.x=pos.x-lastMouse.x;
mouseVel.y=pos.y-lastMouse.y;
lastMouse={...pos};
if(grabbed){
grabbed.x = pos.x + grabOffset.x;
grabbed.y = pos.y + grabOffset.y;
// keep prev in sync to avoid huge velocity on release
// we set px to previous frame's position for throw
return;
}
if(isTearing && tearPrev){
const broken=engine.tearLine(tearPrev.x,tearPrev.y,pos.x,pos.y, CONFIG.TEAR_RADIUS);
if(broken>0) showToast('✂ Tear');
tearPrev={...pos};
return;
}
if(isDragging && dragStart){
dragEnd={...pos};
}
});
function endPointer(e){
const pos= e? canvasPos(e): lastMouse;
if(grabbed){
// throw: set previous position opposite to drag velocity
grabbed.unpin();
if(grabbed._wasPinned) grabbed.pin(); // if it was pinned before, keep pinned? No, toggle off for throw
// Actually if it was pinned and alt wasn't used, we want to keep it pinned? For UX, release as unpinned unless it was originally pinned
if(grabbed._wasPinned){
// keep pinned if originally pinned, but give velocity by adjusting px
// don't unpin
grabbed.pin();
} else {
grabbed.unpin();
// apply throw velocity: px = x - vx
grabbed.px = grabbed.x - mouseVel.x*0.9;
grabbed.py = grabbed.y - mouseVel.y*0.9;
}
delete grabbed._wasPinned;
grabbed=null;
wrap.classList.remove('grabbing');
}
if(isTearing){
isTearing=false; tearPrev=null;
}
if(isDragging && dragStart && dragEnd){
const dx=dragEnd.x - dragStart.x, dy=dragEnd.y - dragStart.y;
const len=Math.hypot(dx,dy);
if(len>8){
if(tool==='rope'){
const segs = clamp(Math.round(len/14), 6, 28);
spawnRope(engine, dragStart.x, dragStart.y, dragEnd.x, dragEnd.y, segs);
} else if(tool==='cloth'){
const w=Math.abs(dx), h=Math.abs(dy);
const x=Math.min(dragStart.x,dragEnd.x), y=Math.min(dragStart.y,dragEnd.y);
const cols=clamp(Math.round(w/22), 4, 16), rows=clamp(Math.round(h/22), 3, 12);
spawnCloth(engine, x,y, Math.max(80,w), Math.max(60,h), cols, rows, document.getElementById('selPin').value);
}
} else {
// click spawn for ball/soft
if(tool==='ball') spawnBall(engine, pos.x,pos.y, 14+Math.random()*10);
else if(tool==='soft') spawnSoftBody(engine, pos.x,pos.y, 36+Math.random()*18, 10+Math.floor(Math.random()*4));
else if(tool==='rope') spawnRope(engine, pos.x,pos.y, pos.x, pos.y+120, 12);
else if(tool==='cloth') spawnCloth(engine, pos.x-80,pos.y-40, 160,110, 10,6, document.getElementById('selPin').value);
}
isDragging=false; dragStart=null; dragEnd=null;
}
}
canvas.addEventListener('pointerup',endPointer);
canvas.addEventListener('pointercancel',endPointer);
canvas.addEventListener('pointerleave',()=>{ if(grabbed) endPointer(); });
/* keyboard */
window.addEventListener('keydown',e=>{
if(e.code==='Space'){ e.preventDefault(); togglePause(); }
else if(e.code==='ArrowRight' && engine.paused){ engine.step(CONFIG.TIMESTEP); }
else if(e.key.toLowerCase()==='r'){ setTool('rope'); }
else if(e.key.toLowerCase()==='c'){ setTool('cloth'); }
else if(e.key.toLowerCase()==='b'){ setTool('ball'); }
else if(e.key.toLowerCase()==='s'){ setTool('soft'); }
else if(e.key==='Delete' || e.key.toLowerCase()==='x'){ engine.clear(); }
});
function setTool(t){
tool=t;
toolsEl.querySelectorAll('.btn').forEach(b=>b.classList.toggle('active', b.dataset.tool===t));
}
function togglePause(){
engine.paused=!engine.paused;
document.getElementById('btnPause').textContent= engine.paused? '▶ Resume':'⏸ Pause';
}
document.getElementById('btnPause').addEventListener('click',togglePause);
document.getElementById('btnStep').addEventListener('click',()=>{ if(engine.paused) engine.step(CONFIG.TIMESTEP); else { engine.paused=true; engine.step(CONFIG.TIMESTEP); togglePause(); togglePause(); } });
document.getElementById('btnClear').addEventListener('click',()=>{ demo(); });
/* sliders */
const slGrav=document.getElementById('slGrav'), vGrav=document.getElementById('vGrav');
const slIter=document.getElementById('slIter'), vIter=document.getElementById('vIter');
const slWind=document.getElementById('slWind'), vWind=document.getElementById('vWind');
slGrav.addEventListener('input',()=>{ engine.gravity=parseInt(slGrav.value); vGrav.textContent=slGrav.value; });
slIter.addEventListener('input',()=>{ engine.iterations=parseInt(slIter.value); vIter.textContent=slIter.value; });
slWind.addEventListener('input',()=>{ engine.wind=parseInt(slWind.value); vWind.textContent=slWind.value; });
document.getElementById('chkStress').addEventListener('change',e=>showStress=e.target.checked);
document.getElementById('chkMesh').addEventListener('change',e=>showMesh=e.target.checked);
document.getElementById('chkPoints').addEventListener('change',e=>showPoints=e.target.checked);
document.getElementById('chkFloor').addEventListener('change',e=>showFloor=e.target.checked);
document.getElementById('selPin').addEventListener('change',()=>{});
/* toast */
let toastT=0;
function toast(msg){
const el=document.getElementById('toast');
el.textContent=msg; el.classList.add('show');
clearTimeout(toastT); toastT=setTimeout(()=>el.classList.remove('show'),900);
}
function showToast(m){ toast(m); }
/* ============================================================
RENDERING
============================================================ */
function drawStatics(){
// floor
if(showFloor){
ctx.fillStyle='#0f1e2e';
ctx.fillRect(0, engine.floorY, W, H-engine.floorY);
ctx.fillStyle='#1a2f4a';
ctx.fillRect(0, engine.floorY, W, 2);
// grid
ctx.strokeStyle='rgba(255,255,255,0.04)';
ctx.lineWidth=1;
for(let x=0;x<W;x+=40){ ctx.beginPath(); ctx.moveTo(x,engine.floorY); ctx.lineTo(x,H); ctx.stroke(); }
}
// circles
for(const s of engine.statics){
if(s.type==='circle'){
ctx.beginPath(); ctx.arc(s.x,s.y,s.r,0,Math.PI*2);
const g=ctx.createRadialGradient(s.x-12,s.y-18,6, s.x,s.y,s.r);
g.addColorStop(0,'#2a4a6b'); g.addColorStop(1,'#0f1e2e');
ctx.fillStyle=g; ctx.fill();
ctx.strokeStyle='rgba(76,201,240,0.25)'; ctx.lineWidth=2; ctx.stroke();
// highlight
ctx.beginPath(); ctx.arc(s.x-10,s.y-14,s.r*0.28,0,Math.PI*2);
ctx.fillStyle='rgba(255,255,255,0.07)'; ctx.fill();
} else if(s.type==='box'){
ctx.fillStyle='#1c2e45';
ctx.strokeStyle='rgba(76,201,240,0.2)';
ctx.lineWidth=2;
ctx.beginPath(); ctx.roundRect(s.x,s.y,s.w,s.h,6); ctx.fill(); ctx.stroke();
}
}
}
function constraintColor(c, stretch){
if(!showStress) {
if(c.type==='rope') return CONFIG.COLORS.rope;
if(c.type==='cloth' || c.type==='shear' || c.type==='bend') return 'rgba(76,201,240,0.9)';
if(c.type==='soft') return 'rgba(255,183,3,0.9)';
return 'rgba(200,220,255,0.7)';
}
const t=clamp(stretch*6,0,1); // 0..1
// green -> yellow -> red
const r= Math.round(46 + (231-46)*t);
const g= Math.round(196 + (29-196)*t);
const b= Math.round(182 + (54-182)*t);
return `rgb(${r},${g},${b})`;
}
function render(){
ctx.clearRect(0,0,W,H);
// subtle vignette
const vg=ctx.createRadialGradient(W/2,H/2, Math.min(W,H)*0.4, W/2,H/2, Math.max(W,H));
vg.addColorStop(0,'transparent'); vg.addColorStop(1,'rgba(0,0,0,0.35)');
ctx.fillStyle=vg; ctx.fillRect(0,0,W,H);
drawStatics();
// draw constraints
// For cloth, optionally fill mesh polygons
if(showMesh){
// collect cloth quads for fill
// Instead, draw filled mesh by grouping constraints? Simpler: fill each quad if we can detect grid
// We'll fill soft bodies as polygons, and cloth as translucent mesh via per-constraint lines + quad fills
// Soft bodies fill
// Identify soft bodies by constraints type soft — group points
// Quick: draw soft polygons by finding centers
// We'll just draw all constraints as lines first, then overlay fills
}
// Soft body fills (convex-ish)
// Find soft centers (points with many soft constraints)
// Instead, collect ring groups: each soft body has a center point; find points that have >6 soft constraints to center
// Simpler: for each point that is center of soft body (we flagged by being central), draw polygon of its ring
// Detect by: points that have many connections and are near centroid of a cluster
// Easier: during spawn we didn't tag, so we approximate: draw all soft constraints as filled fan from heuristic
// Let's draw soft fills by scanning constraints of type soft and building polygons per connected component
// Quick approach: draw filled shape for each soft constraint loop by using all soft points' convex hull per cluster
// For simplicity, just draw each soft triangle fan around its center if we can find center
// Let's attempt: group soft points by proximity clustering
const softPoints = engine.points.filter(p=>p.color===CONFIG.COLORS.soft);
if(softPoints.length){
// cluster by distance
const visited=new Set();
const clusters=[];
for(const p of softPoints){
if(visited.has(p)) continue;
const cluster=[p]; visited.add(p);
// BFS via soft constraints
const queue=[p];
while(queue.length){
const cur=queue.pop();
for(const c of engine.constraints){
if(c.broken || c.type!=='soft') continue;
let other=null;
if(c.a===cur) other=c.b;
else if(c.b===cur) other=c.a;
else continue;
if(!visited.has(other) && other.color===CONFIG.COLORS.soft){
visited.add(other); cluster.push(other); queue.push(other);
}
}
}
if(cluster.length>3) clusters.push(cluster);
}
for(const cl of clusters){
// find center as point with most connections (likely center)
let center=cl[0], maxC=0;
for(const p of cl){
let cnt=0;
for(const c of engine.constraints) if(!c.broken && c.type==='soft' && (c.a===p||c.b===p)) cnt++;
if(cnt>maxC){maxC=cnt; center=p;}
}
const ring=cl.filter(p=>p!==center);
if(ring.length<3) continue;
// sort ring by angle around center
ring.sort((a,b)=>Math.atan2(a.y-center.y,a.x-center.x)-Math.atan2(b.y-center.y,b.x-center.x));
ctx.beginPath();
ctx.moveTo(ring[0].x, ring[0].y);
for(let i=1;i<ring.length;i++) ctx.lineTo(ring[i].x, ring[i].y);
ctx.closePath();
ctx.fillStyle=CONFIG.COLORS.softFill;
ctx.fill();
ctx.strokeStyle='rgba(255,183,3,0.35)'; ctx.lineWidth=1.2; ctx.stroke();
}
}
// Cloth mesh fill — draw quads with subtle fill
if(showMesh){
// find cloth points
const clothPts=engine.points.filter(p=>p.color===CONFIG.COLORS.cloth);
// To fill, we can draw each quad where 4 points form a cell: check constraints existence
// Build map from position to point for quick lookup — but points move, so use constraint adjacency
// Instead, just draw lines with alpha and add a translucent overlay by drawing all cloth constraints as mesh
// We'll fill by drawing each small triangle (two per quad) if both shear constraints exist
// Simpler: draw a translucent polygon covering all cloth points via alpha shape? Skip and just draw grid lines with fill per quad detection via brute
// Brute: for each cloth point, try to find right, down, diag neighbors via constraints
// We'll just draw cloth fill as series of quads detected via constraints
// Quick fill: iterate constraints of type cloth (horizontal/vertical) and fill quads where we find a 4-cycle
// For performance, do lightweight: draw cloth as connected mesh with fill using global composite
// We'll draw cloth mesh fill by triangulating: for each shear constraint, fill its triangle
ctx.lineWidth=1;
// First, draw cloth filled triangles
for(const c of engine.constraints){
if(c.broken) continue;
if(c.type==='shear'){
// shear connects diagonal of a quad — the triangle is c.a, c.b plus one of the structural neighbors
// find common neighbor
// Instead, just fill triangle formed by c.a, c.b and midpoint? Not accurate
// We'll just draw faint fill for each cloth structural quad by checking grid adjacency via distance
// Skip complex fill, draw lines only but with background fill per point cloud hull
}
}
// Hull fill for cloth clusters
const clothClusters=[];
{
const visited=new Set();
const clothSet=new Set(clothPts);
for(const p of clothPts){
if(visited.has(p)) continue;
const cl=[p]; visited.add(p);
const q=[p];
while(q.length){
const cur=q.pop();
for(const c of engine.constraints){
if(c.broken) continue;
if(c.type!=='cloth' && c.type!=='shear' && c.type!=='bend') continue;
let o=null;
if(c.a===cur) o=c.b; else if(c.b===cur) o=c.a; else continue;
if(clothSet.has(o) && !visited.has(o)){ visited.add(o); cl.push(o); q.push(o); }
}
}
if(cl.length>4) clothClusters.push(cl);
}
}
for(const cl of clothClusters){
// compute hull (simple: sort by angle around centroid and fill)
let cx=0,cy=0; for(const p of cl){cx+=p.x; cy+=p.y;} cx/=cl.length; cy/=cl.length;
const sorted=[...cl].sort((a,b)=>Math.atan2(a.y-cy,a.x-cx)-Math.atan2(b.y-cy,b.x-cx));
// Only fill if cluster is cloth-like (not too scattered) — check area
ctx.beginPath(); ctx.moveTo(sorted[0].x, sorted[0].y);
for(let i=1;i<sorted.length;i++) ctx.lineTo(sorted[i].x, sorted[i].y);
ctx.closePath();
ctx.fillStyle='rgba(76,201,240,0.08)';
ctx.fill();
}
}
// draw constraints lines
for(const c of engine.constraints){
if(c.broken) continue;
// hide bend constraints visually unless stress
if(c.type==='bend' && !showStress) continue;
const stretch = Math.abs(Math.hypot(c.a.x-c.b.x,c.a.y-c.b.y)-c.rest)/c.rest;
ctx.strokeStyle=constraintColor(c, stretch);
ctx.lineWidth= (c.type==='rope')? 2.2 : (c.type==='bend'? 0.7 : 1.1);
if(c.type==='shear') ctx.globalAlpha=0.55;
else ctx.globalAlpha=1;
ctx.beginPath(); ctx.moveTo(c.a.x,c.a.y); ctx.lineTo(c.b.x,c.b.y); ctx.stroke();
}
ctx.globalAlpha=1;
// points
if(showPoints){
for(const p of engine.points){
ctx.beginPath(); ctx.arc(p.x,p.y,p.radius,0,Math.PI*2);
ctx.fillStyle= p.pinned? CONFIG.COLORS.pin : (p.color||'#fff');
ctx.fill();
if(p.pinned){
ctx.strokeStyle='#fff'; ctx.lineWidth=1.5; ctx.stroke();
ctx.beginPath(); ctx.arc(p.x,p.y,p.radius+3,0,Math.PI*2); ctx.strokeStyle='rgba(255,0,110,0.35)'; ctx.lineWidth=2; ctx.stroke();
}
}
} else {
// only pinned + balls + soft centers + rope points
for(const p of engine.points){
const isBall = p.color===CONFIG.COLORS.ball;
const isRope = p.color===CONFIG.COLORS.rope;
const isSoft = p.color===CONFIG.COLORS.soft;
if(p.pinned || isBall || (isSoft && p.radius>3) ){
ctx.beginPath(); ctx.arc(p.x,p.y,p.radius+ (isBall?0:0),0,Math.PI*2);
ctx.fillStyle= p.pinned? CONFIG.COLORS.pin : p.color;
ctx.fill();
if(p.pinned){ ctx.strokeStyle='#fff'; ctx.lineWidth=1.2; ctx.stroke(); }
if(isBall){
// highlight
ctx.beginPath(); ctx.arc(p.x-3,p.y-3, p.radius*0.35,0,Math.PI*2);
ctx.fillStyle='rgba(255,255,255,0.45)'; ctx.fill();
}
} else if(p.pinned){
ctx.beginPath(); ctx.arc(p.x,p.y,5,0,Math.PI*2); ctx.fillStyle=CONFIG.COLORS.pin; ctx.fill();
}
}
// pinned cloth points
for(const p of engine.points){
if(p.pinned && p.color===CONFIG.COLORS.cloth){
ctx.beginPath(); ctx.arc(p.x,p.y,4,0,Math.PI*2); ctx.fillStyle=CONFIG.COLORS.pin; ctx.fill();
ctx.strokeStyle='#fff'; ctx.lineWidth=1; ctx.stroke();
}
}
}
// grabbed highlight
if(grabbed){
ctx.beginPath(); ctx.arc(grabbed.x,grabbed.y, CONFIG.GRAB_RADIUS,0,Math.PI*2);
ctx.strokeStyle='rgba(76,201,240,0.35)'; ctx.lineWidth=2; ctx.setLineDash([6,6]); ctx.stroke(); ctx.setLineDash([]);
ctx.beginPath(); ctx.arc(grabbed.x,grabbed.y,6,0,Math.PI*2); ctx.fillStyle='#fff'; ctx.fill();
}
// drag preview
if(isDragging && dragStart && dragEnd){
ctx.setLineDash([6,6]);
ctx.strokeStyle='rgba(255,255,255,0.7)'; ctx.lineWidth=1.5;
if(tool==='rope'){
ctx.beginPath(); ctx.moveTo(dragStart.x,dragStart.y); ctx.lineTo(dragEnd.x,dragEnd.y); ctx.stroke();
ctx.fillStyle=CONFIG.COLORS.rope; ctx.beginPath(); ctx.arc(dragStart.x,dragStart.y,5,0,Math.PI*2); ctx.fill();
} else if(tool==='cloth'){
const x=Math.min(dragStart.x,dragEnd.x), y=Math.min(dragStart.y,dragEnd.y);
const w=Math.abs(dragEnd.x-dragStart.x), h=Math.abs(dragEnd.y-dragStart.y);
ctx.strokeRect(x,y,w,h);
ctx.fillStyle='rgba(76,201,240,0.12)'; ctx.fillRect(x,y,w,h);
} else {
ctx.beginPath(); ctx.arc(dragEnd.x,dragEnd.y,18,0,Math.PI*2); ctx.stroke();
}
ctx.setLineDash([]);
ctx.fillStyle='rgba(255,255,255,0.9)'; ctx.font='11px system-ui'; ctx.fillText(tool, dragEnd.x+8, dragEnd.y-8);
}
// tear line
if(isTearing && tearPrev){
ctx.strokeStyle='#ff3b5c'; ctx.lineWidth=3; ctx.beginPath();
ctx.moveTo(tearPrev.x, tearPrev.y); ctx.lineTo(lastMouse.x,lastMouse.y); ctx.stroke();
ctx.fillStyle='#ff3b5c'; ctx.beginPath(); ctx.arc(lastMouse.x,lastMouse.y,4,0,Math.PI*2); ctx.fill();
}
}
/* ============================================================
MAIN LOOP — fixed timestep accumulator
============================================================ */
let acc=0, lastT=performance.now(), fps=60, frameCount=0, fpsTime=0;
let physTime=0;
function frame(now){
requestAnimationFrame(frame);
let dt=(now - lastT)/1000;
lastT=now;
dt=Math.min(dt, CONFIG.MAX_DT);
// FPS
frameCount++; fpsTime+=dt;
if(fpsTime>0.3){ fps=Math.round(frameCount/fpsTime); frameCount=0; fpsTime=0; }
if(!engine.paused){
acc+=dt;
let steps=0;
const t0=performance.now();
while(acc >= CONFIG.TIMESTEP && steps < CONFIG.MAX_SUBSTEPS){
engine.step(CONFIG.TIMESTEP);
acc-=CONFIG.TIMESTEP;
steps++;
}
// if still has leftover due to clamp, drop it (prevents spiral)
if(steps>=CONFIG.MAX_SUBSTEPS) acc=0;
physTime=performance.now()-t0;
}
render();
// stats
document.getElementById('sFps').textContent=fps;
document.getElementById('sPhys').textContent=physTime.toFixed(1)+'ms';
document.getElementById('sPts').textContent=engine.points.length;
document.getElementById('sCons').textContent=engine.constraints.filter(c=>!c.broken).length;
document.getElementById('sEng').textContent=engine.kineticEnergy().toFixed(1);
}
requestAnimationFrame(frame);
/* ============================================================
SELF-CHECKS (exposed helpers)
============================================================ */
window.Point = Point;
window.Constraint = Constraint;
window.spawnRope = spawnRope;
window.spawnCloth = spawnCloth;
window.spawnSoftBody = spawnSoftBody;
window.spawnBall = spawnBall;
window.engine = engine;
window.simCheck = {
dropSoftSettle(){
engine.clear();
const pts=spawnSoftBody(engine, W*0.5, 80, 44, 10);
engine.gravity=980;
for(let i=0;i<180;i++) Engine.prototype.step.call(engine, CONFIG.TIMESTEP);
const ke=engine.kineticEnergy();
const belowFloor=engine.points.some(p=>p.y + p.radius > engine.floorY + 1);
const hasNaN=engine.points.some(p=>!Number.isFinite(p.x)||!Number.isFinite(p.y));
return {ke, belowFloor, hasNaN, points:engine.points.length};
}
};
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.2-contributor","provider":"openrouter","persona":"full","sessionId":"b5f5d6c2-2a06-46fd-8844-065a1d242b8e","tokensIn":765145,"tokensOut":22321,"tokensTotal":787466,"cost":0.025463660000000003,"turns":20,"toolCalls":19,"failedToolCalls":0,"timestamp":"2026-08-23T01:09:42.565Z"} -->