← Physics Playground results

Physics Playground

Qwen 3.6 27b MTP · composer

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 60,544 bytes · SHA-256 a079eae58654
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>2D Soft-Body Physics Playground</title>
<style>
/* ── css: layout ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #1a1a2e; font-family: 'Segoe UI', system-ui, sans-serif; color: #ccc; }
#app { position: relative; width: 100%; height: 100%; }
canvas { display: block; position: absolute; top: 0; left: 0; cursor: crosshair; }

/* Toolbar */
#toolbar {
  position: absolute; top: 8px; left: 50%; transform: translateX(-50%);
  display: flex; align-items: center; gap: 4px; padding: 6px 12px;
  background: rgba(10, 10, 30, 0.85); border-radius: 10px;
  backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,0.08);
  z-index: 10; user-select: none;
}
#toolbar button {
  background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
  color: #ddd; padding: 5px 10px; border-radius: 6px; cursor: pointer;
  font-size: 13px; transition: all 0.15s; white-space: nowrap;
}
#toolbar button:hover { background: rgba(255,255,255,0.12); }
#toolbar button.active { background: rgba(80, 140, 255, 0.3); border-color: rgba(80, 140, 255, 0.6); color: #fff; }
#toolbar .sep { width: 1px; height: 20px; background: rgba(255,255,255,0.1); margin: 0 4px; }
#toolbar label { font-size: 11px; color: #aaa; cursor: pointer; display: flex; align-items: center; gap: 3px; white-space: nowrap; }

/* Controls bar */
#controls {
  position: absolute; bottom: 8px; left: 50%; transform: translateX(-50%);
  display: flex; align-items: center; gap: 12px; padding: 6px 14px;
  background: rgba(10, 10, 30, 0.85); border-radius: 10px;
  backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,0.08);
  z-index: 10; user-select: none; flex-wrap: wrap; justify-content: center;
}
#controls label { font-size: 11px; color: #aaa; display: flex; align-items: center; gap: 4px; }
#controls input[type="range"] { width: 70px; height: 4px; accent-color: #508cff; }
#controls button {
  background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
  color: #ddd; padding: 4px 8px; border-radius: 5px; cursor: pointer;
  font-size: 12px; transition: all 0.15s;
}
#controls button:hover { background: rgba(255,255,255,0.12); }
#stats { font-size: 10px; color: #666; margin-left: auto; white-space: nowrap; }

/* Slider value display */
#controls span { min-width: 30px; text-align: right; font-variant-numeric: tabular-nums; }
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div id="toolbar">
  <button data-tool="grab" class="active" title="Grab (G)">✋ Grab</button>
  <button data-tool="rope" title="Rope (R)">🪢 Rope</button>
  <button data-tool="cloth" title="Cloth (C)">🧵 Cloth</button>
  <button data-tool="softbody" title="Soft Body (S)">🫧 Blob</button>
  <button data-tool="ball" title="Ball (B)">⚽ Ball</button>
  <span class="sep"></span>
  <label><input type="checkbox" id="chkStress"> Stress</label>
  <label><input type="checkbox" id="chkPoints"> Points</label>
  <label><input type="checkbox" id="chkWind"> Wind</label>
</div>
<div id="controls">
  <label>G: <input type="range" id="sliderGravity" min="-2000" max="3000" value="980" step="10"><span id="valG">980</span></label>
  <label>Damp: <input type="range" id="sliderDamping" min="0.90" max="1.0" value="0.995" step="0.001"><span id="valD">0.995</span></label>
  <label>Itr: <input type="range" id="sliderIter" min="1" max="20" value="6" step="1"><span id="valI">6</span></label>
  <button id="btnPause">⏸ Pause</button>
  <button id="btnStep">⏭ Step</button>
  <button id="btnReset">🔄 Reset</button>
  <div id="stats">FPS: -- | Phys: --ms</div>
</div>
<script>
'use strict';

// ── const: CONFIG ──
// ============================================================
// TUNABLE CONSTANTS — tweak these to adjust simulation feel
// ============================================================
const CONFIG = {
  // Physics timestep (fixed)
  dt: 1 / 60,
  maxDtAccumulator: 0.25,       // clamp accumulator on tab-switch
  constraintIterations: 6,      // relaxation passes per step
  
  // Gravity & damping
  gravity: 980,                 // pixels/s² downward
  damping: 0.995,               // velocity multiplier per step (energy decay)
  
  // Collision
  wallFriction: 0.3,            // friction against walls/floor
  collisionPadding: 2,          // extra padding for point collisions
  
  // Interaction
  grabRadius: 18,               // pixels to detect nearest point
  tearRadius: 15,               // radius for cutting constraints
  throwDamping: 0.9,            // velocity damping on release
  
  // Rendering
  pointRadius: 2,               // size of rendered points
  clothLineWidth: 1,            // line width for cloth mesh
  ropeLineWidth: 2.5,           // line width for ropes
  softBodyFillAlpha: 0.35,      // fill alpha for soft bodies
  
  // Colors per object type (HSL base hues)
  colors: {
    rope:       { h: 210, s: 80, l: 60 },   // blue
    cloth:      { h: 340, s: 70, l: 55 },   // pink/magenta
    softbody:   { h: 160, s: 70, l: 50 },   // teal/green
    ball:       { h: 40,  s: 90, l: 55 },   // yellow/orange
  },
  
  // Stress visualization thresholds (stretch ratio)
  stressGreen: 1.0,             // ratio at which color is green
  stressRed: 1.3,               // ratio at which color is red
  
  // Wind
  windStrength: 200,            // pixels/s² horizontal force
};

// ── const: STATIC_OBJS ──
// ============================================================
// STATIC GEOMETRY — immovable objects in the scene
// ============================================================
const STATIC_OBJS = [
  { type: 'circle', cx: 0.5, cy: 0.65, r: 0.12 },   // big circle (relative to canvas)
  { type: 'box', x: 0.15, y: 0.78, w: 0.12, h: 0.04 }, // left platform
  { type: 'box', x: 0.73, y: 0.72, w: 0.12, h: 0.04 }, // right platform (higher)
];

// ── class: Point ──
// ============================================================
// POINT CLASS — a single particle in the simulation
// ============================================================
class Point {
  constructor(x, y, pinned = false) {
    this.x = x;
    this.y = y;
    this.px = x;           // previous position (for Verlet velocity)
    this.py = y;
    this.pinned = pinned;   // if true, point doesn't move
    this.radius = 0;        // for ball objects
    this.mass = 1;          // uniform mass for simplicity
  }
  
  get vx() { return this.x - this.px; }
  get vy() { return this.y - this.py; }
  
  setVelocity(vx, vy) {
    this.px = this.x - vx;
    this.py = this.y - vy;
  }
}

// ── class: Constraint ──
// ============================================================
// CONSTRAINT CLASS — distance constraint between two points
// ============================================================
class Constraint {
  constructor(p1, p2, stiffness = 1.0) {
    this.p1 = p1;
    this.p2 = p2;
    this.restLength = Math.hypot(p2.x - p1.x, p2.y - p1.y);
    this.stiffness = stiffness;
    this.active = true;     // set false to "cut" the constraint
  }
  
  get currentLength() {
    return Math.hypot(this.p2.x - this.p1.x, this.p2.y - this.p1.y);
  }
  
  get stretchRatio() {
    if (this.restLength < 0.001) return 1; // guard zero-length
    return this.currentLength / this.restLength;
  }
}

// ── class: PhysicsObject ──
// ============================================================
// OBJECT CLASS — a collection of points and constraints
// ============================================================
class PhysicsObject {
  constructor(type, colorKey) {
    this.type = type;       // 'rope', 'cloth', 'softbody', 'ball'
    this.colorKey = colorKey;
    this.points = [];
    this.constraints = [];
    this.alive = true;
  }
  
  addPoint(x, y, pinned) {
    const p = new Point(x, y, pinned);
    this.points.push(p);
    return p;
  }
  
  addConstraint(i1, i2, stiffness) {
    if (i1 < 0 || i1 >= this.points.length || i2 < 0 || i2 >= this.points.length) return null;
    const c = new Constraint(this.points[i1], this.points[i2], stiffness);
    this.constraints.push(c);
    return c;
  }
  
  destroy() {
    this.alive = false;
  }
}

// ── function: integratePoints ──
/**
 * Integrate all points using Verlet with damping and gravity.
 */
