← Physics Playground results

Physics Playground

Qwen 3.8 · 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 66,595 bytes · SHA-256 42cffc530462
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Verlet Playground — Soft-Body Physics</title>
<style>
/* ── css: layout ── */
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #0b0e14; }
body { font-family: "Segoe UI", system-ui, -apple-system, sans-serif; }
#app, #stage { position: fixed; inset: 0; }
#view { position: absolute; inset: 0; width: 100%; height: 100%; display: block; cursor: crosshair; }
#view.grabbing { cursor: grabbing; }
#view.cutting { cursor: cell; }

/* ---- HUD pills (top-right) ---- */
#hud { position: absolute; top: 10px; right: 12px; display: flex; gap: 6px; pointer-events: none; z-index: 5; }
.pill {
  background: rgba(16, 20, 30, 0.78); border: 1px solid rgba(255,255,255,0.09);
  color: #9fb3c8; font-size: 11px; font-variant-numeric: tabular-nums;
  padding: 4px 9px; border-radius: 20px; letter-spacing: 0.02em;
  backdrop-filter: blur(4px); white-space: nowrap;
}
.pill b { color: #e8f1fa; font-weight: 600; }

/* ---- Toolbar (bottom) ---- */
#toolbar {
  position: absolute; left: 0; right: 0; bottom: 0; z-index: 5;
  display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
  padding: 9px 12px 11px;
  background: linear-gradient(to top, rgba(10, 13, 20, 0.92), rgba(10, 13, 20, 0.66) 75%, transparent);
  user-select: none;
}
.tgroup { display: flex; align-items: center; gap: 5px; }
.tgroup.sliders { gap: 12px; margin-left: auto; }
.tgroup.toggles { gap: 10px; }
.tool {
  appearance: none; border: 1px solid rgba(255,255,255,0.12); border-radius: 8px;
  background: rgba(255,255,255,0.05); color: #c7d5e3; font-size: 12px; font-weight: 600;
  padding: 6px 11px; cursor: pointer; transition: background .12s, border-color .12s, color .12s, transform .06s;
}
.tool:hover { background: rgba(255,255,255,0.11); color: #fff; }
.tool:active { transform: translateY(1px); }
.tool.active { background: var(--tc, #4cc9f0); border-color: var(--tc, #4cc9f0); color: #081019; }
.tool:disabled { opacity: 0.35; cursor: default; }
.sliders label, .toggles label {
  display: flex; align-items: center; gap: 6px; color: #8fa2b5; font-size: 11px; font-weight: 600;
}
.sliders span { min-width: 26px; color: #c7d5e3; font-variant-numeric: tabular-nums; }
input[type="range"] {
  -webkit-appearance: none; width: 86px; height: 4px; border-radius: 3px;
  background: #2a3546; outline: none; cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%;
  background: #e8f1fa; border: 2px solid #4cc9f0;
}
input[type="checkbox"] { accent-color: #4cc9f0; width: 13px; height: 13px; cursor: pointer; }

/* ---- Hint (top-left) ---- */
#hint {
  position: absolute; top: 10px; left: 12px; z-index: 5; pointer-events: none;
  color: #66788c; font-size: 11px; letter-spacing: 0.02em;
  background: rgba(16, 20, 30, 0.6); border: 1px solid rgba(255,255,255,0.07);
  padding: 5px 10px; border-radius: 20px; backdrop-filter: blur(4px);
}
#hint b { color: #aebfd2; font-weight: 600; }
</style>
</head>
<body>
<div id="stage">
  <canvas id="view"></canvas>
  <div id="hud">
    <span class="pill" id="hud-fps">-- fps</span>
    <span class="pill" id="hud-physics">-- μs/step</span>
    <span class="pill" id="hud-counts">0 pts · 0 cons</span>
  </div>
  <div id="toolbar">
    <div class="tgroup" id="spawns">
      <button class="tool" data-tool="rope" title="Rope — drag to draw a chain">Rope</button>
      <button class="tool" data-tool="cloth" title="Cloth — drag a rectangle">Cloth</button>
      <button class="tool" data-tool="blob" title="Soft body blob">Blob</button>
      <button class="tool" data-tool="box" title="Soft body box">Box</button>
      <button class="tool" data-tool="ball" title="Bouncing ball">Ball</button>
      <button class="tool" data-tool="grab" title="Grab &amp; throw (default)">Grab</button>
      <button class="tool" data-tool="cut" title="Slice constraints">Cut</button>
    </div>
    <div class="tgroup" id="ctrls">
      <button class="tool" id="btn-pause" title="Pause (Space)">Pause</button>
      <button class="tool" id="btn-step" title="Step one frame (ArrowRight)" disabled="">Step</button>
      <button class="tool" id="btn-reset" title="Rebuild demo scene (R)">Reset</button>
      <button class="tool" id="btn-clear" title="Remove everything">Clear</button>
    </div>
    <div class="tgroup sliders">
      <label>Gravity <input id="in-gravity" type="range" min="0" max="2500" value="1400" step="50"></label>
      <label>Wind <input id="in-wind" type="range" min="-1500" max="1500" value="0" step="25"></label>
      <label>Iter <input id="in-iter" type="range" min="1" max="16" value="5" step="1"><span id="v-iter">5</span></label>
      <label>Damp <input id="in-damp" type="range" min="0.970" max="1" value="0.994" step="0.001"><span id="v-damp">.994</span></label>
    </div>
    <div class="tgroup toggles">
      <label class="chk"><input id="in-pins" type="checkbox" checked="">Pins</label>
      <label class="chk"><input id="in-dots" type="checkbox">Dots</label>
      <label class="chk"><input id="in-stress" type="checkbox">Stress</label>
      <label class="chk"><input id="in-trail" type="checkbox" checked="">Trail</label>
      <label class="chk"><input id="in-palett" type="checkbox">Cloth: corners</label>
    </div>
  </div>
  <div id="hint">Left-drag grab &amp; throw · right-drag cut · shift+click pin · dbl-click point: remove object · space pause · → step · 1–5 tools · R reset</div>
</div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./config":"src/config.ts","./engine":"src/engine.ts","./render":"src/render.ts","./interaction":"src/interaction.ts","./factories":"src/factories.ts"},"src/engine.ts":{"./config":"src/config.ts","./types":"src/types.ts"},"src/factories.ts":{"./config":"src/config.ts","./engine":"src/engine.ts","./types":"src/types.ts"},"src/render.ts":{"./config":"src/config.ts","./engine":"src/engine.ts","./types":"src/types.ts"},"src/interaction.ts":{"./config":"src/config.ts","./engine":"src/engine.ts","./render":"src/render.ts","./factories":"src/factories.ts","./types":"src/types.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 — entry: canvas setup, fixed-timestep loop, HUD/toolbar wiring,
// demo scene, and the headless `window.sim` API.
// ============================================================================
const config_1 = require("./config");
const engine_1 = require("./engine");
const render_1 = require("./render");
const interaction_1 = require("./interaction");
const factories_1 = require("./factories");
// -------------------------------------------------------------- DOM handles
const $ = (id) => document.getElementById(id);
let engine;
let renderer;
let inter;
let canvas;
let renderOn = true; // frozen by sim.freezeRender() for pure-physics timing
const els = {};
// -------------------------------------------------------------- demo scene
function buildDemo() {
    const e = engine;
    e.clear();
    const w = e.w, h = e.h, floor = h - config_1.CFG.WALL_MARGIN;
    const circle = { x: w * 0.52, y: h * 0.55, r: Math.min(h * 0.15, 100), hue: 210 };
    e.statics = [circle, { x: w * 0.16, y: h * 0.34, r: 44, hue: 210 }];
    e.staticBoxes = [
        { x: w * 0.78, y: floor - 84, w: 132, h: 84, hue: 210 },
    ];
    (0, factories_1.spawnRope)(e, w * 0.17, h * 0.16, w * 0.27, h * 0.40);
    (0, factories_1.spawnCloth)(e, circle.x - w * 0.13, h * 0.10, circle.x + w * 0.13, h * 0.34, true);
    (0, factories_1.spawnBlob)(e, w * 0.86, h * 0.14, 46);
    (0, factories_1.spawnBox)(e, w * 0.92, h * 0.40, 40);
    (0, factories_1.spawnBall)(e, w * 0.36, h * 0.12, 15);
    (0, factories_1.spawnBall)(e, w * 0.30, h * 0.52, 10, 82);
    updateCounts();
}
// ---------------------------------------------------------------- HUD counts
let lastHudT = 0;
let fpsEMA = 60, physUs = 0;
function updateCounts() {
    if (!els['hud-counts'])
        return;
    els['hud-counts'].textContent = `${engine.points.length} pts · ${engine.cons.length} cons`;
}
// --------------------------------------------------------------- main start
let booted = false;
function start() {
    if (booted)
        return; // idempotent: harness + self-invoke may both fire
    booted = true;
    canvas = $('view');
    for (const id of ['hud-fps', 'hud-physics', 'hud-counts', 'spawns', 'ctrls',
        'btn-pause', 'btn-step', 'btn-reset', 'btn-clear',
        'in-gravity', 'in-wind', 'in-iter', 'in-damp', 'v-iter', 'v-damp',
        'in-pins', 'in-dots', 'in-stress', 'in-trail', 'in-palett'])
        els[id] = $(id);
    // ---- engine / renderer / interaction --------------------------------
    engine = new engine_1.Engine();
    renderer = new render_1.Renderer(canvas);
    inter = new interaction_1.Interaction(canvas, engine, renderer, {
        onTool: t => markTool(t),
        onPaused: p => {
            els['btn-pause'].textContent = p ? 'Resume' : 'Pause';
            els['btn-step'].disabled = !p;
        },
        onCounts: updateCounts,
        onReset: buildDemo,
    });
    // ---- sizing ----------------------------------------------------------
    const fit = () => {
        const w = window.innerWidth, h = window.innerHeight;
        const dpr = Math.min(window.devicePixelRatio || 1, 2);
        renderer.resize(w, h, dpr);
        engine.resize(w, h);
    };
    fit();
    window.addEventListener('resize', fit);
    // ---- toolbar wiring ----------------------------------------------------
    const tools = ['rope', 'cloth', 'blob', 'box', 'ball', 'grab', 'cut'];
    for (const b of els['spawns'].querySelectorAll('[data-tool]')) {
        b.addEventListener('click', () => inter.setTool(b.dataset.tool));
    }
    const pauseBtn = els['btn-pause'];
    pauseBtn.addEventListener('click', () => inter.setPaused(!inter.paused));
    els['btn-step'].addEventListener('click', () => { if (inter.paused)
        engine.step(); });
    els['btn-reset'].addEventListener('click', () => inter.reset());
    els['btn-clear'].addEventListener('click', () => { inter.clearAll(); engine.statics = []; engine.staticBoxes = []; updateCounts(); });
    const g = els['in-gravity'];
    g.addEventListener('input', () => { engine.gravity = +g.value; });
    const wn = els['in-wind'];
    wn.addEventListener('input', () => { engine.wind = +wn.value; });
    const it = els['in-iter'];
    it.addEventListener('input', () => { engine.iterations = +it.value; els['v-iter'].textContent = it.value; });
    const dm = els['in-damp'];
    dm.addEventListener('input', () => { engine.damping = +dm.value; els['v-damp'].textContent = (+dm.value).toFixed(3).slice(1); });
    const cb = (id, fn) => {
        const el = els[id];
        el.addEventListener('change', () => fn(el.checked));
    };
    cb('in-pins', b => { });
    cb('in-dots', () => { });
    cb('in-stress', () => { });
    cb('in-trail', () => { });
    cb('in-palett', b => (0, interaction_1.setClothPinEdge)(!b)); // checkbox "corners": off => edge pins
    (0, interaction_1.setClothPinEdge)(!(els['in-palett'].checked)); // sync initial state
    // ---- demo -------------------------------------------------------------
    buildDemo();
    // ---- headless API ------------------------------------------------------
    window['sim'] = {
        points: () => engine.points,
        cons: () => engine.cons,
        objs: () => engine.objs,
        step: () => engine.step(),
        stepFrames: (n) => { for (let i = 0; i < n; i++)
            engine.step(); },
        pause: () => inter.setPaused(true),
        resume: () => inter.setPaused(false),
        freezeRender: (on = true) => { renderOn = !on; if (!renderOn)
            renderer.render(engine, { ...inter.uiForRender(), showPins: false, showDots: false, stress: false, trail: false }, 0); },
        reset: () => buildDemo(),
        clear: () => { inter.clearAll(); },
        spawnRope: (x1, y1, x2, y2) => (0, factories_1.spawnRope)(engine, x1, y1, x2, y2),
        spawnCloth: (x1, y1, x2, y2, pinEdge = true) => (0, factories_1.spawnCloth)(engine, x1, y1, x2, y2, pinEdge),
        spawnBlob: (x, y, r) => (0, factories_1.spawnBlob)(engine, x, y, r),
        spawnBox: (x, y, r) => (0, factories_1.spawnBox)(engine, x, y, r),
        spawnBall: (x, y, r) => (0, factories_1.spawnBall)(engine, x, y, r),
        setPin: (i, on) => engine.setPin(i, on),
        tear: (ci) => { engine.cons[ci].dead = true; engine.pruneDead(); },
        kineticEnergy: () => engine.kineticEnergy(),
        isSane: () => engine.isSane(),
        stats: () => ({ points: engine.points.length, cons: engine.cons.length, ke: engine.kineticEnergy(), sane: engine.isSane() }),
        engine,
    };
    // ---- main loop: fixed-timestep accumulator -----------------------------
    let last = performance.now();
    let acc = 0;
    let frames = 0, fpsT = last;
    let totalSteps = 0, totalPhysMs = 0;
    const frame = (now) => {
        // clamp delta so a long tab-away can never dump a huge backlog
        let dt = (now - last) / 1000;
        last = now;
        if (dt > config_1.CFG.MAX_FRAME)
            dt = config_1.CFG.MAX_FRAME;
        let physT = 0;
        if (!inter.paused) {
            acc += dt;
            let steps = 0;
            const t0 = performance.now();
            while (acc >= config_1.CFG.DT && steps < config_1.CFG.MAX_STEPS) {
                engine.step();
                acc -= config_1.CFG.DT;
                steps++;
                if (steps === config_1.CFG.MAX_STEPS)
                    acc = 0; // drop backlog
            }
            physT = performance.now() - t0;
            totalSteps += steps;
            totalPhysMs += physT;
        }
        // ---- render (skippable via sim.freezeRender() for pure-physics timing) ----
        if (renderOn) {
            const ui = {
                ...inter.uiForRender(),
                showPins: els['in-pins'].checked,
                showDots: els['in-dots'].checked,
                stress: els['in-stress'].checked,
                trail: els['in-trail'].checked,
            };
            renderer.updateFX(dt);
            renderer.render(engine, ui, now / 1000);
        }
        // ---- HUD ----
        frames++;
        if (now - fpsT >= 500) {
            fpsEMA = frames * 1000 / (now - fpsT);
            frames = 0;
            fpsT = now;
        }
        if (now - lastHudT > 250) {
            lastHudT = now;
            els['hud-fps'].textContent = `${fpsEMA.toFixed(0)} fps`;
            const perStep = totalSteps > 0 ? (totalPhysMs / totalSteps) * 1e6 : 0;
            els['hud-physics'].textContent = inter.paused ? 'paused' : `${perStep.toFixed(0)} μs/step`;
        }
        requestAnimationFrame(frame);
    };
    requestAnimationFrame(frame);
}
const TOOL_HUE = { ...config_1.CFG.HUES, grab: 200, cut: 350 };
function markTool(t) {
    const btns = document.querySelectorAll('#spawns [data-tool]');
    btns.forEach(b => {
        const on = b.dataset.tool === t;
        b.classList.toggle('active', on);
        b.style.setProperty('--tc', `hsl(${TOOL_HUE[b.dataset.tool ?? 'grab']}, 85%, 62%)`);
    });
}
// Self-start (the inline bundle runs this module with the DOM already parsed).
start();
};

// ── module: src/config.ts ──
__mods["src/config.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CFG = void 0;
// ============================================================================
// TUNABLE CONSTANTS — the whole feel of the sim lives in this block
// ============================================================================
exports.CFG = {
    // ---- timestep ----
    DT: 1 / 60, // physics timestep (s), fixed at 60 Hz
    MAX_FRAME: 1 / 10, // clamp for a single rAF delta (tab-switch safety)
    MAX_STEPS: 4, // max physics steps per frame (spiral-of-death guard)
    // ---- dynamics ----
    GRAVITY: 1400, // px/s^2 (slider 0..2500)
    WIND: 0, // px/s^2 x-force (slider -1500..1500)
    DAMPING: 0.994, // velocity retention per step (slider .970..1)
    ITERATIONS: 5, // constraint relaxation passes per step (slider 1..16)
    MAX_SPEED: 3200, // px/s hard velocity clamp (tunneling/energy guard)
    SHAPE_K: 40, // soft-body shape-memory spring (s^-2): a = deviation * K
    REST_FRICTION: 0.22, // tangential damping when a point touches a static shape
    BALL_BOUNCE: 0.55, // tangential restitution preserved for heavy balls
    // ---- interaction ----
    GRAB_RADIUS: 26, // px, nearest-point search radius
    CUT_RADIUS: 11, // px, half-thickness of the slice knife
    GRAB_PULL: 0.65, // grab point is pulled toward cursor this fraction/step
    THROW_CAP: 2800, // px/s, max velocity granted by a release
    // ---- geometry / limits ----
    WALL_MARGIN: 6, // inset of the bounding box walls
    MAX_POINTS: 9000, // hard cap on point count
    POINT_DOTS: 2.0, // px radius for point dots
    BALL_RADIUS: 14, // default spawned ball radius
    // ---- rendering ----
    STRESS_GREEN: '#3ddc84',
    STRESS_RED: '#ff4d6d',
    STRESS_START: 1.0, // stretch ratio where stress color begins
    TRAIL_ALPHA: 0.34, // bg fade alpha (lower = longer trail); 1 = no trail
    HUES: {
        rope: 197, // cyan
        cloth: 335, // pink
        blob: 268, // violet
        box: 42, // amber
        ball: 152, // green
    },
};
};

// ── module: src/types.ts ──
__mods["src/types.ts"] = function (exports, require, module) {
"use strict";
// ============================================================================
// Core data structures
// ============================================================================
Object.defineProperty(exports, "__esModule", { value: true });
};

// ── module: src/engine.ts ──
__mods["src/engine.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Engine = void 0;
// ============================================================================
// ENGINE — Verlet integration + constraint relaxation (position-based)
//   step(): 1) integrate  2) soft-body shape springs
//           3) N relaxation passes  4) grab  5) static collisions  6) guards
// ============================================================================
const config_1 = require("./config");
class Engine {
    constructor() {
        this.points = [];
        this.cons = [];
        this.objs = [];
        this.statics = [];
        this.staticBoxes = [];
        this.nextId = 0;
        this.w = 1280;
        this.h = 720;
        this.gravity = config_1.CFG.GRAVITY;
        this.wind = config_1.CFG.WIND;
        this.damping = config_1.CFG.DAMPING;
        this.iterations = config_1.CFG.ITERATIONS;
        this.dt = config_1.CFG.DT;
        this.grab = null;
        this.nanHits = 0; // defensive: bumped when the guard restores a bad point
        this.shapeF = new Float32Array(0); // per-point shape-force scratch
    }
    // ------------------------------------------------------------------ adding
    resize(w, h) { this.w = w; this.h = h; }
    addPoint(x, y, r = 1.5, pinned = false, objId = -1) {
        const p = { x, y, px: x, py: y, r, inv: pinned ? 0 : 1, pinned, obj: objId };
        this.points.push(p);
        return this.points.length - 1;
    }
    addConstraint(a, b, w = 1, kind = 'rope') {
        const pa = this.points[a], pb = this.points[b];
        const dx = pb.x - pa.x, dy = pb.y - pa.y;
        const rest = Math.max(Math.hypot(dx, dy), 1e-6); // guard: coincident points
        this.cons.push({ a, b, rest, w, kind, dead: false });
        return this.cons.length - 1;
    }
    addObj(kind, pts, cons, hue, ring = [], shape = 'circle', shapeX = [], shapeY = [], cols = 0, rows = 0) {
        const o = { id: this.nextId++, kind, pts, cons, cols, rows, hue, ring, shape, shapeX, shapeY };
        this.objs.push(o);
        return o;
    }
    setPin(i, on) {
        const p = this.points[i];
        p.pinned = on;
        p.inv = on ? 0 : 1;
    }
    clear() {
        this.points.length = 0;
        this.cons.length = 0;
        this.objs.length = 0;
        this.grab = null;
    }
    removeObj(id) {
        const o = this.objs.find(x => x.id === id);
        if (!o)
            return;
        for (const ci of o.cons)
            if (this.cons[ci])
                this.cons[ci].dead = true;
        for (const pi of o.pts)
            this.points[pi] = undefined; // slot out
        this.objs = this.objs.filter(x => x.id !== id);
        this.compact();
    }
    /** Drop dead constraints + slotted-out points, remap indices. */
    compact() {
        const pMap = new Array(this.points.length).fill(-1);
        const pts = [];
        for (let i = 0; i < this.points.length; i++) {
            const p = this.points[i];
            if (!p)
                continue;
            pMap[i] = pts.length;
            pts.push(p);
        }
        const cMap = new Array(this.cons.length).fill(-1);
        const cons = [];
        for (let i = 0; i < this.cons.length; i++) {
            const c = this.cons[i];
            if (c.dead)
                continue;
            const a = pMap[c.a], b = pMap[c.b];
            if (a < 0 || b < 0)
                continue;
            cMap[i] = cons.length;
            cons.push({ a, b, rest: c.rest, w: c.w, kind: c.kind, dead: false });
        }
        const objs = [];
        for (const o of this.objs) {
            const pts = o.pts.map(i => pMap[i]).filter(i => i >= 0);
            const cons = o.cons.map(i => cMap[i]).filter(i => i >= 0);
            if (pts.length === 0)
                continue;
            objs.push({ ...o, pts, cons, ring: o.ring.map(i => pMap[i]).filter(i => i >= 0) });
        }
        this.points = pts;
        this.cons = cons;
        this.objs = objs;
        if (this.grab && this.grab.active && this.grab.p >= this.points.length)
            this.grab.active = false;
    }
    // ------------------------------------------------------------- simulation
    step(dt) {
        const d = dt ?? this.dt;
        const dt2 = d * d;
        const maxDisp = config_1.CFG.MAX_SPEED * d;
        const pts = this.points;
        // ---- 1) shape-memory forces (TRUE forces: a = deviation * K) ---------
        // Applied during integration so collision projection can cancel them —
        // positional "creep" instead pumped energy against the floor.
        const n = pts.length;
        if (this.shapeF.length !== 2 * n)
            this.shapeF = new Float32Array(2 * n);
        const sf = this.shapeF;
        sf.fill(0);
        for (let oi = 0; oi < this.objs.length; oi++) {
            const o = this.objs[oi];
            if (!o.shape || o.ring.length === 0)
                continue;
            let cx = 0, cy = 0;
            for (const i of o.ring) {
                const p = pts[i];
                cx += p.x;
                cy += p.y;
            }
            const m = o.ring.length;
            cx /= m;
            cy /= m;
            const K = config_1.CFG.SHAPE_K;
            for (let j = 0; j < m; j++) {
                const i = o.ring[j];
                const p = pts[i];
                if (p.pinned)
                    continue;
                sf[2 * i] += (cx + o.shapeX[j] - p.x) * K;
                sf[2 * i + 1] += (cy + o.shapeY[j] - p.y) * K;
            }
        }
        // ---- 2) Verlet integration (gravity + wind + shape forces) ----------
        for (let i = 0; i < n; i++) {
            const p = pts[i];
            if (p.pinned)
                continue;
            let vx = (p.x - p.px) * this.damping;
            let vy = (p.y - p.py) * this.damping;
            // hard velocity clamp — nothing can tunnel at any frame rate
            const s = Math.hypot(vx, vy);
            if (s > maxDisp) {
                const k = maxDisp / s;
                vx *= k;
                vy *= k;
            }
            p.px = p.x;
            p.py = p.y;
            p.x += vx + (this.wind + sf[2 * i]) * dt2;
            p.y += vy + (this.gravity + sf[2 * i + 1]) * dt2;
        }
        // ---- 3) constraint relaxation INTERLEAVED with collision projection --
        // The solver can shove points into statics (compressed contact bands);
        // projecting every pass instead of once kills the sustained rattle that
        // an end-of-step collision alone leaves behind.
        const cons = this.cons;
        for (let it = 0; it < this.iterations; it++) {
            for (let i = 0; i < cons.length; i++) {
                const c = cons[i];
                if (c.dead)
                    continue;
                const a = pts[c.a], b = pts[c.b];
                const dx = b.x - a.x, dy = b.y - a.y;
                const d = Math.sqrt(dx * dx + dy * dy);
                if (d < 1e-6)
                    continue; // guard: coincident points
                const total = a.inv + b.inv;
                if (total === 0)
                    continue; // both pinned
                const diff = ((d - c.rest) / d) * c.w;
                const wa = a.inv / total, wb = b.inv / total;
                a.x += dx * diff * wa;
                a.y += dy * diff * wa;
                b.x -= dx * diff * wb;
                b.y -= dy * diff * wb;
            }
            this.collide(d);
        }
        // ---- 4) grab: point follows cursor, carries cursor velocity ----------
        if (this.grab && this.grab.active) {
            const g = this.grab;
            const p = pts[g.p];
            if (p) {
                if (p.pinned) {
                    // dragging a pin = sliding its anchor (no throw on release)
                    p.x += (g.x - p.x) * 0.9;
                    p.y += (g.y - p.y) * 0.9;
                    p.px = p.x;
                    p.py = p.y;
                }
                else {
                    p.x += (g.x - p.x) * config_1.CFG.GRAB_PULL;
                    p.y += (g.y - p.y) * config_1.CFG.GRAB_PULL;
                    // impart (capped) cursor velocity so release = throw
                    let tvx = g.vx * d, tvy = g.vy * d;
                    const ts = Math.hypot(tvx, tvy);
                    if (ts > config_1.CFG.THROW_CAP * d) {
                        const k = config_1.CFG.THROW_CAP * d / ts;
                        tvx *= k;
                        tvy *= k;
                    }
                    p.px = p.x - tvx;
                    p.py = p.y - tvy;
                }
            }
        }
        // ---- 5) final collision pass (grab may have moved a point into a
        //         static/wall; interleaved passes above kept everything else out)
        this.collide(d);
        // ---- 6) NaN / bound guards -------------------------------------------
        const lim = 1e5;
        for (let i = 0; i < pts.length; i++) {
            const p = pts[i];
            if (!isFinite(p.x) || !isFinite(p.y)) {
                p.x = p.px;
                p.y = p.py;
                if (!isFinite(p.x) || !isFinite(p.y)) {
                    p.x = this.w / 2;
                    p.y = this.h / 3;
                }
                p.px = p.x;
                p.py = p.y;
                this.nanHits++;
                continue;
            }
            if (p.x > lim) {
                p.x = lim;
                p.px = lim;
            }
            else if (p.x < -lim) {
                p.x = -lim;
                p.px = -lim;
            }
            if (p.y > lim) {
                p.y = lim;
                p.py = lim;
            }
            else if (p.y < -lim) {
                p.y = -lim;
                p.py = -lim;
            }
        }
    }
    /** Points vs. walls + static circles + static boxes (with friction). */
    collide(_dt) {
        const pts = this.points;
        const m = config_1.CFG.WALL_MARGIN;
        for (let i = 0; i < pts.length; i++) {
            const p = pts[i];
            if (p.pinned)
                continue;
            const e = p.r > 6 ? config_1.CFG.BALL_BOUNCE : 0.05; // heavy balls bounce
            const f = config_1.CFG.REST_FRICTION;
            // walls
            if (p.x < m + p.r)
                this.resolve(p, 1, 0, m + p.r - p.x, e, f);
            else if (p.x > this.w - m - p.r)
                this.resolve(p, -1, 0, p.x - (this.w - m - p.r), e, f);
            if (p.y < m + p.r)
                this.resolve(p, 0, 1, m + p.r - p.y, e, f);
            else if (p.y > this.h - m - p.r)
                this.resolve(p, 0, -1, p.y - (this.h - m - p.r), e, f);
            // static circles
            for (const c of this.statics) {
                const dx = p.x - c.x, dy = p.y - c.y;
                const R = c.r + p.r;
                const d2 = dx * dx + dy * dy;
                if (d2 >= R * R)
                    continue;
                const d = Math.sqrt(d2);
                if (d < 1e-6) {
                    this.resolve(p, 0, -1, R, e, f);
                    continue;
                } // guard: at center
                this.resolve(p, dx / d, dy / d, R - d, e, f);
            }
            // static boxes (AABB)
            for (const b of this.staticBoxes) {
                const bx0 = b.x, by0 = b.y, bx1 = b.x + b.w, by1 = b.y + b.h;
                if (p.x > bx0 && p.x < bx1 && p.y > by0 && p.y < by1) {
                    const dl = p.x - bx0, dr = bx1 - p.x, dtp = p.y - by0, db = by1 - p.y;
                    const min = Math.min(dl, dr, dtp, db);
                    if (min === dl)
                        this.resolve(p, -1, 0, dl + p.r, e, f);
                    else if (min === dr)
                        this.resolve(p, 1, 0, dr + p.r, e, f);
                    else if (min === dtp)
                        this.resolve(p, 0, -1, dtp + p.r, e, f);
                    else
                        this.resolve(p, 0, 1, db + p.r, e, f);
                }
                else {
                    const cx = Math.max(bx0, Math.min(p.x, bx1));
                    const cy = Math.max(by0, Math.min(p.y, by1));
                    const dx = p.x - cx, dy = p.y - cy;
                    const d2 = dx * dx + dy * dy;
                    if (d2 >= p.r * p.r)
                        continue;
                    const d = Math.sqrt(d2);
                    if (d < 1e-6) {
                        this.resolve(p, 0, -1, p.r, e, f);
                        continue;
                    }
                    this.resolve(p, dx / d, dy / d, p.r - d, e, f);
                }
            }
        }
    }
    /** Push out along (nx,ny) by `push`, project velocity: bounce + friction.
     *  The position push itself must NEVER create velocity (px is always
     *  rewritten) — leaving it would inject a phantom outward velocity every
     *  contact frame and sustain perpetual "rattle" on static shapes.
     *  Restitution is scaled down for slow impacts so resting contact settles. */
    resolve(p, nx, ny, push, e, f) {
        if (push <= 0)
            return;
        const vx = p.x - p.px, vy = p.y - p.py;
        p.x += nx * push;
        p.y += ny * push;
        const vn = vx * nx + vy * ny;
        const vtx = vx - vn * nx, vty = vy - vn * ny;
        let bounce = 0;
        if (vn < 0) {
            const impact = -vn;
            const eEff = impact < 40 ? e * (impact / 40) : e;
            bounce = -vn * eEff;
        }
        const nvx = vtx * (1 - f) + nx * bounce;
        const nvy = vty * (1 - f) + ny * bounce;
        p.px = p.x - nvx;
        p.py = p.y - nvy;
    }
    // -------------------------------------------------------------- utilities
    /** Total kinetic energy (0.5 * |v|^2 summed over free points, v in px/s). */
    kineticEnergy() {
        let ke = 0;
        const invDt = 1 / this.dt;
        for (const p of this.points) {
            if (p.pinned)
                continue;
            const vx = (p.x - p.px) * invDt, vy = (p.y - p.py) * invDt;
            ke += 0.5 * (vx * vx + vy * vy);
        }
        return ke;
    }
    /** All coordinates finite and within sane bounds? */
    isSane() {
        for (const p of this.points) {
            if (!isFinite(p.x) || !isFinite(p.y) || !isFinite(p.px) || !isFinite(p.py))
                return false;
            if (Math.abs(p.x) > 1e5 || Math.abs(p.y) > 1e5)
                return false;
        }
        return true;
    }
    /** Slice every constraint crossed by segment (x1,y1)-(x2,y2). Returns cuts. */
    cutSegment(x1, y1, x2, y2) {
        let n = 0;
        for (const c of this.cons) {
            if (c.dead)
                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)) {
                c.dead = true;
                n++;
            }
        }
        return n;
    }
    /** Drop torn constraints, remap indices, fix object lists. */
    pruneDead() {
        if (!this.cons.some(c => c.dead))
            return;
        const old = this.cons;
        const map = new Array(old.length).fill(-1);
        const live = [];
        for (let i = 0; i < old.length; i++) {
            if (!old[i].dead) {
                map[i] = live.length;
                live.push(old[i]);
            }
        }
        for (const o of this.objs)
            o.cons = o.cons.map(i => map[i]).filter(i => i >= 0);
        this.cons = live;
    }
    /** Nearest free point to (x,y) within radius (radius grows with point size). */
    nearestPoint(x, y, radius) {
        let best = -1, bd = Infinity;
        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;
            const reach = radius + p.r;
            if (d2 < reach * reach && d2 < bd) {
                bd = d2;
                best = i;
            }
        }
        return best;
    }
}
exports.Engine = Engine;
// Standard segment intersection (orientation test).
function segSeg(ax, ay, bx, by, cx, cy, dx, dy) {
    const o1 = orient(ax, ay, bx, by, cx, cy);
    const o2 = orient(ax, ay, bx, by, dx, dy);
    const o3 = orient(cx, cy, dx, dy, ax, ay);
    const o4 = orient(cx, cy, dx, dy, bx, by);
    return o1 !== o2 && o3 !== o4;
}
function orient(ax, ay, bx, by, cx, cy) {
    const v = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
    if (v > 1e-9)
        return 1;
    if (v < -1e-9)
        return -1;
    return 0;
}
};

