← Physics Playground results

Physics Playground

Qwen 3.6 27B MTP GGUF IQ4 NL · 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 53,111 bytes · SHA-256 499c14396f41
<!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; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #111; font-family: 'Segoe UI', system-ui, sans-serif; }
#canvas { display: block; position: absolute; top: 0; left: 0; width: 100%; height: 100%; cursor: crosshair; }
#toolbar {
  position: fixed; top: 8px; left: 50%; transform: translateX(-50%);
  display: flex; align-items: center; gap: 6px;
  background: rgba(20,20,30,0.85); border: 1px solid rgba(100,100,140,0.4);
  border-radius: 10px; padding: 6px 14px; z-index: 10;
  backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
}
#toolbar button {
  background: rgba(60,60,100,0.6); color: #ccc; border: 1px solid rgba(100,100,160,0.3);
  border-radius: 6px; padding: 5px 12px; font-size: 13px; cursor: pointer;
  transition: background 0.15s, border-color 0.15s;
}
#toolbar button:hover { background: rgba(80,80,140,0.7); border-color: rgba(140,140,200,0.5); color: #fff; }
#toolbar button.active { background: rgba(100,120,200,0.5); border-color: rgba(160,160,240,0.6); color: #fff; }
#toolbar .sep { width: 1px; height: 24px; background: rgba(100,100,140,0.4); }
#toolbar label { color: #999; font-size: 12px; display: flex; align-items: center; gap: 4px; white-space: nowrap; }
#toolbar input[type="range"] { width: 70px; height: 4px; accent-color: #6688cc; }
#hud {
  position: fixed; bottom: 8px; left: 8px;
  display: flex; gap: 16px; font-size: 12px; color: rgba(180,180,220,0.8);
  background: rgba(20,20,30,0.7); padding: 4px 12px; border-radius: 6px;
  backdrop-filter: blur(4px); z-index: 10;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas><div id="toolbar">
  <button id="btnRope" title="Rope (R)">⛓ Rope</button>
  <button id="btnCloth" title="Cloth (C)">🧵 Cloth</button>
  <button id="btnSoftBody" title="Soft Body (S)">🔵 Blob</button>
  <button id="btnBall" title="Ball (B)">⚪ Ball</button>
  <span class="sep"></span>
  <button id="btnPause" title="Pause (Space)">⏸ Pause</button>
  <button id="btnStep" title="Step (→)">⏭ Step</button>
  <button id="btnReset" title="Reset All">🗑 Reset</button>
  <span class="sep"></span>
  <label>Grav: <input type="range" id="gravSlider" min="0" max="2000" value="980" step="10"></label>
  <label>Iter: <input type="range" id="iterSlider" min="1" max="20" value="6" step="1"></label>
  <label>Wind: <input type="range" id="windSlider" min="-500" max="500" value="0" step="10"></label>
</div>
<div id="hud">
  <span id="hudFPS">FPS: --</span>
  <span id="hudPoints">Points: 0</span>
  <span id="hudConstraints">Constraints: 0</span>
  <span id="hudTime">Physics: -- ms</span>
  <span id="hudStatus"></span>