function integratePoints(points, dt, gravity, damping, windForce) {
  const dt2 = dt * dt;
  for (const p of points) {
    if (p.pinned) continue;
    
    // Guard: skip if position is NaN/Infinity
    if (!isFinite(p.x) || !isFinite(p.y)) {
      p.x = 0;
      p.y = 0;
      p.px = 0;
      p.py = 0;
      continue;
    }
    
    // Compute velocity from position difference
    let vx = (p.x - p.px) * damping;
    let vy = (p.y - p.py) * damping;
    
    // Guard extreme velocities (from tab-switch or extreme positions)
    const maxVel = 5000; // pixels per frame max
    if (!isFinite(vx)) vx = 0;
    if (!isFinite(vy)) vy = 0;
    vx = Math.max(-maxVel, Math.min(maxVel, vx));
    vy = Math.max(-maxVel, Math.min(maxVel, vy));
    
    // Apply gravity
    vy += gravity * dt2;
    
    // Apply wind if active
    if (windForce !== 0) {
      vx += windForce * dt2;
    }
    
    // Verlet integration: x' = x + v + a*dt²
    const newX = p.x + vx;
    const newY = p.y + vy;
    
    // NaN guard
    if (!isFinite(newX) || !isFinite(newY)) {
      p.px = p.x;
      p.py = p.y;
      continue;
    }
    
    p.px = p.x;
    p.py = p.y;
    p.x = newX;
    p.y = newY;
  }
}

// ── function: solveConstraints ──
/**
 * Solve distance constraints via iterative relaxation.
 */
function solveConstraints(constraints, iterations) {
  for (let iter = 0; iter < iterations; iter++) {
    for (const c of constraints) {
      if (!c.active) continue;
      
      const dx = c.p2.x - c.p1.x;
      const dy = c.p2.y - c.p1.y;
      const dist = Math.sqrt(dx * dx + dy * dy);
      
      // Guard zero-length constraint
      if (dist < 0.0001) continue;
      
      const diff = (c.restLength - dist) / dist;
      const correction = diff * c.stiffness * 0.5;
      
      const ox = dx * correction;
      const oy = dy * correction;
      
      if (!isFinite(ox) || !isFinite(oy)) continue;
      
      // Apply correction based on pin status
      if (c.p1.pinned && c.p2.pinned) continue;
      
      if (c.p1.pinned) {
        c.p2.x += ox * 2;
        c.p2.y += oy * 2;
      } else if (c.p2.pinned) {
        c.p1.x -= ox * 2;
        c.p1.y -= oy * 2;
      } else {
        c.p1.x -= ox;
        c.p1.y -= oy;
        c.p2.x += ox;
        c.p2.y += oy;
      }
    }
  }
}

// ── function: collideWithStatic ──
/**
 * Handle collisions between points and static geometry.
 */
function collideWithStatic(points, canvasW, canvasH) {
  const pad = CONFIG.collisionPadding;
  
  for (const p of points) {
    if (p.pinned) continue;
    
    // Walls and floor/ceiling
    let vx = p.x - p.px;
    let vy = p.y - p.py;
    
    // Left wall
    if (p.x < pad + p.radius) {
      p.x = pad + p.radius;
      p.px = p.x + Math.abs(vx) * CONFIG.wallFriction;
    }
    // Right wall
    if (p.x > canvasW - pad - p.radius) {
      p.x = canvasW - pad - p.radius;
      p.px = p.x - Math.abs(vx) * CONFIG.wallFriction;
    }
    // Floor
    if (p.y > canvasH - pad - p.radius) {
      p.y = canvasH - pad - p.radius;
      p.py = p.y + Math.abs(vy) * CONFIG.wallFriction;
      // Extra floor friction for horizontal velocity
      p.px = p.x - vx * 0.95;
    }
    // Ceiling
    if (p.y < pad + p.radius) {
      p.y = pad + p.radius;
      p.py = p.y - Math.abs(vy) * CONFIG.wallFriction;
    }
    
    // Static circles and boxes
    for (const obj of STATIC_OBJS) {
      if (obj.type === 'circle') {
        const cx = obj.cx * canvasW;
        const cy = obj.cy * canvasH;
        const r = obj.r * canvasW;
        
        const dx = p.x - cx;
        const dy = p.y - cy;
        const dist = Math.sqrt(dx * dx + dy * dy);
        const minDist = r + p.radius + pad;
        
        if (dist < minDist && dist > 0.001) {
          // Push point out of circle
          const nx = dx / dist;
          const ny = dy / dist;
          const overlap = minDist - dist;
          
          p.x += nx * overlap;
          p.y += ny * overlap;
          
          // Reflect velocity with friction
          const vDotN = vx * nx + vy * ny;
          if (vDotN < 0) {
            const fx = vx - (1 + CONFIG.wallFriction) * vDotN * nx;
            const fy = vy - (1 + CONFIG.wallFriction) * vDotN * ny;
            p.px = p.x - fx;
            p.py = p.y - fy;
          }
        }
      } else if (obj.type === 'box') {
        const bx = obj.x * canvasW;
        const by = obj.y * canvasH;
        const bw = obj.w * canvasW;
        const bh = obj.h * canvasH;
        
        // AABB collision with point
        const closestX = Math.max(bx, Math.min(p.x, bx + bw));
        const closestY = Math.max(by, Math.min(p.y, by + bh));
        
        const dx = p.x - closestX;
        const dy = p.y - closestY;
        const distSq = dx * dx + dy * dy;
        const minDist = p.radius + pad;
        
        if (distSq < minDist * minDist && distSq > 0.001) {
          const dist = Math.sqrt(distSq);
          let nx, ny;
          
          if (dist > 0.001) {
            nx = dx / dist;
            ny = dy / dist;
          } else {
            // Point is inside box - push out in dominant axis
            const overlapX = Math.min(p.x - bx, bx + bw - p.x);
            const overlapY = Math.min(p.y - by, by + bh - p.y);
            if (overlapX < overlapY) {
              nx = p.x < bx + bw / 2 ? -1 : 1;
              ny = 0;
            } else {
              nx = 0;
              ny = p.y < by + bh / 2 ? -1 : 1;
            }
          }
          
          const overlap = minDist - dist;
          p.x += nx * (overlap > 0 ? overlap : 1);
          p.y += ny * (overlap > 0 ? overlap : 1);
          
          // Reflect velocity with friction
          const vDotN = vx * nx + vy * ny;
          if (vDotN < 0) {
            const fx = vx - (1 + CONFIG.wallFriction) * vDotN * nx;
            const fy = vy - (1 + CONFIG.wallFriction) * vDotN * ny;
            p.px = p.x - fx;
            p.py = p.y - fy;
          }
        }
      }
    }
  }
}

// ── function: clampPoints ──
/**
 * Clamp any NaN or extreme values to prevent simulation explosion.
 */
function clampPoints(points) {
  const MAX_COORD = 10000; // Any coordinate beyond this is considered escaped
  
  for (const p of points) {
    // First: handle NaN/Infinity directly
    if (!isFinite(p.x) || !isFinite(p.y)) {
      p.x = 0;
      p.y = 0;
      p.px = 0;
      p.py = 0;
      continue;
    }
    
    // Clamp extreme positions to origin with zero velocity
    if (Math.abs(p.x) > MAX_COORD || Math.abs(p.y) > MAX_COORD) {
      p.x = 0;
      p.y = 0;
      p.px = 0;
      p.py = 0;
    }
    
    // Clamp extreme previous positions (zero velocity)
    if (!isFinite(p.px) || !isFinite(p.py)) {
      p.px = p.x;
      p.py = p.y;
    } else if (Math.abs(p.px) > MAX_COORD * 2 || Math.abs(p.py) > MAX_COORD * 2) {
      // Extreme velocity would cause explosion - zero it out
      p.px = p.x;
      p.py = p.y;
    }
  }
}

// ── function: physicsStep ──
/**
 * Single physics step: integrate → collide → solve constraints.
 */
function physicsStep(allPoints, allConstraints, dt, gravity, damping, iterations, windForce, canvasW, canvasH) {
  // Clamp extreme positions BEFORE integration (tab-switch protection)
  clampPoints(allPoints);
  
  // Integrate positions
  integratePoints(allPoints, dt, gravity, damping, windForce);
  
  // Collision with static geometry
  collideWithStatic(allPoints, canvasW, canvasH);
  
  // Solve constraints iteratively
  solveConstraints(allConstraints, iterations);
  
  // Second collision pass after constraint solving (prevents tunneling)
  collideWithStatic(allPoints, canvasW, canvasH);
  
  // Clamp any escaped points
  clampPoints(allPoints);
}

// ── function: computeKineticEnergy ──
/**
 * Compute total kinetic energy of all points.
 */
