← Physics Playground results

Physics Playground

Ornith 1.5 35B A3B · 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 43,805 bytes · SHA-256 aef0ce342736
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Soft-Body Physics Playground</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body {
    width: 100%; height: 100%;
    background: #0a0b12;
    overflow: hidden;
    font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
    color: #cfd4e6;
    user-select: none;
    -webkit-user-select: none;
  }
  #stage {
    display: block;
    position: absolute;
    top: 0; left: 0;
    cursor: crosshair;
  }
  /* Minimal toolbar, kept out of the way */
  #toolbar {
    position: absolute;
    top: 12px; left: 12px;
    display: flex;
    flex-direction: column;
    gap: 8px;
    z-index: 10;
    max-width: 232px;
  }
  .panel {
    background: rgba(20, 22, 34, 0.78);
    border: 1px solid rgba(120, 130, 170, 0.18);
    border-radius: 10px;
    padding: 9px 10px;
    backdrop-filter: blur(6px);
    box-shadow: 0 6px 22px rgba(0,0,0,0.45);
  }
  .panel h3 {
    font-size: 10px;
    letter-spacing: 1.5px;
    text-transform: uppercase;
    color: #7f87ad;
    margin-bottom: 7px;
    font-weight: 600;
  }
  .row { display: flex; gap: 6px; margin-bottom: 6px; flex-wrap: wrap; }
  .row:last-child { margin-bottom: 0; }
  button {
    font-family: inherit;
    font-size: 11px;
    color: #d6dcf2;
    background: rgba(60, 66, 95, 0.5);
    border: 1px solid rgba(120, 130, 170, 0.22);
    border-radius: 7px;
    padding: 6px 10px;
    cursor: pointer;
    transition: background 0.12s, border-color 0.12s, transform 0.05s;
    flex: 1;
    min-width: 0;
  }
  button:hover { background: rgba(80, 88, 128, 0.7); border-color: rgba(150,160,205,0.45); }
  button:active { transform: translateY(1px); }
  button.active {
    background: rgba(90, 120, 220, 0.65);
    border-color: rgba(140,170,255,0.7);
    color: #fff;
    box-shadow: 0 0 10px rgba(110,140,255,0.35);
  }
  .slider-row { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; }
  .slider-row:last-child { margin-bottom: 0; }
  .slider-row label {
    font-size: 10px;
    color: #9aa2c4;
    width: 62px;
    flex: none;
    letter-spacing: 0.5px;
  }
  input[type=range] {
    -webkit-appearance: none;
    appearance: none;
    flex: 1;
    height: 4px;
    border-radius: 3px;
    background: rgba(90, 100, 140, 0.5);
    outline: none;
  }
  input[type=range]::-webkit-slider-thumb {
    -webkit-appearance: none;
    width: 14px; height: 14px;
    border-radius: 50%;
    background: #8fa0ff;
    cursor: pointer;
    border: 2px solid #1a1c2b;
    box-shadow: 0 0 6px rgba(140,160,255,0.5);
  }
  input[type=range]::-moz-range-thumb {
    width: 14px; height: 14px;
    border-radius: 50%;
    background: #8fa0ff;
    cursor: pointer;
    border: 2px solid #1a1c2b;
  }
  .val { font-size: 10px; color: #8fa0ff; width: 34px; text-align: right; flex: none; }
  #readout {
    font-size: 10px;
    line-height: 1.6;
    color: #9aa2c4;
  }
  #readout b { color: #cfe0ff; font-weight: 600; }
  #readout .good { color: #6ee7a0; }
  #readout .bad { color: #ff7a8a; }
  #hint {
    position: absolute;
    bottom: 12px; left: 50%;
    transform: translateX(-50%);
    font-size: 11px;
    color: #6b7294;
    background: rgba(20, 22, 34, 0.6);
    padding: 5px 14px;
    border-radius: 20px;
    border: 1px solid rgba(120,130,170,0.15);
    z-index: 10;
    letter-spacing: 0.4px;
    pointer-events: none;
  }
  #hint kbd {
    color: #aeb6de;
    background: rgba(80,88,128,0.4);
    padding: 1px 5px;
    border-radius: 4px;
    font-size: 10px;
    border: 1px solid rgba(120,130,170,0.2);
  }
</style>
</head>
<body>
<canvas id="stage"></canvas>

<div id="toolbar">
  <div class="panel">
    <h3>Spawn</h3>
    <div class="row">
      <button id="tool-rope" class="tool active">Rope</button>
      <button id="tool-cloth" class="tool">Cloth</button>
    </div>
    <div class="row">
      <button id="tool-body" class="tool">Soft Body</button>
      <button id="tool-ball" class="tool">Ball</button>
    </div>
    <div class="row">
      <button id="tool-grab" class="tool">Grab</button>
      <button id="tool-cut" class="tool">Tear</button>
    </div>
  </div>

  <div class="panel">
    <h3>Physics</h3>
    <div class="slider-row">
      <label>Gravity</label>
      <input type="range" id="sl-gravity" min="0" max="2000" value="1000" step="10">
      <span id="v-gravity" class="val">1000</span>
    </div>
    <div class="slider-row">
      <label>Iterations</label>
      <input type="range" id="sl-iters" min="1" max="12" value="5" step="1">
      <span id="v-iters" class="val">5</span>
    </div>
    <div class="slider-row">
      <label>Damping</label>
      <input type="range" id="sl-damping" min="900" max="1000" value="997" step="1">
      <span id="v-damping" class="val">0.997</span>
    </div>
    <div class="slider-row">
      <label>Wind</label>
      <input type="range" id="sl-wind" min="-800" max="800" value="0" step="10">
      <span id="v-wind" class="val">0</span>
    </div>
    <div class="row">
      <button id="chk-stress">Stress</button>
      <button id="chk-points">Points</button>
    </div>
  </div>

  <div class="panel">
    <h3>Controls</h3>
    <div class="row">
      <button id="btn-pause">Pause</button>
      <button id="btn-step">Step</button>
    </div>
    <div class="row">
      <button id="btn-reset">Reset</button>
      <button id="btn-clear">Clear</button>
    </div>
  </div>

  <div class="panel">
    <div id="readout"></div>
  </div>
</div>