</div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./config":"src/config.ts","./simulation":"src/simulation.ts","./renderer":"src/renderer.ts","./input":"src/input.ts"},"src/simulation.ts":{"./config":"src/config.ts","./types":"src/types.ts"},"src/renderer.ts":{"./config":"src/config.ts","./simulation":"src/simulation.ts","./types":"src/types.ts"},"src/input.ts":{"./config":"src/config.ts","./simulation":"src/simulation.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 });
const config_1 = require("./config");
const simulation_1 = require("./simulation");
const renderer_1 = require("./renderer");
const input_1 = require("./input");
// ── Main Entry ──
let sim;
let renderer;
let input;
// HUD elements
let hudFPS;
let hudPoints;
let hudConstraints;
let hudTime;
let hudStatus;
// FPS tracking
let frameCount = 0;
let fpsTimer = 0;
let displayFPS = 0;
// Time tracking
let lastTime = 0;
let physicsTimeSum = 0;
let physicsFrameCount = 0;
function init() {
    const canvas = document.getElementById('canvas');
    const app = document.body;
    sim = new simulation_1.Simulation();
    renderer = new renderer_1.Renderer(canvas);
    input = new input_1.InputHandler(canvas, sim);
    hudFPS = document.getElementById('hudFPS');
    hudPoints = document.getElementById('hudPoints');
    hudConstraints = document.getElementById('hudConstraints');
    hudTime = document.getElementById('hudTime');
    hudStatus = document.getElementById('hudStatus');
    // Setup canvas size
    const w = app.clientWidth;
    const h = app.clientHeight;
    sim.canvasW = w;
    sim.canvasH = h;
    renderer.resize(w, h);
    sim.updateStaticCircles(w, h);
    // Handle resize
    window.addEventListener('resize', () => {
        const w = app.clientWidth;
        const h = app.clientHeight;
        sim.canvasW = w;
        sim.canvasH = h;
        renderer.resize(w, h);
        sim.updateStaticCircles(w, h);
    });
    // Toolbar button handlers
    const btnRope = document.getElementById('btnRope');
    const btnCloth = document.getElementById('btnCloth');
    const btnSoftBody = document.getElementById('btnSoftBody');
    const btnBall = document.getElementById('btnBall');
    const btnPause = document.getElementById('btnPause');
    const btnStep = document.getElementById('btnStep');
    const btnReset = document.getElementById('btnReset');
    const gravSlider = document.getElementById('gravSlider');
    const iterSlider = document.getElementById('iterSlider');
    const windSlider = document.getElementById('windSlider');
    function setActiveButton(activeTool) {
        btnRope.classList.toggle('active', activeTool === 'rope');
        btnCloth.classList.toggle('active', activeTool === 'cloth');
        btnSoftBody.classList.toggle('active', activeTool === 'softBody');
        btnBall.classList.toggle('active', activeTool === 'ball');
    }
    btnRope.addEventListener('click', () => { input.selectTool('rope'); setActiveButton('rope'); });
    btnCloth.addEventListener('click', () => { input.selectTool('cloth'); setActiveButton('cloth'); });
    btnSoftBody.addEventListener('click', () => { input.selectTool('softBody'); setActiveButton('softBody'); });
    btnBall.addEventListener('click', () => { input.selectTool('ball'); setActiveButton('ball'); });
    btnPause.addEventListener('click', () => {
        sim.paused = !sim.paused;
        btnPause.textContent = sim.paused ? '▶ Play' : '⏸ Pause';
        updateStatus();
    });
    btnStep.addEventListener('click', () => {
        if (sim.paused) {
            sim.step(config_1.CONFIG.FIXED_DT);
            renderer.render(sim);
        }
    });
    btnReset.addEventListener('click', () => {
        sim.reset();
        updateHUD();
    });
    gravSlider.addEventListener('input', () => {
        sim.gravity = parseFloat(gravSlider.value);
    });
    iterSlider.addEventListener('input', () => {
        sim.iterations = parseInt(iterSlider.value, 10);
    });
    windSlider.addEventListener('input', () => {
        sim.wind = parseFloat(windSlider.value);
    });
    // Input callbacks
    input.onTogglePause = (paused) => {
        btnPause.textContent = paused ? '▶ Play' : '⏸ Pause';
        updateStatus();
    };
    input.onStep = () => {
        sim.step(config_1.CONFIG.FIXED_DT);
        renderer.render(sim);
    };
    input.onToolSelect = (tool) => {
        setActiveButton(tool);
    };
    // Spawn some default objects for demo
    spawnDemoObjects();
    // Expose on window for testing
    window.sim = sim;
    // Start loop
    requestAnimationFrame(loop);
}
function spawnDemoObjects() {
    const w = sim.canvasW;
    const h = sim.canvasH;
    // A rope from near the top
    sim.spawnRope(w * 0.2, 30, config_1.CONFIG.ROPE_SEGMENTS, config_1.CONFIG.ROPE_SPACING, config_1.CONFIG.COLORS.rope);
    // A cloth pinned at corners from near the top
    sim.spawnCloth(w * 0.45, 50, config_1.CONFIG.CLOTH_COLS, config_1.CONFIG.CLOTH_ROWS, config_1.CONFIG.CLOTH_SPACING, config_1.CONFIG.COLORS.cloth, 'corners');
    // A soft body
    sim.spawnSoftBody(w * 0.75, 150, config_1.CONFIG.SOFT_BODY_RADIUS, config_1.CONFIG.SOFT_BODY_SEGMENTS, config_1.CONFIG.COLORS.softBody);
    // A ball
    sim.spawnBall(w * 0.1, 100, config_1.CONFIG.BALL_RADIUS, config_1.CONFIG.COLORS.ball);
}
function updateStatus() {
    hudStatus.textContent = sim.paused ? 'PAUSED' : '';
}
function updateHUD() {
    hudPoints.textContent = `Points: ${sim.totalPoints()}`;
    hudConstraints.textContent = `Constraints: ${sim.totalConstraints()}`;
}
function loop(timestamp) {
    // FPS calculation
    if (!lastTime)
        lastTime = timestamp;
    const deltaMs = timestamp - lastTime;
    lastTime = timestamp;
    frameCount++;
    fpsTimer += deltaMs;
    if (fpsTimer >= 1000) {
        displayFPS = Math.round(frameCount * 1000 / fpsTimer);
        hudFPS.textContent = `FPS: ${displayFPS}`;
        frameCount = 0;
        fpsTimer = 0;
        // Update physics time display
        if (physicsFrameCount > 0) {
            const avgMs = physicsTimeSum / physicsFrameCount;
            hudTime.textContent = `Physics: ${avgMs.toFixed(1)} ms`;
        }
        physicsTimeSum = 0;
        physicsFrameCount = 0;
        updateHUD();
    }
    if (!sim.paused) {
        // Fixed timestep accumulator with max delta clamp
        const delta = Math.min(deltaMs / 1000, config_1.CONFIG.MAX_FRAME_DELTA);
        sim.accumulator += delta;
        let steps = 0;
        const maxSteps = 10; // prevent spiral of death
        const startPhysics = performance.now();
        while (sim.accumulator >= config_1.CONFIG.FIXED_DT && steps < maxSteps) {
            sim.step(config_1.CONFIG.FIXED_DT);
            sim.accumulator -= config_1.CONFIG.FIXED_DT;
            steps++;
        }
        // Drain remaining accumulator to prevent spiral
        if (steps >= maxSteps) {
            sim.accumulator = 0;
        }
        const physicsMs = performance.now() - startPhysics;
        physicsTimeSum += physicsMs;
        physicsFrameCount++;
    }
    // Render
    renderer.render(sim);
    // Draw preview lines for rope/cloth tool
    if (input.mouseDown && input.tool === 'rope') {
        const ctx = renderer.ctx;
        ctx.beginPath();
        ctx.moveTo(input.mouseStartX, input.mouseStartY);
        ctx.lineTo(input.mouseX, input.mouseY);
        ctx.strokeStyle = 'rgba(79, 195, 247, 0.5)';
        ctx.lineWidth = 3;
        ctx.setLineDash([5, 5]);
        ctx.stroke();
        ctx.setLineDash([]);
    }
    else if (input.mouseDown && input.tool === 'cloth') {
        const ctx = renderer.ctx;
        const cols = config_1.CONFIG.CLOTH_COLS;
        const rows = config_1.CONFIG.CLOTH_ROWS;
        const spacing = config_1.CONFIG.CLOTH_SPACING;
        const w = cols * spacing;
        const h = rows * spacing;
        ctx.strokeStyle = 'rgba(129, 199, 132, 0.4)';
        ctx.lineWidth = 1;
        ctx.strokeRect(input.mouseStartX, input.mouseStartY, w, h);
    }
    requestAnimationFrame(loop);
}
// Start
init();
};

// ── module: src/config.ts ──
__mods["src/config.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CONFIG = void 0;
// Tunable constants
exports.CONFIG = {
    // Physics
    FIXED_DT: 1 / 60,
    MAX_FRAME_DELTA: 0.1, // clamp to 100ms to prevent explosion on tab switch
    DAMPING: 0.99,
    GRAVITY: 980,
    WIND: 0,
    ITERATIONS: 6,
    // Collision
    FLOOR_Y_OFFSET: 0, // floor is at canvas height - this
    WALL_INSET: 2,
    FRICTION: 0.8,
    RESTITUTION: 0.4,
    // Interaction
    GRAB_RADIUS: 20,
    TEAR_RADIUS: 15,
    THROW_SCALE: 3, // multiply release velocity for "throw"
    // Spawn sizes
    ROPE_SEGMENTS: 20,
    ROPE_SPACING: 12,
    CLOTH_COLS: 18,
    CLOTH_ROWS: 12,
    CLOTH_SPACING: 14,
    SOFT_BODY_RINGS: 1,
    SOFT_BODY_SEGMENTS: 14,
    SOFT_BODY_RADIUS: 40,
    BALL_RADIUS: 15,
    // Rendering
    POINT_RADIUS: 3,
    SHOW_POINTS: false,
    SHOW_STRESS: false,
    MOTION_BLUR: 0.15, // 0 = no blur, 1 = full trail
    // Colors per object type
    COLORS: {
        rope: '#4fc3f7',
        cloth: '#81c784',
        softBody: '#e57373',
        ball: '#ffb74d',
    },
    // Static geometry
    STATIC_CIRCLES: [
        { x: 0.5, y: 0.42, r: 45 }, // under the cloth
        { x: 0.8, y: 0.82, r: 35 }, // lower right
    ],
};
};

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

