← Physics Playground results

Physics Playground

Qwen 3.6 27b MTP · 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 60,342 bytes · SHA-256 0cf41bbdca40
<!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: #0d1117;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', monospace;
  color: #c9d1d9;
}

#app {
  width: 100%;
  height: 100%;
  position: relative;
}

canvas#canvas {
  display: block;
  width: 100%;
  height: 100%;
}
/* ── css: toolbar ── */
#toolbar {
  position: absolute;
  top: 8px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 6px 12px;
  background: rgba(13, 17, 23, 0.85);
  border: 1px solid #30363d;
  border-radius: 8px;
  backdrop-filter: blur(8px);
  z-index: 10;
  user-select: none;
}

#toolbar button {
  background: rgba(48, 54, 61, 0.8);
  color: #c9d1d9;
  border: 1px solid #30363d;
  border-radius: 4px;
  padding: 4px 10px;
  font-size: 12px;
  cursor: pointer;
  transition: background 0.15s, border-color 0.15s;
  white-space: nowrap;
}

#toolbar button:hover {
  background: rgba(63, 69, 81, 0.9);
  border-color: #58a6ff;
}

#toolbar button.active {
  background: rgba(56, 132, 255, 0.3);
  border-color: #58a6ff;
  color: #fff;
}

#toolbar .sep {
  width: 1px;
  height: 24px;
  background: #30363d;
  margin: 0 4px;
}

#toolbar label {
  font-size: 11px;
  color: #8b949e;
  display: flex;
  align-items: center;
  gap: 4px;
  white-space: nowrap;
}

#toolbar input[type="range"] {
  width: 60px;
  height: 16px;
  accent-color: #58a6ff;
  cursor: pointer;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div id="toolbar">
  <button id="btn-rope" title="Spawn Rope (drag to draw)">🪢 Rope</button>
  <button id="btn-cloth" title="Spawn Cloth">🧵 Cloth</button>
  <button id="btn-softbody" title="Spawn Soft Body">🟢 Blob</button>
  <button id="btn-ball" title="Spawn Ball">⚽ Ball</button>
  <span class="sep"></span>
  <button id="btn-pause" title="Pause/Play (Space)">⏸ Pause</button>
  <button id="btn-step" title="Step one frame (→)">⏭ Step</button>
  <button id="btn-reset" title="Reset (R)">🗑 Reset</button>
  <span class="sep"></span>
  <label>G: <input type="range" id="slider-gravity" min="0" max="200" value="80"></label>
  <label>Iter: <input type="range" id="slider-iterations" min="1" max="15" value="6"></label>
  <label>Wind: <input type="range" id="slider-wind" min="-200" max="200" value="0"></label>
  <span class="sep"></span>
  <button id="btn-points">●</button>
  <button id="btn-stress">🌡</button>
  <button id="btn-blur">〰</button>