<div id="hint">
  <kbd>Drag</kbd> draw &nbsp; <kbd>Left-drag</kbd> grab &nbsp; <kbd>Right-drag</kbd> tear &nbsp; <kbd>Shift-click</kbd> pin &nbsp; <kbd>Space</kbd> pause &nbsp; <kbd>→</kbd> step
</div>

<script>
'use strict';

/* =========================================================================
   TUNABLE CONSTANTS — the single source of truth for the whole sim.
   ========================================================================= */
const CFG = {
  // Fixed physics timestep (60 Hz). Render rate decoupled from this.
  DT: 1 / 60,
  MAX_FRAME: 0.25,        // clamp wall-clock delta so tab-switch can't explode
  ACCUM_MAX: 0.25,        // cap accumulator so a backlog can't detonate next frame

  // Integration
  DAMPING: 0.997,         // velocity retention per step (energy decay -> settles)
  SLOP: 0.0001,           // epsilon to avoid division by zero in constraints

  // Constraint relaxation
  ITERATIONS: 5,          // constraint solves per physics step
  STIFFNESS: 1.0,         // point-based dynamics: relax fully (PBD-like)

  // Forces
  GRAVITY: 1000,          // px/s^2
  WIND: 0,
  WIND_GUST: 0.4,         // gentle time-varying gust multiplier

  // Collision
  FLOOR_FRICTION: 0.90,   // tangential velocity retained on floor hit
  WALL_FRICTION: 0.90,
  BALL_REST: 0.55,        // restitution for ball
  CIRCLE_REST: 0.4,

  // Geometry / spawning
  ROPE_SEGMENTS: 16,
  ROPE_SPACING: 14,
  Cloth: { cols: 9, rows: 7, spacing: 16, pinPattern: 'full' },
  SOFT_RADIUS: 6,
  SOFT_POINTS: 12,
  BALL_RADIUS: 26,

  // Interaction
  GRAB_RADIUS: 28,
  PIN_RADIUS: 18,
  CUT_RADIUS: 14,        // constraint tear proximity

  // Bounds for sanity checks
  MAX_COORD: 8000,

  // Colors — each object type a distinct hue
  COLORS: {
    rope:   '#ffb347',
    cloth:  '#4dd2ff',
    soft:   '#ff6ec7',
    ball:   '#7bff94',
    pin:    '#ffe14d',
    grab:   '#ffffff',
    cut:    '#ff5566',
    stressLow: '#4dff9a',
    stressHigh: '#ff4d58',
    static: '#3a4066',
    staticEdge: '#5a6299',
  },
};

/* =========================================================================
   MATH HELPERS
   ========================================================================= */
const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
const lerpColor = (a, b, t) => {
  // a,b are [r,g,b] 0-255
  const r = Math.round(a[0] + (b[0] - a[0]) * t);
  const g = Math.round(a[1] + (b[1] - a[1]) * t);
  const bl = Math.round(a[2] + (b[2] - a[2]) * t);
  return `rgb(${r},${g},${bl})`;
};
const COL_A = [77, 255, 154];
const COL_B = [255, 77, 88];

/* =========================================================================
   POINT — a mass node. Position-based dynamics: we store position and the
   previous position; velocity is implicit (x - xPrev).
   ========================================================================= */
class Point {
  constructor(x, y, opts = {}) {
    this.x = x;
    this.y = y;
    this.px = x;
    this.py = y;
    this.ax = 0;
    this.ay = 0;
    this.pinned = opts.pinned || false;
    this.grabbed = opts.grabbed || false;
    this.mass = opts.mass || 1;
    this.radius = opts.radius || 0;
    this.invMass = this.pinned ? 0 : (1 / this.mass);
    this.obj = opts.obj || null; // back-reference to owning object
    this.vx = 0;
    this.vy = 0;
  }

  applyForce(fx, fy, dt) {
    // Accumulate acceleration (force / mass). Pinned points ignore forces.
    if (this.invMass === 0) return;
    this.ax += fx * this.invMass;
    this.ay += fy * this.invMass;
  }

  integrate(dt, damping, gravity, wind) {
    if (this.invMass === 0) {
      // Pinned / grabbed-while-held: keep put, but propagate grab motion
      // via the constraint solver (grabbed handled in Sim).
      this.px = this.x;
      this.py = this.y;
      return;
    }
    // Verlet: x' = x + (x - xPrev)*damping + a*dt^2
    const vx = (this.x - this.px) * damping;
    const vy = (this.y - this.py) * damping;
    const dt2 = dt * dt;

    let nx = this.x + vx + this.ax * dt2;
    let ny = this.y + vy + this.ay * dt2;

    // Guard against NaN poisoning the whole sim.
    if (!isFinite(nx)) nx = this.x;
    if (!isFinite(ny)) ny = this.y;

    this.px = this.x;
    this.py = this.y;
    this.x = nx;
    this.y = ny;

    // Implicit velocity for throwing / readout.
    this.vx = (this.x - this.px);
    this.vy = (this.y - this.py);

    // Hard bound: kill runaway coordinates before they blow up.
    if (Math.abs(this.x) > CFG.MAX_COORD) { this.x = Math.sign(this.x) * CFG.MAX_COORD; this.px = this.x; }
    if (Math.abs(this.y) > CFG.MAX_COORD) { this.y = Math.sign(this.y) * CFG.MAX_COORD; this.py = this.y; }

    // Reset per-frame accelerations.
    this.ax = 0;
    this.ay = 0;
  }
}

/* =========================================================================
   CONSTRAINT — a distance link between two points.
   ========================================================================= */
class Constraint {
  constructor(p1, p2, opts = {}) {
    this.p1 = p1;
    this.p2 = p2;
    const dx = p2.x - p1.x;
    const dy = p2.y - p1.y;
    this.rest = Math.sqrt(dx * dx + dy * dy) || CFG.SLOP;
    this.stiffness = opts.stiffness || CFG.STIFFNESS;
    this.active = true;
    this.torn = false;
    this.tearing = false; // temporarily disabled by a cut stroke (reversible-ish)
    this.stress = 0;        // 0..1+ current stretch for visualization
    this.type = opts.type || 'dist';
  }

