← Physics Playground results

Physics Playground

Qwen 3.8 27B GGUF · typescript

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 55,414 bytes · SHA-256 c3a4f189bfe1
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Soft-Body Physics Playground — Verlet</title>
<style>
/* ── css: layout ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; overflow: hidden; background: #0b0f17; }
body {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  color: #b8c7e0;
  user-select: none;
  -webkit-user-select: none;
}
#app { display: flex; flex-direction: column; height: 100vh; }

#topbar {
  display: flex; align-items: center; gap: 14px;
  padding: 7px 12px;
  background: #0e1420;
  border-bottom: 1px solid #1c2638;
  flex-wrap: wrap;
}
#brand { font-size: 13px; font-weight: 700; letter-spacing: 2px; color: #e8f0ff; white-space: nowrap; }
#brand .sub { color: #4d6285; font-weight: 400; letter-spacing: 1px; font-size: 10px; margin-left: 6px; }

.toolgroup { display: flex; gap: 4px; }
.tool {
  background: #131b2b; color: #8fa3c4; border: 1px solid #223049;
  border-radius: 6px; padding: 5px 9px; font: 12px ui-monospace, monospace;
  cursor: pointer; transition: all .12s;
}
.tool:hover { background: #1a2438; color: #d3e2ff; }
.tool.active { background: #24406b; border-color: #3f6db3; color: #eaf3ff; box-shadow: 0 0 10px rgba(70,130,220,.25) inset; }

.ctrl { display: flex; gap: 10px; margin-left: auto; flex-wrap: wrap; }
.chk { font-size: 11px; color: #8fa3c4; cursor: pointer; display: flex; align-items: center; gap: 4px; }
.chk input { accent-color: #4d8fe0; cursor: pointer; }

#midbar {
  display: flex; align-items: center; gap: 16px;
  padding: 5px 12px;
  background: #0c111c;
  border-bottom: 1px solid #17202f;
  flex-wrap: wrap;
}
.slider { font-size: 11px; color: #7e92b5; display: flex; align-items: center; gap: 6px; }
.slider input[type=range] { width: 110px; accent-color: #4d8fe0; cursor: pointer; }
.slider .val { color: #d3e2ff; min-width: 34px; }
.slider.g { display: flex; }
.slider.g input[type=checkbox] { accent-color: #4d8fe0; }
.btns { display: flex; gap: 5px; margin-left: auto; }
.btns button {
  background: #131b2b; color: #b8c7e0; border: 1px solid #223049;
  border-radius: 6px; padding: 5px 10px; font: 11px ui-monospace, monospace; cursor: pointer;
}
.btns button:hover { background: #1c2740; color: #fff; }

#stage { flex: 1; position: relative; min-height: 320px; }
#sim { position: absolute; inset: 0; display: block; touch-action: none; }

#bottombar {
  display: flex; align-items: center; justify-content: space-between;
  padding: 5px 12px;
  background: #0e1420;
  border-top: 1px solid #1c2638;
  font-size: 11px;
}
#hud { color: #6fdd9a; letter-spacing: .5px; }
#help { color: #46587a; }

@media (max-width: 900px) {
  #help { display: none; }
  .ctrl { margin-left: 0; }
  .btns { margin-left: 0; }
}
</style>
</head>
<body>
<div id="app">
  <header id="topbar">
    <div id="brand">SOFT-BODY PLAYGROUND <span class="sub">verlet · pbd</span></div>
    <div class="toolgroup" id="tools">
      <button class="tool active" data-tool="grab" title="Grab / throw (V)">🖐 grab</button>
      <button class="tool" data-tool="rope" title="Drag to draw a rope (O)">🪢 rope</button>
      <button class="tool" data-tool="cloth" title="Drag a rectangle — cloth (C)">🕸 cloth</button>
      <button class="tool" data-tool="soft" title="Drag a circle — soft body (B)">🟡 blob</button>
      <button class="tool" data-tool="ball" title="Click — ball (X)">🔴 ball</button>
      <button class="tool" data-tool="tear" title="Drag to slice (T)">✂ tear</button>
    </div>
    <div class="ctrl">
      <label class="chk"><input type="checkbox" id="chk-stress"> stress</label>
      <label class="chk"><input type="checkbox" id="chk-points"> points</label>
      <label class="chk"><input type="checkbox" id="chk-trail"> trail</label>
      <label class="chk" title="Pin the whole top edge instead of both corners"><input type="checkbox" id="chk-full"> cloth: full edge</label>
    </div>
  </header>
  <div id="midbar">
    <label class="slider g">
      <input type="checkbox" id="chk-gravity" checked=""> g:
      <input type="range" id="sld-gravity" min="0" max="2400" step="50" value="1200">
      <span class="val" id="val-gravity">1200</span>
    </label>
    <label class="slider">
      iter:
      <input type="range" id="sld-iter" min="1" max="12" step="1" value="5">
      <span class="val" id="val-iter">5</span>
    </label>
    <label class="slider">
      wind:
      <input type="range" id="sld-wind" min="-100" max="100" step="5" value="0">
      <span class="val" id="val-wind">0</span>
    </label>
    <div class="btns">
      <button id="btn-pause" title="Space">❚❚ Pause</button>
      <button id="btn-step" title="ArrowRight — step one frame while paused">Step ▸</button>
      <button id="btn-reset" title="R">Reset</button>
      <button id="btn-clear">Clear</button>
    </div>
  </div>
  <main id="stage"><canvas id="sim"></canvas></main>
  <footer id="bottombar">
    <div id="hud">—</div>
    <div id="help">LMB grab · RMB or Shift-drag tear · Shift/P + click pin · Space pause · → step · V/C/O/X/B/T tools</div>
  </footer>
</div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./engine":"src/engine.ts","./config":"src/config.ts","./scene":"src/scene.ts","./render":"src/render.ts","./input":"src/input.ts","./ui":"src/ui.ts"},"src/engine.ts":{"./config":"src/config.ts"},"src/scene.ts":{"./engine":"src/engine.ts"},"src/render.ts":{"./engine":"src/engine.ts","./config":"src/config.ts"},"src/input.ts":{"./engine":"src/engine.ts","./config":"src/config.ts"},"src/ui.ts":{"./engine":"src/engine.ts","./input":"src/input.ts","./scene":"src/scene.ts"}};
function __require(id) {
  if (__cache[id]) return __cache[id].exports;
  var module = __cache[id] = { exports: {} };
  var factory = __mods[id];
  if (!factory) throw new Error("Module not found: " + id);
  factory(module.exports, function (spec) {
    var target = (__map[id] && __map[id][spec]) || spec;
    return __require(target);
  }, module);
  return module.exports;
}

// ── module: src/main.ts ──
__mods["src/main.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.start = start;
// ============================================================
// MAIN — canvas setup, fixed-timestep loop, HUD, window.sim
// ============================================================
const engine_1 = require("./engine");
const config_1 = require("./config");
const scene_1 = require("./scene");
const render_1 = require("./render");
const input_1 = require("./input");
const ui_1 = require("./ui");
const stage = document.getElementById('stage');
const canvas = document.getElementById('sim');
const stageEl = document.getElementById('stage');
function measure() {
    const r = stageEl.getBoundingClientRect();
    return { W: Math.max(640, Math.round(r.width)), H: Math.max(480, Math.round(r.height)) };
}
const sim = new engine_1.Sim();
const fx = { tears: [] };
let W = 0, H = 0;
const { W: iw, H: ih } = measure();
W = iw;
H = ih;
(0, scene_1.buildDemo)(sim, W, H);
(0, render_1.ensureCanvasSize)(canvas, W, H);
const ctx = canvas.getContext('2d');
const inputOpts = { tearHeld: false, pinHeld: false, clothFullEdge: false };
const input = new input_1.Input(canvas, sim, inputOpts, {
    onTear: (x, y, count) => {
        for (let i = 0; i < count && fx.tears.length < 90; i++) {
            fx.tears.push({ x: x + (Math.random() - 0.5) * 18, y: y + (Math.random() - 0.5) * 18, born: performance.now() });
        }
    },
    onPin: () => { },
    onGrab: () => { },
    onRelease: () => { },
    onSpawn: (tool, x1, y1, x2, y2) => {
        if (tool === 'rope')
            sim.spawnRope(x1, y1, x2, y2);
        else if (tool === 'cloth')
            sim.spawnCloth(x1, y1, x2, y2, inputOpts.clothFullEdge);
        else if (tool === 'soft') {
            const r = Math.max(24, Math.min(90, Math.hypot(x2 - x1, y2 - y1) * 0.5));
            sim.spawnSoft((x1 + x2) / 2, (y1 + y2) / 2, r);
        }
        else if (tool === 'ball') {
            const r = Math.max(10, Math.min(26, Math.hypot(x2 - x1, y2 - y1) * 0.5));
            sim.spawnBall(x1, y1, r);
        }
    },
    onToolEnd: () => { },
});
// re-layout on resize: keep bounds in sync (sim keeps running)
const onResize = () => {
    const { W: nw, H: nh } = measure();
    if (nw === W && nh === H)
        return;
    W = nw;
    H = nh;
    sim.bounds = { minX: 8, maxX: W - 8, minY: 8, maxY: H - 14 };
    (0, render_1.ensureCanvasSize)(canvas, W, H);
};
window.addEventListener('resize', onResize);
// FPS + physics time readout
let frames = 0, lastFps = performance.now(), fps = 60;
let physMs = 0;
const physAvg = { v: 0 };
(0, ui_1.wireUI)(sim, input, canvas, W, H, {
    reset: () => (0, scene_1.buildDemo)(sim, W, H),
    clearAll: () => { },
}, () => ({ fps, ms: physAvg.v, points: sim.points.length, constraints: sim.constraints.filter(c => c.active).length, objects: sim.objects.length }));
let last = performance.now();
let acc = 0;
let rafScheduled = false;
function frame(now) {
    // ---- fixed timestep accumulator with max delta clamp ----
    let delta = (now - last) / 1000;
    last = now;
    if (delta > config_1.CFG.MAX_FRAME_DELTA)
        delta = config_1.CFG.MAX_FRAME_DELTA; // tab-switch guard
    if (!sim.paused) {
        acc += delta;
        let steps = 0;
        const t0 = performance.now();
        while (acc >= config_1.CFG.DT && steps < config_1.CFG.MAX_CATCHUP) {
            sim.step();
            acc -= config_1.CFG.DT;
            steps++;
        }
        if (steps >= config_1.CFG.MAX_CATCHUP)
            acc = 0; // drop excess time, never spiral
        const ms = performance.now() - t0;
        physAvg.v = physMs ? physMs * 0.9 + ms * 0.1 : ms;
        physMs = ms;
    }
    // ---- render ----
    const p = input.preview();
    (0, render_1.render)(ctx, sim, W, H, {
        showStress: document.getElementById('chk-stress').checked,
        showPoints: document.getElementById('chk-points').checked,
        trail: document.getElementById('chk-trail').checked,
        hover: input.hover,
        grab: input.activeGrab(),
        now,
    }, fx);
    if (p)
        drawPreview(ctx, p.tool, p.ax, p.ay, p.bx, p.by);
    const t = input.activeTear();
    if (t) {
        ctx.strokeStyle = '#ff8fb3';
        ctx.lineWidth = 2.5;
        ctx.setLineDash([6, 4]);
        ctx.beginPath();
        ctx.moveTo(t.x, t.y);
        ctx.lineTo(t.x, t.y + 1);
        ctx.stroke();
        ctx.setLineDash([]);
    }
    if (sim.paused)
        drawPausedBadge(ctx, W);
    frames++;
    if (now - lastFps >= 500) {
        fps = Math.round(frames * 1000 / (now - lastFps));
        frames = 0;
        lastFps = now;
    }
    if (!rafScheduled) {
        rafScheduled = true;
        requestAnimationFrame((t) => { rafScheduled = false; frame(t); });
    }
}
function drawPreview(ctx, tool, ax, ay, bx, by) {
    ctx.save();
    ctx.setLineDash([5, 4]);
    ctx.lineWidth = 2;
    if (tool === 'rope') {
        ctx.strokeStyle = '#38d9ff';
        ctx.beginPath();
        ctx.moveTo(ax, ay);
        ctx.lineTo(bx, by);
        ctx.stroke();
    }
    else if (tool === 'cloth') {
        ctx.strokeStyle = '#ff5fd0';
        ctx.strokeRect(Math.min(ax, bx), Math.min(ay, by), Math.abs(bx - ax), Math.abs(by - ay));
    }
    else if (tool === 'soft') {
        ctx.strokeStyle = '#ffc94d';
        const r = Math.max(24, Math.min(90, Math.hypot(bx - ax, by - ay) * 0.5));
        ctx.beginPath();
        ctx.arc((ax + bx) / 2, (ay + by) / 2, r, 0, 6.2832);
        ctx.stroke();
    }
    else if (tool === 'ball') {
        ctx.strokeStyle = '#ff5c5c';
        const r = Math.max(10, Math.min(26, Math.hypot(bx - ax, by - ay) * 0.5));
        ctx.beginPath();
        ctx.arc(ax, ay, r, 0, 6.2832);
        ctx.stroke();
    }
    ctx.restore();
}
function drawPausedBadge(ctx, W) {
    ctx.fillStyle = 'rgba(10,14,22,0.75)';
    ctx.fillRect(W / 2 - 120, 40, 240, 30);
    ctx.fillStyle = '#cfe3ff';
    ctx.font = '13px ui-monospace, monospace';
    ctx.textAlign = 'center';
    ctx.fillText('PAUSED — → steps one frame', W / 2, 60);
    ctx.textAlign = 'left';
}
// expose for headless probing / console play
window.sim = {
    sim,
    get points() { return sim.points; },
    get constraints() { return sim.constraints; },
    get objects() { return sim.objects; },
    get statics() { return sim.statics; },
    get paused() { return sim.paused; },
    set paused(v) { sim.paused = v; },
    step: (n = 1) => { for (let i = 0; i < n; i++)
        sim.step(); },
    frame: (now) => frame(now ?? performance.now()),
    clear: () => sim.clear(),
    spawnRope: (a, b, c, d) => sim.spawnRope(a, b, c, d),
    spawnCloth: (a, b, c, d, full = false) => sim.spawnCloth(a, b, c, d, full),
    spawnSoft: (x, y, r) => sim.spawnSoft(x, y, r ?? 46),
    spawnBall: (x, y, r) => sim.spawnBall(x, y, r ?? 14),
    tear: (a, b, c, d) => sim.tear(a, b, c, d),
    togglePin: (x, y) => sim.togglePin(x, y),
    grab: (x, y) => sim.nearestPoint(x, y, config_1.CFG.GRAB_RADIUS),
    reset: () => (0, scene_1.buildDemo)(sim, W, H),
    getStats: () => ({
        fps, ms: physAvg.v, points: sim.points.length,
        constraints: sim.constraints.filter(c => c.active).length,
        objects: sim.objects.length,
        kinetic: kineticEnergy(sim),
        paused: sim.paused,
    }),
};
function kineticEnergy(s) {
    const dt = config_1.CFG.DT;
    let e = 0;
    for (const p of s.points) {
        if (p.pinned)
            continue;
        const vx = (p.x - p.px) / dt, vy = (p.y - p.py) / dt;
        e += 0.5 * (1 / Math.max(p.invMass, 1e-9)) * (vx * vx + vy * vy);
    }
    return e;
}
requestAnimationFrame((t) => { last = t; requestAnimationFrame(frame); });
function start() { }
};

// ── module: src/config.ts ──
__mods["src/config.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.TRAIL_ALPHA = exports.COLORS = exports.CFG = void 0;
exports.hexA = hexA;
exports.clamp = clamp;
// ============================================================
// TUNABLE CONSTANTS — everything gameplay/physics relevant
// ============================================================
exports.CFG = {
    DT: 1 / 60, // fixed physics timestep (60 Hz)
    MAX_FRAME_DELTA: 0.25, // clamp on accumulated frame delta (tab-switch safety)
    MAX_CATCHUP: 5, // max physics steps per rendered frame
    GRAVITY: 1200, // px / s^2
    DAMPING: 0.9965, // velocity retained per step
    ITERATIONS: 5, // constraint relaxation passes per step
    MAX_SPEED: 45, // px per step — hard velocity cap (anti-tunneling)
    FRICTION: 0.18, // tangential velocity lost per contact
    TEAR_RADIUS: 14, // px — constraints this close to the cut line are severed
    GRAB_RADIUS: 30, // px — click this near a point to grab it
    CELL_SIZE: 64, // spatial hash cell for point-vs-point collision
    HOVER_RADIUS: 22, // px — hover highlight reach
};
exports.COLORS = {
    bg: '#0b0f17',
    wall: '#232c3b',
    floorFill: 'rgba(44,56,80,0.16)',
    static: '#131a29',
    staticLine: '#31405e',
    rope: '#38d9ff',
    cloth: '#ff5fd0',
    soft: '#ffc94d',
    ball: '#ff5c5c',
    pin: '#eaf2ff',
    hover: '#7dffce',
    stress: ['#3dffa0', '#a4ff4d', '#ffe45c', '#ff9e40', '#ff4d5e'], // green -> red
};
exports.TRAIL_ALPHA = 0.38; // bg repaint alpha when motion-blur trail is on
function hexA(hex, a) {
    const r = parseInt(hex.slice(1, 3), 16);
    const g = parseInt(hex.slice(3, 5), 16);
    const b = parseInt(hex.slice(5, 7), 16);
    return `rgba(${r},${g},${b},${a})`;
}
function clamp(v, lo, hi) {
    return v < lo ? lo : v > hi ? hi : v;
}
};

// ── module: src/engine.ts ──
__mods["src/engine.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Sim = void 0;
exports.segSeg = segSeg;
exports.distPtSeg = distPtSeg;
// ============================================================
// CORE ENGINE — Verlet integration + constraint relaxation
// Position-based dynamics: integrate, then iterate
//   (distance constraints, static collisions, point collisions)
// ============================================================
const config_1 = require("./config");
// ---------- geometry helpers ----------
function cross(ax, ay, bx, by, cx, cy) {
    return (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
}
/** strict segment/segment intersection */
function segSeg(ax, ay, bx, by, cx, cy, dx, dy) {
    const o1 = cross(ax, ay, bx, by, cx, cy);
    const o2 = cross(ax, ay, bx, by, dx, dy);
    const o3 = cross(cx, cy, dx, dy, ax, ay);
    const o4 = cross(cx, cy, dx, dy, bx, by);
    return o1 * o2 < 0 && o3 * o4 < 0;
}
/** point-to-segment distance (segment may be degenerate) */
function distPtSeg(px, py, ax, ay, bx, by) {
    const dx = bx - ax, dy = by - ay;
    const l2 = dx * dx + dy * dy;
    let t = l2 > 1e-12 ? ((px - ax) * dx + (py - ay) * dy) / l2 : 0;
    t = (0, config_1.clamp)(t, 0, 1);
    const qx = ax + dx * t, qy = ay + dy * t;
    return Math.hypot(px - qx, py - qy);
}
// ============================================================
// SIM
// ============================================================
class Sim {
    constructor() {
        this.points = [];
        this.constraints = [];
        this.objects = [];
        this.statics = [];
        this.bounds = { minX: 8, maxX: 800, minY: 8, maxY: 600 };
        this.gravity = config_1.CFG.GRAVITY;
        this.wind = 0; // -100..100
        this.damping = config_1.CFG.DAMPING;
        this.iterations = config_1.CFG.ITERATIONS;
        this.paused = false;
        this.time = 0;
        this.stepCount = 0;
        this.nextId = 1;
        /** point-vs-point collision via spatial hash (gives balls/soft bodies volume) */
        this.grid = new Map();
    }
    // ---------- lifecycle ----------
    clear() {
        this.points.length = 0;
        this.constraints.length = 0;
        this.objects.length = 0;
        this.statics.length = 0;
        this.time = 0;
        this.stepCount = 0;
        this.nextId = 1;
    }
    addPoint(x, y, invMass = 1, r = 3, bounce = 0.05, pp = true) {
        this.points.push({ x, y, px: x, py: y, invMass, r, bounce, pp, pinned: false, obj: -1 });
        return this.points.length - 1;
    }
    addConstraint(a, b, kind, obj) {
        const pa = this.points[a], pb = this.points[b];
        const d = Math.hypot(pb.x - pa.x, pb.y - pa.y);
        this.constraints.push({ a, b, rest: Math.max(d, 0.001), active: true, kind, obj });
        return this.constraints.length - 1;
    }
    // ---------- spawning ----------
    spawnRope(x1, y1, x2, y2) {
        const id = this.nextId++;
        const dx = x2 - x1, dy = y2 - y1;
        const len = Math.max(Math.hypot(dx, dy), 40);
        const n = Math.min(60, Math.max(6, Math.round(len / 16)));
        const pts = [];
        for (let i = 0; i <= n; i++) {
            const t = i / n;
            const idx = this.addPoint(x1 + dx * t, y1 + dy * t, 1, 3, 0.05, false);
            this.points[idx].obj = id;
            if (i === 0)
                this.points[idx].pinned = true;
            pts.push(idx);
        }
        const cons = [];
        for (let i = 0; i < n; i++)
            cons.push(this.addConstraint(pts[i], pts[i + 1], 'struct', id));
        // small weight on the free end gives a pendulum feel
        const tip = this.addPoint(x2, y2, 0.35, 10, 0.25);
        this.points[tip].obj = id;
        cons.push(this.addConstraint(pts[n], tip, 'link', id));
        const obj = { id, type: 'rope', color: config_1.COLORS.rope, pts, cons, tip };
        this.objects.push(obj);
        return obj;
    }
    spawnCloth(x1, y1, x2, y2, pinFullEdge) {
        const id = this.nextId++;
        const ax = Math.min(x1, x2), ay = Math.min(y1, y2);
        const w = Math.max(Math.abs(x2 - x1), 80);
        const h = Math.max(Math.abs(y2 - y1), 80);
        const cols = Math.min(40, Math.max(4, Math.round(w / 22)));
        const rows = Math.min(32, Math.max(4, Math.round(h / 22)));
        const idx = [];
        for (let r = 0; r < rows; r++) {
            const row = [];
            for (let c = 0; c < cols; c++) {
                const p = this.addPoint(ax + (w / (cols - 1)) * c, ay + (h / (rows - 1)) * r, 1, 5, 0.05, false);
                this.points[p].obj = id;
                row.push(p);
            }
            idx.push(row);
        }
        const cons = [];
        const hC = [];
        const vC = [];
        for (let r = 0; r < rows; r++) {
            hC.push([]);
            for (let c = 0; c < cols - 1; c++)
                hC[r].push(this.addConstraint(idx[r][c], idx[r][c + 1], 'struct', id));
        }
        for (let r = 0; r < rows - 1; r++) {
            vC.push([]);
            for (let c = 0; c < cols; c++)
                vC[r].push(this.addConstraint(idx[r][c], idx[r + 1][c], 'struct', id));
        }
        for (let r = 0; r < rows - 1; r++) {
            for (let c = 0; c < cols - 1; c++) {
                this.addConstraint(idx[r][c], idx[r + 1][c + 1], 'shear', id);
                this.addConstraint(idx[r][c + 1], idx[r + 1][c], 'shear', id);
            }
        }
        const cells = [];
        for (let r = 0; r < rows - 1; r++) {
            for (let c = 0; c < cols - 1; c++) {
                cells.push([
                    hC[r][c], hC[r + 1][c], vC[r][c], vC[r][c + 1],
                    idx[r][c], idx[r][c + 1], idx[r + 1][c + 1], idx[r + 1][c],
                ]);
            }
        }
        if (pinFullEdge) {
            for (let c = 0; c < cols; c++)
                this.points[idx[0][c]].pinned = true;
        }
        else {
            this.points[idx[0][0]].pinned = true;
            this.points[idx[0][cols - 1]].pinned = true;
        }
        const pts = [];
        for (const row of idx)
            pts.push(...row);
        const consIdx = [];
        for (let i = 0; i < this.constraints.length; i++)
            if (this.constraints[i].obj === id)
                consIdx.push(i);
        const obj = {
            id, type: 'cloth', color: config_1.COLORS.cloth,
            pts: pts, cons: consIdx, cells, cols, rows,
        };
        this.objects.push(obj);
        return obj;
    }
    spawnSoft(x, y, r) {
        const id = this.nextId++;
        r = (0, config_1.clamp)(r, 24, 120);
        const n = 16;
        const ring = [];
        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, 1, 4, 0.05);
            this.points[p].obj = id;
            ring.push(p);
        }
        const center = this.addPoint(x, y, 1, 2, 0.05);
        this.points[center].obj = id;
        const cons = [];
        for (let i = 0; i < n; i++) {
            cons.push(this.addConstraint(ring[i], ring[(i + 1) % n], 'edge', id));
            cons.push(this.addConstraint(ring[i], center, 'brace', id));
        }
        for (let i = 0; i < n / 2; i++)
            cons.push(this.addConstraint(ring[i], ring[(i + n / 2) % n], 'brace', id));
        const obj = { id, type: 'soft', color: config_1.COLORS.soft, pts: [...ring, center], cons, ring, center };
        this.objects.push(obj);
        return obj;
    }
    spawnBall(x, y, r) {
        const id = this.nextId++;
        r = (0, config_1.clamp)(r, 10, 30);
        const p = this.addPoint(x, y, 1, r, 0.6);
        this.points[p].obj = id;
        const obj = { id, type: 'ball', color: config_1.COLORS.ball, pts: [p], cons: [], radius: r };
        this.objects.push(obj);
        return obj;
    }
    // ---------- interaction queries ----------
    nearestPoint(x, y, maxDist = config_1.CFG.GRAB_RADIUS) {
        let best = -1;
        let bd = maxDist * maxDist;
        for (let i = 0; i < this.points.length; i++) {
            const p = this.points[i];
            const dx = p.x - x, dy = p.y - y;
            const d2 = dx * dx + dy * dy;
            if (d2 < bd) {
                bd = d2;
                best = i;
            }
        }
        return best;
    }
    /** toggle pin on the nearest point; returns index or -1 */
    togglePin(x, y, maxDist = config_1.CFG.GRAB_RADIUS) {
        const i = this.nearestPoint(x, y, maxDist);
        if (i < 0)
            return -1;
        const p = this.points[i];
        p.pinned = !p.pinned;
        if (p.pinned) {
            p.px = p.x;
            p.py = p.y;
        }
        return i;
    }
    /** sever every constraint hit by the segment (or within TEAR_RADIUS of it) */
    tear(x1, y1, x2, y2) {
        const hits = [];
        let count = 0;
        for (let i = 0; i < this.constraints.length; i++) {
            const c = this.constraints[i];
            if (!c.active)
                continue;
            const a = this.points[c.a], b = this.points[c.b];
            if (segSeg(x1, y1, x2, y2, a.x, a.y, b.x, b.y) ||
                distPtSeg(a.x, a.y, x1, y1, x2, y2) < config_1.CFG.TEAR_RADIUS ||
                distPtSeg(b.x, b.y, x1, y1, x2, y2) < config_1.CFG.TEAR_RADIUS) {
                c.active = false;
                count++;
                if (hits.length < 80)
                    hits.push({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
            }
        }
        return { count, hits };
    }
    // ---------- physics step (fixed DT) ----------
    step() {
        const dt = config_1.CFG.DT, dt2 = dt * dt;
        this.time += dt;
        this.stepCount++;
        const pts = this.points;
        const g = this.gravity, w = this.windAccel(), damp = this.damping;
        // -- Verlet integration --
        for (let i = 0; i < pts.length; i++) {
            const p = pts[i];
            if (p.pinned) {
                p.px = p.x;
                p.py = p.y;
                continue;
            }
            let vx = (p.x - p.px) * damp;
            let vy = (p.y - p.py) * damp;
            const sp2 = vx * vx + vy * vy;
            if (sp2 > config_1.CFG.MAX_SPEED * config_1.CFG.MAX_SPEED) {
                const s = config_1.CFG.MAX_SPEED / Math.sqrt(sp2);
                vx *= s;
                vy *= s;
            }
            p.px = p.x;
            p.py = p.y;
            p.x += vx + w * dt2;
            p.y += vy + g * dt2;
        }
        // -- constraint relaxation + collisions --
        for (let it = 0; it < this.iterations; it++) {
            this.solveConstraints();
            this.collideStatics();
            this.collidePoints();
        }
        this.sanitize();
    }
    windAccel() {
        if (this.wind === 0)
            return 0;
        const t = this.time;
        const gust = 0.55 * Math.sin(t * 1.4) + 0.3 * Math.sin(t * 3.7 + 1.2) + 0.15 * Math.sin(t * 7.1);
        return this.wind * 7 * gust;
    }
    solveConstraints() {
        const cs = this.constraints, pts = this.points;
        for (let i = 0; i < cs.length; i++) {
            const c = cs[i];
            if (!c.active)
                continue;
            const pa = pts[c.a], pb = pts[c.b];
            const dx = pb.x - pa.x, dy = pb.y - pa.y;
            const d2 = dx * dx + dy * dy;
            if (d2 < 1e-12)
                continue; // coincident points — skip
            const d = Math.sqrt(d2);
            const wa = pa.pinned ? 0 : pa.invMass;
            const wb = pb.pinned ? 0 : pb.invMass;
            const wsum = wa + wb;
            if (wsum <= 0)
                continue; // both pinned — skip
            const k = (d - c.rest) / (d * wsum);
            pa.x += dx * k * wa;
            pa.y += dy * k * wa;
            pb.x -= dx * k * wb;
            pb.y -= dy * k * wb;
        }
    }
    /** push a point out of a plane; adjust implicit velocity (bounce + friction) */
    applyContact(p, nx, ny, pen) {
        if (pen <= 0 || p.pinned)
            return;
        const vx = p.x - p.px, vy = p.y - p.py;
        p.x += nx * pen;
        p.y += ny * pen;
        const vn = vx * nx + vy * ny;
        if (vn < 0) {
            const tx = vx - vn * nx, ty = vy - vn * ny;
            const f = 1 - config_1.CFG.FRICTION;
            p.px = p.x - (tx * f - vn * p.bounce * nx);
            p.py = p.y - (ty * f - vn * p.bounce * ny);
        }
    }
    collideStatics() {
        const b = this.bounds, pts = this.points;
        for (let i = 0; i < pts.length; i++) {
            const p = pts[i];
            // bounding walls / floor / ceiling
            if (p.y > b.maxY - p.r)
                this.applyContact(p, 0, -1, p.y - (b.maxY - p.r));
            if (p.y < b.minY + p.r)
                this.applyContact(p, 0, 1, (b.minY + p.r) - p.y);
            if (p.x < b.minX + p.r)
                this.applyContact(p, 1, 0, (b.minX + p.r) - p.x);
            if (p.x > b.maxX - p.r)
                this.applyContact(p, -1, 0, p.x - (b.maxX - p.r));
            // static obstacles
            for (let s = 0; s < this.statics.length; s++) {
                const st = this.statics[s];
                if (st.kind === 'circle')
                    this.collideCircle(p, st);
                else
                    this.collideBox(p, st);
            }
        }
    }
    collideCircle(p, s) {
        const dx = p.x - s.x, dy = p.y - s.y;
        const rr = s.r + p.r;
        const d2 = dx * dx + dy * dy;
        if (d2 >= rr * rr)
            return;
        const d = Math.sqrt(d2);
        if (d < 1e-9) {
            this.applyContact(p, 0, -1, rr);
            return;
        } // coincident — push up
        this.applyContact(p, dx / d, dy / d, rr - d);
    }
    collideBox(p, s) {
        const cx = (0, config_1.clamp)(p.x, s.x, s.x + s.w);
        const cy = (0, config_1.clamp)(p.y, s.y, s.y + s.h);
        const dx = p.x - cx, dy = p.y - cy;
        const d2 = dx * dx + dy * dy;
        if (d2 > p.r * p.r)
            return;
        if (d2 < 1e-12) {
            // point inside the box — exit through the nearest face
            const dl = p.x - s.x, dr = s.x + s.w - p.x;
            const dt = p.y - s.y, db = s.y + s.h - p.y;
            if (dl <= dr && dl <= dt && dl <= db)
                this.applyContact(p, -1, 0, dl + p.r);
            else if (dr <= dl && dr <= dt && dr <= db)
                this.applyContact(p, 1, 0, dr + p.r);
            else if (dt <= dl && dt <= dr && dt <= db)
                this.applyContact(p, 0, -1, dt + p.r);
            else
                this.applyContact(p, 0, 1, db + p.r);
        }
        else {
            const d = Math.sqrt(d2);
            this.applyContact(p, dx / d, dy / d, p.r - d);
        }
    }
    collidePoints() {
        const pts = this.points, cell = config_1.CFG.CELL_SIZE;
        const grid = this.grid;
        grid.clear();
        const key = (cx, cy) => (cx + 2048) * 8192 + (cy + 2048);
        for (let i = 0; i < pts.length; i++) {
            const p = pts[i];
            if (!p.pp)
                continue;
            const cx = Math.floor(p.x / cell), cy = Math.floor(p.y / cell);
            const k = key(cx, cy);
            let arr = grid.get(k);
            if (!arr) {
                arr = [];
                grid.set(k, arr);
            }
            arr.push(i);
        }
        for (let i = 0; i < pts.length; i++) {
            const p = pts[i];
            if (!p.pp)
                continue;
            const cx = Math.floor(p.x / cell), cy = Math.floor(p.y / cell);
            for (let gx = cx - 1; gx <= cx + 1; gx++) {
                for (let gy = cy - 1; gy <= cy + 1; gy++) {
                    const arr = grid.get(key(gx, gy));
                    if (!arr)
                        continue;
                    for (let n = 0; n < arr.length; n++) {
                        const j = arr[n];
                        if (j <= i)
                            continue;
                        const q = pts[j];
                        const dx = q.x - p.x, dy = q.y - p.y;
                        const rr = p.r + q.r;
                        const d2 = dx * dx + dy * dy;
                        if (d2 >= rr * rr || d2 < 1e-12)
                            continue;
                        const d = Math.sqrt(d2);
                        const nx = dx / d, ny = dy / d;
                        const wa = p.pinned ? 0 : p.invMass;
                        const wb = q.pinned ? 0 : q.invMass;
                        const wsum = wa + wb;
                        if (wsum <= 0)
                            continue;
                        const pen = (rr - d) / wsum;
                        p.x -= nx * pen * wa;
                        p.y -= ny * pen * wa;
                        q.x += nx * pen * wb;
                        q.y += ny * pen * wb;
                    }
                }
            }
        }
    }
    /** no NaN / Infinity escapes — restore to previous (finite) position */
    sanitize() {
        const L = 1e6;
        for (let i = 0; i < this.points.length; i++) {
            const p = this.points[i];
            if (!Number.isFinite(p.x))
                p.x = Number.isFinite(p.px) ? p.px : 0;
            if (!Number.isFinite(p.y))
                p.y = Number.isFinite(p.py) ? p.py : 0;
            if (!Number.isFinite(p.px))
                p.px = p.x;
            if (!Number.isFinite(p.py))
                p.py = p.y;
            p.x = (0, config_1.clamp)(p.x, -L, L);
            p.y = (0, config_1.clamp)(p.y, -L, L);
            p.px = (0, config_1.clamp)(p.px, -L, L);
            p.py = (0, config_1.clamp)(p.py, -L, L);
        }
    }
}
exports.Sim = Sim;
};