</div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./config":"src/config.ts","./types":"src/types.ts","./physics":"src/physics.ts","./objects":"src/objects.ts","./input":"src/input.ts","./render":"src/render.ts"},"src/physics.ts":{"./config":"src/config.ts","./types":"src/types.ts"},"src/objects.ts":{"./config":"src/config.ts","./types":"src/types.ts"},"src/input.ts":{"./config":"src/config.ts","./types":"src/types.ts","./objects":"src/objects.ts"},"src/render.ts":{"./config":"src/config.ts","./types":"src/types.ts","./objects":"src/objects.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: Simulation Loop, UI Wiring, Window Exposure ─────────
Object.defineProperty(exports, "__esModule", { value: true });
const config_1 = require("./config");
const physics_1 = require("./physics");
const objects_1 = require("./objects");
const input_1 = require("./input");
const render_1 = require("./render");
// ─── Simulation State ────────────────────────────────────────────────
const points = [];
const constraints = [];
const objects = [];
let paused = false;
let gravityOn = true;
let currentGravity = config_1.CONFIG.gravity;
let currentWind = 0;
let currentIterations = config_1.CONFIG.iterations;
// Static geometry
const staticCircles = [
    { x: 400, y: 520, radius: 60 }, // Large circle obstacle
    { x: 750, y: 380, radius: 40 }, // Medium circle
];
const staticBoxes = [
    { x: 900, y: 550, w: 120, h: 20 }, // Platform
];
// ─── Canvas Setup ────────────────────────────────────────────────────
const canvasEl = document.getElementById('canvas');
const renderer = new render_1.Renderer(canvasEl);
function resizeCanvas() {
    const w = window.innerWidth;
    const h = window.innerHeight;
    canvasEl.width = w;
    canvasEl.height = h;
}
// ─── Input Handling ──────────────────────────────────────────────────
const input = new input_1.InputHandler(canvasEl);
let spawnMode = null;
let spawnStartX = 0;
let spawnStartY = 0;
let spawnDragX = 0;
let spawnDragY = 0;
let tearLinePreview = null;
input.onGrab = (idx) => { };
input.onRelease = () => { };
input.onTearLine = (line) => {
    tearLinePreview = line;
    (0, objects_1.tearConstraints)(constraints, line.x1, line.y1, line.x2, line.y2, points);
};
input.onPinToggle = (idx) => {
    if (idx >= 0 && idx < points.length) {
        points[idx].pinned = !points[idx].pinned;
    }
};
input.onSpawnStart = (x, y) => {
    if (!spawnMode)
        return;
    spawnStartX = x;
    spawnStartY = y;
    spawnDragX = x;
    spawnDragY = y;
};
input.onSpawnDrag = (x, y) => {
    spawnDragX = x;
    spawnDragY = y;
};
input.onSpawnEnd = () => {
    if (!spawnMode)
        return;
    // Spawn the selected object type at drag start position
    const sx = spawnStartX;
    const sy = spawnStartY;
    switch (spawnMode) {
        case 'rope':
            (0, objects_1.spawnRope)(points, constraints, objects, sx, sy, spawnDragX, spawnDragY);
            break;
        case 'cloth':
            (0, objects_1.spawnCloth)(points, constraints, objects, sx - 140, sy - 100);
            break;
        case 'softBody':
            (0, objects_1.spawnSoftBody)(points, constraints, objects, sx, sy);
            break;
        case 'ball':
            (0, objects_1.spawnBall)(points, constraints, objects, sx, sy);
            break;
    }
    spawnMode = null;
    updateToolbarActive();
};
// ─── Toolbar Setup ──────────────────────────────────────────────────
const toolbarBtns = {};
function setupToolbar() {
    const btnIds = ['btn-rope', 'btn-cloth', 'btn-softbody', 'btn-ball'];
    for (const id of btnIds) {
        const el = document.getElementById(id);
        if (el) {
            toolbarBtns[id] = el;
            el.addEventListener('click', () => {
                const type = id.replace('btn-', '');
                spawnMode = spawnMode === type ? null : type;
                updateToolbarActive();
            });
        }
    }
    // Pause button
    const pauseBtn = document.getElementById('btn-pause');
    if (pauseBtn) {
        pauseBtn.addEventListener('click', () => {
            paused = !paused;
            pauseBtn.textContent = paused ? '▶ Play' : '⏸ Pause';
        });
    }
    // Step button
    const stepBtn = document.getElementById('btn-step');
    if (stepBtn) {
        stepBtn.addEventListener('click', () => {
            if (paused) {
                runPhysicsStep();
            }
        });
    }
    // Reset button
    const resetBtn = document.getElementById('btn-reset');
    if (resetBtn) {
        resetBtn.addEventListener('click', resetSimulation);
    }
    // Gravity slider
    const gravSlider = document.getElementById('slider-gravity');
    if (gravSlider) {
        gravSlider.value = String(currentGravity / 10);
        gravSlider.addEventListener('input', () => {
            currentGravity = parseFloat(gravSlider.value) * 10;
        });
    }
    // Iterations slider
    const iterSlider = document.getElementById('slider-iterations');
    if (iterSlider) {
        iterSlider.value = String(currentIterations);
        iterSlider.addEventListener('input', () => {
            currentIterations = parseInt(iterSlider.value, 10);
        });
    }
    // Wind slider
    const windSlider = document.getElementById('slider-wind');
    if (windSlider) {
        windSlider.addEventListener('input', () => {
            currentWind = parseFloat(windSlider.value);
        });
    }
    // Toggle buttons
    const togglePointsBtn = document.getElementById('btn-points');
    if (togglePointsBtn) {
        togglePointsBtn.addEventListener('click', () => {
            renderer.setShowPoints(!renderer.showPoints);
        });
    }
    const toggleStressBtn = document.getElementById('btn-stress');
    if (toggleStressBtn) {
        toggleStressBtn.addEventListener('click', () => {
            renderer.setShowStress(!renderer.showStress);
        });
    }
    const toggleBlurBtn = document.getElementById('btn-blur');
    if (toggleBlurBtn) {
        toggleBlurBtn.addEventListener('click', () => {
            renderer.setMotionBlur(!renderer.motionBlur);
        });
    }
}
function updateToolbarActive() {
    for (const [id, el] of Object.entries(toolbarBtns)) {
        if (!el)
            continue;
        const type = id.replace('btn-', '');
        if (spawnMode === type) {
            el.classList.add('active');
        }
        else {
            el.classList.remove('active');
        }
    }
}
// ─── Keyboard Controls ──────────────────────────────────────────────
document.addEventListener('keydown', (e) => {
    if (e.code === 'Space') {
        e.preventDefault();
        paused = !paused;
        const pauseBtn = document.getElementById('btn-pause');
        if (pauseBtn) {
            pauseBtn.textContent = paused ? '▶ Play' : '⏸ Pause';
        }
    }
    else if (e.code === 'ArrowRight' && paused) {
        e.preventDefault();
        runPhysicsStep();
    }
    else if (e.key === 'r' || e.key === 'R') {
        resetSimulation();
    }
});
// ─── Reset Simulation ────────────────────────────────────────────────
function resetSimulation() {
    points.length = 0;
    constraints.length = 0;
    objects.length = 0;
    spawnMode = null;
    updateToolbarActive();
}
// ─── Physics Step ────────────────────────────────────────────────────
function runPhysicsStep() {
    const bounds = {
        left: 0,
        top: 0,
        right: canvasEl.width,
        bottom: canvasEl.height,
    };
    (0, physics_1.physicsStep)(points, constraints, gravityOn ? currentGravity : 0, currentWind, currentIterations, bounds, staticCircles, staticBoxes);
    // Apply grab after physics step (keeps grabbed point at cursor)
    input.applyGrab(points);
}
// ─── Game Loop with Fixed Timestep Accumulator ──────────────────────
let lastTime = performance.now();
let accumulator = 0;
const dtMs = config_1.CONFIG.dt * 1000; // ms per physics step
let fpsCounter = 0;
let fpsDisplay = 60;
let fpsTimer = 0;
let stepTimeMs = 0;
function gameLoop(now) {
    let frameDelta = (now - lastTime) / 1000; // seconds
    lastTime = now;
    // Clamp delta to prevent explosion on tab switch
    if (frameDelta > config_1.CONFIG.maxFrameDelta) {
        frameDelta = config_1.CONFIG.maxFrameDelta;
    }
    accumulator += frameDelta;
    // FPS counting
    fpsCounter++;
    fpsTimer += frameDelta;
    if (fpsTimer >= 0.5) {
        fpsDisplay = Math.round(fpsCounter / fpsTimer);
        fpsCounter = 0;
        fpsTimer = 0;
    }
    // Fixed timestep physics steps
    if (!paused) {
        let stepCount = 0;
        const maxSteps = 10; // Prevent spiral of death
        const startStepTime = performance.now();
        while (accumulator >= config_1.CONFIG.dt && stepCount < maxSteps) {
            runPhysicsStep();
            accumulator -= config_1.CONFIG.dt;
            stepCount++;
        }
        stepTimeMs = performance.now() - startStepTime;
        // Drain remaining accumulator if we hit the limit
        if (stepCount >= maxSteps) {
            accumulator = 0;
        }
    }
    else {
        // When paused, drain accumulator to avoid burst on resume
        accumulator = Math.min(accumulator, config_1.CONFIG.dt);
    }
    // Render
    const bounds = {
        left: 0,
        top: 0,
        right: canvasEl.width,
        bottom: canvasEl.height,
    };
    renderer.render(points, constraints, objects, staticCircles, staticBoxes, input.grabbedPointIndex, tearLinePreview, bounds);
    // Clear tear preview after rendering (it's only for visual feedback)
    if (tearLinePreview && !input.isTearing) {
        tearLinePreview = null;
    }
    renderer.renderHUD(fpsDisplay, stepTimeMs, paused);
    requestAnimationFrame(gameLoop);
}
// ─── Expose Simulation on Window for Probing ────────────────────────
window.sim = {
    get points() { return points; },
    get constraints() { return constraints; },
    get objects() { return objects; },
    step: () => runPhysicsStep(),
    get paused() { return paused; },
    set paused(v) { paused = v; },
    reset: resetSimulation,
    spawnRope: (sx, sy, ex, ey) => (0, objects_1.spawnRope)(points, constraints, objects, sx, sy, ex, ey),
    spawnCloth: (x, y) => (0, objects_1.spawnCloth)(points, constraints, objects, x, y),
    spawnSoftBody: (cx, cy) => (0, objects_1.spawnSoftBody)(points, constraints, objects, cx, cy),
    spawnBall: (x, y) => (0, objects_1.spawnBall)(points, constraints, objects, x, y),
    tearConstraints: (x1, y1, x2, y2) => (0, objects_1.tearConstraints)(constraints, x1, y1, x2, y2, points),
    totalKineticEnergy: () => (0, physics_1.totalKineticEnergy)(points),
    hasNaNOrOutOfBounds: () => (0, physics_1.hasNaNOrOutOfBounds)(points),
};
// Also expose for internal input handler access
window.__simPoints = points;
// ─── Initialize ──────────────────────────────────────────────────────
function init() {
    resizeCanvas();
    setupToolbar();
    // Spawn some default objects to demonstrate the playground
    const w = canvasEl.width;
    const h = canvasEl.height;
    // Rope from top-left area
    (0, objects_1.spawnRope)(points, constraints, objects, 150, 60, 150, 350);
    // Cloth pinned at corners
    (0, objects_1.spawnCloth)(points, constraints, objects, w / 2 - 140, 80, 20, 15, 14, 'corners');
    // Soft body in the middle-right area
    (0, objects_1.spawnSoftBody)(points, constraints, objects, w - 200, h / 3);
    // A couple of balls
    (0, objects_1.spawnBall)(points, constraints, objects, w / 2, 50);
    (0, objects_1.spawnBall)(points, constraints, objects, w - 100, 100);
    requestAnimationFrame(gameLoop);
}
// Handle window resize
window.addEventListener('resize', () => {
    resizeCanvas();
});
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 timestep (fixed) — decoupled from render rate
    dt: 1 / 60,
    // Damping per step (1.0 = no damping, lower = more energy loss)
    damping: 0.995,
    // Constraint solver iterations per physics step
    iterations: 6,
    // Gravity strength (pixels/s²). Set to 0 to disable.
    gravity: 800,
    // Wind force (pixels/s²), horizontal only
    wind: 0,
    // Maximum frame delta before clamping (prevents tab-switch explosion)
    maxFrameDelta: 0.25,
    // Grab radius in pixels — click within this to grab nearest point
    grabRadius: 18,
    // Tear/cut radius — constraints within this distance of the cut line are severed
    tearRadius: 12,
    // Point rendering radius (for debug / non-cloth objects)
    pointRadius: 3,
    // ─── Colors ──────────────────────────────────────────────────────
    colors: {
        bg: '#0d1117',
        grid: '#161b22',
        wallStroke: '#30363d',
        rope: '#58a6ff', // blue
        cloth: '#f0883e', // orange
        softBody: '#3fb950', // green
        ball: '#db61a2', // pink
        pin: '#ffffff',
        grabHighlight: '#ffd700',
        tearEffect: '#ff4444',
        stressGreen: '#3fb950',
        stressRed: '#f85149',
    },
    // ─── Object Defaults ─────────────────────────────────────────────
    ropeSegments: 20,
    ropeSpacing: 16,
    clothCols: 20,
    clothRows: 15,
    clothSpacing: 14,
    softBodyRadius: 50,
    softBodyPoints: 16,
    ballRadius: 15,
    // ─── Static Geometry (walls + obstacles) ────────────────────────
    wallThickness: 20,
};
};

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

