← Physics Playground results

Physics Playground

Qwen 3.6 27B Fable Fusion 711 Uncensored Heretic NM DAU NEO MAX MTP GGUF · 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 38,070 bytes · SHA-256 35ea1fc8b6f7
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>2D Soft-Body Physics Playground</title>
<style>
:root {
  --bg: #0a0a0f;
  --panel: #16161e;
  --border: #2a2a36;
  --text: #c8c8d4;
  --accent: #4fc3f7;
  --danger: #ef5350;
  --success: #66bb6a;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
  background: var(--bg);
  color: var(--text);
  font-family: 'Segoe UI', system-ui, sans-serif;
  font-size: 13px;
  overflow: hidden;
  display: flex;
  height: 100vh;
}
#sidebar {
  width: 240px;
  min-width: 240px;
  background: var(--panel);
  border-right: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 12px;
  overflow-y: auto;
  z-index: 10;
}
.section {
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 10px;
}
.section-title {
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: #666;
  margin-bottom: 8px;
}
.spawn-btn {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  padding: 7px 10px;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: #1e1e28;
  color: var(--text);
  cursor: pointer;
  font-size: 12px;
  margin-bottom: 4px;
  transition: all 0.15s;
}
.spawn-btn:hover { background: #282838; border-color: var(--accent); }
.spawn-btn.active { border-color: var(--accent); background: #1a2836; box-shadow: 0 0 8px rgba(79,195,247,0.2); }
.spawn-dot {
  width: 10px;
  height: 10px;
  border-radius: 50%;
}
.controls-row {
  display: flex;
  gap: 6px;
  margin-bottom: 6px;
}
.ctrl-btn {
  padding: 6px 10px;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: #1e1e28;
  color: var(--text);
  cursor: pointer;
  font-size: 12px;
  flex: 1;
  transition: all 0.15s;
}
.ctrl-btn:hover { background: #282838; border-color: var(--accent); }
.ctrl-btn.danger:hover { border-color: var(--danger); background: #281e1e; }
.slider-row {
  display: flex;
  flex-direction: column;
  gap: 3px;
  margin-bottom: 6px;
}
.slider-row label {
  display: flex;
  justify-content: space-between;
  font-size: 11px;
  color: #888;
}
.slider-row input[type="range"] {
  width: 100%;
  accent-color: var(--accent);
}
.toggle-row {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 5px;
}
.toggle-row input[type="checkbox"] {
  accent-color: var(--accent);
}
#stats {
  font-family: 'Courier New', monospace;
  font-size: 10px;
  color: #666;
  line-height: 1.5;
}
#canvas-wrap {
  flex: 1;
  position: relative;
  overflow: hidden;
}
canvas {
  display: block;
  width: 100%;
  height: 100%;
  cursor: crosshair;
}
#overlay-hint {
  position: absolute;
  bottom: 12px;
  left: 12px;
  right: 12px;
  display: flex;
  gap: 12px;
  font-size: 11px;
  color: #555;
  pointer-events: none;
}
#overlay-hint span {
  background: rgba(10,10,15,0.8);
  padding: 4px 8px;
  border-radius: 3px;
}
.kbd {
  background: #282838;
  padding: 1px 5px;
  border-radius: 3px;
  border: 1px solid #3a3a46;
  font-family: monospace;
  font-size: 10px;
}
#tear-indicator {
  position: absolute;
  top: 12px;
  left: 12px;
  padding: 4px 10px;
  border-radius: 4px;
  font-size: 11px;
  font-weight: 600;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.2s;
}
#tear-indicator.active {
  opacity: 1;
  background: rgba(239,83,80,0.2);
  border: 1px solid var(--danger);
  color: var(--danger);
}
</style>
</head>
<body>
<div id="sidebar">
  <div class="section">
    <div class="section-title">Spawn</div>
    <button class="spawn-btn" data-spawn="rope"><div class="spawn-dot" style="background:#4fc3f7"></div>Rope (drag to draw)</button>
    <button class="spawn-btn" data-spawn="cloth"><div class="spawn-dot" style="background:#ff7043"></div>Cloth (drag rect)</button>
    <button class="spawn-btn" data-spawn="softbody"><div class="spawn-dot" style="background:#66bb6a"></div>Soft Body (click)</button>
    <button class="spawn-btn" data-spawn="ball"><div class="spawn-dot" style="background:#ffd54f"></div>Ball (click)</button>
    <div class="toggle-row">
      <span>Pin corners only</span>
      <input type="checkbox" id="pinCorners">
    </div>
    <div class="slider-row">
      <label><span>Segments</span><span id="segVal">20</span></label>
      <input type="range" id="segmentSlider" min="5" max="60" value="20">
    </div>
  </div>

  <div class="section">
    <div class="section-title">Simulation</div>
    <div class="controls-row">
      <button class="ctrl-btn" id="btnPause">⏸ Pause</button>
      <button class="ctrl-btn" id="btnStep">⏭ Step</button>
    </div>
    <div class="controls-row">
      <button class="ctrl-btn" id="btnReset">↺ Reset</button>
      <button class="ctrl-btn danger" id="btnClear">✕ Clear All</button>
    </div>
    <div class="slider-row">
      <label><span>Gravity</span><span id="gravVal">980</span></label>
      <input type="range" id="gravSlider" min="0" max="2000" value="980">
    </div>
    <div class="slider-row">
      <label><span>Damping</span><span id="dampVal">0.985</span></label>
      <input type="range" id="dampSlider" min="0.9" max="0.999" value="0.985" step="0.001">
    </div>
    <div class="slider-row">
      <label><span>Iterations</span><span id="iterVal">5</span></label>
      <input type="range" id="iterSlider" min="1" max="12" value="5">
    </div>
    <div class="toggle-row">
      <span>Wind</span>
      <input type="checkbox" id="windToggle">
    </div>
    <div class="slider-row" id="windRow" style="display:none">
      <label><span>Wind Force</span><span id="windVal">150</span></label>
      <input type="range" id="windSlider" min="0" max="500" value="150">
    </div>
  </div>

  <div class="section">
    <div class="section-title">Visual</div>
    <div class="toggle-row">
      <span>Stress colors</span>
      <input type="checkbox" id="stressToggle">
    </div>
    <div class="toggle-row">
      <span>Show points</span>
      <input type="checkbox" id="pointsToggle">
    </div>
    <div class="toggle-row">
      <span>Cloth fill</span>
      <input type="checkbox" id="clothFillToggle" checked>
    </div>
  </div>

  <div class="section">
    <div class="section-title">Stats</div>
    <div id="stats">
      FPS: 0<br>
      Points: 0<br>
      Constraints: 0<br>
      Phys time: 0.00ms
    </div>
  </div>