function computeKineticEnergy(points) {
  let ke = 0;
  for (const p of points) {
    const vx = p.x - p.px;
    const vy = p.y - p.py;
    ke += 0.5 * p.mass * (vx * vx + vy * vy);
  }
  return ke;
}

// ── function: checkNaN ──
/**
 * Check if any point has NaN coordinates.
 */
function checkNaN(points) {
  for (const p of points) {
    if (!isFinite(p.x) || !isFinite(p.y)) return true;
  }
  return false;
}

// ── function: spawnRope ──
// ============================================================
// OBJECT SPAWNERS — create physics objects from user input
// ============================================================

/**
 * Spawn a rope between two points (click-drag).
 */
function spawnRope(x1, y1, x2, y2, numSegments) {
  const dx = x2 - x1;
  const dy = y2 - y1;
  const len = Math.sqrt(dx * dx + dy * dy);
  
  if (len < 5) return null; // too short
  
  const obj = new PhysicsObject('rope', 'rope');
  const segLen = len / numSegments;
  
  for (let i = 0; i <= numSegments; i++) {
    const t = i / numSegments;
    const px = x1 + dx * t;
    const py = y1 + dy * t;
    obj.addPoint(px, py, i === 0); // pin first point
  }
  
  for (let i = 0; i < numSegments; i++) {
    obj.addConstraint(i, i + 1, 1.0);
  }
  
  return obj;
}

// ── function: spawnCloth ──
/**
 * Spawn a cloth rectangle with structural and shear constraints.
 */
function spawnCloth(x, y, width, height, cols, rows, pinMode) {
  const obj = new PhysicsObject('cloth', 'cloth');
  // Store grid dimensions for rendering
  obj._cols = cols;
  obj._rows = rows;
  
  const cellW = width / (cols - 1);
  const cellH = height / (rows - 1);
  
  // Create grid of points
  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      const px = x + c * cellW;
      const py = y + r * cellH;
      
      // Pin mode: 'corners' pins top-left and top-right, 'full' pins entire top row
      let pinned = false;
      if (pinMode === 'full') {
        pinned = r === 0;
      } else {
        pinned = r === 0 && (c === 0 || c === cols - 1);
      }
      
      obj.addPoint(px, py, pinned);
    }
  }
  
  // Structural constraints (horizontal + vertical)
  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols - 1; c++) {
      const idx = r * cols + c;
      obj.addConstraint(idx, idx + 1, 1.0); // horizontal
    }
  }
  
  for (let r = 0; r < rows - 1; r++) {
    for (let c = 0; c < cols; c++) {
      const idx = r * cols + c;
      obj.addConstraint(idx, idx + cols, 1.0); // vertical
    }
  }
  
  // Shear constraints (diagonal) — every other cell for stability
  for (let r = 0; r < rows - 1; r++) {
    for (let c = 0; c < cols - 1; c++) {
      const idx = r * cols + c;
      if ((r + c) % 2 === 0) {
        obj.addConstraint(idx, idx + cols + 1, 0.5); // diagonal /
      } else {
        obj.addConstraint(idx + 1, idx + cols, 0.5); // diagonal \
      }
    }
  }
  
  return obj;
}

// ── function: spawnSoftBody ──
/**
 * Spawn a soft body (blob) — circle of points with edge and internal constraints.
 */
function spawnSoftBody(cx, cy, radius, numPoints) {
  const obj = new PhysicsObject('softbody', 'softbody');
  
  // Create ring of points
  for (let i = 0; i < numPoints; i++) {
    const angle = (2 * Math.PI * i) / numPoints - Math.PI / 2;
    const px = cx + radius * Math.cos(angle);
    const py = cy + radius * Math.sin(angle);
    obj.addPoint(px, py, false);
  }
  
  // Add center point for volume preservation
  const centerIdx = numPoints;
  obj.addPoint(cx, cy, false);
  
  // Edge constraints (ring)
  for (let i = 0; i < numPoints; i++) {
    obj.addConstraint(i, (i + 1) % numPoints, 1.0);
  }
  
  // Spoke constraints (center to each point)
  for (let i = 0; i < numPoints; i++) {
    obj.addConstraint(i, centerIdx, 0.5);
  }
  
  // Cross-brace constraints for volume preservation
  for (let i = 0; i < numPoints; i++) {
    const j = (i + Math.floor(numPoints / 2)) % numPoints;
    obj.addConstraint(i, j, 0.3);
  }
  
  // Additional internal bracing for stability
  if (numPoints >= 6) {
    for (let i = 0; i < numPoints; i++) {
      const j = (i + Math.floor(numPoints / 4)) % numPoints;
      obj.addConstraint(i, j, 0.2);
    }
  }
  
  return obj;
}

// ── function: spawnBall ──
/**
 * Spawn a ball (single point with radius).
 */
function spawnBall(x, y, radius) {
  const obj = new PhysicsObject('ball', 'ball');
  const p = obj.addPoint(x, y, false);
  p.radius = radius;
  return obj;
}

// ── function: spawnSoftBodyBox ──
/**
 * Spawn a soft body box — rectangular blob with edge and internal constraints.
 */
function spawnSoftBodyBox(cx, cy, width, height) {
  const obj = new PhysicsObject('softbody', 'softbody');
  
  // Create points at corners and midpoints of edges
  const hw = width / 2;
  const hh = height / 2;
  
  // Corners: TL=0, TR=1, BR=2, BL=3
  obj.addPoint(cx - hw, cy - hh, false); // TL
  obj.addPoint(cx + hw, cy - hh, false); // TR
  obj.addPoint(cx + hw, cy + hh, false); // BR
  obj.addPoint(cx - hw, cy + hh, false); // BL
  
  // Edge midpoints: top=4, right=5, bottom=6, left=7
  obj.addPoint(cx, cy - hh, false);       // top
  obj.addPoint(cx + hw, cy, false);       // right
  obj.addPoint(cx, cy + hh, false);       // bottom
  obj.addPoint(cx - hw, cy, false);       // left
  
  // Center point: 8
  obj.addPoint(cx, cy, false);            // center
  
  // Edge constraints (perimeter)
  const edgePairs = [[0,4],[4,1],[1,5],[5,2],[2,6],[6,3],[3,7],[7,0]];
  for (const [a, b] of edgePairs) {
    obj.addConstraint(a, b, 1.0);
  }
  
  // Corner-to-corner diagonals
  obj.addConstraint(0, 2, 0.4);
  obj.addConstraint(1, 3, 0.4);
  
  // Spoke constraints (center to all)
  for (let i = 0; i < 8; i++) {
    obj.addConstraint(i, 8, 0.5);
  }
  
  // Cross-brace constraints
  obj.addConstraint(0, 6, 0.3);
  obj.addConstraint(1, 7, 0.3);
  obj.addConstraint(2, 4, 0.3);
  obj.addConstraint(3, 5, 0.3);
  
  return obj;
}

// ── function: getColor ──
// ============================================================
// RENDERING — draw everything to canvas
// ============================================================

/**
 * Get color string from config color key and optional stretch ratio.
 */
function getColor(colorKey, stretchRatio = 1.0) {
  const c = CONFIG.colors[colorKey];
  if (!c) return '#ffffff';
  
  if (stretchRatio !== undefined && stretchRatio > 1.0) {
    // Stress visualization: green → yellow → red based on stretch
    const t = Math.min(1, Math.max(0, (stretchRatio - CONFIG.stressGreen) / 
      (CONFIG.stressRed - CONFIG.stressGreen)));
    const h = 120 * (1 - t); // green=120 to red=0
    return `hsl(${h}, 90%, ${50 + 10 * (1-t)}%)`;
  }
  
  return `hsl(${c.h}, ${c.s}%, ${c.l}%)`;
}

// ── function: drawStaticGeometry ──
/**
 * Draw static geometry (walls, circles, boxes).
 */