// ── module: src/simulation.ts ──
__mods["src/simulation.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Simulation = void 0;
const config_1 = require("./config");
// ── Simulation State ──
class Simulation {
    constructor() {
        this.points = [];
        this.constraints = [];
        this.objects = [];
        this.grabPoint = null;
        this.grabMouseX = 0;
        this.grabMouseY = 0;
        this.isDragging = false;
        this.dragStartX = 0;
        this.dragStartY = 0;
        this.paused = false;
        this.accumulator = 0;
        this.canvasW = 800;
        this.canvasH = 600;
        this.gravity = config_1.CONFIG.GRAVITY;
        this.wind = 0;
        this.iterations = config_1.CONFIG.ITERATIONS;
        this.showStress = false;
        // For exposed testing
        this.staticCircles = [];
        // Nothing — populated at runtime
    }
    // ── Point/Constraint Helpers ──
    addPoint(x, y, pinned = false, radius = 0, obj = -1) {
        const idx = this.points.length;
        this.points.push({
            x, y,
            px: x, py: y,
            pinned,
            radius,
            obj,
        });
        return idx;
    }
    addConstraint(a, b, length, obj) {
        const idx = this.constraints.length;
        this.constraints.push({ a, b, length, obj, torn: false });
        return idx;
    }
    // Safe distance helper (avoids sqrt(0))
    dist(x1, y1, x2, y2) {
        const dx = x2 - x1, dy = y2 - y1;
        return Math.sqrt(dx * dx + dy * dy);
    }
    // ── Object Spawning ──
    spawnRope(x, y, segments, spacing, color) {
        const objIdx = this.objects.length;
        const obj = {
            type: 'rope', color,
            pointStart: this.points.length, pointCount: 0,
            constraintStart: this.constraints.length, constraintCount: 0,
            alive: true,
        };
        // Add all points first
        for (let i = 0; i < segments + 1; i++) {
            this.addPoint(x, y + i * spacing, i === 0, 0, objIdx); // pin top
        }
        // Then add constraints between consecutive points
        for (let i = 0; i < segments; i++) {
            this.addConstraint(this.points.length - segments - 1 + i, this.points.length - segments + i, spacing, objIdx);
        }
        obj.pointCount = segments + 1;
        obj.constraintCount = segments;
        this.objects.push(obj);
    }
    spawnCloth(x, y, cols, rows, spacing, color, pinPattern) {
        const objIdx = this.objects.length;
        const obj = {
            type: 'cloth', color,
            pointStart: this.points.length, pointCount: 0,
            constraintStart: this.constraints.length, constraintCount: 0,
            alive: true,
        };
        // Create grid
        const grid = [];
        for (let row = 0; row < rows; row++) {
            grid[row] = [];
            for (let col = 0; col < cols; col++) {
                const px = x + col * spacing;
                const py = y + row * spacing;
                let pinned = false;
                if (pinPattern === 'full' && row === 0) {
                    pinned = true;
                }
                else if (pinPattern === 'corners' && row === 0 && (col === 0 || col === cols - 1)) {
                    pinned = true;
                }
                grid[row][col] = this.addPoint(px, py, pinned, 0, objIdx);
            }
        }
        obj.pointCount = cols * rows;
        // Structural + shear constraints
        for (let row = 0; row < rows; row++) {
            for (let col = 0; col < cols; col++) {
                const idx = grid[row][col];
                // Right neighbor
                if (col + 1 < cols) {
                    this.addConstraint(idx, grid[row][col + 1], spacing, objIdx);
                }
                // Bottom neighbor
                if (row + 1 < rows) {
                    this.addConstraint(idx, grid[row + 1][col], spacing, objIdx);
                }
                // Shear: bottom-right diagonal
                if (row + 1 < rows && col + 1 < cols) {
                    this.addConstraint(idx, grid[row + 1][col + 1], spacing * Math.SQRT2, objIdx);
                }
            }
        }
        obj.constraintCount = this.constraints.length - obj.constraintStart;
        this.objects.push(obj);
    }
    spawnSoftBody(x, y, radius, segments, color) {
        const objIdx = this.objects.length;
        const obj = {
            type: 'softBody', color,
            pointStart: this.points.length, pointCount: 0,
            constraintStart: this.constraints.length, constraintCount: 0,
            alive: true,
        };
        // Create ring of points
        const ring = [];
        for (let i = 0; i < segments; i++) {
            const angle = (i / segments) * Math.PI * 2 - Math.PI / 2;
            const px = x + Math.cos(angle) * radius;
            const py = y + Math.sin(angle) * radius;
            ring.push(this.addPoint(px, py, false, 0, objIdx));
        }
        // Center point
        const center = this.addPoint(x, y, false, 0, objIdx);
        obj.pointCount = segments + 1;
        // Edge constraints (ring)
        for (let i = 0; i < segments; i++) {
            const next = (i + 1) % segments;
            this.addConstraint(ring[i], ring[next], radius * 2 * Math.sin(Math.PI / segments), objIdx);
        }
        // Internal braces: center to each ring point
        for (let i = 0; i < segments; i++) {
            this.addConstraint(center, ring[i], radius, objIdx);
        }
        // Cross braces for extra stiffness
        for (let i = 0; i < segments; i++) {
            const opp = (i + Math.floor(segments / 2)) % segments;
            this.addConstraint(ring[i], ring[opp], radius * 2, objIdx);
        }
        obj.constraintCount = this.constraints.length - obj.constraintStart;
        this.objects.push(obj);
    }
    spawnBall(x, y, radius, color) {
        const objIdx = this.objects.length;
        const obj = {
            type: 'ball', color,
            pointStart: this.points.length, pointCount: 1,
            constraintStart: this.constraints.length, constraintCount: 0,
            alive: true,
        };
        this.addPoint(x, y, false, radius, objIdx);
        this.objects.push(obj);
    }
    // ── Physics Step ──
    updateStaticCircles(w, h) {
        this.staticCircles = config_1.CONFIG.STATIC_CIRCLES.map(c => ({
            x: c.x * w,
            y: (c.y) * h,
            r: c.r,
        }));
    }
    step(dt) {
        const damping = config_1.CONFIG.DAMPING;
        const floorY = this.canvasH - config_1.CONFIG.FLOOR_Y_OFFSET;
        const wallLeft = config_1.CONFIG.WALL_INSET;
        const wallRight = this.canvasW - config_1.CONFIG.WALL_INSET;
        // 1. Verlet Integration
        for (let i = 0; i < this.points.length; i++) {
            const p = this.points[i];
            if (p.pinned)
                continue;
            if (this.grabPoint === i)
                continue; // grabbed points follow mouse
            const vx = (p.x - p.px) * damping;
            const vy = (p.y - p.py) * damping;
            p.px = p.x;
            p.py = p.y;
            p.x += vx;
            p.y += vy + this.gravity * dt * dt;
            // Wind
            p.x += this.wind * dt * dt;
        }
        // 2. Constraint Relaxation (multiple iterations)
        for (let iter = 0; iter < this.iterations; iter++) {
            // Distance constraints
            for (let i = 0; i < this.constraints.length; i++) {
                const c = this.constraints[i];
                if (c.torn)
                    continue;
                const pa = this.points[c.a];
                const pb = this.points[c.b];
                const dx = pb.x - pa.x;
                const dy = pb.y - pa.y;
                const distSq = dx * dx + dy * dy;
                // Guard against zero-length / coincident
                if (distSq < 0.0001)
                    continue;
                const dist = Math.sqrt(distSq);
                const diff = (c.length - dist) / dist;
                // Guard against division by zero in mass calc
                const totalMass = (pa.pinned ? 0 : 1) + (pb.pinned ? 0 : 1);
                if (totalMass < 0.001)
                    continue;
                const halfDiff = diff * 0.5;
                if (!pa.pinned) {
                    pa.x -= dx * halfDiff;
                    pa.y -= dy * halfDiff;
                }
                if (!pb.pinned) {
                    pb.x += dx * halfDiff;
                    pb.y += dy * halfDiff;
                }
            }
            // Wall/Floor collisions
            for (let i = 0; i < this.points.length; i++) {
                const p = this.points[i];
                if (p.pinned)
                    continue;
                // Floor
                if (p.y > floorY) {
                    const vy = p.y - p.py;
                    p.y = floorY;
                    p.py = p.y + vy * config_1.CONFIG.RESTITUTION;
                    p.px = p.x - (p.x - p.px) * config_1.CONFIG.FRICTION;
                }
                // Ceiling
                if (p.y < -p.radius) {
                    p.y = -p.radius;
                    p.py = p.y + (p.y - p.py) * config_1.CONFIG.RESTITUTION;
                }
                // Left wall
                if (p.x < wallLeft) {
                    const vx = p.x - p.px;
                    p.x = wallLeft;
                    p.px = p.x + vx * config_1.CONFIG.RESTITUTION;
                    p.py = p.y - (p.y - p.py) * config_1.CONFIG.FRICTION;
                }
                // Right wall
                if (p.x > wallRight) {
                    const vx = p.x - p.px;
                    p.x = wallRight;
                    p.px = p.x + vx * config_1.CONFIG.RESTITUTION;
                    p.py = p.y - (p.y - p.py) * config_1.CONFIG.FRICTION;
                }
                // Static circle collisions
                for (let s = 0; s < this.staticCircles.length; s++) {
                    const sc = this.staticCircles[s];
                    const dx = p.x - sc.x;
                    const dy = p.y - sc.y;
                    const distSq = dx * dx + dy * dy;
                    const minDist = sc.r + p.radius;
                    if (distSq < minDist * minDist && distSq > 0.0001) {
                        const dist = Math.sqrt(distSq);
                        const nx = dx / dist;
                        const ny = dy / dist;
                        const overlap = minDist - dist;
                        p.x += nx * overlap;
                        p.y += ny * overlap;
                        // Friction: damp tangential velocity
                        const vx = p.x - p.px;
                        const vy = p.y - p.py;
                        const dot = vx * nx + vy * ny;
                        const tx = vx - dot * nx;
                        const ty = vy - dot * ny;
                        p.px = p.x - (dot * nx + tx * config_1.CONFIG.FRICTION);
                        p.py = p.y - (dot * ny + ty * config_1.CONFIG.FRICTION);
                    }
                }
            }
            // Point-to-point collisions (for balls mainly)
            for (let i = 0; i < this.points.length; i++) {
                const pi = this.points[i];
                if (pi.pinned || pi.radius <= 0)
                    continue;
                for (let j = i + 1; j < this.points.length; j++) {
                    const pj = this.points[j];
                    if (pj.pinned || pj.radius <= 0)
                        continue;
                    if (pi.obj === pj.obj)
                        continue; // skip same object
                    const dx = pj.x - pi.x;
                    const dy = pj.y - pi.y;
                    const distSq = dx * dx + dy * dy;
                    const minDist = pi.radius + pj.radius;
                    if (distSq < minDist * minDist && distSq > 0.0001) {
                        const dist = Math.sqrt(distSq);
                        const nx = dx / dist;
                        const ny = dy / dist;
                        const overlap = (minDist - dist) * 0.5;
                        if (!pi.pinned) {
                            pi.x -= nx * overlap;
                            pi.y -= ny * overlap;
                        }
                        if (!pj.pinned) {
                            pj.x += nx * overlap;
                            pj.y += ny * overlap;
                        }
                    }
                }
            }
        }
        // NaN guard: reset any NaN points
        for (let i = 0; i < this.points.length; i++) {
            const p = this.points[i];
            if (Number.isNaN(p.x) || Number.isNaN(p.y) || Number.isNaN(p.px) || Number.isNaN(p.py)) {
                p.x = 0;
                p.y = 0;
                p.px = 0;
                p.py = 0;
            }
        }
    }
    // ── Grab / Interaction ──
    findNearestPoint(mx, my, radius) {
        let bestIdx = -1;
        let bestDist = radius * radius;
        for (let i = 0; i < this.points.length; i++) {
            const p = this.points[i];
            const dx = p.x - mx;
            const dy = p.y - my;
            const d2 = dx * dx + dy * dy;
            if (d2 < bestDist) {
                bestDist = d2;
                bestIdx = i;
            }
        }
        return bestIdx >= 0 ? bestIdx : null;
    }
    grabPointAt(mx, my) {
        const idx = this.findNearestPoint(mx, my, config_1.CONFIG.GRAB_RADIUS);
        if (idx !== null && idx >= 0) {
            this.grabPoint = idx;
            this.isDragging = true;
            this.grabMouseX = mx;
            this.grabMouseY = my;
            this.dragStartX = mx;
            this.dragStartY = my;
            return true;
        }
        return false;
    }
    releasePoint() {
        if (this.grabPoint !== null) {
            // Apply throw velocity
            const p = this.points[this.grabPoint];
            const throwX = this.grabMouseX;
            const throwY = this.grabMouseY;
            p.px = p.x - (throwX - p.x) * config_1.CONFIG.THROW_SCALE;
            p.py = p.y - (throwY - p.y) * config_1.CONFIG.THROW_SCALE;
        }
        this.grabPoint = null;
        this.isDragging = false;
    }
    // Update grabbed point position during drag
    updateGrab(mx, my) {
        if (this.grabPoint !== null) {
            const p = this.points[this.grabPoint];
            p.x = mx;
            p.y = my;
        }
    }
    // Tear constraints along a line segment
    tearAlongLine(x1, y1, x2, y2) {
        const tearR = config_1.CONFIG.TEAR_RADIUS;
        for (let i = 0; i < this.constraints.length; i++) {
            const c = this.constraints[i];
            if (c.torn)
                continue;
            const pa = this.points[c.a];
            const pb = this.points[c.b];
            // Distance from segment to constraint midpoint
            const mx = (pa.x + pb.x) * 0.5;
            const my = (pa.y + pb.y) * 0.5;
            // Simple: check if midpoint is near the drag line
            const dx = mx - x1;
            const dy = my - y1;
            const d2 = dx * dx + dy * dy;
            if (d2 < tearR * tearR) {
                c.torn = true;
            }
            // Also check distance from line segment to both endpoints
            const distToA = this.distPointToSegment(pa.x, pa.y, x1, y1, x2, y2);
            const distToB = this.distPointToSegment(pb.x, pb.y, x1, y1, x2, y2);
            if (distToA < tearR || distToB < tearR) {
                c.torn = true;
            }
        }
    }
    distPointToSegment(px, py, x1, y1, x2, y2) {
        const dx = x2 - x1;
        const dy = y2 - y1;
        const lenSq = dx * dx + dy * dy;
        if (lenSq < 0.0001)
            return this.dist(px, py, x1, y1);
        let t = ((px - x1) * dx + (py - y1) * dy) / lenSq;
        t = Math.max(0, Math.min(1, t));
        const projX = x1 + t * dx;
        const projY = y1 + t * dy;
        return this.dist(px, py, projX, projY);
    }
    togglePin(mx, my) {
        const idx = this.findNearestPoint(mx, my, config_1.CONFIG.GRAB_RADIUS);
        if (idx !== null && idx >= 0) {
            this.points[idx].pinned = !this.points[idx].pinned;
        }
    }
    // ── Utilities ──
    getKineticEnergy() {
        let energy = 0;
        for (let i = 0; i < this.points.length; i++) {
            const p = this.points[i];
            const vx = p.x - p.px;
            const vy = p.y - p.py;
            energy += vx * vx + vy * vy;
        }
        return energy;
    }
    hasNaN() {
        for (let i = 0; i < this.points.length; i++) {
            const p = this.points[i];
            if (Number.isNaN(p.x) || Number.isNaN(p.y))
                return true;
        }
        return false;
    }
    outOfBounds(bound) {
        for (let i = 0; i < this.points.length; i++) {
            const p = this.points[i];
            if (Math.abs(p.x) > bound || Math.abs(p.y) > bound)
                return true;
        }
        return false;
    }
    reset() {
        this.points = [];
        this.constraints = [];
        this.objects = [];
        this.grabPoint = null;
        this.isDragging = false;
        this.accumulator = 0;
    }
    totalPoints() {
        return this.points.length;
    }
    totalConstraints() {
        return this.constraints.length;
    }
}
exports.Simulation = Simulation;
};

