← Physics Playground results

Physics Playground

Qwen 3.6 27B MTP 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 67,140 bytes · SHA-256 44d5b268f938
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>2D Soft-Body Physics Playground</title>
<style>
/* ── css: layout ── */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  background: #0f1117;
  overflow: hidden;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', monospace;
  color: #e5e7eb;
}

#app {
  width: 100vw;
  height: 100vh;
  position: relative;
}

#canvas {
  display: block;
  width: 100%;
  height: 100%;
  cursor: crosshair;
}

#toolbar {
  position: absolute;
  bottom: 16px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 16px;
  background: rgba(15, 17, 23, 0.85);
  border: 1px solid rgba(107, 114, 128, 0.3);
  border-radius: 12px;
  backdrop-filter: blur(8px);
  z-index: 10;
  flex-wrap: wrap;
  justify-content: center;
}

.toolbar-group {
  display: flex;
  align-items: center;
  gap: 4px;
}

.toolbar-group.sliders {
  gap: 10px;
}

.toolbar-group + .toolbar-group {
  border-left: 1px solid rgba(107, 114, 128, 0.2);
  padding-left: 8px;
}

button {
  background: rgba(55, 58, 70, 0.6);
  border: 1px solid rgba(107, 114, 128, 0.3);
  border-radius: 8px;
  color: #e5e7eb;
  padding: 6px 10px;
  font-size: 16px;
  cursor: pointer;
  transition: all 0.15s;
  line-height: 1;
}

button:hover {
  background: rgba(75, 80, 100, 0.8);
  border-color: rgba(156, 163, 175, 0.5);
}

button.active {
  background: rgba(99, 102, 241, 0.4);
  border-color: rgba(129, 140, 248, 0.6);
  box-shadow: 0 0 8px rgba(99, 102, 241, 0.3);
}

button.active-stress {
  background: rgba(239, 68, 68, 0.3);
  border-color: rgba(239, 68, 68, 0.6);
}

label {
  display: flex;
  align-items: center;
  gap: 4px;
  font-size: 11px;
  color: #9ca3af;
  cursor: pointer;
}

label span {
  white-space: nowrap;
}

input[type="range"] {
  width: 60px;
  height: 4px;
  appearance: none;
  background: rgba(107, 114, 128, 0.3);
  border-radius: 2px;
  outline: none;
}

input[type="range"]::-webkit-slider-thumb {
  appearance: none;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: #6366f1;
  cursor: pointer;
}

input[type="checkbox"] {
  accent-color: #6366f1;
}

.toolbar-hint {
  width: 100%;
  text-align: center;
  font-size: 10px;
  color: #4b5563;
  margin-top: 4px;
  letter-spacing: 0.3px;
}

#spawn-preview {
  position: absolute;
  pointer-events: none;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 5;
}
</style>
</head>
<body>
<div id="app">
  <canvas id="canvas" width="1280" height="720"></canvas>
  <div id="toolbar">
    <div class="toolbar-group">
      <button id="btn-rope" title="Spawn Rope (R)">🪢</button>
      <button id="btn-cloth" title="Spawn Cloth (C)">🧥</button>
      <button id="btn-softbody" title="Spawn Soft Body (S)">🟣</button>
      <button id="btn-ball" title="Spawn Ball (B)">⚽</button>
    </div>
    <div class="toolbar-group">
      <button id="btn-pinmode" title="Pin Mode (P)">📌 Corners</button>
    </div>
    <div class="toolbar-group">
      <button id="btn-pause" title="Pause (Space)">⏸</button>
      <button id="btn-step" title="Step (→)">⏭</button>
      <button id="btn-reset" title="Reset All (Esc)">🗑</button>
    </div>
    <div class="toolbar-group sliders">
      <label title="Toggle Gravity (G)">
        <input type="checkbox" id="chk-gravity" checked="">
        <span>Gravity</span>
      </label>
      <label title="Drag strength">
        <span>G:</span>
        <input type="range" id="slider-gravity" min="0" max="2000" value="980" step="10">
      </label>
      <label title="Solver iterations">
        <span>Iter:</span>
        <input type="range" id="slider-iterations" min="1" max="20" value="6" step="1">
      </label>
      <label title="Damping">
        <span>Damp:</span>
        <input type="range" id="slider-damping" min="0" max="100" value="2" step="1">
      </label>
    </div>
    <div class="toolbar-group">
      <button id="btn-stress" title="Stress Visual (V)">🌡</button>
      <button id="btn-points" title="Show Points (T)">🔵</button>
      <button id="btn-blur" title="Motion Blur (M)">✨</button>
    </div>
    <div class="toolbar-hint">
      <span>Click: grab | Right-drag: tear | Shift+click: pin/unpin | Draw to spawn</span>
    </div>
  </div>
  <div id="spawn-preview"></div>