  // Solve a single distance constraint (PBD projection).
  solve() {
    if (!this.active || this.torn) return;
    const p1 = this.p1, p2 = this.p2;
    let dx = p2.x - p1.x;
    let dy = p2.y - p1.y;
    let dist = Math.sqrt(dx * dx + dy * dy);
    if (dist < CFG.SLOP) {
      // Zero-length / coincident points: nudge apart to avoid div-by-zero.
      dx = CFG.SLOP;
      dy = 0;
      dist = CFG.SLOP;
    }

    // Track stress = how far stretched relative to rest (for viz).
    this.stress = dist / this.rest;

    const diff = (dist - this.rest) * this.stiffness;
    const invMassSum = p1.invMass + p2.invMass;
    if (invMassSum <= 0) return; // both pinned

    // Normalized correction direction.
    const nx = dx / dist;
    const ny = dy / dist;

    const corrX = nx * (diff / invMassSum);
    const corrY = ny * (diff / invMassSum);

    p1.x += corrX * p1.invMass;
    p1.y += corrY * p1.invMass;
    p2.x -= corrX * p2.invMass;
    p2.y -= corrY * p2.invMass;
  }
}

/* =========================================================================
   STATIC GEOMETRY — non-moving collision shapes.
   ========================================================================= */
class StaticCircle {
  constructor(x, y, r) { this.x = x; this.y = y; this.r = r; }
}
class StaticBox {
  constructor(x, y, w, h) {
    this.x = x; this.y = y; this.w = w; this.h = h;
    this.left = x - w / 2; this.right = x + w / 2;
    this.top = y - h / 2; this.bottom = y + h / 2;
  }
}

/* =========================================================================
   SIM — the whole world. Owns points, constraints, static shapes.
   ========================================================================= */
class Sim {
  constructor(canvas) {
    this.canvas = canvas;
    this.ctx = canvas.getContext('2d');
    this.W = canvas.width;
    this.H = canvas.height;

    this.points = [];
    this.constraints = [];
    this.staticShapes = [];
    this.objects = [];

    this.paused = false;
    this.accumulator = 0;
    this.lastTime = 0;
    this.framesSinceStep = 0;

    // Physics params (also driven by sliders).
    this.gravity = CFG.GRAVITY;
    this.iterations = CFG.ITERATIONS;
    this.damping = CFG.DAMPING;
    this.wind = CFG.WIND;

    // Render toggles.
    this.showStress = false;
    this.showPoints = false;

    // Interaction state.
    this.tool = 'rope';
    this.mouse = { x: 0, y: 0, px: 0, py: 0, down: false, right: false };
    this.grab = null;   // { point, vx, vy }
    this.cutStroke = null; // { p1, p2 } for live tear visualization
    this.spawnPreview = null;

    this.stats = { fps: 60, physTime: 0, steps: 0, ke: 0, points: 0 };

    this.bindUI();
    this.bindInput();
    this.seed();
  }

  /* ---- world setup --------------------------------------------------- */
  seed() {
    const w = this.W, h = this.H;
    // A couple of static shapes to drape onto.
    this.staticShapes.push(new StaticCircle(w * 0.62, h * 0.6, 55));
    this.staticShapes.push(new StaticBox(w * 0.32, h * 0.72, 170, 26));
    // A ledge box for soft bodies to land on.
    this.staticShapes.push(new StaticBox(w * 0.75, h * 0.78, 150, 24));

    // A default hanging rope (hangs straight down from a pin).
    this.spawnRope(w * 0.25, h * 0.2, 1);
    // A default cloth.
    this.spawnCloth(w * 0.5, h * 0.2);
  }

  resize(w, h) {
    this.W = w; this.H = h;
    this.canvas.width = w;
    this.canvas.height = h;
  }

  /* ---- fixed-timestep step ------------------------------------------- */
  step(dt = CFG.DT) {
    const t0 = performance.now();

    // 1) Integrate (Verlet) with forces.
    const gust = 1 + 0.25 * Math.sin(performance.now() * 0.0013) * (Math.abs(this.wind) > 10 ? 1 : 0);
    for (const p of this.points) {
      p.ax = 0; p.ay = 0;
      // Gravity (down +Y).
      p.applyForce(0, this.gravity * p.mass, 0);
      // Wind (horizontal), with slight per-point jitter for natural sway.
      if (this.wind !== 0) {
        const j = 1 + 0.15 * Math.sin((p.x * 0.03) + performance.now() * 0.002);
        p.applyForce(this.wind * p.mass * j, 0, 0);
      }
      p.integrate(dt, this.damping, this.gravity, this.wind);
    }

    // 2) Grab constraint: pull grabbed point toward target.
    if (this.grab && this.grab.point) {
      const gp = this.grab.point;
      // Smoothly move grabbed point; also nudge prev so release throws velocity.
      gp.x = this.mouse.x;
      gp.y = this.mouse.y;
      gp.px = this.mouse.px;
      gp.py = this.mouse.py;
      gp.grabbed = true;
    }

    // 3) Constraint relaxation — iterate N times.
    for (let k = 0; k < this.iterations; k++) {
      for (const c of this.constraints) c.solve();
      this.resolveCollisions();
    }

    this.stats.physTime = performance.now() - t0;
    this.stats.steps++;
  }

  // Release a grab (called on mouse-up). The throw velocity is carried by the
  // grabbed point's (x - px) difference set during the drag.
  releaseGrab() {
    if (this.grab && this.grab.point) this.grab.point.grabbed = false;
    this.grab = null;
  }

  /* ---- collisions ---------------------------------------------------- */
  resolveCollisions() {
    const w = this.W, h = this.H;
    for (const p of this.points) {
      if (p.invMass === 0) continue;

      // Ball bounces more than cloth/rope.
      const rest = (p.obj && p.obj.name === 'ball') ? CFG.BALL_REST : 0.3;
      const fric = 0.92;

      // Floor — reflect vertical velocity, friction on horizontal.
      if (p.y > h - p.radius) {
        const vy = p.y - p.py;
        p.y = h - p.radius;
        p.py = p.y + vy * rest;
        p.px = p.x - (p.x - p.px) * fric;
      }
      // Ceiling.
      if (p.y < p.radius) {
        const vy = p.y - p.py;
        p.y = p.radius;
        p.py = p.y + vy * rest;
        p.px = p.x - (p.x - p.px) * fric;
      }
      // Left wall.
      if (p.x < p.radius) {
        const vx = p.x - p.px;
        p.x = p.radius;
        p.px = p.x + vx * rest;
        p.py = p.y - (p.y - p.py) * fric;
      }
      // Right wall.
      if (p.x > w - p.radius) {
        const vx = p.x - p.px;
        p.x = w - p.radius;
        p.px = p.x + vx * rest;
        p.py = p.y - (p.y - p.py) * fric;
      }

      // Static circles / boxes.
      for (const s of this.staticShapes) {
        if (s instanceof StaticCircle) this.collideCircle(p, s);
        else if (s instanceof StaticBox) this.collideBox(p, s);
      }
    }
  }

