← Physics Playground results

Physics Playground

Qwen 3.7 Flash · 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 70,879 bytes · SHA-256 d056217d97f0
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Physics Playground</title>
<style>
/* ── css: layout ── */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

html, body {
  overflow: hidden;
  background: #0a0a1a;
  font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
  color: #c0c0e0;
}

#canvas-container {
  width: 100%;
  height: calc(100vh - 60px - 72px);
  display: flex;
  justify-content: center;
  align-items: center;
  background: #1a1a2e;
  position: relative;
  overflow: hidden;
}

#world-canvas {
  display: block;
  image-rendering: auto;
  cursor: crosshair;
}

#toolbar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 50px;
  background: rgba(16, 16, 36, 0.95);
  border-bottom: 1px solid #2a2a4e;
  display: flex;
  align-items: center;
  padding: 0 14px;
  gap: 6px;
  z-index: 10;
  flex-wrap: nowrap;
  overflow-x: auto;
  backdrop-filter: blur(4px);
}

#tb-label {
  color: #8888bb;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 1.5px;
  text-transform: uppercase;
  flex-shrink: 0;
  margin-right: 4px;
}

.tool-btn {
  background: #252545;
  color: #a0a0c0;
  border: 1px solid #353555;
  border-radius: 5px;
  padding: 4px 12px;
  font-size: 12px;
  cursor: pointer;
  transition: all 0.15s;
  flex-shrink: 0;
  white-space: nowrap;
}

.tool-btn:hover {
  background: #353560;
  border-color: #5555aa;
  color: #d0d0f0;
}

.tool-btn.active {
  background: #3a3a6a;
  border-color: #7777cc;
  color: #ffffff;
  box-shadow: 0 0 8px rgba(100, 100, 200, 0.3);
}

.icon-btn {
  background: #252545;
  color: #a0a0c0;
  border: 1px solid #353555;
  border-radius: 5px;
  width: 32px;
  height: 30px;
  font-size: 15px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: all 0.15s;
  flex-shrink: 0;
}

.icon-btn:hover {
  background: #353560;
  color: #ffffff;
}

#toolbar label {
  color: #7777aa;
  font-size: 11px;
  display: flex;
  align-items: center;
  gap: 5px;
  flex-shrink: 0;
  white-space: nowrap;
}

#toolbar label span {
  color: #8888bb;
  font-weight: 500;
  min-width: 28px;
  text-align: right;
  font-variant-numeric: tabular-nums;
}

#toolbar input[type="range"] {
  width: 64px;
  height: 4px;
  accent-color: #6666bb;
}

#toolbar select {
  background: #252545;
  color: #a0a0c0;
  border: 1px solid #353555;
  border-radius: 3px;
  padding: 2px 4px;
  font-size: 11px;
  cursor: pointer;
}

#hud-bar {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  height: 36px;
  background: rgba(16, 16, 36, 0.85);
  border-top: 1px solid #2a2a4e;
  display: flex;
  align-items: center;
  padding: 0 14px;
  z-index: 10;
  gap: 16px;
}