// ── module: src/scene.ts ──
__mods["src/scene.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.setupScene = setupScene;
exports.buildDemo = buildDemo;
function setupScene(sim, W, H) {
    sim.bounds = { minX: 8, maxX: W - 8, minY: 8, maxY: H - 14 };
    sim.statics.length = 0;
    // platform on the left — a cloth dropped here drapes over it
    sim.statics.push({ kind: 'box', x: W * 0.16, y: H * 0.62, w: W * 0.16, h: 22 });
    // pedestal the big static circle sits on
    sim.statics.push({ kind: 'box', x: W * 0.5 - 55, y: H * 0.75, w: 110, h: H * 0.23 });
    // the big static circle — its top overlaps the demo cloth's lower edge so it drapes over it
    sim.statics.push({ kind: 'circle', x: W * 0.5, y: H * 0.75 - 74, r: 74 });
}
/** the default playground layout — pendulum rope, draped cloth, blob, ball */
function buildDemo(sim, W, H) {
    sim.clear();
    setupScene(sim, W, H);
    sim.spawnRope(W * 0.14, H * 0.13, W * 0.14, H * 0.5);
    sim.spawnCloth(W * 0.415, H * 0.10, W * 0.585, H * 0.44, true);
    sim.spawnSoft(W * 0.76, H * 0.3, 46);
    sim.spawnBall(W * 0.87, H * 0.2, 16);
}
};

