Physics Playground
Claude haiku 5.5 · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Task text captured before this run. Effective runtime system instructions were not captured.
Generation prompt · 645374a2d6
# Task: Build a 2D Soft-Body Physics Playground Build an interactive 2D physics playground (vanilla JS, no external assets, no CDN, no libraries) based on **Verlet integration with constraint relaxation** — ropes, cloth, and soft bodies the user can grab, tear, and spawn. The top priority is **numerical stability** — a physics toy that explodes, jitters, or tunnels is a failed physics toy. ## Core Specification **Engine (write it yourself — position-based dynamics, not a rigid-body library clone)** - Verlet integration: `x' = x + (x - xPrev) * damping + a * dt²` with a **fixed timestep** accumulator (e.g. 60 Hz physics) decoupled from render rate - Constraint solving via relaxation: distance constraints iterated N times per step (N configurable, ~3–8) - Collision: points vs. static shapes (bounding walls/floor, a few static circles/boxes) with friction; simple point-vs-point collision optional - Gravity toggle and strength slider; optional wind - No `NaN` escapes: guard division-by-zero in constraint resolution (zero-length links, coincident points) **Spawnable Objects (toolbar + click/drag to place)** - **Rope:** click-drag to draw a hanging chain of N points pinned at the start - **Cloth:** draggable rectangle of points with structural + shear constraints, pinned along the top edge (pin pattern selectable: both corners / full edge) - **Soft body:** circle/blob or box of points with edge + internal brace constraints that holds shape but deforms on impact - **Ball:** single point with radius that bounces - Spawn several objects; they coexist and interact with static geometry **Interaction** - **Grab:** click near any point to grab it (nearest point within a radius); drag to move it, it follows the cursor with the constraint solver reacting live; release to throw with the drag velocity - **Tear/cut:** right-drag (or hold a modifier) to slice through constraints — cloth rips along the cut line, ropes split - **Pin/unpin:** click a point with a modifier to toggle a pin in place - Pause/step controls (Space to pause, arrow to step one physics frame while paused), reset button, gravity and iterations sliders **Rendering** - Canvas 2D: constraints as lines, points as small dots (hidden by default for cloth — render cloth as a filled mesh or grid lines), soft bodies as filled polygons - Optional stress visualization: color constraints by stretch ratio (green → red) - FPS counter and physics-step time readout ## Visual Style - Dark playground with vibrant materials (each object type a distinct hue), subtle floor/wall rendering - Satisfying feel: slight motion blur trail optional, cursor grab highlight, tear effect - Toolbar minimal and out of the way — the canvas is the app ## Technical Requirements - Vanilla JS, Canvas 2D; all code organized with clear section comments - Fixed-timestep accumulator with a maximum frame delta clamp (tab-switch must not explode the sim) - Tunable constants block at the top (gravity, damping, iterations, timestep, tear radius, colors) - Target: stable 60 FPS with ~1000 constraint points ## Quality Bar (must hit all) - A pendulum rope swings smoothly and settles; cloth hangs from pins and drapes over a static circle without exploding or self-intersecting into chaos - Leaving the tab for 30 seconds and returning does not detonate the simulation - Grabbing and whipping a cloth corner produces a believable wave; releasing throws it - Tearing a rope in the middle leaves two independently swinging halves - No visible jitter on resting stacks; energy visibly decays (nothing oscillates forever) ## Self-Check Before Finishing Verify with the browser tools (expose the sim on `window` — e.g. `sim.points`, `sim.step()` — so it can be probed headlessly): - Drop a soft body from height with gravity on: after settling (~3s of stepped time), its total kinetic energy is near zero and no point is below the floor - Step the sim 1000 frames and assert no coordinate is `NaN` or beyond a sane bound - Pin two cloth corners, step 600 frames, assert the cloth's lowest point stays above the floor and the top pins haven't moved - Tear a rope constraint via the exposed model, step, and assert the halves move independently
Inspect original source 30,529 bytes · SHA-256 8a2b45175330
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Soft-Body Playground</title>
<style>
html,body{margin:0;height:100%;background:#0a0c11;overflow:hidden;font:12px/1.35 system-ui,-apple-system,sans-serif;color:#cfd6e4}
canvas{position:fixed;inset:0;width:100%;height:100%;display:block;cursor:crosshair;touch-action:none}
#bar{position:fixed;left:10px;top:10px;display:flex;flex-direction:column;gap:6px;background:rgba(16,19,27,.85);border:1px solid #242a38;border-radius:8px;padding:8px;width:188px;user-select:none;backdrop-filter:blur(4px)}
#bar h1{font-size:11px;margin:0 0 2px;letter-spacing:.08em;text-transform:uppercase;color:#8d97ab}
.row{display:flex;gap:4px;flex-wrap:wrap;align-items:center}
button,select{background:#1b2130;color:#cfd6e4;border:1px solid #2c3446;border-radius:5px;padding:4px 7px;font:inherit;cursor:pointer}
button:hover,select:hover{border-color:#4a5577}
button.on{border-color:#7aa2ff;background:#243355;color:#fff}
label{display:flex;justify-content:space-between;width:100%;gap:6px;align-items:center}
input[type=range]{width:95px}
.hint{color:#56607a;font-size:10.5px;line-height:1.4}
.sw{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:4px}
#stats{position:fixed;right:10px;top:10px;font:11px/1.45 ui-monospace,monospace;color:#7d879b;background:rgba(0,0,0,.35);padding:5px 8px;border-radius:5px;pointer-events:none;text-align:right}
#help{position:fixed;left:50%;bottom:8px;transform:translateX(-50%);color:#4d566e;font-size:11px;pointer-events:none;white-space:nowrap}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="bar">
<h1>Soft-Body Playground</h1>
<div class="row" id="tools">
<button data-tool="grab" class="on">Grab</button>
<button data-tool="rope"><span class="sw" style="background:#ffb347"></span>Rope</button>
<button data-tool="cloth"><span class="sw" style="background:#4fd1d9"></span>Cloth</button>
<button data-tool="soft"><span class="sw" style="background:#b78cff"></span>Soft</button>
<button data-tool="ball"><span class="sw" style="background:#ff5f7e"></span>Ball</button>
</div>
<label>Soft shape
<select id="shape"><option value="circle">circle</option><option value="box">box</option></select></label>
<label>Cloth pins
<select id="pins"><option value="corners">both corners</option><option value="edge">full edge</option></select></label>
<div class="row">
<button id="pause">Pause</button><button id="step">Step</button><button id="reset">Reset</button>
</div>
<label>Gravity <input type="checkbox" id="grav" checked></label>
<label>Strength <input type="range" id="gstr" min="0" max="2000" value="900"></label>
<label>Iterations <input type="range" id="iters" min="2" max="10" value="6"></label>
<label>Wind <input type="range" id="wind" min="0" max="1500" value="0"></label>
<label>Stress view <input type="checkbox" id="stress"></label>
<label>Motion trail <input type="checkbox" id="trail" checked></label>
<div class="hint">Left-drag: grab / throw<br>Shift+click: pin / unpin<br>Right-drag (or Alt+drag): tear<br>Spawn tools: click (or drag for rope/ball)</div>
</div>
<div id="stats"></div>
<div id="help">Space pause · ←/→/↑/↓ step one frame while paused</div>
<script>
/* =====================================================================
1. TUNABLE CONSTANTS
===================================================================== */
const CFG = {
dt: 1 / 60, // fixed physics timestep (60 Hz)
maxFrameDt: 0.05, // clamp render delta (tab switch must not explode)
maxStepsPerFrame: 8, // spiral-of-death guard
gravity: 900, // px/s^2
damping: 0.995, // Verlet velocity retention per step
iterations: 6, // default constraint relaxation passes (3-8 typical)
maxStep: 25, // max displacement per step (px) — stability clamp
friction: 0.25, // tangential velocity loss on contact
restitution: 0.3, // default bounce (per-point override possible)
tearRadius: 6, // cut reach around cut-line midpoint (px)
grabRadius: 28, // pick radius (px)
grabStiffness: 0.5, // how strongly grabbed point follows cursor per step
throwScale: 1.0, // release velocity multiplier
ropeSeg: 10, // rope link length
clothCols: 20, clothRows: 14, clothSpacing: 12,
softRadius: 46,
softBoxSize: 110,
ballMinR: 12, ballMaxR: 24,
colors: {
rope: [255, 179, 71],
cloth: [79, 209, 217],
soft: [183, 140, 255],
ball: [255, 95, 126],
shear: [79, 209, 217],
static: '#2b3345',
staticEdge: '#55627f',
floor: '#3a4560',
grid: 'rgba(120,140,190,0.045)',
tear: '#ff5252'
}
};
/* =====================================================================
2. ENGINE — Verlet points + distance constraints + PBD relaxation
===================================================================== */
const TAU = Math.PI * 2;
// Segment-segment intersection (orientation test)
function segX(ax, ay, bx, by, cx, cy, dx, dy) {
const o1 = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
const o2 = (bx - ax) * (dy - ay) - (by - ay) * (dx - ax);
if ((o1 > 0) === (o2 > 0) && o1 !== 0 && o2 !== 0) return false;
const o3 = (dx - cx) * (ay - cy) - (dy - cy) * (ax - cx);
const o4 = (dx - cx) * (by - cy) - (dy - cy) * (bx - cx);
if ((o3 > 0) === (o4 > 0) && o3 !== 0 && o4 !== 0) return false;
return true;
}
// Distance from point to segment
function distPtSeg(px, py, ax, ay, bx, by) {
const vx = bx - ax, vy = by - ay;
const L = vx * vx + vy * vy;
let t = L > 1e-9 ? ((px - ax) * vx + (py - ay) * vy) / L : 0;
t = Math.max(0, Math.min(1, t));
return Math.hypot(px - (ax + vx * t), py - (ay + vy * t));
}
const sim = {
W: innerWidth, H: innerHeight,
points: [], constraints: [], bodies: [], statics: [], sparks: [], balls: [],
gravOn: true, gravity: CFG.gravity, wind: 0,
iterations: CFG.iterations,
time: 0, frame: 0, stepMs: 0,
grabbed: null, grabTarget: { x: 0, y: 0 },
dirty: false,
/* ---------- construction helpers ---------- */
addPoint(x, y, rad = 0, bounce = CFG.restitution) {
const p = { x, y, px: x, py: y, inv: 1, pinned: false, grab: false, rad, bounce, body: null };
this.points.push(p);
return p;
},
addCons(a, b, kind, body) {
const rest = Math.max(Math.hypot(b.x - a.x, b.y - a.y), 1e-3);
const c = { a, b, rest, kind, alive: true, body };
this.constraints.push(c);
return c;
},
pin(p, on) {
p.pinned = on;
if (!p.grab) p.inv = on ? 0 : 1;
},
/* ---------- fixed-step physics ---------- */
step() {
const t0 = performance.now();
const dt = CFG.dt, dt2 = dt * dt;
this.time += dt;
const ay = this.gravOn ? this.gravity : 0;
const ax = this.wind * (0.6 + 0.4 * Math.sin(this.time * 1.7));
const pts = this.points;
// 1) Integrate (Verlet): x' = x + (x - xPrev)*damping + a*dt²
for (const p of pts) {
if (p.grab) { // kinematic follow of cursor
p.px = p.x; p.py = p.y;
const t = this.grabTarget;
p.x += (t.x - p.x) * CFG.grabStiffness;
p.y += (t.y - p.y) * CFG.grabStiffness;
continue;
}
if (p.inv === 0) { p.px = p.x; p.py = p.y; continue; } // pinned: no velocity
let vx = (p.x - p.px) * CFG.damping;
let vy = (p.y - p.py) * CFG.damping;
if (!Number.isFinite(vx)) vx = 0;
if (!Number.isFinite(vy)) vy = 0;
p.px = p.x; p.py = p.y;
vx += ax * dt2; vy += ay * dt2;
const sp = Math.hypot(vx, vy);
if (sp > CFG.maxStep) { const s = CFG.maxStep / sp; vx *= s; vy *= s; }
p.x += vx; p.y += vy;
}
// 2) Constraint relaxation interleaved with collisions
for (let k = 0; k < this.iterations; k++) {
this.solveConstraints();
for (const p of pts) this.collide(p);
}
// 3) Sanitize (no NaN escapes)
for (const p of pts) {
if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) {
p.x = p.px = Number.isFinite(p.px) ? p.px : 0;
p.y = p.py = Number.isFinite(p.py) ? p.py : 0;
}
}
if (this.dirty) { this.constraints = this.constraints.filter(c => c.alive); this.dirty = false; }
this.frame++;
this.stepMs = performance.now() - t0;
},
solveConstraints() {
for (const c of this.constraints) {
if (!c.alive) continue;
const a = c.a, b = c.b;
const w = a.inv + b.inv;
if (w === 0) continue; // both pinned / held
let dx = b.x - a.x, dy = b.y - a.y;
let d = Math.sqrt(dx * dx + dy * dy);
if (d < 1e-6) { // coincident: nudge, avoid /0
dx = (Math.random() - 0.5) * 1e-3; dy = (Math.random() - 0.5) * 1e-3;
d = Math.hypot(dx, dy) || 1e-6;
}
const k = ((d - c.rest) / d) / w;
const ux = dx * k, uy = dy * k;
a.x += ux * a.inv; a.y += uy * a.inv;
b.x -= ux * b.inv; b.y -= uy * b.inv;
}
},
/* ---------- collision: walls, floor, ceiling, static shapes ---------- */
collide(p) {
if (p.pinned && !p.grab) return;
const r = p.rad, W = this.W, H = this.H;
if (p.x < r) { p.x = r; this.contact(p, 1, 0); }
if (p.x > W - r) { p.x = W - r; this.contact(p, -1, 0); }
if (p.y < r) { p.y = r; this.contact(p, 0, 1); }
if (p.y > H - r) { p.y = H - r; this.contact(p, 0, -1); }
for (const s of this.statics) {
if (s.type === 'circle') {
const dx = p.x - s.x, dy = p.y - s.y;
const d = Math.sqrt(dx * dx + dy * dy);
const R = s.r + r;
if (d < R) {
let nx = 0, ny = -1;
if (d > 1e-9) { nx = dx / d; ny = dy / d; }
p.x = s.x + nx * R; p.y = s.y + ny * R;
this.contact(p, nx, ny);
}
} else { // axis-aligned box (center s.x,s.y; half sizes hw,hh)
const x0 = s.x - s.hw - r, x1 = s.x + s.hw + r;
const y0 = s.y - s.hh - r, y1 = s.y + s.hh + r;
if (p.x > x0 && p.x < x1 && p.y > y0 && p.y < y1) {
const m = Math.min(p.x - x0, x1 - p.x, p.y - y0, y1 - p.y);
if (m === p.x - x0) { p.x = x0; this.contact(p, -1, 0); }
else if (m === x1 - p.x) { p.x = x1; this.contact(p, 1, 0); }
else if (m === p.y - y0) { p.y = y0; this.contact(p, 0, -1); }
else { p.y = y1; this.contact(p, 0, 1); }
}
}
}
},
// Position already projected onto surface with outward normal (nx,ny).
// Reflect normal velocity (with restitution) and damp tangential (friction).
contact(p, nx, ny) {
const vx = p.x - p.px, vy = p.y - p.py;
const vn = vx * nx + vy * ny;
const vtx = vx - vn * nx, vty = vy - vn * ny;
const nvn = vn < 0 ? -vn * p.bounce : vn;
const f = 1 - CFG.friction;
const nvx = vtx * f + nvn * nx, nvy = vty * f + nvn * ny;
p.px = p.x - nvx; p.py = p.y - nvy;
},
/* ---------- tearing ---------- */
cut(x0, y0, x1, y1) {
for (const c of this.constraints) {
if (!c.alive) continue;
const ax = c.a.x, ay = c.a.y, bx = c.b.x, by = c.b.y;
const mx = (ax + bx) / 2, my = (ay + by) / 2;
if (segX(x0, y0, x1, y1, ax, ay, bx, by) || distPtSeg(mx, my, x0, y0, x1, y1) < CFG.tearRadius) {
c.alive = false;
this.dirty = true;
for (let i = 0; i < 3; i++) this.sparks.push({
x: mx, y: my, vx: (Math.random() - 0.5) * 120, vy: (Math.random() - 0.5) * 120 - 40, life: 1
});
}
}
},
/* ---------- diagnostics ---------- */
kinetic() {
let s = 0;
for (const p of this.points) {
if (p.pinned) continue;
const vx = p.x - p.px, vy = p.y - p.py;
s += vx * vx + vy * vy;
}
return s; // proportional to KE (unit mass, px²/step²)
},
aliveLinks() { let n = 0; for (const c of this.constraints) if (c.alive) n++; return n; },
/* ---------- reset ---------- */
reset() {
this.points = []; this.constraints = []; this.bodies = [];
this.statics = []; this.sparks = []; this.balls = []; this.grabbed = null;
this.time = 0; this.dirty = false;
this.buildStatics();
},
buildStatics() {
const W = this.W, H = this.H;
this.statics.push({ type: 'circle', x: W * 0.5, y: H * 0.62, r: 70 });
this.statics.push({ type: 'box', x: W * 0.2, y: H * 0.78, hw: 120, hh: 10 });
this.statics.push({ type: 'box', x: W * 0.84, y: H * 0.5, hw: 80, hh: 10 });
this.statics.push({ type: 'box', x: W * 0.62, y: H * 0.86, hw: 60, hh: 10 });
}
};
window.sim = sim;
window.CFG = CFG;
/* =====================================================================
3. SPAWNERS — build bodies out of points + constraints
===================================================================== */
let bodySeq = 0;
function makeBody(type, color) {
const b = { id: ++bodySeq, type, color, pts: [], cons: [], quads: null, ring: null };
sim.bodies.push(b);
return b;
}
function addPt(b, x, y, rad, bounce) {
const p = sim.addPoint(x, y, rad, bounce);
p.body = b; b.pts.push(p);
return p;
}
function link(b, a, c, kind) {
const k = sim.addCons(a, c, kind, b);
b.cons.push(k);
return k;
}
// Rope: chain of N points pinned at the start. Click (no drag) = hanging rope.
function spawnRope(x0, y0, x1, y1) {
let dx = x1 - x0, dy = y1 - y0, L = Math.hypot(dx, dy);
let ux, uy;
if (L < 20) { ux = 0; uy = 1; L = 300; } // click without drag: hang straight down
else { ux = dx / L; uy = dy / L; }
const n = Math.max(4, Math.round(L / CFG.ropeSeg)), seg = L / n;
const b = makeBody('rope', CFG.colors.rope);
for (let i = 0; i <= n; i++) addPt(b, x0 + ux * seg * i, y0 + uy * seg * i, 2.5, 0.2);
for (let i = 0; i < n; i++) link(b, b.pts[i], b.pts[i + 1], 'rope');
sim.pin(b.pts[0], true);
return b;
}
// Cloth: grid with structural (h/v) + shear (diagonal) links. Quads drive rendering so tears rip cleanly.
function spawnCloth(cx, cy, pinMode) {
const cols = CFG.clothCols, rows = CFG.clothRows, s = CFG.clothSpacing;
const x0 = cx - (cols - 1) * s / 2, y0 = cy;
const b = makeBody('cloth', CFG.colors.cloth);
const g = [];
for (let j = 0; j < rows; j++) {
g[j] = [];
for (let i = 0; i < cols; i++) g[j][i] = addPt(b, x0 + i * s, y0 + j * s, 2, 0.1);
}
const Hl = [], Vl = [];
for (let j = 0; j < rows; j++) {
Hl[j] = []; Vl[j] = [];
for (let i = 0; i < cols; i++) {
if (i + 1 < cols) Hl[j][i] = link(b, g[j][i], g[j][i + 1], 'cloth');
if (j + 1 < rows) Vl[j][i] = link(b, g[j][i], g[j + 1][i], 'cloth');
if (i + 1 < cols && j + 1 < rows) {
link(b, g[j][i], g[j + 1][i + 1], 'shear');
link(b, g[j][i + 1], g[j + 1][i], 'shear');
}
}
}
b.quads = [];
for (let j = 0; j + 1 < rows; j++) for (let i = 0; i + 1 < cols; i++) {
b.quads.push({
p: [g[j][i], g[j][i + 1], g[j + 1][i + 1], g[j + 1][i]],
L: [Hl[j][i], Hl[j + 1][i], Vl[j][i], Vl[j][i + 1]]
});
}
if (pinMode === 'edge') for (let i = 0; i < cols; i++) sim.pin(g[0][i], true);
else { sim.pin(g[0][0], true); sim.pin(g[0][cols - 1], true); }
return b;
}
// Soft body: circle = outer ring + inner ring + center with braces; box = 4x4 grid with diagonals.
function spawnSoft(cx, cy, shape) {
const b = makeBody('soft', CFG.colors.soft);
if (shape === 'box') {
const S = CFG.softBoxSize, n = 4, sp = S / (n - 1), x0 = cx - S / 2, y0 = cy - S / 2;
const g = [];
for (let j = 0; j < n; j++) {
g[j] = [];
for (let i = 0; i < n; i++) g[j][i] = addPt(b, x0 + i * sp, y0 + j * sp, 3, 0.2);
}
for (let j = 0; j < n; j++) for (let i = 0; i < n; i++) {
if (i + 1 < n) link(b, g[j][i], g[j][i + 1], 'soft');
if (j + 1 < n) link(b, g[j][i], g[j + 1][i], 'soft');
if (i + 1 < n && j + 1 < n) {
link(b, g[j][i], g[j + 1][i + 1], 'brace');
link(b, g[j][i + 1], g[j + 1][i], 'brace');
}
}
const ring = [];
for (let i = 0; i < n; i++) ring.push(g[0][i]);
for (let j = 1; j < n; j++) ring.push(g[j][n - 1]);
for (let i = n - 2; i >= 0; i--) ring.push(g[n - 1][i]);
for (let j = n - 2; j >= 1; j--) ring.push(g[j][0]);
b.ring = ring;
} else {
const R = CFG.softRadius, N = 16, M = 8;
const outer = [], inner = [];
for (let i = 0; i < N; i++) {
const a = i / N * TAU;
outer.push(addPt(b, cx + Math.cos(a) * R, cy + Math.sin(a) * R, 4, 0.2));
}
for (let i = 0; i < M; i++) {
const a = i / M * TAU;
inner.push(addPt(b, cx + Math.cos(a) * R * 0.5, cy + Math.sin(a) * R * 0.5, 3, 0.2));
}
const center = addPt(b, cx, cy, 3, 0.2);
for (let i = 0; i < N; i++) {
link(b, outer[i], outer[(i + 1) % N], 'soft');
link(b, outer[i], outer[(i + 2) % N], 'brace');
link(b, outer[i], inner[Math.floor(i / 2) % M], 'brace');
link(b, outer[i], inner[(Math.floor(i / 2) + 1) % M], 'brace');
}
for (let i = 0; i < M; i++) {
link(b, inner[i], inner[(i + 1) % M], 'soft');
link(b, inner[i], center, 'brace');
}
b.ring = outer;
}
return b;
}
// Ball: single radial particle; interacts with other points via collideBalls().
function spawnBall(x, y, vx, vy) {
const r = CFG.ballMinR + Math.random() * (CFG.ballMaxR - CFG.ballMinR);
const b = makeBody('ball', CFG.colors.ball);
const p = addPt(b, x, y, r, 0.6);
p.px = x - vx * CFG.dt; p.py = y - vy * CFG.dt; // vx,vy in px/s
sim.balls.push(p);
return b;
}
/* =====================================================================
4. INPUT — grab / throw, tear, pin, spawn
===================================================================== */
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let dpr = 1;
const ui = { tool: 'grab', paused: false, stress: false, trail: true, pinMode: 'corners', shape: 'circle' };
const ptr = { x: 0, y: 0, down: false, mode: null, sx: 0, sy: 0, lx: 0, ly: 0, hover: null };
function nearestPoint(x, y, R) {
let best = null, bd = R * R;
for (const p of sim.points) {
const dx = p.x - x, dy = p.y - y, d = dx * dx + dy * dy;
if (d < bd) { bd = d; best = p; }
}
return best;
}
canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('pointerdown', e => {
if (e.button > 2) return;
canvas.setPointerCapture(e.pointerId);
const x = e.clientX, y = e.clientY;
ptr.down = true; ptr.sx = ptr.lx = ptr.x = x; ptr.sy = ptr.ly = ptr.y = y;
if (e.button === 2 || (e.altKey && e.button === 0)) { ptr.mode = 'cut'; sim.cut(x, y, x + 0.01, y); return; }
if (ui.tool !== 'grab') {
ptr.mode = 'spawn';
if (ui.tool === 'cloth') spawnCloth(x, y, ui.pinMode);
else if (ui.tool === 'soft') spawnSoft(x, y, ui.shape);
return;
}
if (e.shiftKey) {
const p = nearestPoint(x, y, CFG.grabRadius);
if (p) sim.pin(p, !p.pinned);
ptr.mode = 'pinned';
return;
}
const p = nearestPoint(x, y, CFG.grabRadius);
if (p) {
ptr.mode = 'grab';
sim.grabbed = p; p.grab = true; p.inv = 0; // kinematic while held
sim.grabTarget.x = x; sim.grabTarget.y = y;
} else ptr.mode = null;
});
canvas.addEventListener('pointermove', e => {
const x = e.clientX, y = e.clientY;
ptr.x = x; ptr.y = y;
if (!ptr.down) { ptr.hover = nearestPoint(x, y, CFG.grabRadius); return; }
if (ptr.mode === 'grab') { sim.grabTarget.x = x; sim.grabTarget.y = y; }
else if (ptr.mode === 'cut') sim.cut(ptr.lx, ptr.ly, x, y);
ptr.lx = x; ptr.ly = y;
});
function endPointer(e) {
if (!ptr.down) return;
ptr.down = false;
ptr.x = e.clientX; ptr.y = e.clientY;
if (ptr.mode === 'grab' && sim.grabbed) {
const g = sim.grabbed;
g.grab = false; g.inv = g.pinned ? 0 : 1; // release: Verlet velocity carries the throw
sim.grabbed = null;
}
if (ptr.mode === 'spawn') {
if (ui.tool === 'rope') spawnRope(ptr.sx, ptr.sy, ptr.x, ptr.y);
else if (ui.tool === 'ball') spawnBall(ptr.sx, ptr.sy, (ptr.x - ptr.sx) * 3, (ptr.y - ptr.sy) * 3);
}
ptr.mode = null;
}
canvas.addEventListener('pointerup', endPointer);
canvas.addEventListener('pointercancel', endPointer);
/* =====================================================================
5. RENDERING — canvas 2D
===================================================================== */
function ratioOf(c) { return Math.hypot(c.b.x - c.a.x, c.b.y - c.a.y) / c.rest; }
function stressColor(r, a) {
// 0 deviation = green, >=20% stretch/compress = red
const s = Math.min(1, Math.abs(r - 1) / 0.2);
return `rgba(${(90 + 165 * s) | 0},${(230 - 170 * s) | 0},${(140 - 100 * s) | 0},${a})`;
}
function rgba(arr, a) { return `rgba(${arr[0]},${arr[1]},${arr[2]},${a})`; }
function seg(c) {
ctx.beginPath(); ctx.moveTo(c.a.x, c.a.y); ctx.lineTo(c.b.x, c.b.y); ctx.stroke();
}
function drawCloth(b) {
const C = CFG.colors.cloth;
for (const q of b.quads) {
const L = q.L;
if (!(L[0].alive && L[1].alive && L[2].alive && L[3].alive)) continue;
const p = q.p;
ctx.beginPath();
ctx.moveTo(p[0].x, p[0].y); ctx.lineTo(p[1].x, p[1].y);
ctx.lineTo(p[2].x, p[2].y); ctx.lineTo(p[3].x, p[3].y);
ctx.closePath();
ctx.fillStyle = ui.stress
? stressColor((ratioOf(L[0]) + ratioOf(L[1]) + ratioOf(L[2]) + ratioOf(L[3])) / 4, 0.4)
: rgba(C, 0.16);
ctx.fill();
}
ctx.lineWidth = 1;
for (const c of b.cons) {
if (!c.alive) continue;
if (c.kind === 'shear' && !ui.stress) continue;
ctx.strokeStyle = ui.stress ? stressColor(ratioOf(c), 0.95) : rgba(C, 0.6);
seg(c);
}
}
function drawRope(b) {
const C = CFG.colors.rope;
ctx.lineCap = 'round';
for (const c of b.cons) {
if (!c.alive) continue;
ctx.lineWidth = 2.6;
ctx.strokeStyle = ui.stress ? stressColor(ratioOf(c), 0.95) : rgba(C, 0.9);
seg(c);
}
ctx.fillStyle = rgba(C, 1);
for (const p of b.pts) { ctx.beginPath(); ctx.arc(p.x, p.y, 1.9, 0, TAU); ctx.fill(); }
}
function drawSoft(b) {
const C = CFG.colors.soft, ring = b.ring;
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 = rgba(C, 0.22);
ctx.fill();
for (const c of b.cons) {
if (!c.alive) continue;
ctx.lineWidth = c.kind === 'brace' ? 0.9 : 1.6;
ctx.strokeStyle = ui.stress ? stressColor(ratioOf(c), 0.95) : rgba(C, c.kind === 'brace' ? 0.35 : 0.95);
seg(c);
}
ctx.fillStyle = rgba(C, 1);
for (const p of b.pts) { ctx.beginPath(); ctx.arc(p.x, p.y, 1.8, 0, TAU); ctx.fill(); }
}
function drawBall(b) {
const p = b.pts[0], r = p.rad;
const g = ctx.createRadialGradient(p.x - r * 0.35, p.y - r * 0.35, r * 0.1, p.x, p.y, r);
g.addColorStop(0, '#ffd3dc'); g.addColorStop(0.45, CFG.colors.ball.length ? '#ff5f7e' : '#ff5f7e'); g.addColorStop(1, '#7d1f36');
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, TAU);
ctx.fillStyle = g; ctx.fill();
}
function render() {
const W = sim.W, H = sim.H, C = CFG.colors;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (ui.trail) { ctx.fillStyle = 'rgba(10,12,17,0.3)'; ctx.fillRect(0, 0, W, H); }
else { ctx.fillStyle = '#0a0c11'; ctx.fillRect(0, 0, W, H); }
// subtle grid
ctx.strokeStyle = C.grid; ctx.lineWidth = 1; ctx.beginPath();
for (let x = 0; x < W; x += 40) { ctx.moveTo(x + 0.5, 0); ctx.lineTo(x + 0.5, H); }
for (let y = 0; y < H; y += 40) { ctx.moveTo(0, y + 0.5); ctx.lineTo(W, y + 0.5); }
ctx.stroke();
// floor + frame
const fg = ctx.createLinearGradient(0, H - 50, 0, H);
fg.addColorStop(0, 'rgba(58,69,96,0)'); fg.addColorStop(1, 'rgba(58,69,96,0.55)');
ctx.fillStyle = fg; ctx.fillRect(0, H - 50, W, 50);
ctx.fillStyle = C.floor; ctx.fillRect(0, H - 3, W, 3);
ctx.strokeStyle = 'rgba(85,98,127,0.35)'; ctx.lineWidth = 1; ctx.strokeRect(0.5, 0.5, W - 1, H - 1);
// static geometry
for (const s of sim.statics) {
ctx.fillStyle = C.static; ctx.strokeStyle = C.staticEdge; ctx.lineWidth = 2;
if (s.type === 'circle') {
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, TAU); ctx.fill(); ctx.stroke();
} else {
ctx.fillRect(s.x - s.hw, s.y - s.hh, s.hw * 2, s.hh * 2);
ctx.strokeRect(s.x - s.hw, s.y - s.hh, s.hw * 2, s.hh * 2);
}
}
// dynamic bodies
for (const b of sim.bodies) {
if (b.type === 'cloth') drawCloth(b);
else if (b.type === 'rope') drawRope(b);
else if (b.type === 'soft') drawSoft(b);
else if (b.type === 'ball') drawBall(b);
}
// pin markers
ctx.fillStyle = '#e8ecf5';
for (const p of sim.points) {
if (!p.pinned) continue;
ctx.fillRect(p.x - 3, p.y - 3, 6, 6);
}
// tear sparks
for (const s of sim.sparks) {
ctx.fillStyle = rgba([255, 82, 82], Math.max(0, s.life));
ctx.beginPath(); ctx.arc(s.x, s.y, 1.6, 0, TAU); ctx.fill();
}
// interaction overlays
if (ptr.mode === 'cut') {
ctx.strokeStyle = C.tear; ctx.lineWidth = 1.5; ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.arc(ptr.x, ptr.y, CFG.tearRadius, 0, TAU); ctx.stroke();
ctx.setLineDash([]);
} else if (ptr.mode === 'spawn' && ui.tool === 'rope') {
ctx.strokeStyle = 'rgba(255,179,71,0.6)'; ctx.setLineDash([3, 4]); ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(ptr.sx, ptr.sy); ctx.lineTo(ptr.x, ptr.y); ctx.stroke(); ctx.setLineDash([]);
} else if (ptr.mode === 'spawn' && ui.tool === 'ball') {
ctx.strokeStyle = 'rgba(255,95,126,0.6)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(ptr.sx, ptr.sy); ctx.lineTo(ptr.x, ptr.y); ctx.stroke();
}
const hl = sim.grabbed || (!ptr.down ? ptr.hover : null);
if (hl) {
ctx.strokeStyle = sim.grabbed ? 'rgba(255,255,255,0.85)' : 'rgba(255,255,255,0.3)';
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(hl.x, hl.y, sim.grabbed ? 13 : 9, 0, TAU); ctx.stroke();
}
}
/* =====================================================================
6. SCENE, UI WIRING, MAIN LOOP
===================================================================== */
function spawnDemo() {
const W = sim.W, H = sim.H;
spawnRope(W * 0.12, 60, W * 0.12, 60 + 260); // hanging rope (pendulum)
spawnRope(W * 0.24, 60, W * 0.24 + 200, 60); // horizontal rope
spawnCloth(W * 0.5, 110, 'corners'); // draped over static circle
spawnSoft(W * 0.2, H * 0.3, 'circle');
spawnSoft(W * 0.74, H * 0.2, 'box');
spawnBall(W * 0.66, H * 0.4, 0, 0);
}
function resetScene() {
sim.reset();
spawnDemo();
}
function sizeCanvas() {
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(innerWidth * dpr);
canvas.height = Math.round(innerHeight * dpr);
}
window.addEventListener('resize', () => {
const ox = sim.W, oy = sim.H;
sim.W = innerWidth; sim.H = innerHeight;
sizeCanvas();
const sx = sim.W / ox, sy = sim.H / oy;
for (const s of sim.statics) { s.x *= sx; s.y *= sy; }
});
// UI
const toolBtns = document.querySelectorAll('[data-tool]');
toolBtns.forEach(btn => btn.addEventListener('click', () => {
ui.tool = btn.dataset.tool;
toolBtns.forEach(b => b.classList.toggle('on', b === btn));
btn.blur();
}));
const pauseBtn = document.getElementById('pause');
function setPaused(v) {
ui.paused = v;
pauseBtn.textContent = v ? 'Resume' : 'Pause';
pauseBtn.classList.toggle('on', v);
}
pauseBtn.addEventListener('click', () => { setPaused(!ui.paused); pauseBtn.blur(); });
document.getElementById('step').addEventListener('click', e => {
setPaused(true); sim.step(); e.currentTarget.blur();
});
document.getElementById('reset').addEventListener('click', e => { resetScene(); e.currentTarget.blur(); });
document.getElementById('grav').addEventListener('change', e => { sim.gravOn = e.target.checked; });
document.getElementById('gstr').addEventListener('input', e => { sim.gravity = +e.target.value; });
document.getElementById('iters').addEventListener('input', e => { sim.iterations = +e.target.value; });
document.getElementById('wind').addEventListener('input', e => { sim.wind = +e.target.value; });
document.getElementById('shape').addEventListener('change', e => { ui.shape = e.target.value; });
document.getElementById('pins').addEventListener('change', e => { ui.pinMode = e.target.value; });
document.getElementById('stress').addEventListener('change', e => { ui.stress = e.target.checked; });
document.getElementById('trail').addEventListener('change', e => { ui.trail = e.target.checked; });
addEventListener('keydown', e => {
if (e.code === 'Space') { e.preventDefault(); setPaused(!ui.paused); }
else if (ui.paused && ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(e.code)) {
e.preventDefault(); sim.step();
}
});
// Main loop: fixed-step accumulator, clamped frame delta
let acc = 0, last = performance.now();
let fpsFrames = 0, fpsT = 0, fps = 60, stepAvg = 0, statsT = 0;
function frame(now) {
let dt = (now - last) / 1000; last = now;
if (!(dt > 0)) dt = 0;
dt = Math.min(dt, CFG.maxFrameDt); // tab switch / hitch: never big catch-up
if (!ui.paused) {
acc += dt;
let n = 0;
while (acc >= CFG.dt && n < CFG.maxStepsPerFrame) {
sim.step();
stepAvg = stepAvg * 0.9 + sim.stepMs * 0.1;
acc -= CFG.dt; n++;
}
if (n >= CFG.maxStepsPerFrame) acc = 0; // drop backlog instead of spiraling
} else acc = 0;
// sparks (render-side particles)
for (const s of sim.sparks) {
s.x += s.vx * dt; s.y += s.vy * dt; s.vy += 500 * dt; s.life -= dt * 2.5;
}
sim.sparks = sim.sparks.filter(s => s.life > 0);
render();
fpsFrames++; fpsT += dt;
if (fpsT >= 0.5) { fps = fpsFrames / fpsT; fpsFrames = 0; fpsT = 0; }
statsT += dt;
if (statsT > 0.2) {
statsT = 0;
document.getElementById('stats').innerHTML =
`${fps.toFixed(0)} fps<br>step ${stepAvg.toFixed(2)} ms<br>` +
`${sim.points.length} pts · ${sim.aliveLinks()} links<br>` +
(ui.paused ? 'PAUSED' : '');
}
requestAnimationFrame(frame);
}
sizeCanvas();
resetScene();
requestAnimationFrame(frame);
document.addEventListener('visibilitychange', () => { last = performance.now(); });
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"87041808-cde4-4539-bb91-6cd8cecd8de6","tokensIn":371459,"tokensOut":144594,"tokensTotal":516053,"cost":0.08077518999999998,"turns":13,"toolCalls":16,"failedToolCalls":1,"timestamp":"2026-10-08T02:52:16.969Z"} -->