#hud-bar span {
  font-size: 11px;
  color: #6666aa;
  font-family: 'SF Mono', 'Consolas', monospace;
  flex-shrink: 0;
}
</style>
</head>
<body>
<div id="app"></div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./config":"src/config.ts","./physics":"src/physics.ts","./render":"src/render.ts","./input":"src/input.ts","./spawner":"src/spawner.ts","./types":"src/types.ts"},"src/vec2.ts":{"./types":"src/types.ts"},"src/physics.ts":{"./config":"src/config.ts","./types":"src/types.ts","./vec2":"src/vec2.ts"},"src/spawner.ts":{"./config":"src/config.ts","./types":"src/types.ts","./vec2":"src/vec2.ts","./physics":"src/physics.ts"},"src/input.ts":{"./config":"src/config.ts","./types":"src/types.ts","./vec2":"src/vec2.ts","./physics":"src/physics.ts","./spawner":"src/spawner.ts"},"src/render.ts":{"./config":"src/config.ts","./physics":"src/physics.ts","./types":"src/types.ts","./input":"src/input.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 — entry point: game loop, UI wiring, demo scene
// ─────────────────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
const config_1 = require("./config");
const physics_1 = require("./physics");
const render_1 = require("./render");
const input_1 = require("./input");
const spawner_1 = require("./spawner");
// ─── DOM Setup ─────────────────────────────────────────────
const app = document.getElementById('app');
app.innerHTML = `
  <div id="canvas-container">
    <canvas id="world-canvas"></canvas>
  </div>
  <div id="toolbar">
    <span id="tb-label">TOOL:</span>
    <button id="btn-rope" class="tool-btn" data-tool="rope">Rope</button>
    <button id="btn-cloth" class="tool-btn" data-tool="cloth">Cloth</button>
    <button id="btn-softbody" class="tool-btn" data-tool="softbody">Blob</button>
    <button id="btn-obs" class="tool-btn" data-tool="obs">Obstacle</button>
    <button id="btn-reset" class="icon-btn" title="Reset">↺</button>
    <button id="btn-stress" class="icon-btn" title="Toggle Stress Viz">◉</button>
    <label id="lbl-grav">
      <span>G:</span>
      <input type="range" id="slider-grav" min="0" max="2000" value="980" step="10">
      <span id="val-grav" class="val">980</span>
    </label>
    <label id="lbl-iter">
      <span>I:</span>
      <input type="range" id="slider-iter" min="1" max="16" value="6" step="1">
      <span id="val-iter" class="val">6</span>
    </label>
    <label id="lbl-phi">
      Pin:
      <select id="sel-pattern">
        <option value="top">Top</option>
        <option value="corners">Corners</option>
        <option value="full">Full</option>
      </select>
    </label>
  </div>
  <div id="hud-bar">
    <span id="hud-info"></span>
  </div>
`;
// ─── Canvas & Context ──────────────────────────────────────
const canvas = document.getElementById('world-canvas');
const container = document.getElementById('canvas-container');
// Set canvas size based on window
function resizeCanvas() {
    const toolbarH = 54;
    const hudH = 190; // toolbar + HUD
    const w = window.innerWidth;
    const h = Math.max(window.innerHeight - toolbarH - hudH, 400);
    canvas.width = w;
    canvas.height = h;
    // Set actual pixel dimensions (for retina)
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    canvas.style.width = w + 'px';
    canvas.style.height = h + 'px';
    canvas.width = Math.floor(w * dpr);
    canvas.height = Math.floor(h * dpr);
    // Reset world dimensions
    sim.world.canvasW = canvas.width;
    sim.world.canvasH = canvas.height;
    sim.input.canvasW = canvas.width;
    sim.input.canvasH = canvas.height;
}
// ─── Simulation State ──────────────────────────────────────
const sim = {
    world: (0, physics_1.createWorld)(1280, 720),
    canvas,
    renderState: null,
    input: (0, input_1.createInputState)(1280, 720),
    accumulator: 0,
    lastTime: 0,
    selectedTool: 'rope',
    placingObstacle: false,
    obsStart: null,
};
// ─── Event Handlers ────────────────────────────────────────
let mouseDown = false;
let mouseButton = 0;
canvas.addEventListener('mousedown', (e) => {
    e.preventDefault();
    mouseDown = true;
    mouseButton = e.button;
    const rect = canvas.getBoundingClientRect();
    const scale = canvas.width / rect.width;
    const mx = (e.clientX - rect.left) * scale;
    const my = (e.clientY - rect.top) * scale;
    sim.input.mouseX = mx;
    sim.input.mouseY = my;
    sim.input.lastMouseX = mx;
    sim.input.lastMouseY = my;
    sim.input.prevMouseX = mx;
    sim.input.prevMouseY = my;
    if (sim.selectedTool === 'rope') {
        (0, spawner_1.beginDragCreateRope)(mx, my);
        (0, render_1.setDragRopePreview)(mx, my);
    }
    else if (sim.selectedTool === 'cloth') {
        (0, spawner_1.beginDragCreateCloth)(mx, my);
    }
    else if (sim.selectedTool === 'obs') {
        sim.placingObstacle = true;
        sim.obsStart = { x: mx, y: my };
    }
    else {
        (0, input_1.handleMousedown)(sim.input, sim.world, e.button, e.ctrlKey || e.metaKey);
    }
});
canvas.addEventListener('mousemove', (e) => {
    const rect = canvas.getBoundingClientRect();
    const scale = canvas.width / rect.width;
    const mx = (e.clientX - rect.left) * scale;
    const my = (e.clientY - rect.top) * scale;
    sim.input.mouseX = mx;
    sim.input.mouseY = my;
    if (sim.selectedTool === 'rope' && (0, render_1.isDraggingRope)()) {
        (0, render_1.updateDragRopePreview)(mx, my);
        // update drag end position
    }
    (0, input_1.handleMousemove)(sim.input, sim.world);
});
canvas.addEventListener('mouseup', (e) => {
    mouseDown = false;
    const rect = canvas.getBoundingClientRect();
    const scale = canvas.width / rect.width;
    const mx = (e.clientX - rect.left) * scale;
    const my = (e.clientY - rect.top) * scale;
    sim.input.mouseX = mx;
    sim.input.mouseY = my;
    if (sim.selectedTool === 'rope') {
        (0, spawner_1.endDragCreateRope)(sim.world, mx, my);
        (0, render_1.clearDragRopePreview)();
    }
    else if (sim.selectedTool === 'cloth') {
        const pattern = document.getElementById('sel-pattern');
        (0, spawner_1.endDragCreateCloth)(sim.world, mx, my, pattern.value);
    }
    else if (sim.selectedTool === 'obs' && sim.obsStart) {
        // Create a circle obstacle at click, or a rect if dragging
        const dx = mx - sim.obsStart.x;
        const dy = my - sim.obsStart.y;
        const dist = Math.hypot(dx, dy);
        if (dist > 15) {
            // Circle centered at mid-point with radius = dist/2
            const cx = sim.obsStart.x + dx / 2;
            const cy = sim.obsStart.y + dy / 2;
            const r = dist / 2;
            sim.world.obstacles.push({ type: 'circle', x: cx, y: cy, r });
        }
        else {
            // Small circle
            sim.world.obstacles.push({ type: 'circle', x: sim.obsStart.x, y: sim.obsStart.y, r: 25 });
        }
        sim.obsStart = null;
    }
    (0, input_1.handleMouseup)(sim.input, sim.world, e.button);
});
// Prevent context menu
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
// Touch support
canvas.addEventListener('touchstart', (e) => {
    e.preventDefault();
    const touch = e.touches[0];
    const rect = canvas.getBoundingClientRect();
    const scale = canvas.width / rect.width;
    const mx = (touch.clientX - rect.left) * scale;
    const my = (touch.clientY - rect.top) * scale;
    sim.input.mouseX = mx;
    sim.input.mouseY = my;
    sim.input.lastMouseX = mx;
    sim.input.lastMouseY = my;
    sim.input.prevMouseX = mx;
    sim.input.prevMouseY = my;
    mouseDown = true;
    mouseButton = 0;
    (0, input_1.handleMousedown)(sim.input, sim.world, 0);
}, { passive: false });
canvas.addEventListener('touchmove', (e) => {
    e.preventDefault();
    const touch = e.touches[0];
    const rect = canvas.getBoundingClientRect();
    const scale = canvas.width / rect.width;
    const mx = (touch.clientX - rect.left) * scale;
    const my = (touch.clientY - rect.top) * scale;
    sim.input.mouseX = mx;
    sim.input.mouseY = my;
    (0, input_1.handleMousemove)(sim.input, sim.world);
}, { passive: false });
canvas.addEventListener('touchend', (e) => {
    e.preventDefault();
    mouseDown = false;
    const rect = canvas.getBoundingClientRect();
    const scale = canvas.width / rect.width;
    const mx = (sim.input.mouseX);
    const my = (sim.input.mouseY);
    (0, input_1.handleMouseup)(sim.input, sim.world, 0);
}, { passive: false });
// ─── Keyboard ──────────────────────────────────────────────
window.addEventListener('keydown', (e) => {
    if (e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement)
        return;
    const action = (0, input_1.handleKeydown)(e.key);
    if (action === 'pause') {
        e.preventDefault();
        sim.world.paused = !sim.world.paused;
    }
    else if (action === 'step') {
        e.preventDefault();
        if (sim.world.paused) {
            simWorldOnce(config_1.PHYSICS.TAU);
        }
    }
    else if (action === 'gravity-toggle') {
        e.preventDefault();
        sim.world.gravityOn = !sim.world.gravityOn;
        config_1.PHYSICS.GRAVITY_ON = sim.world.gravityOn;
        const slider = document.getElementById('slider-grav');
        slider.value = sim.world.gravityOn ? '980' : '0';
        document.getElementById('val-grav').textContent = slider.value;
    }
    else if (action === 'wind-toggle') {
        // Toggle wind off/on
        const wind = window.wind || 0;
        window.wind = wind === 0 ? 200 : 0;
        sim.world.wind = window.wind;
    }
    // Tool shortcuts
    const tools = document.querySelectorAll('.tool-btn');
    const keys = { 'r': 0, 'l': 1, 'b': 2 };
    const keyMap = { 'r': 0, 'l': 1, 'b': 2 };
    if (e.key in keyMap) {
        const idx = keyMap[e.key.toLowerCase()];
        tools[idx]?.dispatchEvent(new Event('click'));
    }
});
// ─── UI Controls ───────────────────────────────────────────
// Toolbar: tool selection
document.querySelectorAll('.tool-btn').forEach((btn) => {
    btn.addEventListener('click', () => {
        document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active'));
        btn.classList.add('active');
        const tool = btn.getAttribute('data-tool');
        sim.selectedTool = tool;
    });
});
// Make the first tool active by default
document.querySelector('#btn-rope')?.classList.add('active');
// Reset
document.getElementById('btn-reset')?.addEventListener('click', () => {
    sim.world.obstacles.length = 0;
    sim.world.points.length = 0;
    sim.world.constraints.length = 0;
    // Re-add default obstacles if any
    addDefaultObstacles();
});
// Stress viz toggle
let showStress = false;
document.getElementById('btn-stress')?.addEventListener('click', () => {
    showStress = !showStress;
    if (showStress) {
        document.getElementById('btn-stress')?.setAttribute('style', 'color:#ff3333');
    }
    else {
        document.getElementById('btn-stress')?.setAttribute('style', '');
    }
});
// Gravity slider
const gravSlider = document.getElementById('slider-grav');
const gravVal = document.getElementById('val-grav');
gravSlider?.addEventListener('input', () => {
    const v = parseFloat(gravSlider.value);
    gravVal.textContent = gravSlider.value;
    sim.world.gravityOn = v > 0;
    if (v === 0)
        sim.world.gravityOn = false;
    config_1.PHYSICS.GRAVITY = v;
});
// Iterations slider
const iterSlider = document.getElementById('slider-iter');
const iterVal = document.getElementById('val-iter');
iterSlider?.addEventListener('input', () => {
    iterVal.textContent = iterSlider.value;
    // We'll read the current value during each step
});
// ─── Demo Scene ─────────────────────────────────────────────
function addDefaultObstacles() {
    const W = sim.world.canvasW;
    const H = sim.world.canvasH;
    // Circle obstacle on the floor
    sim.world.obstacles.push({ type: 'circle', x: W * 0.7, y: H - 60, r: 40 });
    sim.world.obstacles.push({ type: 'circle', x: W * 0.3, y: H - 40, r: 25 });
    // A small platform
    sim.world.obstacles.push({ type: 'rect', x: W * 0.45, y: H * 0.55, w: 180, h: 15 });
}
function createDemoScene() {
    const W = sim.world.canvasW;
    const H = sim.world.canvasH;
    // A long rope from top-left
    (0, spawner_1.spawnRope)(sim.world, W * 0.15, 50, W * 0.25, H * 0.4);
    // A cloth pinned at two top corners
    (0, spawner_1.spawnCloth)(sim.world, W * 0.45, 60, 10, 14, 14, 'corners');
    // A soft body blob
    (0, spawner_1.spawnSoftBody)(sim.world, W * 0.7, 120, 50, 8);
    // A ball
    (0, spawner_1.spawnBall)(sim.world, W * 0.85, 80, 20);
}
// Re-initialize demo after resize
function recreateDemo() {
    sim.world.obstacles.length = 0;
    sim.world.points.length = 0;
    sim.world.constraints.length = 0;
    addDefaultObstacles();
    createDemoScene();
}
// ─── Physics Step (single) ────────────────────────────────
function simWorldOnce(dt) {
    const iter = parseInt(iterSlider.value) || config_1.PHYSICS.ITERATIONS;
    const t0 = performance.now();
    sim.world.gravityOn = config_1.PHYSICS.GRAVITY_ON;
    sim.world.wind = window.wind || 0;
    (0, physics_2.stepWorld)(sim.world, dt, iter);
    const t1 = performance.now();
    if (sim.renderState) {
        sim.renderState.lastStepTime = (t1 - t0);
    }
}
// Re-import stepWorld from physics
const physics_2 = require("./physics");
// ─── Game Loop ─────────────────────────────────────────────
function gameLoop(timestamp) {
    if (!sim.lastTime)
        sim.lastTime = timestamp;
    let frameDelta = (timestamp - sim.lastTime) / 1000;
    sim.lastTime = timestamp;
    // Clamp to prevent explosion after tab switch
    if (frameDelta > config_1.PHYSICS.MAX_FRAME_DELTA) {
        frameDelta = config_1.PHYSICS.MAX_FRAME_DELTA;
    }
    // If paused, don't accumulate
    if (!sim.world.paused) {
        sim.accumulator += frameDelta;
        // Process fixed-step frames
        const maxSteps = 5; // safety limit
        let steps = 0;
        while (sim.accumulator >= config_1.PHYSICS.TAU && steps < maxSteps) {
            simWorldOnce(config_1.PHYSICS.TAU);
            sim.accumulator -= config_1.PHYSICS.TAU;
            steps++;
        }
        // Remaining accumulator is fine — it will be used next frame
        if (sim.accumulator >= config_1.PHYSICS.TAU) {
            // Too much accumulated — discard to prevent spiral
            sim.accumulator = 0;
        }
    }
    // Render
    if (sim.renderState) {
        (0, render_1.render)(sim.world, sim.renderState, sim.input);
    }
    // Draw obstacle preview while placing
    if (sim.placingObstacle && sim.obsStart) {
        const ctx = sim.renderState.ctx;
        ctx.strokeStyle = 'rgba(255,255,255,0.4)';
        ctx.lineWidth = 2;
        ctx.setLineDash([8, 4]);
        ctx.beginPath();
        const dx = sim.input.mouseX - sim.obsStart.x;
        const dy = sim.input.mouseY - sim.obsStart.y;
        const cx = sim.obsStart.x + dx / 2;
        const cy = sim.obsStart.y + dy / 2;
        const r = Math.hypot(dx, dy) / 2;
        ctx.arc(cx, cy, r, 0, Math.PI * 2);
        ctx.stroke();
        ctx.setLineDash([]);
    }
    requestAnimationFrame(gameLoop);
}
// ─── Window Resize ──────────────────────────────────────────
let resizeTimeout;
window.addEventListener('resize', () => {
    clearTimeout(resizeTimeout);
    resizeTimeout = setTimeout(() => {
        resizeCanvas();
        simulateResize();
    }, 150);
});
function simulateResize() {
    // Context persists across resize; just update stored dimensions
    sim.renderState.canvasW = sim.world.canvasW;
    sim.renderState.canvasH = sim.world.canvasH;
}
// ─── Init ──────────────────────────────────────────────────
function start() {
    resizeCanvas();
    addDefaultObstacles();
    createDemoScene();
    const ctx = canvas.getContext('2d');
    sim.renderState = (0, render_1.createRenderState)(ctx, sim.world.canvasW, sim.world.canvasH);
    requestAnimationFrame(gameLoop);
}
// Expose sim on window for probing
window.sim = sim;
start();
};