function drawStaticGeometry(ctx, canvasW, canvasH) {
  // Walls outline
  ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
  ctx.lineWidth = 2;
  ctx.strokeRect(1, 1, canvasW - 2, canvasH - 2);
  
  // Floor highlight
  const floorGrad = ctx.createLinearGradient(0, canvasH - 30, 0, canvasH);
  floorGrad.addColorStop(0, 'rgba(80, 140, 255, 0)');
  floorGrad.addColorStop(1, 'rgba(80, 140, 255, 0.08)');
  ctx.fillStyle = floorGrad;
  ctx.fillRect(0, canvasH - 30, canvasW, 30);
  
  // Static circles and boxes
  for (const obj of STATIC_OBJS) {
    if (obj.type === 'circle') {
      const cx = obj.cx * canvasW;
      const cy = obj.cy * canvasH;
      const r = obj.r * canvasW;
      
      ctx.beginPath();
      ctx.arc(cx, cy, r, 0, Math.PI * 2);
      ctx.fillStyle = 'rgba(100, 100, 140, 0.3)';
      ctx.fill();
      ctx.strokeStyle = 'rgba(150, 150, 200, 0.5)';
      ctx.lineWidth = 2;
      ctx.stroke();
      
      // Inner highlight
      const grad = ctx.createRadialGradient(cx - r * 0.3, cy - r * 0.3, 0, cx, cy, r);
      grad.addColorStop(0, 'rgba(180, 180, 220, 0.15)');
      grad.addColorStop(1, 'rgba(180, 180, 220, 0)');
      ctx.fillStyle = grad;
      ctx.fill();
    } else if (obj.type === 'box') {
      const bx = obj.x * canvasW;
      const by = obj.y * canvasH;
      const bw = obj.w * canvasW;
      const bh = obj.h * canvasH;
      
      ctx.fillStyle = 'rgba(100, 100, 140, 0.3)';
      ctx.fillRect(bx, by, bw, bh);
      ctx.strokeStyle = 'rgba(150, 150, 200, 0.5)';
      ctx.lineWidth = 2;
      ctx.strokeRect(bx, by, bw, bh);
    }
  }
}

// ── function: drawRope ──
/**
 * Draw a rope object.
 */
function drawRope(ctx, obj, showStress, showPoints) {
  const color = getColor('rope');
  
  // Draw constraints as lines
  ctx.strokeStyle = color;
  ctx.lineWidth = CONFIG.ropeLineWidth;
  ctx.lineCap = 'round';
  ctx.lineJoin = 'round';
  
  for (const c of obj.constraints) {
    if (!c.active) continue;
    
    if (showStress) {
      const ratio = c.stretchRatio;
      ctx.strokeStyle = getColor('rope', ratio);
    } else {
      ctx.strokeStyle = color;
    }
    
    ctx.beginPath();
    ctx.moveTo(c.p1.x, c.p1.y);
    ctx.lineTo(c.p2.x, c.p2.y);
    ctx.stroke();
  }
  
  // Draw points if enabled
  if (showPoints) {
    for (const p of obj.points) {
      ctx.fillStyle = color;
      ctx.beginPath();
      ctx.arc(p.x, p.y, CONFIG.pointRadius, 0, Math.PI * 2);
      ctx.fill();
    }
  }
}

// ── function: drawCloth ──
/**
 * Draw a cloth object as filled mesh.
 */
function drawCloth(ctx, obj, showStress, showPoints) {
  const pts = obj.points;
  
  // Use stored grid dimensions if available, otherwise estimate
  let cols = obj._cols || Math.round(Math.sqrt(pts.length));
  let rows = obj._rows || Math.ceil(pts.length / cols);
  
  // Build a 2D grid index map
  const grid = [];
  for (let r = 0; r < rows; r++) {
    grid[r] = [];
    for (let c = 0; c < cols && r * cols + c < pts.length; c++) {
      grid[r][c] = pts[r * cols + c];
    }
  }
  
  // Draw filled cells
  const baseColor = CONFIG.colors.cloth;
  
  for (let r = 0; r < rows - 1; r++) {
    for (let c = 0; c < cols - 1; c++) {
      if (!grid[r] || !grid[r][c] || !grid[r+1] || !grid[r+1][c]) continue;
      
      const p00 = grid[r][c];
      const p10 = grid[r][c + 1];
      const p01 = grid[r + 1][c];
      const p11 = grid[r + 1][c + 1];
      
      if (!p10 || !p01 || !p11) continue;
      
      // Compute average stretch for this cell
      let avgStretch = 1.0;
      let count = 0;
      for (const con of obj.constraints) {
        if (!con.active) continue;
        const ptsInCell = [p00, p10, p01, p11];
        if (ptsInCell.includes(con.p1) && ptsInCell.includes(con.p2)) {
          avgStretch += con.stretchRatio - 1.0;
          count++;
        }
      }
      if (count > 0) avgStretch /= count;
      
      // Cell color based on stress or base
      let fillColor;
      if (showStress && avgStretch > 1.0) {
        const t = Math.min(1, Math.max(0, (avgStretch - CONFIG.stressGreen) / 
          (CONFIG.stressRed - CONFIG.stressGreen)));
        const h = 120 * (1 - t);
        fillColor = `hsla(${h}, 80%, 45%, ${0.6 + 0.3 * (1-t)})`;
      } else {
        // Subtle gradient based on row for depth effect
        const shadeMod = (r / rows) * 8;
        fillColor = `hsla(${baseColor.h}, ${baseColor.s}%, ${baseColor.l - shadeMod}%, 0.45)`;
      }
      
      ctx.fillStyle = fillColor;
      ctx.beginPath();
      ctx.moveTo(p00.x, p00.y);
      ctx.lineTo(p10.x, p10.y);
      ctx.lineTo(p11.x, p11.y);
      ctx.lineTo(p01.x, p01.y);
      ctx.closePath();
      ctx.fill();
    }
  }
  
  // Draw grid lines on top
  const lineColor = showStress ? null : getColor('cloth');
  ctx.lineWidth = CONFIG.clothLineWidth;
  
  for (const c of obj.constraints) {
    if (!c.active) continue;
    
    if (showStress && c.stretchRatio > 1.0) {
      const t = Math.min(1, Math.max(0, (c.stretchRatio - CONFIG.stressGreen) / 
        (CONFIG.stressRed - CONFIG.stressGreen)));
      const h = 120 * (1 - t);
      ctx.strokeStyle = `hsla(${h}, 90%, 60%, 0.7)`;
    } else {
      // Structural constraints brighter, shear dimmer
      const isStructural = c.stiffness >= 0.95;
      if (isStructural) {
        ctx.strokeStyle = `hsla(${baseColor.h}, ${baseColor.s}%, ${baseColor.l + 10}%, 0.6)`;
      } else {
        ctx.strokeStyle = `hsla(${baseColor.h}, ${baseColor.s}%, ${baseColor.l}%, 0.25)`;
      }
    }
    
    ctx.beginPath();
    ctx.moveTo(c.p1.x, c.p1.y);
    ctx.lineTo(c.p2.x, c.p2.y);
    ctx.stroke();
  }
  
  // Draw points if enabled
  if (showPoints) {
    for (const p of obj.points) {
      ctx.fillStyle = getColor('cloth');
      ctx.beginPath();
      ctx.arc(p.x, p.y, CONFIG.pointRadius, 0, Math.PI * 2);
      ctx.fill();
    }
  }
}

// ── function: drawSoftBody ──
/**
 * Draw a soft body as filled polygon.
 */
function drawSoftBody(ctx, obj, showStress, showPoints) {
  const pts = obj.points;
  if (pts.length < 3) return;
  
  // The last point is the center — exclude it from the hull
  const edgePts = pts.slice(0, -1);
  
  if (edgePts.length < 3) return;
  
  // Compute average stretch for fill color
  let avgStretch = 1.0;
  let count = 0;
  for (const c of obj.constraints) {
    if (!c.active) continue;
    avgStretch += c.stretchRatio - 1.0;
    count++;
  }
  if (count > 0) avgStretch /= count;
  
  const baseColor = CONFIG.colors.softbody;
  
  // Fill polygon with glow effect
  let fillColor;
  if (showStress && avgStretch > 1.0) {
    const t = Math.min(1, Math.max(0, (avgStretch - CONFIG.stressGreen) / 
      (CONFIG.stressRed - CONFIG.stressGreen)));
    const h = 120 * (1 - t);
    fillColor = `hsla(${h}, 80%, 50%, ${CONFIG.softBodyFillAlpha})`;
  } else {
    fillColor = `hsla(${baseColor.h}, ${baseColor.s}%, ${baseColor.l}%, ${CONFIG.softBodyFillAlpha})`;
  }
  
  // Draw filled polygon with slight glow
  ctx.save();
  ctx.shadowColor = `hsla(${baseColor.h}, ${baseColor.s}%, ${baseColor.l + 20}%, 0.3)`;
  ctx.shadowBlur = 8;
  
  ctx.fillStyle = fillColor;
  ctx.beginPath();
  ctx.moveTo(edgePts[0].x, edgePts[0].y);
  for (let i = 1; i < edgePts.length; i++) {
    // Use quadratic curves for smoother appearance
    const prevPt = edgePts[i - 1];
    const currPt = edgePts[i];
    const nextPt = edgePts[(i + 1) % edgePts.length];
    
    // Simple line for now, but with proper winding
    ctx.lineTo(currPt.x, currPt.y);
  }
  ctx.closePath();
  ctx.fill();
  ctx.restore();
  
  // Draw constraints as lines
  ctx.lineWidth = 1.5;
  
  for (const c of obj.constraints) {
    if (!c.active) continue;
    
    if (showStress && c.stretchRatio > 1.0) {
      const t = Math.min(1, Math.max(0, (c.stretchRatio - CONFIG.stressGreen) / 
        (CONFIG.stressRed - CONFIG.stressGreen)));
      const h = 120 * (1 - t);
      ctx.strokeStyle = `hsla(${h}, 90%, 60%, 0.8)`;
    } else {
      // Edge constraints are brighter, internal ones dimmer
      const isEdge = c.stiffness >= 0.9;
      if (isEdge) {
        ctx.strokeStyle = `hsla(${baseColor.h}, ${baseColor.s}%, ${baseColor.l + 15}%, 0.7)`;
      } else {
        ctx.strokeStyle = `hsla(${baseColor.h}, ${baseColor.s - 20}%, ${baseColor.l}%, 0.2)`;
      }
    }
    
    ctx.beginPath();
    ctx.moveTo(c.p1.x, c.p1.y);
    ctx.lineTo(c.p2.x, c.p2.y);
    ctx.stroke();
  }
  
  // Draw points if enabled
  if (showPoints) {
    for (const p of obj.points) {
      const isCenter = p === pts[pts.length - 1];
      ctx.fillStyle = isCenter ? '#fff' : getColor('softbody');
      ctx.beginPath();
      ctx.arc(p.x, p.y, isCenter ? CONFIG.pointRadius + 1 : CONFIG.pointRadius, 0, Math.PI * 2);
      ctx.fill();
    }
  }
}