// ── module: src/physics.ts ──
__mods["src/physics.ts"] = function (exports, require, module) {
"use strict";
// ─── Physics Engine: Verlet Integration + Constraint Relaxation ──────
Object.defineProperty(exports, "__esModule", { value: true });
exports.integrate = integrate;
exports.solveConstraints = solveConstraints;
exports.collideWalls = collideWalls;
exports.collideStaticCircle = collideStaticCircle;
exports.collideStaticBox = collideStaticBox;
exports.physicsStep = physicsStep;
exports.totalKineticEnergy = totalKineticEnergy;
exports.hasNaNOrOutOfBounds = hasNaNOrOutOfBounds;
const config_1 = require("./config");
const dt = config_1.CONFIG.dt;
const dt2 = dt * dt;
const damping = config_1.CONFIG.damping;
// ─── Verlet Integration Step ────────────────────────────────────────
function integrate(points, gravity, wind) {
    for (let i = 0; i < points.length; i++) {
        const p = points[i];
        if (p.pinned)
            continue;
        // Verlet: x' = x + (x - prevX) * damping + a * dt²
        const vx = (p.x - p.prevX) * damping;
        const vy = (p.y - p.prevY) * damping;
        p.prevX = p.x;
        p.prevY = p.y;
        p.x += vx + wind * dt2;
        p.y += vy + gravity * dt2;
        // NaN guard — clamp to zero if something went wrong
        if (!isFinite(p.x) || !isFinite(p.y)) {
            p.x = 0;
            p.y = 0;
            p.prevX = 0;
            p.prevY = 0;
        }
    }
}
// ─── Distance Constraint Relaxation ─────────────────────────────────
function solveConstraints(points, constraints, iterations) {
    for (let iter = 0; iter < iterations; iter++) {
        for (let i = 0; i < constraints.length; i++) {
            const c = constraints[i];
            if (c.severed)
                continue;
            const pa = points[c.a];
            const pb = 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 points
            if (distSq < 1e-8)
                continue;
            const dist = Math.sqrt(distSq);
            const diff = (c.restLength - dist) / dist;
            // Split correction equally between both points
            const ox = dx * diff * 0.5;
            const oy = dy * diff * 0.5;
            if (!pa.pinned) {
                pa.x -= ox;
                pa.y -= oy;
            }
            if (!pb.pinned) {
                pb.x += ox;
                pb.y += oy;
            }
        }
    }
}
// ─── Collision: Point vs Static Walls (bounding box) ────────────────
function collideWalls(points, left, top, right, bottom, friction = 0.95) {
    for (let i = 0; i < points.length; i++) {
        const p = points[i];
        if (p.pinned)
            continue;
        const r = p.radius || 0;
        // Left wall
        if (p.x - r < left) {
            p.x = left + r;
            const vx = p.x - p.prevX;
            p.prevX = p.x + Math.abs(vx) * friction;
        }
        // Right wall
        if (p.x + r > right) {
            p.x = right - r;
            const vx = p.x - p.prevX;
            p.prevX = p.x - Math.abs(vx) * friction;
        }
        // Top wall
        if (p.y - r < top) {
            p.y = top + r;
            const vy = p.y - p.prevY;
            p.prevY = p.y + Math.abs(vy) * friction;
        }
        // Bottom wall (floor)
        if (p.y + r > bottom) {
            p.y = bottom - r;
            const vx = p.x - p.prevX;
            const vy = p.y - p.prevY;
            p.prevX = p.x - vx * friction;
            p.prevY = p.y - Math.abs(vy) * friction;
        }
    }
}
// ─── Collision: Point vs Static Circle ──────────────────────────────
function collideStaticCircle(points, cx, cy, radius) {
    for (let i = 0; i < points.length; i++) {
        const p = points[i];
        if (p.pinned)
            continue;
        const dx = p.x - cx;
        const dy = p.y - cy;
        const distSq = dx * dx + dy * dy;
        const pr = p.radius || 0;
        const minDist = radius + pr;
        if (distSq < minDist * minDist && distSq > 1e-8) {
            const dist = Math.sqrt(distSq);
            const nx = dx / dist;
            const ny = dy / dist;
            // Push point out of circle
            p.x = cx + nx * minDist;
            p.y = cy + ny * minDist;
            // Reflect velocity with friction
            const vx = p.x - p.prevX;
            const vy = p.y - p.prevY;
            const dot = vx * nx + vy * ny;
            if (dot < 0) {
                p.prevX += 2 * dot * nx * 0.3; // slight bounce
                p.prevY += 2 * dot * ny * 0.3;
            }
        }
    }
}
// ─── Collision: Point vs Static Box ─────────────────────────────────
function collideStaticBox(points, bx, by, bw, bh) {
    for (let i = 0; i < points.length; i++) {
        const p = points[i];
        if (p.pinned)
            continue;
        const pr = p.radius || 0;
        const halfW = bw / 2 + pr;
        const halfH = bh / 2 + pr;
        // Find closest point on box to the particle
        const cx = Math.max(bx - halfW, Math.min(p.x, bx + halfW));
        const cy = Math.max(by - halfH, Math.min(p.y, by + halfH));
        const dx = p.x - cx;
        const dy = p.y - cy;
        const distSq = dx * dx + dy * dy;
        if (distSq < pr * pr && distSq > 1e-8) {
            const dist = Math.sqrt(distSq);
            const nx = dx / dist;
            const ny = dy / dist;
            p.x = cx + nx * pr;
            p.y = cy + ny * pr;
            // Reflect velocity with friction
            const vx = p.x - p.prevX;
            const vy = p.y - p.prevY;
            const dot = vx * nx + vy * ny;
            if (dot < 0) {
                p.prevX += 2 * dot * nx * 0.3;
                p.prevY += 2 * dot * ny * 0.3;
            }
        }
    }
}
// ─── Full Physics Step ──────────────────────────────────────────────
function physicsStep(points, constraints, gravity, wind, iterations, bounds, staticCircles, staticBoxes) {
    integrate(points, gravity, wind);
    // Collisions before constraint solving (helps stability)
    collideWalls(points, bounds.left, bounds.top, bounds.right, bounds.bottom);
    for (const c of staticCircles) {
        collideStaticCircle(points, c.x, c.y, c.radius);
    }
    for (const b of staticBoxes) {
        collideStaticBox(points, b.x, b.y, b.w, b.h);
    }
    solveConstraints(points, constraints, iterations);
    // Second collision pass after constraints (prevents tunneling)
    collideWalls(points, bounds.left, bounds.top, bounds.right, bounds.bottom);
    for (const c of staticCircles) {
        collideStaticCircle(points, c.x, c.y, c.radius);
    }
    for (const b of staticBoxes) {
        collideStaticBox(points, b.x, b.y, b.w, b.h);
    }
}
// ─── Utility: Compute kinetic energy ────────────────────────────────
function totalKineticEnergy(points) {
    let e = 0;
    for (const p of points) {
        const vx = (p.x - p.prevX);
        const vy = (p.y - p.prevY);
        e += vx * vx + vy * vy;
    }
    return e;
}
// ─── Utility: Check for NaN / out-of-bounds points ──────────────────
function hasNaNOrOutOfBounds(points, maxCoord = 10000) {
    for (const p of points) {
        if (!isFinite(p.x) || !isFinite(p.y))
            return true;
        if (Math.abs(p.x) > maxCoord || Math.abs(p.y) > maxCoord)
            return true;
    }
    return false;
}
};