// ── module: src/render.ts ──
__mods["src/render.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.render = render;
exports.clearEffects = clearEffects;
exports.ensureCanvasSize = ensureCanvasSize;
const config_1 = require("./config");
function render(ctx, sim, W, H, opts, fx) {
    const P = sim.points;
    // ---- background / motion-blur trail ----
    if (opts.trail) {
        ctx.fillStyle = config_1.COLORS.bg;
        ctx.globalAlpha = config_1.TRAIL_ALPHA;
        ctx.fillRect(0, 0, W, H);
        ctx.globalAlpha = 1;
    }
    else {
        ctx.fillStyle = config_1.COLORS.bg;
        ctx.fillRect(0, 0, W, H);
    }
    drawBounds(ctx, sim, W, H);
    drawStatics(ctx, sim);
    // group constraints by object for per-material stroke color
    const byObj = new Map();
    for (let i = 0; i < sim.constraints.length; i++) {
        const c = sim.constraints[i];
        if (!c.active)
            continue;
        let arr = byObj.get(c.obj);
        if (!arr) {
            arr = [];
            byObj.set(c.obj, arr);
        }
        arr.push(i);
    }
    for (const obj of sim.objects) {
        const list = byObj.get(obj.id);
        if (!list || !list.length)
            continue;
        drawConstraintSet(ctx, sim, obj, list, opts.showStress);
    }
    // fill bodies
    for (const obj of sim.objects) {
        if (obj.type === 'ball')
            drawBall(ctx, P[obj.pts[0]], obj.color);
        else if (obj.type === 'soft' && obj.ring)
            drawSoftBody(ctx, obj.ring.map(i => P[i]), obj.color);
    }
    // pins (all objects)
    ctx.fillStyle = config_1.COLORS.pin;
    for (let i = 0; i < P.length; i++) {
        if (P[i].pinned) {
            ctx.beginPath();
            ctx.arc(P[i].x, P[i].y, 3.2, 0, 6.2832);
            ctx.fill();
        }
    }
    // points (when enabled — rope/soft/ball always show dots)
    if (opts.showPoints) {
        ctx.fillStyle = 'rgba(230,240,255,0.9)';
        for (let i = 0; i < P.length; i++) {
            const p = P[i];
            ctx.beginPath();
            ctx.arc(p.x, p.y, 2.2, 0, 6.2832);
            ctx.fill();
        }
    }
    // hover + grab highlights
    if (opts.hover >= 0 && opts.hover !== opts.grab) {
        const p = P[opts.hover];
        ctx.strokeStyle = (0, config_1.hexA)(config_1.COLORS.hover, 0.9);
        ctx.lineWidth = 1.5;
        ctx.beginPath();
        ctx.arc(p.x, p.y, 7, 0, 6.2832);
        ctx.stroke();
    }
    if (opts.grab >= 0) {
        const p = P[opts.grab];
        ctx.strokeStyle = '#ffffff';
        ctx.lineWidth = 2;
        ctx.beginPath();
        ctx.arc(p.x, p.y, 8.5, 0, 6.2832);
        ctx.stroke();
        ctx.fillStyle = 'rgba(255,255,255,0.35)';
        ctx.beginPath();
        ctx.arc(p.x, p.y, 4, 0, 6.2832);
        ctx.fill();
    }
    // tear sparks
    const now = opts.now;
    for (let i = fx.tears.length - 1; i >= 0; i--) {
        const t = fx.tears[i];
        const age = (now - t.born) / 450;
        if (age >= 1) {
            fx.tears.splice(i, 1);
            continue;
        }
        ctx.strokeStyle = (0, config_1.hexA)('#ff8fb3', 1 - age);
        ctx.lineWidth = 2;
        ctx.beginPath();
        ctx.arc(t.x, t.y, 4 + age * 10, 0, 6.2832);
        ctx.stroke();
    }
}
function drawBounds(ctx, sim, W, H) {
    const b = sim.bounds;
    ctx.fillStyle = config_1.COLORS.floorFill;
    ctx.fillRect(0, b.maxY, W, H - b.maxY);
    ctx.strokeStyle = config_1.COLORS.wall;
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(b.minX, 0);
    ctx.lineTo(b.minX, b.maxY);
    ctx.moveTo(b.maxX, 0);
    ctx.lineTo(b.maxX, b.maxY);
    ctx.moveTo(0, b.maxY);
    ctx.lineTo(W, b.maxY);
    ctx.stroke();
}
function drawStatics(ctx, sim) {
    for (const s of sim.statics) {
        ctx.fillStyle = config_1.COLORS.static;
        ctx.strokeStyle = config_1.COLORS.staticLine;
        ctx.lineWidth = 1.5;
        if (s.kind === 'circle') {
            ctx.beginPath();
            ctx.arc(s.x, s.y, s.r, 0, 6.2832);
            ctx.fill();
            ctx.stroke();
        }
        else {
            ctx.fillRect(s.x, s.y, s.w, s.h);
            ctx.strokeRect(s.x, s.y, s.w, s.h);
        }
    }
}
function drawConstraintSet(ctx, sim, obj, list, showStress) {
    const P = sim.points;
    if (obj.type === 'cloth') {
        if (showStress) {
            ctx.lineWidth = 1.2;
            for (const ci of list) {
                const c = sim.constraints[ci];
                const a = P[c.a], b = P[c.b];
                const d = Math.hypot(b.x - a.x, b.y - a.y);
                const t = d > 1e-6 ? (d / c.rest - 1) : 0;
                ctx.strokeStyle = stressColor(t);
                ctx.beginPath();
                ctx.moveTo(a.x, a.y);
                ctx.lineTo(b.x, b.y);
                ctx.stroke();
            }
        }
        else {
            ctx.strokeStyle = (0, config_1.hexA)(obj.color, 0.75);
            ctx.lineWidth = 1.1;
            ctx.beginPath();
            for (const ci of list) {
                const c = sim.constraints[ci];
                const a = P[c.a], b = P[c.b];
                ctx.moveTo(a.x, a.y);
                ctx.lineTo(b.x, b.y);
            }
            ctx.stroke();
        }
    }
    else {
        ctx.strokeStyle = obj.color;
        ctx.lineWidth = obj.type === 'rope' ? 3 : 2.4;
        ctx.lineCap = 'round';
        ctx.lineJoin = 'round';
        if (showStress) {
            for (const ci of list) {
                const c = sim.constraints[ci];
                const a = P[c.a], b = P[c.b];
                const d = Math.hypot(b.x - a.x, b.y - a.y);
                const t = d > 1e-6 ? (d / c.rest - 1) : 0;
                ctx.strokeStyle = stressColor(t);
                ctx.beginPath();
                ctx.moveTo(a.x, a.y);
                ctx.lineTo(b.x, b.y);
                ctx.stroke();
            }
        }
        else {
            ctx.beginPath();
            if (obj.type === 'rope') {
                ctx.moveTo(P[obj.pts[0]].x, P[obj.pts[0]].y);
                for (let i = 1; i < obj.pts.length; i++)
                    ctx.lineTo(P[obj.pts[i]].x, P[obj.pts[i]].y);
                if (obj.tip != null)
                    ctx.lineTo(P[obj.tip].x, P[obj.tip].y);
            }
            else if (obj.ring) {
                ctx.moveTo(P[obj.ring[0]].x, P[obj.ring[0]].y);
                for (let i = 1; i < obj.ring.length; i++)
                    ctx.lineTo(P[obj.ring[i]].x, P[obj.ring[i]].y);
                ctx.closePath();
            }
            ctx.stroke();
        }
        // dots
        if (obj.type === 'rope') {
            ctx.fillStyle = obj.color;
            const a = P[obj.pts[0]];
            ctx.beginPath();
            ctx.arc(a.x, a.y, 3, 0, 6.2832);
            ctx.fill();
            if (obj.tip != null) {
                const t = P[obj.tip];
                ctx.beginPath();
                ctx.arc(t.x, t.y, t.r * 0.55, 0, 6.2832);
                ctx.fill();
            }
        }
        else if (obj.ring && obj.center != null) {
            ctx.fillStyle = (0, config_1.hexA)(obj.color, 0.8);
            const c = P[obj.center];
            ctx.beginPath();
            ctx.arc(c.x, c.y, 2.5, 0, 6.2832);
            ctx.fill();
        }
    }
}
function drawSoftBody(ctx, ring, color) {
    const P = ring;
    ctx.beginPath();
    ctx.moveTo(P[0].x, P[0].y);
    for (let i = 1; i < P.length; i++)
        ctx.lineTo(P[i].x, P[i].y);
    ctx.closePath();
    ctx.fillStyle = (0, config_1.hexA)(color, 0.2);
    ctx.fill();
}
function drawBall(ctx, p, color) {
    const r = p.r;
    ctx.fillStyle = (0, config_1.hexA)(color, 0.92);
    ctx.beginPath();
    ctx.arc(p.x, p.y, r, 0, 6.2832);
    ctx.fill();
    ctx.fillStyle = 'rgba(255,255,255,0.35)';
    ctx.beginPath();
    ctx.arc(p.x - r * 0.3, p.y - r * 0.35, r * 0.28, 0, 6.2832);
    ctx.fill();
}
function stressColor(t) {
    // t = (dist/rest - 1); stretch > 0 red, compression < 0 stays greenish
    const v = clamp01(Math.abs(t) * 3.2);
    const stops = config_1.COLORS.stress;
    const f = v * (stops.length - 1);
    const i = Math.min(stops.length - 2, Math.floor(f));
    const k = f - i;
    return lerpHex(stops[i], stops[i + 1], k);
}
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
function lerpHex(a, b, t) {
    const ar = parseInt(a.slice(1, 3), 16), ag = parseInt(a.slice(3, 5), 16), ab = parseInt(a.slice(5, 7), 16);
    const br = parseInt(b.slice(1, 3), 16), bg = parseInt(b.slice(3, 5), 16), bb = parseInt(b.slice(5, 7), 16);
    const r = Math.round(ar + (br - ar) * t);
    const g = Math.round(ag + (bg - ag) * t);
    const bl = Math.round(ab + (bb - ab) * t);
    return `rgb(${r},${g},${bl})`;
}
function clearEffects(fx) { fx.tears.length = 0; }
function ensureCanvasSize(canvas, W, H) {
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const pw = Math.round(W * dpr), ph = Math.round(H * dpr);
    if (canvas.width !== pw || canvas.height !== ph) {
        canvas.width = pw;
        canvas.height = ph;
        canvas.style.width = W + 'px';
        canvas.style.height = H + 'px';
    }
}
};