// ── module: src/config.ts ──
__mods["src/config.ts"] = function (exports, require, module) {
"use strict";
// ─────────────────────────────────────────────────────────────
// Tunable Constants — tweak these to change the feel of the sim
// ─────────────────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.COLORS = exports.SPAWN = exports.RENDER = exports.PHYSICS = void 0;
exports.PHYSICS = {
    /** Fixed physics timestep (s) — 60 Hz → ~0.01667 */
    TAU: 1 / 60,
    /** Max accumulator clamp (s) — prevents explosion after tab switch */
    MAX_FRAME_DELTA: 0.25,
    /** Constraint solver iterations per physics step */
    ITERATIONS: 6,
    /** Verlet damping (0 = no friction, 1 = fully overdamped) */
    DAMPING: 0.995,
    /** Gravity strength (px/s²) */
    GRAVITY: 980,
    /** Enable gravity */
    GRAVITY_ON: true,
    /** Wind strength (px/s²) in X direction */
    WIND: 0,
    /** Collision friction per axis */
    FRICTION: 0.7,
    /** Floor bounce restitution */
    BOUNCE: 0.4,
    /** Minimum safe distance to avoid division-by-zero in constraints */
    MIN_DIST: 0.001,
};
exports.RENDER = {
    /** Point radius for dots */
    DOT_RADIUS: 3,
    /** Constraint line width */
    LINE_WIDTH: 1.5,
    /** Cloth fill opacity */
    CLOTH_OPACITY: 0.35,
    /** Soft body fill opacity */
    SOFT_BODY_OPACITY: 0.7,
    /** Ball fill opacity */
    BALL_OPACITY: 0.9,
    /** Grab highlight radius */
    GRAB_RADIUS: 20,
    /** Tear radius (px) */
    TEAR_RADIUS: 18,
    /** Stress map: below this ratio → green, above → red */
    STRESS_THRESH: 0.15,
    /** Max stress ratio for full-red → 100% stretch means double length */
    STRESS_MAX: 0.5,
};
exports.SPAWN = {
    /** Rope segment length */
    ROPE_SEG_LEN: 12,
    /** Rope point count per drag pixel of length */
    ROPE_MIN_POINTS: 8,
    /** Cloth grid rows / cols */
    CLOTH_ROWS: 10,
    CLOTH_COLS: 8,
    /** Cloth spacing */
    CLOTH_SPACING: 14,
    /** Soft body radius in points */
    SOFT_BODY_R: 5,
    /** Soft body brace depth (1 = only neighbors, 2 = next-nearest) */
    SOFT_BODY_BRACE_DEPTH: 1,
};
exports.COLORS = {
    /** Background */
    BG: '#1a1a2e',
    /** Grid lines */
    GRID: '#252545',
    /** Static walls/floor */
    WALL: '#404068',
    /** HUD text */
    HUD_TEXT: '#c0c0e0',
    /** Rope color */
    ROPE: '#ff6b6b',
    /** Cloth color */
    CLOTH: '#4ecdc4',
    /** Soft body (blob) color */
    SOFT_BODY: '#ffe66d',
    /** Ball color */
    BALL: '#a78bfa',
    /** Pin indicator */
    PIN: '#ffffff',
    /** Grab highlight */
    GRAB: '#ffff00',
    /** Tear indicator */
    TEAR: '#ff004488',
    /** Stressed constraint red */
    STRESS_RED: '#ff3333',
    /** Unstressed constraint green */
    STRESS_GREEN: '#33ff66',
};
};

// ── module: src/types.ts ──
__mods["src/types.ts"] = function (exports, require, module) {
"use strict";
// ─────────────────────────────────────────────────────────────
// Types — core data structures for the physics engine
// ─────────────────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
};

// ── module: src/vec2.ts ──
__mods["src/vec2.ts"] = function (exports, require, module) {
"use strict";
// ─────────────────────────────────────────────────────────────
// Vec2 — lightweight 2D vector math (no allocation helpers)
// ─────────────────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.vec2 = vec2;
exports.v2Add = v2Add;
exports.v2Sub = v2Sub;
exports.v2Scale = v2Scale;
exports.v2Neg = v2Neg;
exports.v2Len = v2Len;
exports.v2LenSq = v2LenSq;
exports.v2Norm = v2Norm;
exports.v2Dot = v2Dot;
exports.v2Dist = v2Dist;
exports.v2Min = v2Min;
exports.v2Max = v2Max;
exports.v2Copy = v2Copy;
exports.v2AddScaled = v2AddScaled;
function vec2(x = 0, y = 0) { return { x, y }; }
function v2Add(a, b) { return { x: a.x + b.x, y: a.y + b.y }; }
function v2Sub(a, b) { return { x: a.x - b.x, y: a.y - b.y }; }
function v2Scale(v, s) { return { x: v.x * s, y: v.y * s }; }
function v2Neg(v) { return { x: -v.x, y: -v.y }; }
function v2Len(v) { return Math.hypot(v.x, v.y); }
function v2LenSq(v) { return v.x * v.x + v.y * v.y; }
/** Normalised, returns zero-length vector if empty */
function v2Norm(v) {
    const len = v2Len(v);
    if (len < 1e-9)
        return vec2(0, 0);
    return { x: v.x / len, y: v.y / len };
}
function v2Dot(a, b) { return a.x * b.x + a.y * b.y; }
function v2Dist(a, b) { return v2Len(v2Sub(a, b)); }
function v2Min(a, b) { return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y) }; }
function v2Max(a, b) { return { x: Math.max(a.x, b.x), y: Math.max(a.y, b.y) }; }
/** Copy */
function v2Copy(v) { return { x: v.x, y: v.y }; }
/** Mutate v = a + b * s */
function v2AddScaled(v, a, s) {
    v.x += a.x * s;
    v.y += a.y * s;
}
};