</div>

<div id="canvas-wrap">
  <canvas id="canvas"></canvas>
  <div id="tear-indicator">✂ CUT MODE</div>
  <div id="overlay-hint">
    <span><kbd class="kbd">Space</kbd> Pause</span>
    <span><kbd class="kbd">←→</kbd> Step</span>
    <span><kbd class="kbd">R</kbd> Reset</span>
    <span><kbd class="kbd">Right-drag</kbd> Cut</span>
    <span><kbd class="kbd">Shift</kbd>+click Pin</span>
    <span><kbd class="kbd">Ctrl</kbd>+click Spawn pt</span>
  </div>
</div>

<script>
/* ==============================================
   TUNABLE CONSTANTS
   ============================================== */
const CFG = {
  TIMESTEP: 1 / 60,
  MAX_SUBSTEPS: 8,
  MAX_FRAME_DELTA: 0.25,
  CONSTRAINT_ITERATIONS: 5,
  DAMPING: 0.985,
  GRAVITY: 980,
  FRICTION: 0.25,
  GRAB_RADIUS: 16,
  TEAR_RADIUS: 12,
  TEAR_FORCE_THRESHOLD: 25,
  MIN_LENGTH: 0.5,
  BOUNCE: 0.3,
  WIND_STRENGTH: 0,
};

const COLORS = {
  rope: '#4fc3f7',
  cloth: '#ff7043',
  softbody: '#66bb6a',
  ball: '#ffd54f',
  pin: '#ffffff',
  grab: '#e0e0ff',
  wall: '#2a2a3a',
  bg: '#0a0a0f',
};

/* ==============================================
   MATH UTILS
   ============================================== */
const Vec2 = {
  add: (a, b) => ({ x: a.x + b.x, y: a.y + b.y }),
  sub: (a, b) => ({ x: a.x - b.x, y: a.y - b.y }),
  scale: (v, s) => ({ x: v.x * s, y: v.y * s }),
  len: (v) => Math.sqrt(v.x * v.x + v.y * v.y),
  dist: (a, b) => Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2),
  normalize: (v) => {
    const l = Math.sqrt(v.x * v.x + v.y * v.y);
    return l > 1e-6 ? { x: v.x / l, y: v.y / l } : { x: 0, y: 0 };
  },
  dot: (a, b) => a.x * b.x + a.y * b.y,
};

function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }

function stressColor(ratio) {
  const r = clamp((ratio - 0.8) / 0.4, 0, 1);
  const g = clamp(1 - (ratio - 0.95) / 0.3, 0, 1);
  return `rgb(${Math.round(r * 255)},${Math.round(g * 255)},20)`;
}

/* ==============================================
   PHYSICS CLASSES
   ============================================== */
class Point {
  constructor(x, y, pinned = false, radius = 0, color = '#ccc') {
    this.x = x;
    this.y = y;
    this.oldX = x;
    this.oldY = y;
    this.pinned = pinned;
    this.radius = radius;
    this.color = color;
    this.mass = 1;
    this.grabbed = false;
    this.grabVelX = 0;
    this.grabVelY = 0;
    this.objectId = -1;
    this.id = -1;
  }

  get vx() { return this.x - this.oldX; }
  get vy() { return this.y - this.oldY; }
  get speed() { return Math.sqrt(this.vx * this.vx + this.vy * this.vy); }
  get ke() { return 0.5 * this.mass * (this.vx * this.vx + this.vy * this.vy); }
}

class Constraint {
  constructor(p1, p2, length = null, color = null) {
    this.p1 = p1;
    this.p2 = p2;
    this.length = length !== null ? length : Vec2.dist(p1, p2);
    this.color = color;
    this.active = true;
    this.stretchRatio = 1;
    this.id = -1;
  }
}

/* ==============================================
   SIMULATION ENGINE
   ============================================== */