  collideCircle(p, s) {
    const dx = p.x - s.x, dy = p.y - s.y;
    const d = Math.sqrt(dx * dx + dy * dy);
    const minD = s.r + p.radius;
    if (d < minD && d > CFG.SLOP) {
      const nx = dx / d, ny = dy / d;
      const overlap = minD - d;
      p.x += nx * overlap;
      p.y += ny * overlap;
      // Reflect velocity for restitution + friction.
      const vx = p.x - p.px, vy = p.y - p.py;
      const vn = vx * nx + vy * ny;
      const tx = vx - nx * vn, ty = vy - ny * vn; // tangential
      p.px = p.x - (nx * vn * -CFG.CIRCLE_REST + tx * CFG.WALL_FRICTION);
      p.py = p.y - (ny * vn * -CFG.CIRCLE_REST + ty * CFG.WALL_FRICTION);
    }
  }

  collideBox(p, box) {
    // Closest point on the box to the point.
    const cx = clamp(p.x, box.left, box.right);
    const cy = clamp(p.y, box.top, box.bottom);
    const dx = p.x - cx, dy = p.y - cy;
    const d2 = dx * dx + dy * dy;
    const minD = p.radius;
    if (d2 < minD * minD && d2 > CFG.SLOP * CFG.SLOP) {
      const d = Math.sqrt(d2);
      let nx, ny;
      if (d >= CFG.SLOP) { nx = dx / d; ny = dy / d; }
      else {
        // Inside / center: push out along nearest axis.
        const overL = p.x - box.left, overR = box.right - p.x;
        const overT = p.y - box.top, overB = box.bottom - p.y;
        const m = Math.min(overL, overR, overT, overB);
        if (m === overL) { nx = -1; ny = 0; }
        else if (m === overR) { nx = 1; ny = 0; }
        else if (m === overT) { nx = 0; ny = -1; }
        else { nx = 0; ny = 1; }
      }
      const overlap = minD - d;
      p.x += nx * overlap;
      p.y += ny * overlap;
      const vx = p.x - p.px, vy = p.y - p.py;
      const vn = vx * nx + vy * ny;
      const tx = vx - nx * vn, ty = vy - ny * vn;
      p.px = p.x - (nx * vn * -CFG.CIRCLE_REST + tx * CFG.WALL_FRICTION);
      p.py = p.y - (ny * vn * -CFG.CIRCLE_REST + ty * CFG.WALL_FRICTION);
    }
  }

  /* ---- object factories ---------------------------------------------- */
  addPoint(x, y, opts) {
    const p = new Point(x, y, opts);
    p.obj = this.objects[this.objects.length - 1] || null;
    this.points.push(p);
    return p;
  }

  addConstraint(p1, p2, opts) {
    const c = new Constraint(p1, p2, opts);
    this.constraints.push(c);
    return c;
  }

  newObject(name) {
    const obj = { name, points: [], constraints: [] };
    this.objects.push(obj);
    return obj;
  }

  spawnRope(x, y, dir) {
    const obj = this.newObject('rope');
    const pts = [];
    for (let i = 0; i < CFG.ROPE_SEGMENTS + 1; i++) {
      const p = this.addPoint(x + dir * i * CFG.ROPE_SPACING, y, { mass: 1, obj });
      pts.push(p);
    }
    for (let i = 0; i < pts.length - 1; i++) {
      const c = this.addConstraint(pts[i], pts[i + 1], { stiffness: 1, type: 'rope' });
      obj.constraints.push(c);
    }
    pts[0].pinned = true; pts[0].invMass = 0;
    obj.pinned = pts[0];
    return obj;
  }

  spawnCloth(x, y, opts = {}) {
    const obj = this.newObject('cloth');
    const cols = CFG.Cloth.cols, rows = CFG.Cloth.rows, sp = CFG.Cloth.spacing;
    const pattern = opts.pattern || CFG.Cloth.pinPattern;
    const grid = [];
    const flat = [];
    for (let r = 0; r < rows; r++) {
      const row = [];
      for (let c = 0; c < cols; c++) {
        const p = this.addPoint(x + c * sp, y + r * sp, { mass: 1, obj });
        row.push(p);
        flat.push(p);
      }
      grid.push(row);
    }
    // Expose the flat grid for the mesh renderer.
    obj.points = flat;
    // Structural constraints.
    for (let r = 0; r < rows; r++) {
      for (let c = 0; c < cols; c++) {
        if (c < cols - 1) this._clothLink(obj, grid[r][c], grid[r][c + 1]);
        if (r < rows - 1) this._clothLink(obj, grid[r][c], grid[r + 1][c]);
      }
    }
    // Shear constraints (diagonals) for shape stability.
    for (let r = 0; r < rows - 1; r++) {
      for (let c = 0; c < cols - 1; c++) {
        if ((r + c) % 2 === 0) this._clothLink(obj, grid[r][c], grid[r + 1][c + 1], 0.8);
        else this._clothLink(obj, grid[r][c + 1], grid[r + 1][c], 0.8);
      }
    }
    // Pins.
    if (pattern === 'corners') {
      grid[0][0].pinned = true; grid[0][0].invMass = 0;
      grid[0][cols - 1].pinned = true; grid[0][cols - 1].invMass = 0;
      obj.pins = [grid[0][0], grid[0][cols - 1]];
    } else { // full edge
      obj.pins = [];
      for (let c = 0; c < cols; c++) { grid[0][c].pinned = true; grid[0][c].invMass = 0; obj.pins.push(grid[0][c]); }
    }
    return obj;
  }