// ── module: src/factories.ts ──
__mods["src/factories.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.spawnRope = spawnRope;
exports.spawnCloth = spawnCloth;
exports.spawnBlob = spawnBlob;
exports.spawnBox = spawnBox;
exports.spawnBall = spawnBall;
exports.hueOf = hueOf;
// ============================================================================
// FACTORIES — build rope / cloth / blob / box / ball from engine calls
// ============================================================================
const config_1 = require("./config");
function ringPath(n, r, shape) {
    const out = [];
    if (shape === 'circle') {
        for (let i = 0; i < n; i++) {
            const a = (i / n) * Math.PI * 2 - Math.PI / 2;
            out.push([Math.cos(a) * r, Math.sin(a) * r]);
        }
    }
    else {
        const h = r * 0.9, s = h * 2;
        const per = Math.max(2, Math.round(n / 4));
        for (let i = 0; i < n; i++) {
            const t = (i / n) * s * 4; // perimeter parameter
            let x = 0, y = 0;
            if (t < s) {
                x = -h + t;
                y = -h;
            }
            else if (t < 2 * s) {
                x = h;
                y = -h + (t - s);
            }
            else if (t < 3 * s) {
                x = h - (t - 2 * s);
                y = h;
            }
            else {
                x = -h;
                y = h - (t - 3 * s);
            }
            out.push([x, y]);
        }
    }
    return out;
}
/** Ring of points + edge constraints + internal braces; returns shape-memory offsets. */
function buildRing(e, cx, cy, r, shape, kind, hue) {
    const n = shape === 'circle' ? 16 : 20;
    const path = ringPath(n, r, shape);
    const pts = [];
    for (let i = 0; i < n; i++)
        pts.push(e.addPoint(cx + path[i][0], cy + path[i][1], 1.5, false, -1));
    const cons = [];
    // edges
    for (let i = 0; i < n; i++)
        cons.push(e.addConstraint(pts[i], pts[(i + 1) % n], 1, kind));
    // internal braces: every 4th chord
    for (let i = 0; i < n; i += 4)
        cons.push(e.addConstraint(pts[i], pts[(i + 5) % n], 0.9, kind));
    // cross bracing for stiffness (ring + opposite arc)
    for (let i = 0; i < n; i += 2)
        cons.push(e.addConstraint(pts[i], pts[(i + n / 2) % n], 0.35, kind));
    const id = e.nextId;
    const o = { id, kind, pts, cons, cols: 0, rows: 0, hue, ring: [...pts],
        shape, shapeX: path.map(p => p[0]), shapeY: path.map(p => p[1]) };
    e.addObj(kind, pts, cons, hue, [...pts], shape, o.shapeX, o.shapeY);
    return e.objs[e.objs.length - 1];
}
// ------------------------------------------------------------------ ROPE
function spawnRope(e, x1, y1, x2, y2) {
    const dx = x2 - x1, dy = y2 - y1;
    const len = Math.max(Math.hypot(dx, dy), 20);
    const seg = 16;
    const n = Math.max(3, Math.min(60, Math.round(len / seg) + 1));
    const hue = config_1.CFG.HUES.rope;
    const id = e.nextId;
    const pts = [];
    for (let i = 0; i < n; i++) {
        const t = i / (n - 1);
        pts.push(e.addPoint(x1 + dx * t, y1 + dy * t, 1.5, i === 0, id));
    }
    const cons = [];
    for (let i = 0; i < n - 1; i++)
        cons.push(e.addConstraint(pts[i], pts[i + 1], 1, 'rope'));
    e.addObj('rope', pts, cons, hue);
    return e.objs[e.objs.length - 1];
}
// ------------------------------------------------------------------ CLOTH
/** Pin mode: 'edge' = whole top row pinned, 'corners' = both top corners only. */
function spawnCloth(e, x1, y1, x2, y2, pinEdge) {
    let x0 = Math.min(x1, x2), x1b = Math.max(x1, x2);
    let y0 = Math.min(y1, y2), y1b = Math.max(y1, y2);
    const w = Math.max(x1b - x0, 40), h = Math.max(y1b - y0, 40);
    const cols = Math.max(4, Math.min(30, Math.round(w / 24) + 1));
    const rows = Math.max(3, Math.min(24, Math.round(h / 24) + 1));
    const hue = config_1.CFG.HUES.cloth;
    const id = e.nextId;
    const pts = [];
    for (let r = 0; r < rows; r++) {
        for (let c = 0; c < cols; c++) {
            const x = x0 + (c / (cols - 1)) * w;
            const y = y0 + (r / (rows - 1)) * h;
            const pinned = pinEdge ? r === 0 : (r === 0 && (c === 0 || c === cols - 1));
            pts.push(e.addPoint(x, y, 1.5, pinned, id));
        }
    }
    const idx = (r, c) => r * cols + c;
    const cons = [];
    // structural
    for (let r = 0; r < rows; r++)
        for (let c = 0; c < cols; c++) {
            if (c + 1 < cols)
                cons.push(e.addConstraint(pts[idx(r, c)], pts[idx(r, c + 1)], 1, 'cloth'));
            if (r + 1 < rows)
                cons.push(e.addConstraint(pts[idx(r, c)], pts[idx(r + 1, c)], 1, 'cloth'));
        }
    // shear
    for (let r = 0; r < rows - 1; r++)
        for (let c = 0; c < cols - 1; c++) {
            cons.push(e.addConstraint(pts[idx(r, c)], pts[idx(r + 1, c + 1)], 0.9, 'cloth'));
            cons.push(e.addConstraint(pts[idx(r, c + 1)], pts[idx(r + 1, c)], 0.9, 'cloth'));
        }
    e.addObj('cloth', pts, cons, hue, [], 'circle', [], [], cols, rows);
    return e.objs[e.objs.length - 1];
}
// ------------------------------------------------------- SOFT BODIES / BALL
function spawnBlob(e, x, y, r = 46) {
    return buildRing(e, x, y, r, 'circle', 'blob', config_1.CFG.HUES.blob);
}
function spawnBox(e, x, y, r = 44) {
    return buildRing(e, x, y, r, 'box', 'box', config_1.CFG.HUES.box);
}
function spawnBall(e, x, y, r = config_1.CFG.BALL_RADIUS, hue) {
    const h = hue ?? config_1.CFG.HUES.ball;
    const id = e.nextId;
    const pi = e.addPoint(x, y, r, false, id);
    e.addObj('ball', [pi], [], h);
    return e.objs[e.objs.length - 1];
}
function hueOf(kind) { return config_1.CFG.HUES[kind]; }
};