</div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./config":"src/config.ts","./point":"src/point.ts","./constraint":"src/constraint.ts","./spawner":"src/spawner.ts","./engine":"src/engine.ts","./renderer":"src/renderer.ts","./interaction":"src/interaction.ts"},"src/constraint.ts":{"./point":"src/point.ts"},"src/spawner.ts":{"./point":"src/point.ts","./constraint":"src/constraint.ts"},"src/engine.ts":{"./point":"src/point.ts","./constraint":"src/constraint.ts","./config":"src/config.ts"},"src/renderer.ts":{"./point":"src/point.ts","./constraint":"src/constraint.ts","./spawner":"src/spawner.ts","./engine":"src/engine.ts","./config":"src/config.ts"},"src/interaction.ts":{"./point":"src/point.ts","./constraint":"src/constraint.ts","./spawner":"src/spawner.ts","./config":"src/config.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";
// ============================================================
// Main — Application entry point, wiring everything together
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.getSim = getSim;
const config_1 = require("./config");
const point_1 = require("./point");
const spawner_1 = require("./spawner");
const engine_1 = require("./engine");
const renderer_1 = require("./renderer");
const interaction_1 = require("./interaction");
// ---- Application State ----
let app = null;
function init() {
    const canvas = document.getElementById('canvas');
    if (!canvas) {
        console.error('Canvas not found');
        return;
    }
    // Size canvas to viewport (no DPR scaling to avoid coordinate confusion)
    const rect = canvas.getBoundingClientRect();
    canvas.width = rect.width;
    canvas.height = rect.height;
    const engine = (0, engine_1.createEngine)(canvas.width, canvas.height);
    const renderer = (0, renderer_1.createRenderer)(canvas);
    const interaction = (0, interaction_1.createInteraction)(canvas);
    // ---- Static geometry ----
    const w = canvas.width, h = canvas.height;
    // Floor bump (circle) — cloth will drape over this
    engine.staticCircles.push({ x: w / 2, y: h - 130, radius: 55 });
    // Left obstacle
    engine.staticCircles.push({ x: w * 0.2, y: h - 200, radius: 30 });
    // Right obstacle
    engine.staticCircles.push({ x: w * 0.8, y: h - 240, radius: 25 });
    // Platform
    engine.staticBoxes.push({ x: w * 0.6, y: h - 55, width: 140, height: 10 });
    // ---- Spawn default objects ----
    const objects = [];
    // Rope: pinned at top, hanging
    const rope = (0, spawner_1.spawnRope)({ x: w * 0.25, y: 60 }, { x: w * 0.25, y: 350 }, config_1.CONFIG.ropePoints, engine.points, config_1.CONFIG.colorRope);
    objects.push(rope);
    // Cloth: pinned at both corners
    const cloth = (0, spawner_1.spawnCloth)({ x: w * 0.35, y: 60 }, config_1.CONFIG.clothCols, config_1.CONFIG.clothRows, 10, 'corners', engine.points, engine.constraints, config_1.CONFIG.colorCloth);
    objects.push(cloth);
    // Soft body
    const softBody = (0, spawner_1.spawnSoftBody)({ x: w * 0.7, y: 180 }, 55, config_1.CONFIG.softBodyPoints, engine.points, engine.constraints, config_1.CONFIG.colorSoftBody);
    objects.push(softBody);
    // Ball
    const ball = (0, spawner_1.spawnBall)({ x: w * 0.55, y: 120 }, 14, engine.points, config_1.CONFIG.colorBall);
    objects.push(ball);
    app = {
        canvas, engine, renderer, interaction, objects,
        spawnMode: 'none',
        lastTime: performance.now(),
        frameCount: 0, fpsTime: performance.now(),
    };
    // ---- Interaction callbacks ----
    interaction.onSpawn = (type, pos, end) => {
        spawnObject(type, pos, end);
    };
    interaction.onPinToggle = (point) => {
        point.pinned = !point.pinned;
        if (point.pinned)
            (0, point_1.setVelocity)(point, 0, 0, config_1.CONFIG.physicsDt);
    };
    // ---- Events ----
    canvas.addEventListener('mousedown', (e) => (0, interaction_1.handleMouseDown)(interaction, engine.points, e));
    canvas.addEventListener('mousemove', (e) => (0, interaction_1.handleMouseMove)(interaction, engine.points, e));
    canvas.addEventListener('mouseup', (e) => {
        (0, interaction_1.handleMouseUp)(interaction, engine.points, e);
        processTearNow();
    });
    canvas.addEventListener('contextmenu', (e) => e.preventDefault());
    window.addEventListener('keydown', handleKeyDown);
    setupToolbar();
    requestAnimationFrame(gameLoop);
}
function spawnObject(type, pos, end) {
    if (!app)
        return;
    let obj;
    switch (type) {
        case 'rope': {
            obj = (0, spawner_1.spawnRope)(pos, end || { x: pos.x, y: pos.y + 200 }, config_1.CONFIG.ropePoints, app.engine.points, config_1.CONFIG.colorRope);
            if (obj.points.length > 0) {
                obj.points[0].pinned = true;
                (0, point_1.setVelocity)(obj.points[0], 0, 0, config_1.CONFIG.physicsDt);
            }
            break;
        }
        case 'cloth': {
            const width = end ? Math.abs(end.x - pos.x) : 180;
            const height = end ? Math.abs(end.y - pos.y) : 130;
            const cols = Math.max(5, Math.min(40, Math.floor(width / 8)));
            const rows = Math.max(3, Math.min(30, Math.floor(height / 8)));
            const spacing = Math.max(6, width / cols);
            obj = (0, spawner_1.spawnCloth)(pos, cols, rows, spacing, app.interaction.pinMode, app.engine.points, app.engine.constraints, config_1.CONFIG.colorCloth);
            break;
        }
        case 'softBody': {
            obj = (0, spawner_1.spawnSoftBody)(pos, 45 + Math.random() * 25, config_1.CONFIG.softBodyPoints, app.engine.points, app.engine.constraints, config_1.CONFIG.colorSoftBody);
            break;
        }
        case 'ball': {
            obj = (0, spawner_1.spawnBall)(pos, 8 + Math.random() * 10, app.engine.points, config_1.CONFIG.colorBall);
            break;
        }
        default: return;
    }
    app.objects.push(obj);
}
function processTearNow() {
    if (!app)
        return;
    const path = app.interaction.tearPath;
    if (path.length < 2)
        return;
    for (let i = 1; i < path.length; i++) {
        (0, interaction_1.tearConstraintsNearSegment)(app.engine.constraints, app.engine.points, path[i - 1].x, path[i - 1].y, path[i].x, path[i].y);
    }
    for (let i = 0; i < path.length; i += 3) {
        app.interaction.tearEffects.push({ x: path[i].x, y: path[i].y, life: 1.0 });
    }
    app.interaction.tearPath = [];
}
function handleKeyDown(e) {
    if (!app)
        return;
    switch (e.key) {
        case ' ':
            e.preventDefault();
            app.engine.paused = !app.engine.paused;
            break;
        case 'ArrowRight':
            e.preventDefault();
            if (app.engine.paused)
                app.engine.stepOne();
            break;
        case 'Escape':
            resetAll();
            break;
        case 'g':
        case 'G':
            config_1.CONFIG.gravityOn = !config_1.CONFIG.gravityOn;
            app.engine.gravityOn = config_1.CONFIG.gravityOn;
            const chk = document.getElementById('chk-gravity');
            if (chk)
                chk.checked = config_1.CONFIG.gravityOn;
            break;
        case 'r':
        case 'R':
            setSpawnMode('rope');
            break;
        case 'c':
        case 'C':
            setSpawnMode('cloth');
            break;
        case 's':
        case 'S':
            setSpawnMode('softBody');
            break;
        case 'b':
        case 'B':
            setSpawnMode('ball');
            break;
        case 'p':
        case 'P':
            cyclePinMode();
            break;
        case 'v':
        case 'V':
            config_1.CONFIG.showStress = !config_1.CONFIG.showStress;
            const btnS = document.getElementById('btn-stress');
            if (btnS)
                btnS.classList.toggle('active', config_1.CONFIG.showStress);
            break;
        case 't':
        case 'T':
            config_1.CONFIG.showPoints = !config_1.CONFIG.showPoints;
            const btnP = document.getElementById('btn-points');
            if (btnP)
                btnP.classList.toggle('active', config_1.CONFIG.showPoints);
            break;
        case 'm':
        case 'M':
            config_1.CONFIG.motionBlur = !config_1.CONFIG.motionBlur;
            const btnB = document.getElementById('btn-blur');
            if (btnB)
                btnB.classList.toggle('active', config_1.CONFIG.motionBlur);
            break;
    }
}
function setSpawnMode(mode) {
    if (!app)
        return;
    app.spawnMode = mode;
    app.interaction.spawnMode = mode;
    app.canvas.style.cursor = mode !== 'none' ? 'crosshair' : 'default';
    ['btn-rope', 'btn-cloth', 'btn-softbody', 'btn-ball'].forEach((id) => {
        const btn = document.getElementById(id);
        if (btn)
            btn.classList.remove('active');
    });
    const map = { rope: 'btn-rope', cloth: 'btn-cloth', softBody: 'btn-softbody', ball: 'btn-ball' };
    if (map[mode]) {
        const btn = document.getElementById(map[mode]);
        if (btn)
            btn.classList.add('active');
    }
}
function cyclePinMode() {
    if (!app)
        return;
    app.interaction.pinMode = app.interaction.pinMode === 'corners' ? 'full' : 'corners';
    const btn = document.getElementById('btn-pinmode');
    if (btn)
        btn.textContent = `📌 ${app.interaction.pinMode === 'corners' ? 'Corners' : 'Full'}`;
}
function resetAll() {
    if (!app)
        return;
    app.engine.points = [];
    app.engine.constraints = [];
    app.objects = [];
    app.spawnMode = 'none';
    app.interaction.spawnMode = 'none';
    (0, engine_1.resetAccumulator)();
    setSpawnMode('none');
}
function setupToolbar() {
    document.getElementById('btn-rope')?.addEventListener('click', () => setSpawnMode('rope'));
    document.getElementById('btn-cloth')?.addEventListener('click', () => setSpawnMode('cloth'));
    document.getElementById('btn-softbody')?.addEventListener('click', () => setSpawnMode('softBody'));
    document.getElementById('btn-ball')?.addEventListener('click', () => setSpawnMode('ball'));
    document.getElementById('btn-pinmode')?.addEventListener('click', cyclePinMode);
    document.getElementById('btn-pause')?.addEventListener('click', () => { if (app)
        app.engine.paused = !app.engine.paused; });
    document.getElementById('btn-step')?.addEventListener('click', () => { if (app && app.engine.paused)
        app.engine.stepOne(); });
    document.getElementById('btn-reset')?.addEventListener('click', resetAll);
    const chk = document.getElementById('chk-gravity');
    chk?.addEventListener('change', (e) => { config_1.CONFIG.gravityOn = e.target.checked; if (app)
        app.engine.gravityOn = config_1.CONFIG.gravityOn; });
    const sGrav = document.getElementById('slider-gravity');
    sGrav?.addEventListener('input', (e) => { const v = parseFloat(e.target.value); config_1.CONFIG.gravity = v; if (app)
        app.engine.gravity = v; });
    const sIter = document.getElementById('slider-iterations');
    sIter?.addEventListener('input', (e) => { const v = parseInt(e.target.value); config_1.CONFIG.iterations = v; if (app)
        app.engine.iterations = v; });
    const sDamp = document.getElementById('slider-damping');
    sDamp?.addEventListener('input', (e) => { const v = parseInt(e.target.value); config_1.CONFIG.damping = v / 10000; if (app)
        app.engine.damping = v / 10000; });
    document.getElementById('btn-stress')?.addEventListener('click', () => { config_1.CONFIG.showStress = !config_1.CONFIG.showStress; const b = document.getElementById('btn-stress'); if (b)
        b.classList.toggle('active', config_1.CONFIG.showStress); });
    document.getElementById('btn-points')?.addEventListener('click', () => { config_1.CONFIG.showPoints = !config_1.CONFIG.showPoints; const b = document.getElementById('btn-points'); if (b)
        b.classList.toggle('active', config_1.CONFIG.showPoints); });
    document.getElementById('btn-blur')?.addEventListener('click', () => { config_1.CONFIG.motionBlur = !config_1.CONFIG.motionBlur; const b = document.getElementById('btn-blur'); if (b)
        b.classList.toggle('active', config_1.CONFIG.motionBlur); });
}
function gameLoop(timestamp) {
    if (!app)
        return;
    const { engine, renderer, interaction } = app;
    app.frameCount++;
    if (timestamp - app.fpsTime >= 1000) {
        renderer.fps = app.frameCount;
        app.frameCount = 0;
        app.fpsTime = timestamp;
    }
    // Sync config ↔ engine
    config_1.CONFIG.gravity = engine.gravity;
    config_1.CONFIG.iterations = engine.iterations;
    config_1.CONFIG.damping = engine.damping;
    config_1.CONFIG.gravityOn = engine.gravityOn;
    config_1.CONFIG.windX = engine.windX;
    config_1.CONFIG.windY = engine.windY;
    if (!engine.paused) {
        const now = performance.now();
        let delta = now - app.lastTime;
        if (delta > 100)
            delta = 100;
        app.lastTime = now;
        const t0 = performance.now();
        (0, engine_1.tick)(engine, delta);
        renderer.stepTimeMs = performance.now() - t0;
    }
    else {
        app.lastTime = performance.now();
        renderer.stepTimeMs = 0;
    }
    (0, interaction_1.updateTearEffects)(interaction, 1 / 60);
    renderer.pointCount = engine.points.length;
    renderer.constraintCount = engine.constraints.filter(c => c.active).length;
    renderer.paused = engine.paused;
    (0, renderer_1.render)(renderer, engine.points, engine.constraints, app.objects, engine.staticCircles, engine.staticBoxes, interaction.grabPoint, interaction.grabPoint ? { x: interaction.lastMousePos.x, y: interaction.lastMousePos.y } : null, interaction.tearEffects.map(e => ({ x: e.x, y: e.y })), interaction.spawnMode !== 'none' && interaction.dragStart && interaction.dragCurrent ? {
        start: interaction.dragStart,
        end: interaction.dragCurrent,
        mode: interaction.spawnMode,
    } : undefined);
    requestAnimationFrame(gameLoop);
}
// ---- Expose for headless testing ----
function getSim() {
    if (!app)
        return null;
    const e = app.engine;
    return {
        points: e.points, constraints: e.constraints,
        step: () => e.stepOne(),
        getTotalKineticEnergy: () => e.getTotalKineticEnergy(),
        hasNaN: () => e.hasNaN(),
        isOutOfBounds: () => e.isOutOfBounds(),
        objects: app.objects,
        paused: () => e.paused,
        pause: (v) => { e.paused = v; },
    };
}
// Auto-run
init();
// Expose sim on window
window.sim = () => getSim();
};