  _clothLink(obj, a, b, stiffness) {
    const c = this.addConstraint(a, b, { stiffness: stiffness || 1, type: 'cloth' });
    obj.constraints.push(c);
    return c;
  }

  spawnSoftBody(x, y, r, kind) {
    const obj = this.newObject('soft');
    const n = CFG.SOFT_POINTS;
    const pts = [];
    // Perimeter points on a circle.
    for (let i = 0; i < n; i++) {
      const a = (i / n) * Math.PI * 2 - Math.PI / 2;
      const p = this.addPoint(x + Math.cos(a) * r, y + Math.sin(a) * r, { mass: 1, radius: 2, obj });
      pts.push(p);
    }
    // Edge constraints keep the perimeter a polygon.
    for (let i = 0; i < n; i++) {
      const a = pts[i], b = pts[(i + 1) % n];
      this.addConstraint(a, b, { stiffness: 1, type: 'soft-edge' });
    }
    // Cross-braces (diagonals) give the body structural rigidity so it
    // resists collapsing to a line, while still allowing free deformation.
    // These are symmetric and do not inject net momentum when squashing.
    const skips = [Math.floor(n / 2), Math.floor(n / 2) - 1];
    for (const s of skips) {
      for (let i = 0; i < n; i++) {
        this.addConstraint(pts[i], pts[(i + s) % n], { stiffness: 0.6, type: 'soft-brace' });
      }
    }
    obj.points = pts;
    return obj;
  }

  spawnBall(x, y, r) {
    const obj = this.newObject('ball');
    const p = this.addPoint(x, y, { mass: 1, radius: r || CFG.BALL_RADIUS, obj });
    obj.point = p;
    return obj;
  }

  /* ---- interaction helpers ------------------------------------------- */
  findNearestPoint(x, y, radius) {
    let best = null, bestD = radius * radius;
    for (const p of this.points) {
      const dx = p.x - x, dy = p.y - y;
      const d2 = dx * dx + dy * dy;
      if (d2 < bestD) { bestD = d2; best = p; }
    }
    return best;
  }

  /* ---- tearing ------------------------------------------------------- */
  cutAlong(a, b) {
    // Remove (tear) any active constraint that intersects segment a-b within CUT_RADIUS.
    const thr = CFG.CUT_RADIUS;
    for (const c of this.constraints) {
      if (!c.active || c.torn) continue;
      if (segDist(c.p1.x, c.p1.y, c.p2.x, c.p2.y, a.x, a.y, b.x, b.y) < thr) {
        c.active = false;
        c.torn = true;
      }
    }
  }

  /* ---- pin toggle ---------------------------------------------------- */
  togglePin(p) {
    p.pinned = !p.pinned;
    p.invMass = p.pinned ? 0 : 1 / p.mass;
  }

  /* ---- main loop ----------------------------------------------------- */
  start() {
    this.lastTime = performance.now();
    const loop = (now) => {
      const frame = now - this.lastTime;
      this.lastTime = now;

      // FPS measurement.
      this.framesSinceStep++;
      if (this.framesSinceStep % 15 === 0) {
        this.stats.fps = 1000 / Math.max(frame, 1);
      }

      // Fixed-timestep accumulator, clamped for safety.
      let dt = Math.min(frame / 1000, CFG.MAX_FRAME);
      this.accumulator += dt;
      if (this.accumulator > CFG.ACCUM_MAX) this.accumulator = CFG.ACCUM_MAX;

      if (!this.paused) {
        let guard = 0;
        while (this.accumulator >= CFG.DT && guard < 8) {
          this.step(CFG.DT);
          this.accumulator -= CFG.DT;
          guard++;
        }
      }

      this.render();
      this.updateReadout();
      requestAnimationFrame(loop);
    };
    requestAnimationFrame(loop);
  }

  /* ---- rendering ----------------------------------------------------- */
  render() {
    const ctx = this.ctx, w = this.W, h = this.H;
    // Background with subtle vignette + motion trail.
    ctx.fillStyle = '#0a0b12';
    ctx.fillRect(0, 0, w, h);
    const g = ctx.createRadialGradient(w / 2, h * 0.4, 80, w / 2, h * 0.4, w * 0.8);
    g.addColorStop(0, 'rgba(30,34,60,0.35)');
    g.addColorStop(1, 'rgba(8,9,14,0)');
    ctx.fillStyle = g;
    ctx.fillRect(0, 0, w, h);

    this.renderStatic();

    // Render per-object type appropriately.
    for (const obj of this.objects) {
      if (obj.name === 'cloth') this.renderClothMesh(obj);
    }
    for (const obj of this.objects) {
      if (obj.name === 'soft') this.renderSoftBody(obj);
    }
    for (const obj of this.objects) {
      if (obj.name === 'rope' || obj.name === 'ball') this.renderLines(obj);
    }
    // Cloth structural lines (over mesh) for definition.
    for (const obj of this.objects) if (obj.name === 'cloth') this.renderClothLines(obj);

    if (this.showPoints) this.renderPoints();

    this.renderGrabCut();
  }

  renderStatic() {
    const ctx = this.ctx;
    const w = this.W, h = this.H;
    // Floor line.
    ctx.strokeStyle = 'rgba(90,100,150,0.35)';
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(0, h - 1); ctx.lineTo(w, h - 1);
    ctx.stroke();
    // Left/right walls subtle.
    ctx.strokeStyle = 'rgba(90,100,150,0.12)';
    ctx.beginPath(); ctx.moveTo(1, 0); ctx.lineTo(1, h); ctx.stroke();
    ctx.beginPath(); ctx.moveTo(w - 1, 0); ctx.lineTo(w - 1, h); ctx.stroke();

    for (const s of this.staticShapes) {
      if (s instanceof StaticCircle) {
        const grd = ctx.createRadialGradient(s.x - s.r * 0.3, s.y - s.r * 0.3, s.r * 0.2, s.x, s.y, s.r);
        grd.addColorStop(0, '#4a5288');
        grd.addColorStop(1, '#23284a');
        ctx.fillStyle = grd;
        ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill();
        ctx.strokeStyle = CFG.COLORS.staticEdge;
        ctx.lineWidth = 2;
        ctx.stroke();
      } else if (s instanceof StaticBox) {
        const grd = ctx.createLinearGradient(0, s.top, 0, s.bottom);
        grd.addColorStop(0, '#3a4066');
        grd.addColorStop(1, '#23284a');
        ctx.fillStyle = grd;
        ctx.fillRect(s.left, s.top, s.w, s.h);
        ctx.strokeStyle = CFG.COLORS.staticEdge;
        ctx.lineWidth = 2;
        ctx.strokeRect(s.left, s.top, s.w, s.h);
      }
    }
  }