// ── module: src/render.ts ──
__mods["src/render.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Renderer = void 0;
// ============================================================================
// RENDERER — Canvas 2D: trails, statics, objects, stress, FX, previews
// ============================================================================
const config_1 = require("./config");
class Renderer {
    constructor(canvas) {
        this.canvas = canvas;
        this.view = { w: 1280, h: 720, dpr: 1 };
        this.particles = [];
        this.ghosts = [];
        this.stressCache = [];
        this.bgGrad = null;
        this.bgKey = '';
        this.firstFrame = true;
        this.ctx = canvas.getContext('2d');
    }
    resize(w, h, dpr) {
        this.view.w = w;
        this.view.h = h;
        this.view.dpr = dpr;
        this.canvas.width = Math.round(w * dpr);
        this.canvas.height = Math.round(h * dpr);
        this.bgKey = '';
    }
    // -------------------------------------------------------------- effects
    spawnSparks(x, y, hue, n = 10) {
        for (let i = 0; i < n; i++) {
            const a = Math.random() * Math.PI * 2;
            const s = 40 + Math.random() * 160;
            this.particles.push({
                x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s - 40,
                life: 0, maxLife: 0.35 + Math.random() * 0.3,
                hue, size: 1.5 + Math.random() * 2,
            });
        }
        if (this.particles.length > 400)
            this.particles.splice(0, this.particles.length - 400);
    }
    spawnGhosts(obj, points) {
        for (const i of obj.pts) {
            const p = points[i];
            this.ghosts.push({ x: p.x, y: p.y, life: 0.5, hue: obj.hue });
        }
        if (this.ghosts.length > 300)
            this.ghosts.splice(0, this.ghosts.length - 300);
    }
    updateFX(dt) {
        for (const p of this.particles) {
            p.life += dt;
            p.x += p.vx * dt;
            p.y += p.vy * dt;
            p.vy += 500 * dt;
            p.vx *= 0.98;
        }
        this.particles = this.particles.filter(p => p.life < p.maxLife);
        for (const g of this.ghosts)
            g.life -= dt;
        this.ghosts = this.ghosts.filter(g => g.life > 0);
    }
    // -------------------------------------------------------------- drawing
    stressColor(t) {
        const i = Math.min(63, Math.max(0, (t * 63) | 0));
        let c = this.stressCache[i];
        if (!c) {
            c = `rgb(${lerp(61, 255, i / 63) | 0},${lerp(220, 77, i / 63) | 0},${lerp(132, 109, i / 63) | 0})`;
            this.stressCache[i] = c;
        }
        return c;
    }
    render(e, ui, nowSec) {
        const ctx = this.ctx;
        const { w, h, dpr } = this.view;
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        // ---- background: full clear or trail fade ---------------------------
        if (this.firstFrame) {
            this.firstFrame = false;
            this.paintBackground(ctx);
        }
        if (ui.trail) {
            ctx.globalCompositeOperation = 'source-over';
            ctx.fillStyle = `rgba(11, 14, 20, ${config_1.CFG.TRAIL_ALPHA})`;
            ctx.fillRect(0, 0, w, h);
        }
        else {
            this.paintBackground(ctx);
        }
        this.drawWalls(ctx, w, h);
        this.drawStatics(ctx, e);
        this.drawObjects(ctx, e, ui, nowSec);
        this.drawPoints(ctx, e, ui);
        this.drawGrab(ctx, e, ui);
        if (ui.preview)
            this.drawPreview(ctx, ui.preview);
        this.drawCutFX(ctx, ui);
        this.drawParticles(ctx);
        this.drawGhosts(ctx);
        if (ui.paused)
            this.drawPaused(ctx, w, h);
    }
    paintBackground(ctx) {
        const { w, h } = this.view;
        const key = `${w}x${h}`;
        if (this.bgKey !== key || !this.bgGrad) {
            const g = ctx.createLinearGradient(0, 0, 0, h);
            g.addColorStop(0, '#0d1119');
            g.addColorStop(1, '#0a0d13');
            this.bgGrad = g;
            this.bgKey = key;
        }
        ctx.fillStyle = this.bgGrad;
        ctx.fillRect(0, 0, w, h);
    }
    drawWalls(ctx, w, h) {
        const m = config_1.CFG.WALL_MARGIN;
        ctx.strokeStyle = 'rgba(120, 160, 210, 0.16)';
        ctx.lineWidth = 1.5;
        ctx.strokeRect(m - 3, m - 3, w - (m - 3) * 2, h - (m - 3) * 2);
        // floor glow
        ctx.strokeStyle = 'rgba(140, 190, 255, 0.35)';
        ctx.lineWidth = 2.5;
        ctx.beginPath();
        ctx.moveTo(m, h - m);
        ctx.lineTo(w - m, h - m);
        ctx.stroke();
    }
    drawStatics(ctx, e) {
        for (const c of e.statics) {
            ctx.beginPath();
            ctx.arc(c.x, c.y, c.r, 0, Math.PI * 2);
            ctx.fillStyle = `hsla(${c.hue}, 60%, 55%, 0.13)`;
            ctx.fill();
            ctx.strokeStyle = `hsla(${c.hue}, 75%, 65%, 0.55)`;
            ctx.lineWidth = 2;
            ctx.stroke();
        }
        for (const b of e.staticBoxes) {
            ctx.fillStyle = `hsla(${b.hue}, 55%, 55%, 0.13)`;
            ctx.fillRect(b.x, b.y, b.w, b.h);
            ctx.strokeStyle = `hsla(${b.hue}, 70%, 65%, 0.5)`;
            ctx.lineWidth = 2;
            ctx.strokeRect(b.x, b.y, b.w, b.h);
        }
    }
    drawObjects(ctx, e, ui, _now) {
        for (const o of e.objs) {
            if (o.kind === 'cloth')
                this.drawCloth(ctx, e, o, ui);
            else if (o.kind === 'rope')
                this.drawChain(ctx, e, o, ui);
            else if (o.kind === 'ball')
                this.drawBall(ctx, e, o, ui);
            else
                this.drawSoft(ctx, e, o, ui);
        }
    }
    /** Cloth: filled cells + grid lines (dots hidden by design). */
    drawCloth(ctx, e, o, ui) {
        const pts = e.points;
        const { cols, rows } = o;
        if (cols === 0 || rows === 0)
            return;
        const idx = (r, c) => o.pts[r * cols + c];
        // filled fabric
        ctx.beginPath();
        for (let r = 0; r < rows - 1; r++) {
            for (let c = 0; c < cols - 1; c++) {
                const a = pts[idx(r, c)], b = pts[idx(r, c + 1)];
                const d = pts[idx(r + 1, c + 1)], f = pts[idx(r + 1, c)];
                ctx.moveTo(a.x, a.y);
                ctx.lineTo(b.x, b.y);
                ctx.lineTo(d.x, d.y);
                ctx.lineTo(f.x, f.y);
                ctx.closePath();
            }
        }
        ctx.fillStyle = `hsla(${o.hue}, 85%, 60%, 0.16)`;
        ctx.fill();
        // grid lines (only live constraints)
        ctx.lineWidth = 1.2;
        ctx.beginPath();
        for (const ci of o.cons) {
            const c = e.cons[ci];
            if (!c || c.dead)
                continue;
            const a = pts[c.a], b = pts[c.b];
            if (c.kind === 'cloth') {
                ctx.moveTo(a.x, a.y);
                ctx.lineTo(b.x, b.y);
            }
        }
        ctx.strokeStyle = `hsla(${o.hue}, 90%, 70%, 0.6)`;
        ctx.stroke();
        // stress overlay
        if (ui.stress)
            this.drawStress(ctx, e, o, o.hue);
    }
    /** Rope / chain: single stroked polyline. */
    drawChain(ctx, e, o, ui) {
        const pts = e.points;
        // walk contiguous live segments
        ctx.lineWidth = 2.5;
        ctx.beginPath();
        let started = false;
        for (let k = 0; k < o.pts.length; k++) {
            const i = o.pts[k];
            if (k > 0) {
                // find if a constraint connects o.pts[k-1]..o.pts[k]
                const ci = o.cons.find(c => {
                    const cc = e.cons[c];
                    return cc && !cc.dead && ((cc.a === o.pts[k - 1] && cc.b === i) || (cc.b === o.pts[k - 1] && cc.a === i));
                });
                if (ci === undefined)
                    started = false;
            }
            const p = pts[i];
            if (!started) {
                ctx.moveTo(p.x, p.y);
                started = true;
            }
            else
                ctx.lineTo(p.x, p.y);
        }
        ctx.strokeStyle = `hsla(${o.hue}, 90%, 65%, 0.9)`;
        ctx.stroke();
        if (ui.stress)
            this.drawStress(ctx, e, o, o.hue);
    }
    drawSoft(ctx, e, o, ui) {
        const pts = e.points;
        const ring = o.ring;
        if (ring.length < 3)
            return;
        ctx.beginPath();
        ctx.moveTo(pts[ring[0]].x, pts[ring[0]].y);
        for (let i = 1; i < ring.length; i++)
            ctx.lineTo(pts[ring[i]].x, pts[ring[i]].y);
        ctx.closePath();
        ctx.fillStyle = `hsla(${o.hue}, 80%, 65%, 0.22)`;
        ctx.fill();
        ctx.strokeStyle = `hsla(${o.hue}, 90%, 70%, 0.85)`;
        ctx.lineWidth = 2.5;
        ctx.stroke();
        if (ui.stress)
            this.drawStress(ctx, e, o, o.hue);
    }
    drawBall(ctx, e, o, _ui) {
        const p = e.points[o.pts[0]];
        if (!p)
            return;
        const g = ctx.createRadialGradient(p.x - p.r * 0.35, p.y - p.r * 0.35, p.r * 0.15, p.x, p.y, p.r);
        g.addColorStop(0, `hsla(${o.hue}, 90%, 82%, 0.95)`);
        g.addColorStop(1, `hsla(${o.hue}, 80%, 52%, 0.9)`);
        ctx.beginPath();
        ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
        ctx.fillStyle = g;
        ctx.fill();
    }
    /** Color live constraints of one object by stretch (green→red). */
    drawStress(ctx, e, o, hue) {
        const pts = e.points;
        for (const ci of o.cons) {
            const c = e.cons[ci];
            if (!c || c.dead)
                continue;
            const a = pts[c.a], b = pts[c.b];
            const d = Math.hypot(b.x - a.x, b.y - a.y);
            const t = Math.min(1, Math.max(0, (d / c.rest - 1) / 0.35));
            ctx.strokeStyle = t > 0.04 ? this.stressColor(t) : `hsla(${hue}, 85%, 62%, 0.5)`;
            ctx.lineWidth = 1.6;
            ctx.beginPath();
            ctx.moveTo(a.x, a.y);
            ctx.lineTo(b.x, b.y);
            ctx.stroke();
        }
    }
    drawPoints(ctx, e, ui) {
        const showDots = ui.showDots;
        if (!showDots && !ui.showPins)
            return;
        for (const p of e.points) {
            if (p.pinned) {
                if (!ui.showPins)
                    continue;
                ctx.fillStyle = '#ffd166';
                ctx.beginPath();
                ctx.arc(p.x, p.y, 4, 0, Math.PI * 2);
                ctx.fill();
                ctx.strokeStyle = 'rgba(0,0,0,0.6)';
                ctx.lineWidth = 1;
                ctx.stroke();
            }
            else if (showDots) {
                ctx.fillStyle = 'rgba(220, 235, 250, 0.5)';
                ctx.beginPath();
                ctx.arc(p.x, p.y, config_1.CFG.POINT_DOTS, 0, Math.PI * 2);
                ctx.fill();
            }
        }
    }
    drawGrab(ctx, e, ui) {
        if (!ui.grab || !ui.grab.active)
            return;
        const g = ui.grab;
        const p = e.points[g.p];
        // link from cursor to point
        ctx.strokeStyle = 'rgba(255, 255, 255, 0.35)';
        ctx.lineWidth = 1;
        ctx.setLineDash([4, 4]);
        ctx.beginPath();
        ctx.moveTo(g.x, g.y);
        if (p)
            ctx.lineTo(p.x, p.y);
        ctx.stroke();
        ctx.setLineDash([]);
        // ring at cursor
        ctx.beginPath();
        ctx.arc(g.x, g.y, config_1.CFG.GRAB_RADIUS * 0.5, 0, Math.PI * 2);
        ctx.strokeStyle = 'rgba(120, 220, 255, 0.8)';
        ctx.lineWidth = 2;
        ctx.stroke();
        if (p) {
            ctx.beginPath();
            ctx.arc(p.x, p.y, 5, 0, Math.PI * 2);
            ctx.fillStyle = '#78dcff';
            ctx.fill();
        }
    }
    drawPreview(ctx, pv) {
        ctx.setLineDash([6, 5]);
        ctx.lineWidth = 1.5;
        ctx.strokeStyle = 'rgba(200, 220, 240, 0.5)';
        if (pv.kind === 'rope') {
            ctx.beginPath();
            ctx.moveTo(pv.x1, pv.y1);
            ctx.lineTo(pv.x2, pv.y2);
            ctx.stroke();
            ctx.beginPath();
            ctx.arc(pv.x1, pv.y1, 4, 0, Math.PI * 2);
            ctx.fillStyle = '#4cc9f0';
            ctx.fill();
        }
        else {
            ctx.strokeRect(Math.min(pv.x1, pv.x2), Math.min(pv.y1, pv.y2), Math.abs(pv.x2 - pv.x1), Math.abs(pv.y2 - pv.y1));
        }
        ctx.setLineDash([]);
    }
    drawCutFX(ctx, ui) {
        if (!ui.cutting || !ui.cutLast || !ui.mouse.down)
            return;
        const l = ui.cutLast, m = ui.mouse;
        ctx.strokeStyle = 'rgba(255, 90, 120, 0.9)';
        ctx.lineWidth = 2;
        ctx.beginPath();
        ctx.moveTo(l.x, l.y);
        ctx.lineTo(m.x, m.y);
        ctx.stroke();
        // knife glow at tip
        const g = ctx.createRadialGradient(m.x, m.y, 0, m.x, m.y, config_1.CFG.CUT_RADIUS * 1.6);
        g.addColorStop(0, 'rgba(255, 90, 120, 0.5)');
        g.addColorStop(1, 'rgba(255, 90, 120, 0)');
        ctx.fillStyle = g;
        ctx.beginPath();
        ctx.arc(m.x, m.y, config_1.CFG.CUT_RADIUS * 1.6, 0, Math.PI * 2);
        ctx.fill();
    }
    drawParticles(ctx) {
        for (const p of this.particles) {
            const t = 1 - p.life / p.maxLife;
            ctx.globalAlpha = t;
            ctx.fillStyle = `hsl(${p.hue}, 95%, 70%)`;
            ctx.beginPath();
            ctx.arc(p.x, p.y, p.size * t + 0.4, 0, Math.PI * 2);
            ctx.fill();
        }
        ctx.globalAlpha = 1;
    }
    drawGhosts(ctx) {
        for (const g of this.ghosts) {
            ctx.globalAlpha = g.life * 0.6;
            ctx.fillStyle = `hsl(${g.hue}, 90%, 70%)`;
            ctx.beginPath();
            ctx.arc(g.x, g.y, 2, 0, Math.PI * 2);
            ctx.fill();
        }
        ctx.globalAlpha = 1;
    }
    drawPaused(ctx, w, h) {
        ctx.fillStyle = 'rgba(10, 13, 20, 0.25)';
        ctx.fillRect(0, 0, w, h);
        ctx.fillStyle = 'rgba(200, 220, 245, 0.9)';
        ctx.font = '600 15px "Segoe UI", system-ui, sans-serif';
        ctx.textAlign = 'center';
        ctx.fillText('PAUSED — → to step, Space to resume', w / 2, 44);
        ctx.textAlign = 'left';
    }
}
exports.Renderer = Renderer;
function lerp(a, b, t) { return a + (b - a) * t; }
};