// ── function: drawBall ──
/**
 * Draw a ball object.
 */
function drawBall(ctx, obj) {
  const p = obj.points[0];
  if (!p || !p.radius) return;
  
  // Ball with gradient for 3D effect
  const grad = ctx.createRadialGradient(
    p.x - p.radius * 0.3, p.y - p.radius * 0.3, 0,
    p.x, p.y, p.radius
  );
  const baseColor = CONFIG.colors.ball;
  grad.addColorStop(0, `hsla(${baseColor.h}, ${baseColor.s}%, 75%, 1)`);
  grad.addColorStop(0.7, `hsla(${baseColor.h}, ${baseColor.s}%, ${baseColor.l}%, 1)`);
  grad.addColorStop(1, `hsla(${baseColor.h}, ${baseColor.s - 10}%, ${baseColor.l - 15}%, 1)`);
  
  ctx.fillStyle = grad;
  ctx.beginPath();
  ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
  ctx.fill();
  
  // Outline
  ctx.strokeStyle = `hsla(${baseColor.h}, ${baseColor.s}%, ${baseColor.l - 10}%, 0.5)`;
  ctx.lineWidth = 1;
  ctx.stroke();
}

// ── function: drawGrabHighlight ──
/**
 * Draw grab highlight around grabbed point.
 */
function drawGrabHighlight(ctx, point) {
  if (!point) return;
  
  const pulse = Math.sin(Date.now() / 150) * 3 + 8;
  
  // Outer glow ring
  ctx.strokeStyle = 'rgba(255, 255, 100, 0.4)';
  ctx.lineWidth = 3;
  ctx.beginPath();
  ctx.arc(point.x, point.y, pulse + 6, 0, Math.PI * 2);
  ctx.stroke();
  
  // Inner ring
  ctx.strokeStyle = 'rgba(255, 255, 100, 0.8)';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.arc(point.x, point.y, pulse, 0, Math.PI * 2);
  ctx.stroke();
  
  // Center dot with glow
  ctx.save();
  ctx.shadowColor = 'rgba(255, 255, 100, 0.6)';
  ctx.shadowBlur = 6;
  ctx.fillStyle = 'rgba(255, 255, 180, 0.9)';
  ctx.beginPath();
  ctx.arc(point.x, point.y, 4, 0, Math.PI * 2);
  ctx.fill();
  ctx.restore();
}

// ── function: drawPins ──
/**
 * Draw pin indicators.
 */
function drawPins(ctx, points) {
  for (const p of points) {
    if (!p.pinned) continue;
    
    // Small triangle indicator above pinned point with glow
    const size = 5;
    ctx.save();
    ctx.shadowColor = 'rgba(255, 200, 50, 0.4)';
    ctx.shadowBlur = 4;
    ctx.fillStyle = 'rgba(255, 200, 50, 0.9)';
    ctx.beginPath();
    ctx.moveTo(p.x, p.y - size * 1.5);
    ctx.lineTo(p.x - size, p.y - size * 3);
    ctx.lineTo(p.x + size, p.y - size * 3);
    ctx.closePath();
    ctx.fill();
    ctx.restore();
    
    // Small circle at pin point
    ctx.fillStyle = 'rgba(255, 200, 50, 0.6)';
    ctx.beginPath();
    ctx.arc(p.x, p.y, 3, 0, Math.PI * 2);
    ctx.fill();
  }
}

// ── function: drawTearIndicator ──
/**
 * Draw tear/cut indicator.
 */
function drawTearIndicator(ctx, x, y) {
  ctx.strokeStyle = 'rgba(255, 80, 80, 0.7)';
  ctx.lineWidth = 2;
  ctx.setLineDash([4, 4]);
  ctx.beginPath();
  ctx.arc(x, y, CONFIG.tearRadius, 0, Math.PI * 2);
  ctx.stroke();
  ctx.setLineDash([]);
  
  // Crosshair
  const s = 8;
  ctx.strokeStyle = 'rgba(255, 80, 80, 0.9)';
  ctx.lineWidth = 1.5;
  ctx.beginPath();
  ctx.moveTo(x - s, y); ctx.lineTo(x + s, y);
  ctx.moveTo(x, y - s); ctx.lineTo(x, y + s);
  ctx.stroke();
}

// ── function: drawSpawnPreview ──
/**
 * Draw spawn preview (for rope/cloth placement).
 */
function drawSpawnPreview(ctx, x1, y1, x2, y2, tool) {
  if (tool === 'rope') {
    ctx.strokeStyle = `rgba(${CONFIG.colors.rope.h}, ${CONFIG.colors.rope.s}%, ${CONFIG.colors.rope.l}%, 0.5)`;
    ctx.lineWidth = CONFIG.ropeLineWidth;
    ctx.setLineDash([6, 4]);
    ctx.beginPath();
    ctx.moveTo(x1, y1);
    ctx.lineTo(x2, y2);
    ctx.stroke();
    ctx.setLineDash([]);
    
    // Pin indicator at start
    ctx.fillStyle = 'rgba(255, 200, 50, 0.7)';
    ctx.beginPath();
    ctx.arc(x1, y1, 4, 0, Math.PI * 2);
    ctx.fill();
    
  } else if (tool === 'cloth') {
    const w = x2 - x1;
    const h = y2 - y1;
    ctx.strokeStyle = `hsla(${CONFIG.colors.cloth.h}, ${CONFIG.colors.cloth.s}%, ${CONFIG.colors.cloth.l}%, 0.4)`;
    ctx.lineWidth = 1;
    ctx.setLineDash([4, 4]);
    ctx.strokeRect(x1, y1, w, h);
    ctx.setLineDash([]);
    
    // Pin indicators at corners (or full top edge)
    ctx.fillStyle = 'rgba(255, 200, 50, 0.7)';
    ctx.beginPath();
    ctx.arc(x1, y1, 4, 0, Math.PI * 2);
    ctx.fill();
    ctx.beginPath();
    ctx.arc(x1 + w, y1, 4, 0, Math.PI * 2);
    ctx.fill();
    
  } else if (tool === 'softbody') {
    const r = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
    ctx.strokeStyle = `hsla(${CONFIG.colors.softbody.h}, ${CONFIG.colors.softbody.s}%, ${CONFIG.colors.softbody.l}%, 0.4)`;
    ctx.lineWidth = 1;
    ctx.setLineDash([4, 4]);
    ctx.beginPath();
    ctx.arc(x1, y1, r, 0, Math.PI * 2);
    ctx.stroke();
    ctx.setLineDash([]);
    
  } else if (tool === 'ball') {
    const r = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
    ctx.strokeStyle = `hsla(${CONFIG.colors.ball.h}, ${CONFIG.colors.ball.s}%, ${CONFIG.colors.ball.l}%, 0.4)`;
    ctx.lineWidth = 1;
    ctx.setLineDash([4, 4]);
    ctx.beginPath();
    ctx.arc(x1, y1, r, 0, Math.PI * 2);
    ctx.stroke();
    ctx.setLineDash([]);
  }
}