// ── module: src/renderer.ts ──
__mods["src/renderer.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Renderer = void 0;
const config_1 = require("./config");
// ── Renderer ──
class Renderer {
    constructor(canvas) {
        this.canvas = canvas;
        this.ctx = canvas.getContext('2d');
    }
    resize(w, h) {
        const dpr = window.devicePixelRatio || 1;
        this.canvas.width = w * dpr;
        this.canvas.height = h * dpr;
        this.canvas.style.width = w + 'px';
        this.canvas.style.height = h + 'px';
        this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    }
    render(sim) {
        const ctx = this.ctx;
        const w = sim.canvasW;
        const h = sim.canvasH;
        // Motion blur: semi-transparent overlay
        if (config_1.CONFIG.MOTION_BLUR > 0 && !sim.paused) {
            ctx.fillStyle = `rgba(17, 17, 25, ${1 - config_1.CONFIG.MOTION_BLUR})`;
            ctx.fillRect(0, 0, w, h);
        }
        else {
            // Full clear
            ctx.fillStyle = '#111119';
            ctx.fillRect(0, 0, w, h);
        }
        // Draw walls/floor
        this.drawBounds(ctx, w, h);
        // Draw static circles
        for (const sc of sim.staticCircles) {
            ctx.beginPath();
            ctx.arc(sc.x, sc.y, sc.r, 0, Math.PI * 2);
            ctx.fillStyle = 'rgba(60, 60, 80, 0.6)';
            ctx.fill();
            ctx.strokeStyle = 'rgba(100, 100, 130, 0.5)';
            ctx.lineWidth = 1.5;
            ctx.stroke();
        }
        // Render each object
        for (const obj of sim.objects) {
            if (!obj.alive)
                continue;
            switch (obj.type) {
                case 'rope':
                    this.renderRope(sim, obj);
                    break;
                case 'cloth':
                    this.renderCloth(sim, obj);
                    break;
                case 'softBody':
                    this.renderSoftBody(sim, obj);
                    break;
                case 'ball':
                    this.renderBall(sim, obj);
                    break;
            }
        }
        // Render points
        if (config_1.CONFIG.SHOW_POINTS) {
            this.renderPoints(sim);
        }
        // Draw grab highlight
        if (sim.grabPoint !== null) {
            const gp = sim.points[sim.grabPoint];
            ctx.beginPath();
            ctx.arc(gp.x, gp.y, config_1.CONFIG.GRAB_RADIUS, 0, Math.PI * 2);
            ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
            ctx.lineWidth = 2;
            ctx.stroke();
        }
    }
    drawBounds(ctx, w, h) {
        const inset = config_1.CONFIG.WALL_INSET;
        ctx.strokeStyle = 'rgba(80, 80, 110, 0.4)';
        ctx.lineWidth = 2;
        ctx.strokeRect(inset, inset, w - inset * 2, h - inset * 2);
        // Floor highlight
        ctx.fillStyle = 'rgba(60, 60, 90, 0.15)';
        ctx.fillRect(inset, h - 4, w - inset * 2, 4);
    }
    renderRope(sim, obj) {
        const ctx = this.ctx;
        const stressVis = sim.showStress;
        // Collect connected chains (handle torn segments)
        const chains = [];
        const constraintTorn = [];
        for (let i = 0; i < obj.constraintCount; i++) {
            constraintTorn[i] = sim.constraints[obj.constraintStart + i].torn;
        }
        // Build chains of connected points
        let chain = [obj.pointStart];
        for (let i = 0; i < obj.constraintCount; i++) {
            if (!constraintTorn[i]) {
                chain.push(obj.pointStart + i + 1);
            }
            else {
                if (chain.length > 1)
                    chains.push(chain);
                chain = [obj.pointStart + i + 1];
            }
        }
        if (chain.length > 1)
            chains.push(chain);
        // Draw each chain as a smooth curve
        for (const ch of chains) {
            ctx.beginPath();
            for (let j = 0; j < ch.length; j++) {
                const p = sim.points[ch[j]];
                if (j === 0)
                    ctx.moveTo(p.x, p.y);
                else
                    ctx.lineTo(p.x, p.y);
            }
            if (stressVis) {
                // Color by stress along the chain
                const gradient = ctx.createLinearGradient(sim.points[ch[0]].x, sim.points[ch[0]].y, sim.points[ch[ch.length - 1]].x, sim.points[ch[ch.length - 1]].y);
                gradient.addColorStop(0, '#4caf50');
                gradient.addColorStop(1, '#f44336');
                ctx.strokeStyle = gradient;
            }
            else {
                ctx.strokeStyle = obj.color;
            }
            ctx.lineWidth = 3;
            ctx.lineCap = 'round';
            ctx.lineJoin = 'round';
            ctx.stroke();
            // Draw dots at joints for visual clarity
            for (let j = 0; j < ch.length; j++) {
                const p = sim.points[ch[j]];
                ctx.beginPath();
                ctx.arc(p.x, p.y, 2.5, 0, Math.PI * 2);
                ctx.fillStyle = obj.color;
                ctx.fill();
            }
            // Pin indicator
            const firstP = sim.points[ch[0]];
            if (firstP.pinned) {
                ctx.beginPath();
                ctx.arc(firstP.x, firstP.y, 5, 0, Math.PI * 2);
                ctx.fillStyle = '#fff';
                ctx.fill();
                ctx.strokeStyle = obj.color;
                ctx.lineWidth = 2;
                ctx.stroke();
            }
        }
    }
    renderCloth(sim, obj) {
        const ctx = this.ctx;
        const cols = config_1.CONFIG.CLOTH_COLS;
        const rows = config_1.CONFIG.CLOTH_ROWS;
        // Build grid to render
        const grid = [];
        const pStart = obj.pointStart;
        for (let r = 0; r < rows; r++) {
            grid[r] = [];
            for (let c = 0; c < cols; c++) {
                const idx = pStart + r * cols + c;
                grid[r][c] = sim.points[idx];
            }
        }
        // Build constraint lookup: map "a_b" → torn status (either direction)
        const tornLookup = new Map();
        for (let ci = 0; ci < sim.constraints.length; ci++) {
            const cc = sim.constraints[ci];
            const key1 = `${cc.a}_${cc.b}`;
            const key2 = `${cc.b}_${cc.a}`;
            tornLookup.set(key1, cc.torn);
            tornLookup.set(key2, cc.torn);
        }
        const isTorn = (a, b) => {
            return tornLookup.get(`${a}_${b}`) === true;
        };
        // Draw filled quads as triangles (2 per quad)
        for (let r = 0; r < rows - 1; r++) {
            for (let c = 0; c < cols - 1; c++) {
                const p00 = grid[r][c];
                const p10 = grid[r][c + 1];
                const p01 = grid[r + 1][c];
                const p11 = grid[r + 1][c + 1];
                const idx00 = pStart + r * cols + c;
                const idx10 = pStart + r * cols + c + 1;
                const idx01 = pStart + (r + 1) * cols + c;
                const idx11 = pStart + (r + 1) * cols + c + 1;
                const rightTorn = isTorn(idx00, idx10);
                const leftTorn = isTorn(idx01, idx11);
                const vertLTorn = isTorn(idx00, idx01);
                const vertRTorn = isTorn(idx10, idx11);
                // Draw triangle 1: top-left, top-right, bottom-left
                if (!rightTorn && !vertLTorn) {
                    ctx.beginPath();
                    ctx.moveTo(p00.x, p00.y);
                    ctx.lineTo(p10.x, p10.y);
                    ctx.lineTo(p01.x, p01.y);
                    ctx.closePath();
                    ctx.fillStyle = this.alphaColor(obj.color, 0.2);
                    ctx.fill();
                }
                // Draw triangle 2: top-right, bottom-right, bottom-left
                if (!vertRTorn && !leftTorn) {
                    ctx.beginPath();
                    ctx.moveTo(p10.x, p10.y);
                    ctx.lineTo(p11.x, p11.y);
                    ctx.lineTo(p01.x, p01.y);
                    ctx.closePath();
                    ctx.fillStyle = this.alphaColor(obj.color, 0.2);
                    ctx.fill();
                }
            }
        }
        // Draw grid lines (more visible structure)
        for (let r = 0; r < rows; r++) {
            for (let c = 0; c < cols - 1; c++) {
                const idx = pStart + r * cols + c;
                const idxR = pStart + r * cols + c + 1;
                if (!isTorn(idx, idxR)) {
                    ctx.beginPath();
                    ctx.moveTo(grid[r][c].x, grid[r][c].y);
                    ctx.lineTo(grid[r][c + 1].x, grid[r][c + 1].y);
                    ctx.strokeStyle = this.alphaColor(obj.color, 0.35);
                    ctx.lineWidth = 0.8;
                    ctx.stroke();
                }
            }
        }
        for (let r = 0; r < rows - 1; r++) {
            for (let c = 0; c < cols; c++) {
                const idx = pStart + r * cols + c;
                const idxB = pStart + (r + 1) * cols + c;
                if (!isTorn(idx, idxB)) {
                    ctx.beginPath();
                    ctx.moveTo(grid[r][c].x, grid[r][c].y);
                    ctx.lineTo(grid[r + 1][c].x, grid[r + 1][c].y);
                    ctx.strokeStyle = this.alphaColor(obj.color, 0.35);
                    ctx.lineWidth = 0.8;
                    ctx.stroke();
                }
            }
        }
        // Draw pin indicators
        for (let r = 0; r < rows; r++) {
            for (let c = 0; c < cols; c++) {
                const p = grid[r][c];
                if (p.pinned) {
                    ctx.beginPath();
                    ctx.arc(p.x, p.y, 5, 0, Math.PI * 2);
                    ctx.fillStyle = '#fff';
                    ctx.fill();
                    ctx.strokeStyle = this.alphaColor(obj.color, 0.8);
                    ctx.lineWidth = 2;
                    ctx.stroke();
                }
            }
        }
    }
    renderSoftBody(sim, obj) {
        const ctx = this.ctx;
        const segments = obj.pointCount - 1; // center + ring
        const pStart = obj.pointStart;
        // Collect ring points in order
        const ringPts = [];
        for (let i = 0; i < segments; i++) {
            ringPts.push(sim.points[pStart + i]);
        }
        // Find torn edges to split into chains
        const tornEdges = [];
        for (let i = 0; i < segments; i++) {
            const next = (i + 1) % segments;
            const ci = obj.constraintStart + i;
            tornEdges[i] = sim.constraints[ci].torn;
        }
        // Build chains of connected (non-torn) points
        const chains = [];
        for (let i = 0; i < segments; i++) {
            let chain = [i];
            while (tornEdges[(chain[chain.length - 1]) % segments] && chain.length < segments) {
                const next = (chain[chain.length - 1] + 1) % segments;
                if (next === chain[0] && chain.length > 1)
                    break;
                chain.push(next);
            }
            chains.push(chain);
            // Skip already added
            for (let j = 1; j < chain.length; j++) {
                // We'll deduplicate by skipping chains that start with already-added indices
            }
        }
        // Simpler approach: just draw what we can
        // Draw filled polygon from all ring points (even with tears, it looks fine for soft body)
        ctx.beginPath();
        for (let i = 0; i < segments; i++) {
            const p = ringPts[i];
            if (i === 0)
                ctx.moveTo(p.x, p.y);
            else
                ctx.lineTo(p.x, p.y);
        }
        ctx.closePath();
        ctx.fillStyle = this.alphaColor(obj.color, 0.3);
        ctx.fill();
        ctx.strokeStyle = obj.color;
        ctx.lineWidth = 2;
        ctx.stroke();
        // Draw internal lines (center to ring)
        const center = sim.points[pStart + segments];
        for (let i = 0; i < segments; i++) {
            const ci = obj.constraintStart + segments + i; // center-to-ring constraints
            if (ci < sim.constraints.length && !sim.constraints[ci].torn) {
                const p = ringPts[i];
                ctx.beginPath();
                ctx.moveTo(center.x, center.y);
                ctx.lineTo(p.x, p.y);
                ctx.strokeStyle = this.alphaColor(obj.color, 0.2);
                ctx.lineWidth = 1;
                ctx.stroke();
            }
        }
        // Draw cross braces
        for (let i = 0; i < segments; i++) {
            const ci = obj.constraintStart + segments * 2 + i;
            if (ci >= sim.constraints.length)
                break;
            const c = sim.constraints[ci];
            if (c.torn)
                continue;
            const pa = sim.points[c.a];
            const pb = sim.points[c.b];
            ctx.beginPath();
            ctx.moveTo(pa.x, pa.y);
            ctx.lineTo(pb.x, pb.y);
            ctx.strokeStyle = this.alphaColor(obj.color, 0.15);
            ctx.lineWidth = 1;
            ctx.stroke();
        }
        // Draw pins
        for (let i = 0; i < obj.pointCount; i++) {
            const p = sim.points[pStart + i];
            if (p.pinned) {
                ctx.beginPath();
                ctx.arc(p.x, p.y, 4, 0, Math.PI * 2);
                ctx.fillStyle = '#fff';
                ctx.fill();
            }
        }
    }
    renderBall(sim, obj) {
        const ctx = this.ctx;
        const p = sim.points[obj.pointStart];
        const r = p.radius;
        // Glow
        const gradient = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r * 2);
        gradient.addColorStop(0, this.alphaColor(obj.color, 0.4));
        gradient.addColorStop(1, 'rgba(0,0,0,0)');
        ctx.beginPath();
        ctx.arc(p.x, p.y, r * 2, 0, Math.PI * 2);
        ctx.fillStyle = gradient;
        ctx.fill();
        // Ball
        ctx.beginPath();
        ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
        ctx.fillStyle = obj.color;
        ctx.fill();
        ctx.strokeStyle = this.alphaColor(obj.color, 0.8);
        ctx.lineWidth = 2;
        ctx.stroke();
    }
    renderPoints(sim) {
        const ctx = this.ctx;
        for (let i = 0; i < sim.points.length; i++) {
            const p = sim.points[i];
            const obj = sim.objects.find(o => o.pointStart <= i && i < o.pointStart + o.pointCount);
            const color = obj ? obj.color : '#fff';
            ctx.beginPath();
            ctx.arc(p.x, p.y, p.pinned ? 4 : config_1.CONFIG.POINT_RADIUS, 0, Math.PI * 2);
            if (p.pinned) {
                ctx.fillStyle = '#fff';
            }
            else {
                ctx.fillStyle = color;
            }
            ctx.fill();
        }
    }
    alphaColor(hex, alpha) {
        // Parse hex color
        const r = parseInt(hex.slice(1, 3), 16);
        const g = parseInt(hex.slice(3, 5), 16);
        const b = parseInt(hex.slice(5, 7), 16);
        return `rgba(${r}, ${g}, ${b}, ${alpha})`;
    }
}
exports.Renderer = Renderer;
};

