Physics Playground
Qwen 3.6 27b MTP · typescript

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"} -->