class Simulation {
  constructor(canvas) {
    this.canvas = canvas;
    this.ctx = canvas.getContext('2d');
    this.points = [];
    this.constraints = [];
    this.nextPointId = 0;
    this.nextConstraintId = 0;
    this.nextObjectId = 0;

    // Static geometry
    this.staticCircles = [];
    this.staticBoxes = [];

    // State
    this.paused = false;
    this.windToggle = false;
    this.showStress = false;
    this.showPoints = false;
    this.clothFill = true;

    // Interaction
    this.mouseDown = false;
    this.mouseX = 0;
    this.mouseY = 0;
    this.lastMouseX = 0;
    this.lastMouseY = 0;
    this.grabbedPoint = null;
    this.cutting = false;
    this.spawnMode = null;
    this.spawnStart = null;

    // Timing
    this.accumulator = 0;
    this.lastTime = performance.now();
    this.physicsTime = 0;

    // Stats
    this.frameCount = 0;
    this.fps = 0;
    this.fpsTimer = 0;
    this.lastPhysTime = 0;

    this.resize();
    window.addEventListener('resize', () => this.resize());
    this.setupInput();
  }

  resize() {
    const rect = this.canvas.parentElement.getBoundingClientRect();
    this.canvas.width = rect.width;
    this.canvas.height = rect.height;
    this.width = rect.width;
    this.height = rect.height;
  }

  setupInput() {
    const c = this.canvas;

    c.addEventListener('mousedown', (e) => {
      const rect = c.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;

      this.mouseX = x;
      this.mouseY = y;
      this.lastMouseX = x;
      this.lastMouseY = y;

      if (e.button === 2) {
        e.preventDefault();
        this.cutting = true;
        this.mouseDown = true;
        return;
      }

      // Shift + click: toggle pin
      if (e.shiftKey) {
        const pt = this.findNearestPoint(x, y, 20);
        if (pt) {
          pt.pinned = !pt.pinned;
        }
        return;
      }

      // Ctrl + click: spawn point at location when in spawn mode
      if (e.ctrlKey && this.spawnMode === 'softbody') {
        this.spawnSoftBodyAt(x, y);
        return;
      }

      if (this.spawnMode) {
        this.mouseDown = true;
        this.spawnStart = { x, y };
        return;
      }

      // Grab point
      const pt = this.findNearestPoint(x, y, CFG.GRAB_RADIUS);
      if (pt && !pt.pinned) {
        this.grabbedPoint = pt;
        this.mouseDown = true;
      }
    });

    c.addEventListener('mousemove', (e) => {
      const rect = c.getBoundingClientRect();
      this.lastMouseX = this.mouseX;
      this.lastMouseY = this.mouseY;
      this.mouseX = e.clientX - rect.left;
      this.mouseY = e.clientY - rect.top;

      if (this.cutting && this.mouseDown) {
        this.cutConstraintsBetween(this.lastMouseX, this.lastMouseY, this.mouseX, this.mouseY);
      }

      if (this.grabbedPoint && this.mouseDown) {
        this.grabbedPoint.oldX = this.mouseX;
        this.grabbedPoint.oldY = this.mouseY;
        this.grabbedPoint.x = this.mouseX;
        this.grabbedPoint.y = this.mouseY;
        this.grabbedPoint.grabbed = true;
      }
    });

    c.addEventListener('mouseup', (e) => {
      if (this.grabbedPoint) {
        this.grabbedPoint.grabbed = false;
        // Preserve throw velocity from grab
        this.grabbedPoint = null;
      }
      if (this.cutting) {
        this.cutting = false;
      }

      // Handle spawn drag
      if (this.spawnMode && this.spawnStart && this.mouseDown) {
        const x = this.mouseX;
        const y = this.mouseY;
        const startX = this.spawnStart.x;
        const startY = this.spawnStart.y;
        const seg = parseInt(document.getElementById('segmentSlider').value);

        if (this.spawnMode === 'rope') {
          this.spawnRope(startX, startY, x, y, seg);
        } else if (this.spawnMode === 'cloth') {
          this.spawnCloth(Math.min(startX, x), Math.min(startY, y), Math.abs(x - startX), Math.abs(y - startY), seg);
        }

        this.spawnStart = null;
      }

      this.mouseDown = false;
    });

    c.addEventListener('contextmenu', (e) => e.preventDefault());

    // Keyboard
    window.addEventListener('keydown', (e) => {
      if (e.code === 'Space') {
        e.preventDefault();
        this.paused = !this.paused;
        document.getElementById('btnPause').textContent = this.paused ? '▶ Play' : '⏸ Pause';
      }
      if (e.code === 'ArrowLeft' || e.code === 'ArrowRight') {
        if (this.paused) {
          this.step();
        }
      }
      if (e.code === 'KeyR') {
        this.reset();
      }
    });

    // UI bindings
    document.querySelectorAll('.spawn-btn').forEach(btn => {
      btn.addEventListener('click', () => {
        document.querySelectorAll('.spawn-btn').forEach(b => b.classList.remove('active'));
        if (this.spawnMode === btn.dataset.spawn) {
          this.spawnMode = null;
        } else {
          this.spawnMode = btn.dataset.spawn;
          btn.classList.add('active');
        }
      });
    });

    document.getElementById('btnPause').addEventListener('click', () => {
      this.paused = !this.paused;
      document.getElementById('btnPause').textContent = this.paused ? '▶ Play' : '⏸ Pause';
    });

    document.getElementById('btnStep').addEventListener('click', () => this.step());
    document.getElementById('btnReset').addEventListener('click', () => this.reset());
    document.getElementById('btnClear').addEventListener('click', () => this.clearAll());

    document.getElementById('gravSlider').addEventListener('input', (e) => {
      CFG.GRAVITY = parseFloat(e.target.value);
      document.getElementById('gravVal').textContent = CFG.GRAVITY;
    });

    document.getElementById('dampSlider').addEventListener('input', (e) => {
      CFG.DAMPING = parseFloat(e.target.value);
      document.getElementById('dampVal').textContent = CFG.DAMPING.toFixed(3);
    });

    document.getElementById('iterSlider').addEventListener('input', (e) => {
      CFG.CONSTRAINT_ITERATIONS = parseInt(e.target.value);
      document.getElementById('iterVal').textContent = CFG.CONSTRAINT_ITERATIONS;
    });

    document.getElementById('segmentSlider').addEventListener('input', (e) => {
      document.getElementById('segVal').textContent = e.target.value;
    });

    document.getElementById('windToggle').addEventListener('change', (e) => {
      this.windToggle = e.target.checked;
      document.getElementById('windRow').style.display = e.target.checked ? 'flex' : 'none';
    });

    document.getElementById('windSlider').addEventListener('input', (e) => {
      CFG.WIND_STRENGTH = parseFloat(e.target.value);
      document.getElementById('windVal').textContent = CFG.WIND_STRENGTH;
    });

    document.getElementById('stressToggle').addEventListener('change', (e) => {
      this.showStress = e.target.checked;
    });

    document.getElementById('pointsToggle').addEventListener('change', (e) => {
      this.showPoints = e.target.checked;
    });

    document.getElementById('clothFillToggle').addEventListener('change', (e) => {
      this.clothFill = e.target.checked;
    });

    // Tear indicator
    window.addEventListener('mousedown', (e) => {
      const ind = document.getElementById('tear-indicator');
      if (e.button === 2) ind.classList.add('active');
    });
    window.addEventListener('mouseup', (e) => {
      if (e.button === 2) document.getElementById('tear-indicator').classList.remove('active');
    });
  }

