Physics Playground
Qwen 3.8 max · middle

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 50,557 bytes · SHA-256 cad73e8fc14d
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Soft-Body Physics Playground — Verlet + Constraint Relaxation</title>
<style>
html,body{margin:0;height:100%;overflow:hidden;background:#0a0d13;
font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;user-select:none;-webkit-user-select:none;}
#cv{display:block;position:fixed;inset:0;cursor:crosshair;touch-action:none;}
/* ---------- control panel ---------- */
#panel{position:fixed;top:12px;left:12px;width:236px;z-index:10;
background:rgba(13,17,26,.86);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);
border:1px solid rgba(255,255,255,.09);border-radius:13px;color:#cfd8e6;
box-shadow:0 10px 40px rgba(0,0,0,.5);font-size:12px;}
#phead{display:flex;align-items:center;justify-content:space-between;padding:10px 12px 8px;}
#ptitle{font-weight:700;letter-spacing:.14em;font-size:11.5px;color:#eaf3ff;}
#ptitle span{display:block;font-weight:400;letter-spacing:.02em;font-size:9.5px;color:#5f6d85;margin-top:2px;}
#fold{background:#1a2130;border:1px solid rgba(255,255,255,.1);color:#9fb0c8;border-radius:6px;
width:22px;height:20px;line-height:1;cursor:pointer;font-size:12px;}
#fold:hover{background:#242d40;}
#pbody{padding:0 12px 12px;max-height:calc(100vh - 90px);overflow-y:auto;}
#panel.min #pbody{display:none;}
#tools{display:grid;grid-template-columns:repeat(3,1fr);gap:5px;margin-bottom:8px;}
.tbtn{background:#181f2d;border:1px solid rgba(255,255,255,.08);border-radius:8px;color:#dfe8f5;
padding:6px 0 4px;cursor:pointer;font-size:15px;line-height:1.1;text-align:center;}
.tbtn i{display:block;font-style:normal;font-size:8.5px;letter-spacing:.08em;color:#7f8ea6;margin-top:2px;text-transform:uppercase;}
.tbtn:hover{background:#222c40;}
.tbtn.active{background:#123c4d;border-color:#3fd0ff;color:#7ce4ff;box-shadow:0 0 10px rgba(63,208,255,.25);}
.tbtn.active i{color:#59b8d8;}
#chips{display:flex;gap:6px;margin-bottom:10px;}
.chip{background:#161d2b;border:1px solid rgba(255,255,255,.08);color:#9fb0c8;border-radius:20px;
font-size:10px;padding:3px 10px;cursor:pointer;}
.chip:hover{border-color:#3fd0ff;color:#7ce4ff;}
.srow{margin:7px 0;}
.srow label{display:flex;justify-content:space-between;font-size:10.5px;color:#8fa0b8;margin-bottom:2px;letter-spacing:.04em;}
.srow label b{color:#dfe8f5;font-weight:600;font-variant-numeric:tabular-nums;}
.srow input[type=range]{width:100%;accent-color:#3fd0ff;height:16px;margin:0;}
#checks{display:grid;grid-template-columns:1fr 1fr;gap:3px 8px;margin:9px 0;}
#checks label{display:flex;align-items:center;gap:5px;font-size:11px;color:#9fb0c8;cursor:pointer;}
#checks input{accent-color:#3fd0ff;margin:0;}
#actions{display:grid;grid-template-columns:repeat(4,1fr);gap:5px;margin:4px 0 8px;}
#actions button{background:#181f2d;border:1px solid rgba(255,255,255,.09);border-radius:7px;color:#cfd8e6;
font-size:10px;padding:6px 0;cursor:pointer;letter-spacing:.02em;}
#actions button:hover{background:#232e44;border-color:#3fd0ff66;}
#stats{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10px;color:#6d7d96;
background:rgba(0,0,0,.25);border-radius:7px;padding:5px 8px;margin-bottom:8px;white-space:pre;}
#help{font-size:9.5px;line-height:1.65;color:#5f6d85;}
#help b{color:#93a4bd;font-weight:600;}
#pbody::-webkit-scrollbar{width:6px;} #pbody::-webkit-scrollbar-thumb{background:#2a3550;border-radius:3px;}
</style>
</head>
<body>
<canvas id="cv"></canvas>
<div id="panel">
<div id="phead">
<div id="ptitle">SOFT-BODY LAB<span>verlet integration · constraint relaxation</span></div>
<button id="fold" title="collapse panel">–</button>
</div>
<div id="pbody">
<div id="tools">
<button class="tbtn active" data-tool="grab" title="Grab & throw (1)">✋<i>grab</i></button>
<button class="tbtn" data-tool="rope" title="Rope — drag from anchor (2)">〰<i>rope</i></button>
<button class="tbtn" data-tool="cloth" title="Cloth — drag a rectangle (3)">▦<i>cloth</i></button>
<button class="tbtn" data-tool="blob" title="Soft blob — drag radius (4)">●<i>blob</i></button>
<button class="tbtn" data-tool="jelly" title="Soft box — drag a rectangle (5)">▣<i>jelly</i></button>
<button class="tbtn" data-tool="ball" title="Ball — drag to fling (6)">◉<i>ball</i></button>
</div>
<div id="chips">
<button class="chip" id="pinChip" title="Cloth pin pattern (click to cycle)">cloth pins: full edge</button>
</div>
<div class="srow"><label>gravity <b id="gravVal">1200</b></label><input type="range" id="grav" min="0" max="2400" step="25" value="1200"></div>
<div class="srow"><label>solver iterations <b id="iterVal">5</b></label><input type="range" id="iter" min="1" max="10" step="1" value="5"></div>
<div class="srow"><label>wind strength <b id="windVal">220</b></label><input type="range" id="wind" min="0" max="600" step="10" value="220"></div>
<div id="checks">
<label><input type="checkbox" id="cGrav" checked> gravity</label>
<label><input type="checkbox" id="cWind"> wind</label>
<label><input type="checkbox" id="cStress" checked> stress view</label>
<label><input type="checkbox" id="cPts"> show points</label>
<label><input type="checkbox" id="cColl" checked> point collide</label>
<label><input type="checkbox" id="cTrail"> motion trails</label>
</div>
<div id="actions">
<button id="bPause" title="Space">⏸ pause</button>
<button id="bStep" title="→ while paused">step →</button>
<button id="bReset" title="R">reset</button>
<button id="bClear" title="C">clear</button>
</div>
<div id="stats">booting…</div>
<div id="help">
<b>drag</b> grab / whip · <b>R-drag / Alt-drag</b> slice<br>
<b>Shift-click</b> pin / unpin a point<br>
<b>Space</b> pause · <b>→</b> step frame · <b>1–6</b> tools<br>
<b>R</b> reset · <b>C</b> clear · <b>V</b> stress · <b>G</b> gravity
</div>
</div>
</div>
<script>
'use strict';
/* ================================================================
1. TUNABLE CONSTANTS — the whole feel of the sim lives here
================================================================ */
const CFG = {
DT: 1 / 60, // fixed physics timestep (s)
MAX_STEPS: 5, // max physics steps per render frame (death-spiral guard)
MAX_FRAME_MS: 120, // clamp on frame delta (tab-switch must not detonate)
GRAVITY: 1200, // px / s^2
DAMPING: 0.995, // verlet velocity retention per step (energy decay)
MAX_VEL: 3600, // px/s speed clamp — anti-explosion / anti-tunnel
ITERATIONS: 5, // constraint-relaxation passes per step
FRICTION: 0.14, // tangential velocity removed on contact (final pass)
WIND: 220, // horizontal accel amplitude when wind enabled
GRAB_RADIUS: 30, // px — nearest-point capture radius
TEAR_RADIUS: 9, // px — click-to-cut proximity radius
ROPE_SEG: 11, // px per rope segment
CLOTH_SPACING: 13, // px cloth grid spacing
MAX_POINTS: 3200, // global budget for dynamic points
PAIR_CELL: 32, // spatial-hash cell for point/point collision
TRAIL_FADE: 0.30, // alpha of the fade wash when trails are on
};
/* ================================================================
2. SMALL MATH UTILITIES
================================================================ */
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
function segIntersect(ax, ay, bx, by, cx, cy, dx, dy) {
const d = (bx - ax) * (dy - cy) - (by - ay) * (dx - cx);
if (Math.abs(d) < 1e-12) return false;
const t = ((cx - ax) * (dy - cy) - (cy - ay) * (dx - cx)) / d;
const u = ((cx - ax) * (by - ay) - (cy - ay) * (bx - ax)) / d;
return t >= 0 && t <= 1 && u >= 0 && u <= 1;
}
function pointSegDist2(px, py, ax, ay, bx, by) {
const dx = bx - ax, dy = by - ay;
const l2 = dx * dx + dy * dy;
let t = l2 > 1e-9 ? ((px - ax) * dx + (py - ay) * dy) / l2 : 0;
t = clamp(t, 0, 1);
const qx = ax + dx * t - px, qy = ay + dy * t - py;
return qx * qx + qy * qy;
}
function strainColor(strain) {
const t = clamp(strain / 0.6, 0, 1); // 0 = relaxed … 0.6+ = max red
return 'hsl(' + (130 - 130 * t) + ',92%,55%)';
}
// roundRect polyfill for older canvases
if (!CanvasRenderingContext2D.prototype.roundRect) {
CanvasRenderingContext2D.prototype.roundRect = function (x, y, w, h, r) {
r = Math.min(r, w / 2, h / 2);
this.moveTo(x + r, y);
this.arcTo(x + w, y, x + w, y + h, r);
this.arcTo(x + w, y + h, x, y + h, r);
this.arcTo(x, y + h, x, y, r);
this.arcTo(x, y, x + w, y, r);
this.closePath();
return this;
};
}
/* ================================================================
3. WORLD — Verlet points + distance constraints (PBD style)
================================================================ */
class World {
constructor(w, h) {
this.w = w; this.h = h;
this.points = []; // {x,y,px,py,r,pinned,grabbed,rest,nodot,obj}
this.constraints = []; // {a,b,rest,stiff,active,obj}
this.statics = []; // {type:'circle',x,y,r} | {type:'box',x,y,hw,hh}
this.objects = []; // render metadata {kind:'rope|cloth|blob|jelly|ball',...}
this.time = 0;
this.gravity = CFG.GRAVITY;
this.gravityOn = true;
this.windOn = false;
this.wind = CFG.WIND;
this.iterations = CFG.ITERATIONS;
this.collide = true; // point-vs-point collision toggle
this.grabbed = null; this.grabX = 0; this.grabY = 0;
this.onCut = null; // callback(x,y) when a constraint is sliced
}
/* ---------- construction ---------- */
addPoint(x, y, o = {}) {
const p = {
x, y, px: x, py: y,
r: o.r !== undefined ? o.r : 2,
pinned: !!o.pinned, grabbed: false,
rest: o.rest !== undefined ? o.rest : 0, // restitution
nodot: !!o.nodot, obj: o.obj || null,
};
this.points.push(p);
return p;
}
link(a, b, o = {}) {
const dx = b.x - a.x, dy = b.y - a.y;
const c = {
a, b,
rest: o.rest !== undefined ? o.rest : Math.sqrt(dx * dx + dy * dy),
stiff: o.stiff !== undefined ? o.stiff : 1,
active: true, obj: o.obj || null,
};
this.constraints.push(c);
return c;
}
addCircle(x, y, r) { this.statics.push({ type: 'circle', x, y, r }); }
addBox(x, y, hw, hh) { this.statics.push({ type: 'box', x, y, hw, hh }); }
/* ---------- one fixed physics step ---------- */
step(dt) {
if (dt === undefined) dt = CFG.DT;
this.time += dt;
const g = this.gravityOn ? this.gravity : 0;
const windAmp = this.windOn ? this.wind : 0;
const damp = CFG.DAMPING;
const maxV = CFG.MAX_VEL * dt;
const maxV2 = maxV * maxV;
const t = this.time;
/* -- integrate (Verlet) -- */
for (let i = 0; i < this.points.length; i++) {
const p = this.points[i];
if (p.pinned || p.grabbed) { p.px = p.x; p.py = p.y; continue; }
let vx = (p.x - p.px) * damp;
let vy = (p.y - p.py) * damp;
const v2 = vx * vx + vy * vy;
if (v2 > maxV2) { const s = maxV / Math.sqrt(v2); vx *= s; vy *= s; } // speed clamp = stability
p.px = p.x; p.py = p.y;
let ax = 0;
if (windAmp) ax = windAmp * (0.55 + 0.45 * Math.sin(t * 2.3 + p.y * 0.006 + p.x * 0.002));
p.x += vx + ax * dt * dt;
p.y += vy + g * dt * dt;
}
const gp = this.grabbed;
if (gp) { gp.x = this.grabX; gp.y = this.grabY; }
/* -- constraint relaxation + static collision, per iteration -- */
const iter = this.iterations;
for (let it = 0; it < iter; it++) {
const last = it === iter - 1;
const cs = this.constraints;
for (let i = 0; i < cs.length; i++) {
const c = cs[i];
if (!c.active) continue;
const a = c.a, b = c.b;
const dx = b.x - a.x, dy = b.y - a.y;
const d2 = dx * dx + dy * dy;
if (d2 < 1e-12) continue; // coincident points — NaN guard
const d = Math.sqrt(d2);
const diff = (d - c.rest) / d * c.stiff;
const aP = a.pinned || a.grabbed, bP = b.pinned || b.grabbed;
if (!aP && !bP) {
const ox = dx * diff * 0.5, oy = dy * diff * 0.5;
a.x += ox; a.y += oy; b.x -= ox; b.y -= oy;
} else if (aP && !bP) { b.x -= dx * diff; b.y -= dy * diff; }
else if (!aP && bP) { a.x += dx * diff; a.y += dy * diff; }
}
if (gp) { gp.x = this.grabX; gp.y = this.grabY; } // keep grabbed point welded to cursor
this.collideStatics(last); // friction only on final pass
}
/* -- point vs point (one pass, spatial hash) -- */
if (this.collide) this.collidePoints();
/* -- belt & suspenders: numeric sanitiser (no NaN may escape) -- */
const M = 1e5;
for (let i = 0; i < this.points.length; i++) {
const p = this.points[i];
if (!Number.isFinite(p.x) || !Number.isFinite(p.y) ||
!Number.isFinite(p.px) || !Number.isFinite(p.py) ||
p.x > M || p.x < -M || p.y > M || p.y < -M) {
p.x = clamp(Number.isFinite(p.x) ? p.x : this.w * 0.5, 4, this.w - 4);
p.y = clamp(Number.isFinite(p.y) ? p.y : this.h * 0.5, 4, this.h - 4);
p.px = p.x; p.py = p.y;
}
}
}
/* ---------- static geometry collision (walls + shapes) ---------- */
collideStatics(withFriction) {
const F = CFG.FRICTION;
for (let i = 0; i < this.points.length; i++) {
const p = this.points[i];
if (p.pinned || p.grabbed) continue;
const r = p.r;
// world bounds (floor / walls / ceiling)
if (p.y > this.h - r) {
const vx = p.x - p.px, vy = p.y - p.py;
p.y = this.h - r;
this.respond(p, 0, -1, vx, vy, withFriction);
} else if (p.y < r) {
const vx = p.x - p.px, vy = p.y - p.py;
p.y = r;
this.respond(p, 0, 1, vx, vy, withFriction);
}
if (p.x < r) {
const vx = p.x - p.px, vy = p.y - p.py;
p.x = r;
this.respond(p, 1, 0, vx, vy, withFriction);
} else if (p.x > this.w - r) {
const vx = p.x - p.px, vy = p.y - p.py;
p.x = this.w - r;
this.respond(p, -1, 0, vx, vy, withFriction);
}
// static circles & boxes
for (let s = 0; s < this.statics.length; s++) {
const sh = this.statics[s];
if (sh.type === 'circle') {
const dx = p.x - sh.x, dy = p.y - sh.y;
const R = sh.r + r, d2 = dx * dx + dy * dy;
if (d2 < R * R) {
let d = Math.sqrt(d2), nx, ny;
if (d < 1e-6) { nx = 0; ny = -1; d = 1e-6; } else { nx = dx / d; ny = dy / d; }
const vx = p.x - p.px, vy = p.y - p.py;
p.x = sh.x + nx * R; p.y = sh.y + ny * R;
this.respond(p, nx, ny, vx, vy, withFriction);
}
} else { // box
const lx = p.x - sh.x, ly = p.y - sh.y;
const cx = clamp(lx, -sh.hw, sh.hw), cy = clamp(ly, -sh.hh, sh.hh);
const dx = lx - cx, dy = ly - cy;
const d2 = dx * dx + dy * dy;
if (d2 < r * r) {
const vx = p.x - p.px, vy = p.y - p.py;
let nx, ny, push;
if (d2 > 1e-9) {
const d = Math.sqrt(d2);
nx = dx / d; ny = dy / d; push = r - d;
} else { // point center inside the box — eject along smallest penetration
const pxr = sh.hw - Math.abs(lx), pyb = sh.hh - Math.abs(ly);
if (pxr < pyb) { nx = lx >= 0 ? 1 : -1; ny = 0; push = pxr + r; }
else { nx = 0; ny = ly >= 0 ? 1 : -1; push = pyb + r; }
}
p.x += nx * push; p.y += ny * push;
this.respond(p, nx, ny, vx, vy, withFriction);
}
}
}
}
}
// set previous-position so implied velocity = reflected/damped contact response
respond(p, nx, ny, vx, vy, withFriction) {
const vn = vx * nx + vy * ny;
let tx = vx - vn * nx, ty = vy - vn * ny;
if (withFriction) { tx *= (1 - CFG.FRICTION); ty *= (1 - CFG.FRICTION); }
const bn = vn < 0 ? -vn * p.rest : 0; // restitution only when moving into surface
p.px = p.x - (tx + nx * bn);
p.py = p.y - (ty + ny * bn);
}
/* ---------- point vs point collision via spatial hash ---------- */
collidePoints() {
const cell = CFG.PAIR_CELL, pts = this.points, n = pts.length;
if (n < 2) return;
const grid = new Map();
for (let i = 0; i < n; i++) {
const p = pts[i];
const key = (Math.floor(p.x / cell) + 2048) * 8192 + (Math.floor(p.y / cell) + 2048);
let arr = grid.get(key);
if (!arr) grid.set(key, arr = []);
arr.push(i);
}
const NB = [0, 1, 8192, 8191, 8193]; // self + 4 neighbours (each pair seen once)
for (const [key, arr] of grid) {
for (let k = 0; k < NB.length; k++) {
const other = k === 0 ? arr : grid.get(key + NB[k]);
if (!other) continue;
for (let i = 0; i < arr.length; i++) {
const a = pts[arr[i]];
const jStart = k === 0 ? i + 1 : 0;
for (let j = jStart; j < other.length; j++) {
const b = pts[other[j]];
const minD = a.r + b.r;
const dx = b.x - a.x, dy = b.y - a.y;
const d2 = dx * dx + dy * dy;
if (d2 >= minD * minD) continue;
const aP = a.pinned || a.grabbed, bP = b.pinned || b.grabbed;
if (aP && bP) continue;
let d = Math.sqrt(d2), nx, ny;
if (d < 1e-6) { nx = 1; ny = 0; d = 1e-6; } else { nx = dx / d; ny = dy / d; }
const push = (minD - d);
if (!aP && !bP) {
const h = push * 0.5;
a.x -= nx * h; a.y -= ny * h; b.x += nx * h; b.y += ny * h;
} else if (aP) { b.x += nx * push; b.y += ny * push; }
else { a.x -= nx * push; a.y -= ny * push; }
}
}
}
}
}
/* ---------- queries / interaction helpers ---------- */
nearestPoint(x, y, maxD) {
let best = null, bd = maxD * maxD;
for (let i = 0; i < this.points.length; i++) {
const p = this.points[i];
const dx = p.x - x, dy = p.y - y, d2 = dx * dx + dy * dy;
if (d2 < bd) { bd = d2; best = p; }
}
return best;
}
togglePinAt(x, y) {
const p = this.nearestPoint(x, y, 24);
if (!p) return null;
p.pinned = !p.pinned;
if (p.pinned) { p.px = p.x; p.py = p.y; }
return p;
}
slice(x0, y0, x1, y1) {
const degenerate = (x1 - x0) * (x1 - x0) + (y1 - y0) * (y1 - y0) < 0.5;
const tr2 = CFG.TEAR_RADIUS * CFG.TEAR_RADIUS;
let cuts = 0;
for (let i = 0; i < this.constraints.length; i++) {
const c = this.constraints[i];
if (!c.active) continue;
const a = c.a, b = c.b;
const hit = degenerate
? pointSegDist2(x0, y0, a.x, a.y, b.x, b.y) < tr2
: segIntersect(x0, y0, x1, y1, a.x, a.y, b.x, b.y);
if (hit) {
c.active = false; cuts++;
if (this.onCut) this.onCut((a.x + b.x) / 2, (a.y + b.y) / 2);
}
}
return cuts;
}
kinetic() {
let e = 0;
for (let i = 0; i < this.points.length; i++) {
const p = this.points[i];
const vx = p.x - p.px, vy = p.y - p.py;
e += vx * vx + vy * vy;
}
return e;
}
clearDynamic() {
this.points.length = 0;
this.constraints.length = 0;
this.objects.length = 0;
this.grabbed = null;
}
}
/* ================================================================
4. SPAWNABLE OBJECTS — rope, cloth, soft blob, jelly box, ball
================================================================ */
const HUES = { rope: [38, 22, 55], cloth: [176, 199, 152], blob: [330, 300, 348], jelly: [140, 96, 164], ball: [208, 188, 228] };
const hueIdx = {};
function nextHue(kind) {
const list = HUES[kind];
hueIdx[kind] = (hueIdx[kind] || 0) + 1;
return list[(hueIdx[kind] - 1) % list.length];
}
World.prototype.spawnRope = function (x0, y0, x1, y1, opts = {}) {
let dx = x1 - x0, dy = y1 - y0;
let len = Math.sqrt(dx * dx + dy * dy);
if (len < 8) { dx = 0; dy = 180; len = 180; } // tiny drag → default hanging rope
const n = clamp(Math.round(len / CFG.ROPE_SEG), 5, 90);
if (this.points.length + n + 1 > CFG.MAX_POINTS) return null;
const meta = { kind: 'rope', hue: nextHue('rope'), pts: [], cons: [] };
for (let i = 0; i <= n; i++) {
const t = i / n;
meta.pts.push(this.addPoint(x0 + dx * t, y0 + dy * t, { r: 2.2, obj: meta }));
}
for (let i = 0; i < n; i++) meta.cons.push(this.link(meta.pts[i], meta.pts[i + 1], { obj: meta }));
if (opts.pinStart !== false) meta.pts[0].pinned = true;
this.objects.push(meta);
return meta;
};
World.prototype.spawnCloth = function (x0, y0, w, h, pinMode) {
const sp = CFG.CLOTH_SPACING;
let cols = clamp(Math.round(w / sp), 3, 44);
let rows = clamp(Math.round(h / sp), 3, 30);
const budget = CFG.MAX_POINTS - this.points.length;
let total = (cols + 1) * (rows + 1);
if (total > budget) {
const f = Math.sqrt(Math.max(0, budget) / total);
cols = Math.max(3, Math.floor(cols * f));
rows = Math.max(3, Math.floor(rows * f));
total = (cols + 1) * (rows + 1);
}
if (total > budget || total < 16) return null;
const cw = w / cols, ch = h / rows, cp = cols + 1;
const meta = { kind: 'cloth', hue: nextHue('cloth'), cols, rows, pts: [], hcs: [], vcs: [], restArea: cw * ch };
for (let j = 0; j <= rows; j++)
for (let i = 0; i <= cols; i++) {
const p = this.addPoint(x0 + i * cw, y0 + j * ch, { r: 2, obj: meta, nodot: true });
meta.pts.push(p);
}
for (let j = 0; j <= rows; j++)
for (let i = 0; i < cols; i++)
meta.hcs.push(this.link(meta.pts[j * cp + i], meta.pts[j * cp + i + 1], { obj: meta }));
for (let j = 0; j < rows; j++)
for (let i = 0; i <= cols; i++)
meta.vcs.push(this.link(meta.pts[j * cp + i], meta.pts[(j + 1) * cp + i], { obj: meta }));
for (let j = 0; j < rows; j++)
for (let i = 0; i < cols; i++) { // shear diagonals keep the weave from collapsing
this.link(meta.pts[j * cp + i], meta.pts[(j + 1) * cp + i + 1], { stiff: 0.55, obj: meta });
this.link(meta.pts[j * cp + i + 1], meta.pts[(j + 1) * cp + i], { stiff: 0.55, obj: meta });
}
if (pinMode === 'corners') {
meta.pts[0].pinned = true;
meta.pts[cols].pinned = true;
} else { // full edge
for (let i = 0; i <= cols; i++) meta.pts[i].pinned = true;
}
this.objects.push(meta);
return meta;
};
World.prototype.spawnBlob = function (cx, cy, R) {
R = clamp(R, 14, 150);
const K = clamp(Math.round((2 * Math.PI * R) / 15), 9, 26);
if (this.points.length + K + 1 > CFG.MAX_POINTS) return null;
const meta = { kind: 'blob', hue: nextHue('blob'), ring: [], cons: [], center: null };
meta.center = this.addPoint(cx, cy, { r: 2, nodot: true, obj: meta });
for (let k = 0; k < K; k++) {
const ang = (k / K) * Math.PI * 2 - Math.PI / 2;
meta.ring.push(this.addPoint(cx + Math.cos(ang) * R, cy + Math.sin(ang) * R, { r: 2.5, obj: meta }));
}
for (let k = 0; k < K; k++) meta.cons.push(this.link(meta.ring[k], meta.ring[(k + 1) % K], { obj: meta }));
for (let k = 0; k < K; k++) this.link(meta.ring[k], meta.ring[(k + 2) % K], { stiff: 0.55, obj: meta }); // bending
for (let k = 0; k < K; k++) this.link(meta.center, meta.ring[k], { stiff: 0.45, obj: meta }); // spokes
this.objects.push(meta);
return meta;
};
World.prototype.spawnJelly = function (x0, y0, w, h) {
w = Math.max(46, w); h = Math.max(46, h);
const n = 4, cp = n + 1; // 5×5 points
if (this.points.length + cp * cp > CFG.MAX_POINTS) return null;
const meta = { kind: 'jelly', hue: nextHue('jelly'), pts: [], cons: [], perim: [] };
const cw = w / n, ch = h / n;
for (let j = 0; j <= n; j++)
for (let i = 0; i <= n; i++)
meta.pts.push(this.addPoint(x0 + i * cw, y0 + j * ch, { r: 3, obj: meta }));
const at = (i, j) => meta.pts[j * cp + i];
for (let j = 0; j <= n; j++)
for (let i = 0; i < n; i++) meta.cons.push(this.link(at(i, j), at(i + 1, j), { stiff: 0.9, obj: meta }));
for (let j = 0; j < n; j++)
for (let i = 0; i <= n; i++) meta.cons.push(this.link(at(i, j), at(i, j + 1), { stiff: 0.9, obj: meta }));
for (let j = 0; j < n; j++)
for (let i = 0; i < n; i++) {
this.link(at(i, j), at(i + 1, j + 1), { stiff: 0.45, obj: meta });
this.link(at(i + 1, j), at(i, j + 1), { stiff: 0.45, obj: meta });
}
// perimeter in order (for the filled outline)
for (let i = 0; i <= n; i++) meta.perim.push(at(i, 0));
for (let j = 1; j <= n; j++) meta.perim.push(at(n, j));
for (let i = n - 1; i >= 0; i--) meta.perim.push(at(i, n));
for (let j = n - 1; j >= 1; j--) meta.perim.push(at(0, j));
// perimeter bending springs → holds its squareness
const P = meta.perim.length;
for (let k = 0; k < P; k++) this.link(meta.perim[k], meta.perim[(k + 2) % P], { stiff: 0.5, obj: meta });
this.objects.push(meta);
return meta;
};
World.prototype.spawnBall = function (x, y, r) {
if (this.points.length + 1 > CFG.MAX_POINTS) return null;
r = clamp(r || 13, 6, 16);
const meta = { kind: 'ball', hue: nextHue('ball'), p: null, r };
meta.p = this.addPoint(x, y, { r, rest: 0.55, obj: meta });
this.objects.push(meta);
return meta;
};
/* ================================================================
5. DEFAULT SCENE
================================================================ */
function buildDefaultScene(w) {
w.statics.length = 0;
// static circle for the cloth to drape over
w.addCircle(w.w * 0.36, w.h * 0.66, clamp(w.w * 0.07, 46, 92));
// static box on the right for the blob to land on
const hh = clamp(w.h * 0.11, 60, 96), hw = clamp(w.w * 0.085, 60, 115);
w.addBox(w.w * 0.80, w.h - hh, hw, hh);
w.spawnCloth(w.w * 0.20, w.h * 0.10, Math.min(w.w * 0.34, 430), Math.min(w.h * 0.36, 300), 'edge');
w.spawnRope(w.w * 0.62, w.h * 0.08, w.w * 0.62, w.h * 0.08 + Math.min(w.h * 0.42, 360));
w.spawnBlob(w.w * 0.80, w.h * 0.28, 44);
w.spawnBall(w.w * 0.52, w.h * 0.16, 13);
}
World.prototype.reset = function () {
this.clearDynamic();
buildDefaultScene(this);
};
/* ================================================================
6. SELF-TEST — headless checks on scratch worlds
================================================================ */
function selfTest() {
const out = {};
// 1. drop a soft blob from height → settles, nothing below floor
{
const w = new World(world.w, world.h);
w.spawnBlob(w.w / 2, w.h * 0.2, 42);
for (let i = 0; i < 180; i++) w.step();
const n = w.points.length, ke = w.kinetic();
let below = 0, bad = 0;
for (const p of w.points) {
if (p.y > w.h + 0.5) below++;
if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) bad++;
}
out.dropBlob = { pass: bad === 0 && below === 0 && ke / n < 0.03, kePerPoint: +(ke / n).toFixed(5), pointsBelowFloor: below, nan: bad };
}
// 2. 1000 frames of a mixed scene → no NaN, no runaway coordinates
{
const w = new World(world.w, world.h);
w.spawnRope(w.w * 0.3, 40, w.w * 0.3, 300);
w.spawnCloth(w.w * 0.45, 40, 220, 150, 'corners');
w.spawnBlob(w.w * 0.7, 120, 36);
w.spawnBall(w.w * 0.55, 60, 13);
for (let i = 0; i < 1000; i++) w.step();
let bad = 0;
for (const p of w.points)
if (!Number.isFinite(p.x) || !Number.isFinite(p.y) || Math.abs(p.x) > 1e5 || Math.abs(p.y) > 1e5) bad++;
out.stability1000 = { pass: bad === 0, badCoords: bad, points: w.points.length };
}
// 3. corner-pinned cloth, 600 frames → pins immovable, cloth above floor
{
const w = new World(world.w, world.h);
const m = w.spawnCloth(w.w / 2 - 110, 60, 220, 150, 'corners');
const p0 = m.pts[0], pN = m.pts[m.cols];
const a0 = { x: p0.x, y: p0.y }, aN = { x: pN.x, y: pN.y };
for (let i = 0; i < 600; i++) w.step();
let maxY = -1e9;
for (const p of m.pts) if (p.y > maxY) maxY = p.y;
const pinsHold = Math.hypot(p0.x - a0.x, p0.y - a0.y) < 1e-3 && Math.hypot(pN.x - aN.x, pN.y - aN.y) < 1e-3;
out.clothPins600 = { pass: pinsHold && maxY <= w.h + 0.5, pinsHold, lowestY: +maxY.toFixed(1), floorY: w.h };
}
// 4. tear a rope mid-air → halves move independently
{
const w = new World(world.w, world.h);
const m = w.spawnRope(w.w / 2, 60, w.w / 2, 60 + 22 * CFG.ROPE_SEG);
for (let i = 0; i < 120; i++) w.step();
const mid = Math.floor(m.cons.length / 2);
const upperProbe = m.pts[3], lowerProbe = m.pts[m.pts.length - 4];
const u0 = upperProbe.y, l0 = lowerProbe.y;
m.cons[mid].active = false;
for (let i = 0; i < 90; i++) w.step();
// connectivity: lower probe must be unreachable from the anchor now
const adj = new Map();
for (const c of m.cons) if (c.active) {
if (!adj.has(c.a)) adj.set(c.a, []);
if (!adj.has(c.b)) adj.set(c.b, []);
adj.get(c.a).push(c.b); adj.get(c.b).push(c.a);
}
const seen = new Set([m.pts[0]]), stack = [m.pts[0]];
while (stack.length) {
const q = stack.pop();
for (const nb of (adj.get(q) || [])) if (!seen.has(nb)) { seen.add(nb); stack.push(nb); }
}
const independent = !seen.has(lowerProbe);
const lowerFell = lowerProbe.y - l0 > 20;
const upperNear = Math.abs(upperProbe.y - u0) < 40;
out.ropeTear = { pass: independent && lowerFell && upperNear, independent, lowerDrop: +(lowerProbe.y - l0).toFixed(1), upperDrift: +Math.abs(upperProbe.y - u0).toFixed(1) };
}
out.allPass = out.dropBlob.pass && out.stability1000.pass && out.clothPins600.pass && out.ropeTear.pass;
return out;
}
/* ================================================================
7. CANVAS SETUP
================================================================ */
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
let dpr = 1;
const world = new World(window.innerWidth, window.innerHeight);
buildDefaultScene(world);
function resize() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.floor(window.innerWidth * dpr);
cv.height = Math.floor(window.innerHeight * dpr);
cv.style.width = window.innerWidth + 'px';
cv.style.height = window.innerHeight + 'px';
world.w = window.innerWidth;
world.h = window.innerHeight;
}
window.addEventListener('resize', resize);
resize();
/* ================================================================
8. COSMETIC STATE — particles, slice trail, UI flags
================================================================ */
const particles = [];
function burst(x, y, hue, n, speed) {
for (let i = 0; i < n && particles.length < 500; i++) {
const a = Math.random() * Math.PI * 2, s = (0.3 + Math.random()) * (speed || 160);
particles.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s - 40, life: 0.45 + Math.random() * 0.3, t: 0, hue });
}
}
world.onCut = (x, y) => burst(x, y, 18, 7, 220);
const sliceTrail = [];
function pushSlice(x, y) {
sliceTrail.push({ x, y, t: performance.now() });
if (sliceTrail.length > 40) sliceTrail.shift();
}
const ui = {
tool: 'grab',
clothPins: 'edge', // 'edge' | 'corners'
stress: true,
showPoints: false,
trails: false,
paused: false,
};
/* ================================================================
9. INPUT — grab / slice / pin / spawn
================================================================ */
const mouse = { x: 0, y: 0, over: false };
let action = null; // {type:'slice',lx,ly} | {type:'place',sx,sy,ex,ey}
let activePointer = null;
function toLocal(e) {
const r = cv.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
cv.addEventListener('contextmenu', e => e.preventDefault());
cv.addEventListener('pointerdown', e => {
const m = toLocal(e);
mouse.x = m.x; mouse.y = m.y; mouse.over = true;
if (activePointer !== null) return;
activePointer = e.pointerId;
cv.setPointerCapture(e.pointerId);
// right-drag or Alt+left-drag → slice
if (e.button === 2 || (e.button === 0 && e.altKey)) {
action = { type: 'slice', lx: m.x, ly: m.y };
pushSlice(m.x, m.y);
world.slice(m.x, m.y, m.x, m.y); // click-cut via tear radius
return;
}
if (e.button !== 0) return;
// Shift-click → toggle pin
if (e.shiftKey) {
const p = world.togglePinAt(m.x, m.y);
if (p) burst(p.x, p.y, p.pinned ? 45 : 200, 8, 120);
return;
}
if (ui.tool === 'grab') {
const p = world.nearestPoint(m.x, m.y, CFG.GRAB_RADIUS);
if (p && !p.pinned) {
p.grabbed = true;
world.grabbed = p;
world.grabX = m.x; world.grabY = m.y;
}
} else {
action = { type: 'place', sx: m.x, sy: m.y, ex: m.x, ey: m.y };
}
});
cv.addEventListener('pointermove', e => {
const m = toLocal(e);
mouse.x = m.x; mouse.y = m.y; mouse.over = true;
if (!action || e.pointerId !== activePointer) return;
if (action.type === 'slice') {
world.slice(action.lx, action.ly, m.x, m.y);
pushSlice(m.x, m.y);
action.lx = m.x; action.ly = m.y;
} else if (action.type === 'place') {
action.ex = m.x; action.ey = m.y;
}
});
function endPointer(e) {
if (e.pointerId !== activePointer) return;
activePointer = null;
if (action && action.type === 'place') commitSpawn(action);
action = null;
if (world.grabbed) { world.grabbed.grabbed = false; world.grabbed = null; } // throw with drag velocity
}
cv.addEventListener('pointerup', endPointer);
cv.addEventListener('pointercancel', endPointer);
cv.addEventListener('pointerleave', () => { mouse.over = false; });
function commitSpawn(a) {
const dx = a.ex - a.sx, dy = a.ey - a.sy;
let made = null, hue = 0;
switch (ui.tool) {
case 'rope':
made = world.spawnRope(a.sx, a.sy, a.ex, a.ey); hue = 40; break;
case 'cloth': {
const x0 = Math.min(a.sx, a.ex), y0 = Math.min(a.sy, a.ey);
const w = Math.max(60, Math.abs(dx)), h = Math.max(60, Math.abs(dy));
made = world.spawnCloth(x0, y0, w, h, ui.clothPins); hue = 180; break;
}
case 'blob': {
const R = Math.hypot(dx, dy);
made = world.spawnBlob(a.sx, a.sy, R < 12 ? 42 : R); hue = 320; break;
}
case 'jelly': {
const x0 = Math.min(a.sx, a.ex), y0 = Math.min(a.sy, a.ey);
const w = Math.max(50, Math.abs(dx)), h = Math.max(50, Math.abs(dy));
made = world.spawnJelly(x0, y0, w, h); hue = 130; break;
}
case 'ball': {
made = world.spawnBall(a.sx, a.sy, 13); hue = 210;
if (made) { made.p.px = made.p.x - dx * 0.06; made.p.py = made.p.y - dy * 0.06; } // fling
break;
}
}
if (made) burst(a.sx, a.sy, hue, 10, 140);
}
/* ---------- keyboard ---------- */
const TOOL_ORDER = ['grab', 'rope', 'cloth', 'blob', 'jelly', 'ball'];
function setTool(t) {
ui.tool = t;
document.querySelectorAll('.tbtn').forEach(b => b.classList.toggle('active', b.dataset.tool === t));
cv.style.cursor = t === 'grab' ? 'crosshair' : 'cell';
}
window.addEventListener('keydown', e => {
if (e.target && (e.target.tagName === 'INPUT' && e.target.type !== 'range')) return;
switch (e.code) {
case 'Space': e.preventDefault(); togglePause(); return;
case 'ArrowRight': case 'Period':
if (ui.paused) world.step();
e.preventDefault(); return;
}
const k = e.key.toLowerCase();
if (k >= '1' && k <= '6') setTool(TOOL_ORDER[+k - 1]);
else if (k === 'r') world.reset();
else if (k === 'c') world.clearDynamic();
else if (k === 'escape') setTool('grab');
else if (k === 'v') { cStress.click(); }
else if (k === 'g') { cGrav.click(); }
});
/* ================================================================
10. RENDERING
================================================================ */
function drawBackground() {
ctx.fillStyle = '#0a0d13';
ctx.fillRect(0, 0, world.w, world.h);
// faint grid
ctx.strokeStyle = 'rgba(255,255,255,0.028)';
ctx.lineWidth = 1;
ctx.beginPath();
for (let x = 0.5; x < world.w; x += 64) { ctx.moveTo(x, 0); ctx.lineTo(x, world.h); }
for (let y = 0.5; y < world.h; y += 64) { ctx.moveTo(0, y); ctx.lineTo(world.w, y); }
ctx.stroke();
// floor / wall accents
const fg = ctx.createLinearGradient(0, world.h - 60, 0, world.h);
fg.addColorStop(0, 'rgba(63,208,255,0)');
fg.addColorStop(1, 'rgba(63,208,255,0.06)');
ctx.fillStyle = fg;
ctx.fillRect(0, world.h - 60, world.w, 60);
ctx.fillStyle = 'rgba(120,210,255,0.20)';
ctx.fillRect(0, world.h - 1.5, world.w, 1.5);
ctx.fillStyle = 'rgba(120,210,255,0.07)';
ctx.fillRect(0, 0, 1.5, world.h);
ctx.fillRect(world.w - 1.5, 0, 1.5, world.h);
}
function drawStatics() {
for (const s of world.statics) {
if (s.type === 'circle') {
const g = ctx.createRadialGradient(s.x - s.r * 0.35, s.y - s.r * 0.4, s.r * 0.1, s.x, s.y, s.r);
g.addColorStop(0, '#212a3d'); g.addColorStop(1, '#141a28');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(140,175,225,0.22)'; ctx.lineWidth = 1.5; ctx.stroke();
ctx.strokeStyle = 'rgba(140,175,225,0.05)'; ctx.lineWidth = 7; ctx.stroke();
} else {
ctx.beginPath();
ctx.roundRect(s.x - s.hw, s.y - s.hh, s.hw * 2, s.hh * 2, 9);
const g = ctx.createLinearGradient(0, s.y - s.hh, 0, s.y + s.hh);
g.addColorStop(0, '#222b3f'); g.addColorStop(1, '#131926');
ctx.fillStyle = g; ctx.fill();
ctx.strokeStyle = 'rgba(140,175,225,0.22)'; ctx.lineWidth = 1.5; ctx.stroke();
}
}
}
function drawCloth(o) {
const { pts, cols, rows, hue, hcs, vcs, restArea } = o;
const cp = cols + 1;
for (let j = 0; j < rows; j++) {
for (let i = 0; i < cols; i++) {
if (!(hcs[j * cols + i].active && hcs[(j + 1) * cols + i].active &&
vcs[j * cp + i].active && vcs[j * cp + i + 1].active)) continue;
const a = pts[j * cp + i], b = pts[j * cp + i + 1],
c = pts[(j + 1) * cp + i], d = pts[(j + 1) * cp + i + 1];
const ux = b.x - a.x, uy = b.y - a.y, vx = c.x - a.x, vy = c.y - a.y;
const s = (ux * vy - uy * vx) / restArea; // signed area ratio → fake lighting
let L;
if (s <= 0.04) L = 9; // backside of a fold
else L = 13 + 36 * Math.min(1.05, s);
ctx.fillStyle = 'hsl(' + hue + ',60%,' + L + '%)';
ctx.beginPath();
ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(d.x, d.y); ctx.lineTo(c.x, c.y);
ctx.closePath(); ctx.fill();
}
}
}
function drawRope(o) {
ctx.lineCap = 'round';
ctx.beginPath();
for (const c of o.cons) if (c.active) { ctx.moveTo(c.a.x, c.a.y); ctx.lineTo(c.b.x, c.b.y); }
ctx.strokeStyle = 'rgba(0,0,0,0.55)'; ctx.lineWidth = 4.6; ctx.stroke();
if (ui.stress) {
ctx.lineWidth = 2.3;
for (const c of o.cons) {
if (!c.active) continue;
const dx = c.b.x - c.a.x, dy = c.b.y - c.a.y;
const strain = (Math.sqrt(dx * dx + dy * dy) - c.rest) / c.rest;
ctx.strokeStyle = strainColor(strain);
ctx.beginPath(); ctx.moveTo(c.a.x, c.a.y); ctx.lineTo(c.b.x, c.b.y); ctx.stroke();
}
} else {
ctx.strokeStyle = 'hsl(' + o.hue + ',85%,62%)'; ctx.lineWidth = 2.3;
ctx.beginPath();
for (const c of o.cons) if (c.active) { ctx.moveTo(c.a.x, c.a.y); ctx.lineTo(c.b.x, c.b.y); }
ctx.stroke();
}
}
function smoothPath(pts, closed) {
const n = pts.length;
ctx.beginPath();
let mx = (pts[0].x + pts[n - 1].x) / 2, my = (pts[0].y + pts[n - 1].y) / 2;
ctx.moveTo(mx, my);
for (let i = 0; i < n; i++) {
const p = pts[i], q = pts[(i + 1) % n];
ctx.quadraticCurveTo(p.x, p.y, (p.x + q.x) / 2, (p.y + q.y) / 2);
}
if (closed) ctx.closePath();
}
function drawBlob(o) {
const R = o.ring;
let cx = 0, cy = 0;
for (const p of R) { cx += p.x; cy += p.y; }
cx /= R.length; cy /= R.length;
smoothPath(R, true);
const g = ctx.createRadialGradient(cx, cy, 2, cx, cy, Math.max(...R.map(p => Math.hypot(p.x - cx, p.y - cy))));
g.addColorStop(0, 'hsl(' + o.hue + ',78%,62%)');
g.addColorStop(1, 'hsl(' + o.hue + ',70%,38%)');
ctx.fillStyle = g; ctx.fill();
ctx.strokeStyle = 'hsla(' + o.hue + ',88%,72%,0.85)'; ctx.lineWidth = 2; ctx.stroke();
if (ui.stress) {
ctx.lineWidth = 1.4;
for (const c of o.cons) {
if (!c.active) continue;
const dx = c.b.x - c.a.x, dy = c.b.y - c.a.y;
ctx.strokeStyle = strainColor((Math.sqrt(dx * dx + dy * dy) - c.rest) / c.rest);
ctx.beginPath(); ctx.moveTo(c.a.x, c.a.y); ctx.lineTo(c.b.x, c.b.y); ctx.stroke();
}
}
}
function drawJelly(o) {
smoothPath(o.perim, true);
ctx.fillStyle = 'hsla(' + o.hue + ',62%,46%,0.82)'; ctx.fill();
ctx.strokeStyle = 'hsla(' + o.hue + ',85%,68%,0.9)'; ctx.lineWidth = 2; ctx.stroke();
if (ui.stress) {
ctx.lineWidth = 1.2;
for (const c of o.cons) {
if (!c.active) continue;
const dx = c.b.x - c.a.x, dy = c.b.y - c.a.y;
ctx.strokeStyle = strainColor((Math.sqrt(dx * dx + dy * dy) - c.rest) / c.rest);
ctx.beginPath(); ctx.moveTo(c.a.x, c.a.y); ctx.lineTo(c.b.x, c.b.y); ctx.stroke();
}
}
}
function drawBall(o) {
const p = o.p, r = o.r;
const g = ctx.createRadialGradient(p.x - r * 0.35, p.y - r * 0.4, r * 0.15, p.x, p.y, r);
g.addColorStop(0, 'hsl(' + o.hue + ',85%,70%)');
g.addColorStop(0.65, 'hsl(' + o.hue + ',75%,48%)');
g.addColorStop(1, 'hsl(' + o.hue + ',70%,30%)');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.28)'; ctx.lineWidth = 1.2; ctx.stroke();
}
function drawPointsAndPins() {
if (ui.showPoints) {
ctx.fillStyle = 'rgba(210,228,255,0.85)';
for (const p of world.points) {
if (p.nodot) continue;
ctx.beginPath(); ctx.arc(p.x, p.y, Math.max(1.4, p.r * 0.6), 0, Math.PI * 2); ctx.fill();
}
}
// pins — always visible
for (const p of world.points) {
if (!p.pinned) continue;
ctx.fillStyle = '#ffb454';
ctx.beginPath(); ctx.arc(p.x, p.y, 3.6, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(255,180,84,0.35)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(p.x, p.y, 7, 0, Math.PI * 2); ctx.stroke();
}
// grabbed point highlight
if (world.grabbed) {
const p = world.grabbed;
ctx.strokeStyle = 'rgba(90,225,255,0.9)'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(p.x, p.y, 9 + Math.sin(performance.now() * 0.01) * 1.5, 0, Math.PI * 2); ctx.stroke();
}
}
function drawParticles(dt) {
for (let i = particles.length - 1; i >= 0; i--) {
const q = particles[i];
q.t += dt;
if (q.t > q.life) { particles.splice(i, 1); continue; }
q.vy += 900 * dt;
q.x += q.vx * dt; q.y += q.vy * dt;
const a = 1 - q.t / q.life;
ctx.fillStyle = 'hsla(' + q.hue + ',95%,65%,' + a.toFixed(3) + ')';
ctx.fillRect(q.x - 1.5, q.y - 1.5, 3, 3);
}
}
function drawSliceTrail() {
const now = performance.now();
while (sliceTrail.length && now - sliceTrail[0].t > 180) sliceTrail.shift();
if (sliceTrail.length < 2) return;
ctx.lineCap = 'round';
for (let i = 1; i < sliceTrail.length; i++) {
const a = sliceTrail[i - 1], b = sliceTrail[i];
const age = (now - b.t) / 180;
ctx.strokeStyle = 'rgba(255,140,100,' + (0.7 * (1 - age)).toFixed(3) + ')';
ctx.lineWidth = 2.5 * (1 - age) + 0.5;
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
}
}
function drawCursorFx() {
if (!mouse.over) return;
if (ui.tool === 'grab' && !world.grabbed && !action) {
const p = world.nearestPoint(mouse.x, mouse.y, CFG.GRAB_RADIUS);
if (p && !p.pinned) {
ctx.strokeStyle = 'rgba(120,220,255,0.45)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(p.x, p.y, 8, 0, Math.PI * 2); ctx.stroke();
}
}
}
function drawGhost() {
if (!action || action.type !== 'place') return;
const { sx, sy, ex, ey } = action;
ctx.setLineDash([6, 6]);
ctx.strokeStyle = 'rgba(255,255,255,0.4)';
ctx.lineWidth = 1.4;
switch (ui.tool) {
case 'rope':
ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(ex, ey); ctx.stroke();
ctx.fillStyle = 'rgba(255,180,84,0.9)';
ctx.beginPath(); ctx.arc(sx, sy, 3.5, 0, Math.PI * 2); ctx.fill();
break;
case 'cloth': case 'jelly': {
const x0 = Math.min(sx, ex), y0 = Math.min(sy, ey);
ctx.strokeRect(x0, y0, Math.abs(ex - sx), Math.abs(ey - sy));
break;
}
case 'blob':
ctx.beginPath(); ctx.arc(sx, sy, Math.max(12, Math.hypot(ex - sx, ey - sy)), 0, Math.PI * 2); ctx.stroke();
break;
case 'ball':
ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(ex, ey); ctx.stroke();
ctx.beginPath(); ctx.arc(sx, sy, 13, 0, Math.PI * 2); ctx.stroke();
break;
}
ctx.setLineDash([]);
}
function render(dtSec) {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (ui.trails && !ui.paused) {
ctx.fillStyle = 'rgba(10,13,19,' + CFG.TRAIL_FADE + ')';
ctx.fillRect(0, 0, world.w, world.h);
} else {
drawBackground();
}
drawStatics();
for (const o of world.objects) if (o.kind === 'cloth') drawCloth(o);
for (const o of world.objects) if (o.kind === 'blob') drawBlob(o);
for (const o of world.objects) if (o.kind === 'jelly') drawJelly(o);
for (const o of world.objects) if (o.kind === 'rope') drawRope(o);
for (const o of world.objects) if (o.kind === 'ball') drawBall(o);
drawPointsAndPins();
drawParticles(dtSec);
drawSliceTrail();
drawCursorFx();
drawGhost();
}
/* ================================================================
11. MAIN LOOP — fixed-timestep accumulator
================================================================ */
let last = performance.now();
let acc = 0;
let fpsEma = 60, physMsEma = 0;
function frame(now) {
requestAnimationFrame(frame);
let dms = now - last; last = now;
if (dms > CFG.MAX_FRAME_MS) dms = CFG.MAX_FRAME_MS; // tab-switch clamp — never detonate
if (dms > 0) fpsEma += (1000 / dms - fpsEma) * 0.08;
if (!ui.paused) {
acc += dms / 1000;
let n = 0;
const t0 = performance.now();
while (acc >= CFG.DT && n < CFG.MAX_STEPS) { world.step(CFG.DT); acc -= CFG.DT; n++; }
if (n === CFG.MAX_STEPS) acc = 0; // drop backlog — stay real-time
if (n > 0) physMsEma += ((performance.now() - t0) - physMsEma) * 0.15;
}
render(dms / 1000);
statsTick(now);
}
requestAnimationFrame(frame);
/* ================================================================
12. UI WIRING
================================================================ */
const $ = id => document.getElementById(id);
const cGrav = $('cGrav'), cWind = $('cWind'), cStress = $('cStress'),
cPts = $('cPts'), cColl = $('cColl'), cTrail = $('cTrail');
document.querySelectorAll('.tbtn').forEach(b => b.addEventListener('click', () => setTool(b.dataset.tool)));
$('pinChip').addEventListener('click', () => {
ui.clothPins = ui.clothPins === 'edge' ? 'corners' : 'edge';
$('pinChip').textContent = 'cloth pins: ' + (ui.clothPins === 'edge' ? 'full edge' : 'two corners');
});
$('grav').addEventListener('input', e => { world.gravity = +e.target.value; $('gravVal').textContent = e.target.value; });
$('iter').addEventListener('input', e => { world.iterations = +e.target.value; $('iterVal').textContent = e.target.value; });
$('wind').addEventListener('input', e => { world.wind = +e.target.value; $('windVal').textContent = e.target.value; });
cGrav.addEventListener('change', () => { world.gravityOn = cGrav.checked; });
cWind.addEventListener('change', () => { world.windOn = cWind.checked; });
cStress.addEventListener('change', () => { ui.stress = cStress.checked; });
cPts.addEventListener('change', () => { ui.showPoints = cPts.checked; });
cColl.addEventListener('change', () => { world.collide = cColl.checked; });
cTrail.addEventListener('change', () => { ui.trails = cTrail.checked; });
function togglePause() {
ui.paused = !ui.paused;
$('bPause').textContent = ui.paused ? '▶ resume' : '⏸ pause';
if (!ui.paused) { last = performance.now(); acc = 0; }
}
$('bPause').addEventListener('click', togglePause);
$('bStep').addEventListener('click', () => { if (!ui.paused) togglePause(); world.step(); });
$('bReset').addEventListener('click', () => world.reset());
$('bClear').addEventListener('click', () => world.clearDynamic());
$('fold').addEventListener('click', () => {
const p = $('panel');
p.classList.toggle('min');
$('fold').textContent = p.classList.contains('min') ? '+' : '–';
});
let lastStats = 0;
function statsTick(now) {
if (now - lastStats < 250) return;
lastStats = now;
$('stats').textContent =
fpsEma.toFixed(0) + ' fps · phys ' + physMsEma.toFixed(2) + ' ms\n' +
world.points.length + ' pts · ' + world.constraints.length + ' links · ' + world.objects.length + ' bodies' +
(ui.paused ? ' · PAUSED (→ steps)' : '');
}
/* ================================================================
13. PUBLIC API + HEADLESS SELF-CHECK
================================================================ */
window.sim = world; // sim.points, sim.constraints, sim.step(), …
window.CFG = CFG;
world.selfTest = selfTest;
world.spawn = {
rope: (x0, y0, x1, y1) => world.spawnRope(x0, y0, x1, y1),
cloth: (x, y, w, h, pins) => world.spawnCloth(x, y, w, h, pins || 'edge'),
blob: (x, y, r) => world.spawnBlob(x, y, r),
jelly: (x, y, w, h) => world.spawnJelly(x, y, w, h),
ball: (x, y, r) => world.spawnBall(x, y, r),
};
console.log('%csoft-body playground ready — probe window.sim (sim.points, sim.step(), sim.selfTest())',
'color:#4dd7ff;font-weight:bold');
if (location.hash.includes('test')) {
setTimeout(() => {
const r = selfTest();
console.log('[selfTest]', JSON.stringify(r, null, 2));
}, 60);
}
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen/qwen3.8-max","provider":"openrouter","persona":"middle","sessionId":"96f5adad-ed94-4a28-820d-ad0474703c49","tokensIn":701089,"tokensOut":57392,"tokensTotal":758481,"cost":0.6677459999999998,"turns":13,"toolCalls":18,"failedToolCalls":0,"timestamp":"2026-08-03T21:33:51.596Z"} -->