// ── module: src/input.ts ──
__mods["src/input.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Input = void 0;
const config_1 = require("./config");
class Input {
    constructor(canvas, sim, opts, hooks) {
        this.canvas = canvas;
        this.sim = sim;
        this.opts = opts;
        this.hooks = hooks;
        this.tool = 'grab';
        this.hover = -1;
        this.down = false;
        this.mode = 'grab';
        this.grabIdx = -1;
        this.sx = 0;
        this.sy = 0;
        this.lx = 0;
        this.ly = 0;
        this.vx = 0;
        this.vy = 0;
        this.spawnFrom = null;
        this.lastTear = { x: 0, y: 0 };
        canvas.addEventListener('pointerdown', (e) => this.pointerdown(e));
        canvas.addEventListener('pointermove', (e) => this.pointermove(e));
        window.addEventListener('pointerup', (e) => this.pointerup(e));
        canvas.addEventListener('contextmenu', (e) => e.preventDefault());
    }
    toCanvas(e) {
        const r = this.canvas.getBoundingClientRect();
        return { x: e.clientX - r.left, y: e.clientY - r.top };
    }
    pointerdown(e) {
        if (this.down)
            return;
        const { x, y } = this.toCanvas(e);
        this.down = true;
        this.sx = x;
        this.sy = y;
        this.lx = x;
        this.ly = y;
        this.vx = 0;
        this.vy = 0;
        // right button always tears
        if (e.button === 2 || (this.opts.tearHeld && e.button === 0)) {
            this.mode = 'tear';
            this.lastTear = { x, y };
            const res = this.sim.tear(x - 1, y - 1, x + 1, y + 1);
            this.hooks.onTear(x, y, res.count);
            return;
        }
        if (e.button !== 0)
            return;
        if (this.opts.pinHeld) {
            this.mode = 'pin';
            const idx = this.sim.togglePin(x, y);
            if (idx >= 0)
                this.hooks.onPin(x, y, idx);
            return;
        }
        const spawn = this.tool === 'rope' ? 'rope' : this.tool === 'cloth' ? 'cloth' :
            this.tool === 'soft' ? 'soft' : this.tool === 'ball' ? 'ball' : null;
        if (spawn === 'rope' || spawn === 'cloth') {
            this.mode = 'spawn';
            return;
        }
        if (spawn === 'soft' || spawn === 'ball') {
            this.mode = 'spawn';
            this.spawnFrom = spawn;
            return;
        }
        // grab tool
        const idx = this.sim.nearestPoint(x, y, config_1.CFG.GRAB_RADIUS);
        if (idx < 0)
            return;
        this.mode = 'grab';
        this.grabIdx = idx;
        const p = this.sim.points[idx];
        p.px = p.x;
        p.py = p.y;
        this.hooks.onGrab(idx);
    }
    pointermove(e) {
        const { x, y } = this.toCanvas(e);
        if (!this.down) {
            this.hover = this.sim.nearestPoint(x, y, config_1.CFG.HOVER_RADIUS);
            this.canvas.style.cursor = this.hover >= 0 ? 'grab' : this.tool === 'grab' ? 'default' : 'crosshair';
            return;
        }
        const dx = x - this.lx, dy = y - this.ly;
        this.vx = dx;
        this.vy = dy;
        this.lx = x;
        this.ly = y;
        if (this.mode === 'tear') {
            const res = this.sim.tear(this.lastTear.x, this.lastTear.y, x, y);
            if (res.count > 0)
                this.hooks.onTear(x, y, res.count);
            this.lastTear = { x, y };
            return;
        }
        if (this.mode === 'grab' && this.grabIdx >= 0) {
            const p = this.sim.points[this.grabIdx];
            // set position + implicit velocity so the solver reacts live
            p.px = p.x;
            p.py = p.y;
            p.x = x;
            p.y = y;
            this.canvas.style.cursor = 'grabbing';
        }
    }
    pointerup(e) {
        if (!this.down)
            return;
        this.down = false;
        const { x, y } = this.toCanvas(e);
        if (this.mode === 'grab' && this.grabIdx >= 0) {
            const p = this.sim.points[this.grabIdx];
            p.px = p.x - this.vx;
            p.py = p.y - this.vy;
            this.hooks.onRelease(this.grabIdx);
            this.grabIdx = -1;
        }
        else if (this.mode === 'spawn' && this.spawnFrom) {
            this.hooks.onSpawn(this.spawnFrom, this.sx, this.sy, x, y);
            this.spawnFrom = null;
            this.hooks.onToolEnd();
        }
        else if (this.mode === 'spawn') {
            if (Math.hypot(x - this.sx, y - this.sy) > 20) {
                this.hooks.onSpawn(this.tool, this.sx, this.sy, x, y);
                this.hooks.onToolEnd();
            }
        }
        if (this.mode === 'grab')
            this.canvas.style.cursor = this.hover >= 0 ? 'grab' : 'default';
        this.mode = 'grab';
    }
    /** live preview drag: {tool, ax, ay, bx, by} | null */
    preview() {
        if (!this.down || this.mode !== 'spawn')
            return null;
        return { tool: this.tool, ax: this.sx, ay: this.sy, bx: this.lx, by: this.ly };
    }
    activeGrab() { return this.grabIdx; }
    activeTear() {
        return this.down && this.mode === 'tear' ? { x: this.lx, y: this.ly } : null;
    }
}
exports.Input = Input;
};