  colorForConstraint(c) {
    if (!this.showStress || !c.active) return null;
    const s = c.stress;
    const t = clamp((s - 1) * 2, 0, 1); // stretch beyond rest
    return lerpColor(COL_A, COL_B, t);
  }

  renderLines(obj) {
    const ctx = this.ctx;
    ctx.lineWidth = 2.5;
    ctx.lineCap = 'round';
    for (const c of obj.constraints) {
      if (!c.active) continue;
      if (obj.name === 'ball') continue; // ball has no constraints
      const col = this.colorForConstraint(c);
      ctx.strokeStyle = col || this.colorsFor(obj.name);
      ctx.beginPath();
      ctx.moveTo(c.p1.x, c.p1.y);
      ctx.lineTo(c.p2.x, c.p2.y);
      ctx.stroke();
    }
  }

  colorsFor(name) {
    return { rope: CFG.COLORS.rope, ball: CFG.COLORS.ball }[name] || '#fff';
  }

  renderClothMesh(obj) {
    const cols = CFG.Cloth.cols, rows = CFG.Cloth.rows;
    const ctx = this.ctx;
    // Build grid lookup.
    const grid = [];
    let idx = 0;
    for (let r = 0; r < rows; r++) {
      const row = [];
      for (let c = 0; c < cols; c++) { row.push(obj.points[idx++]); }
      grid.push(row);
    }
    // Filled quad per cell using a flat-shaded hue.
    for (let r = 0; r < rows - 1; r++) {
      for (let c = 0; c < cols - 1; c++) {
        const p00 = grid[r][c], p10 = grid[r][c + 1];
        const p01 = grid[r + 1][c], p11 = grid[r + 1][c + 1];
        // Average stretch for shading.
        let stress = 0, n = 0;
        const cellCon = [
          [p00, p10], [p00, p01], [p10, p11], [p01, p11],
        ];
        for (const [a, b] of cellCon) {
          const dx = b.x - a.x, dy = b.y - a.y;
          const d = Math.sqrt(dx * dx + dy * dy);
          stress += d; n++;
        }
        const avg = stress / n;
        const t = clamp((avg - CFG.Cloth.spacing) * 0.06, 0, 1);
        const col = this.showStress
          ? lerpColor(COL_A, COL_B, t)
          : this.clothShade(t);
        ctx.fillStyle = col;
        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();
      }
    }
  }

  clothShade(t) {
    // Vibrant cyan-magenta gradient by stress.
    const r = Math.round(77 + (255 - 77) * t);
    const g = Math.round(210 + (110 - 210) * t);
    const b = Math.round(255 - 60 * t);
    return `rgb(${r},${g},${b})`;
  }

  renderClothLines(obj) {
    const ctx = this.ctx;
    ctx.lineWidth = 1;
    ctx.globalAlpha = 0.35;
    for (const c of obj.constraints) {
      if (!c.active) continue;
      const col = this.colorForConstraint(c);
      ctx.strokeStyle = col || 'rgba(255,255,255,0.6)';
      ctx.beginPath();
      ctx.moveTo(c.p1.x, c.p1.y);
      ctx.lineTo(c.p2.x, c.p2.y);
      ctx.stroke();
    }
    ctx.globalAlpha = 1;
  }

  renderSoftBody(obj) {
    const ctx = this.ctx;
    // Perimeter points are the first N.
    const n = CFG.SOFT_POINTS;
    const peri = obj.points.slice(0, n);
    if (peri.length < 3) return;
    // Filled polygon (convex-ish blob).
    ctx.beginPath();
    ctx.moveTo(peri[0].x, peri[0].y);
    for (let i = 1; i < peri.length; i++) ctx.lineTo(peri[i].x, peri[i].y);
    ctx.closePath();
    const grd = ctx.createRadialGradient(
      (peri[0].x + peri[Math.floor(n / 2)].x) / 2,
      (peri[0].y + peri[Math.floor(n / 2)].y) / 2, 6,
      obj.points.reduce((s, p) => s + p.x, 0) / obj.points.length,
      obj.points.reduce((s, p) => s + p.y, 0) / obj.points.length, 60
    );
    grd.addColorStop(0, '#ffb3e0');
    grd.addColorStop(1, CFG.COLORS.soft);
    ctx.fillStyle = grd;
    ctx.fill();
    ctx.lineWidth = 2;
    ctx.strokeStyle = 'rgba(255,255,255,0.5)';
    ctx.stroke();
    // Eyes for character.
    const cx = obj.points.reduce((s, p) => s + p.x, 0) / obj.points.length;
    const cy = obj.points.reduce((s, p) => s + p.y, 0) / obj.points.length;
    ctx.fillStyle = 'rgba(20,10,25,0.85)';
    ctx.beginPath(); ctx.arc(cx - 10, cy - 6, 4, 0, Math.PI * 2); ctx.fill();
    ctx.beginPath(); ctx.arc(cx + 10, cy - 6, 4, 0, Math.PI * 2); ctx.fill();
  }