  /* ---------- Object Creation ---------- */

  addPoint(x, y, pinned = false, radius = 0, color = '#ccc') {
    const pt = new Point(x, y, pinned, radius, color);
    pt.id = this.nextPointId++;
    this.points.push(pt);
    return pt;
  }

  addConstraint(p1, p2, length = null, color = null) {
    const c = new Constraint(p1, p2, length, color);
    c.id = this.nextConstraintId++;
    this.constraints.push(c);
    return c;
  }

  spawnRope(x1, y1, x2, y2, segments) {
    const color = COLORS.rope;
    const dx = x2 - x1;
    const dy = y2 - y1;
    const len = Math.sqrt(dx * dx + dy * dy);
    if (len < 20) return;
    const spacing = len / segments;
    const nx = dx / len;
    const ny = dy / len;

    const pts = [];
    for (let i = 0; i <= segments; i++) {
      const px = x1 + nx * i * spacing;
      const py = y1 + ny * i * spacing;
      pts.push(this.addPoint(px, py, i === 0, 0, color));
    }

    for (let i = 0; i < pts.length - 1; i++) {
      pts[i].objectId = this.nextObjectId;
      pts[i + 1].objectId = this.nextObjectId;
      this.addConstraint(pts[i], pts[i + 1], spacing, color);
    }
    this.nextObjectId++;
  }

  spawnCloth(x, y, w, h, segs) {
    const color = COLORS.cloth;
    if (w < 20 || h < 20) return;
    const cols = Math.max(4, Math.round(w / 18));
    const rows = Math.max(4, Math.round(h / 18));
    const dx = w / (cols - 1);
    const dy = h / (rows - 1);

    const pinCornersOnly = document.getElementById('pinCorners').checked;
    const pts = [];
    for (let row = 0; row < rows; row++) {
      for (let col = 0; col < cols; col++) {
        const px = x + col * dx;
        const py = y + row * dy;
        let pinned = false;
        if (row === 0) {
          if (pinCornersOnly) {
            pinned = (col === 0 || col === cols - 1);
          } else {
            pinned = true;
          }
        }
        pts.push(this.addPoint(px, py, pinned, 0, color));
      }
    }

    const oid = this.nextObjectId++;
    for (let row = 0; row < rows; row++) {
      for (let col = 0; col < cols; col++) {
        const idx = row * cols + col;
        pts[idx].objectId = oid;

        // Structural (horizontal + vertical)
        if (col < cols - 1) {
          this.addConstraint(pts[idx], pts[idx + 1], dx, color);
        }
        if (row < rows - 1) {
          this.addConstraint(pts[idx], pts[idx + cols], dy, color);
        }

        // Shear (diagonal) - every other to avoid over-constraining
        if (col < cols - 1 && row < rows - 1) {
          if ((row + col) % 2 === 0) {
            this.addConstraint(pts[idx], pts[idx + cols + 1], Math.sqrt(dx * dx + dy * dy), color);
          } else {
            this.addConstraint(pts[idx + 1], pts[idx + cols], Math.sqrt(dx * dx + dy * dy), color);
          }
        }
      }
    }
  }