// ── function: render ──
/**
 * Main render function — draws everything.
 */
function render(ctx, objects, canvasW, canvasH, showStress, showPoints, 
               grabbedPoint, tearPos, spawnPreview) {
  // Clear with slight trail for motion blur effect
  ctx.fillStyle = 'rgba(26, 26, 46, 0.85)';
  ctx.fillRect(0, 0, canvasW, canvasH);
  
  // Draw static geometry
  drawStaticGeometry(ctx, canvasW, canvasH);
  
  // Draw all objects (sorted by type for layering: balls on top)
  const order = ['cloth', 'softbody', 'rope', 'ball'];
  const sortedObjects = [...objects].sort((a, b) => 
    order.indexOf(a.type) - order.indexOf(b.type));
  
  for (const obj of sortedObjects) {
    if (!obj.alive || obj.points.length === 0) continue;
    
    switch (obj.type) {
      case 'rope': drawRope(ctx, obj, showStress, showPoints); break;
      case 'cloth': drawCloth(ctx, obj, showStress, showPoints); break;
      case 'softbody': drawSoftBody(ctx, obj, showStress, showPoints); break;
      case 'ball': drawBall(ctx, obj); break;
    }
  }
  
  // Draw pins for all objects
  for (const obj of objects) {
    if (!obj.alive) continue;
    drawPins(ctx, obj.points);
  }
  
  // Draw grab highlight
  drawGrabHighlight(ctx, grabbedPoint);
  
  // Draw tear indicator
  if (tearPos) {
    drawTearIndicator(ctx, tearPos.x, tearPos.y);
  }
  
  // Draw spawn preview
  if (spawnPreview) {
    drawSpawnPreview(ctx, spawnPreview.x1, spawnPreview.y1, 
                     spawnPreview.x2, spawnPreview.y2, spawnPreview.tool);
  }
}

// ── function: findNearestPoint ──
// ============================================================
// INTERACTION — mouse/touch handling for grab, tear, spawn, pin
// ============================================================

/**
 * Find nearest point to a position within the grab radius.
 */
function findNearestPoint(allPoints, x, y, radius) {
  let best = null;
  let bestDist = radius;
  
  for (const p of allPoints) {
    const dx = p.x - x;
    const dy = p.y - y;
    const dist = Math.sqrt(dx * dx + dy * dy);
    
    if (dist < bestDist) {
      bestDist = dist;
      best = p;
    }
  }
  
  return best;
}

// ── function: findObjectForPoint ──
/**
 * Find which object a point belongs to.
 */
function findObjectForPoint(point, objects) {
  for (const obj of objects) {
    if (!obj.alive) continue;
    if (obj.points.includes(point)) return obj;
  }
  return null;
}

// ── function: cutConstraintsNear ──
/**
 * Cut constraints near a position (tear/cut operation).
 */
function cutConstraintsNear(allConstraints, x, y, radius) {
  let cutCount = 0;
  
  for (const c of allConstraints) {
    if (!c.active) continue;
    
    // Check if the constraint line segment is close to the point
    const dx = c.p2.x - c.p1.x;
    const dy = c.p2.y - c.p1.y;
    const lenSq = dx * dx + dy * dy;
    
    if (lenSq < 0.0001) continue;
    
    // Project point onto line segment
    let t = ((x - c.p1.x) * dx + (y - c.p1.y) * dy) / lenSq;
    t = Math.max(0, Math.min(1, t));
    
    const closestX = c.p1.x + t * dx;
    const closestY = c.p1.y + t * dy;
    
    const dist = Math.sqrt((x - closestX) ** 2 + (y - closestY) ** 2);
    
    if (dist < radius) {
      c.active = false;
      cutCount++;
    }
  }
  
  return cutCount;
}

// ── function: togglePin ──
/**
 * Toggle pin on a point.
 */
function togglePin(point) {
  point.pinned = !point.pinned;
  if (point.pinned) {
    // Zero velocity when pinning
    point.px = point.x;
    point.py = point.y;
  }
  return point.pinned;
}

// ── function: getAllPoints ──
/**
 * Collect all points and constraints from alive objects.
 */
function getAllPoints(objects) {
  const pts = [];
  for (const obj of objects) {
    if (!obj.alive) continue;
    pts.push(...obj.points);
  }
  return pts;
}

// ── function: getAllConstraints ──
function getAllConstraints(objects) {
  const cons = [];
  for (const obj of objects) {
    if (!obj.alive) continue;
    cons.push(...obj.constraints);
  }
  return cons;
}

// ── function: cleanupDeadObjects ──
/**
 * Remove dead objects from the array.
 */
function cleanupDeadObjects(objects) {
  // An object is "dead" if it has no active constraints and fewer than 2 points
  for (let i = objects.length - 1; i >= 0; i--) {
    const obj = objects[i];
    if (!obj.alive) {
      objects.splice(i, 1);
    } else if (obj.type !== 'ball' && obj.points.length > 0) {
      // Check if all constraints are cut — object has fallen apart
      const activeCount = obj.constraints.filter(c => c.active).length;
      if (activeCount === 0 && obj.points.length > 2) {
        // Object is completely torn apart - keep points but mark as loose
        // Actually, let's keep them alive so they can still interact with walls
      }
    }
  }
}

// ── function: handleMouseDown ──
/**
 * Handle mouse down — start grab or spawn.
 */
function handleMouseDown(sim, x, y) {
  const allPoints = getAllPoints(sim.objects);
  
  if (sim.currentTool === 'grab') {
    // Try to grab nearest point
    const pt = findNearestPoint(allPoints, x, y, CONFIG.grabRadius);
    if (pt) {
      sim.grabbedPoint = pt;
      sim.grabStartX = x;
      sim.grabStartY = y;
      sim.lastMouseX = x;
      sim.lastMouseY = y;
      sim.dragStartTime = performance.now();
    }
  } else {
    // Start spawning — record first click position
    sim.spawnStartX = x;
    sim.spawnStartY = y;
    sim.isSpawning = true;
  }
}

// ── function: handleMouseMove ──
/**
 * Handle mouse move — drag grabbed point or update spawn preview.
 */
function handleMouseMove(sim, x, y) {
  if (sim.grabbedPoint) {
    // Move grabbed point to cursor position
    const pt = sim.grabbedPoint;
    
    // Track velocity for throw on release
    const dx = x - sim.lastMouseX;
    const dy = y - sim.lastMouseY;
    
    pt.x = x;
    pt.y = y;
    pt.px = x - dx * CONFIG.throwDamping;
    pt.py = y - dy * CONFIG.throwDamping;
    
    sim.lastMouseX = x;
    sim.lastMouseY = y;
  } else if (sim.isSpawning) {
    // Update spawn preview
    sim.spawnPreview = {
      tool: sim.currentTool,
      x1: sim.spawnStartX,
      y1: sim.spawnStartY,
      x2: x,
      y2: y,
    };
  } else if (sim.isTearing) {
    // Cut constraints near cursor
    const allConstraints = getAllConstraints(sim.objects);
    cutConstraintsNear(allConstraints, x, y, CONFIG.tearRadius);
    sim.tearPos = { x, y };
  }
}

// ── function: handleMouseUp ──
/**
 * Handle mouse up — release grab or complete spawn.
 */
function handleMouseUp(sim, x, y) {
  if (sim.grabbedPoint) {
    // Release with throw velocity already set in mouseMove
    sim.grabbedPoint = null;
  } else if (sim.isSpawning) {
    // Complete spawn based on tool type
    const sx = sim.spawnStartX;
    const sy = sim.spawnStartY;
    
    switch (sim.currentTool) {
      case 'rope': {
        const dx = x - sx;
        const dy = y - sy;
        const len = Math.sqrt(dx * dx + dy * dy);
        const segments = Math.max(5, Math.min(60, Math.floor(len / 8)));
        const rope = spawnRope(sx, sy, x, y, segments);
        if (rope) sim.objects.push(rope);
        break;
      }
      case 'cloth': {
        const w = Math.abs(x - sx);
        const h = Math.abs(y - sy);
        if (w > 20 && h > 20) {
          const minX = Math.min(sx, x);
          const minY = Math.min(sy, y);
          const cols = Math.max(4, Math.min(30, Math.floor(w / 12)));
          const rows = Math.max(4, Math.min(30, Math.floor(h / 12)));
          const cloth = spawnCloth(minX, minY, w, h, cols, rows, 'corners');
          if (cloth) sim.objects.push(cloth);
        }
        break;
      }
      case 'softbody': {
        const r = Math.sqrt((x - sx) ** 2 + (y - sy) ** 2);
        if (r > 15) {
          const numPts = Math.max(8, Math.min(24, Math.floor(r / 6)));
          const blob = spawnSoftBody(sx, sy, r, numPts);
          if (blob) sim.objects.push(blob);
        } else {
          // Click without drag — spawn default size soft body box
          const box = spawnSoftBodyBox(sx, sy, 80, 60);
          if (box) sim.objects.push(box);
        }
        break;
      }
      case 'ball': {
        const r = Math.sqrt((x - sx) ** 2 + (y - sy) ** 2);
        const radius = Math.max(10, Math.min(60, r > 5 ? r : 25));
        const ball = spawnBall(sx, sy, radius);
        if (ball) sim.objects.push(ball);
        break;
      }
    }
    
    sim.isSpawning = false;
    sim.spawnPreview = null;
  }
  
  sim.tearPos = null;
}