// ── module: src/objects.ts ──
__mods["src/objects.ts"] = function (exports, require, module) {
"use strict";
// ─── Object Spawning: Rope, Cloth, Soft Body, Ball ──────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.spawnRope = spawnRope;
exports.spawnCloth = spawnCloth;
exports.spawnSoftBody = spawnSoftBody;
exports.spawnBall = spawnBall;
exports.tearConstraints = tearConstraints;
exports.findNearestPoint = findNearestPoint;
exports.getObjectColor = getObjectColor;
exports.getObjectForPoint = getObjectForPoint;
exports.getConstraintsForObject = getConstraintsForObject;
const config_1 = require("./config");
let _nextId = 0;
function makePoint(x, y, pinned = false, radius) {
    return { x, y, prevX: x, prevY: y, pinned, radius };
}
function addConstraint(constraints, a, b, restLength) {
    const dx = (constraints.length > 0 ? undefined : 0) || 0; // placeholder
    if (restLength === undefined) {
        // Will be computed from current positions — caller must set it
        constraints.push({ a, b, restLength: 0, severed: false });
    }
    else {
        constraints.push({ a, b, restLength, severed: false });
    }
}
function dist(x1, y1, x2, y2) {
    const dx = x2 - x1;
    const dy = y2 - y1;
    return Math.sqrt(dx * dx + dy * dy);
}
// ─── Spawn Rope ─────────────────────────────────────────────────────
function spawnRope(points, constraints, objects, startX, startY, endX, endY) {
    const segments = config_1.CONFIG.ropeSegments;
    const spacing = config_1.CONFIG.ropeSpacing;
    // Compute actual length and adjust segment count if needed
    const totalLen = dist(startX, startY, endX, endY);
    const segCount = Math.max(3, Math.round(totalLen / spacing));
    const actualSpacing = totalLen / (segCount - 1);
    const dx = (endX - startX) / (segCount - 1);
    const dy = (endY - startY) / (segCount - 1);
    const pointStart = points.length;
    for (let i = 0; i < segCount; i++) {
        // Pin the first point
        points.push(makePoint(startX + dx * i, startY + dy * i, i === 0));
    }
    const constraintStart = constraints.length;
    for (let i = 0; i < segCount - 1; i++) {
        constraints.push({
            a: pointStart + i,
            b: pointStart + i + 1,
            restLength: actualSpacing,
            severed: false,
        });
    }
    objects.push({
        id: `rope-${_nextId++}`,
        type: 'rope',
        pointStart,
        constraintStart,
        pointCount: segCount,
        constraintCount: segCount - 1,
    });
}
// ─── Spawn Cloth ────────────────────────────────────────────────────
function spawnCloth(points, constraints, objects, x, y, cols = config_1.CONFIG.clothCols, rows = config_1.CONFIG.clothRows, spacing = config_1.CONFIG.clothSpacing, pinMode = 'corners') {
    const pointStart = points.length;
    // Create grid of points
    for (let row = 0; row < rows; row++) {
        for (let col = 0; col < cols; col++) {
            let pinned = false;
            if (pinMode === 'fullEdge') {
                pinned = row === 0;
            }
            else {
                // corners only
                pinned = row === 0 && (col === 0 || col === cols - 1);
            }
            points.push(makePoint(x + col * spacing, y + row * spacing, pinned));
        }
    }
    const constraintStart = constraints.length;
    // Structural constraints (horizontal + vertical)
    for (let row = 0; row < rows; row++) {
        for (let col = 0; col < cols; col++) {
            const idx = pointStart + row * cols + col;
            // Right neighbor
            if (col < cols - 1) {
                constraints.push({ a: idx, b: idx + 1, restLength: spacing, severed: false });
            }
            // Bottom neighbor
            if (row < rows - 1) {
                constraints.push({ a: idx, b: idx + cols, restLength: spacing, severed: false });
            }
        }
    }
    // Shear constraints (diagonal) for stiffness
    for (let row = 0; row < rows - 1; row++) {
        for (let col = 0; col < cols - 1; col++) {
            const idx = pointStart + row * cols + col;
            const diagLen = spacing * Math.sqrt(2);
            // Top-left to bottom-right
            constraints.push({ a: idx, b: idx + cols + 1, restLength: diagLen, severed: false });
            // Top-right to bottom-left
            constraints.push({ a: idx + 1, b: idx + cols, restLength: diagLen, severed: false });
        }
    }
    objects.push({
        id: `cloth-${_nextId++}`,
        type: 'cloth',
        pointStart,
        constraintStart,
        pointCount: rows * cols,
        constraintCount: constraints.length - constraintStart,
        clothCols: cols,
        clothRows: rows,
    });
}
// ─── Spawn Soft Body (circle/blob) ──────────────────────────────────
function spawnSoftBody(points, constraints, objects, cx, cy, radius = config_1.CONFIG.softBodyRadius, numPoints = config_1.CONFIG.softBodyPoints) {
    const pointStart = points.length;
    // Place points in a circle
    for (let i = 0; i < numPoints; i++) {
        const angle = (2 * Math.PI * i) / numPoints - Math.PI / 2;
        points.push(makePoint(cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)));
    }
    // Add a center point for internal pressure
    points.push(makePoint(cx, cy));
    const centerIdx = pointStart + numPoints;
    const constraintStart = constraints.length;
    // Edge constraints (perimeter)
    for (let i = 0; i < numPoints; i++) {
        const next = (i + 1) % numPoints;
        const angle1 = (2 * Math.PI * i) / numPoints - Math.PI / 2;
        const angle2 = (2 * Math.PI * next) / numPoints - Math.PI / 2;
        const edgeLen = dist(cx + radius * Math.cos(angle1), cy + radius * Math.sin(angle1), cx + radius * Math.cos(angle2), cy + radius * Math.sin(angle2));
        constraints.push({ a: pointStart + i, b: pointStart + next, restLength: edgeLen, severed: false });
    }
    // Internal brace constraints (center to each perimeter point)
    for (let i = 0; i < numPoints; i++) {
        constraints.push({ a: centerIdx, b: pointStart + i, restLength: radius * 0.95, severed: false });
    }
    // Cross-brace constraints (opposite points) for shape retention
    const half = Math.floor(numPoints / 2);
    for (let i = 0; i < numPoints; i++) {
        const opp = (i + half) % numPoints;
        constraints.push({ a: pointStart + i, b: pointStart + opp, restLength: radius * 1.9, severed: false });
    }
    objects.push({
        id: `softbody-${_nextId++}`,
        type: 'softBody',
        pointStart,
        constraintStart,
        pointCount: numPoints + 1, // +1 for center
        constraintCount: constraints.length - constraintStart,
    });
}
// ─── Spawn Ball (single bouncing point with radius) ────────────────
function spawnBall(points, constraints, objects, x, y, radius = config_1.CONFIG.ballRadius) {
    const idx = points.length;
    points.push(makePoint(x, y, false, radius));
    objects.push({
        id: `ball-${_nextId++}`,
        type: 'ball',
        pointStart: idx,
        constraintStart: constraints.length,
        pointCount: 1,
        constraintCount: 0,
    });
}
// ─── Tear / Cut Constraints Along a Line Segment ────────────────────
function tearConstraints(constraints, x1, y1, x2, y2, points, radius = config_1.CONFIG.tearRadius) {
    const dx = x2 - x1;
    const dy = y2 - y1;
    const lenSq = dx * dx + dy * dy;
    if (lenSq < 1e-8)
        return; // No valid line segment
    for (let i = 0; i < constraints.length; i++) {
        const c = constraints[i];
        if (c.severed)
            continue;
        const pa = points[c.a];
        const pb = points[c.b];
        // Midpoint of constraint
        const mx = (pa.x + pb.x) / 2;
        const my = (pa.y + pb.y) / 2;
        // Project midpoint onto the tear line segment
        const t = Math.max(0, Math.min(1, ((mx - x1) * dx + (my - y1) * dy) / lenSq));
        const projX = x1 + t * dx;
        const projY = y1 + t * dy;
        // Distance from midpoint to tear line
        const distToLine = Math.sqrt((mx - projX) ** 2 + (my - projY) ** 2);
        if (distToLine < radius) {
            c.severed = true;
        }
    }
}
// ─── Find Nearest Point to Mouse ────────────────────────────────────
function findNearestPoint(points, mx, my, radius = config_1.CONFIG.grabRadius) {
    let bestIdx = -1;
    let bestDistSq = radius * radius;
    for (let i = 0; i < points.length; i++) {
        const p = points[i];
        if (p.pinned)
            continue; // Can't grab pinned points
        const dx = p.x - mx;
        const dy = p.y - my;
        const dSq = dx * dx + dy * dy;
        if (dSq < bestDistSq) {
            bestDistSq = dSq;
            bestIdx = i;
        }
    }
    return bestIdx;
}
// ─── Get Object Color ───────────────────────────────────────────────
function getObjectColor(type) {
    switch (type) {
        case 'rope': return config_1.CONFIG.colors.rope;
        case 'cloth': return config_1.CONFIG.colors.cloth;
        case 'softBody': return config_1.CONFIG.colors.softBody;
        case 'ball': return config_1.CONFIG.colors.ball;
    }
}
// ─── Get Object by Point Index ──────────────────────────────────────
function getObjectForPoint(objects, pointIdx) {
    for (const obj of objects) {
        if (pointIdx >= obj.pointStart && pointIdx < obj.pointStart + obj.pointCount) {
            return obj;
        }
    }
    return undefined;
}
// ─── Get Constraints for Object ─────────────────────────────────────
function getConstraintsForObject(constraints, objects, pointIdx) {
    const obj = getObjectForPoint(objects, pointIdx);
    if (!obj)
        return [];
    const result = [];
    for (let i = obj.constraintStart; i < obj.constraintStart + obj.constraintCount; i++) {
        if (i >= constraints.length)
            break;
        const c = constraints[i];
        if (c.a === pointIdx || c.b === pointIdx) {
            result.push(c);
        }
    }
    return result;
}
};