  spawnSoftBodyAt(x, y) {
    const color = COLORS.softbody;
    const segs = parseInt(document.getElementById('segmentSlider').value);
    const radius = 50;
    const pts = [];

    // Center point
    const center = this.addPoint(x, y, false, 0, color);
    pts.push(center);

    // Ring points
    for (let i = 0; i < segs; i++) {
      const angle = (i / segs) * Math.PI * 2;
      const px = x + Math.cos(angle) * radius;
      const py = y + Math.sin(angle) * radius;
      const pt = this.addPoint(px, py, false, 3, color);
      pt.objectId = this.nextObjectId;
      pts.push(pt);
    }

    const oid = this.nextObjectId++;
    center.objectId = oid;

    // Edge constraints
    for (let i = 1; i < pts.length; i++) {
      this.addConstraint(pts[i], pts[(i % (pts.length - 1)) + 1], null, color);
    }

    // Radial constraints (center to ring)
    for (let i = 1; i < pts.length; i++) {
      this.addConstraint(center, pts[i], radius, color);
    }

    // Cross braces for rigidity (every 3rd)
    for (let i = 1; i < pts.length; i++) {
      const j = ((i + Math.floor(segs / 3)) % (pts.length - 1)) + 1;
      this.addConstraint(pts[i], pts[j], null, color);
    }
  }

  spawnSoftBody(x, y, w, h) {
    this.spawnSoftBodyAt(x + w / 2, y + h / 2);
  }

  spawnBall(x, y) {
    const color = COLORS.ball;
    const r = 12;
    const pt = this.addPoint(x, y, false, r, color);
    pt.objectId = this.nextObjectId++;
  }

  /* ---------- Query ---------- */

  findNearestPoint(x, y, radius) {
    let best = null;
    let bestDist = radius * radius;
    for (let i = 0; i < this.points.length; i++) {
      const p = this.points[i];
      const dx = x - p.x;
      const dy = y - p.y;
      const d2 = dx * dx + dy * dy;
      if (d2 < bestDist) {
        bestDist = d2;
        best = p;
      }
    }
    return best;
  }

  /* ---------- Cutting ---------- */

  cutConstraintsBetween(x1, y1, x2, y2) {
    const segs = 5;
    for (let i = 0; i <= segs; i++) {
      const t = i / segs;
      const cx = x1 + (x2 - x1) * t;
      const cy = y1 + (y2 - y1) * t;
      this.cutAtPoint(cx, cy);
    }
  }

  cutAtPoint(x, y) {
    for (let i = this.constraints.length - 1; i >= 0; i--) {
      const c = this.constraints[i];
      if (!c.active) continue;
      if (this.pointToSegmentDist(x, y, c.p1.x, c.p1.y, c.p2.x, c.p2.y) < CFG.TEAR_RADIUS) {
        c.active = false;
      }
    }
  }

  pointToSegmentDist(px, py, ax, ay, bx, by) {
    const dx = bx - ax;
    const dy = by - ay;
    const len2 = dx * dx + dy * dy;
    if (len2 < 1e-6) return Math.sqrt((px - ax) ** 2 + (py - ay) ** 2);
    let t = ((px - ax) * dx + (py - ay) * dy) / len2;
    t = clamp(t, 0, 1);
    const cx = ax + t * dx;
    const cy = ay + t * dy;
    return Math.sqrt((px - cx) ** 2 + (py - cy) ** 2);
  }

  /* ---------- Physics Step ---------- */

  integrate() {
    const dt = CFG.TIMESTEP;
    const dt2 = dt * dt;
    const grav = CFG.GRAVITY;
    const damp = CFG.DAMPING;
    const wind = this.windToggle ? CFG.WIND_STRENGTH : 0;

    for (let i = 0; i < this.points.length; i++) {
      const p = this.points[i];
      if (p.pinned || p.grabbed) continue;

      const vx = (p.x - p.oldX) * damp;
      const vy = (p.y - p.oldY) * damp;

      p.oldX = p.x;
      p.oldY = p.y;

      p.x += vx + wind * dt2;
      p.y += vy + grav * dt2;

      // NaN guard
      if (!isFinite(p.x) || !isFinite(p.y)) {
        p.x = p.oldX;
        p.y = p.oldY;
      }
    }
  }

  solveConstraints() {
    const iterations = CFG.CONSTRAINT_ITERATIONS;

    for (let iter = 0; iter < iterations; iter++) {
      for (let i = 0; i < this.constraints.length; i++) {
        const c = this.constraints[i];
        if (!c.active) continue;

        const p1 = c.p1;
        const p2 = c.p2;

        const dx = p2.x - p1.x;
        const dy = p2.y - p1.y;
        const dist = Math.sqrt(dx * dx + dy * dy);

        if (dist < CFG.MIN_LENGTH) {
          c.stretchRatio = 1;
          continue;
        }

        c.stretchRatio = dist / c.length;

        const diff = (dist - c.length) / dist;
        const corrX = dx * diff * 0.5;
        const corrY = dy * diff * 0.5;

        if (!p1.pinned && !p1.grabbed) {
          p1.x += corrX;
          p1.y += corrY;
        }
        if (!p2.pinned && !p2.grabbed) {
          p2.x -= corrX;
          p2.y -= corrY;
        }
      }

      // Collisions with static geometry
      this.handleCollisions();
    }
  }