// ── module: src/ui.ts ──
__mods["src/ui.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildDemo = void 0;
exports.wireUI = wireUI;
const scene_1 = require("./scene");
Object.defineProperty(exports, "buildDemo", { enumerable: true, get: function () { return scene_1.buildDemo; } });
const $ = (id) => document.getElementById(id);
function wireUI(sim, input, canvas, W, H, signals, getStats) {
    // ---- tool buttons ----
    const toolBtns = Array.from(document.querySelectorAll('[data-tool]'));
    const setTool = (t) => {
        input.tool = t;
        toolBtns.forEach(b => b.classList.toggle('active', b.dataset.tool === t));
    };
    toolBtns.forEach(b => b.addEventListener('click', () => setTool(b.dataset.tool)));
    // ---- checkboxes ----
    const chkStress = $('chk-stress');
    const chkPoints = $('chk-points');
    const chkTrail = $('chk-trail');
    const chkFull = $('chk-full');
    const chkGravity = $('chk-gravity');
    chkFull.addEventListener('change', () => { input.opts.clothFullEdge = chkFull.checked; });
    // ---- sliders ----
    const gravSlider = $('sld-gravity');
    const iterSlider = $('sld-iter');
    const windSlider = $('sld-wind');
    const valGrav = $('val-gravity');
    const valIter = $('val-iter');
    const valWind = $('val-wind');
    chkGravity.checked = sim.gravity > 0;
    const syncGravity = () => {
        sim.gravity = chkGravity.checked ? parseFloat(gravSlider.value) : 0;
        gravSlider.disabled = !chkGravity.checked;
        if (valGrav)
            valGrav.textContent = sim.gravity === 0 ? 'off' : String(Math.round(sim.gravity));
    };
    chkGravity.addEventListener('change', syncGravity);
    gravSlider.addEventListener('input', syncGravity);
    syncGravity();
    iterSlider.value = String(sim.iterations);
    if (valIter)
        valIter.textContent = String(sim.iterations);
    iterSlider.addEventListener('input', () => {
        sim.iterations = parseInt(iterSlider.value, 10);
        if (valIter)
            valIter.textContent = iterSlider.value;
    });
    windSlider.value = String(sim.wind);
    if (valWind)
        valWind.textContent = '0';
    windSlider.addEventListener('input', () => {
        sim.wind = parseFloat(windSlider.value);
        if (valWind)
            valWind.textContent = String(Math.round(sim.wind));
    });
    // ---- pause / step / reset / clear ----
    const btnPause = $('btn-pause');
    const btnStep = $('btn-step');
    const pauseLabel = () => { btnPause.textContent = sim.paused ? '▶ Play' : '❚❚ Pause'; };
    const setPaused = (v) => { sim.paused = v; pauseLabel(); };
    btnPause.addEventListener('click', () => setPaused(!sim.paused));
    btnStep.addEventListener('click', () => { if (!sim.paused)
        setPaused(true); sim.step(); });
    $('btn-reset').addEventListener('click', () => signals.reset());
    $('btn-clear').addEventListener('click', () => { sim.clear(); signals.clearAll(); });
    // ---- keyboard ----
    const onKey = (e) => {
        const tag = e.target?.tagName;
        if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') {
            if (e.code === 'Space')
                e.target.blur();
            else
                return;
        }
        switch (e.code) {
            case 'Space':
                e.preventDefault();
                setPaused(!sim.paused);
                break;
            case 'ArrowRight':
                e.preventDefault();
                if (sim.paused)
                    sim.step();
                break;
            case 'KeyR':
                signals.reset();
                break;
            case 'KeyV':
                setTool('grab');
                break;
            case 'KeyT':
                setTool('tear');
                break;
            case 'KeyC':
                setTool('cloth');
                break;
            case 'KeyB':
                setTool('soft');
                break;
            case 'KeyX':
                setTool('ball');
                break;
            case 'KeyO':
                setTool('rope');
                break;
            case 'KeyP':
                input.opts.pinHeld = true;
                break;
            case 'KeyShift':
                input.opts.tearHeld = true;
                break;
        }
    };
    const onKeyUp = (e) => {
        if (e.code === 'KeyP')
            input.opts.pinHeld = false;
        if (e.code === 'KeyShift')
            input.opts.tearHeld = false;
    };
    window.addEventListener('keydown', onKey);
    window.addEventListener('keyup', onKeyUp);
    // ---- HUD (throttled) ----
    const hud = $('hud');
    let lastHud = 0;
    const hudTick = (now) => {
        if (now - lastHud < 250)
            return;
        lastHud = now;
        const s = getStats();
        hud.textContent = `${s.fps} fps · phys ${s.ms.toFixed(1)} ms · ${s.points} pts · ${s.constraints} cons · ${s.objects} objs${sim.paused ? ' · PAUSED' : ''}`;
    };
    const interval = setInterval(hudTick, 250);
    return () => {
        toolBtns.forEach(b => b.removeEventListener('click', (e) => setTool(b.dataset.tool)));
        window.removeEventListener('keydown', onKey);
        window.removeEventListener('keyup', onKeyUp);
        clearInterval(interval);
    };
}
};

// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"unsloth/Qwen3.8-27B-GGUF","provider":"brigid","persona":"typescript","sessionId":"fe08d2bd-533e-46d1-a6cb-5531b5815f82","tokensIn":1347958,"tokensOut":137190,"tokensTotal":1485148,"cost":0,"turns":41,"toolCalls":61,"failedToolCalls":0,"timestamp":"2026-08-15T19:03:51.811Z"} -->