// ── module: src/input.ts ──
__mods["src/input.ts"] = function (exports, require, module) {
"use strict";
// ─── Input Handling: Grab, Tear, Pin Toggle ──────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.InputHandler = void 0;
const config_1 = require("./config");
const objects_1 = require("./objects");
class InputHandler {
    constructor(canvas) {
        this.grab = null;
        this.tearing = false;
        this.tearStartX = 0;
        this.tearStartY = 0;
        this.lastMouseX = 0;
        this.lastMouseY = 0;
        this.canvas = canvas;
        this.bindEvents();
    }
    bindEvents() {
        const c = this.canvas;
        c.addEventListener('mousedown', (e) => this.onMouseDown(e));
        c.addEventListener('mousemove', (e) => this.onMouseMove(e));
        c.addEventListener('mouseup', (e) => this.onMouseUp(e));
        c.addEventListener('contextmenu', (e) => e.preventDefault());
        // Touch support
        c.addEventListener('touchstart', (e) => {
            e.preventDefault();
            const t = e.touches[0];
            this.onMouseDown({ clientX: t.clientX, clientY: t.clientY });
        }, { passive: false });
        c.addEventListener('touchmove', (e) => {
            e.preventDefault();
            const t = e.touches[0];
            this.onMouseMove({ clientX: t.clientX, clientY: t.clientY });
        }, { passive: false });
        c.addEventListener('touchend', (e) => {
            e.preventDefault();
            this.onMouseUp(e);
        }, { passive: false });
    }
    getCanvasPos(e) {
        const rect = this.canvas.getBoundingClientRect();
        return {
            x: (e.clientX - rect.left) * (this.canvas.width / rect.width),
            y: (e.clientY - rect.top) * (this.canvas.height / rect.height),
        };
    }
    onMouseDown(e) {
        const pos = this.getCanvasPos(e);
        this.lastMouseX = pos.x;
        this.lastMouseY = pos.y;
        // Right click or shift+click → tear mode
        if (e.button === 2 || e.shiftKey) {
            this.tearing = true;
            this.tearStartX = pos.x;
            this.tearStartY = pos.y;
            return;
        }
        // Ctrl+click → toggle pin
        if (e.ctrlKey || e.metaKey) {
            const idx = (0, objects_1.findNearestPoint)(this.points, pos.x, pos.y, config_1.CONFIG.grabRadius * 1.5);
            if (idx >= 0 && this.onPinToggle) {
                this.onPinToggle(idx);
            }
            return;
        }
        // Left click → grab nearest point or start spawn drag
        const idx = (0, objects_1.findNearestPoint)(this.points, pos.x, pos.y);
        if (idx >= 0) {
            this.grab = {
                pointIndex: idx,
                mouseX: pos.x,
                mouseY: pos.y,
                prevMouseX: pos.x,
                prevMouseY: pos.y,
            };
            if (this.onGrab)
                this.onGrab(idx);
        }
        else {
            // Start spawn drag
            if (this.onSpawnStart) {
                this.onSpawnStart(pos.x, pos.y);
            }
        }
    }
    onMouseMove(e) {
        const pos = this.getCanvasPos(e);
        if (this.tearing) {
            // Tear along the line from start to current position
            if (this.onTearLine) {
                this.onTearLine({
                    x1: this.lastMouseX,
                    y1: this.lastMouseY,
                    x2: pos.x,
                    y2: pos.y,
                });
            }
            this.tearStartX = pos.x;
            this.tearStartY = pos.y;
        }
        if (this.grab !== null) {
            this.grab.prevMouseX = this.grab.mouseX;
            this.grab.prevMouseY = this.grab.mouseY;
            this.grab.mouseX = pos.x;
            this.grab.mouseY = pos.y;
        }
        // Spawn drag callback
        if (this.onSpawnDrag && !this.tearing && this.grab === null) {
            this.onSpawnDrag(pos.x, pos.y);
        }
        this.lastMouseX = pos.x;
        this.lastMouseY = pos.y;
    }
    onMouseUp(e) {
        if (this.tearing) {
            this.tearing = false;
        }
        if (this.grab !== null) {
            // Throw with drag velocity
            const p = this.points[this.grab.pointIndex];
            if (!p.pinned) {
                const vx = this.grab.mouseX - this.grab.prevMouseX;
                const vy = this.grab.mouseY - this.grab.prevMouseY;
                // Set prev position to create velocity on release
                p.prevX = p.x - vx * 0.5; // dampen throw slightly
                p.prevY = p.y - vy * 0.5;
            }
            if (this.onRelease)
                this.onRelease();
            this.grab = null;
        }
        if (this.onSpawnEnd && !this.tearing) {
            this.onSpawnEnd();
        }
    }
    // Apply grab to physics points each frame
    applyGrab(points) {
        if (!this.grab)
            return;
        const p = points[this.grab.pointIndex];
        if (p.pinned)
            return;
        // Move point directly to mouse position
        p.x = this.grab.mouseX;
        p.y = this.grab.mouseY;
    }
    get isGrabbing() {
        return this.grab !== null;
    }
    get isTearing() {
        return this.tearing;
    }
    get grabbedPointIndex() {
        return this.grab ? this.grab.pointIndex : null;
    }
    // Expose for probing
    get points() {
        return window.__simPoints || [];
    }
}
exports.InputHandler = InputHandler;
};