// ── module: src/config.ts ──
__mods["src/config.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// Tunable Constants
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.CONFIG = void 0;
exports.CONFIG = {
    physicsDt: 1 / 60,
    maxDt: 0.2,
    damping: 0.001,
    iterations: 6,
    gravity: 980,
    gravityOn: true,
    windX: 0,
    windY: 0,
    grabRadius: 20,
    tearRadius: 18,
    throwFactor: 200,
    pointRadius: 2.5,
    showPoints: false,
    showStress: false,
    motionBlur: true,
    motionBlurAlpha: 0.15,
    colorRope: '#ff6b6b',
    colorCloth: '#4ecdc4',
    colorSoftBody: '#a78bfa',
    colorBall: '#fbbf24',
    colorStatic: '#6b7280',
    colorPin: '#f472b6',
    colorTear: '#ff4444',
    bgColor: '#0f1117',
    gridColor: '#1a1d27',
    stressGreen: '#22c55e',
    stressRed: '#ef4444',
    wallMargin: 5,
    ropePoints: 30,
    clothCols: 20,
    clothRows: 15,
    softBodyPoints: 12,
};
};

// ── module: src/point.ts ──
__mods["src/point.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// Point — a single Verlet particle
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.createPoint = createPoint;
exports.clonePoint = clonePoint;
exports.getVelocity = getVelocity;
exports.setVelocity = setVelocity;
exports.kineticEnergy = kineticEnergy;
let _nextId = 0;
function createPoint(x, y, pinned = false, radius = 0, mass = 1) {
    return {
        x, y,
        px: x, py: y, // start at rest
        pinned,
        radius,
        mass,
        id: _nextId++,
    };
}
function clonePoint(p) {
    return { ...p, id: _nextId++ };
}
/** Compute current velocity (for throw/interaction) */
function getVelocity(p, dt) {
    if (dt === 0)
        return { vx: 0, vy: 0 };
    return {
        vx: (p.x - p.px) / dt,
        vy: (p.y - p.py) / dt,
    };
}
function setVelocity(p, vx, vy, dt) {
    p.px = p.x - vx * dt;
    p.py = p.y - vy * dt;
}
function kineticEnergy(p, dt) {
    const v = getVelocity(p, dt);
    return 0.5 * p.mass * (v.vx * v.vx + v.vy * v.vy);
}
};

// ── module: src/constraint.ts ──
__mods["src/constraint.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// Constraints — distance constraints with stretch tracking
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.createConstraint = createConstraint;
exports.resolveConstraint = resolveConstraint;
function createConstraint(a, b, restLength, stiffness = 1.0) {
    return { a, b, restLength, stiffness, active: true, stretchRatio: 1 };
}
/** Resolve a single distance constraint — returns true if resolved */
function resolveConstraint(constraint, points, damping) {
    if (!constraint.active)
        return false;
    if (constraint.a >= points.length || constraint.b >= points.length)
        return false;
    const pa = points[constraint.a];
    const pb = points[constraint.b];
    const dx = pb.x - pa.x;
    const dy = pb.y - pa.y;
    const dist = Math.sqrt(dx * dx + dy * dy);
    // Guard: zero-length link → skip (both points at same location, no direction)
    if (dist < 1e-8)
        return false;
    // Track stretch for visualization
    constraint.stretchRatio = constraint.restLength > 1e-8 ? dist / constraint.restLength : 1;
    // How far from rest length
    const diff = (dist - constraint.restLength) / dist;
    const offset = diff * 0.5 * constraint.stiffness;
    // If both pinned, nothing to do
    const aPinned = pa.pinned;
    const bPinned = pb.pinned;
    if (aPinned && bPinned)
        return true;
    const totalMass = pa.mass + pb.mass;
    const massA = pa.mass / totalMass;
    const massB = pb.mass / totalMass;
    // Apply Verlet-style displacement
    if (!aPinned) {
        const mx = dx * offset * massA;
        const my = dy * offset * massA;
        pa.x += mx;
        pa.y += my;
    }
    if (!bPinned) {
        const mx = dx * offset * massB;
        const my = dy * offset * massB;
        pb.x -= mx;
        pb.y -= my;
    }
    // Apply damping to relative velocity along constraint axis
    if (damping > 0) {
        if (!aPinned) {
            pa.x += (pa.x - pa.px) * -damping;
            pa.y += (pa.y - pa.py) * -damping;
        }
        if (!bPinned) {
            pb.x += (pb.x - pb.px) * -damping;
            pb.y += (pb.y - pb.py) * -damping;
        }
    }
    return true;
}
};