// ── function: handleRightMouseDown ──
/**
 * Handle right-click drag for tearing.
 */
function handleRightMouseDown(sim, x, y) {
  sim.isTearing = true;
  const allConstraints = getAllConstraints(sim.objects);
  cutConstraintsNear(allConstraints, x, y, CONFIG.tearRadius);
  sim.tearPos = { x, y };
}

// ── function: handleRightMouseMove ──
/**
 * Handle right-click move for tearing.
 */
function handleRightMouseMove(sim, x, y) {
  if (sim.isTearing) {
    const allConstraints = getAllConstraints(sim.objects);
    cutConstraintsNear(allConstraints, x, y, CONFIG.tearRadius);
    sim.tearPos = { x, y };
  }
}

// ── function: handleRightMouseUp ──
/**
 * Handle right-click up — stop tearing.
 */
function handleRightMouseUp(sim) {
  sim.isTearing = false;
  sim.tearPos = null;
}

// ── function: handleModifierClick ──
/**
 * Toggle pin on nearest point (Ctrl+click or Shift+click).
 */
function handleModifierClick(sim, x, y) {
  const allPoints = getAllPoints(sim.objects);
  const pt = findNearestPoint(allPoints, x, y, CONFIG.grabRadius + 5);
  if (pt) {
    togglePin(pt);
    return true;
  }
  return false;
}

// ── function: getCanvasCoords ──
/**
 * Get canvas coordinates from mouse event.
 */
function getCanvasCoords(canvas, e) {
  const rect = canvas.getBoundingClientRect();
  return {
    x: (e.clientX - rect.left) * (canvas.width / rect.width),
    y: (e.clientY - rect.top) * (canvas.height / rect.height),
  };
}

// ── function: getTouchCoords ──
/**
 * Get canvas coordinates from touch event.
 */
function getTouchCoords(canvas, e) {
  const rect = canvas.getBoundingClientRect();
  const t = e.touches[0] || e.changedTouches[0];
  return {
    x: (t.clientX - rect.left) * (canvas.width / rect.width),
    y: (t.clientY - rect.top) * (canvas.height / rect.height),
  };
}

// ── function: createSimulation ──
// ============================================================
// SIMULATION MANAGER — state management and main loop
// ============================================================

/**
 * Create a new simulation instance.
 */
function createSimulation(canvasW, canvasH) {
  return {
    objects: [],
    
    // Physics state
    paused: false,
    accumulator: 0,
    lastTime: 0,
    
    // Interaction state
    currentTool: 'grab',
    grabbedPoint: null,
    isSpawning: false,
    spawnStartX: 0,
    spawnStartY: 0,
    spawnPreview: null,
    isTearing: false,
    tearPos: null,
    
    // Mouse tracking for throw velocity
    lastMouseX: 0,
    lastMouseY: 0,
    dragStartTime: 0,
    
    // Display options
    showStress: false,
    showPoints: false,
    windEnabled: false,
    
    // Performance tracking
    fps: 0,
    frameCount: 0,
    lastFpsTime: 0,
    physicsTimeMs: 0,
    
    // Canvas dimensions (updated on resize)
    canvasW: canvasW,
    canvasH: canvasH,
  };
}

// ── function: stepSimulation ──
/**
 * Step the simulation by one fixed timestep.
 */
function stepSimulation(sim) {
  const dt = CONFIG.dt;
  const gravity = sim.windEnabled ? CONFIG.gravity : CONFIG.gravity;
  const damping = parseFloat(document.getElementById('sliderDamping')?.value || CONFIG.damping);
  const iterations = parseInt(document.getElementById('sliderIter')?.value || CONFIG.constraintIterations);
  const windForce = sim.windEnabled ? CONFIG.windStrength * Math.sin(performance.now() / 1000) : 0;
  
  const allPoints = getAllPoints(sim.objects);
  const allConstraints = getAllConstraints(sim.objects);
  
  if (allPoints.length === 0) return;
  
  const t0 = performance.now();
  
  physicsStep(allPoints, allConstraints, dt, gravity, damping, iterations, windForce, 
              sim.canvasW, sim.canvasH);
  
  sim.physicsTimeMs = performance.now() - t0;
}

// ── function: updateSimulation ──
/**
 * Update simulation with fixed timestep accumulator.
 */
function updateSimulation(sim, currentTime) {
  if (sim.lastTime === 0) sim.lastTime = currentTime;
  
  let frameDelta = (currentTime - sim.lastTime) / 1000;
  sim.lastTime = currentTime;
  
  // Clamp delta to prevent explosion on tab-switch
  frameDelta = Math.min(frameDelta, CONFIG.maxDtAccumulator);
  
  sim.accumulator += frameDelta;
  
  if (!sim.paused) {
    // Step physics with fixed timestep
    let steps = 0;
    const maxSteps = 10; // Prevent infinite loop
    
    while (sim.accumulator >= CONFIG.dt && steps < maxSteps) {
      stepSimulation(sim);
      sim.accumulator -= CONFIG.dt;
      steps++;
    }
    
    // If accumulator is still too large, reset it (prevents spiral of death)
    if (sim.accumulator > CONFIG.maxDtAccumulator) {
      sim.accumulator = 0;
    }
  }
  
  // FPS tracking
  sim.frameCount++;
  if (currentTime - sim.lastFpsTime >= 1000) {
    sim.fps = Math.round(sim.frameCount * 1000 / (currentTime - sim.lastFpsTime));
    sim.frameCount = 0;
    sim.lastFpsTime = currentTime;
  }
}

// ── function: resetSimulation ──
/**
 * Reset the simulation — clear all objects.
 */
function resetSimulation(sim) {
  sim.objects = [];
  sim.grabbedPoint = null;
  sim.isSpawning = false;
  sim.spawnPreview = null;
  sim.isTearing = false;
  sim.tearPos = null;
  sim.accumulator = 0;
}

// ── function: spawnDemoObjects ──
/**
 * Spawn initial demo objects for the playground.
 */
function spawnDemoObjects(sim) {
  const w = sim.canvasW;
  const h = sim.canvasH;
  
  // A rope hanging from top-left area (pendulum-like)
  const rope = spawnRope(w * 0.25, 15, w * 0.3, h * 0.45, 25);
  if (rope) sim.objects.push(rope);
  
  // A cloth pinned at corners — draped nicely
  const clothW = Math.min(280, w * 0.22);
  const clothH = Math.min(200, h * 0.3);
  const clothX = w * 0.5 - clothW / 2;
  const cloth = spawnCloth(clothX, 25, clothW, clothH, 18, 14, 'corners');
  if (cloth) sim.objects.push(cloth);
  
  // A soft body blob — placed to the right
  const blob = spawnSoftBody(w * 0.78, h * 0.25, 35, 12);
  if (blob) sim.objects.push(blob);
  
  // A couple of balls at different sizes
  const ball1 = spawnBall(w * 0.15, h * 0.2, 18);
  if (ball1) sim.objects.push(ball1);
  
  const ball2 = spawnBall(w * 0.9, h * 0.35, 12);
  if (ball2) sim.objects.push(ball2);
}

// ── function: exposeSimAPI ──
/**
 * Expose simulation API on window for headless testing.
 */
