← Physics Playground results

Physics Playground

Deepseek v4.1 Flash · full

Initial view of Physics Playground

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>