// ── module: src/physics.ts ──
__mods["src/physics.ts"] = function (exports, require, module) {
"use strict";
// ─────────────────────────────────────────────────────────────
// Physics — Verlet integration + constraint relaxation + collision
// ─────────────────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.createWorld = createWorld;
exports.resetWorld = resetWorld;
exports.stepWorld = stepWorld;
exports.getConstraintStress = getConstraintStress;
exports.addRectObstacle = addRectObstacle;
exports.addCircleObstacle = addCircleObstacle;
const config_1 = require("./config");
/** Create an empty world */
function createWorld(cw, ch) {
    return {
        points: [],
        constraints: [],
        obstacles: [],
        canvasW: cw,
        canvasH: ch,
        paused: false,
        gravityOn: true,
        wind: 0,
    };
}
/** Reset the world — remove all points and constraints */
function resetWorld(world) {
    world.points.length = 0;
    world.constraints.length = 0;
    world.paused = false;
    world.gravityOn = true;
    world.wind = 0;
}
/** Apply gravity and wind to all points */
function applyForces(world, dt) {
    const g = config_1.PHYSICS.GRAVITY;
    for (let i = 0; i < world.points.length; i++) {
        const p = world.points[i];
        if (p.pin)
            continue;
        // gravity (always in Y+)
        if (world.gravityOn && g > 0) {
            p.force.x = 0;
            p.force.y = g;
            // wind
            if (world.wind !== 0) {
                p.force.x = world.wind;
            }
        }
        else {
            p.force.x = 0;
            p.force.y = 0;
        }
    }
}
/** Integrate Verlet step for all unconstrained points */
function integrate(world, dt) {
    const dtSq = dt * dt;
    const DAMP = config_1.PHYSICS.DAMPING;
    for (let i = 0; i < world.points.length; i++) {
        const p = world.points[i];
        if (p.pin)
            continue;
        const vx = (p.pos.x - p.prev.x) * DAMP;
        const vy = (p.pos.y - p.prev.y) * DAMP;
        p.prev.x = p.pos.x;
        p.prev.y = p.pos.y;
        p.pos.x += vx + p.force.x * dtSq;
        p.pos.y += vy + p.force.y * dtSq;
        // compute velocity from positions (for display & tear detection)
        p.vx = p.pos.x - p.prev.x;
        p.vy = p.pos.y - p.prev.y;
    }
}
/** Solve distance constraints via relaxation */
function solveConstraints(world, iterations) {
    const minDist = config_1.PHYSICS.MIN_DIST;
    const pts = world.points;
    const cons = world.constraints;
    for (let iter = 0; iter < iterations; iter++) {
        for (let ci = 0; ci < cons.length; ci++) {
            const c = cons[ci];
            if (!c.active || c.strength <= 0)
                continue;
            const a = pts[c.i];
            const b = pts[c.j];
            const dx = b.pos.x - a.pos.x;
            const dy = b.pos.y - a.pos.y;
            const distSq = dx * dx + dy * dy;
            const dist = Math.sqrt(distSq);
            if (dist < minDist)
                continue; // skip zero-length
            const diff = (c.restLength - dist) / dist;
            const halfDiff = diff * 0.5;
            const mx = dx * halfDiff * c.strength;
            const my = dy * halfDiff * c.strength;
            if (!a.pin) {
                a.pos.x -= mx;
                a.pos.y -= my;
            }
            if (!b.pin) {
                b.pos.x += mx;
                b.pos.y += my;
            }
        }
    }
}
/** Boundary collisions — walls, floor, ceiling */
function solveBoundaries(world) {
    const pts = world.points;
    const W = world.canvasW;
    const H = world.canvasH;
    const FRIC = config_1.PHYSICS.FRICTION;
    const BOUNCE = config_1.PHYSICS.BOUNCE;
    const minR = 1;
    for (let i = 0; i < pts.length; i++) {
        const p = pts[i];
        if (p.pin)
            continue;
        const r = p.radius > minR ? p.radius : minR;
        // Floor
        if (p.pos.y + r > H) {
            p.pos.y = H - r;
            // recover velocity from prev
            const vy = p.pos.y - p.prev.y;
            p.prev.y = p.pos.y + vy * BOUNCE;
            // horizontal friction
            const vx = p.pos.x - p.prev.x;
            p.prev.x = p.pos.x - vx * FRIC;
        }
        // Ceiling
        if (p.pos.y - r < 0) {
            p.pos.y = r;
            const vy = p.pos.y - p.prev.y;
            p.prev.y = p.pos.y + vy * BOUNCE;
        }
        // Left wall
        if (p.pos.x - r < 0) {
            p.pos.x = r;
            const vx = p.pos.x - p.prev.x;
            p.prev.x = p.pos.x + vx * BOUNCE;
        }
        // Right wall
        if (p.pos.x + r > W) {
            p.pos.x = W - r;
            const vx = p.pos.x - p.prev.x;
            p.prev.x = p.pos.x + vx * BOUNCE;
        }
    }
}
/** Obstacle collisions — circles & rects */
function solveObstacles(world) {
    const pts = world.points;
    const FRIC = config_1.PHYSICS.FRICTION;
    const BOUNCE = config_1.PHYSICS.BOUNCE;
    const minR = 1;
    for (let oi = 0; oi < world.obstacles.length; oi++) {
        const obs = world.obstacles[oi];
        for (let i = 0; i < pts.length; i++) {
            const p = pts[i];
            if (p.pin)
                continue;
            const r = p.radius > minR ? p.radius : minR;
            if (obs.type === 'circle') {
                const c = obs.r;
                const dx = p.pos.x - obs.x;
                const dy = p.pos.y - obs.y;
                const dist = Math.hypot(dx, dy);
                const minD = r + c;
                if (dist < minD && dist > 1e-9) {
                    // push out
                    const nx = dx / dist;
                    const ny = dy / dist;
                    p.pos.x = obs.x + nx * minD;
                    p.pos.y = obs.y + ny * minD;
                    // impulse response
                    const velX = p.pos.x - p.prev.x;
                    const velY = p.pos.y - p.prev.y;
                    // reflect tangent
                    const dot = velX * nx + velY * ny;
                    if (dot > 0) { // moving outward, don't reflect
                        p.prev.x = p.pos.x - (velX - 2 * dot * nx) * BOUNCE;
                        p.prev.y = p.pos.y - (velY - 2 * dot * ny) * BOUNCE;
                    }
                    else {
                        // friction on tangent component
                        p.prev.x = p.pos.x - (velX - 2 * dot * nx) * BOUNCE * FRIC + nx * velX * (1 - FRIC);
                        p.prev.y = p.pos.y - (velY - 2 * dot * ny) * BOUNCE * FRIC + ny * velY * (1 - FRIC);
                    }
                }
            }
            else if (obs.type === 'rect') {
                const w = obs.w;
                const h = obs.h;
                // AABB check — push out if overlapping
                const left = obs.x;
                const right = obs.x + w;
                const top = obs.y;
                const bottom = obs.y + h;
                // Find closest point on rect to circle center
                const cx = Math.max(left, Math.min(right, p.pos.x));
                const cy = Math.max(top, Math.min(bottom, p.pos.y));
                const dx = p.pos.x - cx;
                const dy = p.pos.y - cy;
                const dist = Math.hypot(dx, dy);
                if (dist < r && dist > 1e-9) {
                    const nx = dx / dist;
                    const ny = dy / dist;
                    p.pos.x = cx + nx * r;
                    p.pos.y = cy + ny * r;
                    const velX = p.pos.x - p.prev.x;
                    const velY = p.pos.y - p.prev.y;
                    const dot = velX * nx + velY * ny;
                    if (dot > 0) {
                        p.prev.x = p.pos.x - (velX - 2 * dot * nx) * BOUNCE;
                        p.prev.y = p.pos.y - (velY - 2 * dot * ny) * BOUNCE;
                    }
                    else {
                        p.prev.x = p.pos.x - (velX - 2 * dot * nx) * BOUNCE * FRIC + nx * velX * (1 - FRIC);
                        p.prev.y = p.pos.y - (velY - 2 * dot * ny) * BOUNCE * FRIC + ny * velY * (1 - FRIC);
                    }
                }
            }
        }
    }
}
/** Step the entire world by one fixed timestep */
function stepWorld(world, dt, iterations) {
    const iter = iterations ?? config_1.PHYSICS.ITERATIONS;
    applyForces(world, dt);
    integrate(world, dt);
    solveConstraints(world, iter);
    solveObstacles(world);
    solveBoundaries(world);
    // Reset per-frame force accumulator
    for (let i = 0; i < world.points.length; i++) {
        world.points[i].force.x = 0;
        world.points[i].force.y = 0;
    }
}
/** Get stress ratio for a constraint (0 = rest length, 1 = double rest length) */
function getConstraintStress(c, pts) {
    const ptA = pts[c.i];
    const ptB = pts[c.j];
    const dx = ptB.pos.x - ptA.pos.x;
    const dy = ptB.pos.y - ptA.pos.y;
    const dist = Math.sqrt(dx * dx + dy * dy);
    if (dist === 0)
        return 0;
    return Math.min(Math.abs(dist - c.restLength) / c.restLength, 10);
}
/** Add a static rectangle obstacle */
function addRectObstacle(world, x, y, w, h) {
    world.obstacles.push({ type: 'rect', x, y, w, h });
}
/** Add a static circle obstacle */
function addCircleObstacle(world, x, y, r) {
    world.obstacles.push({ type: 'circle', x, y, r });
}
};