// ── module: src/interaction.ts ──
__mods["src/interaction.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Interaction = void 0;
exports.setClothPinEdge = setClothPinEdge;
// ============================================================================
// INTERACTION — mouse + keyboard: grab/throw, cut, pin, spawn tools
// ============================================================================
const config_1 = require("./config");
const factories_1 = require("./factories");
class Interaction {
    constructor(canvas, engine, renderer, cb = {}) {
        this.canvas = canvas;
        this.engine = engine;
        this.renderer = renderer;
        this.cb = cb;
        this.tool = 'grab';
        this.paused = false;
        this.mouse = { x: 0, y: 0, down: false };
        this.grab = { active: false, p: -1, x: 0, y: 0, vx: 0, vy: 0, prevX: 0, prevY: 0 };
        this.cut = { active: false, x: 0, y: 0, prevX: 0, prevY: 0 };
        this.preview = null;
        this.spawnPos = null;
        this.lastCut = { x: 0, y: 0 };
        this.lastT = performance.now();
        this.lastClick = null;
        this.bind();
    }
    // ----------------------------------------------------------------- binding
    bind() {
        const cv = this.canvas;
        cv.addEventListener('contextmenu', e => e.preventDefault());
        cv.addEventListener('mousedown', e => this.onDown(e));
        window.addEventListener('mousemove', e => this.onMove(e));
        window.addEventListener('mouseup', e => this.onUp(e));
        window.addEventListener('keydown', e => this.onKey(e));
        window.addEventListener('blur', () => { if (this.grab.active)
            this.releaseGrab(true); });
    }
    pos(e) {
        const r = this.canvas.getBoundingClientRect();
        return { x: e.clientX - r.left, y: e.clientY - r.top };
    }
    // ----------------------------------------------------------------- mouse
    onDown(e) {
        const { x, y } = this.pos(e);
        this.mouse = { x, y, down: true };
        this.lastT = performance.now();
        // shift+click = toggle pin (always)
        if (e.shiftKey) {
            const i = this.engine.nearestPoint(x, y, config_1.CFG.GRAB_RADIUS);
            if (i >= 0) {
                const p = this.engine.points[i];
                this.engine.setPin(i, !p.pinned);
                if (!p.pinned) {
                    p.px = p.x;
                    p.py = p.y;
                }
                this.renderer.spawnSparks(p.x, p.y, p.pinned ? 48 : 200, 8);
            }
            this.mouse.down = false;
            return;
        }
        if (e.button === 2 || this.tool === 'cut') {
            // cut mode
            this.cut = { active: true, x, y, prevX: x, prevY: y };
            this.lastCut = { x, y };
            cv_class(this.canvas, 'cutting', true);
            return;
        }
        // left button
        if (this.tool === 'rope' || this.tool === 'cloth') {
            this.preview = { kind: this.tool, x1: x, y1: y, x2: x, y2: y };
            this.spawnPos = { x, y };
        }
        else if (this.tool === 'blob' || this.tool === 'box' || this.tool === 'ball') {
            this.spawnPos = { x, y };
        }
        else { // grab
            const i = this.engine.nearestPoint(x, y, config_1.CFG.GRAB_RADIUS);
            if (i >= 0) {
                this.grab = { active: true, p: i, x, y, vx: 0, vy: 0, prevX: x, prevY: y };
                this.engine.grab = this.grab;
                this.canvas.classList.add('grabbing');
            }
        }
    }
    onUpLeft(x, y) {
        // double-tap on a point removes its object
        this.tryRemoveAt(x, y);
    }
    onMove(e) {
        const { x, y } = this.pos(e);
        const now = performance.now();
        const dt = Math.max((now - this.lastT) / 1000, 1e-4);
        this.lastT = now;
        this.mouse.x = x;
        this.mouse.y = y;
        if (this.preview) {
            this.preview.x2 = x;
            this.preview.y2 = y;
        }
        else if (this.grab.active) {
            const p = this.engine.points[this.grab.p];
            if (this.paused) {
                // paused: reposition under cursor with zero velocity (scene editing)
                if (p) {
                    p.x = x;
                    p.y = y;
                    p.px = x;
                    p.py = y;
                }
                this.grab.vx = 0;
                this.grab.vy = 0;
            }
            else {
                // smoothed cursor velocity (for throw)
                const vx = (x - this.grab.prevX) / dt;
                const vy = (y - this.grab.prevY) / dt;
                this.grab.vx = this.grab.vx * 0.6 + vx * 0.4;
                this.grab.vy = this.grab.vy * 0.6 + vy * 0.4;
            }
            this.grab.x = x;
            this.grab.y = y;
            this.grab.prevX = x;
            this.grab.prevY = y;
        }
        if (this.cut.active) {
            this.cut.prevX = this.cut.x;
            this.cut.prevY = this.cut.y;
            this.cut.x = x;
            this.cut.y = y;
            const n = this.engine.cutSegment(this.cut.prevX, this.cut.prevY, x, y);
            if (n > 0) {
                this.renderer.spawnSparks(x, y, 350, 6);
                this.engine.pruneDead();
            }
            this.lastCut = { x: this.cut.x, y: this.cut.y };
        }
    }
    onUp(e) {
        const wasDown = this.mouse.down;
        this.mouse.down = false;
        if (this.preview && this.spawnPos && wasDown) {
            const p0 = this.spawnPos, p1 = { x: this.mouse.x, y: this.mouse.y };
            if (this.preview.kind === 'rope') {
                if (Math.hypot(p1.x - p0.x, p1.y - p0.y) > 14)
                    (0, factories_1.spawnRope)(this.engine, p0.x, p0.y, p1.x, p1.y);
            }
            else {
                const w = Math.abs(p1.x - p0.x), h = Math.abs(p1.y - p0.y);
                if (w > 24 && h > 24)
                    (0, factories_1.spawnCloth)(this.engine, p0.x, p0.y, p1.x, p1.y, clothPinEdge());
            }
            this.cb.onCounts?.();
        }
        else if (this.spawnPos && wasDown && this.tool !== 'grab') {
            // single-click tools: spawn on release at pointer
            const { x, y } = this.spawnPos;
            if (this.tool === 'blob')
                (0, factories_1.spawnBlob)(this.engine, x, y);
            else if (this.tool === 'box')
                (0, factories_1.spawnBox)(this.engine, x, y);
            else if (this.tool === 'ball')
                (0, factories_1.spawnBall)(this.engine, x, y);
            this.cb.onCounts?.();
        }
        else if (this.grab.active) {
            this.releaseGrab(false);
            if (this.tool === 'grab')
                this.onUpLeft(this.mouse.x, this.mouse.y);
        }
        this.preview = null;
        this.spawnPos = null;
        this.cut.active = false;
        cv_class(this.canvas, 'cutting', false);
    }
    releaseGrab(silent) {
        this.grab.active = false;
        this.engine.grab = null;
        this.canvas.classList.remove('grabbing');
        if (!silent) {
            // throw! (free points only — pins keep their new anchor)
            const i = this.grab.p;
            const p = this.engine.points[i];
            if (p && !p.pinned) {
                const vx = clamp(this.grab.vx, -config_1.CFG.THROW_CAP, config_1.CFG.THROW_CAP) * this.engine.dt;
                const vy = clamp(this.grab.vy, -config_1.CFG.THROW_CAP, config_1.CFG.THROW_CAP) * this.engine.dt;
                p.px = p.x - vx;
                p.py = p.y - vy;
                this.renderer.spawnSparks(p.x, p.y, 197, 6);
            }
        }
    }
    /** Double-tap (within 320ms / 10px) on an object's point removes the object.
     *  Implemented explicitly (no reliance on native dblclick from synthetic events). */
    tryRemoveAt(x, y) {
        const i = this.engine.nearestPoint(x, y, config_1.CFG.GRAB_RADIUS * 1.5);
        if (i < 0)
            return false;
        const now = performance.now();
        const lc = this.lastClick;
        if (lc && now - lc.t < 320 && Math.hypot(x - lc.x, y - lc.y) < 10) {
            this.lastClick = null;
            this.removeObjectAt(i);
            return true;
        }
        this.lastClick = { x, y, t: now, p: i };
        return false;
    }
    removeObjectAt(i) {
        const p = this.engine.points[i];
        if (!p || p.obj < 0)
            return;
        const obj = this.engine.objs.find(o => o.id === p.obj);
        if (!obj)
            return;
        this.renderer.spawnGhosts(obj, this.engine.points);
        this.engine.removeObj(p.obj);
        this.cb.onCounts?.();
    }
    // --------------------------------------------------------------- keyboard
    onKey(e) {
        const k = e.key;
        if (k === ' ' || k === 'Spacebar') {
            e.preventDefault();
            this.setPaused(!this.paused);
        }
        else if ((k === 'ArrowRight' || k === 'ArrowUp') && this.paused) {
            e.preventDefault();
            this.engine.step();
        }
        else if (k === 'r' || k === 'R') {
            this.cb.onReset?.();
        }
        else if (k === 'c' || k === 'C') {
            this.setTool('cut');
        }
        else if (k === 'g' || k === 'G') {
            this.setTool('grab');
        }
        else if (k >= '1' && k <= '5') {
            const tools = ['rope', 'cloth', 'blob', 'box', 'ball'];
            this.setTool(tools[+k - 1]);
        }
    }
    // ------------------------------------------------------------- public API
    setTool(t) {
        if (this.tool === t && t !== 'cut')
            return;
        this.tool = t;
        this.cb.onTool?.(t);
    }
    setPaused(p) {
        this.paused = p;
        this.cb.onPaused?.(p);
    }
    reset() { this.cb.onReset?.(); }
    clearAll() {
        this.engine.clear();
        this.renderer.particles.length = 0;
        this.cb.onCounts?.();
    }
    /** Live UI snapshot for the renderer. */
    uiForRender() {
        return {
            grab: this.grab, mouse: this.mouse, preview: this.preview,
            paused: this.paused, cutting: this.cut.active, cutLast: this.lastCut,
        };
    }
}
exports.Interaction = Interaction;
// ------------------------------------------------------------------ helpers
function cv_class(el, name, on) { el.classList.toggle(name, on); }
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
// cloth pin-mode preference lives on main (checkbox); default edge
let pinEdgePref = true;
function setClothPinEdge(b) { pinEdgePref = b; }
function clothPinEdge() { return pinEdgePref; }
};

// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"Qwen3.8","provider":"brigid","persona":"typescript","sessionId":"2e1ca1a3-eee3-453c-94b4-d8a14b05704c","tokensIn":6163735,"tokensOut":191222,"tokensTotal":6354957,"cost":0,"turns":101,"toolCalls":118,"failedToolCalls":3,"timestamp":"2026-09-09T10:52:54.222Z"} -->