// ── module: src/spawner.ts ──
__mods["src/spawner.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// Physics Object — a collection of points + constraints with metadata
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.spawnRope = spawnRope;
exports.spawnCloth = spawnCloth;
exports.spawnSoftBody = spawnSoftBody;
exports.spawnBall = spawnBall;
const point_1 = require("./point");
const constraint_1 = require("./constraint");
/**
 * Spawn a rope: chain of N points pinned at the first one
 */
function spawnRope(start, end, numPoints, globalPoints, color) {
    const points = [];
    const constraints = [];
    const dx = (end.x - start.x) / (numPoints - 1);
    const dy = (end.y - start.y) / (numPoints - 1);
    for (let i = 0; i < numPoints; i++) {
        points.push((0, point_1.createPoint)(start.x + dx * i, start.y + dy * i, i === 0));
    }
    for (let i = 0; i < numPoints - 1; i++) {
        const pA = points[i];
        const pB = points[i + 1];
        const dist = Math.sqrt((pB.x - pA.x) ** 2 + (pB.y - pA.y) ** 2);
        constraints.push((0, constraint_1.createConstraint)(i, i + 1, dist));
    }
    // Register into global array
    const offset = globalPoints.length;
    for (const p of points)
        globalPoints.push(p);
    // Fix constraint indices
    for (const c of constraints) {
        c.a += offset;
        c.b += offset;
    }
    return { type: 'rope', points, constraints, offsetInPoints: offset, color };
}
/**
 * Spawn cloth: grid of points with structural + shear constraints
 */
function spawnCloth(origin, cols, rows, spacing, pinMode, globalPoints, globalConstraints, color) {
    const points = [];
    // Create points in a grid
    for (let row = 0; row < rows; row++) {
        for (let col = 0; col < cols; col++) {
            const x = origin.x + col * spacing;
            const y = origin.y + row * spacing;
            let pinned = false;
            if (pinMode === 'corners') {
                pinned = (row === 0) && (col === 0 || col === cols - 1);
            }
            else {
                pinned = row === 0;
            }
            points.push((0, point_1.createPoint)(x, y, pinned));
        }
    }
    const constraints = [];
    // Structural constraints (horizontal + vertical neighbors)
    for (let row = 0; row < rows; row++) {
        for (let col = 0; col < cols; col++) {
            const idx = row * cols + col;
            if (col > 0) {
                constraints.push((0, constraint_1.createConstraint)(idx, idx - 1, spacing));
            }
            if (row > 0) {
                constraints.push((0, constraint_1.createConstraint)(idx, idx - cols, spacing));
            }
        }
    }
    // Shear constraints (diagonals)
    for (let row = 0; row < rows - 1; row++) {
        for (let col = 0; col < cols - 1; col++) {
            const idx = row * cols + col;
            constraints.push((0, constraint_1.createConstraint)(idx, idx + cols + 1, spacing * Math.SQRT2, 0.5));
            constraints.push((0, constraint_1.createConstraint)(idx + 1, idx + cols, spacing * Math.SQRT2, 0.5));
        }
    }
    // Register into global arrays
    const offset = globalPoints.length;
    for (const p of points)
        globalPoints.push(p);
    const constraintOffset = globalConstraints.length;
    for (const c of constraints) {
        c.a += offset;
        c.b += offset;
        globalConstraints.push(c);
    }
    return {
        type: 'cloth',
        points,
        constraints,
        offsetInPoints: offset,
        color,
        clothRect: { cols, rows },
    };
}
/**
 * Spawn a soft body (circle/blob): ring of points with internal braces
 */
function spawnSoftBody(center, radius, numPoints, globalPoints, globalConstraints, color) {
    const points = [];
    // Create points in a circle
    for (let i = 0; i < numPoints; i++) {
        const angle = (2 * Math.PI * i) / numPoints - Math.PI / 2;
        points.push((0, point_1.createPoint)(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle)));
    }
    const constraints = [];
    // Edge constraints (ring)
    for (let i = 0; i < numPoints; i++) {
        const next = (i + 1) % numPoints;
        const pa = points[i];
        const pb = points[next];
        const dist = Math.sqrt((pb.x - pa.x) ** 2 + (pb.y - pa.y) ** 2);
        constraints.push((0, constraint_1.createConstraint)(i, next, dist));
    }
    // Internal brace constraints (star from center, plus some cross-braces)
    // Virtual center: use average position constraint
    // Instead, add cross-braces: point[i] to point[i + numPoints/2]
    const half = Math.floor(numPoints / 2);
    for (let i = 0; i < numPoints; i++) {
        constraints.push((0, constraint_1.createConstraint)(i, (i + half) % numPoints, radius * 2, 0.6));
    }
    // Add a few more internal braces for shape stability
    for (let i = 0; i < numPoints; i++) {
        const skip = Math.floor(numPoints / 3);
        constraints.push((0, constraint_1.createConstraint)(i, (i + skip) % numPoints, radius * Math.sin(Math.PI * skip / numPoints) * 2, 0.4));
    }
    // Register into global arrays
    const offset = globalPoints.length;
    for (const p of points)
        globalPoints.push(p);
    const constraintOffset = globalConstraints.length;
    for (const c of constraints) {
        c.a += offset;
        c.b += offset;
        globalConstraints.push(c);
    }
    return {
        type: 'softBody',
        points,
        constraints,
        offsetInPoints: offset,
        color,
    };
}
/**
 * Spawn a ball: single point with radius
 */
function spawnBall(pos, ballRadius, globalPoints, color) {
    const points = [(0, point_1.createPoint)(pos.x, pos.y, false, ballRadius)];
    const constraints = [];
    const offset = globalPoints.length;
    for (const p of points)
        globalPoints.push(p);
    return { type: 'ball', points, constraints, offsetInPoints: offset, color };
}
};