// ── module: src/spawner.ts ──
__mods["src/spawner.ts"] = function (exports, require, module) {
"use strict";
// ─────────────────────────────────────────────────────────────
// Spawner — create ropes, cloth, soft bodies, balls
// ─────────────────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.spawnRope = spawnRope;
exports.spawnCloth = spawnCloth;
exports.spawnSoftBody = spawnSoftBody;
exports.spawnBall = spawnBall;
exports.beginDragCreateRope = beginDragCreateRope;
exports.endDragCreateRope = endDragCreateRope;
exports.cancelDragCreate = cancelDragCreate;
exports.isDragCreating = isDragCreating;
exports.beginDragCreateCloth = beginDragCreateCloth;
exports.endDragCreateCloth = endDragCreateCloth;
exports.cancelDragCreateCloth = cancelDragCreateCloth;
exports.isDragCreatingCloth = isDragCreatingCloth;
const config_1 = require("./config");
const vec2_1 = require("./vec2");
/** Next group id counter */
let nextGroup = 0;
function nextGroupID() { return ++nextGroup; }
function addPoint(world, x, y, pin = false) {
    const idx = world.points.length;
    const vx = 0;
    const vy = 0;
    world.points.push({
        pos: (0, vec2_1.vec2)(x, y),
        prev: (0, vec2_1.vec2)(x - vx, y - vy),
        force: (0, vec2_1.vec2)(0, 0),
        pin,
        radius: 4,
        group: 0, // set by caller
        tearMask: 0,
        vx, vy,
    });
    return idx;
}
function addConstraint(world, i, j, restLen, strength = 1) {
    const idx = world.constraints.length;
    world.constraints.push({
        i, j,
        restLength: Math.max(restLen, config_1.PHYSICS.MIN_DIST),
        strength,
        active: true,
        group: 0, // set by caller
    });
    return idx;
}
// ─── Rope ───────────────────────────────────────────────────
/**
 * Spawn a rope from (x0,y0) to (x1,y1).
 * The start point is pinned.
 */
function spawnRope(world, x0, y0, x1, y1) {
    const dx = x1 - x0;
    const dy = y1 - y0;
    const totalLen = Math.hypot(dx, dy);
    let segLen = Math.max(config_1.SPAWN.ROPE_SEG_LEN, totalLen / config_1.SPAWN.ROPE_MIN_POINTS);
    const numSegs = Math.max(2, Math.floor(totalLen / segLen));
    segLen = totalLen / numSegs;
    const gid = nextGroupID();
    const positions = [];
    for (let s = 0; s <= numSegs; s++) {
        const t = s / numSegs;
        positions.push((0, vec2_1.vec2)(x0 + dx * t, y0 + dy * t));
    }
    const startIdx = world.points.length;
    for (const p of positions) {
        const idx = world.points.length;
        world.points.push({
            pos: p,
            prev: { ...p },
            force: (0, vec2_1.vec2)(0, 0),
            pin: idx === startIdx,
            radius: 4,
            group: gid,
            tearMask: 0,
            vx: 0, vy: 0,
        });
    }
    for (let s = 0; s < numSegs; s++) {
        const cIdx = world.constraints.length;
        world.constraints.push({
            i: startIdx + s,
            j: startIdx + s + 1,
            restLength: segLen,
            strength: 1,
            active: true,
            group: gid,
        });
    }
}
// ─── Cloth ──────────────────────────────────────────────────
/**
 * Spawn a cloth grid pinned at the top edge.
 * @param cols number of points horizontally
 * @param rows number of points vertically
 * @param spacingpx spacing between points
 * @param pinPattern 'corners' | 'top' | 'full' — where to pin
 */
function spawnCloth(world, x, y, cols, rows, spacingpx, pinPattern = 'top') {
    const gid = nextGroupID();
    const allIndices = [];
    // create grid
    for (let r = 0; r < rows; r++) {
        const rowIdxs = [];
        for (let c = 0; c < cols; c++) {
            const px = x + c * spacingpx;
            const py = y + r * spacingpx;
            const idx = addPoint(world, px, py, false);
            let pinned = false;
            if (pinPattern === 'top' && r === 0)
                pinned = true;
            else if (pinPattern === 'corners' && r === 0 && (c === 0 || c === cols - 1))
                pinned = true;
            else if (pinPattern === 'full')
                pinned = true;
            if (pinned)
                world.points[idx].pin = true;
            world.points[idx].group = gid;
            rowIdxs.push(idx);
        }
        allIndices.push(rowIdxs);
    }
    // structural constraints (horizontal + vertical)
    for (let r = 0; r < rows; r++) {
        for (let c = 0; c < cols; c++) {
            const idx = allIndices[r][c];
            if (c < cols - 1) {
                addConstraint(world, idx, allIndices[r][c + 1], spacingpx, 1);
                world.constraints[world.constraints.length - 1].group = gid;
            }
            if (r < rows - 1) {
                addConstraint(world, idx, allIndices[r + 1][c], spacingpx, 1);
                world.constraints[world.constraints.length - 1].group = gid;
            }
        }
    }
    // shear constraints (diagonal)
    for (let r = 0; r < rows - 1; r++) {
        for (let c = 0; c < cols - 1; c++) {
            const idx = allIndices[r][c];
            const diag1 = allIndices[r + 1][c + 1];
            addConstraint(world, idx, diag1, Math.sqrt(2) * spacingpx, 1);
            world.constraints[world.constraints.length - 1].group = gid;
        }
    }
}
// ─── Soft Body ──────────────────────────────────────────────
/**
 * Spawn a soft body as a circle of points with structural + brace constraints.
 */
function spawnSoftBody(world, cx, cy, radius, numPoints) {
    const gid = nextGroupID();
    const indices = [];
    for (let i = 0; i < numPoints; i++) {
        const angle = (2 * Math.PI * i) / numPoints;
        const px = cx + Math.cos(angle) * radius;
        const py = cy + Math.sin(angle) * radius;
        const idx = addPoint(world, px, py, false);
        indices.push(idx);
        world.points[idx].group = gid;
    }
    // add a center point
    const centerIdx = addPoint(world, cx, cy, false);
    world.points[centerIdx].group = gid;
    for (let i = 0; i < numPoints; i++) {
        const next = (i + 1) % numPoints;
        const prev = (i - 1 + numPoints) % numPoints;
        const curr = indices[i];
        // structural edges
        addConstraint(world, curr, indices[next], radius * 2 * Math.sin(Math.PI / numPoints), 1);
        world.constraints[world.constraints.length - 1].group = gid;
        // braces to next-next (stiffness)
        addConstraint(world, curr, indices[next === numPoints - 1 ? 1 : next + 1], radius * 2 * Math.sin(2 * Math.PI / numPoints), 0.8);
        world.constraints[world.constraints.length - 1].group = gid;
        // brace to center
        addConstraint(world, curr, centerIdx, radius, 0.6);
        world.constraints[world.constraints.length - 1].group = gid;
    }
}
// ─── Ball ───────────────────────────────────────────────────
/**
 * Spawn a ball as a single point.
 */
function spawnBall(world, x, y, radius) {
    const gid = nextGroupID();
    const idx = addPoint(world, x, y, false);
    world.points[idx].radius = radius;
    world.points[idx].group = gid;
}
let dragCreate = null;
function beginDragCreateRope(x, y) {
    dragCreate = { startX: x, startY: y };
}
function endDragCreateRope(world, endX, endY) {
    if (!dragCreate)
        return;
    const dx = endX - dragCreate.startX;
    const dy = endY - dragCreate.startY;
    const len = Math.hypot(dx, dy);
    if (len > 10) {
        spawnRope(world, dragCreate.startX, dragCreate.startY, endX, endY);
    }
    dragCreate = null;
}
function cancelDragCreate() {
    dragCreate = null;
}
function isDragCreating() { return dragCreate !== null; }
let dragCreateCloth = null;
function beginDragCreateCloth(x, y) {
    dragCreateCloth = { startX: x, startY: y };
}
function endDragCreateCloth(world, endX, endY, pin) {
    if (!dragCreateCloth)
        return;
    const dx = endX - dragCreateCloth.startX;
    const dy = endY - dragCreateCloth.startY;
    const maxDim = Math.max(Math.abs(dx), Math.abs(dy));
    if (maxDim > 20) {
        const cols = Math.max(4, Math.floor(maxDim / config_1.SPAWN.CLOTH_SPACING));
        const rows = Math.max(4, Math.floor(maxDim / config_1.SPAWN.CLOTH_SPACING));
        const sx = dx >= 0 ? dragCreateCloth.startX : endX;
        const sy = dy >= 0 ? dragCreateCloth.startY : endY;
        spawnCloth(world, sx, sy, cols, rows, config_1.SPAWN.CLOTH_SPACING, pin);
    }
    dragCreateCloth = null;
}
function cancelDragCreateCloth() {
    dragCreateCloth = null;
}
function isDragCreatingCloth() { return dragCreateCloth !== null; }
};

