Physics Playground
Tiel Coder 35B A3B GGUF 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 34,317 bytes · SHA-256 bd496fd9ec8d
<!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; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0c12; font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
#c { display: block; position: absolute; top: 0; left: 0; cursor: crosshair; }
#ui { position: absolute; inset: 0; pointer-events: none; }
#toolbar {
position: absolute; top: 10px; left: 50%; transform: translateX(-50%);
display: flex; gap: 6px; align-items: center; padding: 6px 10px;
background: rgba(18, 22, 32, 0.82); border: 1px solid rgba(120, 140, 180, 0.18);
border-radius: 12px; backdrop-filter: blur(6px); pointer-events: auto;
box-shadow: 0 6px 24px rgba(0,0,0,0.4);
}
#toolbar button, #toolbar select {
background: rgba(40, 48, 66, 0.6); color: #cdd6e8; border: 1px solid rgba(120,140,180,0.2);
padding: 6px 10px; border-radius: 8px; font: inherit; font-size: 12px; cursor: pointer;
transition: background .12s, border-color .12s;
}
#toolbar button:hover, #toolbar select:hover { background: rgba(70, 82, 110, 0.8); }
#toolbar button.active { background: #3a7bd5; border-color: #6fb0ff; color: #fff; }
.sep { width: 1px; height: 22px; background: rgba(120,140,180,0.22); margin: 0 2px; }
#toolbar label { color: #9fb0cc; font-size: 11px; display: flex; align-items: center; gap: 5px; white-space: nowrap; }
#toolbar input[type=range] { width: 84px; accent-color: #3a7bd5; }
#toolbar input[type=checkbox] { accent-color: #3a7bd5; }
#readouts {
position: absolute; top: 10px; right: 12px; pointer-events: none;
background: rgba(18,22,32,0.72); border: 1px solid rgba(120,140,180,0.15);
border-radius: 10px; padding: 8px 12px; color: #8fa6c8; font-size: 11px; line-height: 1.6;
text-align: right;
}
#readouts b { color: #cfe0ff; font-weight: 600; }
#hint {
position: absolute; bottom: 10px; left: 50%; transform: translateX(-50%);
color: #5c6c88; font-size: 11px; pointer-events: none; text-align: center;
background: rgba(10,12,18,0.6); padding: 4px 12px; border-radius: 8px;
}
#hint span { color: #8fa6c8; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="ui">
<div id="toolbar">
<button data-tool="grab" class="active">Grab</button>
<div class="sep"></div>
<button data-tool="rope">Rope</button>
<button data-tool="cloth">Cloth</button>
<button data-tool="softbody">Soft Body</button>
<button data-tool="ball">Ball</button>
<button data-tool="pin">Pin</button>
<div class="sep"></div>
<label>Cloth pins
<select id="pinPattern">
<option value="edge">Full edge</option>
<option value="corners">Corners</option>
</select>
</label>
<div class="sep"></div>
<button id="pauseBtn">Pause</button>
<button id="stepBtn">Step</button>
<button id="resetBtn">Reset</button>
<div class="sep"></div>
<label>Gravity <input type="range" id="gravity" min="0" max="4000" step="50" value="1500"></label>
<label>Iter <input type="range" id="iters" min="1" max="10" value="5"></label>
<label>Wind <input type="range" id="wind" min="-1200" max="1200" step="20" value="0"></label>
<div class="sep"></div>
<label><input type="checkbox" id="stress"> Stress</label>
<label><input type="checkbox" id="trail"> Trail</label>
</div>
<div id="readouts">
<div>FPS: <b id="fps">--</b></div>
<div>Physics: <b id="ptime">--</b> ms</div>
<div>Points: <b id="stats">0</b> · Constraints: <b id="conns">0</b></div>
<div>Paused: <b id="pausedState">no</b></div>
</div>
<div id="hint">
<span>Left-drag</span> spawn (or grab a point) · <span>Right-drag</span> cut · <span>Pin tool / Ctrl-click</span> toggle pin · <span>Space</span> pause · <span>←/→</span> step
</div>
</div>
<script>
"use strict";
/* =========================================================================
SECTION 1 — TUNABLE CONSTANTS
========================================================================= */
const CONFIG = {
timestep: 1 / 60, // fixed physics step (60 Hz)
iterations: 5, // constraint relaxation iterations per step
gravity: 1500, // px/s^2 (downward; y grows downward)
wind: 0, // px/s^2 horizontal
damping: 0.990, // velocity retention per step (energy decay)
friction: 0.80, // tangential friction on collision
restitution: 0.35, // bounce energy retained on collision
maxDelta: 0.25, // clamp per-frame delta so tab-switch can't explode
tearRatio: 3.0, // constraint tears when stretched beyond rest*ratio
grabRadius: 22, // px — pickup radius for grabbing
tearRadius: 14, // px — cut proximity radius
pointRadius: 2.2,
colors: {
rope: '#ffcc33',
cloth: '#44ccff',
clothPin: '#ffffff',
softbody: '#ff66aa',
softLine: 'rgba(255,102,170,0.35)',
ball: '#66ff88',
grabbed: '#ffaa00',
pinned: '#ffffff',
wall: '#2a3350',
floor: '#24304d',
grid: 'rgba(90,110,150,0.06)',
}
};
/* =========================================================================
SECTION 2 — VECTOR / POINT / CONSTRAINT
========================================================================= */
class Point {
constructor(x, y, opts = {}) {
this.x = x; this.y = y;
this.px = x; this.py = y; // previous position (Verlet)
this.pinned = !!opts.pinned;
this.grabbed = false;
this.mass = opts.mass ?? 1;
this.massInv = this.pinned ? 0 : (1 / this.mass);
this.radius = opts.radius ?? CONFIG.pointRadius;
this.ax = 0; this.ay = 0; // accumulated external accel (wind etc.)
}
release(vx, vy) {
const dt = CONFIG.timestep;
this.px = this.x - vx * dt;
this.py = this.y - vy * dt;
}
}
class Constraint {
constructor(a, b, rest, opts = {}) {
this.a = a; this.b = b;
this.rest = rest;
this.stiffness = opts.stiffness ?? 1;
this.tear = opts.tear !== undefined ? opts.tear : rest * CONFIG.tearRatio;
this.broken = false;
this.stretch = 1; // dist / rest (for stress viz)
this.type = opts.type ?? 'dist';
}
solve() {
if (this.broken) return;
const dx = this.b.x - this.a.x;
const dy = this.b.y - this.a.y;
let dist = dx * dx + dy * dy;
if (dist < 1e-8) { this.stretch = 1; return; } // guard zero-length
dist = Math.sqrt(dist);
this.stretch = dist / this.rest;
if (this.stretch > this.tear / this.rest) { this.broken = true; return; }
const diff = (dist - this.rest) / dist * this.stiffness;
const corrX = dx * diff;
const corrY = dy * diff;
const invA = this.a.massInv;
const invB = this.b.massInv;
if (invA <= 0 && invB <= 0) return;
if (!this.a.pinned) { this.a.x += corrX * invA; this.a.y += corrY * invA; }
if (!this.b.pinned) { this.b.x -= corrX * invB; this.b.y -= corrY * invB; }
}
}
/* =========================================================================
SECTION 3 — STATIC GEOMETRY + COLLISION
========================================================================= */
function makeSim(w, h) {
const sim = {
points: [], constraints: [], objects: [], particles: [],
cutTrail: [], preview: null, dragging: null, grabPoint: null,
w, h, floorY: h - 24, wallL: 24, wallR: w - 24,
staticShapes: []
};
// Static obstacles: two circles + a ramp box, so cloth drapes & bounces.
sim.staticShapes = [
{ type: 'circle', x: w * 0.66, y: h * 0.58, r: 66 },
{ type: 'circle', x: w * 0.34, y: h * 0.70, r: 48 },
{ type: 'box', cx: w * 0.50, cy: h * 0.74, w: 190, h: 28 },
];
return sim;
}
// Point vs static shape resolution. Mutates the point to rest on the surface.
function collide(sim, p) {
const r = p.radius;
// Floor
if (p.y > sim.floorY - r) {
p.y = sim.floorY - r;
applySurfaceFriction(p);
}
// Side walls
if (p.x < sim.wallL + r) {
p.x = sim.wallL + r; applySurfaceFriction(p);
} else if (p.x > sim.wallR - r) {
p.x = sim.wallR - r; applySurfaceFriction(p);
}
// Obstacles
for (const s of sim.staticShapes) {
if (s.type === 'circle') {
const dx = p.x - s.x, dy = p.y - s.y;
const d2 = dx * dx + dy * dy;
const min = s.r + r;
if (d2 < min * min && d2 > 1e-8) {
const d = Math.sqrt(d2);
const push = (min - d) / d;
p.x += dx * push; p.y += dy * push;
}
} else if (s.type === 'box') {
const hw = s.w / 2, hh = s.h / 2;
const nx = p.x - s.cx, ny = p.y - s.cy;
const ox = hw + r - Math.abs(nx);
const oy = hh + r - Math.abs(ny);
if (ox > 0 && oy > 0) {
if (ox < oy) {
p.x += Math.sign(nx) * ox; applySurfaceFriction(p, true);
} else {
p.y += Math.sign(ny) * oy; applySurfaceFriction(p);
}
}
}
}
}
// Apply friction + restitution to the velocity implied by Verlet.
function applySurfaceFriction(p, normalX) {
let vx = p.x - p.px;
let vy = p.y - p.py;
if (normalX) {
// horizontal surface: friction on x, bounce on y
p.px = p.x - vx * CONFIG.friction;
p.py = p.y + vy * CONFIG.restitution;
} else {
// horizontal floor/wall-parallel: friction on y (vertical motion), bounce on x
p.px = p.x + vx * CONFIG.restitution;
p.py = p.y - vy * CONFIG.friction;
}
}
/* =========================================================================
SECTION 4 — SPAWNERS (rope / cloth / softbody / ball)
========================================================================= */
function addPoint(sim, p) { sim.points.push(p); return p; }
function spawnRope(sim, x0, y0, x1, y1) {
const dx = x1 - x0, dy = y1 - y0;
const len = Math.hypot(dx, dy) || 1;
const n = Math.max(4, Math.round(len / 18));
const pts = [];
for (let i = 0; i <= n; i++) {
const t = i / n;
pts.push(addPoint(sim, new Point(x0 + dx * t, y0 + dy * t, { pinned: i === 0 })));
}
const obj = { type: 'rope', points: pts, constraints: [], color: CONFIG.colors.rope };
for (let i = 0; i < pts.length - 1; i++) {
const c = new Constraint(pts[i], pts[i + 1], dist(pts[i], pts[i + 1]));
sim.constraints.push(c); obj.constraints.push(c);
}
sim.objects.push(obj);
}
function spawnCloth(sim, x, y, w, h) {
const cols = Math.max(3, Math.round(w / 18));
const rows = Math.max(3, Math.round(h / 18));
const cellX = w / (cols - 1), cellY = h / (rows - 1);
const grid = [];
for (let r = 0; r < rows; r++) {
const row = [];
for (let c = 0; c < cols; c++) {
const px = x + c * cellX, py = y + r * cellY;
const pinned = (r === 0) && (pinPattern === 'edge' || c === 0 || c === cols - 1);
row.push(addPoint(sim, new Point(px, py, { pinned })));
}
grid.push(row);
}
const struct = [];
const shear = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const p = grid[r][c];
if (c < cols - 1) struct.push(link(sim, p, grid[r][c + 1]));
if (r < rows - 1) struct.push(link(sim, p, grid[r + 1][c]));
if (c < cols - 1 && r < rows - 1) {
shear.push(link(sim, p, grid[r + 1][c + 1], { tear: (struct.length ? 1e9 : 1e9) }));
shear.push(link(sim, grid[r + 1][c], grid[r][c + 1]));
}
}
}
sim.objects.push({ type: 'cloth', grid, rows, cols, points: [], struct, shear, color: CONFIG.colors.cloth });
sim.objects[sim.objects.length - 1].points = grid.flat();
}
function link(sim, a, b, opts) {
const c = new Constraint(a, b, dist(a, b), opts);
sim.constraints.push(c);
return c;
}
function spawnSoftBody(sim, cx, cy, size, kind) {
const pts = [];
const center = addPoint(sim, new Point(cx, cy, { mass: 1.4 }));
const ringN = 14;
const ring = [];
for (let i = 0; i < ringN; i++) {
const ang = (i / ringN) * Math.PI * 2;
const px = cx + Math.cos(ang) * size;
const py = cy + Math.sin(ang) * size;
ring.push(addPoint(sim, new Point(px, py, { mass: 1 })));
}
const obj = { type: 'softbody', kind, points: [center, ...ring], constraints: [], color: CONFIG.colors.softbody, cx: 0, cy: 0, size };
// structural: ring edges (soft — let the body deform)
for (let i = 0; i < ringN; i++) {
const a = ring[i], b = ring[(i + 1) % ringN];
obj.constraints.push(link(sim, a, b, { stiffness: 0.25, tear: size * 1.8 }));
}
// radial braces to center (strong — define the radius)
for (const p of ring) obj.constraints.push(link(sim, center, p, { stiffness: 0.35, tear: size * 2.2 }));
// cross braces (opposite points) for shape retention
for (let i = 0; i < ringN; i++) {
const opp = ring[(i + Math.floor(ringN / 2)) % ringN];
obj.constraints.push(link(sim, ring[i], opp, { stiffness: 0.3, tear: size * 3.5 }));
}
sim.objects.push(obj);
}
function spawnBall(sim, x, y) {
const p = addPoint(sim, new Point(x, y, { mass: 1, radius: 16 }));
sim.objects.push({ type: 'ball', point: p, radius: 16, color: CONFIG.colors.ball });
}
function dist(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); }
/* =========================================================================
SECTION 5 — SIMULATION STEP
========================================================================= */
function safe(p) {
if (!isFinite(p.x) || !isFinite(p.y)) { p.x = p.px; p.y = p.py; }
}
function step(sim) {
const dt = CONFIG.timestep;
const dt2 = dt * dt;
// --- Integrate (Verlet) ---
for (const p of sim.points) {
if (p.pinned || p.grabbed) { safe(p); continue; }
const vx = (p.x - p.px) * CONFIG.damping;
const vy = (p.y - p.py) * CONFIG.damping;
p.px = p.x; p.py = p.y;
p.x += vx;
p.y += vy + CONFIG.gravity * dt2;
}
// Grabbed points follow the cursor (position set by interaction layer).
// --- Solve constraints + collisions (relaxation) ---
for (let it = 0; it < CONFIG.iterations; it++) {
for (const c of sim.constraints) c.solve();
for (const p of sim.points) {
if (p.pinned || p.grabbed) continue;
collide(sim, p);
safe(p);
}
}
}
/* =========================================================================
SECTION 6 — RENDERING
========================================================================= */
function stressColor(ratio) {
// ratio = dist/rest (1 = rest). blue(compressed) -> green -> yellow -> red
let r, g, b;
if (ratio < 1) { const t = Math.max(0, ratio); r = 140 + t * 40; g = 190 + t * 40; b = 255; }
else if (ratio < 1.4) { const t = (ratio - 1) / 0.4; r = 90 + t * 165; g = 230 - t * 70; b = 90 - t * 30; }
else if (ratio < 1.9) { const t = (ratio - 1.4) / 0.5; r = 255; g = 160 - t * 120; b = 60 - t * 30; }
else { r = 255; g = 40; b = 40; }
return `rgb(${r | 0},${g | 0},${b | 0})`;
}
function render(sim, ctx, showStress, showTrail) {
const { w, h } = sim;
if (showTrail) {
ctx.fillStyle = 'rgba(10,12,18,0.28)';
ctx.fillRect(0, 0, w, h);
} else {
ctx.fillStyle = '#0a0c12';
ctx.fillRect(0, 0, w, h);
}
// Background grid
ctx.strokeStyle = CONFIG.colors.grid;
ctx.lineWidth = 1;
ctx.beginPath();
for (let x = 0; x < w; x += 48) { ctx.moveTo(x + 0.5, 0); ctx.lineTo(x + 0.5, h); }
for (let y = 0; y < h; y += 48) { ctx.moveTo(0, y + 0.5); ctx.lineTo(w, y + 0.5); }
ctx.stroke();
// Static geometry
drawStatic(sim, ctx);
// Objects
for (const obj of sim.objects) drawObject(sim, ctx, obj, showStress);
// Particles (tear sparks)
drawParticles(ctx);
// Grab highlight
if (sim.grabPoint) {
const g = sim.grabPoint;
ctx.beginPath();
ctx.arc(g.x, g.y, 12, 0, Math.PI * 2);
ctx.strokeStyle = CONFIG.colors.grabbed;
ctx.lineWidth = 2;
ctx.stroke();
}
// Preview while spawning
if (sim.preview) drawPreview(ctx, sim.preview);
// Cut trail
if (sim.cutTrail.length > 1) {
ctx.beginPath();
ctx.moveTo(sim.cutTrail[0].x, sim.cutTrail[0].y);
for (let i = 1; i < sim.cutTrail.length; i++) ctx.lineTo(sim.cutTrail[i].x, sim.cutTrail[i].y);
ctx.strokeStyle = 'rgba(255,90,90,0.7)';
ctx.lineWidth = 2;
ctx.stroke();
}
}
function drawStatic(sim, ctx) {
// Floor
ctx.fillStyle = CONFIG.colors.floor;
ctx.fillRect(sim.wallL, sim.floorY, sim.wallR - sim.wallL, sim.h - sim.floorY);
ctx.strokeStyle = '#3a4a72';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(sim.wallL, sim.floorY + 0.5);
ctx.lineTo(sim.wallR, sim.floorY + 0.5);
ctx.stroke();
// Walls
ctx.fillStyle = CONFIG.colors.wall;
ctx.fillRect(sim.wallL - 12, 0, 12, sim.floorY);
ctx.fillRect(sim.wallR, 0, 12, sim.floorY);
// Obstacles
for (const s of sim.staticShapes) {
if (s.type === 'circle') {
const grd = ctx.createRadialGradient(s.x - s.r * 0.3, s.y - s.r * 0.3, s.r * 0.2, s.x, s.y, s.r);
grd.addColorStop(0, '#3a4668');
grd.addColorStop(1, '#1c2438');
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fillStyle = grd; ctx.fill();
ctx.strokeStyle = '#4a5a82'; ctx.lineWidth = 2; ctx.stroke();
} else if (s.type === 'box') {
ctx.fillStyle = '#26324f';
ctx.fillRect(s.cx - s.w / 2, s.cy - s.h / 2, s.w, s.h);
ctx.strokeStyle = '#4a5a82'; ctx.lineWidth = 2;
ctx.strokeRect(s.cx - s.w / 2 + 0.5, s.cy - s.h / 2 + 0.5, s.w, s.h);
}
}
}
function drawObject(sim, ctx, obj, showStress) {
if (obj.type === 'rope') {
ctx.lineCap = 'round';
// constraints
ctx.lineWidth = 3;
for (const c of obj.constraints) {
if (c.broken) continue;
ctx.strokeStyle = showStress ? stressColor(c.stretch) : CONFIG.colors.rope;
ctx.globalAlpha = showStress ? 0.9 : 0.85;
ctx.beginPath();
ctx.moveTo(c.a.x, c.a.y); ctx.lineTo(c.b.x, c.b.y);
ctx.stroke();
}
ctx.globalAlpha = 1;
// points
for (const p of obj.points) {
ctx.beginPath();
ctx.arc(p.x, p.y, p.pinned ? 4 : 2.6, 0, Math.PI * 2);
ctx.fillStyle = p.pinned ? CONFIG.colors.pinned : '#ffe08a';
ctx.fill();
}
} else if (obj.type === 'cloth') {
// Filled mesh (two triangles per quad) colored by stress
const { grid, rows, cols, struct } = obj;
if (showStress) {
for (let r = 0; r < rows - 1; r++) {
for (let c = 0; c < cols - 1; c++) {
const p00 = grid[r][c], p01 = grid[r][c + 1];
const p10 = grid[r + 1][c], p11 = grid[r + 1][c + 1];
const s0 = stressColor(Math.max(p00.stretch || 1, p01.stretch || 1, p10.stretch || 1, p11.stretch || 1));
ctx.fillStyle = s0;
tri(ctx, p00, p01, p10);
tri(ctx, p01, p11, p10);
}
}
} else {
// subtle shaded mesh
for (let r = 0; r < rows - 1; r++) {
for (let c = 0; c < cols - 1; c++) {
const p00 = grid[r][c], p01 = grid[r][c + 1];
const p10 = grid[r + 1][c], p11 = grid[r + 1][c + 1];
const shade = 0.55 + 0.45 * (r / (rows - 1));
ctx.fillStyle = `rgba(68,204,255,${shade * 0.35})`;
tri(ctx, p00, p01, p10);
tri(ctx, p01, p11, p10);
}
}
}
// structural lines
ctx.lineWidth = 1.4;
for (const c of struct) {
if (c.broken) continue;
ctx.strokeStyle = showStress ? stressColor(c.stretch) : 'rgba(140,215,255,0.75)';
ctx.beginPath(); ctx.moveTo(c.a.x, c.a.y); ctx.lineTo(c.b.x, c.b.y); ctx.stroke();
}
if (showStress) {
ctx.lineWidth = 1;
for (const c of obj.shear) {
if (c.broken) continue;
ctx.strokeStyle = stressColor(c.stretch);
ctx.globalAlpha = 0.4;
ctx.beginPath(); ctx.moveTo(c.a.x, c.a.y); ctx.lineTo(c.b.x, c.b.y); ctx.stroke();
ctx.globalAlpha = 1;
}
}
// pinned markers
for (let r = 0; r < rows; r++)
for (let c = 0; c < cols; c++)
if (grid[r][c].pinned) {
ctx.fillStyle = CONFIG.colors.clothPin;
ctx.fillRect(grid[r][c].x - 3, grid[r][c].y - 3, 6, 6);
}
} else if (obj.type === 'softbody') {
const ring = obj.points.slice(1);
// filled polygon
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();
const grd = ctx.createRadialGradient(
obj.points[0].x, obj.points[0].y, ring[0].radius * 0.2,
obj.points[0].x, obj.points[0].y, obj.size * 1.3);
grd.addColorStop(0, '#ffa8cc');
grd.addColorStop(1, 'rgba(255,102,170,0.55)');
ctx.fillStyle = grd; ctx.fill();
ctx.strokeStyle = CONFIG.colors.softbody; ctx.lineWidth = 2.5; ctx.stroke();
// internal braces faint
ctx.strokeStyle = CONFIG.colors.softLine; ctx.lineWidth = 1;
for (const c of obj.constraints) {
if (c.broken || c.type === undefined) continue;
}
} else if (obj.type === 'ball') {
const p = obj.point;
const grd = ctx.createRadialGradient(p.x - p.radius * 0.3, p.y - p.radius * 0.3, 2, p.x, p.y, p.radius);
grd.addColorStop(0, '#ccffdd');
grd.addColorStop(1, CONFIG.colors.ball);
ctx.beginPath(); ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
ctx.fillStyle = grd; ctx.fill();
ctx.strokeStyle = '#2a8f4a'; ctx.lineWidth = 2; ctx.stroke();
}
}
function tri(ctx, a, b, c) {
ctx.beginPath();
ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(c.x, c.y); ctx.closePath();
ctx.fill();
}
function drawParticles(ctx) {
for (const pt of sim.particles) {
ctx.globalAlpha = Math.max(0, pt.life);
ctx.fillStyle = '#ffcc66';
ctx.fillRect(pt.x - 1.5, pt.y - 1.5, 3, 3);
}
ctx.globalAlpha = 1;
}
function drawPreview(ctx, pv) {
ctx.setLineDash([5, 5]);
ctx.strokeStyle = 'rgba(200,220,255,0.5)';
ctx.lineWidth = 1.5;
if (pv.type === 'rope') {
ctx.beginPath(); ctx.moveTo(pv.x0, pv.y0); ctx.lineTo(pv.x1, pv.y1); ctx.stroke();
const dx = pv.x1 - pv.x0, dy = pv.y1 - pv.y0, len = Math.hypot(dx, dy) || 1;
const n = Math.max(4, Math.round(len / 18));
for (let i = 0; i <= n; i++) {
const t = i / n;
ctx.beginPath(); ctx.arc(pv.x0 + dx * t, pv.y0 + dy * t, 2.5, 0, Math.PI * 2);
ctx.fillStyle = i === 0 ? '#fff' : 'rgba(255,224,138,0.7)'; ctx.fill();
}
} else if (pv.type === 'cloth') {
ctx.strokeRect(pv.x, pv.y, pv.w, pv.h);
ctx.fillStyle = 'rgba(68,204,255,0.12)';
ctx.fillRect(pv.x, pv.y, pv.w, pv.h);
} else if (pv.type === 'softbody') {
ctx.beginPath(); ctx.arc(pv.cx, pv.cy, pv.size, 0, Math.PI * 2);
ctx.stroke(); ctx.fillStyle = 'rgba(255,102,170,0.12)'; ctx.fill();
} else if (pv.type === 'ball') {
ctx.beginPath(); ctx.arc(pv.x, pv.y, pv.r, 0, Math.PI * 2); ctx.stroke();
}
ctx.setLineDash([]);
}
/* =========================================================================
SECTION 7 — INTERACTION
========================================================================= */
let tool = 'grab';
let pinPattern = 'corners';
let paused = false;
function setupInteraction(canvas, sim) {
const getPos = (e) => {
const rect = canvas.getBoundingClientRect();
return { x: (e.clientX - rect.left), y: (e.clientY - rect.top) };
};
let mouse = { x: 0, y: 0 };
let samples = [];
let grabPoint = null;
let cutting = false;
let cutTrail = [];
let lastSample = { x: 0, y: 0, t: 0 };
canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('mousedown', (e) => {
const p = getPos(e);
mouse = p;
const now = performance.now();
samples = [{ x: p.x, y: p.y, t: now }];
lastSample = { x: p.x, y: p.y, t: now };
// Right button => start cutting
if (e.button === 2) {
cutting = true; cutTrail = [p];
return;
}
// Pin tool: toggle pin under cursor
if (tool === 'pin' || (e.ctrlKey || e.metaKey)) {
const near = nearestPoint(sim, p.x, p.y, CONFIG.grabRadius * 1.4);
if (near) { near.pinned = !near.pinned; near.massInv = near.pinned ? 0 : 1 / near.mass; spawnTear(sim, near.x, near.y); }
return;
}
// Grab: nearest point within radius
const np = nearestPoint(sim, p.x, p.y, CONFIG.grabRadius);
if (np) {
grabPoint = np;
np.grabbed = true;
np.x = p.x; np.y = p.y; np.px = p.x; np.py = p.y;
return;
}
// Spawn drag
if (tool === 'rope' || tool === 'cloth' || tool === 'softbody' || tool === 'ball') {
if (tool === 'rope') sim.dragging = { type: 'rope', x0: p.x, y0: p.y, x1: p.x, y1: p.y };
else if (tool === 'cloth') sim.dragging = { type: 'cloth', x: p.x, y: p.y, w: 0, h: 0 };
else if (tool === 'softbody') sim.dragging = { type: 'softbody', cx: p.x, cy: p.y, size: 0 };
else if (tool === 'ball') sim.dragging = { type: 'ball', x: p.x, y: p.y, r: 16 };
}
});
canvas.addEventListener('mousemove', (e) => {
const p = getPos(e);
mouse = p;
const now = performance.now();
if (grabPoint) {
grabPoint.x = p.x; grabPoint.y = p.y;
samples.push({ x: p.x, y: p.y, t: now });
if (samples.length > 12) samples.shift();
lastSample = { x: p.x, y: p.y, t: now };
} else if (cutting) {
cutTrail.push(p);
if (cutTrail.length > 40) cutTrail.shift();
cutAlong(sim, lastSample, p);
lastSample = { x: p.x, y: p.y, t: now };
} else if (sim.dragging) {
if (sim.dragging.type === 'rope') { sim.dragging.x1 = p.x; sim.dragging.y1 = p.y; }
else if (sim.dragging.type === 'cloth') {
sim.dragging.w = p.x - sim.dragging.x; sim.dragging.h = p.y - sim.dragging.y;
if (e.shiftKey) { const s = Math.max(Math.abs(sim.dragging.w), Math.abs(sim.dragging.h)); sim.dragging.w = Math.sign(sim.dragging.w) * s; sim.dragging.h = Math.sign(sim.dragging.h) * s; }
} else if (sim.dragging.type === 'softbody') { sim.dragging.size = Math.hypot(p.x - sim.dragging.cx, p.y - sim.dragging.cy); }
}
});
const endInteraction = () => {
if (grabPoint) {
// Throw: estimate velocity from recent samples
let vx = 0, vy = 0;
const recent = samples.slice(-6);
if (recent.length >= 2) {
const first = recent[0], last = recent[recent.length - 1];
const dt = (last.t - first.t) / 1000;
if (dt > 1e-4) {
vx = (last.x - first.x) / dt;
vy = (last.y - first.y) / dt;
}
}
const g = grabPoint;
g.grabbed = false;
const sp = Math.hypot(vx, vy);
if (sp > 4000) { const s = 4000 / sp; vx *= s; vy *= s; } // clamp throw speed
g.release(vx, vy);
grabPoint = null;
}
cutting = false; cutTrail = [];
dragging = null;
};
canvas.addEventListener('mouseup', endInteraction);
canvas.addEventListener('mouseleave', endInteraction);
// Expose for probing
sim._cutAlong = cutAlong;
sim._togglePin = (x, y) => {
const np = nearestPoint(sim, x, y, CONFIG.grabRadius * 1.4);
if (np) { np.pinned = !np.pinned; np.massInv = np.pinned ? 0 : 1 / np.mass; }
};
sim._nearestPoint = (x, y) => nearestPoint(sim, x, y, CONFIG.grabRadius);
sim._grab = (x, y) => {
const np = nearestPoint(sim, x, y, CONFIG.grabRadius);
if (np) { grabPoint = np; np.grabbed = true; np.x = x; np.y = y; np.px = x; np.py = y; return true; }
return false;
};
sim._release = () => endInteraction();
}
function nearestPoint(sim, x, y, maxR) {
let best = null, bestD = maxR * maxR;
for (const p of sim.points) {
if (p.pinned) continue;
const dx = p.x - x, dy = p.y - y;
const d = dx * dx + dy * dy;
if (d < bestD) { bestD = d; best = p; }
}
return best;
}
// Break any constraint whose segment passes within tearRadius of the mouse path.
function cutAlong(sim, a, b) {
const rr = CONFIG.tearRadius;
const r2 = rr * rr;
for (const c of sim.constraints) {
if (c.broken) continue;
if (segDist2(c.a.x, c.a.y, c.b.x, c.b.y, a.x, a.y, b.x, b.y) < r2) {
c.broken = true;
spawnTear(sim, (c.a.x + c.b.x) / 2, (c.a.y + c.b.y) / 2);
}
}
}
// squared distance from point (px,py) to segment (ax,ay)-(bx,by)
function segDist2(px, py, ax, ay, bx, by) {
const dx = bx - ax, dy = by - ay;
const l2 = dx * dx + dy * dy;
let t = l2 > 0 ? ((px - ax) * dx + (py - ay) * dy) / l2 : 0;
t = Math.max(0, Math.min(1, t));
const cx = ax + t * dx, cy = ay + t * dy;
const ex = px - cx, ey = py - cy;
return ex * ex + ey * ey;
}
// Tiny spark burst for tear feedback
function spawnTear(sim, x, y) {
for (let i = 0; i < 5; i++) {
const ang = Math.random() * Math.PI * 2;
const sp = 40 + Math.random() * 120;
sim.particles.push({ x, y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp, life: 1 });
}
}
/* =========================================================================
SECTION 8 — MAIN LOOP + CONTROLS
========================================================================= */
function main() {
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let dpr = Math.min(window.devicePixelRatio || 1, 2);
function resize() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.floor(window.innerWidth * dpr);
canvas.height = Math.floor(window.innerHeight * dpr);
canvas.style.width = window.innerWidth + 'px';
canvas.style.height = window.innerHeight + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (sim) { sim.w = window.innerWidth; sim.h = window.innerHeight; sim.floorY = window.innerHeight - 24; sim.wallR = window.innerWidth - 24; }
}
window.addEventListener('resize', resize);
let sim = makeSim(window.innerWidth, window.innerHeight);
window._liveSim = sim;
resize();
setupInteraction(canvas, sim);
// --- Controls ---
const $ = (id) => document.getElementById(id);
$('pauseBtn').addEventListener('click', () => togglePause());
$('stepBtn').addEventListener('click', () => { if (paused) { step(sim); } });
$('resetBtn').addEventListener('click', () => { sim = makeSim(window.innerWidth, window.innerHeight); window._liveSim = sim; resize(); setupInteraction(canvas, sim); populate(sim); });
$('gravity').addEventListener('input', (e) => { CONFIG.gravity = +e.target.value; });
$('iters').addEventListener('input', (e) => { CONFIG.iterations = +e.target.value; });
$('wind').addEventListener('input', (e) => { CONFIG.wind = +e.target.value; });
$('pinPattern').addEventListener('change', (e) => { pinPattern = e.target.value; });
$('stress').addEventListener('change', (e) => { CONFIG.showStress = e.target.checked; });
$('trail').addEventListener('change', (e) => { CONFIG.showTrail = e.target.checked; });
document.querySelectorAll('#toolbar [data-tool]').forEach((btn) => {
btn.addEventListener('click', () => {
tool = btn.dataset.tool;
document.querySelectorAll('#toolbar [data-tool]').forEach(b => b.classList.toggle('active', b === btn));
canvas.style.cursor = (tool === 'grab') ? 'grab' : (tool === 'pin') ? 'pointer' : 'crosshair';
});
});
// Keyboard
window.addEventListener('keydown', (e) => {
if (e.code === 'Space') { e.preventDefault(); togglePause(); }
else if (e.code === 'ArrowRight' && paused) { step(sim); }
else if (e.code === 'ArrowLeft' && paused) { /* step back not supported; noop */ }
});
function togglePause() {
paused = !paused;
$('pauseBtn').textContent = paused ? 'Play' : 'Pause';
$('pauseBtn').classList.toggle('active', paused);
$('pausedState').textContent = paused ? 'yes' : 'no';
}
// --- Timing ---
let last = performance.now();
let accumulator = 0;
let fps = 60, fpsTimer = 0, frameCount = 0;
let ptime = 0;
function loop(now) {
let delta = (now - last) / 1000;
last = now;
if (delta > CONFIG.maxDelta) delta = CONFIG.maxDelta; // tab-switch guard
// FPS
frameCount++; fpsTimer += delta;
if (fpsTimer >= 0.25) { fps = frameCount / fpsTimer; frameCount = 0; fpsTimer = 0; }
if (!paused) {
const t0 = performance.now();
accumulator += delta;
while (accumulator >= CONFIG.timestep) {
step(sim);
accumulator -= CONFIG.timestep;
}
ptime = performance.now() - t0;
} else {
// still integrate particles + grab follow so cut/trail stay alive
}
// Update particles
for (const pt of sim.particles) {
pt.x += pt.vx * CONFIG.timestep; pt.y += pt.vy * CONFIG.timestep;
pt.vy += CONFIG.gravity * 0.3 * CONFIG.timestep;
pt.life -= CONFIG.timestep * 2.2;
}
sim.particles = sim.particles.filter(p => p.life > 0);
// Preview follows drag state
sim.preview = sim.dragging;
render(sim, ctx, CONFIG.showStress, CONFIG.showTrail);
// Readouts
$('fps').textContent = fps.toFixed(0);
$('ptime').textContent = ptime.toFixed(2);
$('stats').textContent = sim.points.length;
$('conns').textContent = sim.constraints.filter(c => !c.broken).length;
requestAnimationFrame(loop);
}
// Populate with a starter scene BEFORE starting the loop
populate(sim);
requestAnimationFrame(loop);
}
function populate(sim) {
spawnRope(sim, sim.w * 0.30, sim.h * 0.28, sim.w * 0.30, sim.h * 0.28 + 160);
spawnCloth(sim, sim.w * 0.55, sim.h * 0.26, 220, 180);
spawnSoftBody(sim, sim.w * 0.28, sim.h * 0.5, 44, 'blob');
spawnBall(sim, sim.w * 0.72, sim.h * 0.32);
}
/* =========================================================================
SECTION 9 — WINDOW EXPOSURE (headless probing)
========================================================================= */
Object.defineProperty(window, 'sim', {
get() { return this._simRef; },
set(v) { this._simRef = v; }
});
window.CONFIG = CONFIG;
window.__makeSim = makeSim;
window.__spawn = {
rope: (x0, y0, x1, y1) => spawnRope(window.sim, x0, y0, x1, y1),
cloth: (x, y, w, h) => spawnCloth(window.sim, x, y, w, h),
softbody: (cx, cy, size, kind) => spawnSoftBody(window.sim, cx, cy, size, kind),
ball: (x, y) => spawnBall(window.sim, x, y),
};
// Kick off and expose the live sim for headless probing.
main();
window.sim = window._liveSim;
</script>
</body>
</html>