// ── module: src/engine.ts ──
__mods["src/engine.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// Physics Engine — Verlet integration + constraint relaxation
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.createEngine = createEngine;
exports.tick = tick;
exports.resetAccumulator = resetAccumulator;
const point_1 = require("./point");
const constraint_1 = require("./constraint");
const config_1 = require("./config");
let _accumulator = 0;
function createEngine(width, height) {
    const engine = {
        points: [],
        constraints: [],
        staticCircles: [],
        staticBoxes: [],
        width,
        height,
        paused: false,
        damping: config_1.CONFIG.damping,
        iterations: config_1.CONFIG.iterations,
        gravity: config_1.CONFIG.gravity,
        gravityOn: config_1.CONFIG.gravityOn,
        windX: config_1.CONFIG.windX,
        windY: config_1.CONFIG.windY,
        step: () => step(engine),
        stepOne: () => step(engine),
        getTotalKineticEnergy: () => getTotalKineticEnergy(engine),
        hasNaN: () => hasNaN(engine),
        isOutOfBounds: () => isOutOfBounds(engine),
    };
    return engine;
}
function step(engine) {
    const dt = config_1.CONFIG.physicsDt;
    const dt2 = dt * dt;
    const gravity = engine.gravityOn ? engine.gravity : 0;
    const damping = engine.damping;
    const iterations = engine.iterations;
    const points = engine.points;
    const constraints = engine.constraints;
    const margin = config_1.CONFIG.wallMargin;
    const floorY = engine.height - margin;
    const ceilY = margin;
    const wallL = margin;
    const wallR = engine.width - margin;
    const friction = 0.3;
    // ---- 1. Verlet Integration ----
    for (let i = 0; i < points.length; i++) {
        const p = points[i];
        if (p.pinned)
            continue;
        const vx = p.x - p.px;
        const vy = p.y - p.py;
        p.px = p.x;
        p.py = p.y;
        p.x += vx * (1 - damping) + engine.windX * dt2;
        p.y += vy * (1 - damping) + gravity * dt2 + engine.windY * dt2;
        // NaN guard
        if (!isFinite(p.x) || !isFinite(p.y)) {
            p.x = p.px;
            p.y = p.py;
            p.px = p.x;
            p.py = p.y;
        }
    }
    // ---- 2. Constraint Solving (iterative relaxation) ----
    for (let iter = 0; iter < iterations; iter++) {
        // Distance constraints
        for (let j = 0; j < constraints.length; j++) {
            (0, constraint_1.resolveConstraint)(constraints[j], points, 0);
        }
        // Wall/floor collisions
        for (let i = 0; i < points.length; i++) {
            const p = points[i];
            const rad = p.radius || 0;
            const vx = p.x - p.px;
            const vy = p.y - p.py;
            // Floor
            if (p.y + rad > floorY) {
                p.y = floorY - rad;
                const correction = p.y - p.py;
                p.py = p.y - correction;
                // Friction on floor contact
                p.px = p.x - vx * (1 - friction);
            }
            // Ceiling
            if (p.y - rad < ceilY) {
                p.y = ceilY + rad;
                const correction = p.y - p.py;
                p.py = p.y - correction;
            }
            // Left wall
            if (p.x - rad < wallL) {
                p.x = wallL + rad;
                const correction = p.x - p.px;
                p.px = p.x - correction;
                p.py = p.y - vy * (1 - friction);
            }
            // Right wall
            if (p.x + rad > wallR) {
                p.x = wallR - rad;
                const correction = p.x - p.px;
                p.px = p.x - correction;
                p.py = p.y - vy * (1 - friction);
            }
            // Static circle collisions
            for (const c of engine.staticCircles) {
                resolvePointCircleCollision(p, c, friction);
            }
            // Static box collisions
            for (const b of engine.staticBoxes) {
                resolvePointBoxCollision(p, b, friction);
            }
        }
    }
    // ---- 3. Final NaN guard & bounds clamp ----
    for (let i = 0; i < points.length; i++) {
        const p = points[i];
        if (!isFinite(p.x) || !isFinite(p.y)) {
            p.x = engine.width / 2;
            p.y = engine.height / 2;
            p.px = p.x;
            p.py = p.y;
        }
        // Hard clamp to prevent runaway
        const bound = Math.max(engine.width, engine.height) * 2;
        if (p.x < -bound)
            p.x = -bound;
        if (p.x > engine.width + bound)
            p.x = engine.width + bound;
        if (p.y < -bound)
            p.y = -bound;
        if (p.y > engine.height + bound)
            p.y = engine.height + bound;
    }
}
function resolvePointCircleCollision(p, circle, friction) {
    if (p.pinned)
        return;
    const dx = p.x - circle.x;
    const dy = p.y - circle.y;
    const dist = Math.sqrt(dx * dx + dy * dy);
    const minDist = circle.radius + (p.radius || 0);
    if (dist < minDist && dist > 1e-8) {
        const vx = p.x - p.px;
        const vy = p.y - p.py;
        const nx = dx / dist;
        const ny = dy / dist;
        // Push out
        p.x = circle.x + nx * minDist;
        p.y = circle.y + ny * minDist;
        // Reflect velocity with friction
        const normalVel = vx * nx + vy * ny;
        if (normalVel < 0) {
            const tx = -ny; // tangent
            const ty = nx;
            const tangentVel = vx * tx + vy * ty;
            // Restore previous position from corrected velocity
            const newVx = -(normalVel * 0.5) * nx + (tangentVel * (1 - friction)) * tx;
            const newVy = -(normalVel * 0.5) * ny + (tangentVel * (1 - friction)) * ty;
            p.px = p.x - newVx;
            p.py = p.y - newVy;
        }
        else {
            p.px = p.x - vx;
            p.py = p.y - vy;
        }
    }
}
function resolvePointBoxCollision(p, box, friction) {
    if (p.pinned)
        return;
    const rad = p.radius || 0;
    // AABB test with radius
    const minX = box.x;
    const maxX = box.x + box.width;
    const minY = box.y;
    const maxY = box.y + box.height;
    // Find closest point on box to particle
    let closestX = Math.max(minX, Math.min(p.x, maxX));
    let closestY = Math.max(minY, Math.min(p.y, maxY));
    const dx = p.x - closestX;
    const dy = p.y - closestY;
    const dist = Math.sqrt(dx * dx + dy * dy);
    if (dist < rad && dist > 1e-8) {
        const nx = dx / dist;
        const ny = dy / dist;
        // Push out
        p.x = closestX + nx * rad;
        p.y = closestY + ny * rad;
        const vx = p.x - p.px;
        const vy = p.y - p.py;
        const normalVel = vx * nx + vy * ny;
        if (normalVel < 0) {
            const tx = -ny;
            const ty = nx;
            const tangentVel = vx * tx + vy * ty;
            const newVx = -(normalVel * 0.3) * nx + (tangentVel * (1 - friction)) * tx;
            const newVy = -(normalVel * 0.3) * ny + (tangentVel * (1 - friction)) * ty;
            p.px = p.x - newVx;
            p.py = p.y - newVy;
        }
    }
    else if (dist < 1e-8) {
        // Point is at exact corner of box — push along diagonal
        p.x += rad;
        p.y += rad;
    }
}
function stepAccumulated(engine) {
    // Run one physics step
    step(engine);
}
function tick(engine, deltaMs) {
    // Clamp delta to prevent explosion
    let dt = deltaMs / 1000;
    dt = Math.min(dt, config_1.CONFIG.maxDt);
    _accumulator += dt;
    // Run as many fixed physics steps as we can
    const maxSteps = 10; // prevent spiral of death
    let steps = 0;
    while (_accumulator >= config_1.CONFIG.physicsDt && steps < maxSteps) {
        stepAccumulated(engine);
        _accumulator -= config_1.CONFIG.physicsDt;
        steps++;
    }
}
function resetAccumulator() {
    _accumulator = 0;
}
function getTotalKineticEnergy(engine) {
    let energy = 0;
    const dt = config_1.CONFIG.physicsDt;
    for (const p of engine.points) {
        const v = (0, point_1.getVelocity)(p, dt);
        energy += 0.5 * p.mass * (v.vx * v.vx + v.vy * v.vy);
    }
    return energy;
}
function hasNaN(engine) {
    for (const p of engine.points) {
        if (!isFinite(p.x) || !isFinite(p.y))
            return true;
    }
    return false;
}
function isOutOfBounds(engine) {
    const bound = Math.max(engine.width, engine.height) * 2;
    for (const p of engine.points) {
        if (Math.abs(p.x) > bound || Math.abs(p.y) > bound || p.y > engine.height + bound)
            return true;
    }
    return false;
}
};