  handleCollisions() {
    const w = this.width;
    const h = this.height;
    const bounce = CFG.BOUNCE;
    const friction = CFG.FRICTION;

    for (let i = 0; i < this.points.length; i++) {
      const p = this.points[i];
      const r = p.radius || 0;

      if (p.pinned || p.grabbed) continue;

      // Walls
      if (p.x < r) {
        p.x = r;
        if (p.oldX > p.x) p.oldX = p.x + (p.oldX - p.x) * bounce;
        p.oldY = p.y - (p.y - p.oldY) * friction;
      }
      if (p.x > w - r) {
        p.x = w - r;
        if (p.oldX < p.x) p.oldX = p.x - (p.x - p.oldX) * bounce;
        p.oldY = p.y - (p.y - p.oldY) * friction;
      }
      if (p.y < r) {
        p.y = r;
        if (p.oldY > p.y) p.oldY = p.y + (p.oldY - p.y) * bounce;
        p.oldX = p.x - (p.x - p.oldX) * friction;
      }
      if (p.y > h - r) {
        p.y = h - r;
        if (p.oldY < p.y) p.oldY = p.y - (p.y - p.oldY) * bounce;
        p.oldX = p.x - (p.x - p.oldX) * friction;
      }

      // Static circles
      for (let j = 0; j < this.staticCircles.length; j++) {
        const c = this.staticCircles[j];
        const dx = p.x - c.x;
        const dy = p.y - c.y;
        const dist = Math.sqrt(dx * dx + dy * dy);
        const minDist = c.r + r;
        if (dist < minDist && dist > 1e-6) {
          const nx = dx / dist;
          const ny = dy / dist;
          const overlap = minDist - dist;
          p.x += nx * overlap;
          p.y += ny * overlap;
          // Frictional bounce
          const vx = p.x - p.oldX;
          const vy = p.y - p.oldY;
          const vn = vx * nx + vy * ny;
          if (vn < 0) {
            const tx = -ny;
            const ty = nx;
            const vt = vx * tx + vy * ty;
            p.oldX = p.x - (-vn * bounce * nx + vt * (1 - friction) * tx);
            p.oldY = p.y - (-vn * bounce * ny + vt * (1 - friction) * ty);
          }
        }
      }

      // Static boxes
      for (let j = 0; j < this.staticBoxes.length; j++) {
        const b = this.staticBoxes[j];
        const cx = clamp(p.x, b.x, b.x + b.w);
        const cy = clamp(p.y, b.y, b.y + b.h);
        const dx = p.x - cx;
        const dy = p.y - cy;
        const dist = Math.sqrt(dx * dx + dy * dy);
        if (dist < r && dist > 1e-6) {
          const nx = dx / dist;
          const ny = dy / dist;
          const overlap = r - dist;
          p.x += nx * overlap;
          p.y += ny * overlap;
          const vx = p.x - p.oldX;
          const vy = p.y - p.oldY;
          const vn = vx * nx + vy * ny;
          if (vn < 0) {
            const tx = -ny;
            const ty = nx;
            const vt = vx * tx + vy * ty;
            p.oldX = p.x - (-vn * bounce * nx + vt * (1 - friction) * tx);
            p.oldY = p.y - (-vn * bounce * ny + vt * (1 - friction) * ty);
          }
        }
      }
    }
  }

  step() {
    this.integrate();
    this.solveConstraints();
    this.physicsTime += CFG.TIMESTEP;
  }

  /* ---------- Update Loop ---------- */

  update() {
    const now = performance.now();
    let dt = now - this.lastTime;
    this.lastTime = now;

    // Clamp frame delta to prevent explosions on tab switch
    dt = Math.min(dt, CFG.MAX_FRAME_DELTA * 1000);

    // FPS tracking
    this.frameCount++;
    this.fpsTimer += dt;
    if (this.fpsTimer >= 500) {
      this.fps = Math.round(this.frameCount * 1000 / this.fpsTimer);
      this.frameCount = 0;
      this.fpsTimer = 0;
      this.updateStats();
    }

    if (!this.paused) {
      this.accumulator += dt / 1000;

      let substeps = 0;
      while (this.accumulator >= CFG.TIMESTEP && substeps < CFG.MAX_SUBSTEPS) {
        const stepStart = performance.now();
        this.step();
        this.lastPhysTime = performance.now() - stepStart;
        this.accumulator -= CFG.TIMESTEP;
        substeps++;
      }

      // Prevent spiral of death
      if (this.accumulator > CFG.TIMESTEP * 2) {
        this.accumulator = 0;
      }
    }

    this.render();
    requestAnimationFrame(() => this.update());
  }

  updateStats() {
    const el = document.getElementById('stats');
    el.innerHTML = `FPS: ${this.fps}<br>Points: ${this.points.length}<br>Constraints: ${this.constraints.filter(c => c.active).length}<br>Phys time: ${this.lastPhysTime.toFixed(1)}ms`;
  }

  /* ---------- Rendering ---------- */

