Physics Playground
muse spark 1.2 · basic

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 34,748 bytes · SHA-256 30a579fcca29
<!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>
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#080b10;font-family: ui-sans-system,system-ui,Segoe UI,Roboto,Helvetica,Arial}
canvas{display:block;position:absolute;inset:0;cursor:crosshair}
/* toolbar */
#toolbar{
position:absolute;top:14px;left:14px;z-index:10;
background:rgba(20,24,33,0.92);backdrop-filter:blur(12px);
border:1px solid rgba(255,255,255,0.08);border-radius:16px;
padding:12px;display:flex;flex-direction:column;gap:10px;min-width:260px;
box-shadow:0 10px 40px rgba(0,0,0,0.5);
}
#toolbar h1{font-size:11px;letter-spacing:0.18em;color:#8b9bb4;text-transform:uppercase;font-weight:700}
.row{display:flex;gap:6px;flex-wrap:wrap}
.tool{flex:1;min-width:56px;padding:8px 8px;border-radius:10px;border:1px solid rgba(255,255,255,0.08);
background:#1a2030;color:#cbd5e1;font-size:12px;font-weight:600;cursor:pointer;transition:.15s;text-align:center}
.tool.active{background:#ffffff;color:#0f172a;border-color:#fff;box-shadow:0 4px 16px rgba(255,255,255,0.2)}
.tool:hover{filter:brightness(1.2)}
.tool b{display:block;font-size:15px}
.divider{height:1px;background:rgba(255,255,255,0.07);margin:2px 0}
.ctrl{display:flex;align-items:center;justify-content:space-between;gap:10px;color:#94a3b8;font-size:11px}
.ctrl label{white-space:nowrap;font-weight:600;letter-spacing:.06em;text-transform:uppercase;font-size:10px}
.ctrl input[type=range]{flex:1;accent-color:#38bdf8;height:3px}
.ctrl span{font-variant-numeric:tabular-nums;color:#e2e8f0;min-width:36px;text-align:right;font-weight:700}
.btn{padding:7px 10px;border-radius:9px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.06);color:#e2e8f0;font-size:11px;font-weight:700;cursor:pointer}
.btn:hover{background:rgba(255,255,255,0.12)}
.btn.primary{background:#38bdf8;color:#0c1a24;border-color:#38bdf8}
.hint{font-size:10px;line-height:1.4;color:#64748b}
.hint kbd{padding:1px 5px;border-radius:4px;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.12);color:#cbd5e1;font-size:10px}
/* top right stats */
#stats{position:absolute;top:14px;right:14px;z-index:10;display:flex;gap:8px}
.stat{background:rgba(20,24,33,0.92);border:1px solid rgba(255,255,255,0.08);border-radius:12px;padding:8px 12px;color:#94a3b8;font-size:11px;min-width:90px}
.stat b{display:block;color:#f1f5f9;font-size:14px;font-variant-numeric:tabular-nums}
.pill{display:inline-flex;align-items:center;gap:6px;font-size:10px;padding:4px 8px;border-radius:20px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);color:#cbd5e1}
.dot{width:7px;height:7px;border-radius:50%}
#tearing{position:absolute;left:50%;bottom:18px;transform:translateX(-50%);z-index:10;background:#ef4444;color:white;padding:6px 14px;border-radius:20px;font-size:11px;font-weight:800;letter-spacing:.08em;opacity:0;pointer-events:none;transition:.2s}
#tearing.on{opacity:1}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="toolbar">
<h1>Soft-Body Lab — Verlet</h1>
<div class="row">
<button class="tool active" data-tool="grab"><b>✋</b>Grab</button>
<button class="tool" data-tool="rope"><b>〰</b>Rope</button>
<button class="tool" data-tool="cloth"><b>▦</b>Cloth</button>
<button class="tool" data-tool="soft"><b>⬢</b>Blob</button>
<button class="tool" data-tool="ball"><b>●</b>Ball</button>
</div>
<div class="hint">Drag on canvas to spawn. <kbd>Right-drag</kbd> to tear. <kbd>Shift+Click</kbd> pin/unpin. <kbd>Space</kbd> pause <kbd>→</kbd> step</div>
<div class="divider"></div>
<div class="ctrl"><label>Gravity</label><input id="gSlider" type="range" min="0" max="1800" value="980" step="10"><span id="gVal">980</span></div>
<div class="ctrl"><label>Wind</label><input id="wSlider" type="range" min="-600" max="600" value="0" step="10"><span id="wVal">0</span></div>
<div class="ctrl"><label>Iterations</label><input id="iSlider" type="range" min="1" max="12" value="5" step="1"><span id="iVal">5</span></div>
<div class="ctrl"><label>Damping</label><input id="dSlider" type="range" min="0.9" max="0.999" value="0.99" step="0.001"><span id="dVal">0.99</span></div>
<div class="row" style="align-items:center">
<label class="pill"><input type="checkbox" id="stressToggle"> <span style="margin-left:6px">Stress colors</span></label>
<label class="pill"><input type="checkbox" id="trailToggle"> <span style="margin-left:6px">Trail</span></label>
<span class="pill"><span class="dot" style="background:#38bdf8"></span> Stable 60Hz</span>
</div>
<div class="row">
<button class="btn primary" id="pauseBtn">⏸ Pause</button>
<button class="btn" id="stepBtn">Step →</button>
<button class="btn" id="resetBtn">Reset</button>
<button class="btn" id="demoBtn">Demo</button>
</div>
<div class="hint">Floor & walls + 2 circles + 1 box collider. All points collide. Fixed timestep with clamp.</div>
</div>
<div id="stats">
<div class="stat">FPS <b id="fps">60</b></div>
<div class="stat">Physics <b id="ptime">0.0 ms</b></div>
<div class="stat">Points <b id="pcount">0</b></div>
</div>
<div id="tearing">✂ CUTTING</div>
<script>
// ============================================================
// CONFIG — Tunable constants (edit here)
// ============================================================
const CONFIG = {
DT: 1/60, // fixed timestep seconds
GRAVITY: 980, // px/s^2
WIND: 0, // px/s^2 horizontal
DAMPING: 0.99, // velocity retention (0..1)
ITERATIONS: 5, // constraint solver passes
TEAR_RADIUS: 18, // px distance to cut
GRAB_RADIUS: 28, // px
POINT_RADIUS: 4.5,
BALL_RADIUS: 14,
FRICTION: 0.82, // tangential damping on collision
RESTITUTION: 0.32,
COLORS: {
rope: '#ff5c5c',
cloth: '#2dd4bf',
soft: '#facc15',
ball: '#a78bfa',
pin: '#38bdf8',
static: '#1f2937',
stressLow: '#22c55e',
stressHigh: '#ef4444'
}
};
// ============================================================
// CANVAS & STATE
// ============================================================
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let W=0,H=0,DPR=1;
function resize(){
DPR = Math.min(2, window.devicePixelRatio||1);
W = window.innerWidth; H = window.innerHeight;
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);
}
window.addEventListener('resize', resize); resize();
// Sim data
let points = []; // {x,y,px,py, pinned, radius, color, mass, id}
let constraints = []; // {a,b, rest, stiff, type, broken, color}
let staticCircles = [];
let staticBoxes = [];
let gravityOn = true;
let paused = false;
let showStress=false, showTrail=false;
let tool='grab';
let nextId=0;
// Interaction
let grabbed = null;
let grabOffset={x:0,y:0};
let mouse={x:0,y:0, down:false, rightDown:false, prevX:0,prevY:0, vx:0,vy:0};
let dragStart=null; // for spawn preview
let tearPath=[]; // array of segments {x1,y1,x2,y2}
let preview=null;
// Stats
let fps=60, pTime=0, frameCount=0, lastFpsTime=performance.now();
// ============================================================
// HELPERS
// ============================================================
function addPoint(x,y,pinned=false,radius=CONFIG.POINT_RADIUS,color='#fff'){
const p={id:nextId++, x,y, px:x, py:y, pinned, radius, color, ox:x,oy:y};
points.push(p); return p;
}
function addConstraint(a,b,stiff=1,type='generic'){
const dx=a.x-b.x, dy=a.y-b.y;
let rest=Math.hypot(dx,dy);
if(rest<1e-4) rest=1e-4;
const c={a,b, rest, stiff, type, broken:false};
constraints.push(c); return c;
}
function dist(a,b){return Math.hypot(a.x-b.x,a.y-b.y);}
function lerp(a,b,t){return a+(b-a)*t}
function hexToRgb(h){const r=parseInt(h.slice(1,3),16),g=parseInt(h.slice(3,5),16),b=parseInt(h.slice(5,7),16);return [r,g,b];}
// ============================================================
// SPAWNERS
// ============================================================
function createRope(x0,y0,x1,y1){
const len=Math.hypot(x1-x0,y1-y0);
const segs=Math.max(6, Math.min(24, Math.round(len/18)||12));
const pts=[];
for(let i=0;i<=segs;i++){
const t=i/segs;
const x=lerp(x0,x1,t), y=lerp(y0,y1,t)+ Math.sin(t*Math.PI)*4;
const pinned = i===0;
const p=addPoint(x,y,pinned, 4, CONFIG.COLORS.rope);
pts.push(p);
}
for(let i=0;i<segs;i++) addConstraint(pts[i],pts[i+1],1,'rope');
// small brace for stability
for(let i=0;i<segs-1;i++) addConstraint(pts[i],pts[i+2],0.3,'rope');
}
function createCloth(x,y,w,h, pinMode='top'){
w=Math.abs(w); h=Math.abs(h);
if(w<40) w=120; if(h<40) h=120;
const cols=Math.max(6, Math.min(14, Math.round(w/18)));
const rows=Math.max(6, Math.min(14, Math.round(h/18)));
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;
const pinned = (pinMode==='top' && r===0) || (pinMode==='corners' && r===0 && (c===0||c===cols));
const p=addPoint(px,py,pinned, 3.2, CONFIG.COLORS.cloth);
grid[r][c]=p;
}
}
for(let r=0;r<=rows;r++){
for(let c=0;c<=cols;c++){
if(c<cols) addConstraint(grid[r][c], grid[r][c+1],1,'cloth');
if(r<rows) addConstraint(grid[r][c], grid[r+1][c],1,'cloth');
if(c<cols && r<rows){
addConstraint(grid[r][c], grid[r+1][c+1],1,'cloth');
addConstraint(grid[r+1][c], grid[r][c+1],1,'cloth');
}
// bend constraints for stability
if(c<cols-1) addConstraint(grid[r][c], grid[r][c+2],0.5,'cloth');
if(r<rows-1) addConstraint(grid[r][c], grid[r+2][c],0.5,'cloth');
}
}
}
function createSoftBody(cx,cy, radius=56){
const n=12;
const pts=[];
const center=addPoint(cx,cy,false,5, CONFIG.COLORS.soft);
for(let i=0;i<n;i++){
const ang=i/n*Math.PI*2;
const x=cx+Math.cos(ang)*radius, y=cy+Math.sin(ang)*radius;
const p=addPoint(x,y,false,5, CONFIG.COLORS.soft);
pts.push(p);
}
// ring
for(let i=0;i<n;i++) addConstraint(pts[i], pts[(i+1)%n],1,'soft');
// spokes
pts.forEach(p=> addConstraint(p, center,1,'soft'));
// braces
for(let i=0;i<n;i++) addConstraint(pts[i], pts[(i+2)%n],0.6,'soft');
// cross braces for volume
for(let i=0;i<n/2;i++) addConstraint(pts[i], pts[(i+n/2)%n],0.35,'soft');
}
function createBall(x,y){
const p=addPoint(x,y,false, CONFIG.BALL_RADIUS, CONFIG.COLORS.ball);
// single point ball is just a point with larger radius; constraints not needed
}
// ============================================================
// STATIC GEOMETRY
// ============================================================
function resetStatics(){
staticCircles = [
{x: W*0.5, y: H*0.72, r: 78},
{x: W*0.26, y: H*0.78, r: 56}
];
staticBoxes = [
{x: W*0.70, y: H*0.78, w: 180, h: 22}
];
}
resetStatics();
// ============================================================
// PHYSICS — Verlet + Relaxation
// ============================================================
function stepPhysics(dt){
const gx = CONFIG.WIND;
const gy = gravityOn ? CONFIG.GRAVITY : 0;
const damp = CONFIG.DAMPING;
// integrate
for(const p of points){
if(p.pinned || grabbed===p) continue;
const vx = (p.x - p.px) * damp;
const vy = (p.y - p.py) * damp;
const nx = p.x + vx + gx*dt*dt;
const ny = p.y + vy + gy*dt*dt;
p.px = p.x; p.py = p.y;
p.x = nx; p.y = ny;
}
// grabbed point follows mouse directly (kinematic)
if(grabbed){
const prevX=grabbed.x, prevY=grabbed.y;
grabbed.px = prevX - mouse.vx*dt*1.2;
// we set px to create throw velocity; but while dragging we override
grabbed.x = mouse.x + grabOffset.x;
grabbed.y = mouse.y + grabOffset.y;
// keep px close to maintain low velocity while held?
// Actually keep px = x - tiny to avoid jitter
grabbed.px = grabbed.x - mouse.vx*dt*0.15;
grabbed.py = grabbed.y - mouse.vy*dt*0.15;
}
// constraints relaxation + collisions interleaved
for(let iter=0; iter<CONFIG.ITERATIONS; iter++){
for(const c of constraints){
if(c.broken) continue;
const a=c.a, b=c.b;
let dx=b.x - a.x, dy=b.y - a.y;
let d=Math.hypot(dx,dy);
if(d<1e-6) continue; // guard division by zero
const diff = (c.rest - d)/d;
const k = 0.5 * c.stiff;
// correction
const offX = dx * diff * k;
const offY = dy * diff * k;
if(a.pinned && b.pinned) continue;
else if(a.pinned){
b.x += dx * diff; b.y += dy * diff;
} else if(b.pinned || b===grabbed){
a.x -= dx * diff; a.y -= dy * diff;
} else if(a===grabbed){
b.x += dx * diff; b.y += dy * diff;
} else {
a.x -= offX; a.y -= offY;
b.x += offX; b.y += offY;
}
// clamp NaN guard
if(!isFinite(a.x)||!isFinite(a.y)){a.x=a.px; a.y=a.py;}
if(!isFinite(b.x)||!isFinite(b.y)){b.x=b.px; b.y=b.py;}
}
// point vs statics (each iter for stability)
for(const p of points){
if(p.pinned) continue;
// walls / floor / ceiling
const r=p.radius;
if(p.y + r > H-12){
p.y = H-12 - r;
// friction: damp x velocity
p.px = p.x - (p.x - p.px)*CONFIG.FRICTION;
p.py = p.y + (p.y - p.py)* -CONFIG.RESTITUTION*0.2;
}
if(p.y - r < 12){ p.y=12+r; p.py = p.y + (p.y-p.py)*-0.2; }
if(p.x - r < 12){ p.x=12+r; p.px = p.x + (p.x-p.px)*-0.2; }
if(p.x + r > W-12){ p.x=W-12 - r; p.px = p.x + (p.x-p.px)*-0.2; }
// circles
for(const s of staticCircles){
let dx=p.x - s.x, dy=p.y - s.y;
let d=Math.hypot(dx,dy);
const minD = s.r + r;
if(d < minD){
if(d<1e-6){ dx=1; dy=0; d=1; }
const nx=dx/d, ny=dy/d;
const push = minD - d;
p.x += nx*push; p.y += ny*push;
// friction along tangent
const vx = p.x - p.px, vy = p.y - p.py;
const dot = vx*nx + vy*ny;
const tx = vx - dot*nx, ty = vy - dot*ny;
p.px = p.x - tx*CONFIG.FRICTION - dot*nx*(-CONFIG.RESTITUTION);
p.py = p.y - ty*CONFIG.FRICTION - dot*ny*(-CONFIG.RESTITUTION);
}
}
// boxes AABB
for(const b of staticBoxes){
const closestX = Math.max(b.x - b.w/2, Math.min(p.x, b.x + b.w/2));
const closestY = Math.max(b.y - b.h/2, Math.min(p.y, b.y + b.h/2));
let dx=p.x - closestX, dy=p.y - closestY;
let d2=dx*dx+dy*dy;
if(d2 < r*r - 1e-6){
let d=Math.sqrt(d2);
if(d<1e-6){ dx=0; dy=-1; d=1; }
const nx=dx/d, ny=dy/d;
const push = r - d + 0.5;
p.x += nx*push; p.y += ny*push;
const vx=p.x - p.px, vy=p.y - p.py;
const dot=vx*nx+vy*ny;
const tx=vx - dot*nx, ty=vy - dot*ny;
p.px = p.x - tx*CONFIG.FRICTION;
p.py = p.y - ty*CONFIG.FRICTION;
if(ny < -0.5){ // top of box
p.py = p.y + (p.y - p.py)* -0.1;
}
}
}
}
}
// global bounds clamp to avoid explosion off-screen
for(const p of points){
if(!isFinite(p.x)||!isFinite(p.y)||!isFinite(p.px)||!isFinite(p.py)){
p.x=p.px; p.y=p.py;
}
// sanity bound 5x viewport
const B=5000;
p.x=Math.max(-B, Math.min(B, p.x));
p.y=Math.max(-B, Math.min(B, p.y));
}
}
// ============================================================
// TEAR LOGIC — break constraints crossing cut line
// ============================================================
function segIntersectsConstraint(ax,ay,bx,by, c){
// distance from segment to constraint segment
const x1=c.a.x, y1=c.a.y, x2=c.b.x, y2=c.b.y;
// quick bbox
const minX=Math.min(ax,bx)-CONFIG.TEAR_RADIUS, maxX=Math.max(ax,bx)+CONFIG.TEAR_RADIUS;
const minY=Math.min(ay,by)-CONFIG.TEAR_RADIUS, maxY=Math.max(ay,by)+CONFIG.TEAR_RADIUS;
if(Math.max(x1,x2)<minX||Math.min(x1,x2)>maxX||Math.max(y1,y2)<minY||Math.min(y1,y2)>maxY) return false;
// closest distance between segments approximated by sampling mid
// Compute segment-segment distance
function segDist(px,py, qx,qy, rx,ry, sx,sy){
// brute: distance between segments
const ux=qx-px, uy=qy-py;
const vx=sx-rx, vy=sy-ry;
const wx=px-rx, wy=py-ry;
const a=ux*ux+uy*uy, b=ux*vx+uy*vy, c_=vx*vx+vy*vy, d=ux*wx+uy*wy, e=vx*wx+vy*wy;
const den=a*c_-b*b;
let sc, tc;
if(den<1e-8){ sc=0; tc= (b>c_? d/b : e/c_); } else { sc=(b*e - c_*d)/den; tc=(a*e - b*d)/den; }
sc=Math.max(0,Math.min(1,sc)); tc=Math.max(0,Math.min(1,tc));
const cx=px+ux*sc, cy=py+uy*sc;
const dx=rx+vx*tc, dy=ry+vy*tc;
return Math.hypot(cx-dx, cy-dy);
}
const d=segDist(ax,ay,bx,by,x1,y1,x2,y2);
return d < CONFIG.TEAR_RADIUS;
}
function tearAlong(x1,y1,x2,y2){
let cut=0;
for(const c of constraints){
if(c.broken) continue;
if(segIntersectsConstraint(x1,y1,x2,y2,c)){
// don't cut pins? allow but soften
c.broken=true; cut++;
}
}
return cut;
}
// ============================================================
// RENDERING
// ============================================================
function draw(){
if(!showTrail){
ctx.clearRect(0,0,W,H);
} else {
ctx.fillStyle='rgba(8,11,16,0.22)';
ctx.fillRect(0,0,W,H);
}
// subtle grid
ctx.save();
ctx.strokeStyle='rgba(255,255,255,0.03)';
ctx.lineWidth=1;
for(let x=0;x<W;x+=48){ ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,H); ctx.stroke(); }
for(let y=0;y<H;y+=48){ ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke(); }
// floor/walls
ctx.fillStyle='rgba(255,255,255,0.06)';
ctx.fillRect(0,H-12,W,12);
ctx.fillRect(0,0,12,H); ctx.fillRect(W-12,0,12,H);
ctx.strokeStyle='rgba(255,255,255,0.08)'; ctx.strokeRect(0.5,0.5,W-1,H-1);
// statics
ctx.fillStyle=CONFIG.COLORS.static;
for(const s of staticCircles){
ctx.beginPath(); ctx.arc(s.x,s.y,s.r,0,Math.PI*2); ctx.fill();
ctx.strokeStyle='rgba(255,255,255,0.06)'; ctx.lineWidth=2; ctx.stroke();
// highlight
ctx.fillStyle='rgba(255,255,255,0.06)';
ctx.beginPath(); ctx.arc(s.x-18,s.y-18,s.r*0.3,0,Math.PI*2); ctx.fill();
}
for(const b of staticBoxes){
ctx.fillRect(b.x - b.w/2, b.y - b.h/2, b.w, b.h);
ctx.strokeStyle='rgba(255,255,255,0.08)'; ctx.strokeRect(b.x - b.w/2, b.y - b.h/2, b.w, b.h);
}
// constraints
for(const c of constraints){
if(c.broken) continue;
const a=c.a,b=c.b;
let col;
if(showStress){
const d=Math.hypot(b.x-a.x,b.y-a.y);
const s=d / c.rest;
const t=Math.max(0,Math.min(1,(s-1)*3)); // 1..2 maps 0..1
const r1=34,g1=197,b1=94, r2=239,g2=68,b2=68;
const r=Math.round(r1 + (r2-r1)*t), g=Math.round(g1 + (g2-g1)*t), bb=Math.round(b1 + (b2-b1)*t);
col=`rgb(${r},${g},${bb})`;
} else {
if(c.type==='rope') col=CONFIG.COLORS.rope;
else if(c.type==='cloth') col='rgba(45,212,191,0.85)';
else if(c.type==='soft') col='rgba(250,204,21,0.9)';
else col='rgba(255,255,255,0.5)';
}
ctx.strokeStyle=col;
ctx.lineWidth= c.type==='cloth' ? 1 : 2;
if(c.type==='rope') ctx.lineWidth=2.6;
ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke();
}
// cloth filled mesh (subtle)
// group cloth points? just draw polygons for cloth constraints quad fill
if(!showStress){
ctx.fillStyle='rgba(45,212,191,0.08)';
// Find cloth quads by scanning constraints? Instead fill by drawing triangles between points that form cloth grid via proximity
// Simple: for each cloth point, fill with its right/bottom neighbor if exists (approx)
// We'll brute: for cloth points, look for nearby cloth points forming squares
// Easier: just draw soft bodies as filled polygons
}
// soft bodies polygon fill (draw each soft cluster)
// Derive soft clusters: collect soft constraints ring; instead fill by points labeled soft
const softGroups = new Map(); // use center association? We'll group by proximity: all soft points near each other
// Simpler: for each soft center point (pin-less large cluster) fill polygon of its ring
// Identify soft centers: points with many soft constraints (degree >6)
for(const p of points){
if(p.color!==CONFIG.COLORS.soft) continue;
// find connected soft ring neighbors
}
// Instead just draw filled polygon for soft bodies using constraints of type soft that form ring
// Collect soft points and try to hull them per connected component via BFS
const visited=new Set();
for(const p of points){
if(p.color!==CONFIG.COLORS.soft || visited.has(p.id)) continue;
// BFS over soft constraints
const stack=[p]; const comp=[];
visited.add(p.id);
while(stack.length){
const cur=stack.pop(); comp.push(cur);
for(const c of 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.id) && other.color===CONFIG.COLORS.soft){ visited.add(other.id); stack.push(other); }
}
}
if(comp.length>3){
// sort by angle around centroid
let cx=0,cy=0; comp.forEach(q=>{cx+=q.x; cy+=q.y}); cx/=comp.length; cy/=comp.length;
comp.sort((a,b)=> Math.atan2(a.y-cy,a.x-cx)-Math.atan2(b.y-cy,b.x-cx));
// filter to outer ring (ignore center point that is near centroid)
const outer=comp.filter(q=> Math.hypot(q.x-cx,q.y-cy) > 18);
if(outer.length>=3){
ctx.fillStyle='rgba(250,204,21,0.18)';
ctx.strokeStyle='rgba(250,204,21,0.35)';
ctx.lineWidth=1;
ctx.beginPath(); outer.forEach((q,i)=> i===0?ctx.moveTo(q.x,q.y):ctx.lineTo(q.x,q.y)); ctx.closePath(); ctx.fill(); ctx.stroke();
}
}
}
// points
for(const p of points){
const isGrabbed = p===grabbed;
const isPinned = p.pinned;
// hide interior cloth points dots (show only border/pinned)
const hide = p.color===CONFIG.COLORS.cloth && !p.pinned && !isGrabbed;
if(hide) continue;
ctx.beginPath();
ctx.arc(p.x,p.y, isGrabbed?7: p.radius, 0, Math.PI*2);
if(p.color===CONFIG.COLORS.ball){
ctx.fillStyle= p.color;
ctx.fill();
ctx.fillStyle='rgba(255,255,255,0.35)';
ctx.beginPath(); ctx.arc(p.x-4,p.y-4,3,0,Math.PI*2); ctx.fill();
} else {
ctx.fillStyle = isGrabbed ? '#ffffff' : p.color;
ctx.fill();
}
if(isPinned){
ctx.strokeStyle='#0ea5e9'; ctx.lineWidth=2; ctx.stroke();
ctx.fillStyle='#fff'; ctx.beginPath(); ctx.arc(p.x,p.y,1.6,0,Math.PI*2); ctx.fill();
}
if(isGrabbed){
ctx.strokeStyle='rgba(255,255,255,0.9)'; ctx.lineWidth=1.5; ctx.stroke();
ctx.shadowColor='white'; ctx.shadowBlur=12; ctx.stroke(); ctx.shadowBlur=0;
}
}
// preview
if(preview){
ctx.save();
ctx.setLineDash([6,6]);
ctx.strokeStyle='rgba(255,255,255,0.9)'; ctx.lineWidth=1.5;
if(preview.type==='rope'){
ctx.beginPath(); ctx.moveTo(preview.x0,preview.y0); ctx.lineTo(preview.x1,preview.y1); ctx.stroke();
ctx.fillStyle='#ff5c5c'; ctx.beginPath(); ctx.arc(preview.x0,preview.y0,5,0,Math.PI*2); ctx.fill();
} else if(preview.type==='cloth' || preview.type==='soft' || preview.type==='ball'){
const x=Math.min(preview.x0,preview.x1), y=Math.min(preview.y0,preview.y1);
const w=Math.abs(preview.x1-preview.x0), h=Math.abs(preview.y1-preview.y0);
if(preview.type==='cloth') ctx.strokeRect(x,y,w,h);
else if(preview.type==='soft'){ const r=Math.hypot(w,h)/2; const cx=(preview.x0+preview.x1)/2, cy=(preview.y0+preview.y1)/2; ctx.beginPath(); ctx.arc(cx,cy,Math.max(20,r),0,Math.PI*2); ctx.stroke(); }
else { ctx.beginPath(); ctx.arc(preview.x1,preview.y1, CONFIG.BALL_RADIUS,0,Math.PI*2); ctx.stroke(); }
}
ctx.restore();
ctx.fillStyle='rgba(255,255,255,0.7)'; ctx.font='11px ui-sans-system'; ctx.fillText('release to spawn', preview.x1+10, preview.y1+10);
}
// tear path
if(tearPath.length){
ctx.strokeStyle='#ef4444'; ctx.lineWidth=3; ctx.lineCap='round';
ctx.beginPath();
tearPath.forEach((s,i)=> i===0?ctx.moveTo(s.x1,s.y1):ctx.lineTo(s.x2,s.y2));
ctx.stroke();
ctx.fillStyle='#ef4444'; ctx.beginPath(); ctx.arc(mouse.x,mouse.y,5,0,Math.PI*2); ctx.fill();
}
// cursor grab halo when near point
if(!mouse.down && tool==='grab' && !grabbed){
let near=null, md=CONFIG.GRAB_RADIUS;
for(const p of points){ const d=Math.hypot(p.x-mouse.x,p.y-mouse.y); if(d<md){md=d; near=p;}}
if(near){
ctx.strokeStyle='rgba(255,255,255,0.5)'; ctx.setLineDash([4,4]); ctx.lineWidth=1;
ctx.beginPath(); ctx.arc(near.x,near.y, near.radius+10,0,Math.PI*2); ctx.stroke(); ctx.setLineDash([]);
}
}
ctx.restore;
}
// ============================================================
// INPUT
// ============================================================
function getNearPoint(x,y,rad=CONFIG.GRAB_RADIUS){
let best=null, bd=rad;
for(const p of points){
const d=Math.hypot(p.x-x,p.y-y);
if(d<bd){bd=d; best=p;}
}
return best;
}
function setTool(t){
tool=t;
document.querySelectorAll('.tool').forEach(el=> el.classList.toggle('active', el.dataset.tool===t));
canvas.style.cursor = t==='grab' ? 'grab' : 'crosshair';
}
document.querySelectorAll('.tool').forEach(b=> b.addEventListener('click', ()=> setTool(b.dataset.tool)));
canvas.addEventListener('mousedown', e=>{
const rect=canvas.getBoundingClientRect();
const x=(e.clientX-rect.left), y=(e.clientY-rect.top);
mouse.x=x; mouse.y=y; mouse.prevX=x; mouse.prevY=y;
if(e.button===2){ // right drag tear start
mouse.rightDown=true; tearPath=[{x1:x,y1:y,x2:x,y2:y}];
document.getElementById('tearing').classList.add('on');
e.preventDefault(); return;
}
mouse.down=true;
// pin toggle with shift
if(e.shiftKey){
const p=getNearPoint(x,y,18);
if(p){ p.pinned=!p.pinned; if(p.pinned){p.px=p.x; p.py=p.y;} }
return;
}
if(tool==='grab'){
const p=getNearPoint(x,y);
if(p){
grabbed=p;
grabOffset.x=p.x - x; grabOffset.y=p.y - y;
canvas.style.cursor='grabbing';
}
} else {
dragStart={x,y};
preview={type:tool, x0:x,y0:y,x1:x,y1:y};
}
});
canvas.addEventListener('mousemove', e=>{
const rect=canvas.getBoundingClientRect();
const x=(e.clientX-rect.left), y=(e.clientY-rect.top);
mouse.vx = x - mouse.x; mouse.vy = y - mouse.y;
mouse.prevX=mouse.x; mouse.prevY=mouse.y;
mouse.x=x; mouse.y=y;
if(mouse.rightDown){
const last=tearPath[tearPath.length-1];
const nx=last.x2, ny=last.y2;
tearAlong(nx,ny,x,y);
tearPath.push({x1:nx,y1:ny,x2:x,y2:y});
// also break constraints directly near
for(const c of constraints){
if(c.broken) continue;
const mx=(c.a.x+c.b.x)/2, my=(c.a.y+c.b.y)/2;
if(Math.hypot(mx-x,my-y) < CONFIG.TEAR_RADIUS*0.9) c.broken=true;
}
} else if(mouse.down){
if(grabbed){
// position updated in physics step via mouse
} else if(preview){
preview.x1=x; preview.y1=y;
}
}
});
function endDrag(e){
const isRight = e && e.button===2;
if(isRight || mouse.rightDown){
mouse.rightDown=false; tearPath=[]; document.getElementById('tearing').classList.remove('on');
if(isRight) return;
}
if(!mouse.down) return;
mouse.down=false;
if(grabbed){
// throw: give velocity from mouse
const vx=mouse.vx*1.8, vy=mouse.vy*1.8;
grabbed.px = grabbed.x - vx;
grabbed.py = grabbed.y - vy;
grabbed=null;
canvas.style.cursor='grab';
} else if(preview && dragStart){
const x0=preview.x0,y0=preview.y0,x1=preview.x1,y1=preview.y1;
const w=Math.abs(x1-x0), h=Math.abs(y1-y0);
if(tool==='rope'){
createRope(x0,y0,x1,y1);
} else if(tool==='cloth'){
if(w>20&&h>20) createCloth(Math.min(x0,x1), Math.min(y0,y1), w,h, 'top');
else createCloth(x1-60,y1-60,120,120,'top');
} else if(tool==='soft'){
const r=Math.max(30, Math.min(90, Math.hypot(w,h)/2));
createSoftBody((x0+x1)/2,(y0+y1)/2, r);
} else if(tool==='ball'){
createBall(x1,y1);
}
preview=null; dragStart=null;
// stay in same tool? keep for rapid spawning
}
}
canvas.addEventListener('mouseup', endDrag);
canvas.addEventListener('mouseleave', ()=>{ mouse.down=false; mouse.rightDown=false; tearPath=[]; document.getElementById('tearing').classList.remove('on'); if(grabbed){ const vx=mouse.vx*1.2, vy=mouse.vy*1.2; grabbed.px=grabbed.x - vx; grabbed.py=grabbed.y - vy; grabbed=null; } preview=null;});
canvas.addEventListener('contextmenu', e=> e.preventDefault());
// touch
canvas.addEventListener('touchstart', e=>{
const t=e.touches[0]; const r=canvas.getBoundingClientRect(); const x=t.clientX-r.left, y=t.clientY-r.top;
mouse.x=x; mouse.y=y; mouse.down=true;
const p=getNearPoint(x,y); if(p){ grabbed=p; grabOffset.x=p.x-x; grabOffset.y=p.y-y; }
e.preventDefault();
},{passive:false});
canvas.addEventListener('touchmove', e=>{
const t=e.touches[0]; const r=canvas.getBoundingClientRect(); const x=t.clientX-r.left, y=t.clientY-r.top;
mouse.vx=x-mouse.x; mouse.vy=y-mouse.y; mouse.x=x; mouse.y=y; e.preventDefault();
},{passive:false});
canvas.addEventListener('touchend', ()=>{ if(grabbed){ grabbed.px=grabbed.x - mouse.vx*1.5; grabbed.py=grabbed.y - mouse.vy*1.5; grabbed=null; } mouse.down=false; });
// ============================================================
// CONTROLS
// ============================================================
const gSlider=document.getElementById('gSlider'), gVal=document.getElementById('gVal');
const wSlider=document.getElementById('wSlider'), wVal=document.getElementById('wVal');
const iSlider=document.getElementById('iSlider'), iVal=document.getElementById('iVal');
const dSlider=document.getElementById('dSlider'), dVal=document.getElementById('dVal');
gSlider.addEventListener('input', ()=>{ CONFIG.GRAVITY=parseFloat(gSlider.value); gVal.textContent=gSlider.value;});
wSlider.addEventListener('input', ()=>{ CONFIG.WIND=parseFloat(wSlider.value); wVal.textContent=wSlider.value;});
iSlider.addEventListener('input', ()=>{ CONFIG.ITERATIONS=parseInt(iSlider.value); iVal.textContent=iSlider.value;});
dSlider.addEventListener('input', ()=>{ CONFIG.DAMPING=parseFloat(dSlider.value); dVal.textContent=Number(dSlider.value).toFixed(3);});
document.getElementById('stressToggle').addEventListener('change', e=> showStress=e.target.checked);
document.getElementById('trailToggle').addEventListener('change', e=> showTrail=e.target.checked);
document.getElementById('pauseBtn').addEventListener('click', ()=> togglePause());
document.getElementById('stepBtn').addEventListener('click', ()=>{ if(paused) doStep(); });
document.getElementById('resetBtn').addEventListener('click', ()=> reset());
document.getElementById('demoBtn').addEventListener('click', ()=> spawnDemo());
function togglePause(){ paused=!paused; document.getElementById('pauseBtn').textContent = paused ? '▶ Resume' : '⏸ Pause'; }
function doStep(){ stepPhysics(CONFIG.DT); }
window.addEventListener('keydown', e=>{
if(e.code==='Space'){ e.preventDefault(); togglePause(); }
if(e.code==='ArrowRight' && paused){ doStep(); }
if(e.key==='r' || e.key==='R'){ reset(); }
});
function reset(){
points=[]; constraints=[]; nextId=0; grabbed=null; resetStatics();
spawnDemo();
}
function spawnDemo(){
// clear leaving statics? keep points cumulative
// initial demo if empty, else add more
if(points.length===0){
createRope(W*0.16, 90, W*0.16, 280);
createCloth(W*0.28, 90, 210, 140, 'top');
createSoftBody(W*0.72, 160, 58);
createBall(W*0.55, 60);
createBall(W*0.62, 40);
} else {
// add random
const r=Math.random();
if(r<0.33) createRope(W*0.2+Math.random()*W*0.6, 80, W*0.2+Math.random()*W*0.6, 240);
else if(r<0.66) createCloth(W*0.3+Math.random()*200, 90, 140+Math.random()*80, 120,'top');
else createSoftBody(Math.random()*W*0.6+W*0.2, 120, 44+Math.random()*22);
}
}
spawnDemo();
// ============================================================
// MAIN LOOP — fixed timestep accumulator
// ============================================================
let accumulator=0, lastTime=performance.now();
function frame(now){
requestAnimationFrame(frame);
let delta = (now - lastTime)/1000;
lastTime=now;
// clamp max delta (tab switch must not explode)
delta = Math.min(delta, 0.1);
// FPS
frameCount++; if(now - lastFpsTime > 500){ fps=Math.round(frameCount*1000/(now-lastFpsTime)); document.getElementById('fps').textContent=fps; document.getElementById('pcount').textContent=points.length; frameCount=0; lastFpsTime=now; }
if(!paused){
accumulator += delta;
const t0=performance.now();
let steps=0;
while(accumulator >= CONFIG.DT && steps<8){
stepPhysics(CONFIG.DT);
accumulator -= CONFIG.DT;
steps++;
}
pTime = performance.now()-t0;
document.getElementById('ptime').textContent = pTime.toFixed(1)+' ms';
}
draw();
}
requestAnimationFrame(frame);
// ============================================================
// EXPOSED FOR HEADLESS TESTS
// ============================================================
window.sim = {
get points(){return points;},
get constraints(){return constraints;},
get staticCircles(){return staticCircles;},
get staticBoxes(){return staticBoxes;},
step: ()=> stepPhysics(CONFIG.DT),
reset,
createRope, createCloth, createSoftBody, createBall,
tearAlong,
CONFIG
};
// Self-check helpers (not auto-run, for manual probe)
window.simCheck = {
dropSettle(){
// drop soft body from height and check kinetic ~0 after 3s
const before=points.length;
createSoftBody(W*0.5, 40, 50);
for(let i=0;i<180;i++) stepPhysics(CONFIG.DT);
let ke=0; for(const p of points.slice(before)){ const vx=p.x-p.px, vy=p.y-p.py; ke+=vx*vx+vy*vy; }
const belowFloor = points.slice(before).some(p=> p.y + p.radius > H-12+0.5);
return {ke, belowFloor, ok: ke<1 && !belowFloor};
}
};
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.2","provider":"openrouter","persona":"basic","sessionId":"4fdb3056-6262-4d27-bfe8-487e41578fb2","tokensIn":1116,"tokensOut":12789,"tokensTotal":13905,"cost":0.05555355,"turns":1,"toolCalls":0,"failedToolCalls":0,"timestamp":"2026-08-06T15:15:31.325Z"} -->