// ── module: src/renderer.ts ──
__mods["src/renderer.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// Renderer — Canvas 2D rendering for the physics playground
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.createRenderer = createRenderer;
exports.render = render;
const config_1 = require("./config");
function createRenderer(canvas) {
    const ctx = canvas.getContext('2d');
    return {
        canvas,
        ctx,
        width: canvas.width,
        height: canvas.height,
        fps: 0,
        stepTimeMs: 0,
        pointCount: 0,
        constraintCount: 0,
        paused: false,
    };
}
function render(r, points, constraints, objects, staticCircles, staticBoxes, grabPoint, grabPos, tearEffects, spawnPreview) {
    const ctx = r.ctx;
    const w = r.width;
    const h = r.height;
    // Motion blur trail
    if (config_1.CONFIG.motionBlur) {
        ctx.fillStyle = `rgba(15, 17, 23, ${config_1.CONFIG.motionBlurAlpha})`;
        ctx.fillRect(0, 0, w, h);
    }
    else {
        ctx.fillStyle = config_1.CONFIG.bgColor;
        ctx.fillRect(0, 0, w, h);
    }
    // Subtle grid
    drawGrid(ctx, w, h);
    // Floor/wall indicators
    drawBounds(ctx, w, h);
    // Static objects
    drawStaticObjects(ctx, staticCircles, staticBoxes);
    // Tear effects
    drawTearEffects(ctx, tearEffects);
    // Objects
    for (const obj of objects) {
        drawObject(ctx, obj, points, constraints);
    }
    // Spawn preview
    if (spawnPreview) {
        drawSpawnPreview(ctx, spawnPreview);
    }
    // Grab highlight
    if (grabPoint && grabPos) {
        drawGrabHighlight(ctx, grabPoint, grabPos);
    }
    // Pins
    drawPins(ctx, points);
    // HUD
    drawHUD(ctx, r, w, h);
}
function drawGrid(ctx, w, h) {
    ctx.strokeStyle = config_1.CONFIG.gridColor;
    ctx.lineWidth = 0.5;
    const spacing = 60;
    for (let x = spacing; x < w; x += spacing) {
        ctx.beginPath();
        ctx.moveTo(x, 0);
        ctx.lineTo(x, h);
        ctx.stroke();
    }
    for (let y = spacing; y < h; y += spacing) {
        ctx.beginPath();
        ctx.moveTo(0, y);
        ctx.lineTo(w, y);
        ctx.stroke();
    }
}
function drawBounds(ctx, w, h) {
    const m = config_1.CONFIG.wallMargin;
    ctx.strokeStyle = 'rgba(107, 114, 128, 0.4)';
    ctx.lineWidth = 2;
    // Floor line
    ctx.beginPath();
    ctx.moveTo(m, h - m);
    ctx.lineTo(w - m, h - m);
    ctx.stroke();
    // Subtle glow at floor
    ctx.strokeStyle = 'rgba(99, 102, 241, 0.15)';
    ctx.lineWidth = 6;
    ctx.beginPath();
    ctx.moveTo(m, h - m);
    ctx.lineTo(w - m, h - m);
    ctx.stroke();
}
function drawStaticObjects(ctx, circles, boxes) {
    for (const c of circles) {
        // Glow
        ctx.beginPath();
        ctx.arc(c.x, c.y, c.radius + 4, 0, Math.PI * 2);
        ctx.fillStyle = 'rgba(107, 114, 128, 0.1)';
        ctx.fill();
        ctx.beginPath();
        ctx.arc(c.x, c.y, c.radius, 0, Math.PI * 2);
        ctx.fillStyle = 'rgba(107, 114, 128, 0.2)';
        ctx.fill();
        ctx.strokeStyle = config_1.CONFIG.colorStatic;
        ctx.lineWidth = 2;
        ctx.stroke();
    }
    for (const b of boxes) {
        ctx.fillStyle = 'rgba(107, 114, 128, 0.2)';
        ctx.fillRect(b.x, b.y, b.width, b.height);
        ctx.strokeStyle = config_1.CONFIG.colorStatic;
        ctx.lineWidth = 2;
        ctx.strokeRect(b.x, b.y, b.width, b.height);
    }
}
function drawTearEffects(ctx, effects) {
    for (const e of effects) {
        // Outer glow
        const grad = ctx.createRadialGradient(e.x, e.y, 0, e.x, e.y, 15);
        grad.addColorStop(0, 'rgba(255, 68, 68, 0.6)');
        grad.addColorStop(0.5, 'rgba(255, 68, 68, 0.2)');
        grad.addColorStop(1, 'rgba(255, 68, 68, 0)');
        ctx.beginPath();
        ctx.arc(e.x, e.y, 15, 0, Math.PI * 2);
        ctx.fillStyle = grad;
        ctx.fill();
        // Core
        ctx.beginPath();
        ctx.arc(e.x, e.y, 3, 0, Math.PI * 2);
        ctx.fillStyle = '#ff6666';
        ctx.fill();
    }
}
function drawSpawnPreview(ctx, preview) {
    const { start, end, mode } = preview;
    ctx.setLineDash([6, 4]);
    ctx.lineWidth = 1.5;
    if (mode === 'rope') {
        ctx.strokeStyle = config_1.CONFIG.colorRope + 'aa';
        ctx.beginPath();
        ctx.moveTo(start.x, start.y);
        ctx.lineTo(end.x, end.y);
        ctx.stroke();
        // Pin indicator at start
        ctx.setLineDash([]);
        ctx.beginPath();
        ctx.arc(start.x, start.y, 5, 0, Math.PI * 2);
        ctx.fillStyle = config_1.CONFIG.colorPin + '88';
        ctx.fill();
        // Length indicator
        const dist = Math.sqrt((end.x - start.x) ** 2 + (end.y - start.y) ** 2);
        ctx.fillStyle = config_1.CONFIG.colorRope + '88';
        ctx.font = '11px monospace';
        ctx.fillText(`${Math.floor(dist)}px`, (start.x + end.x) / 2 + 8, (start.y + end.y) / 2);
    }
    else if (mode === 'cloth') {
        ctx.strokeStyle = config_1.CONFIG.colorCloth + 'aa';
        const dx = end.x - start.x;
        const dy = end.y - start.y;
        ctx.strokeRect(start.x, start.y, dx, dy);
        // Show grid preview
        ctx.setLineDash([]);
        ctx.strokeStyle = config_1.CONFIG.colorCloth + '33';
        ctx.lineWidth = 0.5;
        const cols = Math.max(5, Math.min(40, Math.floor(Math.abs(dx) / 8)));
        const rows = Math.max(3, Math.min(30, Math.floor(Math.abs(dy) / 8)));
        const sx = Math.abs(dx) / cols;
        const sy = Math.abs(dy) / rows;
        const signX = dx >= 0 ? 1 : -1;
        const signY = dy >= 0 ? 1 : -1;
        for (let r = 0; r <= rows; r++) {
            ctx.beginPath();
            ctx.moveTo(start.x, start.y + r * sy * signY);
            ctx.lineTo(start.x + dx, start.y + r * sy * signY);
            ctx.stroke();
        }
        for (let c = 0; c <= cols; c++) {
            ctx.beginPath();
            ctx.moveTo(start.x + c * sx * signX, start.y);
            ctx.lineTo(start.x + c * sx * signX, start.y + dy);
            ctx.stroke();
        }
    }
    else if (mode === 'softBody' || mode === 'ball') {
        ctx.setLineDash([]);
        ctx.beginPath();
        ctx.arc(start.x, start.y, mode === 'ball' ? 15 : 50, 0, Math.PI * 2);
        ctx.strokeStyle = (mode === 'ball' ? config_1.CONFIG.colorBall : config_1.CONFIG.colorSoftBody) + 'aa';
        ctx.lineWidth = 2;
        ctx.stroke();
    }
    ctx.setLineDash([]);
}
function drawObject(ctx, obj, points, constraints) {
    const color = obj.color;
    const offset = obj.offsetInPoints;
    if (obj.type === 'ball') {
        const p = points[offset];
        if (!p || !p.radius)
            return;
        // Ball with gradient
        const grad = ctx.createRadialGradient(p.x - p.radius * 0.3, p.y - p.radius * 0.3, 0, p.x, p.y, p.radius);
        grad.addColorStop(0, '#fff');
        grad.addColorStop(0.3, color);
        grad.addColorStop(1, color + '66');
        ctx.beginPath();
        ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
        ctx.fillStyle = grad;
        ctx.fill();
        ctx.strokeStyle = color;
        ctx.lineWidth = 2;
        ctx.stroke();
    }
    else if (obj.type === 'rope') {
        const pts = obj.points.map((_, i) => points[offset + i]).filter(Boolean);
        if (pts.length < 2)
            return;
        // Shadow
        ctx.beginPath();
        ctx.moveTo(pts[0].x + 2, pts[0].y + 2);
        for (let i = 1; i < pts.length; i++) {
            ctx.lineTo(pts[i].x + 2, pts[i].y + 2);
        }
        ctx.strokeStyle = 'rgba(0,0,0,0.3)';
        ctx.lineWidth = 7;
        ctx.lineCap = 'round';
        ctx.lineJoin = 'round';
        ctx.stroke();
        // Main rope
        ctx.beginPath();
        ctx.moveTo(pts[0].x, pts[0].y);
        for (let i = 1; i < pts.length; i++) {
            ctx.lineTo(pts[i].x, pts[i].y);
        }
        ctx.strokeStyle = color;
        ctx.lineWidth = 5;
        ctx.stroke();
        // Highlight
        ctx.beginPath();
        ctx.moveTo(pts[0].x, pts[0].y);
        for (let i = 1; i < pts.length; i++) {
            ctx.lineTo(pts[i].x, pts[i].y);
        }
        ctx.strokeStyle = '#ffffff44';
        ctx.lineWidth = 2;
        ctx.stroke();
        if (config_1.CONFIG.showPoints) {
            for (const p of pts)
                drawPointDot(ctx, p, color);
        }
    }
    else if (obj.type === 'cloth') {
        const cols = obj.clothRect?.cols || 10;
        const rows = obj.clothRect?.rows || 10;
        // Draw cloth as filled mesh
        for (let row = 0; row < rows - 1; row++) {
            for (let col = 0; col < cols - 1; col++) {
                const i00 = offset + row * cols + col;
                const i01 = offset + row * cols + (col + 1);
                const i10 = offset + (row + 1) * cols + col;
                const i11 = offset + (row + 1) * cols + (col + 1);
                const p00 = points[i00];
                const p01 = points[i01];
                const p10 = points[i10];
                const p11 = points[i11];
                if (!p00 || !p01 || !p10 || !p11)
                    continue;
                // Compute average strain for color variation
                let stress = 0;
                // Check horizontal constraint for stress visualization
                let stretchStress = 0;
                for (const c of constraints) {
                    if ((c.a === i00 && c.b === i01) || (c.a === i01 && c.b === i00)) {
                        stretchStress = Math.abs(c.stretchRatio - 1);
                        break;
                    }
                }
                const stressAlpha = Math.min(stretchStress * 3, 1);
                // Two triangles per quad
                ctx.fillStyle = `rgba(${hexToRgb(color)}, ${0.3 + stressAlpha * 0.3})`;
                ctx.beginPath();
                ctx.moveTo(p00.x, p00.y);
                ctx.lineTo(p01.x, p01.y);
                ctx.lineTo(p10.x, p10.y);
                ctx.closePath();
                ctx.fill();
                ctx.beginPath();
                ctx.moveTo(p01.x, p01.y);
                ctx.lineTo(p11.x, p11.y);
                ctx.lineTo(p10.x, p10.y);
                ctx.closePath();
                ctx.fill();
            }
        }
        // Grid lines
        ctx.strokeStyle = color + '33';
        ctx.lineWidth = 0.5;
        for (let row = 0; row < rows; row++) {
            ctx.beginPath();
            for (let col = 0; col < cols; col++) {
                const idx = offset + row * cols + col;
                const p = points[idx];
                if (!p)
                    continue;
                if (col === 0)
                    ctx.moveTo(p.x, p.y);
                else
                    ctx.lineTo(p.x, p.y);
            }
            ctx.stroke();
        }
        for (let col = 0; col < cols; col++) {
            ctx.beginPath();
            for (let row = 0; row < rows; row++) {
                const idx = offset + row * cols + col;
                const p = points[idx];
                if (!p)
                    continue;
                if (row === 0)
                    ctx.moveTo(p.x, p.y);
                else
                    ctx.lineTo(p.x, p.y);
            }
            ctx.stroke();
        }
        if (config_1.CONFIG.showPoints) {
            for (let i = 0; i < obj.points.length; i++) {
                const p = points[offset + i];
                if (p)
                    drawPointDot(ctx, p, color);
            }
        }
    }
    else if (obj.type === 'softBody') {
        const pts = obj.points.map((_, i) => points[offset + i]).filter(Boolean);
        if (pts.length < 3)
            return;
        // Sort by angle from centroid
        let cx = 0, cy = 0;
        for (const p of pts) {
            cx += p.x;
            cy += p.y;
        }
        cx /= pts.length;
        cy /= pts.length;
        const sorted = [...pts].sort((a, b) => Math.atan2(a.y - cy, a.x - cx) - Math.atan2(b.y - cy, b.x - cy));
        // Glow
        ctx.beginPath();
        ctx.moveTo(sorted[0].x, sorted[0].y);
        for (let i = 1; i < sorted.length; i++)
            ctx.lineTo(sorted[i].x, sorted[i].y);
        ctx.closePath();
        ctx.fillStyle = color + '22';
        ctx.fill();
        // Fill
        ctx.beginPath();
        ctx.moveTo(sorted[0].x, sorted[0].y);
        for (let i = 1; i < sorted.length; i++)
            ctx.lineTo(sorted[i].x, sorted[i].y);
        ctx.closePath();
        // Gradient fill
        const grad = ctx.createRadialGradient(cx, cy - 10, 0, cx, cy, Math.max(1, ...pts.map(p => Math.sqrt((p.x - cx) ** 2 + (p.y - cy) ** 2))));
        grad.addColorStop(0, color + '88');
        grad.addColorStop(1, color + '44');
        ctx.fillStyle = grad;
        ctx.fill();
        ctx.strokeStyle = color;
        ctx.lineWidth = 2.5;
        ctx.stroke();
        if (config_1.CONFIG.showStress) {
            for (const c of obj.constraints) {
                const pa = points[c.a];
                const pb = points[c.b];
                if (!pa || !pb)
                    continue;
                const stress = Math.min(Math.abs(c.stretchRatio - 1) * 4, 1);
                const r = Math.round(34 + stress * 221);
                const g = Math.round(197 - stress * 143);
                const b = Math.round(94 - stress * 50);
                ctx.beginPath();
                ctx.moveTo(pa.x, pa.y);
                ctx.lineTo(pb.x, pb.y);
                ctx.strokeStyle = `rgba(${r},${g},${b},0.7)`;
                ctx.lineWidth = 1.5;
                ctx.stroke();
            }
        }
        if (config_1.CONFIG.showPoints) {
            for (const p of pts)
                drawPointDot(ctx, p, color);
        }
    }
}
function drawGrabHighlight(ctx, point, pos) {
    // Dashed circle at cursor
    ctx.beginPath();
    ctx.arc(pos.x, pos.y, config_1.CONFIG.grabRadius, 0, Math.PI * 2);
    ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
    ctx.lineWidth = 1.5;
    ctx.setLineDash([4, 4]);
    ctx.stroke();
    ctx.setLineDash([]);
    // Line from cursor to grabbed point
    ctx.beginPath();
    ctx.moveTo(pos.x, pos.y);
    ctx.lineTo(point.x, point.y);
    ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)';
    ctx.lineWidth = 1;
    ctx.setLineDash([2, 2]);
    ctx.stroke();
    ctx.setLineDash([]);
    // Grabbed point highlight
    const grad = ctx.createRadialGradient(point.x, point.y, 0, point.x, point.y, 8);
    grad.addColorStop(0, 'rgba(255, 255, 255, 0.8)');
    grad.addColorStop(1, 'rgba(255, 255, 255, 0)');
    ctx.beginPath();
    ctx.arc(point.x, point.y, 8, 0, Math.PI * 2);
    ctx.fillStyle = grad;
    ctx.fill();
    ctx.beginPath();
    ctx.arc(point.x, point.y, 4, 0, Math.PI * 2);
    ctx.fillStyle = '#fff';
    ctx.fill();
}
function drawPointDot(ctx, p, color) {
    ctx.beginPath();
    ctx.arc(p.x, p.y, config_1.CONFIG.pointRadius, 0, Math.PI * 2);
    ctx.fillStyle = color;
    ctx.fill();
}
function drawPins(ctx, points) {
    for (const p of points) {
        if (!p.pinned)
            continue;
        // Pin cross
        ctx.strokeStyle = config_1.CONFIG.colorPin;
        ctx.lineWidth = 2;
        ctx.beginPath();
        ctx.moveTo(p.x - 5, p.y - 5);
        ctx.lineTo(p.x + 5, p.y + 5);
        ctx.moveTo(p.x + 5, p.y - 5);
        ctx.lineTo(p.x - 5, p.y + 5);
        ctx.stroke();
        // Center dot
        ctx.beginPath();
        ctx.arc(p.x, p.y, 3, 0, Math.PI * 2);
        ctx.fillStyle = config_1.CONFIG.colorPin;
        ctx.fill();
    }
}
function drawHUD(ctx, r, w, h) {
    ctx.font = '11px monospace';
    ctx.textAlign = 'left';
    const lines = [];
    lines.push(`FPS: ${r.fps}`);
    lines.push(`Step: ${r.stepTimeMs.toFixed(2)}ms`);
    lines.push(`Pts: ${r.pointCount} | Cons: ${r.constraintCount}`);
    if (r.paused)
        lines.push('⏸ PAUSED');
    const startY = 16;
    for (let i = 0; i < lines.length; i++) {
        ctx.fillStyle = i === lines.length - 1 && r.paused ? '#fbbf24' : 'rgba(107, 114, 128, 0.8)';
        ctx.fillText(lines[i], 10, startY + i * 16);
    }
    // Gravity indicator
    if (config_1.CONFIG.gravityOn) {
        ctx.fillStyle = 'rgba(107, 114, 128, 0.6)';
        ctx.textAlign = 'right';
        ctx.fillText(`g=${config_1.CONFIG.gravity}`, w - 10, 20);
        ctx.textAlign = 'left';
    }
}
// Helper: hex color to RGB values
function hexToRgb(hex) {
    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 `${r},${g},${b}`;
}
};