function exposeSimAPI(sim) {
  window.sim = {
    get points() { return getAllPoints(sim.objects); },
    get constraints() { return getAllConstraints(sim.objects); },
    get objects() { return sim.objects; },
    step: () => { 
      const allPts = getAllPoints(sim.objects);
      const allCons = getAllConstraints(sim.objects);
      
      if (allPts.length === 0) return;
      
      const dt = CONFIG.dt;
      const gravity = sim.windEnabled ? CONFIG.gravity : CONFIG.gravity;
      const damping = parseFloat(document.getElementById('sliderDamping')?.value || CONFIG.damping);
      const iterations = parseInt(document.getElementById('sliderIter')?.value || CONFIG.constraintIterations);
      const windForce = sim.windEnabled ? CONFIG.windStrength * Math.sin(performance.now() / 1000) : 0;
      
      // Use window dimensions as fallback if sim.canvasW is not set
      const cw = (typeof sim.canvasW === 'number' && isFinite(sim.canvasW)) ? sim.canvasW : window.innerWidth;
      const ch = (typeof sim.canvasH === 'number' && isFinite(sim.canvasH)) ? sim.canvasH : window.innerHeight;
      
      physicsStep(allPts, allCons, dt, gravity, damping, iterations, windForce, cw, ch);
    },
    reset: () => resetSimulation(sim),
    spawnRope: (x1, y1, x2, y2, n) => { const r = spawnRope(x1, y1, x2, y2, n); if (r) sim.objects.push(r); return r; },
    spawnCloth: (x, y, w, h, cols, rows, mode) => { const c = spawnCloth(x, y, w, h, cols, rows, mode || 'corners'); if (c) sim.objects.push(c); return c; },
    spawnSoftBody: (cx, cy, r, n) => { const s = spawnSoftBody(cx, cy, r, n); if (s) sim.objects.push(s); return s; },
    spawnBall: (x, y, r) => { const b = spawnBall(x, y, r); if (b) sim.objects.push(b); return b; },
    cutConstraint: (idx) => { 
      const allC = getAllConstraints(sim.objects);
      if (allC[idx]) { allC[idx].active = false; return true; }
      return false;
    },
    getKineticEnergy: () => computeKineticEnergy(getAllPoints(sim.objects)),
    hasNaN: () => checkNaN(getAllPoints(sim.objects)),
    paused: { 
      get: () => sim.paused, 
      set: (v) => { sim.paused = v; } 
    },
  };
}

// ── main ──
// ============================================================
// MAIN — initialization, event wiring, render loop
// ============================================================
(function() {
  const canvas = document.getElementById('canvas');
  const ctx = canvas.getContext('2d');
  
  // Resize canvas to fill window
  function resizeCanvas() {
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
  }
  resizeCanvas();
  
  // Create simulation (after initial resize)
  const sim = createSimulation(canvas.width, canvas.height);
  spawnDemoObjects(sim);
  exposeSimAPI(sim);
  
  // Now set up resize listener that updates sim dimensions
  window.addEventListener('resize', () => {
    resizeCanvas();
    sim.canvasW = canvas.width;
    sim.canvasH = canvas.height;
  });
  
  // ---- UI Controls ----
  
  // Tool buttons
  document.querySelectorAll('#toolbar [data-tool]').forEach(btn => {
    btn.addEventListener('click', () => {
      document.querySelector('#toolbar .active')?.classList.remove('active');
      btn.classList.add('active');
      sim.currentTool = btn.dataset.tool;
      
      // Update cursor
      canvas.style.cursor = sim.currentTool === 'grab' ? 'default' : 'crosshair';
    });
  });
  
  // Checkboxes
  document.getElementById('chkStress').addEventListener('change', (e) => {
    sim.showStress = e.target.checked;
  });
  document.getElementById('chkPoints').addEventListener('change', (e) => {
    sim.showPoints = e.target.checked;
  });
  document.getElementById('chkWind').addEventListener('change', (e) => {
    sim.windEnabled = e.target.checked;
  });
  
  // Sliders
  const sliderG = document.getElementById('sliderGravity');
  const valG = document.getElementById('valG');
  sliderG.addEventListener('input', () => {
    CONFIG.gravity = parseFloat(sliderG.value);
    valG.textContent = sliderG.value;
  });
  
  const sliderD = document.getElementById('sliderDamping');
  const valD = document.getElementById('valD');
  sliderD.addEventListener('input', () => {
    valD.textContent = sliderD.value;
  });
  
  const sliderI = document.getElementById('sliderIter');
  const valI = document.getElementById('valI');
  sliderI.addEventListener('input', () => {
    CONFIG.constraintIterations = parseInt(sliderI.value);
    valI.textContent = sliderI.value;
  });
  
  // Pause/Step/Reset buttons
  const btnPause = document.getElementById('btnPause');
  btnPause.addEventListener('click', () => {
    sim.paused = !sim.paused;
    btnPause.textContent = sim.paused ? '▶ Play' : '⏸ Pause';
  });
  
  document.getElementById('btnStep').addEventListener('click', () => {
    if (sim.paused) {
      stepSimulation(sim);
    }
  });
  
  document.getElementById('btnReset').addEventListener('click', () => {
    resetSimulation(sim);
    spawnDemoObjects(sim);
  });
  
  // ---- Mouse Events ----
  
  canvas.addEventListener('mousedown', (e) => {
    e.preventDefault();
    const coords = getCanvasCoords(canvas, e);
    
    if (e.button === 2) {
      // Right click — tear mode
      handleRightMouseDown(sim, coords.x, coords.y);
    } else if (e.ctrlKey || e.shiftKey) {
      // Modifier + left click — toggle pin
      handleModifierClick(sim, coords.x, coords.y);
    } else {
      // Left click — grab or spawn
      handleMouseDown(sim, coords.x, coords.y);
    }
  });
  
  canvas.addEventListener('mousemove', (e) => {
    const coords = getCanvasCoords(canvas, e);
    
    if (sim.isTearing) {
      handleRightMouseMove(sim, coords.x, coords.y);
    } else {
      handleMouseMove(sim, coords.x, coords.y);
    }
  });
  
  canvas.addEventListener('mouseup', (e) => {
    const coords = getCanvasCoords(canvas, e);
    
    if (e.button === 2) {
      handleRightMouseUp(sim);
    } else {
      handleMouseUp(sim, coords.x, coords.y);
    }
  });
  
  // Prevent context menu on right click
  canvas.addEventListener('contextmenu', (e) => e.preventDefault());
  
  // ---- Touch Events ----
  
  canvas.addEventListener('touchstart', (e) => {
    e.preventDefault();
    const coords = getTouchCoords(canvas, e);
    
    if (e.touches.length === 1) {
      handleMouseDown(sim, coords.x, coords.y);
    }
  }, { passive: false });
  
  canvas.addEventListener('touchmove', (e) => {
    e.preventDefault();
    const coords = getTouchCoords(canvas, e);
    handleMouseMove(sim, coords.x, coords.y);
  }, { passive: false });
  
  canvas.addEventListener('touchend', (e) => {
    e.preventDefault();
    const coords = getTouchCoords(canvas, e);
    handleMouseUp(sim, coords.x, coords.y);
  }, { passive: false });
  
  // ---- Keyboard Events ----
  
  document.addEventListener('keydown', (e) => {
    switch(e.key) {
      case ' ':
        e.preventDefault();
        sim.paused = !sim.paused;
        btnPause.textContent = sim.paused ? '▶ Play' : '⏸ Pause';
        break;
      case 'ArrowRight':
        if (sim.paused) stepSimulation(sim);
        break;
      case 'g': case 'G':
        document.querySelector('[data-tool="grab"]')?.click();
        break;
      case 'r': case 'R':
        document.querySelector('[data-tool="rope"]')?.click();
        break;
      case 'c': case 'C':
        document.querySelector('[data-tool="cloth"]')?.click();
        break;
      case 's': case 'S':
        document.querySelector('[data-tool="softbody"]')?.click();
        break;
      case 'b': case 'B':
        document.querySelector('[data-tool="ball"]')?.click();
        break;
    }
  });
  
  // ---- Render Loop ----
  
  const statsEl = document.getElementById('stats');
  
  function renderLoop(timestamp) {
    updateSimulation(sim, timestamp);
    
    // Render
    render(ctx, sim.objects, canvas.width, canvas.height, 
           sim.showStress, sim.showPoints,
           sim.grabbedPoint, sim.tearPos, sim.spawnPreview);
    
    // Update stats display
    if (sim.frameCount > 0) {
      statsEl.textContent = `FPS: ${sim.fps} | Phys: ${sim.physicsTimeMs.toFixed(1)}ms`;
    }
    
    requestAnimationFrame(renderLoop);
  }
  
  requestAnimationFrame(renderLoop);
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"composer","sessionId":"1f5567c4-ad0a-4274-99b0-277b3c7f44d6","tokensIn":3183370,"tokensOut":59389,"tokensTotal":3242759,"turns":69,"toolCalls":68,"failedToolCalls":1,"timestamp":"2026-07-30T04:06:03.057Z"} -->