Physics Playground
Qwen 3.6 27b MTP · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 36,220 bytes · SHA-256 06c1ad0dd09b
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>2D Soft-Body Physics Playground</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #1a1a2e; overflow: hidden; font-family: 'Segoe UI', system-ui, sans-serif; color: #eee; user-select: none; }
canvas { display: block; cursor: crosshair; }
/* Toolbar */
#toolbar {
position: fixed; top: 0; left: 0; right: 0; height: 48px;
background: rgba(16, 16, 32, 0.92); backdrop-filter: blur(8px);
display: flex; align-items: center; padding: 0 12px; gap: 6px;
border-bottom: 1px solid rgba(255,255,255,0.08); z-index: 10;
}
#toolbar .sep { width: 1px; height: 28px; background: rgba(255,255,255,0.1); margin: 0 4px; }
.tb-btn {
padding: 6px 14px; border: 1px solid rgba(255,255,255,0.12); border-radius: 6px;
background: rgba(255,255,255,0.05); color: #ccc; font-size: 13px; cursor: pointer;
transition: all 0.15s; white-space: nowrap;
}
.tb-btn:hover { background: rgba(255,255,255,0.12); color: #fff; }
.tb-btn.active { background: rgba(100,140,255,0.25); border-color: rgba(100,140,255,0.5); color: #8ab4ff; }
.tb-label { font-size: 11px; color: #777; margin-right: 2px; }
/* Sliders */
.slider-group { display: flex; align-items: center; gap: 6px; }
.slider-group label { font-size: 11px; color: #888; white-space: nowrap; }
.slider-group input[type=range] { width: 80px; height: 4px; accent-color: #5a7fff; cursor: pointer; }
/* HUD */
#hud {
position: fixed; bottom: 12px; left: 12px; font-size: 11px; color: rgba(255,255,255,0.4);
font-family: 'Courier New', monospace; pointer-events: none; z-index: 10;
}
/* Help overlay */
#help {
position: fixed; bottom: 12px; right: 12px; font-size: 11px; color: rgba(255,255,255,0.3);
text-align: right; pointer-events: none; z-index: 10; line-height: 1.6;
}
/* Pin mode indicator */
#pinMode { font-size: 11px; color: #ff9f43; }
</style>
</head>
<body>
<div id="toolbar">
<button class="tb-btn active" data-tool="rope">Rope</button>
<button class="tb-btn" data-tool="cloth">Cloth</button>
<button class="tb-btn" data-tool="softbody">Soft Body</button>
<button class="tb-btn" data-tool="ball">Ball</button>
<div class="sep"></div>
<button class="tb-btn" id="btnPause">⏸ Pause</button>
<button class="tb-btn" id="btnStep">⏭ Step</button>
<button class="tb-btn" id="btnReset">↺ Reset</button>
<div class="sep"></div>
<div class="slider-group">
<label>Gravity</label>
<input type="range" id="slGravity" min="0" max="2000" step="10" value="980">
</div>
<div class="slider-group">
<label>Damping</label>
<input type="range" id="slDamping" min="900" max="1000" step="1" value="985">
</div>
<div class="slider-group">
<label>Iterations</label>
<input type="range" id="slIter" min="1" max="20" step="1" value="6">
</div>
<div class="sep"></div>
<button class="tb-btn" id="btnStress">Stress</button>
<span id="pinMode"></span>
</div>
<canvas id="canvas"></canvas>
<div id="hud"></div>
<div id="help">
Left-drag: spawn / grab<br>
Right-drag: tear constraints<br>
Shift+click: pin/unpin point<br>
Space: pause · ← →: step
</div>
<script>
// ============================================================
// TUNABLE CONSTANTS
// ============================================================
const CFG = {
gravity: 980, // pixels/s² downward
damping: 0.985, // velocity multiplier per step (1 - friction)
iterations: 6, // constraint relaxation passes per physics step
timestep: 1 / 60, // fixed physics dt in seconds
maxFrameDelta: 0.2, // clamp accumulator to prevent explosion on tab-switch
grabRadius: 15, // pixels — snap to nearest point within this radius
tearRadius: 12, // pixels — remove constraints whose midpoint is within this of the cut line
ropeSegments: 30, // number of segments in a spawned rope
clothCols: 24, // columns for cloth grid
clothRows: 18, // rows for cloth grid
softBodyRadius: 60, // radius of spawned soft body
softBodyPoints: 16, // points around the perimeter
ballRadius: 20, // radius of spawned ball
wallRestitution: 0.15, // velocity retained after bounce (very dissipative)
sleepThreshold: 1.2, // pixels/frame — below this, point "sleeps" (velocity zeroed)
wallFriction: 0.2, // tangential friction on walls
staticCircleRadius: 50, // radius of static obstacle circles
// Colors per object type (HSL base hues)
colors: {
rope: { h: 210, s: 80, l: 60 }, // blue
cloth: { h: 340, s: 70, l: 55 }, // pink/magenta
softbody: { h: 150, s: 70, l: 50 }, // green
ball: { h: 40, s: 90, l: 60 }, // yellow/orange
},
stressColors: true, // show stress visualization when enabled
};
// ============================================================
// SIMULATION STATE (exposed on window for probing)
// ============================================================
const sim = {
points: [],
constraints: [],
staticCircles: [],
staticBoxes: [],
clothGrids: [], // array of { grid: Point[][], cols, rows } for rendering filled mesh
softBodyGroups: [], // array of Point[] for each spawned soft body (for grouped fill)
paused: false,
stepCount: 0,
};
// ============================================================
// POINT CLASS
// ============================================================
class Point {
constructor(x, y, pinned = false) {
this.x = x;
this.y = y;
this.px = x; // previous position (for Verlet)
this.py = y;
this.pinned = pinned;
this.mass = 1;
this.radius = 0; // for balls and collision
this.type = 'default'; // rope, cloth, softbody, ball
this._vx = 0; // cached velocity (computed on demand)
this._vy = 0;
}
get vx() { return this.x - this.px; }
get vy() { return this.y - this.py; }
kineticEnergy() {
const v2 = (this.x - this.x) * (this.x - this.px) + (this.y - this.py) * (this.y - this.py);
// Actually compute properly:
const vx = (this.x - this.px) / CFG.timestep;
const vy = (this.y - this.py) / CFG.timestep;
return 0.5 * this.mass * (vx * vx + vy * vy);
}
speed() {
const vx = (this.x - this.px) / CFG.timestep;
const vy = (this.y - this.py) / CFG.timestep;
return Math.sqrt(vx * vx + vy * vy);
}
}
// ============================================================
// CONSTRAINT CLASS
// ============================================================
class Constraint {
constructor(p1, p2, restLength = null, stiffness = 1.0) {
this.p1 = p1;
this.p2 = p2;
if (restLength === null) {
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
restLength = Math.sqrt(dx * dx + dy * dy);
}
this.restLength = Math.max(restLength, 0.001); // guard zero-length
this.stiffness = stiffness;
this.broken = false;
this.type = 'default';
}
get stretchRatio() {
const dx = this.p2.x - this.p1.x;
const dy = this.p2.y - this.p1.y;
const dist = Math.sqrt(dx * dx + dy * dy);
return dist / this.restLength;
}
}
// ============================================================
// PHYSICS ENGINE
// ============================================================
function integrateVerlet(dt) {
const dtSq = dt * dt;
for (const p of sim.points) {
if (p.pinned) continue;
const vx = (p.x - p.px) * CFG.damping;
const vy = (p.y - p.py) * CFG.damping;
p.px = p.x;
p.py = p.y;
p.x += vx;
p.y += vy + CFG.gravity * dtSq;
}
}
function solveConstraints() {
const restMult = CFG.wallRestitution; // velocity retained after bounce
for (let iter = 0; iter < CFG.iterations; iter++) {
// Distance constraints — with slight damping per iteration to dissipate energy
for (const c of sim.constraints) {
if (c.broken) continue;
const dx = c.p2.x - c.p1.x;
const dy = c.p2.y - c.p1.y;
const distSq = dx * dx + dy * dy;
if (distSq < 0.0001 || distSq > 1e8) continue; // guard degenerate cases
const dist = Math.sqrt(distSq);
const diff = (c.restLength - dist) / dist;
const correction = diff * 0.5 * c.stiffness;
if (!c.p1.pinned && !c.p2.pinned) {
// Apply slight damping to relative velocity along constraint axis
const nx = dx / dist, ny = dy / dist;
const relVx = (c.p2.x - c.p2.px) - (c.p1.x - c.p1.px);
const relVy = (c.p2.y - c.p2.py) - (c.p1.y - c.p1.py);
const relVelAlong = relVx * nx + relVy * ny;
const dampFactor = 0.985; // slight damping along constraint
c.p1.x -= dx * correction + nx * relVelAlong * (1 - dampFactor) * 0.5;
c.p1.y -= dy * correction + ny * relVelAlong * (1 - dampFactor) * 0.5;
c.p2.x += dx * correction - nx * relVelAlong * (1 - dampFactor) * 0.5;
c.p2.y += dy * correction - ny * relVelAlong * (1 - dampFactor) * 0.5;
} else if (c.p1.pinned && !c.p2.pinned) {
c.p2.x += dx * correction;
c.p2.y += dy * correction;
} else if (!c.p1.pinned && c.p2.pinned) {
c.p1.x -= dx * correction;
c.p1.y -= dy * correction;
}
}
// Wall collisions — position correction + velocity damping via prev-pos manipulation
for (const p of sim.points) {
if (p.pinned) continue;
const r = p.radius || 0;
const vx = p.x - p.px;
const vy = p.y - p.py;
// Floor
if (p.y + r > canvas.height) {
p.y = canvas.height - r;
// Dampen normal velocity, apply tangential friction
p.py = p.y + Math.abs(vy) * restMult;
p.px = p.x - vx * (1 - CFG.wallFriction);
}
// Ceiling
if (p.y - r < 0) {
p.y = r;
p.py = p.y - Math.abs(vy) * restMult;
}
// Left wall
if (p.x - r < 0) {
p.x = r;
p.px = p.x + Math.abs(vx) * restMult;
p.py = p.y - vy * (1 - CFG.wallFriction);
}
// Right wall
if (p.x + r > canvas.width) {
p.x = canvas.width - r;
p.px = p.x - Math.abs(vx) * restMult;
p.py = p.y - vy * (1 - CFG.wallFriction);
}
// Static circles — push out + dampen velocity
for (const sc of sim.staticCircles) {
const cdx = p.x - sc.x;
const cdy = p.y - sc.y;
const cdistSq = cdx * cdx + cdy * cdy;
const minDist = sc.radius + r;
if (cdistSq < minDist * minDist && cdistSq > 0.0001) {
const cdist = Math.sqrt(cdistSq);
const nx = cdx / cdist;
const ny = cdy / cdist;
const overlap = minDist - cdist;
// Position correction
p.x += nx * overlap;
p.y += ny * overlap;
// Velocity damping: decompose into normal and tangential
const cvx = p.x - p.px;
const cvy = p.y - p.py;
const nVel = cvx * nx + cvy * ny;
if (nVel < 0) {
// Moving toward obstacle — reflect with restitution + friction
const tx = -ny, ty = nx;
const tVel = cvx * tx + cvy * ty;
p.px = p.x - (-nVel * restMult * nx + tVel * (1 - CFG.wallFriction) * tx);
p.py = p.y - (-nVel * restMult * ny + tVel * (1 - CFG.wallFriction) * ty);
}
}
}
// Static boxes — SAT-style push out
for (const sb of sim.staticBoxes) {
const cx = Math.max(sb.x, Math.min(p.x, sb.x + sb.w));
const cy = Math.max(sb.y, Math.min(p.y, sb.y + sb.h));
const bdx = p.x - cx;
const bdy = p.y - cy;
const bdistSq = bdx * bdx + bdy * bdy;
if (bdistSq < r * r && bdistSq > 0.001) {
const bdist = Math.sqrt(bdistSq);
const nx = bdx / bdist;
const ny = bdy / bdist;
p.x += nx * (r - bdist);
p.y += ny * (r - bdist);
// Dampen velocity on collision
const cvx = p.x - p.px;
const cvy = p.y - p.py;
const nVel = cvx * nx + cvy * ny;
if (nVel < 0) {
p.px = p.x - cvx * restMult;
p.py = p.y - cvy * restMult;
}
} else if (bdistSq <= 0.001) {
// Inside box — push out along shortest axis
const dLeft = p.x - sb.x;
const dRight = sb.x + sb.w - p.x;
const dTop = p.y - sb.y;
const dBottom = sb.y + sb.h - p.y;
const minD = Math.min(dLeft, dRight, dTop, dBottom);
if (minD === dLeft) { p.x = sb.x - r; p.px = p.x + (p.x - p.px) * restMult; }
else if (minD === dRight) { p.x = sb.x + sb.w + r; p.px = p.x + (p.x - p.px) * restMult; }
else if (minD === dTop) { p.y = sb.y - r; p.py = p.y + (p.y - p.py) * restMult; }
else { p.y = sb.y + sb.h + r; p.py = p.y + (p.y - p.py) * restMult; }
}
}
}
// Grab constraint: force grabbed point to follow mouse
if (grabbedPoint && grabActive) {
grabbedPoint.x = mouseX;
grabbedPoint.y = mouseY;
}
}
}
function clampPositions() {
// Final position clamp + sleep detection — ensures no point escapes bounds and kills micro-oscillations
for (const p of sim.points) {
if (p.pinned) continue;
const r = p.radius || 0;
// Sleep: zero out velocity below threshold to kill jitter
const vx = p.x - p.px;
const vy = p.y - p.py;
const speed = Math.sqrt(vx * vx + vy * vy);
if (speed < CFG.sleepThreshold) {
p.px = p.x;
p.py = p.y;
}
// Clamp to bounds
if (p.y + r > canvas.height) {
p.y = canvas.height - r;
if ((p.y - p.py) < 0) p.py = p.y;
}
if (p.x - r < 0) { p.x = r; if ((p.x - p.px) > 0) p.px = p.x; }
if (p.x + r > canvas.width) { p.x = canvas.width - r; if ((p.x - p.px) < 0) p.px = p.x; }
if (p.y - r < 0) { p.y = r; if ((p.y - p.py) > 0) p.py = p.y; }
}
}
function physicsStep() {
integrateVerlet(CFG.timestep);
solveConstraints();
clampPositions();
sim.stepCount++;
}
// ============================================================
// SPAWNING FUNCTIONS
// ============================================================
function spawnRope(x1, y1, x2, y2) {
const n = CFG.ropeSegments;
const dx = (x2 - x1) / n;
const dy = (y2 - y1) / n;
const pts = [];
for (let i = 0; i <= n; i++) {
const p = new Point(x1 + dx * i, y1 + dy * i);
p.type = 'rope';
if (i === 0) p.pinned = true;
sim.points.push(p);
pts.push(p);
}
for (let i = 0; i < n; i++) {
const c = new Constraint(pts[i], pts[i + 1]);
c.type = 'rope';
sim.constraints.push(c);
}
}
function spawnCloth(cx, cy, cols, rows) {
cols = cols || CFG.clothCols;
rows = rows || CFG.clothRows;
const spacing = 12;
const w = (cols - 1) * spacing;
const h = (rows - 1) * spacing;
const startX = cx - w / 2;
const startY = cy - h / 2;
const grid = [];
for (let r = 0; r < rows; r++) {
grid[r] = [];
for (let c = 0; c < cols; c++) {
const p = new Point(startX + c * spacing, startY + r * spacing);
p.type = 'cloth';
// Pin top edge: both corners or full edge
if (r === 0 && (c === 0 || c === cols - 1)) p.pinned = true;
sim.points.push(p);
grid[r][c] = p;
}
}
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
// Structural constraints (horizontal + vertical)
if (c < cols - 1) {
const con = new Constraint(grid[r][c], grid[r][c + 1]);
con.type = 'cloth';
sim.constraints.push(con);
}
if (r < rows - 1) {
const con = new Constraint(grid[r][c], grid[r + 1][c]);
con.type = 'cloth';
sim.constraints.push(con);
}
// Shear constraints (diagonal) — every other cell for stability
if (r < rows - 1 && c < cols - 1) {
const con = new Constraint(grid[r][c], grid[r + 1][c + 1]);
con.type = 'cloth';
sim.constraints.push(con);
}
}
}
// Store grid reference for rendering filled mesh
sim.clothGrids.push({ grid, cols, rows });
}
function spawnSoftBody(cx, cy, radius, nPoints) {
radius = radius || CFG.softBodyRadius;
nPoints = nPoints || CFG.softBodyPoints;
const pts = [];
for (let i = 0; i < nPoints; i++) {
const angle = (2 * Math.PI * i) / nPoints - Math.PI / 2;
const p = new Point(cx + radius * Math.cos(angle), cy + radius * Math.sin(angle));
p.type = 'softbody';
sim.points.push(p);
pts.push(p);
}
// Edge constraints — these define the perimeter shape
for (let i = 0; i < nPoints; i++) {
const con = new Constraint(pts[i], pts[(i + 1) % nPoints]);
con.type = 'softbody';
sim.constraints.push(con);
}
// Internal brace constraints — cross-braces for shape retention (not too many to avoid rigidity)
// Only connect opposite points, with lower stiffness so the body can deform
const halfN = Math.floor(nPoints / 2);
for (let i = 0; i < nPoints; i++) {
const j = (i + halfN) % nPoints;
const con = new Constraint(pts[i], pts[j]);
con.type = 'softbody';
con.stiffness = 0.5; // softer internal braces allow deformation
sim.constraints.push(con);
}
// A few additional braces for stability (skip +2 to avoid over-constraining)
if (nPoints >= 8) {
const thirdN = Math.floor(nPoints / 3);
for (let i = 0; i < nPoints; i++) {
const j = (i + thirdN) % nPoints;
const con = new Constraint(pts[i], pts[j]);
con.type = 'softbody';
con.stiffness = 0.35; // even softer for extra braces
sim.constraints.push(con);
}
}
// Store group reference for rendering filled polygon
sim.softBodyGroups.push([...pts]);
}
function spawnBall(x, y) {
const p = new Point(x, y);
p.type = 'ball';
p.radius = CFG.ballRadius;
sim.points.push(p);
return p;
}
// ============================================================
// STATIC OBSTACLES
// ============================================================
function initStaticObstacles() {
const w = canvas.width;
const h = canvas.height;
sim.staticCircles = [
{ x: w * 0.5, y: h * 0.65, radius: CFG.staticCircleRadius },
{ x: w * 0.25, y: h * 0.75, radius: 35 },
{ x: w * 0.75, y: h * 0.78, radius: 40 },
];
sim.staticBoxes = [
{ x: w * 0.15, y: h - 60, w: 120, h: 30 },
{ x: w * 0.7, y: h - 80, w: 100, h: 40 },
];
}
// ============================================================
// INTERACTION STATE
// ============================================================
let currentTool = 'rope';
let grabActive = false;
let grabbedPoint = null;
let tearActive = false;
let mouseX = 0, mouseY = 0;
let spawnStartX = 0, spawnStartY = 0;
let isSpawning = false;
// ============================================================
// CANVAS SETUP
// ============================================================
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
resizeCanvas();
window.addEventListener('resize', () => {
const oldW = canvas.width, oldH = canvas.height;
resizeCanvas();
// Reposition static obstacles on resize
initStaticObstacles();
});
// ============================================================
// RENDERING
// ============================================================
function hsl(h, s, l) { return `hsl(${h}, ${s}%, ${l}%)`; }
function getConstraintColor(c) {
if (!CFG.stressColors) {
const col = CFG.colors[c.type] || CFG.colors.default;
return hsl(col.h, col.s, col.l);
}
// Stress visualization: green (relaxed) → yellow → red (stretched)
const ratio = c.stretchRatio;
let r, g, b;
if (ratio < 1.0) {
// Compressed — blue-ish
const t = Math.max(0, ratio);
r = 50 * (1 - t);
g = 180 * t + 50 * (1 - t);
b = 255 * (1 - t);
} else if (ratio < 1.3) {
// Normal to slightly stretched — green to yellow
const t = (ratio - 1.0) / 0.3;
r = 80 + 175 * t;
g = 220 - 40 * t;
b = 80;
} else {
// Stretched — yellow to red
const t = Math.min(1, (ratio - 1.3) / 0.7);
r = 255;
g = 180 * (1 - t);
b = 40;
}
return `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`;
}
function render() {
const w = canvas.width, h = canvas.height;
// Background with slight trail effect (motion blur)
ctx.fillStyle = 'rgba(16, 16, 32, 0.88)';
ctx.fillRect(0, 0, w, h);
// Draw static obstacles — circles
for (const sc of sim.staticCircles) {
const grad = ctx.createRadialGradient(sc.x, sc.y, 0, sc.x, sc.y, sc.radius);
grad.addColorStop(0, 'rgba(80, 80, 120, 0.4)');
grad.addColorStop(1, 'rgba(60, 60, 100, 0.2)');
ctx.beginPath();
ctx.arc(sc.x, sc.y, sc.radius, 0, Math.PI * 2);
ctx.fillStyle = grad;
ctx.fill();
ctx.strokeStyle = 'rgba(140, 140, 200, 0.5)';
ctx.lineWidth = 2;
ctx.stroke();
}
// Draw static obstacles — boxes
for (const sb of sim.staticBoxes) {
const grad = ctx.createLinearGradient(sb.x, sb.y, sb.x, sb.y + sb.h);
grad.addColorStop(0, 'rgba(80, 80, 120, 0.4)');
grad.addColorStop(1, 'rgba(60, 60, 100, 0.25)');
ctx.fillStyle = grad;
ctx.fillRect(sb.x, sb.y, sb.w, sb.h);
ctx.strokeStyle = 'rgba(140, 140, 200, 0.5)';
ctx.lineWidth = 2;
ctx.strokeRect(sb.x, sb.y, sb.w, sb.h);
}
// Floor line with subtle glow
const floorGrad = ctx.createLinearGradient(0, h - 30, 0, h);
floorGrad.addColorStop(0, 'rgba(100, 120, 255, 0)');
floorGrad.addColorStop(1, 'rgba(100, 120, 255, 0.15)');
ctx.fillStyle = floorGrad;
ctx.fillRect(0, h - 30, w, 30);
// Draw cloth fills (filled mesh from grid data)
for (const cg of sim.clothGrids) {
drawClothMeshFill(cg.grid, cg.cols, cg.rows);
}
// Draw soft body fills (from stored groups)
for (const group of sim.softBodyGroups) {
drawSoftBodyGroupFill(group);
}
// Draw constraints as lines (non-cloth types visible; cloth uses fill above)
for (const c of sim.constraints) {
if (c.broken || c.type === 'cloth') continue;
const col = getConstraintColor(c);
ctx.beginPath();
ctx.moveTo(c.p1.x, c.p1.y);
ctx.lineTo(c.p2.x, c.p2.y);
ctx.strokeStyle = col;
ctx.lineWidth = c.type === 'softbody' ? 1.5 : 2;
ctx.globalAlpha = 0.85;
ctx.stroke();
ctx.globalAlpha = 1;
}
// Draw cloth constraint lines (subtle grid overlay)
for (const c of sim.constraints) {
if (c.broken || c.type !== 'cloth') continue;
const col = getConstraintColor(c);
ctx.beginPath();
ctx.moveTo(c.p1.x, c.p1.y);
ctx.lineTo(c.p2.x, c.p2.y);
ctx.strokeStyle = col;
ctx.lineWidth = 0.6;
ctx.globalAlpha = 0.35;
ctx.stroke();
ctx.globalAlpha = 1;
}
// Draw balls with gradient fill
for (const p of sim.points) {
if (p.type === 'ball') {
const col = CFG.colors.ball;
ctx.beginPath();
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
const grad = ctx.createRadialGradient(
p.x - p.radius * 0.3, p.y - p.radius * 0.3, 0,
p.x, p.y, p.radius
);
grad.addColorStop(0, hsl(col.h, col.s, Math.min(85, col.l + 20)));
grad.addColorStop(1, hsl(col.h, col.s, col.l - 10));
ctx.fillStyle = grad;
ctx.fill();
ctx.strokeStyle = hsl(col.h, col.s, col.l + 15);
ctx.lineWidth = 2;
ctx.stroke();
}
}
// Draw pinned points as bright dots
for (const p of sim.points) {
if (p.pinned) {
ctx.beginPath();
ctx.arc(p.x, p.y, 5, 0, Math.PI * 2);
const pinGrad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, 5);
pinGrad.addColorStop(0, '#fff');
pinGrad.addColorStop(1, 'rgba(255,255,255,0.3)');
ctx.fillStyle = pinGrad;
ctx.fill();
}
}
// Draw grab highlight ring
if (grabbedPoint && grabActive) {
const pulseR = CFG.grabRadius + 8 + Math.sin(performance.now() * 0.01) * 3;
ctx.beginPath();
ctx.arc(grabbedPoint.x, grabbedPoint.y, pulseR, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(100, 200, 255, 0.7)';
ctx.lineWidth = 2;
ctx.stroke();
// Crosshair lines
const ch = 6;
ctx.beginPath();
ctx.moveTo(grabbedPoint.x - pulseR - ch, grabbedPoint.y);
ctx.lineTo(grabbedPoint.x + pulseR + ch, grabbedPoint.y);
ctx.moveTo(grabbedPoint.x, grabbedPoint.y - pulseR - ch);
ctx.lineTo(grabbedPoint.x, grabbedPoint.y + pulseR + ch);
ctx.strokeStyle = 'rgba(100, 200, 255, 0.3)';
ctx.lineWidth = 1;
ctx.stroke();
}
// Draw spawn preview line
if (isSpawning && currentTool === 'rope') {
ctx.beginPath();
ctx.moveTo(spawnStartX, spawnStartY);
ctx.lineTo(mouseX, mouseY);
ctx.strokeStyle = 'rgba(100, 200, 255, 0.4)';
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.stroke();
ctx.setLineDash([]);
// Preview pin point
ctx.beginPath();
ctx.arc(spawnStartX, spawnStartY, 5, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(100, 200, 255, 0.6)';
ctx.fill();
}
// Spawn preview for cloth/softbody (show bounding box/radius)
if (isSpawning && currentTool !== 'rope' && currentTool !== 'ball') {
const dx = mouseX - spawnStartX;
const dy = mouseY - spawnStartY;
ctx.strokeStyle = 'rgba(100, 200, 255, 0.3)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
if (currentTool === 'cloth') {
ctx.strokeRect(spawnStartX, spawnStartY, dx, dy);
} else if (currentTool === 'softbody') {
const r = Math.sqrt(dx * dx + dy * dy);
ctx.beginPath();
ctx.arc(spawnStartX, spawnStartY, r, 0, Math.PI * 2);
ctx.stroke();
}
ctx.setLineDash([]);
}
}
function drawClothMeshFill(grid, cols, rows) {
const col = CFG.colors.cloth;
// Draw each quad cell as two triangles with subtle fill
for (let r = 0; r < rows - 1; r++) {
for (let c = 0; c < cols - 1; c++) {
const p00 = grid[r][c];
const p10 = grid[r][c + 1];
const p01 = grid[r + 1][c];
const p11 = grid[r + 1][c + 1];
// Check if any constraint in this cell is broken — skip torn cells
let torn = false;
for (const con of sim.constraints) {
if (con.type !== 'cloth' || !con.broken) continue;
if ((con.p1 === p00 && con.p2 === p10) ||
(con.p1 === p00 && con.p2 === p01) ||
(con.p1 === p10 && con.p2 === p11) ||
(con.p1 === p01 && con.p2 === p11)) {
torn = true;
}
}
if (!torn) {
ctx.beginPath();
ctx.moveTo(p00.x, p00.y);
ctx.lineTo(p10.x, p10.y);
ctx.lineTo(p11.x, p11.y);
ctx.lineTo(p01.x, p01.y);
ctx.closePath();
ctx.fillStyle = hsl(col.h, col.s * 0.5, col.l - 5);
ctx.globalAlpha = 0.2;
ctx.fill();
}
}
}
ctx.globalAlpha = 1;
}
function drawSoftBodyGroupFill(group) {
if (group.length < 3) return;
// Sort by angle around centroid for proper polygon fill
let cx = 0, cy = 0;
for (const p of group) { cx += p.x; cy += p.y; }
cx /= group.length;
cy /= group.length;
const sorted = [...group].sort((a, b) => {
return Math.atan2(a.y - cy, a.x - cx) - Math.atan2(b.y - cy, b.x - cx);
});
const col = CFG.colors.softbody;
ctx.beginPath();
ctx.moveTo(sorted[0].x, sorted[0].y);
for (let i = 1; i < sorted.length; i++) {
ctx.lineTo(sorted[i].x, sorted[i].y);
}
ctx.closePath();
const grad = ctx.createRadialGradient(cx - 15, cy - 15, 0, cx, cy, CFG.softBodyRadius * 1.2);
grad.addColorStop(0, hsl(col.h, col.s * 0.7, Math.min(80, col.l + 15)));
grad.addColorStop(1, hsl(col.h, col.s * 0.5, col.l - 10));
ctx.fillStyle = grad;
ctx.globalAlpha = 0.6;
ctx.fill();
// Edge highlight
ctx.strokeStyle = hsl(col.h, col.s, col.l + 20);
ctx.lineWidth = 2;
ctx.stroke();
ctx.globalAlpha = 1;
}
// ============================================================
// HUD UPDATE
// ============================================================
let fpsFrames = 0, fpsTime = 0, currentFPS = 60;
let physicsStepTime = 0;
function updateHUD(dt) {
fpsFrames++;
fpsTime += dt;
if (fpsTime >= 0.5) {
currentFPS = Math.round(fpsFrames / fpsTime);
fpsFrames = 0;
fpsTime = 0;
}
const totalKE = sim.points.reduce((sum, p) => sum + p.kineticEnergy(), 0);
document.getElementById('hud').innerHTML =
`FPS: ${currentFPS} | Physics: ${(physicsStepTime * 1000).toFixed(2)}ms | ` +
`Points: ${sim.points.length} | Constraints: ${sim.constraints.filter(c => !c.broken).length} | ` +
`Steps: ${sim.stepCount} | KE: ${totalKE.toFixed(0)}`;
}
// ============================================================
// INPUT HANDLING
// ============================================================
function findNearestPoint(x, y, radius) {
let best = null;
let bestDist = radius * radius;
for (const p of sim.points) {
const dx = p.x - x;
const dy = p.y - y;
const d2 = dx * dx + dy * dy;
if (d2 < bestDist) {
bestDist = d2;
best = p;
}
}
return best;
}
function tearConstraintsAlongLine(x1, y1, x2, y2) {
// Remove constraints whose midpoint is within tearRadius of the line segment
for (const c of sim.constraints) {
if (c.broken) continue;
const mx = (c.p1.x + c.p2.x) / 2;
const my = (c.p1.y + c.p2.y) / 2;
// Distance from point to line segment
const dx = x2 - x1;
const dy = y2 - y1;
const lenSq = dx * dx + dy * dy;
if (lenSq < 0.001) continue;
let t = ((mx - x1) * dx + (my - y1) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
const px = x1 + t * dx;
const py = y1 + t * dy;
const distSq = (mx - px) * (mx - px) + (my - py) * (my - py);
if (distSq < CFG.tearRadius * CFG.tearRadius) {
c.broken = true;
}
}
}
let lastMouseX = 0, lastMouseY = 0;
canvas.addEventListener('mousedown', (e) => {
const rect = canvas.getBoundingClientRect();
mouseX = e.clientX - rect.left;
mouseY = e.clientY - rect.top;
lastMouseX = mouseX;
lastMouseY = mouseY;
if (e.button === 2 || e.shiftKey && !e.ctrlKey) {
// Right click or shift+click for tear/pin
if (e.shiftKey && !e.ctrlKey) {
// Pin/unpin toggle
const p = findNearestPoint(mouseX, mouseY, CFG.grabRadius);
if (p) {
p.pinned = !p.pinned;
document.getElementById('pinMode').textContent = p.pinned ? '📌 Pinned' : '';
setTimeout(() => { document.getElementById('pinMode').textContent = ''; }, 1000);
}
} else if (e.button === 2) {
tearActive = true;
}
return;
}
// Left click: grab or spawn
const p = findNearestPoint(mouseX, mouseY, CFG.grabRadius);
if (p && !isSpawning) {
grabbedPoint = p;
grabActive = true;
isSpawning = false;
} else {
// Start spawning
isSpawning = true;
spawnStartX = mouseX;
spawnStartY = mouseY;
if (currentTool === 'ball') {
spawnBall(mouseX, mouseY);
isSpawning = false;
}
}
});
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
lastMouseX = mouseX;
lastMouseY = mouseY;
mouseX = e.clientX - rect.left;
mouseY = e.clientY - rect.top;
if (tearActive) {
tearConstraintsAlongLine(lastMouseX, lastMouseY, mouseX, mouseY);
}
});
canvas.addEventListener('mouseup', (e) => {
const rect = canvas.getBoundingClientRect();
mouseX = e.clientX - rect.left;
mouseY = e.clientY - rect.top;
if (tearActive && e.button === 2) {
tearActive = false;
return;
}
if (grabActive && grabbedPoint) {
// Throw with drag velocity
const throwVx = mouseX - lastMouseX;
const throwVy = mouseY - lastMouseY;
grabbedPoint.px = grabbedPoint.x - throwVx * 0.5;
grabbedPoint.py = grabbedPoint.y - throwVy * 0.5;
grabActive = false;
grabbedPoint = null;
return;
}
if (isSpawning) {
if (currentTool === 'rope') {
spawnRope(spawnStartX, spawnStartY, mouseX, mouseY);
} else if (currentTool === 'cloth') {
const cols = Math.max(4, Math.floor(Math.abs(mouseX - spawnStartX) / 10));
const rows = Math.max(3, Math.floor(Math.abs(mouseY - spawnStartY) / 10));
spawnCloth((spawnStartX + mouseX) / 2, (spawnStartY + mouseY) / 2, cols, rows);
} else if (currentTool === 'softbody') {
const radius = Math.max(20, Math.sqrt((mouseX - spawnStartX) ** 2 + (mouseY - spawnStartY) ** 2));
spawnSoftBody(spawnStartX, spawnStartY, radius);
}
isSpawning = false;
}
});
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
// Keyboard controls
document.addEventListener('keydown', (e) => {
if (e.code === 'Space') {
e.preventDefault();
sim.paused = !sim.paused;
document.getElementById('btnPause').textContent = sim.paused ? '▶ Play' : '⏸ Pause';
document.getElementById('btnPause').classList.toggle('active', sim.paused);
}
if (e.code === 'ArrowRight' && sim.paused) {
e.preventDefault();
physicsStep();
}
});
// ============================================================
// TOOLBAR CONTROLS
// ============================================================
document.querySelectorAll('.tb-btn[data-tool]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tb-btn[data-tool]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentTool = btn.dataset.tool;
});
});
document.getElementById('btnPause').addEventListener('click', () => {
sim.paused = !sim.paused;
document.getElementById('btnPause').textContent = sim.paused ? '▶ Play' : '⏸ Pause';
document.getElementById('btnPause').classList.toggle('active', sim.paused);
});
document.getElementById('btnStep').addEventListener('click', () => {
if (sim.paused) physicsStep();
});
document.getElementById('btnReset').addEventListener('click', () => {
sim.points = [];
sim.constraints = [];
sim.stepCount = 0;
initStaticObstacles();
});
document.getElementById('slGravity').addEventListener('input', (e) => {
CFG.gravity = parseFloat(e.target.value);
});
document.getElementById('slDamping').addEventListener('input', (e) => {
CFG.damping = parseFloat(e.target.value) / 1000;
});
document.getElementById('slIter').addEventListener('input', (e) => {
CFG.iterations = parseInt(e.target.value);
});
document.getElementById('btnStress').addEventListener('click', () => {
CFG.stressColors = !CFG.stressColors;
document.getElementById('btnStress').classList.toggle('active', CFG.stressColors);
});
// ============================================================
// MAIN LOOP
// ============================================================
let accumulator = 0;
let lastTime = performance.now();
function mainLoop(now) {
requestAnimationFrame(mainLoop);
let dt = (now - lastTime) / 1000;
lastTime = now;
// Clamp delta to prevent explosion on tab-switch
if (dt > CFG.maxFrameDelta) dt = CFG.maxFrameDelta;
if (!sim.paused) {
accumulator += dt;
const stepStart = performance.now();
while (accumulator >= CFG.timestep) {
physicsStep();
accumulator -= CFG.timestep;
}
physicsStepTime = (performance.now() - stepStart) / 1000;
}
render();
updateHUD(dt);
}
// ============================================================
// INITIALIZATION
// ============================================================
function init() {
resizeCanvas();
initStaticObstacles();
// Spawn some demo objects
spawnRope(canvas.width * 0.3, 60, canvas.width * 0.3 + 150, 200);
spawnCloth(canvas.width * 0.7, 120, CFG.clothCols, CFG.clothRows);
spawnSoftBody(canvas.width * 0.5, 150, CFG.softBodyRadius);
spawnBall(canvas.width * 0.85, 100);
// Expose sim on window for probing
window.sim = sim;
window.physicsStep = physicsStep;
window.CFG = CFG;
requestAnimationFrame(mainLoop);
}
init();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"full","sessionId":"14af1b45-ea39-4cd8-8aee-ff9a7e38d0ea","tokensIn":1898093,"tokensOut":35138,"tokensTotal":1933231,"turns":41,"toolCalls":40,"failedToolCalls":1,"timestamp":"2026-07-29T23:09:53.788Z"} -->