// ── module: src/input.ts ──
__mods["src/input.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.InputHandler = void 0;
const config_1 = require("./config");
class InputHandler {
    constructor(canvas, sim) {
        this.tool = null;
        this.mouseDown = false;
        this.rightMouseDown = false;
        this.shiftDown = false;
        this.mouseX = 0;
        this.mouseY = 0;
        this.mouseStartX = 0;
        this.mouseStartY = 0;
        this.lastMouseX = 0;
        this.lastMouseY = 0;
        // For rope drawing preview
        this.ropePreviewEnd = false;
        this.canvas = canvas;
        this.sim = sim;
        this.setupListeners();
    }
    setupListeners() {
        this.canvas.addEventListener('mousedown', (e) => this.onMouseDown(e));
        this.canvas.addEventListener('mousemove', (e) => this.onMouseMove(e));
        this.canvas.addEventListener('mouseup', (e) => this.onMouseUp(e));
        this.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
        window.addEventListener('keydown', (e) => this.onKeyDown(e));
        window.addEventListener('keyup', (e) => this.onKeyUp(e));
    }
    getCanvasPos(e) {
        const rect = this.canvas.getBoundingClientRect();
        const scaleX = this.sim.canvasW / rect.width;
        const scaleY = this.sim.canvasH / rect.height;
        return {
            x: (e.clientX - rect.left) * scaleX,
            y: (e.clientY - rect.top) * scaleY,
        };
    }
    onMouseDown(e) {
        const pos = this.getCanvasPos(e);
        this.mouseX = pos.x;
        this.mouseY = pos.y;
        this.lastMouseX = pos.x;
        this.lastMouseY = pos.y;
        if (e.button === 2 || e.shiftKey) {
            // Right click or shift: tear mode
            this.rightMouseDown = true;
            this.mouseStartX = pos.x;
            this.mouseStartY = pos.y;
            this.onTear(pos.x, pos.y);
            return;
        }
        if (e.altKey || e.ctrlKey) {
            // Alt/Ctrl: toggle pin
            this.sim.togglePin(pos.x, pos.y);
            return;
        }
        this.mouseDown = true;
        this.mouseStartX = pos.x;
        this.mouseStartY = pos.y;
        if (this.tool !== null) {
            // Starting to draw a new object
            // Rope and cloth need drag to define size
            // Soft body and ball are instant
            if (this.tool === 'ball') {
                this.spawnObject(pos.x, pos.y);
            }
            else if (this.tool === 'softBody') {
                this.spawnObject(pos.x, pos.y);
            }
            // rope and cloth: wait for mouseup or drag
        }
        else {
            // No tool selected: try to grab
            this.sim.grabPointAt(pos.x, pos.y);
        }
    }
    onMouseMove(e) {
        const pos = this.getCanvasPos(e);
        this.lastMouseX = this.mouseX;
        this.lastMouseY = this.mouseY;
        this.mouseX = pos.x;
        this.mouseY = pos.y;
        if (this.rightMouseDown) {
            this.onTear(pos.x, pos.y);
            return;
        }
        if (this.sim.isDragging) {
            this.sim.updateGrab(pos.x, pos.y);
        }
        else if (this.tool === 'rope' && this.mouseDown) {
            // Live rope preview handled in renderer via mouse coords
        }
        else if (this.tool === 'cloth' && this.mouseDown) {
            // Live cloth preview
        }
    }
    onMouseUp(e) {
        const pos = this.getCanvasPos(e);
        this.mouseX = pos.x;
        this.mouseY = pos.y;
        if (this.rightMouseDown) {
            this.rightMouseDown = false;
            return;
        }
        if (this.mouseDown && this.tool !== null) {
            // Finish drawing
            if (this.tool === 'rope') {
                this.spawnRope(this.mouseStartX, this.mouseStartY, pos.x, pos.y);
            }
            else if (this.tool === 'cloth') {
                this.spawnCloth(this.mouseStartX, this.mouseStartY, pos.x, pos.y);
            }
        }
        if (this.sim.isDragging) {
            this.sim.releasePoint();
        }
        this.mouseDown = false;
    }
    onTear(x, y) {
        // Tear along line from last mouse position to current
        this.sim.tearAlongLine(this.lastMouseX, this.lastMouseY, x, y);
    }
    spawnRope(x1, y1, x2, y2) {
        const dx = x2 - x1;
        const dy = y2 - y1;
        const length = Math.sqrt(dx * dx + dy * dy);
        const segments = config_1.CONFIG.ROPE_SEGMENTS;
        const spacing = length / segments;
        this.sim.spawnRope(x1, y1, segments, spacing, config_1.CONFIG.COLORS.rope);
    }
    spawnCloth(x1, y1, x2, y2) {
        const cols = config_1.CONFIG.CLOTH_COLS;
        const rows = config_1.CONFIG.CLOTH_ROWS;
        const spacing = config_1.CONFIG.CLOTH_SPACING;
        // Place cloth with top-left at mouse start
        this.sim.spawnCloth(x1, y1, cols, rows, spacing, config_1.CONFIG.COLORS.cloth, 'corners');
    }
    spawnObject(x, y) {
        if (this.tool === 'ball') {
            this.sim.spawnBall(x, y, config_1.CONFIG.BALL_RADIUS, config_1.CONFIG.COLORS.ball);
        }
        else if (this.tool === 'softBody') {
            this.sim.spawnSoftBody(x, y, config_1.CONFIG.SOFT_BODY_RADIUS, config_1.CONFIG.SOFT_BODY_SEGMENTS, config_1.CONFIG.COLORS.softBody);
        }
    }
    onKeyDown(e) {
        if (e.key === 'Shift')
            this.shiftDown = true;
        if (e.key === ' ') {
            e.preventDefault();
            this.sim.paused = !this.sim.paused;
            if (this.onTogglePause)
                this.onTogglePause(this.sim.paused);
        }
        if (e.key === 'ArrowRight' && this.sim.paused) {
            e.preventDefault();
            if (this.onStep)
                this.onStep();
        }
        // Tool shortcuts
        if (e.key === 'r' || e.key === 'R')
            this.selectTool('rope');
        if (e.key === 'c' || e.key === 'C')
            this.selectTool('cloth');
        if (e.key === 's' || e.key === 'S')
            this.selectTool('softBody');
        if (e.key === 'b' || e.key === 'B')
            this.selectTool('ball');
        if (e.key === 'Escape')
            this.selectTool(null);
        if (e.key === '1')
            this.selectTool('rope');
        if (e.key === '2')
            this.selectTool('cloth');
        if (e.key === '3')
            this.selectTool('softBody');
        if (e.key === '4')
            this.selectTool('ball');
    }
    onKeyUp(e) {
        if (e.key === 'Shift')
            this.shiftDown = false;
    }
    selectTool(tool) {
        this.tool = tool;
        if (this.onToolSelect)
            this.onToolSelect(tool);
    }
}
exports.InputHandler = InputHandler;
};

// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"unsloth/Qwen3.6-27B-MTP-GGUF-IQ4_NL","provider":"brigid","persona":"typescript","sessionId":"004ccc55-b92f-4ff3-91fd-dec9fbd7aef7","tokensIn":288650,"tokensOut":7120,"tokensTotal":295770,"cost":0,"turns":12,"toolCalls":11,"failedToolCalls":0,"timestamp":"2026-08-04T01:57:10.965Z"} -->