Physics Playground
Deepseek v4.1 Flash · 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 50,283 bytes · SHA-256 3d2f24be5f52
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>Soft-Body Physics Playground</title>
<style>
/* ============================================================
STYLES — dark playground, minimal out-of-the-way chrome
============================================================ */
*{box-sizing:border-box}
html,body{margin:0;height:100%;overflow:hidden;background:#07090e;
font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
color:#cfd6e6;-webkit-font-smoothing:antialiased;user-select:none}
canvas{position:fixed;top:0;left:0;width:100%;height:100%;display:block;touch-action:none;cursor:crosshair}
/* top-left brand + readouts */
.topbar{position:fixed;top:14px;left:16px;display:flex;flex-direction:column;gap:5px;pointer-events:none;z-index:4}
.brand{font-size:12px;letter-spacing:.3em;font-weight:700;color:#7e93b8;text-shadow:0 0 22px rgba(80,140,255,.45)}
.brand b{color:#eaf1ff}
.stats{font-size:10.5px;font-variant-numeric:tabular-nums;color:#54688a;letter-spacing:.07em}
.stats i{font-style:normal;color:#9fb4d8}
.stats s{text-decoration:none;color:#3f4f6b}
/* bottom toolbar */
.tools{position:fixed;left:50%;bottom:14px;transform:translateX(-50%);display:flex;gap:5px;padding:6px;
border-radius:15px;background:rgba(15,19,29,.74);backdrop-filter:blur(12px) saturate(140%);
border:1px solid rgba(120,160,255,.13);box-shadow:0 14px 40px rgba(0,0,0,.55);z-index:5}
.tools button{appearance:none;border:1px solid transparent;background:rgba(255,255,255,.035);color:#93a7c9;
font:inherit;font-size:11px;letter-spacing:.06em;padding:7px 11px;border-radius:10px;cursor:pointer;
transition:background .13s,color .13s,border-color .13s,transform .08s}
.tools button:hover{background:rgba(255,255,255,.085);color:#e6eeff}
.tools button:active{transform:translateY(1px)}
.tools button.on{background:linear-gradient(180deg,rgba(70,120,255,.34),rgba(70,120,255,.16));
border-color:rgba(130,175,255,.5);color:#eef4ff;box-shadow:0 0 16px rgba(80,140,255,.28) inset}
.tools .sep{width:1px;align-self:stretch;margin:3px 4px;background:rgba(140,170,230,.14)}
.tools button.warn.on{background:linear-gradient(180deg,rgba(255,80,110,.3),rgba(255,80,110,.14));
border-color:rgba(255,120,140,.5);color:#ffe9ee;box-shadow:0 0 16px rgba(255,80,110,.25) inset}
/* right panel */
.panel{position:fixed;top:14px;right:16px;width:218px;padding:11px 13px 10px;border-radius:15px;
background:rgba(15,19,29,.74);backdrop-filter:blur(12px) saturate(140%);
border:1px solid rgba(120,160,255,.13);box-shadow:0 14px 40px rgba(0,0,0,.55);z-index:5}
.panel h4{margin:0 0 8px;font-size:9.5px;letter-spacing:.22em;color:#6d80a3;font-weight:600;text-transform:uppercase}
.row{display:flex;align-items:center;gap:9px;margin:8px 0}
.row label{flex:0 0 58px;font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:#71849f}
.row input[type=range]{flex:1;appearance:none;-webkit-appearance:none;height:3px;border-radius:3px;background:#26314a;outline:none}
.row input[type=range]::-webkit-slider-thumb{appearance:none;-webkit-appearance:none;width:12px;height:12px;border-radius:50%;
background:#eaf1ff;box-shadow:0 0 10px rgba(90,150,255,.85);cursor:pointer;border:none}
.row input[type=range]::-moz-range-thumb{width:12px;height:12px;border:none;border-radius:50%;background:#eaf1ff;cursor:pointer}
.val{flex:0 0 36px;text-align:right;font-size:10.5px;color:#a8bcdd;font-variant-numeric:tabular-nums}
.chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:9px}
.chip{font-size:10px;letter-spacing:.05em;padding:4px 8px;border-radius:8px;cursor:pointer;
border:1px solid rgba(120,160,255,.16);background:rgba(255,255,255,.03);color:#8296b8;transition:.13s}
.chip:hover{color:#dbe6fa;background:rgba(255,255,255,.07)}
.chip.on{background:rgba(70,190,160,.2);border-color:rgba(90,220,190,.5);color:#d8fff5}
select{width:100%;margin-top:8px;appearance:none;background:rgba(255,255,255,.05);color:#b9caea;
border:1px solid rgba(120,160,255,.16);border-radius:9px;padding:6px 8px;font:inherit;font-size:10.5px;cursor:pointer;outline:none}
.hint{position:fixed;left:16px;top:44%;max-width:236px;font-size:10px;line-height:1.7;color:#41526e;z-index:4;pointer-events:none;letter-spacing:.03em}
.hint b{color:#6b81a6;font-weight:600}
@media (max-width:820px){
.panel{width:176px;transform:scale(.92);transform-origin:top right}
.hint{display:none}
.tools button{padding:7px 9px;font-size:10px}
}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div class="topbar">
<div class="brand">SOFT<b>·</b>BODY<b>·</b>LAB</div>
<div class="stats" id="stats">—</div>
</div>
<div class="panel">
<h4>Physics</h4>
<div class="row"><label>Gravity</label><input id="sGrav" type="range" min="0" max="2600" step="20" value="1600"><span class="val" id="vGrav">1600</span></div>
<div class="row"><label>Solve</label><input id="sIter" type="range" min="1" max="12" step="1" value="5"><span class="val" id="vIter">5</span></div>
<div class="row"><label>Damping</label><input id="sDamp" type="range" min="0.9" max="1" step="0.001" value="0.997"><span class="val" id="vDamp">.997</span></div>
<div class="row"><label>Wind</label><input id="sWind" type="range" min="-900" max="900" step="10" value="0"><span class="val" id="vWind">0</span></div>
<div class="row"><label>Bounce</label><input id="sBounce" type="range" min="0" max="0.8" step="0.02" value="0.24"><span class="val" id="vBounce">.24</span></div>
<div class="chips">
<div class="chip on" id="cGrav">gravity</div>
<div class="chip" id="cStress">stress</div>
<div class="chip" id="cPoints">points</div>
<div class="chip" id="cTrail">trail</div>
<div class="chip on" id="cPts">point-collide</div>
</div>
<select id="pinSel">
<option value="edge">cloth pins: full top edge</option>
<option value="corners">cloth pins: both corners</option>
<option value="none">cloth pins: none</option>
</select>
</div>
<div class="tools">
<button data-tool="grab" class="on">Grab</button>
<button data-tool="rope">Rope</button>
<button data-tool="cloth">Cloth</button>
<button data-tool="soft">Blob</button>
<button data-tool="box">Box</button>
<button data-tool="ball">Ball</button>
<button data-tool="tear" class="warn">Tear</button>
<div class="sep"></div>
<button id="bPause">Pause</button>
<button id="bStep">Step</button>
<button id="bClear">Reset</button>
</div>
<div class="hint">
<b>drag</b> spawn · <b>click point</b> grab, drag to swing · <b>shift+click</b> pin/unpin<br>
<b>right-drag</b> tear · <b>space</b> pause · <b>→</b> step · <b>1–7</b> tools · <b>R</b> reset
</div>
<script>
"use strict";
/* ==================================================================================
SOFT-BODY PHYSICS PLAYGROUND
Position-based dynamics: Verlet integration + iterative constraint relaxation.
Sections: 1 CONFIG · 2 UTIL · 3 MODEL · 4 WORLD · 5 SPAWNING · 6 PHYSICS STEP
7 COLLISION · 8 INTERACTION · 9 RENDER · 10 UI · 11 LOOP · 12 SCENE
================================================================================== */
/* ============================ 1. TUNABLE CONSTANTS ============================ */
const CFG = {
dt: 1/60, // fixed physics timestep (s) — decoupled from render
maxFrame: 0.05, // clamp frame delta (tab switch / hitch protection)
maxSubSteps: 8, // hard cap on catch-up steps per frame
iterations: 5, // constraint relaxation passes per step (3–8 is sweet)
damping: 0.997, // per-step velocity retention (energy must decay)
gravity: 1600, // px/s² (slider driven)
gravityOn: true,
wind: 0, // horizontal acceleration px/s²
friction: 0.90, // tangential velocity retention per contact pass
bounce: 0.24, // normal restitution
restVel: 0.55, // below this inbound speed contacts don't bounce (kills rest jitter)
maxSpeed: 45, // px/step velocity clamp (anti-explosion guard)
grabRadius: 30, // px — click-to-grab search radius
tearRadius: 15, // px — slice corridor half-width
tearStretch: 2.35, // constraint breaks past this stretch ratio
pointCollide: true, // particle-vs-particle collision
stress: false, // colour constraints by stretch ratio (toggle / S key)
showPoints: false, // show cloth vertices
trail: false, // motion blur
bodyRadius: 2.0, // default point dot radius
};
const COL = {
bg: "#07090e",
bg2: "#0c1119",
grid: "rgba(120,160,255,0.045)",
wall: "rgba(130,170,255,0.10)",
floor: "rgba(140,180,255,0.16)",
staticA: "rgba(150,180,240,0.09)",
staticB: "rgba(170,200,255,0.30)",
rope: 34, // hue
cloth: 192,
soft: 302,
ball: 48,
tear: "#ff5a7a",
};
/* ============================ 2. SMALL UTILITIES ============================ */
const clamp = (v,a,b)=> v<a?a:(v>b?b:v);
const lerp = (a,b,t)=> a+(b-a)*t;
const rnd = (a,b)=> a+Math.random()*(b-a);
/* deterministic hash → [-1,1], used to break exact degeneracies without randomness */
function hashNudge(i){ const x = Math.sin(i*12.9898)*43758.5453; return (x-Math.floor(x))*2-1; }
/* ============================ 3. MODEL ============================ */
let NEXT_ID = 0;
class Particle {
constructor(x,y,opt){
opt = opt||{};
this.id = NEXT_ID++;
this.x = x; this.y = y;
this.px = x; this.py = y; // previous position → implicit velocity
this.ax = 0; this.ay = 0; // extra per-particle acceleration
this.r = opt.r || 0;
this.mass = opt.mass===undefined ? 1 : opt.mass;
this.baseInvMass = 1/this.mass;
this.pinned = !!opt.pinned;
this.invMass = this.pinned ? 0 : this.baseInvMass;
this.collide = !!opt.collide;
this.obj = null;
this.flash = 0; // pin-toggle visual pulse
}
/* velocity in px/step and px/s */
vx(){ return this.x - this.px; }
vy(){ return this.y - this.py; }
}
class Constraint {
constructor(a,b,stiff,obj){
this.a = a; this.b = b;
const dx = b.x-a.x, dy = b.y-a.y;
this.rest = Math.max(Math.hypot(dx,dy), 0.001); // guard: never zero-length
this.stiff = stiff===undefined ? 1 : stiff;
this.obj = obj || null;
this.dead = false;
this.shear = false; // pure-shear brace (drawn fainter)
this.stretch = 1; // last measured ratio — used for stress colouring
}
}
/* A spawned object: owns its particles + constraints and its draw style */
class SoftObject {
constructor(type, hue){
this.type = type; this.hue = hue;
this.points = []; this.constraints = [];
this.kind = "mesh"; // rope | cloth | blob | ball
}
}
/* ============================ 4. WORLD ============================ */
const canvas = document.getElementById("c");
const ctx = canvas.getContext("2d", { alpha:false });
const sim = {
points: [], // flat list — physics iterates these
constraints: [], // flat list
objects: [],
statics: [], // {kind:'circle'|'box', ...}
walls: {l:0,t:0,r:0,b:0},
time: 0, steps: 0, paused: false,
W: 0, H: 0, DPR: 1,
gravityScale: 1,
};
function resize(){
const dpr = Math.min(window.devicePixelRatio || 1, 2);
sim.DPR = dpr;
sim.W = canvas.clientWidth || window.innerWidth;
sim.H = canvas.clientHeight || window.innerHeight;
canvas.width = Math.round(sim.W*dpr);
canvas.height = Math.round(sim.H*dpr);
ctx.setTransform(dpr,0,0,dpr,0,0);
sim.walls = { l:10, t:-4000, r:sim.W-10, b:sim.H-10 }; // top open: let things fly up
}
window.addEventListener("resize", resize);
/* ============================ 5. SPAWNING ============================ */
function link(a,b,stiff,obj,shear){
const c = new Constraint(a,b,stiff,obj);
c.shear = !!shear;
obj.constraints.push(c);
sim.constraints.push(c);
return c;
}
function pt(x,y,opt,obj){
const p = new Particle(x,y,opt);
p.obj = obj;
obj.points.push(p);
sim.points.push(p);
return p;
}
/* ---- ROPE: chain of points, pinned at the start ---- */
function spawnRope(x1,y1,x2,y2,opts){
opts = opts||{};
const o = new SoftObject("rope", COL.rope);
o.kind = "rope";
const dist = Math.hypot(x2-x1,y2-y1);
const seg = 13;
const n = clamp(Math.round(dist/seg)+1, 3, 90);
const ux = (x2-x1)/(n-1), uy = (y2-y1)/(n-1);
let prev = null;
for (let i=0;i<n;i++){
const p = pt(x1+ux*i, y1+uy*i, {r:1.5, pinned:(i===0 && !opts.unpinned)}, o);
if (prev) link(prev,p,1,o);
prev = p;
}
sim.objects.push(o);
return o;
}
/* ---- CLOTH: grid of points, structural + shear links ---- */
function spawnCloth(x1,y1,x2,y2,opts){
opts = opts||{};
const o = new SoftObject("cloth", COL.cloth);
o.kind = "cloth";
const w = Math.abs(x2-x1), h = Math.abs(y2-y1);
const spacing = 17;
const cols = clamp(Math.round(w/spacing), 2, 42)+1;
const rows = clamp(Math.round(h/spacing), 2, 30)+1;
const x0 = Math.min(x1,x2), y0 = Math.min(y1,y2);
const dx = (w||spacing*(cols-1))/(cols-1);
const dy = (h||spacing*(rows-1))/(rows-1);
const grid = [];
for (let j=0;j<rows;j++){
const row = [];
for (let i=0;i<cols;i++){
const pin = (j===0) && (
opts.pin==="none" ? false :
opts.pin==="corners"? (i===0 || i===cols-1) :
true);
row.push(pt(x0+dx*i, y0+dy*j, {r:1.3, pinned:pin}, o));
}
grid.push(row);
}
for (let j=0;j<rows;j++) for (let i=0;i<cols;i++){
const p = grid[j][i];
if (i<cols-1) link(p, grid[j][i+1], 1, o); // structural →
if (j<rows-1) link(p, grid[j+1][i], 1, o); // structural ↓
if (i<cols-1 && j<rows-1){ // shear braces
link(p, grid[j+1][i+1], 0.34, o, true);
link(grid[j][i+1], grid[j+1][i], 0.34, o, true);
}
}
o.grid = grid; o.cols = cols; o.rows = rows;
o.restArea = dx*dy;
/* optional pre-deformation: rest lengths still describe a flat sheet, but the
points start squeezed/sheared so the cloth can unfurl into a drape */
if (opts.squash){
const k = opts.squash;
for (let j=1;j<rows;j++){
for (let i=0;i<cols;i++){
const p = grid[j][i];
const ny = y0 + (p.y - y0)*k;
p.py += ny - p.y; p.y = ny;
}
}
}
if (opts.shear){
const k = opts.shear / (rows-1);
for (let j=1;j<rows;j++){
const off = k*j;
for (let i=0;i<cols;i++){
const p = grid[j][i];
p.x += off; p.px += off;
}
}
}
sim.objects.push(o);
return o;
}
/* ---- SOFT BODY: blob — ring + hub + bracing ---- */
function spawnBlob(cx,cy,rad,opts){
opts = opts||{};
const o = new SoftObject("soft", COL.soft);
o.kind = "blob";
const n = clamp(Math.round(2*Math.PI*rad/15), 8, 26);
const ring = [];
for (let i=0;i<n;i++){
const a = i/n*Math.PI*2;
ring.push(pt(cx+Math.cos(a)*rad, cy+Math.sin(a)*rad, {r:2.4, collide:true}, o));
}
const hub = pt(cx, cy, {r:2.4, collide:true}, o);
for (let i=0;i<n;i++){
const a = ring[i], b = ring[(i+1)%n];
link(a,b,1,o); // perimeter (drawn crisp)
link(a,hub,0.32,o,true).hidden = true; // spokes hold volume (internal)
link(a,ring[(i+2)%n],0.22,o,true).hidden = true; // braces resist shear / collapse
}
o.ring = ring; o.hub = hub; o.rad = rad;
sim.objects.push(o);
return o;
}
/* ---- BALL: one particle with radius ---- */
function spawnBall(x,y,r,opts){
opts = opts||{};
const o = new SoftObject("ball", COL.ball);
o.kind = "ball";
const p = pt(x,y,{r:Math.max(5,r), collide:true, mass: Math.max(1, r/9)}, o);
o.ball = p;
sim.objects.push(o);
return o;
}
/* ---- BOX soft body (grid squished into a blob-ish box) ---- */
function spawnSoftBox(x1,y1,x2,y2){
const o = new SoftObject("soft", COL.soft);
o.kind = "blob";
const cols = clamp(Math.round(Math.abs(x2-x1)/22),2,12)+1;
const rows = clamp(Math.round(Math.abs(y2-y1)/22),2,12)+1;
const x0 = Math.min(x1,x2), y0 = Math.min(y1,y2);
const dx = Math.abs(x2-x1)/(cols-1), dy = Math.abs(y2-y1)/(rows-1);
const grid = [];
for (let j=0;j<rows;j++){ const row=[];
for (let i=0;i<cols;i++) row.push(pt(x0+dx*i, y0+dy*j, {r:2.2, collide:true}, o));
grid.push(row);
}
for (let j=0;j<rows;j++) for (let i=0;i<cols;i++){
const p=grid[j][i];
if (i<cols-1) link(p,grid[j][i+1],1,o);
if (j<rows-1) link(p,grid[j+1][i],1,o);
if (i<cols-1&&j<rows-1){ link(p,grid[j+1][i+1],0.3,o,true); link(grid[j][i+1],grid[j+1][i],0.3,o,true); }
if (i<cols-2) link(p,grid[j][i+2],0.25,o,true).hidden = true;
if (j<rows-2) link(p,grid[j+2][i],0.25,o,true).hidden = true;
}
o.ring = grid.flat(); o.restArea = dx*dy;
sim.objects.push(o);
return o;
}
/* ---- clear everything ---- */
function clearWorld(){
sim.points.length = 0;
sim.constraints.length = 0;
sim.objects.length = 0;
sim.time = 0; sim.steps = 0;
}
/* ============================ 6. PHYSICS STEP ============================ */
const perfNow = ()=>performance.now();
const STAT = { phys:0, fps:60, cut:0, lastCut:0 };
/* --- Verlet integration: x' = x + (x - xPrev)*damping + a*dt² --- */
function integrate(dt){
const dt2 = dt*dt;
const gx = CFG.gravityOn ? CFG.wind : CFG.wind*0.6;
const gy = CFG.gravityOn ? CFG.gravity : 0;
const damp = CFG.damping, maxV = CFG.maxSpeed;
const pts = sim.points;
for (let i=0;i<pts.length;i++){
const p = pts[i];
if (p.invMass === 0){ p.px = p.x; p.py = p.y; p.ax = 0; p.ay = 0; continue; }
let vx = (p.x - p.px) * damp;
let vy = (p.y - p.py) * damp;
const sp = Math.sqrt(vx*vx + vy*vy);
if (sp > maxV){ // anti-explosion velocity clamp
const k = maxV/sp; vx *= k; vy *= k;
}
p.px = p.x; p.py = p.y;
p.x += vx + (gx + p.ax) * dt2;
p.y += vy + (gy + p.ay) * dt2;
}
}
/* --- one relaxation pass over every distance constraint --- */
function solveConstraints(measure){
const arr = sim.constraints;
for (let i=0;i<arr.length;i++){
const c = arr[i];
if (c.dead) continue;
const a = c.a, b = c.b;
let dx = b.x - a.x, dy = b.y - a.y;
let d2 = dx*dx + dy*dy;
/* guard: coincident points would divide by ~0 → deterministic micro-nudge */
if (d2 < 1e-10){
dx = hashNudge(c.a.id) * 0.01; dy = 0.01; d2 = dx*dx + dy*dy;
}
const d = Math.sqrt(d2);
if (!(d > 1e-9)){ continue; } // guard: degenerate → skip
if (measure) c.stretch = d / c.rest;
const sa = a.invMass, sb = b.invMass;
const tot = sa + sb;
if (tot <= 0) continue; // both pinned → nothing to do
const diff = (d - c.rest) / d;
/* tear: over-stretched links snap (ropes split, cloth rips) */
if (c.rest > 3 && d > c.rest * CFG.tearStretch){
c.dead = true; c.torn = true; dirty = true;
addSpark((a.x+b.x)*0.5, (a.y+b.y)*0.5, c.obj ? c.obj.hue : 0, 3);
continue;
}
const k = c.stiff;
const fa = (sa/tot) * diff * k;
const fb = (sb/tot) * diff * k;
a.x += dx*fa; a.y += dy*fa;
b.x -= dx*fb; b.y -= dy*fb;
}
}
/* --- grabbed point follows the cursor (hard pin → solver reacts live) --- */
function applyGrab(){
const p = input.grabbed;
if (!p) return;
p.x = input.mouse.x; p.y = input.mouse.y;
}
/* --- NaN / runaway guard, cheap insurance every step --- */
function sanitize(){
const W = sim.W, H = sim.H;
const pts = sim.points;
for (let i=0;i<pts.length;i++){
const p = pts[i];
if (!Number.isFinite(p.x) || !Number.isFinite(p.y) ||
!Number.isFinite(p.px) || !Number.isFinite(p.py) ||
Math.abs(p.x) > 1e6 || Math.abs(p.y) > 1e6){
p.x = Number.isFinite(p.x) ? clamp(p.x,-1e5,1e5) : W*0.5;
p.y = Number.isFinite(p.y) ? clamp(p.y,-1e5,1e5) : H*0.5;
p.px = p.x; p.py = p.y;
p.invMass = p.pinned ? 0 : p.baseInvMass;
}
}
}
/* --- drop constraints flagged dead --- */
let dirty = false;
function compact(){
if (!dirty) return;
dirty = false;
const c = sim.constraints;
let w = 0;
for (let i=0;i<c.length;i++){ const cc = c[i]; if (!cc.dead) c[w++] = cc; }
c.length = w;
for (const o of sim.objects){
const oc = o.constraints;
if (!oc.length) continue;
let k = 0;
for (let i=0;i<oc.length;i++){ if (!oc[i].dead) oc[k++] = oc[i]; }
oc.length = k;
}
}
/* --- one fixed-timestep physics tick --- */
function step(dt){
dt = dt || CFG.dt;
const t0 = perfNow();
integrate(dt);
applyGrab();
const iter = Math.max(1, CFG.iterations|0);
for (let it=0; it<iter; it++){
solveConstraints(it === 0);
collideAll();
}
if (CFG.pointCollide){ pointCollisions(); pointCollisions(); }
sanitize();
/* Contact drag: per-point friction cannot brake a rolling body (its contact
points are already stationary), so any body touching a static shape this
step bleeds a little velocity as a whole — rolling resistance. */
const st = sim.steps + 1;
for (let i=0;i<sim.objects.length;i++){
const o = sim.objects[i];
if (o.touch !== st) continue;
const pts = o.points;
for (let j=0;j<pts.length;j++){
const p = pts[j];
if (p.invMass === 0) continue;
p.px += (p.x - p.px) * 0.022;
p.py += (p.y - p.py) * 0.022;
}
}
compact();
sim.time += dt;
sim.steps++;
STAT.phys = perfNow() - t0;
}
/* ============================ 7. COLLISION ============================ */
/* impulse-free contact response: kill inbound normal velocity, damp tangent */
function contact(p, nx, ny){
const vx = p.x - p.px, vy = p.y - p.py;
const vn = vx*nx + vy*ny;
const tx = vx - vn*nx, ty = vy - vn*ny;
const nvn = vn < 0 ? (vn < -CFG.restVel ? -vn*CFG.bounce : 0) : vn; // no micro-bounce at rest
p.px = p.x - (tx*CFG.friction + nvn*nx);
p.py = p.y - (ty*CFG.friction + nvn*ny);
if (p.obj) p.obj.touch = sim.steps + 1; // body is in contact this step
}
/* Teleport a particle by (ox,oy) WITHOUT creating velocity: previous position
moves by the same amount. Deep ejections from a static then can't detonate
the sim — the contact response below only handles the true velocity part. */
function shift(p, ox, oy){
p.x += ox; p.y += oy;
p.px += ox; p.py += oy;
}
function collideCircle(p, s){
const rr = s.r + p.r, eps = 0.05;
let dx = p.x - s.x, dy = p.y - s.y;
let d = Math.sqrt(dx*dx + dy*dy);
if (d < rr + eps){
if (d < 1e-6){ dx = 0; dy = -1; d = 1e-6; }
const nx = dx/d, ny = dy/d, push = rr - d;
if (push > 0) shift(p, nx*push, ny*push);
contact(p, nx, ny);
return;
}
/* swept test — a fast mover must not jump straight through the disc */
const mx = p.x - p.px, my = p.y - p.py;
const L2 = mx*mx + my*my;
if (L2 < 1e-9) return;
let t = ((s.x - p.px)*mx + (s.y - p.py)*my) / L2;
t = t < 0 ? 0 : (t > 1 ? 1 : t);
const ex = p.px + t*mx - s.x, ey = p.py + t*my - s.y;
if (ex*ex + ey*ey < rr*rr){
const nx = dx/d, ny = dy/d;
shift(p, nx*(rr-d), ny*(rr-d));
contact(p, nx, ny);
}
}
function collideBox(p, s){
/* swept top-face guard — thin slabs otherwise get tunnelled through */
if (p.py <= s.y && p.y > s.y && p.x > s.x - p.r && p.x < s.x + s.w + p.r){
shift(p, 0, (s.y - p.r) - p.y);
contact(p, 0, -1);
return;
}
const cx = clamp(p.x, s.x, s.x+s.w);
const cy = clamp(p.y, s.y, s.y+s.h);
let dx = p.x - cx, dy = p.y - cy;
const d = Math.sqrt(dx*dx + dy*dy);
if (d > 1e-6){
if (d < p.r + 0.05){
const nx = dx/d, ny = dy/d, push = p.r - d;
if (push > 0) shift(p, nx*push, ny*push);
contact(p, nx, ny);
}
return;
}
/* centre inside the box → eject along the nearest face */
const dl = p.x - s.x, dr = s.x + s.w - p.x;
const dt2 = p.y - s.y, db = s.y + s.h - p.y;
const m = Math.min(dl, dr, dt2, db);
if (m === dl){ shift(p, (s.x - p.r) - p.x, 0); contact(p,-1, 0); }
else if (m === dr){ shift(p, (s.x+s.w + p.r) - p.x, 0); contact(p, 1, 0); }
else if (m === dt2){ shift(p, 0, (s.y - p.r) - p.y); contact(p, 0,-1); }
else { shift(p, 0, (s.y+s.h + p.r) - p.y); contact(p, 0, 1); }
}
/* particles + walls + static shapes */
function collideAll(){
const pts = sim.points, st = sim.statics, w = sim.walls;
for (let i=0;i<pts.length;i++){
const p = pts[i];
if (p.invMass === 0) continue;
const r = p.r, eps = 0.05; // eps keeps resting contacts registering
if (p.y + r > w.b - eps){ shift(p, 0, (w.b - r) - p.y); contact(p, 0,-1); }
if (p.y - r < w.t + eps){ shift(p, 0, (w.t + r) - p.y); contact(p, 0, 1); }
if (p.x - r < w.l + eps){ shift(p, (w.l + r) - p.x, 0); contact(p, 1, 0); }
if (p.x + r > w.r - eps){ shift(p, (w.r - r) - p.x, 0); contact(p,-1, 0); }
for (let k=0;k<st.length;k++){
const s = st[k];
if (s.kind === "circle") collideCircle(p,s); else collideBox(p,s);
}
}
}
/* simple particle-vs-particle collision via a uniform hash grid */
const _grid = new Map();
function pointCollisions(){
const all = sim.points, list = [];
for (let i=0;i<all.length;i++){
const p = all[i];
if (p.collide && p.r > 0 && p.invMass > 0) list.push(p);
}
if (list.length < 2) return;
let maxr = 0;
for (const p of list) if (p.r > maxr) maxr = p.r;
const cell = maxr * 2;
_grid.clear();
for (const p of list){
const k = ((p.x/cell)|0) + "," + ((p.y/cell)|0);
let a = _grid.get(k);
if (!a){ a = []; _grid.set(k,a); }
a.push(p);
}
for (const p of list){
const cx = (p.x/cell)|0, cy = (p.y/cell)|0;
for (let ox=-1; ox<=1; ox++) for (let oy=-1; oy<=1; oy++){
const a = _grid.get((cx+ox)+","+(cy+oy));
if (!a) continue;
for (let q=0;q<a.length;q++){
const o = a[q];
if (o.id <= p.id) continue;
let dx = o.x-p.x, dy = o.y-p.y;
const rr = p.r + o.r;
let d2 = dx*dx + dy*dy;
if (d2 >= rr*rr) continue;
if (d2 < 1e-10){ dx = hashNudge(p.id) * 0.05; dy = 0.05; d2 = dx*dx+dy*dy; }
const d = Math.sqrt(d2);
const nx = dx/d, ny = dy/d;
const sa = p.invMass, sb = o.invMass, tot = sa+sb;
if (tot <= 0) continue;
const push = (rr - d) * 0.5;
const fa = (sa/tot)*push, fb = (sb/tot)*push;
p.x -= nx*fa; p.y -= ny*fa;
o.x += nx*fb; o.y += ny*fb;
contact(p,-nx,-ny); contact(o, nx, ny);
}
}
}
}
/* ============================ 8. INTERACTION ============================ */
const TOOLS = ["grab","rope","cloth","soft","box","ball","tear"];
const input = {
tool:"grab", grabbed:null, spawning:false, cutting:false,
spawnStart:null, mouse:{x:0,y:0,px:0,py:0}, hover:null,
};
function updateMouse(e){
const r = canvas.getBoundingClientRect();
input.mouse.x = clamp(e.clientX - r.left, 12, sim.W-12);
input.mouse.y = clamp(e.clientY - r.top, 12, sim.H-12);
}
function nearestPoint(x,y,rad,skipPinned){
let best = null, bd = rad*rad;
for (const p of sim.points){
if (skipPinned && p.pinned) continue;
const dx = p.x-x, dy = p.y-y, d2 = dx*dx+dy*dy;
if (d2 < bd){ bd = d2; best = p; }
}
return best;
}
function grabPoint(p){
input.grabbed = p;
p.invMass = 0; // hard-follow the cursor; solver reacts around it
p.px = p.x; p.py = p.y; // start from rest so the throw velocity is the drag
}
function releaseGrab(){
const p = input.grabbed;
if (p && !p.pinned) p.invMass = p.baseInvMass;
input.grabbed = null;
}
function togglePinNear(x,y){
const p = nearestPoint(x,y,CFG.grabRadius,false);
if (!p) return;
p.pinned = !p.pinned;
p.invMass = p.pinned ? 0 : p.baseInvMass;
if (input.grabbed === p && !p.pinned) releaseGrab();
p.flash = 1;
addSpark(p.x, p.y, p.pinned ? 150 : 20, 6);
}
/* segment-segment intersection (with sign test) */
function segSeg(x1,y1,x2,y2,x3,y3,x4,y4){
const d = (x2-x1)*(y4-y3) - (y2-y1)*(x4-x3);
if (Math.abs(d) < 1e-12) return false;
const t = ((x3-x1)*(y4-y3) - (y3-y1)*(x4-x3)) / d;
const u = ((x3-x1)*(y2-y1) - (y3-y1)*(x2-x1)) / d;
return t >= 0 && t <= 1 && u >= 0 && u <= 1;
}
function distToSeg(px,py,x1,y1,x2,y2){
const dx = x2-x1, dy = y2-y1;
const L = dx*dx+dy*dy;
let t = L > 0 ? ((px-x1)*dx + (py-y1)*dy)/L : 0;
t = clamp(t,0,1);
const ex = x1+t*dx-px, ey = y1+t*dy-py;
return Math.sqrt(ex*ex+ey*ey);
}
function tearAlong(x1,y1,x2,y2){
let cut = 0;
const arr = sim.constraints;
for (let i=0;i<arr.length;i++){
const c = arr[i];
if (c.dead) continue;
if (segSeg(x1,y1,x2,y2, c.a.x,c.a.y, c.b.x,c.b.y)){
c.dead = true; cut++;
addSpark((c.a.x+c.b.x)*0.5, (c.a.y+c.b.y)*0.5, c.obj ? c.obj.hue : 0, 2);
}
}
if (cut){ dirty = true; STAT.cut += cut; }
}
function tearAtPoint(x,y){
let cut = 0;
const arr = sim.constraints, r = CFG.tearRadius;
for (let i=0;i<arr.length;i++){
const c = arr[i];
if (c.dead) continue;
if (distToSeg(x,y, c.a.x,c.a.y, c.b.x,c.b.y) < r){
c.dead = true; cut++;
addSpark((c.a.x+c.b.x)*0.5, (c.a.y+c.b.y)*0.5, c.obj ? c.obj.hue : 0, 3);
}
}
if (cut){ dirty = true; STAT.cut += cut; }
}
/* --- spawning from a drag --- */
function finishSpawn(){
const s = input.spawnStart;
if (!s) return;
const e = input.mouse;
const dx = e.x - s.x, dy = e.y - s.y;
const d = Math.hypot(dx,dy);
switch (input.tool){
case "rope": spawnRope(s.x, s.y, d < 14 ? s.x+30 : e.x, d < 14 ? s.y+170 : e.y); break;
case "cloth": spawnCloth(s.x, s.y, d < 20 ? s.x+190 : e.x, d < 20 ? s.y+150 : e.y,
{pin: document.getElementById("pinSel").value}); break;
case "soft": spawnBlob(s.x, s.y, d < 20 ? 42 : clamp(d, 16, 130)); break;
case "box": spawnSoftBox(s.x, s.y, d < 24 ? s.x+80 : e.x, d < 24 ? s.y+70 : e.y); break;
case "ball": spawnBall(s.x, s.y, clamp(d < 16 ? 12 : d, 5, 90)); break;
}
input.spawnStart = null;
}
/* --- tear / interaction sparks --- */
const sparks = [];
function addSpark(x,y,hue,n){
for (let i=0;i<n;i++){
const a = Math.random()*Math.PI*2, s = rnd(0.5,3.2);
sparks.push({x,y,vx:Math.cos(a)*s, vy:Math.sin(a)*s-0.7, life:1, hue});
}
if (sparks.length > 400) sparks.splice(0, sparks.length-400);
}
const cutStrokes = []; // recent slice paths, for the glowing cut trail
/* --- pointer plumbing --- */
canvas.addEventListener("contextmenu", e=>e.preventDefault());
canvas.addEventListener("pointerdown", e=>{
e.preventDefault();
try { canvas.setPointerCapture(e.pointerId); } catch(_){}
input.mouse.px = input.mouse.x; input.mouse.py = input.mouse.y;
updateMouse(e);
input.mouse.px = input.mouse.x; input.mouse.py = input.mouse.y;
if (e.button === 2 || input.tool === "tear"){
input.cutting = true;
tearAtPoint(input.mouse.x, input.mouse.y);
cutStrokes.push({x1:input.mouse.x,y1:input.mouse.y,x2:input.mouse.x,y2:input.mouse.y,t:perfNow()});
return;
}
if (e.shiftKey || e.altKey){ togglePinNear(input.mouse.x, input.mouse.y); return; }
const p = nearestPoint(input.mouse.x, input.mouse.y, CFG.grabRadius, true);
if (p){ grabPoint(p); return; }
if (input.tool !== "grab"){
input.spawning = true;
input.spawnStart = {x:input.mouse.x, y:input.mouse.y};
}
});
canvas.addEventListener("pointermove", e=>{
input.mouse.px = input.mouse.x; input.mouse.py = input.mouse.y;
updateMouse(e);
if (input.cutting){
tearAlong(input.mouse.px, input.mouse.py, input.mouse.x, input.mouse.y);
cutStrokes.push({x1:input.mouse.px,y1:input.mouse.py,x2:input.mouse.x,y2:input.mouse.y,t:perfNow()});
if (cutStrokes.length > 24) cutStrokes.shift();
}
if (input.grabbed && sim.paused){
input.grabbed.x = input.mouse.x; input.grabbed.y = input.mouse.y;
input.grabbed.px = input.mouse.x; input.grabbed.py = input.mouse.y;
}
input.hover = input.grabbed ? null : nearestPoint(input.mouse.x, input.mouse.y, CFG.grabRadius, false);
});
function endPointer(){
if (input.cutting) input.cutting = false;
if (input.grabbed) releaseGrab();
if (input.spawning){ finishSpawn(); input.spawning = false; }
}
canvas.addEventListener("pointerup", endPointer);
canvas.addEventListener("pointercancel", endPointer);
canvas.addEventListener("pointerleave", ()=>{ input.hover = null; });
/* ============================ 9. RENDER ============================ */
let bgGrad = null;
const CELL_BUCKETS = 9;
function render(dtms){
const W = sim.W, H = sim.H;
if (CFG.trail){
ctx.fillStyle = "rgba(7,9,14,0.30)";
ctx.fillRect(0,0,W,H);
} else {
if (!bgGrad){
bgGrad = ctx.createLinearGradient(0,0,0,H);
bgGrad.addColorStop(0,"#0b1220");
bgGrad.addColorStop(0.55,"#080c14");
bgGrad.addColorStop(1,"#06080d");
}
ctx.fillStyle = bgGrad;
ctx.fillRect(0,0,W,H);
drawGrid(W,H);
}
drawStatics();
drawFloor(W,H);
drawCellMeshes();
drawBlobFills();
drawConstraints();
drawBalls();
drawDots();
drawOverlay(W,H,dtms);
}
function drawGrid(W,H){
ctx.strokeStyle = COL.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();
}
function drawFloor(W,H){
/* floor band */
const fg = ctx.createLinearGradient(0,H-64,0,H);
fg.addColorStop(0,"rgba(120,170,255,0)");
fg.addColorStop(1,"rgba(120,170,255,0.09)");
ctx.fillStyle = fg;
ctx.fillRect(0,H-64,W,64);
ctx.strokeStyle = COL.floor; ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0,H-10.5); ctx.lineTo(W,H-10.5);
ctx.stroke();
ctx.strokeStyle = COL.wall;
ctx.beginPath();
ctx.moveTo(10.5,0); ctx.lineTo(10.5,H);
ctx.moveTo(W-10.5,0); ctx.lineTo(W-10.5,H);
ctx.stroke();
}
function drawStatics(){
for (const s of sim.statics){
ctx.beginPath();
if (s.kind === "circle") ctx.arc(s.x, s.y, s.r, 0, Math.PI*2);
else {
const r = 4;
ctx.moveTo(s.x+r, s.y);
ctx.arcTo(s.x+s.w, s.y, s.x+s.w, s.y+s.h, r);
ctx.arcTo(s.x+s.w, s.y+s.h, s.x, s.y+s.h, r);
ctx.arcTo(s.x, s.y+s.h, s.x, s.y, r);
ctx.arcTo(s.x, s.y, s.x+s.w, s.y, r);
ctx.closePath();
}
ctx.fillStyle = COL.staticA;
ctx.fill();
ctx.strokeStyle = COL.staticB;
ctx.lineWidth = 1.2;
ctx.stroke();
}
}
/* filled grid meshes (cloth + soft box), shaded by cell strain */
function drawCellMeshes(){
for (const o of sim.objects){
const grid = o.grid;
if (!grid) continue;
const rows = o.rows, cols = o.cols;
const rest = o.restArea || 1;
const buckets = [];
for (let i=0;i<CELL_BUCKETS;i++) buckets.push(null);
for (let j=0;j<rows-1;j++){
const r0 = grid[j], r1 = grid[j+1];
for (let i=0;i<cols-1;i++){
const A = r0[i], B = r0[i+1], C = r1[i+1], D = r1[i];
const area = 0.5*Math.abs((B.x-A.x)*(C.y-A.y)-(B.y-A.y)*(C.x-A.x))
+ 0.5*Math.abs((D.x-A.x)*(C.y-A.y)-(D.y-A.y)*(C.x-A.x));
const s = area / rest;
if (s > 2.2) continue; // cell is torn open — don't fill it
let bi = Math.floor(clamp(s, 0.30, 1.60) / 1.6 * CELL_BUCKETS);
if (bi >= CELL_BUCKETS) bi = CELL_BUCKETS-1;
let arr = buckets[bi];
if (!arr){ arr = []; buckets[bi] = arr; }
arr.push(A.x,A.y,B.x,B.y,C.x,C.y,D.x,D.y);
}
}
for (let bi=0; bi<CELL_BUCKETS; bi++){
const arr = buckets[bi];
if (!arr) continue;
const t = (bi+0.5)/CELL_BUCKETS; // 0 = crumpled, 1 = taut
const light = 8 + 30*t;
ctx.fillStyle = `hsl(${o.hue} 62% ${light.toFixed(1)}%)`;
ctx.beginPath();
for (let i=0;i<arr.length;i+=8){
ctx.moveTo(arr[i], arr[i+1]);
ctx.lineTo(arr[i+2], arr[i+3]);
ctx.lineTo(arr[i+4], arr[i+5]);
ctx.lineTo(arr[i+6], arr[i+7]);
ctx.closePath();
}
ctx.fill();
}
}
}
/* filled polygons for round blobs */
function drawBlobFills(){
for (const o of sim.objects){
if (o.kind !== "blob" || !o.ring || o.grid) continue;
const ring = o.ring;
ctx.beginPath();
ctx.moveTo((ring[0].x + ring[ring.length-1].x)*0.5, (ring[0].y + ring[ring.length-1].y)*0.5);
for (let i=0;i<ring.length;i++){
const p = ring[i], q = ring[(i+1)%ring.length];
ctx.quadraticCurveTo(p.x, p.y, (p.x+q.x)*0.5, (p.y+q.y)*0.5);
}
ctx.closePath();
ctx.fillStyle = `hsl(${o.hue} 64% 26% / 0.85)`;
ctx.fill();
ctx.strokeStyle = `hsl(${o.hue} 70% 52% / 0.85)`;
ctx.lineWidth = 1.6;
ctx.stroke();
}
}
/* stress-coloured constraint rendering, bucketed for speed */
function drawConstraints(){
const groups = new Map();
const B = 24;
const arr = sim.constraints;
for (let i=0;i<arr.length;i++){
const c = arr[i];
if (c.dead) continue;
const a = c.a, b = c.b;
let key, col, faint = c.shear;
if (CFG.stress){
const dx = b.x-a.x, dy = b.y-a.y;
const d = Math.sqrt(dx*dx+dy*dy) || 1e-6;
const t = clamp((d/c.rest - 0.88)/0.47, 0, 1);
key = (t*(B-1))|0;
col = `hsl(${Math.round(132-132*t)} 88% 56%)`;
} else {
const h = c.obj ? c.obj.hue : 200;
key = "h"+h;
col = `hsl(${h} 72% 58%)`;
}
if (faint) key += "_f";
let g = groups.get(key);
if (!g){ g = {color:col, coords:[], faint:faint}; groups.set(key,g); }
g.coords.push(a.x,a.y,b.x,b.y);
}
ctx.lineCap = "round";
for (const g of groups.values()){
ctx.strokeStyle = g.color;
ctx.globalAlpha = g.faint ? 0.26 : 0.80;
ctx.lineWidth = g.faint ? 0.8 : 1.25;
ctx.beginPath();
const cc = g.coords;
for (let i=0;i<cc.length;i+=4){ ctx.moveTo(cc[i],cc[i+1]); ctx.lineTo(cc[i+2],cc[i+3]); }
ctx.stroke();
}
ctx.globalAlpha = 1;
}
function drawBalls(){
for (const o of sim.objects){
if (o.kind !== "ball") continue;
const p = o.ball;
const g = ctx.createRadialGradient(p.x-p.r*0.35, p.y-p.r*0.4, p.r*0.1, p.x, p.y, p.r);
g.addColorStop(0, `hsl(${o.hue} 95% 72%)`);
g.addColorStop(1, `hsl(${o.hue} 80% 40%)`);
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI*2);
ctx.fillStyle = g; ctx.fill();
ctx.strokeStyle = `hsl(${o.hue} 90% 80% / 0.5)`; ctx.lineWidth = 1; ctx.stroke();
}
}
function drawDots(){
for (const o of sim.objects){
if (o.kind === "cloth" && !CFG.showPoints) continue;
if (o.kind === "ball") continue;
const dot = o.kind === "rope" ? 1.9 : (o.kind === "blob" ? 1.3 : 2.2);
const pts = o.points;
/* free points in the object hue */
ctx.fillStyle = `hsl(${o.hue} 90% 76%)`;
ctx.globalAlpha = 0.92;
ctx.beginPath();
for (let i=0;i<pts.length;i++){
const p = pts[i];
if (p.pinned) continue;
ctx.moveTo(p.x + dot, p.y);
ctx.arc(p.x, p.y, dot, 0, Math.PI*2);
}
ctx.fill();
/* pinned points as bright anchors */
ctx.fillStyle = "#eef5ff";
ctx.globalAlpha = 1;
ctx.beginPath();
for (let i=0;i<pts.length;i++){
const p = pts[i];
if (!p.pinned) continue;
ctx.moveTo(p.x + dot + 0.8, p.y);
ctx.arc(p.x, p.y, dot + 0.8, 0, Math.PI*2);
}
ctx.fill();
ctx.globalAlpha = 1;
}
}
function drawOverlay(W,H,dtms){
/* spawn preview */
if (input.spawning && input.spawnStart){
const s = input.spawnStart, m = input.mouse;
ctx.save();
ctx.strokeStyle = "rgba(160,200,255,0.75)";
ctx.setLineDash([5,5]);
ctx.lineWidth = 1.4;
if (input.tool === "rope"){
ctx.beginPath(); ctx.moveTo(s.x,s.y); ctx.lineTo(m.x,m.y); ctx.stroke();
} else if (input.tool === "cloth" || input.tool === "box"){
ctx.strokeRect(s.x, s.y, m.x-s.x, m.y-s.y);
} else if (input.tool === "soft"){
const r = clamp(Math.hypot(m.x-s.x,m.y-s.y), 16, 130);
ctx.beginPath(); ctx.arc(s.x,s.y,r,0,Math.PI*2); ctx.stroke();
} else if (input.tool === "ball"){
const r = clamp(Math.hypot(m.x-s.x,m.y-s.y), 5, 90);
ctx.beginPath(); ctx.arc(s.x,s.y,r,0,Math.PI*2); ctx.stroke();
}
ctx.restore();
}
/* cut trail */
const now = perfNow();
for (let i=0;i<cutStrokes.length;i++){
const s = cutStrokes[i];
const life = clamp(1 - (now-s.t)/420, 0, 1);
if (life <= 0) continue;
ctx.strokeStyle = COL.tear;
ctx.globalAlpha = life*0.8;
ctx.lineWidth = 1 + life*2.5;
ctx.beginPath(); ctx.moveTo(s.x1,s.y1); ctx.lineTo(s.x2,s.y2); ctx.stroke();
}
ctx.globalAlpha = 1;
while (cutStrokes.length && now - cutStrokes[0].t > 420) cutStrokes.shift();
/* sparks */
const dt = Math.min(dtms||16, 50)/16.67;
for (let i=sparks.length-1;i>=0;i--){
const s = sparks[i];
s.x += s.vx*dt; s.y += s.vy*dt; s.vy += 0.12*dt; s.vx *= 0.94; s.vy *= 0.96;
s.life -= 0.035*dt;
if (s.life <= 0){ sparks.splice(i,1); continue; }
ctx.globalAlpha = s.life*0.9;
ctx.fillStyle = `hsl(${s.hue} 90% 68%)`;
ctx.fillRect(s.x-1, s.y-1, 2, 2);
}
ctx.globalAlpha = 1;
/* hover / grab highlight */
const hv = input.grabbed || input.hover;
if (hv && !input.spawning){
const g = input.grabbed;
ctx.strokeStyle = g ? "rgba(180,220,255,0.95)" : "rgba(150,190,255,0.45)";
ctx.lineWidth = g ? 1.8 : 1;
ctx.beginPath(); ctx.arc(hv.x, hv.y, g ? 13 : 9, 0, Math.PI*2); ctx.stroke();
if (g){
ctx.strokeStyle = "rgba(150,200,255,0.35)";
ctx.setLineDash([3,4]);
ctx.beginPath(); ctx.moveTo(g.x,g.y); ctx.lineTo(input.mouse.x, input.mouse.y); ctx.stroke();
ctx.setLineDash([]);
/* velocity arrow for the throw */
const vx = (input.mouse.x - input.mouse.px)*4, vy = (input.mouse.y - input.mouse.py)*4;
const sp = Math.hypot(vx,vy);
if (sp > 6){
ctx.strokeStyle = "rgba(120,230,255,0.85)";
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(g.x,g.y); ctx.lineTo(g.x+vx, g.y+vy); ctx.stroke();
}
}
}
/* pin flash pulses */
for (const p of sim.points){
if (p.flash > 0){
p.flash -= 0.03;
const a = Math.max(p.flash,0);
ctx.strokeStyle = `rgba(255,255,255,${a*0.6})`;
ctx.lineWidth = 1 + a*2;
ctx.beginPath(); ctx.arc(p.x, p.y, 6 + (1-a)*14, 0, Math.PI*2); ctx.stroke();
}
}
}
/* ============================ 10. UI ============================ */
const el = id => document.getElementById(id);
const toolButtons = Array.prototype.slice.call(document.querySelectorAll(".tools [data-tool]"));
function selectTool(name){
input.tool = name;
for (const b of toolButtons) b.classList.toggle("on", b.dataset.tool === name);
canvas.style.cursor = (name === "tear") ? "crosshair" : (name === "grab" ? "grab" : "crosshair");
}
for (const b of toolButtons) b.addEventListener("click", ()=>selectTool(b.dataset.tool));
function togglePause(){
sim.paused = !sim.paused;
acc = 0; last = perfNow();
el("bPause").classList.toggle("on", sim.paused);
el("bPause").textContent = sim.paused ? "Resume" : "Pause";
}
let hoverPoint = null; /* reserved */
el("bPause").addEventListener("click", togglePause);
el("bStep").addEventListener("click", ()=>{ if (!sim.paused) togglePause(); step(CFG.dt); });
el("bClear").addEventListener("click", ()=>{ scene(); });
/* sliders */
function slider(id, valId, apply, fmt){
const s = el(id), v = el(valId);
const run = ()=>{ const x = parseFloat(s.value); apply(x); if (v) v.textContent = fmt ? fmt(x) : x; };
s.addEventListener("input", run);
run();
}
slider("sGrav", "vGrav", x=>CFG.gravity = x, x=>""+x);
slider("sIter", "vIter", x=>CFG.iterations = x|0, x=>""+(x|0));
slider("sDamp", "vDamp", x=>CFG.damping = x, x=>x.toFixed(3).slice(1));
slider("sWind", "vWind", x=>CFG.wind = x, x=>""+x);
slider("sBounce", "vBounce", x=>CFG.bounce = x, x=>x.toFixed(2).slice(1));
/* chips */
function chip(id, apply){
const c = el(id);
c.addEventListener("click", ()=>{
const on = !c.classList.contains("on");
c.classList.toggle("on", on);
apply(on);
});
}
chip("cGrav", on=>{ CFG.gravityOn = on; });
chip("cStress", on=>{ CFG.stress = on; });
chip("cPoints", on=>{ CFG.showPoints = on; });
chip("cTrail", on=>{ CFG.trail = on; if(!on) bgGrad = null; });
chip("cPts", on=>{ CFG.pointCollide = on; });
/* keyboard */
window.addEventListener("keydown", e=>{
const k = e.key;
if (k === " "){ e.preventDefault(); togglePause(); }
else if (k === "ArrowRight" || k === "."){ e.preventDefault(); if (sim.paused) step(CFG.dt); }
else if (k === "ArrowLeft" || k === ","){ e.preventDefault(); if (sim.paused){ for (let i=0;i<10;i++) step(CFG.dt); } }
else if (k === "r" || k === "R"){ scene(); }
else if (k === "s" || k === "S"){ el("cStress").click(); }
else if (k === "p" || k === "P"){ el("cPause"); el("cPts").click(); }
else if (k >= "1" && k <= "7"){ selectTool(TOOLS[(+k)-1]); }
});
/* ============================ 11. MAIN LOOP ============================ */
let last = perfNow(), acc = 0;
let statTick = 0;
const statsEl = el("stats");
function frame(now){
requestAnimationFrame(frame);
let delta = (now - last)/1000;
last = now;
if (!Number.isFinite(delta) || delta < 0) delta = 0;
if (delta > CFG.maxFrame) delta = CFG.maxFrame; // ← tab-switch / hitch clamp
if (!sim.paused){
acc += delta;
let n = 0;
while (acc >= CFG.dt && n < CFG.maxSubSteps){ step(CFG.dt); acc -= CFG.dt; n++; }
if (acc > CFG.dt * CFG.maxSubSteps) acc = 0; // never accumulate a backlog
} else {
acc = 0;
}
render(delta*1000);
if (delta > 0) STAT.fps += (1/delta - STAT.fps) * 0.08;
if (now - statTick > 200){
statTick = now;
statsEl.innerHTML =
"<i>" + STAT.fps.toFixed(0) + "</i> fps" +
" <s>·</s> <i>" + STAT.phys.toFixed(2) + "</i> ms/step" +
" <s>·</s> <i>" + sim.points.length + "</i> pts" +
" <s>·</s> <i>" + sim.constraints.length + "</i> links" +
" <s>·</s> t <i>" + sim.time.toFixed(1) + "</i>s";
}
}
/* tab visibility — throw away the gap instead of simulating it */
document.addEventListener("visibilitychange", ()=>{
last = perfNow(); acc = 0;
});
window.addEventListener("blur", ()=>{ last = perfNow(); acc = 0; });
/* ============================ 12. DEMO SCENE ============================ */
function scene(){
clearWorld();
const W = sim.W, H = sim.H;
if (!W || !H) return;
const cx = W*0.62; // drape circle (centre of frame)
const cy = H*0.585;
const cr = Math.min(H*0.115, 92);
const px = W*0.795, pw = Math.min(W*0.17, 205), py = H-104;
sim.statics = [
{ kind:"circle", x: cx, y: cy, r: cr },
{ kind:"circle", x: W*0.33, y: H-64, r: Math.min(H*0.075, 54) },
{ kind:"box", x: px, y: py, w: pw, h: 18 },
{ kind:"box", x: px+pw*0.10, y: py+18, w: 11, h: Math.max(H-10-(py+18), 8) },
{ kind:"box", x: px+pw*0.86, y: py+18, w: 11, h: Math.max(H-10-(py+18), 8) },
];
/* RIBBON — pinned along the top edge directly above the circle, pre-squeezed
so it starts clear of it, then unfurls and drapes over the top. */
spawnCloth(cx-52, 40, cx+52, 440, {pin:"edge", squash:0.62});
/* SHEET — hung from two pins set INSIDE its span, so the slack top edge
sags into a catenary and the fabric drapes like a curtain */
const sheet = spawnCloth(W*0.17, 40, W*0.38, 250, {pin:"none"});
const topRow = sheet.grid[0], last = sheet.cols-1;
for (const i of [Math.round(last*0.24), Math.round(last*0.76)]){
const p = topRow[i];
p.pinned = true; p.invMass = 0;
}
spawnRope(W*0.055, 55, W*0.145, 55); // pendulum
spawnBlob(W*0.30, H*0.80, Math.min(H*0.062, 46)); // soft body on the floor
spawnSoftBox(px+pw*0.13, py-84, px+pw*0.53, py-14); // soft box on the pedestal
spawnBall(W*0.055, H*0.42, 13);
spawnBall(px+pw*0.78, H*0.045, 9);
/* pre-settle a short moment so the unfurl is over before the first frame */
for (let i=0;i<45;i++) step(CFG.dt);
sim.time = 0; sim.steps = 0;
}
/* ============================ BOOT ============================ */
resize();
scene();
selectTool("grab");
el("bPause").classList.remove("on");
requestAnimationFrame(frame);
/* ============================ HEADLESS TEST HOOKS ============================ */
window.sim = sim;
sim.step = step;
sim.CFG = CFG;
sim.scene = scene;
sim.reset = scene;
sim.clear = ()=>{ clearWorld(); sim.statics = []; };
sim.setPaused = v => { sim.paused = !!v; };
sim.spawnRope = spawnRope;
sim.spawnCloth = spawnCloth;
sim.spawnBlob = spawnBlob;
sim.spawnBall = spawnBall;
sim.spawnSoftBox = spawnSoftBox;
sim.tearAlong = tearAlong;
sim.tearAtPoint= tearAtPoint;
sim.kineticEnergy = function(){
let e = 0;
const inv = 1/CFG.dt;
for (const p of sim.points){
if (p.invMass === 0) continue;
const vx = (p.x - p.px)*inv, vy = (p.y - p.py)*inv;
e += 0.5 * p.mass * (vx*vx + vy*vy);
}
return e;
};
sim.maxSpeedOf = function(){ let m = 0; for (const p of sim.points){ const s = Math.hypot(p.x-p.px, p.y-p.py); if (s>m) m=s; } return m; };
sim.minY = function(){ let m = Infinity; for (const p of sim.points) if (p.y < m) m = p.y; return m; };
sim.maxY = function(){ let m = -Infinity; for (const p of sim.points) if (p.y > m) m = p.y; return m; };
sim.anyNaN = function(){
for (const p of sim.points){
if (!Number.isFinite(p.x) || !Number.isFinite(p.y) || !Number.isFinite(p.px) || !Number.isFinite(p.py)) return true;
}
return false;
};
sim.anyOutOfBounds = function(margin){
margin = margin===undefined ? 400 : margin;
const W = sim.W, H = sim.H;
for (const p of sim.points){
if (p.x < -margin || p.x > W+margin || p.y < -margin || p.y > H+margin) return true;
}
return false;
};
sim.countObj = t => sim.objects.filter(o=>o.type===t).length;
sim.dirty = ()=>{ dirty = true; };
</script>
</body>
</html>