  render() {
    const ctx = this.ctx;
    const w = this.width;
    const h = this.height;

    // Clear
    ctx.fillStyle = COLORS.bg;
    ctx.fillRect(0, 0, w, h);

    // Grid
    ctx.strokeStyle = '#14141e';
    ctx.lineWidth = 1;
    for (let x = 0; x < w; x += 40) {
      ctx.beginPath();
      ctx.moveTo(x, 0);
      ctx.lineTo(x, h);
      ctx.stroke();
    }
    for (let y = 0; y < h; y += 40) {
      ctx.beginPath();
      ctx.moveTo(0, y);
      ctx.lineTo(w, y);
      ctx.stroke();
    }

    // Draw static geometry
    this.drawStaticGeometry(ctx);

    // Group constraints by object for cloth fill
    const clothObjects = new Map();

    // Draw constraints
    ctx.lineWidth = 1.2;
    for (let i = 0; i < this.constraints.length; i++) {
      const c = this.constraints[i];
      if (!c.active) continue;
      const p1 = c.p1;
      const p2 = c.p2;

      if (this.showStress) {
        ctx.strokeStyle = stressColor(c.stretchRatio);
      } else {
        ctx.strokeStyle = c.color || '#555';
      }

      ctx.beginPath();
      ctx.moveTo(p1.x, p1.y);
      ctx.lineTo(p2.x, p2.y);
      ctx.stroke();

      // Track cloth objects
      if (p1.objectId >= 0 && p1.color === COLORS.cloth) {
        if (!clothObjects.has(p1.objectId)) {
          clothObjects.set(p1.objectId, []);
        }
        clothObjects.get(p1.objectId).push({ p1: { x: p1.x, y: p1.y }, p2: { x: p2.x, y: p2.y } });
      }
    }

    // Draw soft bodies filled
    this.drawSoftBodiesFilled(ctx);

    // Draw cloth filled
    if (this.clothFill) {
      this.drawClothFilled(ctx, clothObjects);
    }

    // Draw balls
    ctx.lineWidth = 1;
    for (let i = 0; i < this.points.length; i++) {
      const p = this.points[i];
      if (p.radius > 0) {
        ctx.beginPath();
        ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
        ctx.fillStyle = p.color;
        ctx.fill();
        ctx.strokeStyle = 'rgba(255,255,255,0.3)';
        ctx.stroke();
      }
    }

    // Draw points
    if (this.showPoints || this.points.length < 200) {
      for (let i = 0; i < this.points.length; i++) {
        const p = this.points[i];
        if (p.radius > 0) continue;

        if (p.pinned) {
          ctx.fillStyle = COLORS.pin;
          ctx.beginPath();
          ctx.moveTo(p.x - 4, p.y);
          ctx.lineTo(p.x + 4, p.y);
          ctx.moveTo(p.x, p.y - 4);
          ctx.lineTo(p.x, p.y + 4);
          ctx.stroke();
        } else if (this.showPoints) {
          ctx.fillStyle = p.color;
          ctx.fillRect(p.x - 1.5, p.y - 1.5, 3, 3);
        }
      }
    }

    // Draw grab highlight
    if (this.grabbedPoint) {
      const p = this.grabbedPoint;
      ctx.strokeStyle = COLORS.grab;
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.arc(p.x, p.y, 8, 0, Math.PI * 2);
      ctx.stroke();
    }

    // Draw spawn preview
    if (this.spawnMode && this.spawnStart && this.mouseDown) {
      ctx.strokeStyle = 'rgba(255,255,255,0.4)';
      ctx.lineWidth = 1;
      ctx.setLineDash([4, 4]);
      if (this.spawnMode === 'rope') {
        ctx.beginPath();
        ctx.moveTo(this.spawnStart.x, this.spawnStart.y);
        ctx.lineTo(this.mouseX, this.mouseY);
        ctx.stroke();
      } else if (this.spawnMode === 'cloth') {
        ctx.strokeRect(
          Math.min(this.spawnStart.x, this.mouseX),
          Math.min(this.spawnStart.y, this.mouseY),
          Math.abs(this.mouseX - this.spawnStart.x),
          Math.abs(this.mouseY - this.spawnStart.y)
        );
      }
      ctx.setLineDash([]);
    }

    // Cut mode cursor trail
    if (this.cutting) {
      ctx.strokeStyle = COLORS.danger;
      ctx.lineWidth = CFG.TEAR_RADIUS * 2;
      ctx.globalAlpha = 0.3;
      ctx.beginPath();
      ctx.arc(this.mouseX, this.mouseY, CFG.TEAR_RADIUS, 0, Math.PI * 2);
      ctx.stroke();
      ctx.globalAlpha = 1;
    }
  }

  drawStaticGeometry(ctx) {
    // Walls (subtle)
    ctx.strokeStyle = '#2a2a3a';
    ctx.lineWidth = 3;
    ctx.strokeRect(1.5, 1.5, this.width - 3, this.height - 3);

    // Floor line
    ctx.strokeStyle = '#3a3a4a';
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(0, this.height - 1);
    ctx.lineTo(this.width, this.height - 1);
    ctx.stroke();

    // Static circles
    for (let i = 0; i < this.staticCircles.length; i++) {
      const c = this.staticCircles[i];
      ctx.beginPath();
      ctx.arc(c.x, c.y, c.r, 0, Math.PI * 2);
      ctx.fillStyle = '#1a1a24';
      ctx.fill();
      ctx.strokeStyle = '#4a4a5a';
      ctx.lineWidth = 2;
      ctx.stroke();
    }

    // Static boxes
    for (let i = 0; i < this.staticBoxes.length; i++) {
      const b = this.staticBoxes[i];
      ctx.fillStyle = '#1a1a24';
      ctx.fillRect(b.x, b.y, b.w, b.h);
      ctx.strokeStyle = '#4a4a5a';
      ctx.lineWidth = 2;
      ctx.strokeRect(b.x, b.y, b.w, b.h);
    }
  }