  renderPoints() {
    const ctx = this.ctx;
    for (const p of this.points) {
      ctx.fillStyle = p.pinned ? CFG.COLORS.pin : (p.grabbed ? CFG.COLORS.grab : '#eef');
      ctx.beginPath();
      ctx.arc(p.x, p.y, p.pinned ? 4 : 2.5, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  renderGrabCut() {
    const ctx = this.ctx;
    // Grab highlight.
    if (this.grab && this.grab.point) {
      const gp = this.grab.point;
      ctx.strokeStyle = CFG.COLORS.grab;
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.arc(gp.x, gp.y, 10, 0, Math.PI * 2);
      ctx.stroke();
    }
    // Live tear stroke.
    if (this.cutStroke) {
      ctx.strokeStyle = CFG.COLORS.cut;
      ctx.lineWidth = 3;
      ctx.lineCap = 'round';
      ctx.beginPath();
      ctx.moveTo(this.cutStroke.p1.x, this.cutStroke.p1.y);
      ctx.lineTo(this.cutStroke.p2.x, this.cutStroke.p2.y);
      ctx.stroke();
    }
    // Tool cursor hint.
    if (this.tool === 'ball' && !this.mouse.down) {
      ctx.strokeStyle = 'rgba(123,255,148,0.5)';
      ctx.lineWidth = 1.5;
      ctx.beginPath();
      ctx.arc(this.mouse.x, this.mouse.y, CFG.BALL_RADIUS, 0, Math.PI * 2);
      ctx.stroke();
    }
  }

  /* ---- readout ------------------------------------------------------- */
  updateReadout() {
    let ke = 0;
    for (const p of this.points) {
      const vx = (p.x - p.px), vy = (p.y - p.py);
      ke += 0.5 * p.mass * (vx * vx + vy * vy) / (CFG.DT * CFG.DT);
    }
    this.stats.ke = ke;
    const el = document.getElementById('readout');
    const stable = ke < 4000 && this.stats.steps > 5;
    el.innerHTML =
      `FPS: <b>${this.stats.fps.toFixed(0)}</b><br>` +
      `Physics: <b>${this.stats.physTime.toFixed(2)}</b> ms / step<br>` +
      `Steps: <b>${this.stats.steps}</b> &nbsp; Points: <b>${this.points.length}</b><br>` +
      `KE: <b class="${stable ? 'good' : 'bad'}">${(ke / 1000).toFixed(2)}</b> kJ<br>` +
      `Paused: <b>${this.paused ? 'yes' : 'no'}</b>`;
  }

  /* ---- UI wiring ----------------------------------------------------- */
  bindUI() {
    const $ = (id) => document.getElementById(id);
    const sl = (id, key, fmt, apply) => {
      const el = $(id), v = $('v-' + id.replace('sl-', ''));
      const upd = () => {
        const val = parseFloat(el.value);
        v.textContent = fmt ? fmt(val) : val;
        apply(val);
      };
      el.addEventListener('input', upd);
      upd();
    };
    sl('sl-gravity', 'gravity', (v) => v, (v) => this.gravity = v);
    sl('sl-iters', 'iterations', null, (v) => { this.iterations = v; });
    sl('sl-damping', 'damping', (v) => (v / 1000).toFixed(3), (v) => { this.damping = v / 1000; });
    sl('sl-wind', 'wind', (v) => v, (v) => { this.wind = v; });

    const setTool = (t) => {
      this.tool = t;
      document.querySelectorAll('.tool').forEach(b => b.classList.remove('active'));
      $('tool-' + t).classList.add('active');
    };
    ['rope', 'cloth', 'body', 'ball', 'grab', 'cut'].forEach(t => {
      $('tool-' + t).addEventListener('click', () => setTool(t));
    });

    $('chk-stress').addEventListener('click', (e) => {
      this.showStress = !this.showStress;
      e.target.classList.toggle('active', this.showStress);
    });
    $('chk-points').addEventListener('click', (e) => {
      this.showPoints = !this.showPoints;
      e.target.classList.toggle('active', this.showPoints);
    });

    $('btn-pause').addEventListener('click', () => this.togglePause());
    $('btn-step').addEventListener('click', () => { this.paused = true; this.step(); this.render(); this.updateReadout(); this.syncPauseBtn(); });
    $('btn-reset').addEventListener('click', () => {
      this.points = []; this.constraints = []; this.objects = [];
      this.seed();
    });
    $('btn-clear').addEventListener('click', () => {
      this.points = []; this.constraints = []; this.objects = [];
    });
    this.syncPauseBtn();
  }

  syncPauseBtn() {
    const b = document.getElementById('btn-pause');
    b.textContent = this.paused ? 'Resume' : 'Pause';
    b.classList.toggle('active', this.paused);
  }

  togglePause() {
    this.paused = !this.paused;
    this.syncPauseBtn();
  }

  /* ---- input --------------------------------------------------------- */
  bindInput() {
    const cv = this.canvas;
    const pos = (e) => {
      const r = cv.getBoundingClientRect();
      const cx = (e.touches ? e.touches[0].clientX : e.clientX) - r.left;
      const cy = (e.touches ? e.touches[0].clientY : e.clientY) - r.top;
      return { x: cx, y: cy };
    };

    // Track modifier state for pinning.
    this.shift = false;
    window.addEventListener('keydown', (e) => { if (e.shiftKey) this.shift = true; });
    window.addEventListener('keyup', (e) => { if (!e.shiftKey) this.shift = false; });

    const onDown = (e) => {
      const p = pos(e);
      this.mouse.px = this.mouse.x; this.mouse.py = this.mouse.y;
      this.mouse.x = p.x; this.mouse.y = p.y;
      this.mouse.down = true;
      this.mouse.right = (e.button === 2);

      if (this.tool === 'grab') {
        const near = this.findNearestPoint(p.x, p.y, CFG.GRAB_RADIUS);
        if (near) {
          if (this.shift) {
            // Shift+click toggles a pin in place.
            this.togglePin(near);
          } else {
            this.grab = { point: near };
            near.grabbed = true;
          }
        }
        return;
      }
      if (this.tool === 'cut') {
        this.cutStroke = { p1: { x: p.x, y: p.y }, p2: { x: p.x, y: p.y } };
        return;
      }
      // Spawn tools: begin drag preview.
      this.spawnStart = { x: p.x, y: p.y };
      this.spawnCurrent = { x: p.x, y: p.y };
    };

    const onMove = (e) => {
      const p = pos(e);
      this.mouse.px = this.mouse.x; this.mouse.py = this.mouse.y;
      this.mouse.x = p.x; this.mouse.y = p.y;
      if (this.cutStroke) {
        this.cutStroke.p2 = { x: p.x, y: p.y };
      }
    };

    const onUp = (e) => {
      const p = this.mouse;
      if (this.tool === 'grab') {
        // Throw: release; velocity carried by prev-position diff.
        this.releaseGrab();
      }
      if (this.tool === 'cut' && this.cutStroke) {
        this.cutAlong(this.cutStroke.p1, this.cutStroke.p2);
        this.cutStroke = null;
      }

      // Finalize spawn on left-up for spawn tools.
      if ((this.tool === 'rope' || this.tool === 'cloth' || this.tool === 'body' || this.tool === 'ball') && this.spawnStart) {
        this.finalizeSpawn(this.spawnStart, { x: p.x, y: p.y });
        this.spawnStart = null;
      }
      this.mouse.down = false;
      this.mouse.right = false;
    };

    cv.addEventListener('mousedown', onDown);
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
    cv.addEventListener('contextmenu', (e) => e.preventDefault());
    // Touch.
    cv.addEventListener('touchstart', (e) => { onDown(e); e.preventDefault(); }, { passive: false });
    cv.addEventListener('touchmove', (e) => { onMove(e); e.preventDefault(); }, { passive: false });
    cv.addEventListener('touchend', (e) => { onUp(e); e.preventDefault(); }, { passive: false });

    // Keyboard.
    window.addEventListener('keydown', (e) => {
      if (e.code === 'Space') { e.preventDefault(); this.togglePause(); }
      else if (e.key === 'ArrowRight' || e.key === 's') { this.paused = true; this.step(); this.render(); this.updateReadout(); this.syncPauseBtn(); }
    });
  }

  finalizeSpawn(start, end) {
    const dx = end.x - start.x, dy = end.y - start.y;
    const dist = Math.sqrt(dx * dx + dy * dy);
    if (this.tool === 'rope') {
      // Place points along the drag direction, pin the anchor. Gravity
      // naturally hangs the rest. If barely dragged, hang straight down.
      const segs = CFG.ROPE_SEGMENTS;
      const len = Math.max(dist, segs * CFG.ROPE_SPACING);
      let dirx = dist > 5 ? dx / dist : 0;
      let diry = dist > 5 ? dy / dist : 1;
      // Normalize so a mostly-horizontal drag still gives a sensible rope.
      const mag = Math.sqrt(dirx * dirx + diry * diry) || 1;
      dirx /= mag; diry /= mag;
      const sp = len / segs;
      const obj = this.newObject('rope');
      const pts = [];
      for (let i = 0; i <= segs; i++) {
        const p = this.addPoint(
          start.x + dirx * i * sp,
          start.y + diry * i * sp,
          { mass: 1, obj }
        );
        pts.push(p);
      }
      for (let i = 0; i < pts.length - 1; i++) {
        const c = this.addConstraint(pts[i], pts[i + 1], { stiffness: 1, type: 'rope' });
        obj.constraints.push(c);
      }
      pts[0].pinned = true; pts[0].invMass = 0;
    } else if (this.tool === 'cloth') {
      const cols = CFG.Cloth.cols, rows = CFG.Cloth.rows, sp = CFG.Cloth.spacing;
      // Size the cloth by drag, but keep it at least a sensible minimum.
      const w = Math.max(dist, cols * sp);
      const h = Math.max(dist, rows * sp);
      this.spawnCloth(start.x, start.y, { pattern: CFG.Cloth.pinPattern });
      // Scale the freshly-spawned points to fill the drag rectangle.
      const obj = this.objects[this.objects.length - 1];
      const grid = [];
      let idx = 0;
      for (let r = 0; r < rows; r++) {
        const row = [];
        for (let c = 0; c < cols; c++) {
          const p = obj.points[idx++];
          p.x = start.x + (c / (cols - 1)) * w;
          p.y = start.y + (r / (rows - 1)) * h;
          p.px = p.x; p.py = p.y;
          row.push(p);
        }
        grid.push(row);
      }
    } else if (this.tool === 'body') {
      const r = Math.max(dist, CFG.SOFT_RADIUS * 2);
      this.spawnSoftBody(start.x, start.y, r, 'blob');
    } else if (this.tool === 'ball') {
      const r = Math.max(dist, CFG.BALL_RADIUS);
      this.spawnBall(start.x, start.y, r);
    }
  }
}

/* ---- segment-to-segment distance for cutting ---- */
function segDist(x1, y1, x2, y2, x3, y3, x4, y4) {
  // Distance between the CONSTRAINT segment (x1,y1)-(x2,y2) and the CUT
  // segment (x3,y3)-(x4,y4). We test each constraint endpoint and the
  // constraint midpoint against the cut segment, plus the cut endpoints
  // against the constraint segment, and take the minimum — robust enough to
  // tear a link whenever the stroke crosses it.
  function distToSeg(px, py, ax, ay, bx, by) {
    const ddx = bx - ax, ddy = by - ay;
    const len2 = ddx * ddx + ddy * ddy;
    if (len2 < CFG.SLOP) return Math.hypot(px - ax, py - ay);
    let t = ((px - ax) * ddx + (py - ay) * ddy) / len2;
    t = clamp(t, 0, 1);
    return Math.hypot(px - (ax + t * ddx), py - (ay + t * ddy));
  }
  const d1 = distToSeg(x1, y1, x3, y3, x4, y4);
  const d2 = distToSeg(x2, y2, x3, y3, x4, y4);
  const mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
  const d3 = distToSeg(mx, my, x3, y3, x4, y4);
  const d4 = distToSeg(x3, y3, x1, y1, x2, y2);
  const d5 = distToSeg(x4, y4, x1, y1, x2, y2);
  return Math.min(d1, d2, d3, d4, d5);
}

/* =========================================================================
   BOOT
   ========================================================================= */
window.addEventListener('load', () => {
  const cv = document.getElementById('stage');
  function fit() {
    cv.width = window.innerWidth;
    cv.height = window.innerHeight;
    if (window.sim) window.sim.resize(cv.width, cv.height);
  }
  fit();
  window.addEventListener('resize', fit);

  const sim = new Sim(cv);
  window.sim = sim;          // expose for headless probing
  window.CFG = CFG;
  window.Point = Point;
  window.Constraint = Constraint;
  window.Sim = Sim;
  sim.start();
});
</script>
</body>
</html>
<!-- agent-meta {"model":"bartowski/Ornith-1.5-35B-A3B","provider":"brigid","persona":"full","sessionId":"0a964860-0d8c-4977-9c98-61bcee6a1135","tokensIn":5957693,"tokensOut":85405,"tokensTotal":6043098,"cost":0,"turns":77,"toolCalls":76,"failedToolCalls":2,"timestamp":"2026-08-23T21:36:12.229Z"} -->