// ── module: src/interaction.ts ──
__mods["src/interaction.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// Interaction — mouse/touch handling for grab, tear, pin, spawn
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.createInteraction = createInteraction;
exports.handleMouseDown = handleMouseDown;
exports.handleMouseMove = handleMouseMove;
exports.handleMouseUp = handleMouseUp;
exports.processTear = processTear;
exports.tearConstraintsNearSegment = tearConstraintsNearSegment;
exports.updateTearEffects = updateTearEffects;
const point_1 = require("./point");
const config_1 = require("./config");
function createInteraction(canvas) {
    return {
        canvas,
        spawnMode: 'none',
        pinMode: 'corners',
        isDragging: false,
        dragStart: null,
        dragCurrent: null,
        grabPoint: null,
        grabOffset: null,
        lastMousePos: { x: 0, y: 0 },
        lastMouseTime: 0,
        mouseVx: 0,
        mouseVy: 0,
        isTearing: false,
        tearPath: [],
        tearEffects: [],
    };
}
function getMousePos(canvas, e) {
    const rect = canvas.getBoundingClientRect();
    return {
        x: (e.clientX - rect.left) * (canvas.width / rect.width),
        y: (e.clientY - rect.top) * (canvas.height / rect.height),
    };
}
function findNearestPoint(points, pos, radius) {
    let best = null;
    let bestDist = radius;
    for (let i = 0; i < points.length; i++) {
        const p = points[i];
        if (p.pinned)
            continue; // don't grab pinned points (they're anchors)
        const dx = p.x - pos.x;
        const dy = p.y - pos.y;
        const dist = Math.sqrt(dx * dx + dy * dy);
        if (dist < bestDist) {
            bestDist = dist;
            best = p;
        }
    }
    return best;
}
function handleMouseDown(state, points, e) {
    const pos = getMousePos(state.canvas, e);
    // Update mouse velocity
    const now = performance.now();
    const dt = (now - state.lastMouseTime) / 1000;
    if (dt > 0.001) {
        state.mouseVx = (pos.x - state.lastMousePos.x) / dt;
        state.mouseVy = (pos.y - state.lastMousePos.y) / dt;
    }
    state.lastMousePos = { ...pos };
    state.lastMouseTime = now;
    // Right-click or Ctrl+click = tear mode
    if (e.button === 2 || e.ctrlKey || e.metaKey) {
        state.isTearing = true;
        state.tearPath = [pos];
        e.preventDefault();
        return;
    }
    // If in spawn mode, start dragging
    if (state.spawnMode !== 'none') {
        state.isDragging = true;
        state.dragStart = pos;
        state.dragCurrent = pos;
        return;
    }
    // Try to grab a point
    const grabbed = findNearestPoint(points, pos, config_1.CONFIG.grabRadius);
    if (grabbed) {
        state.grabPoint = grabbed;
        state.grabOffset = {
            x: grabbed.x - pos.x,
            y: grabbed.y - pos.y,
        };
        // Zero out velocity while grabbing
        (0, point_1.setVelocity)(grabbed, 0, 0, config_1.CONFIG.physicsDt);
        e.preventDefault();
        return;
    }
    // Shift+click = toggle pin on nearest point (even pinned ones)
    if (e.shiftKey) {
        const nearest = findNearestPointOrPinned(points, pos, config_1.CONFIG.grabRadius * 2);
        if (nearest && state.onPinToggle) {
            state.onPinToggle(nearest);
            e.preventDefault();
        }
    }
}
function findNearestPointOrPinned(points, pos, radius) {
    let best = null;
    let bestDist = radius;
    for (let i = 0; i < points.length; i++) {
        const p = points[i];
        const dx = p.x - pos.x;
        const dy = p.y - pos.y;
        const dist = Math.sqrt(dx * dx + dy * dy);
        if (dist < bestDist) {
            bestDist = dist;
            best = p;
        }
    }
    return best;
}
function handleMouseMove(state, points, e) {
    const pos = getMousePos(state.canvas, e);
    // Update mouse velocity
    const now = performance.now();
    const dt = (now - state.lastMouseTime) / 1000;
    if (dt > 0.001) {
        state.mouseVx = (pos.x - state.lastMousePos.x) / dt;
        state.mouseVy = (pos.y - state.lastMousePos.y) / dt;
    }
    state.lastMousePos = { ...pos };
    state.lastMouseTime = now;
    // Teardown
    if (state.isTearing) {
        state.tearPath.push(pos);
        // Check constraints near the tear path
        if (state.onTearConstraint) {
            // We need access to constraints — handled in main loop
        }
        return;
    }
    // Spawn dragging
    if (state.isDragging && state.dragStart) {
        state.dragCurrent = pos;
        return;
    }
    // Grab moving
    if (state.grabPoint && state.grabOffset) {
        const p = state.grabPoint;
        const targetX = pos.x + state.grabOffset.x;
        const targetY = pos.y + state.grabOffset.y;
        // Set velocity from mouse movement so it carries momentum on release
        p.x = targetX;
        p.y = targetY;
        // Clamp to canvas bounds
        p.x = Math.max(config_1.CONFIG.wallMargin, Math.min(state.canvas.width - config_1.CONFIG.wallMargin, p.x));
        p.y = Math.max(config_1.CONFIG.wallMargin, Math.min(state.canvas.height - config_1.CONFIG.wallMargin, p.y));
        // Store previous position for velocity
        p.px = p.x - state.mouseVx * config_1.CONFIG.physicsDt;
        p.py = p.y - state.mouseVy * config_1.CONFIG.physicsDt;
    }
}
function handleMouseUp(state, points, e) {
    const pos = getMousePos(state.canvas, e);
    // Release grab
    if (state.grabPoint) {
        // Throw with mouse velocity
        const v = (0, point_1.getVelocity)(state.grabPoint, config_1.CONFIG.physicsDt);
        const throwSpeed = Math.sqrt(v.vx * v.vx + v.vy * v.vy);
        // Boost throw velocity for satisfying feel
        state.grabPoint.px = state.grabPoint.x - (v.vx + state.mouseVx * 0.5) * config_1.CONFIG.physicsDt;
        state.grabPoint.py = state.grabPoint.y - (v.vy + state.mouseVy * 0.5) * config_1.CONFIG.physicsDt;
        state.grabPoint = null;
        state.grabOffset = null;
    }
    // Finish tear — process the tear path
    if (state.isTearing) {
        state.tearPath.push(pos);
        // Convert tear path to effects
        for (let i = 0; i < state.tearPath.length; i += 2) {
            state.tearEffects.push({
                x: state.tearPath[i].x,
                y: state.tearPath[i].y,
                life: 1.0,
            });
        }
        state.tearPath = [];
        state.isTearing = false;
    }
    // Finish spawn drag
    if (state.isDragging && state.dragStart && state.dragCurrent && state.spawnMode !== 'none') {
        if (state.onSpawn) {
            const dx = state.dragCurrent.x - state.dragStart.x;
            const dy = state.dragCurrent.y - state.dragStart.y;
            const dist = Math.sqrt(dx * dx + dy * dy);
            if (dist > 10) {
                // Spawn rope or cloth
                if (state.spawnMode === 'rope') {
                    state.onSpawn('rope', state.dragStart, state.dragCurrent);
                }
                else if (state.spawnMode === 'cloth') {
                    // Cloth: dragStart is top-left, size determined by drag
                    const width = Math.abs(dx);
                    const height = Math.abs(dy);
                    const cols = Math.max(5, Math.min(40, Math.floor(width / 8)));
                    const rows = Math.max(3, Math.min(30, Math.floor(height / 8)));
                    state.onSpawn('cloth', state.dragStart);
                }
            }
            else {
                // Click without drag — spawn at point
                if (state.spawnMode === 'ball') {
                    state.onSpawn('ball', state.dragStart);
                }
                else if (state.spawnMode === 'softBody') {
                    state.onSpawn('softBody', state.dragStart);
                }
            }
        }
    }
    state.isDragging = false;
    state.dragStart = null;
    state.dragCurrent = null;
}
/** Process tear constraints — call from main with constraints array */
function processTear(state, constraints) {
    if (state.tearPath.length < 2)
        return;
    const path = state.tearPath;
    for (let i = 1; i < path.length; i++) {
        const ax = path[i - 1].x;
        const ay = path[i - 1].y;
        const bx = path[i].x;
        const by = path[i].y;
        // Check each constraint for intersection with this line segment
        for (let j = 0; j < constraints.length; j++) {
            const c = constraints[j];
            if (!c.active)
                continue;
            // Simple distance check: is either endpoint of the constraint close to the tear line?
            const pa = c.a; // these are indices, but we need actual points
            // We'll check in the main module since we need access to points array
        }
    }
}
/** Tear constraints near a line segment — needs points array */
function tearConstraintsNearSegment(constraints, points, ax, ay, bx, by, radius = config_1.CONFIG.tearRadius) {
    let torn = 0;
    for (const c of constraints) {
        if (!c.active)
            continue;
        const pa = points[c.a];
        const pb = points[c.b];
        if (!pa || !pb)
            continue;
        // Distance from segment (ax,ay)-(bx,by) to point (pa.x, pa.y)
        const dA = distToSegment(ax, ay, bx, by, pa.x, pa.y);
        const dB = distToSegment(ax, ay, bx, by, pb.x, pb.y);
        if (dA < radius || dB < radius) {
            c.active = false;
            torn++;
            // Add tear effect
            const mx = (pa.x + pb.x) / 2;
            const my = (pa.y + pb.y) / 2;
            // We'll collect these in main
        }
    }
    return torn;
}
/** Distance from point (px, py) to line segment (ax, ay)-(bx, by) */
function distToSegment(ax, ay, bx, by, px, py) {
    const dx = bx - ax;
    const dy = by - ay;
    const lenSq = dx * dx + dy * dy;
    if (lenSq < 1e-12) {
        return Math.sqrt((px - ax) ** 2 + (py - ay) ** 2);
    }
    let t = ((px - ax) * dx + (py - ay) * dy) / lenSq;
    t = Math.max(0, Math.min(1, t));
    const projX = ax + t * dx;
    const projY = ay + t * dy;
    return Math.sqrt((px - projX) ** 2 + (py - projY) ** 2);
}
/** Decay tear effects */
function updateTearEffects(state, dt) {
    for (let i = state.tearEffects.length - 1; i >= 0; i--) {
        state.tearEffects[i].life -= dt * 3;
        if (state.tearEffects[i].life <= 0) {
            state.tearEffects.splice(i, 1);
        }
    }
}
};

// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"Qwen3.6-27B-MTP-GGUF","provider":"brigid","persona":"typescript","sessionId":"2b5cd0fa-b7cf-4266-9882-44dcf70d34dc","tokensIn":4321608,"tokensOut":52643,"tokensTotal":4374251,"cost":0,"turns":89,"toolCalls":88,"failedToolCalls":5,"timestamp":"2026-08-02T06:57:10.301Z"} -->