  drawSoftBodiesFilled(ctx) {
    // Collect points by object
    const objects = new Map();
    for (let i = 0; i < this.points.length; i++) {
      const p = this.points[i];
      if (p.color === COLORS.softbody && p.objectId >= 0 && !objects.has(p.objectId)) {
        objects.set(p.objectId, []);
      }
      if (p.color === COLORS.softbody && p.objectId >= 0) {
        objects.get(p.objectId).push(p);
      }
    }

    // Draw convex hull-ish fill for each
    for (const [oid, pts] of objects) {
      if (pts.length < 3) continue;

      // Find center (smallest radius point or first)
      let centerIdx = 0;
      let minR = Infinity;
      for (let i = 0; i < pts.length; i++) {
        if (pts[i].radius === 0) {
          centerIdx = i;
          break;
        }
        if (pts[i].radius < minR) {
          minR = pts[i].radius;
          centerIdx = i;
        }
      }
      const center = pts[centerIdx];
      const ring = pts.filter(p => p !== center);

      if (ring.length < 3) continue;

      // Sort ring by angle
      ring.sort((a, b) => {
        return Math.atan2(a.y - center.y, a.x - center.x) - Math.atan2(b.y - center.y, b.x - center.x);
      });

      ctx.beginPath();
      ctx.moveTo(ring[0].x, ring[0].y);
      for (let i = 1; i < ring.length; i++) {
        ctx.lineTo(ring[i].x, ring[i].y);
      }
      ctx.closePath();

      ctx.fillStyle = 'rgba(102,187,106,0.15)';
      ctx.fill();
    }
  }

  drawClothFilled(ctx, clothObjects) {
    // For each cloth object, create triangles from grid
    for (const [oid, constraints] of clothObjects) {
      // Collect unique points
      const points = new Map();
      for (const c of constraints) {
        const key1 = `${c.p1.x.toFixed(0)},${c.p1.y.toFixed(0)}`;
        const key2 = `${c.p2.x.toFixed(0)},${c.p2.y.toFixed(0)}`;
        if (!points.has(key1)) points.set(key1, c.p1);
        if (!points.has(key2)) points.set(key2, c.p2);
      }

      if (points.size < 4) continue;

      // Simple approach: draw triangles based on structural constraints
      const structConstraints = constraints.filter(c => {
        // Horizontal or vertical-ish
        const dx = c.p2.x - c.p1.x;
        const dy = c.p2.y - c.p1.y;
        return Math.abs(Math.abs(dx) - Math.abs(dy)) > Math.sqrt(dx*dx+dy*dy) * 0.3;
      });

      // Find grid structure - this is complex, so use convex hull fill instead
      const pts = Array.from(points.values());
      const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length;
      const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length;

      pts.sort((a, b) => {
        return Math.atan2(a.y - cy, a.x - cx) - Math.atan2(b.y - cy, b.x - cx);
      });

      ctx.beginPath();
      ctx.moveTo(pts[0].x, pts[0].y);
      for (let i = 1; i < pts.length; i++) {
        ctx.lineTo(pts[i].x, pts[i].y);
      }
      ctx.closePath();

      ctx.fillStyle = 'rgba(255,112,67,0.1)';
      ctx.fill();
    }
  }

  /* ---------- Control ---------- */

  reset() {
    this.clearAll();
    this.lastTime = performance.now();
    this.accumulator = 0;
    this.physicsTime = 0;
    this.addDemoObjects();
  }

  clearAll() {
    this.points = [];
    this.constraints = [];
    this.nextPointId = 0;
    this.nextConstraintId = 0;
    this.nextObjectId = 0;
    this.grabbedPoint = null;
    this.spawnStart = null;
    this.updateStats();
  }

  addDemoObjects() {
    const w = this.width;
    const h = this.height;

    // Static obstacle: large circle
    this.staticCircles.push({ x: w * 0.4, y: h * 0.55, r: 60 });
    // Another circle
    this.staticCircles.push({ x: w * 0.65, y: h * 0.45, r: 40 });
    // A box
    this.staticBoxes.push({ x: w * 0.8, y: h * 0.75, w: 100, h: 20 });

    // Rope
    this.spawnRope(w * 0.15, h * 0.15, w * 0.15 + 50, h * 0.55, 30);

    // Cloth
    this.spawnCloth(w * 0.45, h * 0.1, w * 0.25, h * 0.35, 20);

    // Soft body
    this.spawnSoftBodyAt(w * 0.7, h * 0.2, 50);

    // Ball
    this.spawnBall(w * 0.25, h * 0.15);
  }
}

/* ==============================================
   INITIALIZATION
   ============================================== */
const canvas = document.getElementById('canvas');
const sim = new Simulation(canvas);
window.sim = sim; // Expose for debugging/probing

sim.addDemoObjects();
sim.update();
</script>
</body>
</html>
<!-- agent-meta {"model":"Qwen3.6-27B-Fable-Fusion-711-Uncensored-Heretic-NM-DAU-NEO-MAX-MTP-GGUF","provider":"brigid","persona":"full","sessionId":"78308e20-9418-4738-b4d3-dbcbc4156310","tokensIn":23941,"tokensOut":12863,"tokensTotal":36804,"cost":0,"turns":3,"toolCalls":2,"failedToolCalls":0,"timestamp":"2026-08-06T03:44:07.018Z"} -->