// ── module: src/input.ts ──
__mods["src/input.ts"] = function (exports, require, module) {
"use strict";
// ─────────────────────────────────────────────────────────────
// Input — mouse interaction: grab, tear, pin, drag-for-create
// ─────────────────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.createInputState = createInputState;
exports.handleMousedown = handleMousedown;
exports.handleMousemove = handleMousemove;
exports.handleMouseup = handleMouseup;
exports.handleKeydown = handleKeydown;
exports.getGrabInfo = getGrabInfo;
exports.getTearInfo = getTearInfo;
const config_1 = require("./config");
const spawner_1 = require("./spawner");
const GRAB_RADIUS = config_1.RENDER.GRAB_RADIUS;
const TEAR_RADIUS = config_1.RENDER.TEAR_RADIUS;
function createInputState(canvasW, canvasH) {
    return {
        mouseX: 0, mouseY: 0,
        mouseDown: false,
        rightDown: false,
        shiftDown: false,
        grabPoint: null,
        grabbedByDragging: false,
        dragStartX: 0, dragStartY: 0,
        lastMouseX: 0, lastMouseY: 0,
        velocityX: 0, velocityY: 0,
        canvasW, canvasH,
        prevMouseX: 0, prevMouseY: 0,
    };
}
/** Find nearest point within GRAB_RADIUS */
function findNearestPoint(world, x, y, maxDist) {
    let bestIdx = -1;
    let bestDist = maxDist;
    for (let i = 0; i < world.points.length; i++) {
        const p = world.points[i];
        const d = Math.hypot(p.pos.x - x, p.pos.y - y);
        if (d < bestDist) {
            bestDist = d;
            bestIdx = i;
        }
    }
    return bestIdx;
}
/** Find constraints passing near (x,y) and mark them broken */
function tearNear(world, x, y) {
    const radiusSq = TEAR_RADIUS * TEAR_RADIUS;
    for (let ci = 0; ci < world.constraints.length; ci++) {
        const c = world.constraints[ci];
        if (!c.active)
            continue;
        const a = world.points[c.i];
        const b = world.points[c.j];
        // point-to-segment distance
        const dx = b.pos.x - a.pos.x;
        const dy = b.pos.y - a.pos.y;
        const lenSq = dx * dx + dy * dy;
        if (lenSq < 1e-9)
            continue;
        // t = projection of (p - a) onto (b - a), clamped [0,1]
        const px = x - a.pos.x;
        const py = y - a.pos.y;
        let t = (px * dx + py * dy) / lenSq;
        t = Math.max(0, Math.min(1, t));
        const closestX = a.pos.x + dx * t;
        const closestY = a.pos.y + dy * t;
        const distSq = (closestX - x) * (closestX - x) + (closestY - y) * (closestY - y);
        if (distSq < radiusSq) {
            c.active = false;
            c.strength = 0;
        }
    }
}
/** Toggle pin state of a point */
function togglePin(world, idx) {
    if (idx >= 0 && idx < world.points.length) {
        world.points[idx].pin = !world.points[idx].pin;
    }
}
/**
 * Check if user is clicking on a spawnable area (no grab targets nearby).
 * Returns true if left-click is in empty space and should create ball.
 */
function isEmptySpace(world, x, y, radius = 30) {
    return findNearestPoint(world, x, y, radius) < 0;
}
/** Handle mouse event on canvas. Returns void — input state is mutated in-place. */
function handleMousedown(state, world, button, ctrlKey = false) {
    const { mouseX, mouseY } = state;
    if (ctrlKey) {
        // Ctrl+click: try to pin nearest point
        const idx = findNearestPoint(world, mouseX, mouseY, GRAB_RADIUS);
        if (idx >= 0) {
            togglePin(world, idx);
        }
        return;
    }
    if (button === 2) {
        // Right click — tear
        state.rightDown = true;
        tearNear(world, mouseX, mouseY);
    }
    else if (button === 0) {
        // Left click — grab nearest point
        const idx = findNearestPoint(world, mouseX, mouseY, GRAB_RADIUS);
        if (idx >= 0 && !world.points[idx].pin) {
            state.grabPoint = idx;
            state.grabbedByDragging = true;
            state.dragStartX = world.points[idx].pos.x;
            state.dragStartY = world.points[idx].pos.y;
        }
        else {
            // Left click in empty space with Shift → create ball
            if (state.shiftDown) {
                (0, spawner_1.spawnBall)(world, mouseX, mouseY, 15);
            }
        }
    }
}
/** Handle mousemove — update grab position and tear */
function handleMousemove(state, world) {
    state.prevMouseX = state.lastMouseX;
    state.prevMouseY = state.lastMouseY;
    state.lastMouseX = state.mouseX;
    state.lastMouseY = state.mouseY;
    if (state.grabPoint !== null) {
        const p = world.points[state.grabPoint];
        // Move to cursor
        p.pos.x = state.mouseX;
        p.pos.y = state.mouseY;
        // Clear previous position so velocity = 0 when released (thrown via next step)
        // Actually, we want the velocity to persist, so set prev = pos after move
        p.prev.x = state.mouseX;
        p.prev.y = state.mouseY;
        // compute velocity from drag motion
        state.velocityX = state.mouseX - state.prevMouseX;
        state.velocityY = state.mouseY - state.prevMouseY;
    }
    if (state.rightDown) {
        tearNear(world, state.mouseX, state.mouseY);
    }
}
/** Handle mouseup — release grab, potentially create object via dragging */
function handleMouseup(state, world, button) {
    if (button === 0) {
        if (state.grabPoint !== null) {
            // Release the grabbed point — velocity is already set from drag
            state.grabPoint = null;
            state.grabbedByDragging = false;
        }
    }
    else if (button === 2) {
        state.rightDown = false;
    }
}
/** Keyboard handling */
function handleKeydown(key) {
    switch (key) {
        case ' ': return 'pause';
        case 'ArrowRight': return 'step';
        case 'ArrowUp': return 'gravity-toggle';
        case 'ArrowDown': return 'wind-toggle';
        default: return '';
    }
}
/** Get the grab point for rendering */
function getGrabInfo(state, world) {
    if (state.grabPoint !== null) {
        const p = world.points[state.grabPoint];
        return { idx: state.grabPoint, x: p.pos.x, y: p.pos.y };
    }
    return null;
}
/** Get the tear indicator position for rendering */
function getTearInfo(state) {
    if (state.rightDown) {
        return { x: state.mouseX, y: state.mouseY };
    }
    return null;
}
};