// ── module: src/render.ts ──
__mods["src/render.ts"] = function (exports, require, module) {
"use strict";
// ─── Rendering: Canvas 2D with Stress Visualization ──────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.Renderer = void 0;
const config_1 = require("./config");
const objects_1 = require("./objects");
class Renderer {
    constructor(canvas) {
        this._showPoints = false;
        this._showStress = false;
        this._motionBlur = true;
        this.canvas = canvas;
        this.ctx = canvas.getContext('2d');
    }
    setMotionBlur(enabled) {
        this._motionBlur = enabled;
    }
    setShowPoints(enabled) {
        this._showPoints = enabled;
    }
    setShowStress(enabled) {
        this._showStress = enabled;
    }
    get showPoints() { return this._showPoints; }
    get showStress() { return this._showStress; }
    get motionBlur() { return this._motionBlur; }
    render(points, constraints, objects, staticCircles, staticBoxes, grabbedIdx, tearLine, bounds) {
        const ctx = this.ctx;
        const w = this.canvas.width;
        const h = this.canvas.height;
        // Motion blur or clear
        if (this._motionBlur) {
            ctx.fillStyle = 'rgba(13, 17, 23, 0.35)';
            ctx.fillRect(0, 0, w, h);
        }
        else {
            ctx.fillStyle = config_1.CONFIG.colors.bg;
            ctx.fillRect(0, 0, w, h);
        }
        // Draw grid (subtle)
        this.drawGrid(w, h);
        // Draw static geometry
        this.drawStaticGeometry(staticCircles, staticBoxes, bounds);
        // Draw constraints and objects per type
        for (const obj of objects) {
            const color = (0, objects_1.getObjectColor)(obj.type);
            if (obj.type === 'cloth') {
                this.renderCloth(points, constraints, obj, color);
            }
            else if (obj.type === 'softBody') {
                this.renderSoftBody(points, constraints, obj, color);
            }
            else if (obj.type === 'rope') {
                this.renderRope(points, constraints, obj, color);
            }
            else if (obj.type === 'ball') {
                this.renderBall(points[obj.pointStart], color);
            }
            // Draw remaining constraints as lines
            for (let i = obj.constraintStart; i < obj.constraintStart + obj.constraintCount; i++) {
                const c = constraints[i];
                if (c.severed || i >= constraints.length)
                    continue;
                if (obj.type === 'cloth' || obj.type === 'softBody')
                    continue; // handled above
                let lineColor = color;
                if (this._showStress) {
                    const pa = points[c.a];
                    const pb = points[c.b];
                    const dx = pb.x - pa.x;
                    const dy = pb.y - pa.y;
                    const currentLen = Math.sqrt(dx * dx + dy * dy);
                    if (c.restLength > 0) {
                        const stretch = currentLen / c.restLength;
                        lineColor = this.stressColor(stretch);
                    }
                }
                ctx.beginPath();
                ctx.moveTo(points[c.a].x, points[c.a].y);
                ctx.lineTo(points[c.b].x, points[c.b].y);
                ctx.strokeStyle = lineColor;
                ctx.lineWidth = obj.type === 'rope' ? 2.5 : 1;
                ctx.stroke();
            }
        }
        // Draw points (if enabled or for balls)
        if (this._showPoints) {
            this.renderAllPoints(points, objects);
        }
        // Draw grab highlight
        if (grabbedIdx !== null && grabbedIdx < points.length) {
            const p = points[grabbedIdx];
            ctx.beginPath();
            ctx.arc(p.x, p.y, config_1.CONFIG.grabRadius + 4, 0, Math.PI * 2);
            ctx.strokeStyle = config_1.CONFIG.colors.grabHighlight;
            ctx.lineWidth = 2;
            ctx.setLineDash([4, 4]);
            ctx.stroke();
            ctx.setLineDash([]);
            // Glow effect
            const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, config_1.CONFIG.grabRadius + 8);
            grad.addColorStop(0, 'rgba(255, 215, 0, 0.3)');
            grad.addColorStop(1, 'rgba(255, 215, 0, 0)');
            ctx.fillStyle = grad;
            ctx.beginPath();
            ctx.arc(p.x, p.y, config_1.CONFIG.grabRadius + 8, 0, Math.PI * 2);
            ctx.fill();
        }
        // Draw tear line preview
        if (tearLine) {
            ctx.beginPath();
            ctx.moveTo(tearLine.x1, tearLine.y1);
            ctx.lineTo(tearLine.x2, tearLine.y2);
            ctx.strokeStyle = config_1.CONFIG.colors.tearEffect;
            ctx.lineWidth = 3;
            ctx.setLineDash([6, 4]);
            ctx.stroke();
            ctx.setLineDash([]);
            // Tear radius indicator
            ctx.beginPath();
            ctx.arc(tearLine.x2, tearLine.y2, config_1.CONFIG.tearRadius, 0, Math.PI * 2);
            ctx.strokeStyle = 'rgba(255, 68, 68, 0.4)';
            ctx.lineWidth = 1;
            ctx.stroke();
        }
        // Draw pins
        for (const p of points) {
            if (!p.pinned)
                continue;
            ctx.beginPath();
            ctx.arc(p.x, p.y, 5, 0, Math.PI * 2);
            ctx.fillStyle = config_1.CONFIG.colors.pin;
            ctx.fill();
            ctx.strokeStyle = '#666';
            ctx.lineWidth = 1;
            ctx.stroke();
            // Pin crosshair
            ctx.beginPath();
            ctx.moveTo(p.x - 8, p.y);
            ctx.lineTo(p.x + 8, p.y);
            ctx.moveTo(p.x, p.y - 8);
            ctx.lineTo(p.x, p.y + 8);
            ctx.strokeStyle = 'rgba(255,255,255,0.4)';
            ctx.lineWidth = 1;
            ctx.stroke();
        }
    }
    drawGrid(w, h) {
        const ctx = this.ctx;
        const spacing = 50;
        ctx.strokeStyle = config_1.CONFIG.colors.grid;
        ctx.lineWidth = 0.5;
        for (let x = spacing; x < w; x += spacing) {
            ctx.beginPath();
            ctx.moveTo(x, 0);
            ctx.lineTo(x, h);
            ctx.stroke();
        }
        for (let y = spacing; y < h; y += spacing) {
            ctx.beginPath();
            ctx.moveTo(0, y);
            ctx.lineTo(w, y);
            ctx.stroke();
        }
    }
    drawStaticGeometry(circles, boxes, bounds) {
        const ctx = this.ctx;
        // Walls
        ctx.strokeStyle = config_1.CONFIG.colors.wallStroke;
        ctx.lineWidth = 2;
        ctx.strokeRect(bounds.left, bounds.top, bounds.right - bounds.left, bounds.bottom - bounds.top);
        // Floor highlight
        ctx.fillStyle = 'rgba(48, 54, 61, 0.3)';
        ctx.fillRect(bounds.left, bounds.bottom - 2, bounds.right - bounds.left, 2);
        // Static circles
        for (const c of circles) {
            ctx.beginPath();
            ctx.arc(c.x, c.y, c.radius, 0, Math.PI * 2);
            ctx.fillStyle = 'rgba(48, 54, 61, 0.6)';
            ctx.fill();
            ctx.strokeStyle = config_1.CONFIG.colors.wallStroke;
            ctx.lineWidth = 2;
            ctx.stroke();
            // Inner detail
            ctx.beginPath();
            ctx.arc(c.x, c.y, c.radius * 0.7, 0, Math.PI * 2);
            ctx.strokeStyle = 'rgba(48, 54, 61, 0.3)';
            ctx.lineWidth = 1;
            ctx.stroke();
        }
        // Static boxes
        for (const b of boxes) {
            ctx.fillStyle = 'rgba(48, 54, 61, 0.6)';
            ctx.fillRect(b.x - b.w / 2, b.y - b.h / 2, b.w, b.h);
            ctx.strokeStyle = config_1.CONFIG.colors.wallStroke;
            ctx.lineWidth = 2;
            ctx.strokeRect(b.x - b.w / 2, b.y - b.h / 2, b.w, b.h);
        }
    }
    renderCloth(points, constraints, obj, color) {
        const ctx = this.ctx;
        const cols = obj.clothCols || config_1.CONFIG.clothCols;
        const rows = obj.clothRows || config_1.CONFIG.clothRows;
        // Draw cloth as filled mesh (triangles)
        for (let row = 0; row < rows - 1; row++) {
            for (let col = 0; col < cols - 1; col++) {
                const i00 = obj.pointStart + row * cols + col;
                const i10 = i00 + 1;
                const i01 = i00 + cols;
                const i11 = i00 + cols + 1;
                if (i11 >= points.length)
                    continue;
                // Check if any constraint in this cell is severed — skip partially torn cells
                let allIntact = true;
                for (let ci = obj.constraintStart; ci < obj.constraintStart + obj.constraintCount && ci < constraints.length; ci++) {
                    const c = constraints[ci];
                    if (c.severed) {
                        // Check if this constraint is part of the current cell
                        const pts = new Set([i00, i10, i01, i11]);
                        if ((pts.has(c.a) && pts.has(c.b))) {
                            allIntact = false;
                            break;
                        }
                    }
                }
                if (!allIntact)
                    continue;
                const p00 = points[i00];
                const p10 = points[i10];
                const p01 = points[i01];
                const p11 = points[i11];
                // Two triangles per cell
                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(color, 0.25);
                ctx.fill();
                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(color, 0.25);
                ctx.fill();
                // Grid lines
                ctx.strokeStyle = this.alphaColor(color, 0.6);
                ctx.lineWidth = 0.8;
                ctx.beginPath();
                ctx.moveTo(p00.x, p00.y);
                ctx.lineTo(p10.x, p10.y);
                ctx.stroke();
                ctx.beginPath();
                ctx.moveTo(p00.x, p00.y);
                ctx.lineTo(p01.x, p01.y);
                ctx.stroke();
            }
        }
    }
    renderSoftBody(points, constraints, obj, color) {
        const ctx = this.ctx;
        const perimeterCount = obj.pointCount - 1; // minus center point
        if (perimeterCount < 3)
            return;
        // Draw filled polygon from perimeter points
        ctx.beginPath();
        for (let i = 0; i < perimeterCount; i++) {
            const p = points[obj.pointStart + i];
            if (i === 0)
                ctx.moveTo(p.x, p.y);
            else
                ctx.lineTo(p.x, p.y);
        }
        ctx.closePath();
        // Gradient fill for depth effect
        const centerP = points[obj.pointStart + perimeterCount];
        const grad = ctx.createRadialGradient(centerP.x - 10, centerP.y - 10, 0, centerP.x, centerP.y, config_1.CONFIG.softBodyRadius * 1.2);
        grad.addColorStop(0, this.alphaColor(color, 0.5));
        grad.addColorStop(1, this.alphaColor(color, 0.2));
        ctx.fillStyle = grad;
        ctx.fill();
        // Edge outline
        ctx.strokeStyle = color;
        ctx.lineWidth = 2;
        ctx.stroke();
        // Draw internal constraints as faint lines
        for (let i = obj.constraintStart; i < obj.constraintStart + obj.constraintCount; i++) {
            const c = constraints[i];
            if (c.severed || i >= constraints.length)
                continue;
            const pa = points[c.a];
            const pb = points[c.b];
            let lineColor = this.alphaColor(color, 0.15);
            if (this._showStress && c.restLength > 0) {
                const dx = pb.x - pa.x;
                const dy = pb.y - pa.y;
                const currentLen = Math.sqrt(dx * dx + dy * dy);
                lineColor = this.alphaColor(this.stressColor(currentLen / c.restLength), 0.4);
            }
            ctx.beginPath();
            ctx.moveTo(pa.x, pa.y);
            ctx.lineTo(pb.x, pb.y);
            ctx.strokeStyle = lineColor;
            ctx.lineWidth = 1;
            ctx.stroke();
        }
    }
    renderRope(points, constraints, obj, color) {
        // Rope is rendered as lines in the main loop — nothing extra needed here
        // But we can add a thicker rope effect
        const ctx = this.ctx;
        for (let i = obj.constraintStart; i < obj.constraintStart + obj.constraintCount; i++) {
            const c = constraints[i];
            if (c.severed || i >= constraints.length)
                continue;
            let lineColor = color;
            if (this._showStress && c.restLength > 0) {
                const pa = points[c.a];
                const pb = points[c.b];
                const dx = pb.x - pa.x;
                const dy = pb.y - pa.y;
                const currentLen = Math.sqrt(dx * dx + dy * dy);
                lineColor = this.stressColor(currentLen / c.restLength);
            }
            // Thick rope shadow
            ctx.beginPath();
            ctx.moveTo(points[c.a].x, points[c.a].y);
            ctx.lineTo(points[c.b].x, points[c.b].y);
            ctx.strokeStyle = lineColor;
            ctx.lineWidth = 4;
            ctx.globalAlpha = 0.3;
            ctx.stroke();
            ctx.globalAlpha = 1;
            // Main rope line
            ctx.beginPath();
            ctx.moveTo(points[c.a].x, points[c.a].y);
            ctx.lineTo(points[c.b].x, points[c.b].y);
            ctx.strokeStyle = lineColor;
            ctx.lineWidth = 2.5;
            ctx.stroke();
        }
    }
    renderBall(p, color) {
        const ctx = this.ctx;
        const r = p.radius || config_1.CONFIG.ballRadius;
        // Shadow
        ctx.beginPath();
        ctx.arc(p.x + 2, p.y + 2, r, 0, Math.PI * 2);
        ctx.fillStyle = 'rgba(0,0,0,0.3)';
        ctx.fill();
        // Ball with gradient
        const grad = ctx.createRadialGradient(p.x - r * 0.3, p.y - r * 0.3, 0, p.x, p.y, r);
        grad.addColorStop(0, this.alphaColor(color, 1));
        grad.addColorStop(1, color);
        ctx.beginPath();
        ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
        ctx.fillStyle = grad;
        ctx.fill();
        // Highlight
        ctx.beginPath();
        ctx.arc(p.x - r * 0.25, p.y - r * 0.25, r * 0.3, 0, Math.PI * 2);
        ctx.fillStyle = 'rgba(255,255,255,0.4)';
        ctx.fill();
        // Outline
        ctx.beginPath();
        ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
        ctx.strokeStyle = this.alphaColor(color, 0.8);
        ctx.lineWidth = 1.5;
        ctx.stroke();
    }
    renderAllPoints(points, objects) {
        const ctx = this.ctx;
        for (let i = 0; i < points.length; i++) {
            const p = points[i];
            const obj = (0, objects_1.getObjectForPoint)(objects, i);
            const color = obj ? (0, objects_1.getObjectColor)(obj.type) : '#fff';
            ctx.beginPath();
            ctx.arc(p.x, p.y, config_1.CONFIG.pointRadius, 0, Math.PI * 2);
            ctx.fillStyle = color;
            ctx.fill();
        }
    }
    stressColor(stretch) {
        // stretch < 1 → green (compressed), ~1 → yellow, > 1.1 → red
        const t = Math.max(0, Math.min(1, (stretch - 0.9) / 0.2));
        const r = Math.round(63 + (248 - 63) * t);
        const g = Math.round(185 + (81 - 185) * t);
        const b = Math.round(80 + (73 - 80) * t);
        return `rgb(${r},${g},${b})`;
    }
    alphaColor(hex, alpha) {
        // Parse hex color and add alpha
        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})`;
    }
    // ─── HUD: FPS counter and physics time readout ────────────────────
    renderHUD(fps, stepTimeMs, paused) {
        const ctx = this.ctx;
        const w = this.canvas.width;
        ctx.font = '12px monospace';
        ctx.fillStyle = 'rgba(255,255,255,0.6)';
        ctx.textAlign = 'left';
        let y = 20;
        ctx.fillText(`FPS: ${fps}`, 10, y);
        y += 18;
        ctx.fillText(`Physics step: ${stepTimeMs.toFixed(2)}ms`, 10, y);
        y += 18;
        if (paused) {
            ctx.fillStyle = 'rgba(255,200,50,0.8)';
            ctx.fillText('⏸ PAUSED', 10, y);
        }
        // Bottom-right info
        ctx.textAlign = 'right';
        ctx.fillStyle = 'rgba(255,255,255,0.4)';
        ctx.fillText('LMB: grab | RMB/Shift+drag: tear | Ctrl+click: pin', w - 10, this.canvas.height - 10);
    }
}
exports.Renderer = Renderer;
};

// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"typescript","sessionId":"9f52c601-0b81-44a2-b58c-0000a12b45c6","tokensIn":1733637,"tokensOut":28324,"tokensTotal":1761961,"turns":57,"toolCalls":56,"failedToolCalls":1,"timestamp":"2026-07-31T00:58:24.337Z"} -->