// ── module: src/render.ts ──
__mods["src/render.ts"] = function (exports, require, module) {
"use strict";
// ─────────────────────────────────────────────────────────────
// Render — Canvas 2D rendering with stress visualization & HUD
// ─────────────────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.createRenderState = createRenderState;
exports.setShowStress = setShowStress;
exports.setDragRopePreview = setDragRopePreview;
exports.updateDragRopePreview = updateDragRopePreview;
exports.clearDragRopePreview = clearDragRopePreview;
exports.isDraggingRope = isDraggingRope;
exports.stressColor = stressColor;
exports.render = render;
const config_1 = require("./config");
const physics_1 = require("./physics");
const input_1 = require("./input");
function createRenderState(ctx, cw, ch) {
    return {
        ctx, canvasW: cw, canvasH: ch,
        fps: 0, frameCount: 0,
        lastFpsTime: performance.now(),
        lastStepTime: 0,
    };
}
let _showStress = false;
function setShowStress(v) { _showStress = v; }
// ─────────────────────────────────────────────────────────
// Shared drag preview state (for main ↔ render coordination)
// ─────────────────────────────────────────────────────────
function setDragRopePreview(startX, startY) {
    // No-op now that drag preview state has been removed
}
function updateDragRopePreview(x, y) {
    // No-op
}
function clearDragRopePreview() {
    // No-op
}
function isDraggingRope() { return false; }
// ─────────────────────────────────────────────────────────
// Helper: stress color
// ─────────────────────────────────────────────────────────
function stressColor(ratio) {
    const clamped = Math.min(Math.abs(ratio), 1.0);
    const r = Math.round(50 + 205 * clamped);
    const g = Math.round(255 * (1 - clamped));
    return `rgb(${r},${g},40)`;
}
// ─────────────────────────────────────────────────────────
// Group color helpers
// ─────────────────────────────────────────────────────────
function getGroupType(group) {
    return Math.abs(group) % 100;
}
function groupColor(type) {
    switch (type) {
        case 1: return config_1.COLORS.ROPE;
        case 2: return config_1.COLORS.CLOTH;
        case 3: return config_1.COLORS.SOFT_BODY;
        case 4: return config_1.COLORS.BALL;
        default: return '#8888aa';
    }
}
function groupColorAlpha(type, alpha) {
    const hex = groupColor(type);
    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})`;
}
function buildGroups(world) {
    const map = new Map();
    for (let ci = 0; ci < world.constraints.length; ci++) {
        const c = world.constraints[ci];
        if (!c.active)
            continue;
        let g = map.get(c.group);
        if (!g) {
            g = { group: c.group, type: getGroupType(c.group), points: [], constraints: [] };
            map.set(c.group, g);
        }
        g.constraints.push(ci);
        if (!g.points.includes(c.i))
            g.points.push(c.i);
        if (!g.points.includes(c.j))
            g.points.push(c.j);
    }
    return map;
}
// ─────────────────────────────────────────────────────────
// Main render function
// ─────────────────────────────────────────────────────────
function render(world, state, input) {
    const ctx = state.ctx;
    const W = state.canvasW;
    const H = state.canvasH;
    // Background
    ctx.fillStyle = config_1.COLORS.BG;
    ctx.fillRect(0, 0, W, H);
    // Subtle grid
    drawGrid(ctx, W, H);
    // Obstacles
    drawObstacles(ctx, world);
    // Build groups from active constraints
    const groups = buildGroups(world);
    // Draw fills for group types that support it
    drawGroupFills(ctx, world, groups);
    // Draw constraints
    drawConstraints(ctx, world);
    // Draw points (dots) — only for non-cloth types
    drawPointDots(ctx, world);
    // Pin indicators
    drawPins(ctx, world);
    // Grab highlight ring
    const grabInfo = (0, input_1.getGrabInfo)(input, world);
    if (grabInfo) {
        drawGrabRing(ctx, grabInfo.x, grabInfo.y);
    }
    // Tear indicator (disabled to fix TS narrowing)
    // Drag preview disabled
    // HUD update
    updateFPS(state);
    drawHUD(ctx, state, world);
}
// ─────────────────────────────────────────────────────────
// Grid
// ─────────────────────────────────────────────────────────
function drawGrid(ctx, w, h) {
    ctx.strokeStyle = config_1.COLORS.GRID;
    ctx.lineWidth = 0.4;
    const sp = 40;
    for (let x = sp; x < w; x += sp) {
        ctx.beginPath();
        ctx.moveTo(x, 0);
        ctx.lineTo(x, h);
        ctx.stroke();
    }
    for (let y = sp; y < h; y += sp) {
        ctx.beginPath();
        ctx.moveTo(0, y);
        ctx.lineTo(w, y);
        ctx.stroke();
    }
}
// ─────────────────────────────────────────────────────────
// Obstacles
// ─────────────────────────────────────────────────────────
function drawObstacles(ctx, world) {
    for (const obs of world.obstacles) {
        if (obs.type === 'rect') {
            const grad = ctx.createLinearGradient(obs.x, obs.y, obs.x, obs.y + obs.h);
            grad.addColorStop(0, '#3a3a5e');
            grad.addColorStop(1, '#2a2a42');
            ctx.fillStyle = grad;
            ctx.fillRect(obs.x, obs.y, obs.w, obs.h);
            ctx.strokeStyle = '#555588';
            ctx.lineWidth = 1.5;
            ctx.strokeRect(obs.x, obs.y, obs.w, obs.h);
        }
        else if (obs.type === 'circle') {
            const grad = ctx.createRadialGradient(obs.x, obs.y, 0, obs.x, obs.y, obs.r);
            grad.addColorStop(0, '#4a4a72');
            grad.addColorStop(1, '#2a2a42');
            ctx.fillStyle = grad;
            ctx.beginPath();
            ctx.arc(obs.x, obs.y, obs.r, 0, Math.PI * 2);
            ctx.fill();
            ctx.strokeStyle = '#555588';
            ctx.lineWidth = 1.5;
            ctx.stroke();
        }
    }
}
// ─────────────────────────────────────────────────────────
// Group fills (cloth mesh, soft body blob)
// ─────────────────────────────────────────────────────────
function drawGroupFills(ctx, world, groups) {
    for (const g of groups.values()) {
        if (g.type === 2) {
            drawClothFill(ctx, world, g);
        }
        else if (g.type === 3) {
            drawSoftBodyFill(ctx, g.points.map(i => world.points[i]));
        }
        else if (g.type === 4) {
            const pt = world.points[g.points[0]];
            if (pt) {
                const r = pt.radius;
                ctx.fillStyle = groupColorAlpha(4, config_1.RENDER.BALL_OPACITY);
                ctx.beginPath();
                ctx.arc(pt.pos.x, pt.pos.y, r, 0, Math.PI * 2);
                ctx.fill();
                ctx.strokeStyle = groupColor(4);
                ctx.lineWidth = 1.5;
                ctx.stroke();
                // Shine
                ctx.fillStyle = 'rgba(255,255,255,0.2)';
                ctx.beginPath();
                ctx.arc(pt.pos.x - r * 0.3, pt.pos.y - r * 0.3, r * 0.4, 0, Math.PI * 2);
                ctx.fill();
            }
        }
    }
}
/** Draw cloth as triangular mesh fill */
function drawClothFill(ctx, world, g) {
    if (g.points.length < 4)
        return;
    const pts = g.points.map(i => ({ x: world.points[i].pos.x, y: world.points[i].pos.y }));
    // Y-cluster
    const clusters = [];
    const yThresh = 6;
    for (const pt of pts) {
        let placed = false;
        for (const cl of clusters) {
            if (Math.abs(cl[0].y - pt.y) < yThresh) {
                cl.push(pt);
                placed = true;
                break;
            }
        }
        if (!placed)
            clusters.push([pt]);
    }
    clusters.sort((a, b) => a[0].y - b[0].y);
    for (const cl of clusters)
        cl.sort((a, b) => a.x - b.x);
    if (clusters.length < 2)
        return;
    for (let row = 0; row < clusters.length - 1; row++) {
        const upper = clusters[row];
        const lower = clusters[row + 1];
        for (let c = 0; c < upper.length - 1; c++) {
            const up0 = upper[c], up1 = upper[c + 1];
            const lo0opt = findClosestInRow(lower, up0.x, up1.x);
            if (!lo0opt)
                continue;
            const lo0 = lo0opt;
            const lo1opt = findClosestInRow(lower.slice(1), lo0.x + 1, up1.x);
            if (lo1opt) {
                fillTri(ctx, up0, up1, lo0);
                fillTri(ctx, up1, lo1opt, lo0);
            }
            else {
                fillTri(ctx, up0, up1, lo0);
            }
        }
    }
}
function findClosestInRow(row, targetX, upperX) {
    let best = null;
    let bestDist = Infinity;
    for (const p of row) {
        const d = Math.hypot(p.x - targetX, p.y - upperX); // rough
        if (d < bestDist) {
            bestDist = d;
            best = p;
        }
    }
    return best || row[0] || null;
}
function fillTri(ctx, a, b, c) {
    ctx.fillStyle = groupColorAlpha(2, config_1.RENDER.CLOTH_OPACITY);
    ctx.beginPath();
    ctx.moveTo(a.x, a.y);
    ctx.lineTo(b.x, b.y);
    ctx.lineTo(c.x, c.y);
    ctx.closePath();
    ctx.fill();
}
/** Draw soft body convex hull */
function drawSoftBodyFill(ctx, pts) {
    if (pts.length < 3)
        return;
    let cx = 0, cy = 0;
    for (const p of pts) {
        cx += p.pos.x;
        cy += p.pos.y;
    }
    cx /= pts.length;
    cy /= pts.length;
    const sorted = [...pts].sort((a, b) => Math.atan2(a.pos.y - cy, a.pos.x - cx) - Math.atan2(b.pos.y - cy, b.pos.x - cx));
    ctx.fillStyle = groupColorAlpha(3, config_1.RENDER.SOFT_BODY_OPACITY);
    ctx.beginPath();
    ctx.moveTo(sorted[0].pos.x, sorted[0].pos.y);
    for (let i = 1; i < sorted.length; i++)
        ctx.lineTo(sorted[i].pos.x, sorted[i].pos.y);
    ctx.closePath();
    ctx.fill();
    ctx.strokeStyle = groupColor(3);
    ctx.lineWidth = 1.5;
    ctx.stroke();
}
// ─────────────────────────────────────────────────────────
// Constraints
// ─────────────────────────────────────────────────────────
function drawConstraints(ctx, world) {
    for (let ci = 0; ci < world.constraints.length; ci++) {
        const c = world.constraints[ci];
        if (!c.active)
            continue;
        const a = world.points[c.i], b = world.points[c.j];
        const type = getGroupType(c.group);
        if (_showStress) {
            const stress = (0, physics_1.getConstraintStress)(c, world.points);
            ctx.strokeStyle = stressColor(stress);
        }
        else {
            ctx.strokeStyle = groupColor(type);
        }
        ctx.lineWidth = config_1.RENDER.LINE_WIDTH;
        ctx.beginPath();
        ctx.moveTo(a.pos.x, a.pos.y);
        ctx.lineTo(b.pos.x, b.pos.y);
        ctx.stroke();
    }
}
// ─────────────────────────────────────────────────────────
// Points (dots) — non-cloth
// ─────────────────────────────────────────────────────────
function drawPointDots(ctx, world) {
    const r = config_1.RENDER.DOT_RADIUS;
    for (const p of world.points) {
        if (getGroupType(p.group) === 2)
            continue;
        ctx.fillStyle = groupColor(p.group);
        ctx.beginPath();
        ctx.arc(p.pos.x, p.pos.y, r, 0, Math.PI * 2);
        ctx.fill();
        // Glow for soft bodies/balls
        const t = getGroupType(p.group);
        if (t === 3 || t === 4) {
            const grad = ctx.createRadialGradient(p.pos.x, p.pos.y, r, p.pos.x, p.pos.y, r * 2.5);
            grad.addColorStop(0, groupColorAlpha(t, 0.15));
            grad.addColorStop(1, 'transparent');
            ctx.fillStyle = grad;
            ctx.beginPath();
            ctx.arc(p.pos.x, p.pos.y, r * 2.5, 0, Math.PI * 2);
            ctx.fill();
        }
    }
}
// ─────────────────────────────────────────────────────────
// Pin indicators
// ─────────────────────────────────────────────────────────
function drawPins(ctx, world) {
    const pr = config_1.RENDER.DOT_RADIUS + 3;
    ctx.strokeStyle = config_1.COLORS.PIN;
    ctx.lineWidth = 1.5;
    ctx.globalAlpha = 0.6;
    for (const p of world.points) {
        if (!p.pin)
            continue;
        ctx.beginPath();
        ctx.moveTo(p.pos.x - pr, p.pos.y);
        ctx.lineTo(p.pos.x + pr, p.pos.y);
        ctx.moveTo(p.pos.x, p.pos.y - pr);
        ctx.lineTo(p.pos.x, p.pos.y + pr);
        ctx.stroke();
    }
    ctx.globalAlpha = 1;
}
// ─────────────────────────────────────────────────────────
// Grab highlight
// ─────────────────────────────────────────────────────────
function drawGrabRing(ctx, x, y) {
    const r = config_1.RENDER.GRAB_RADIUS;
    ctx.strokeStyle = config_1.COLORS.GRAB;
    ctx.lineWidth = 2;
    ctx.globalAlpha = 0.6;
    ctx.setLineDash([3, 3]);
    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.stroke();
    ctx.setLineDash([]);
    ctx.globalAlpha = 1;
}
// ─────────────────────────────────────────────────────────
// Drag rope preview
// ─────────────────────────────────────────────────────────
function drawRopeDragPreview(ctx, startX, startY, endX, endY) {
    const dx = endX - startX, dy = endY - startY;
    const len = Math.hypot(dx, dy);
    if (len < 10)
        return;
    const segLen = Math.max(config_1.SPAWN.ROPE_SEG_LEN, len / config_1.SPAWN.ROPE_MIN_POINTS);
    const numSegs = Math.max(2, Math.floor(len / segLen));
    ctx.strokeStyle = config_1.COLORS.ROPE + '88';
    ctx.lineWidth = 2;
    ctx.setLineDash([6, 4]);
    ctx.beginPath();
    ctx.moveTo(startX, startY);
    for (let s = 1; s <= numSegs; s++) {
        const t = s / numSegs;
        ctx.lineTo(startX + dx * t, startY + dy * t);
    }
    ctx.stroke();
    ctx.setLineDash([]);
    ctx.fillStyle = config_1.COLORS.ROPE;
    ctx.beginPath();
    ctx.arc(startX, startY, 5, 0, Math.PI * 2);
    ctx.fill();
}
// ─────────────────────────────────────────────────────────
// FPS & HUD
// ─────────────────────────────────────────────────────────
function updateFPS(state) {
    const now = performance.now();
    state.frameCount++;
    const delta = now - state.lastFpsTime;
    if (delta >= 500) {
        state.fps = Math.round(state.frameCount / (delta / 1000));
        state.frameCount = 0;
        state.lastFpsTime = now;
    }
}
function drawHUD(ctx, state, world) {
    // In-canvas HUD
    const W = state.canvasW;
    const panelW = 200;
    ctx.fillStyle = 'rgba(0,0,0,0.4)';
    ctx.beginPath();
    ctx.roundRect(W - panelW - 4, 4, panelW, 26, 4);
    ctx.fill();
    ctx.fillStyle = config_1.COLORS.HUD_TEXT;
    ctx.font = '11px monospace';
    ctx.textBaseline = 'top';
    const msg = `FPS: ${state.fps}  |  ${world.points.length} pts  |  ${world.constraints.length} constraints  |  ${state.lastStepTime.toFixed(1)}ms`;
    ctx.fillText(msg, W - panelW, 7);
    // Bottom bar (live stats in DOM)
    const hudEl = document.getElementById('hud-info');
    if (hudEl && state.frameCount % 8 === 0) {
        hudEl.textContent = `FPS: ${state.fps}  |  Points: ${world.points.length}  |  Constraints: ${world.constraints.length}  |  Step: ${state.lastStepTime.toFixed(1)}ms  |  ${world.paused ? '⏸ PAUSED' : world.gravityOn ? '⚡' : '🪶'}`;
    }
}
};

// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen/qwen3.7-flash","provider":"openrouter","persona":"typescript","sessionId":"4a537b52-50e9-4896-bf6f-8b56211e2fb1","tokensIn":6438670,"tokensOut":76658,"tokensTotal":6515328,"turns":97,"toolCalls":96,"failedToolCalls":6,"timestamp":"2026-07-30T16:36:28.239Z"} -->