Physics Playground
Qwen 3.6 27b · 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 66,466 bytes · SHA-256 5b6a36162a2d
<!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: #1a1a2e;
font-family: 'Segoe UI', system-ui, sans-serif;
}
#app {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
#toolbar {
flex-shrink: 0;
z-index: 10;
}
canvas {
display: block;
cursor: crosshair;
}
canvas:active {
cursor: grabbing;
}
</style>
</head>
<body>
<div id="app"></div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./constants":"src/constants.ts","./types":"src/types.ts","./physics":"src/physics.ts","./spawner":"src/spawner.ts","./input":"src/input.ts","./renderer":"src/renderer.ts","./ui":"src/ui.ts"},"src/physics.ts":{"./constants":"src/constants.ts","./types":"src/types.ts"},"src/spawner.ts":{"./constants":"src/constants.ts","./types":"src/types.ts"},"src/input.ts":{"./constants":"src/constants.ts","./types":"src/types.ts","./physics":"src/physics.ts"},"src/renderer.ts":{"./constants":"src/constants.ts","./types":"src/types.ts","./physics":"src/physics.ts","./spawner":"src/spawner.ts"},"src/ui.ts":{"./constants":"src/constants.ts","./types":"src/types.ts"}};
function __require(id) {
if (__cache[id]) return __cache[id].exports;
var module = __cache[id] = { exports: {} };
var factory = __mods[id];
if (!factory) throw new Error("Module not found: " + id);
factory(module.exports, function (spec) {
var target = (__map[id] && __map[id][spec]) || spec;
return __require(target);
}, module);
return module.exports;
}
// ── module: src/main.ts ──
__mods["src/main.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.start = start;
const constants_1 = require("./constants");
const physics_1 = require("./physics");
const spawner_1 = require("./spawner");
const input_1 = require("./input");
const renderer_1 = require("./renderer");
const ui_1 = require("./ui");
// ===== Simulation State =====
let points = [];
let constraints = [];
let objects = [];
let staticShapes = [];
let nextPointId = 0;
let nextObjId = 0;
// Physics accumulator
let accumulator = 0;
let lastTime = 0;
let paused = false;
let stepOnce = false;
let currentGravity = constants_1.PHYSICS.gravity;
let currentIterations = constants_1.PHYSICS.iterations;
let showStress = false;
let clothPinPattern = 'corners';
let windForce = 0;
// Rendering
let canvas;
let renderer;
let inputHandler;
let toolbar;
// FPS tracking
let fps = 0;
let frameCount = 0;
let fpsTime = 0;
let physicsTime = 0;
// Current spawn tool
let currentTool = 'select';
// Spawn tracking
let pendingSpawn = false;
let spawnStartX = 0;
let spawnStartY = 0;
// Expose sim on window for probing
const sim = {
get points() { return points; },
get constraints() { return constraints; },
get objects() { return objects; },
get staticShapes() { return staticShapes; },
step: () => {
physicsStep(constants_1.PHYSICS.timestep);
},
get kineticEnergy() {
return (0, physics_1.kineticEnergy)(points, constants_1.PHYSICS.timestep);
},
get gravity() { return currentGravity; },
set gravity(v) { currentGravity = v; },
get iterations() { return currentIterations; },
set iterations(v) { currentIterations = v; },
get paused() { return paused; },
set paused(v) { paused = v; },
};
// ===== Initialization =====
function init() {
// Setup DOM
const app = document.getElementById('app');
app.style.cssText = 'display:flex;flex-direction:column;height:100vh;overflow:hidden;background:' + constants_1.COLORS.bg;
// Toolbar container
const toolbarDiv = document.createElement('div');
toolbarDiv.id = 'toolbar';
app.appendChild(toolbarDiv);
// Canvas container
const canvasDiv = document.createElement('div');
canvasDiv.style.cssText = 'flex:1;position:relative;overflow:hidden';
app.appendChild(canvasDiv);
// Canvas
canvas = document.createElement('canvas');
canvas.style.cssText = 'display:block;width:100%;height:100%';
canvasDiv.appendChild(canvas);
// Renderer
renderer = new renderer_1.Renderer(canvas);
// Resize
const resize = () => {
const rect = canvasDiv.getBoundingClientRect();
renderer.resize(rect.width, rect.height);
};
window.addEventListener('resize', resize);
resize();
// Input
inputHandler = new input_1.InputHandler(canvas);
// Toolbar
toolbar = new ui_1.Toolbar(toolbarDiv);
toolbar.setOnToolChange((tool) => { currentTool = tool; });
toolbar.setOnReset(() => resetSim());
toolbar.setOnGravityChange((g) => { currentGravity = g; });
toolbar.setOnIterChange((n) => { currentIterations = n; });
toolbar.setOnPinPatternChange((p) => { clothPinPattern = p; });
toolbar.setOnStressToggle(() => { showStress = !showStress; });
toolbar.setOnWindChange((w) => { windForce = w; });
toolbar.setOnPointsToggle(() => { constants_1.RENDER.showPoints = !constants_1.RENDER.showPoints; });
// Init static shapes
initStaticShapes();
// Spawn demo objects
spawnDemoObjects();
// Start loop
lastTime = performance.now();
requestAnimationFrame(gameLoop);
}
function initStaticShapes() {
const w = canvas.width;
const h = canvas.height;
// A big circle obstacle in the center
staticShapes.push({ x: w * 0.5, y: h * 0.55, radius: 50 });
// A box obstacle on the left
staticShapes.push({ x: w * 0.2, y: h * 0.7, w: 100, h: 20 });
// Another box on the right
staticShapes.push({ x: w * 0.72, y: h * 0.65, w: 80, h: 15 });
}
function spawnDemoObjects() {
const w = canvas.width;
const h = canvas.height;
// Spawn a rope (pendulum)
{
const r = (0, spawner_1.spawnRope)(points, constraints, objects, w * 0.15, 30, w * 0.15, h * 0.45, 20, nextPointId, nextObjId);
nextPointId = r.nextPointId;
nextObjId = r.nextObjId;
}
// Spawn a cloth
{
const r = (0, spawner_1.spawnCloth)(points, constraints, objects, w * 0.42, 30, 15, 12, 12, clothPinPattern, nextPointId, nextObjId);
nextPointId = r.nextPointId;
nextObjId = r.nextObjId;
}
// Spawn a soft body
{
const r = (0, spawner_1.spawnSoftBody)(points, constraints, objects, w * 0.82, h * 0.25, 40, 8, 3, nextPointId, nextObjId);
nextPointId = r.nextPointId;
nextObjId = r.nextObjId;
}
// Spawn a ball
{
const r = (0, spawner_1.spawnBall)(points, constraints, objects, w * 0.5, h * 0.12, 15, nextPointId, nextObjId);
nextPointId = r.nextPointId;
nextObjId = r.nextObjId;
}
}
function resetSim() {
points = [];
constraints = [];
objects = [];
nextPointId = 0;
nextObjId = 0;
accumulator = 0;
staticShapes = [];
initStaticShapes();
spawnDemoObjects();
}
// ===== Physics Step =====
function physicsStep(dt) {
// Apply wind force
if (windForce !== 0) {
for (let i = 0; i < points.length; i++) {
const p = points[i];
if (p.pinned)
continue;
p.x += windForce * dt * dt;
}
}
// Integrate
(0, physics_1.integrate)(points, dt, currentGravity, constants_1.PHYSICS.damping);
// Solve constraints
(0, physics_1.solveConstraints)(points, constraints, currentIterations);
// Collide with static shapes
(0, physics_1.collideStatic)(points, staticShapes);
// Collide with bounds
(0, physics_1.collideBounds)(points, canvas.width, canvas.height);
// NaN guard - reset any NaN points
if ((0, physics_1.checkNaN)(points)) {
for (let i = 0; i < points.length; i++) {
const p = points[i];
if (!isFinite(p.x) || !isFinite(p.y)) {
p.x = p.pinned ? p.x : canvas.width / 2;
p.y = p.pinned ? p.y : 100;
p.prevX = p.x;
p.prevY = p.y;
}
if (!isFinite(p.prevX) || !isFinite(p.prevY)) {
p.prevX = p.x;
p.prevY = p.y;
}
}
}
}
// ===== Game Loop =====
function gameLoop(timestamp) {
// FPS tracking
frameCount++;
fpsTime += timestamp - (lastTime || timestamp);
if (fpsTime >= 1000) {
fps = frameCount;
frameCount = 0;
fpsTime = 0;
}
// Delta time with clamp
let dt = (timestamp - lastTime) / 1000;
lastTime = timestamp;
dt = Math.min(dt, constants_1.PHYSICS.maxDt); // clamp to prevent explosion on tab switch
// Handle pause/step
if (paused && !stepOnce) {
render();
requestAnimationFrame(gameLoop);
return;
}
if (stepOnce) {
physicsStep(constants_1.PHYSICS.timestep);
stepOnce = false;
render();
requestAnimationFrame(gameLoop);
return;
}
// Fixed timestep accumulator
accumulator += dt;
let steps = 0;
const maxSteps = 10; // prevent spiral of death
const stepStart = performance.now();
while (accumulator >= constants_1.PHYSICS.timestep && steps < maxSteps) {
physicsStep(constants_1.PHYSICS.timestep);
accumulator -= constants_1.PHYSICS.timestep;
steps++;
}
// Drain excess accumulator to prevent spiral of death
if (accumulator > constants_1.PHYSICS.timestep * 5) {
accumulator = 0;
}
physicsTime = performance.now() - stepStart;
// Process input
const inputResult = inputHandler.process(points, constraints, canvas.width, canvas.height);
// Handle grab release (throw)
if (inputResult.releaseGrab && inputResult.grabPoint !== null) {
inputHandler.applyThrow(points, inputResult.grabPoint);
}
// Handle tear
for (const ci of inputResult.tearConstraints) {
constraints[ci].active = false;
}
// Handle spawn on release
if (currentTool !== 'select' && !inputHandler.state.mouseDown && pendingSpawn) {
const dragLine = inputHandler.getDragLine();
if (dragLine) {
spawnObject(dragLine.startX, dragLine.startY, dragLine.endX, dragLine.endY);
}
pendingSpawn = false;
}
// Track pending spawn
if (currentTool !== 'select' && inputHandler.state.mouseDown) {
if (!pendingSpawn) {
pendingSpawn = true;
spawnStartX = inputHandler.state.mouseX;
spawnStartY = inputHandler.state.mouseY;
}
}
// Render
render();
requestAnimationFrame(gameLoop);
}
function spawnObject(startX, startY, endX, endY) {
const w = canvas.width;
const h = canvas.height;
switch (currentTool) {
case 'rope': {
const dx = endX - startX;
const dy = endY - startY;
const len = Math.sqrt(dx * dx + dy * dy);
const segCount = Math.max(5, Math.floor(len / 15));
const result = (0, spawner_1.spawnRope)(points, constraints, objects, startX, startY, endX, endY, segCount, nextPointId, nextObjId);
nextPointId = result.nextPointId;
nextObjId = result.nextObjId;
break;
}
case 'cloth': {
const dx = Math.abs(endX - startX);
const dy = Math.abs(endY - startY);
const spacing = 12;
const cols = Math.max(4, Math.floor(dx / spacing));
const rows = Math.max(3, Math.floor(dy / spacing));
const result = (0, spawner_1.spawnCloth)(points, constraints, objects, Math.min(startX, endX), Math.min(startY, endY), cols, rows, spacing, clothPinPattern, nextPointId, nextObjId);
nextPointId = result.nextPointId;
nextObjId = result.nextObjId;
break;
}
case 'softbody': {
const radius = Math.max(20, Math.sqrt((endX - startX) ** 2 + (endY - startY) ** 2) / 2);
const result = (0, spawner_1.spawnSoftBody)(points, constraints, objects, startX, startY, radius, 8, 3, nextPointId, nextObjId);
nextPointId = result.nextPointId;
nextObjId = result.nextObjId;
break;
}
case 'ball': {
const result = (0, spawner_1.spawnBall)(points, constraints, objects, startX, startY, 15, nextPointId, nextObjId);
nextPointId = result.nextPointId;
nextObjId = result.nextObjId;
break;
}
}
}
function render() {
// Motion blur
if (constants_1.RENDER.motionBlur) {
renderer.clear(0.15);
}
else {
renderer.clear(1);
}
renderer.drawGrid();
renderer.drawBounds();
renderer.drawStaticShapes(staticShapes);
renderer.drawObjects(objects, points, constraints, showStress);
renderer.drawGrabHighlight(points, inputHandler.state.grabPoint);
// Draw tear highlight
if (inputHandler.state.rightDown || (inputHandler.state.mouseDown && inputHandler.state.modifier)) {
renderer.drawTearHighlight(inputHandler.state.mouseX, inputHandler.state.mouseY, true);
}
// Draw spawn preview
if (currentTool !== 'select' && inputHandler.state.mouseDown && pendingSpawn) {
const dragLine = inputHandler.getDragLine();
if (dragLine) {
const colorMap = {
rope: constants_1.COLORS.rope,
cloth: constants_1.COLORS.cloth,
softbody: constants_1.COLORS.softBody,
ball: constants_1.COLORS.ball,
};
renderer.drawSpawnLine(dragLine.startX, dragLine.startY, dragLine.endX, dragLine.endY, colorMap[currentTool] || '#fff');
}
}
renderer.drawHUD(fps, physicsTime, points.length, constraints.filter(c => c.active).length, paused);
renderer.drawInstructions();
}
// ===== Expose on window =====
window.sim = sim;
// ===== Start =====
start();
function start() {
init();
}
};
// ── module: src/constants.ts ──
__mods["src/constants.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CANVAS = exports.RENDER = exports.COLORS = exports.PHYSICS = void 0;
// Tunable physics constants
exports.PHYSICS = {
gravity: 980, // pixels/s²
damping: 0.99, // velocity damping per step
iterations: 6, // constraint relaxation iterations
timestep: 1 / 60, // fixed physics timestep (seconds)
maxDt: 0.05, // max frame delta before clamping (anti-explode)
minDt: 1 / 120, // minimum timestep
tearRadius: 30, // radius for tear/cut detection
grabRadius: 25, // radius for grabbing points
collisionPadding: 2, // extra padding for collision response
energyDamp: 0.95, // energy damping for resting stability
maxVelocity: 5000, // velocity clamp to prevent explosion
};
exports.COLORS = {
bg: '#1a1a2e',
grid: '#2a2a4a',
wall: '#4a4a6a',
rope: '#e74c3c',
cloth: '#3498db',
softBody: '#2ecc71',
ball: '#f39c12',
pin: '#e67e22',
grab: '#9b59b6',
tear: '#ff0044',
stressGreen: '#00ff00',
stressRed: '#ff0000',
text: '#cccccc',
toolbar: '#2c2c54',
toolbarHover: '#3d3d6b',
toolbarActive: '#4e4e8a',
};
exports.RENDER = {
pointRadius: 2,
clothPointSize: 0, // 0 = hidden for cloth
showPoints: false,
showStress: false,
trailAlpha: 0.1,
motionBlur: true,
};
exports.CANVAS = {
toolbarHeight: 44,
hudHeight: 20,
};
};
// ── module: src/types.ts ──
__mods["src/types.ts"] = function (exports, require, module) {
"use strict";
// Core physics types
Object.defineProperty(exports, "__esModule", { value: true });
};
// ── module: src/physics.ts ──
__mods["src/physics.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.integrate = integrate;
exports.solveConstraints = solveConstraints;
exports.collideStatic = collideStatic;
exports.collideBounds = collideBounds;
exports.checkNaN = checkNaN;
exports.kineticEnergy = kineticEnergy;
exports.findNearestPoint = findNearestPoint;
exports.findNearbyConstraints = findNearbyConstraints;
exports.constraintStress = constraintStress;
const constants_1 = require("./constants");
// Guard against NaN/Infinity
function safe(v) {
if (!isFinite(v))
return 0;
return v;
}
function clamp(v, lo, hi) {
return v < lo ? lo : v > hi ? hi : v;
}
// Verlet integration step
function integrate(points, dt, gravity, damping) {
const dtSq = dt * dt;
const maxVel = constants_1.PHYSICS.maxVelocity;
for (let i = 0; i < points.length; i++) {
const p = points[i];
if (p.pinned)
continue;
// Verlet: x' = x + (x - xPrev) * damping + a * dt²
const vx = (p.x - p.prevX) * damping;
const vy = (p.y - p.prevY) * damping;
// Clamp velocity to prevent explosion
const speed = Math.sqrt(vx * vx + vy * vy);
let cx = vx, cy = vy;
if (speed > maxVel) {
const scale = maxVel / speed;
cx *= scale;
cy *= scale;
}
p.prevX = p.x;
p.prevY = p.y;
p.x += cx;
p.y += cy + gravity * dtSq;
}
}
// Distance constraint solver
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.active)
continue;
const p1 = points[c.p1];
const p2 = points[c.p2];
if (!p1 || !p2)
continue;
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const dist = Math.sqrt(dx * dx + dy * dy);
// Guard against zero-length
if (dist < 0.0001)
continue;
const diff = (c.restLength - dist) / dist;
// Apply correction weighted by stiffness and pin status
const correction = diff * c.stiffness * 0.5;
const ox = dx * correction;
const oy = dy * correction;
if (!p1.pinned) {
p1.x -= safe(ox);
p1.y -= safe(oy);
}
if (!p2.pinned) {
p2.x += safe(ox);
p2.y += safe(oy);
}
}
}
}
// Collision with static shapes
function collideStatic(points, shapes) {
const pad = constants_1.PHYSICS.collisionPadding;
const friction = 0.9;
for (let i = 0; i < points.length; i++) {
const p = points[i];
if (p.pinned)
continue;
for (let j = 0; j < shapes.length; j++) {
const shape = shapes[j];
if ('radius' in shape && 'w' in shape) {
// Static circle
const c = shape;
const dx = p.x - c.x;
const dy = p.y - c.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const minDist = c.radius + p.radius + pad;
if (dist < minDist && dist > 0.001) {
const nx = dx / dist;
const ny = dy / dist;
const overlap = minDist - dist;
p.x += nx * overlap;
p.y += ny * overlap;
// Friction: reduce tangential velocity
const vx = p.x - p.prevX;
const vy = p.y - p.prevY;
const normalVel = vx * nx + vy * ny;
if (normalVel < 0) {
p.prevX += nx * normalVel * (1 - 0.5); // bounce
p.prevY += ny * normalVel * (1 - 0.5);
// Friction on tangential component
const tx = -ny;
const ty = nx;
const tanVel = vx * tx + vy * ty;
p.prevX += tx * tanVel * (1 - friction);
p.prevY += ty * tanVel * (1 - friction);
}
}
}
else {
// Static box (AABB)
const b = shape;
const cx = b.x + b.w / 2;
const cy = b.y + b.h / 2;
const hw = b.w / 2 + p.radius + pad;
const hh = b.h / 2 + p.radius + pad;
const dx = clamp(p.x, b.x, b.x + b.w);
const dy = clamp(p.y, b.y, b.y + b.h);
const distX = p.x - dx;
const distY = p.y - dy;
const dist = Math.sqrt(distX * distX + distY * distY);
if (dist < p.radius + pad && dist > 0.001) {
const overlap = (p.radius + pad) - dist;
const nx = distX / dist;
const ny = distY / dist;
p.x += nx * overlap;
p.y += ny * overlap;
const vx = p.x - p.prevX;
const vy = p.y - p.prevY;
const normalVel = vx * nx + vy * ny;
if (normalVel < 0) {
p.prevX += nx * normalVel * (1 - 0.5);
p.prevY += ny * normalVel * (1 - 0.5);
}
}
}
}
}
}
// Boundary collision (walls and floor)
function collideBounds(points, width, height) {
const pad = constants_1.PHYSICS.collisionPadding;
const friction = 0.9;
const bounce = 0.5;
for (let i = 0; i < points.length; i++) {
const p = points[i];
if (p.pinned)
continue;
// Floor
if (p.y > height - pad - p.radius) {
p.y = height - pad - p.radius;
const vx = p.x - p.prevX;
const vy = p.y - p.prevY;
// Dampen vertical velocity (bounce)
p.prevY = p.y + vy * bounce;
// Friction on horizontal
p.prevX = p.x - vx * friction;
}
// Ceiling
if (p.y < pad + p.radius) {
p.y = pad + p.radius;
p.prevY = p.y + (p.y - p.prevY) * bounce;
}
// Left wall
if (p.x < pad + p.radius) {
p.x = pad + p.radius;
p.prevX = p.x + (p.x - p.prevX) * bounce;
const vy = p.y - p.prevY;
p.prevY = p.y - vy * friction;
}
// Right wall
if (p.x > width - pad - p.radius) {
p.x = width - pad - p.radius;
p.prevX = p.x + (p.x - p.prevX) * bounce;
const vy = p.y - p.prevY;
p.prevY = p.y - vy * friction;
}
}
}
// Check for NaN in all points
function checkNaN(points) {
for (let i = 0; i < points.length; i++) {
const p = points[i];
if (!isFinite(p.x) || !isFinite(p.y) || !isFinite(p.prevX) || !isFinite(p.prevY)) {
return true;
}
}
return false;
}
// Compute total kinetic energy
function kineticEnergy(points, dt) {
let energy = 0;
for (let i = 0; i < points.length; i++) {
const p = points[i];
if (p.pinned)
continue;
const vx = (p.x - p.prevX) / dt;
const vy = (p.y - p.prevY) / dt;
energy += 0.5 * p.mass * (vx * vx + vy * vy);
}
return energy;
}
// Find nearest point to position within radius
function findNearestPoint(points, x, y, radius) {
let minDist = radius;
let closest = -1;
for (let i = 0; i < points.length; i++) {
const p = points[i];
const dx = p.x - x;
const dy = p.y - y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < minDist) {
minDist = dist;
closest = i;
}
}
return closest >= 0 ? closest : null;
}
// Find constraints near a position (for tearing)
function findNearbyConstraints(constraints, points, x, y, radius) {
const torn = [];
for (let i = 0; i < constraints.length; i++) {
const c = constraints[i];
if (!c.active)
continue;
const p1 = points[c.p1];
const p2 = points[c.p2];
if (!p1 || !p2)
continue;
// Distance from point to line segment
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const lenSq = dx * dx + dy * dy;
if (lenSq < 0.0001)
continue;
// Project point onto line segment
let t = ((x - p1.x) * dx + (y - p1.y) * dy) / lenSq;
t = clamp(t, 0, 1);
const projX = p1.x + t * dx;
const projY = p1.y + t * dy;
const dist = Math.sqrt((x - projX) ** 2 + (y - projY) ** 2);
if (dist < radius) {
torn.push(i);
}
}
return torn;
}
// Compute stress ratio for a constraint
function constraintStress(constraint, points) {
const p1 = points[constraint.p1];
const p2 = points[constraint.p2];
if (!p1 || !p2)
return 0;
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (constraint.restLength < 0.0001)
return 0;
return dist / constraint.restLength;
}
};
// ── module: src/spawner.ts ──
__mods["src/spawner.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.spawnRope = spawnRope;
exports.spawnCloth = spawnCloth;
exports.spawnSoftBody = spawnSoftBody;
exports.spawnBall = spawnBall;
exports.computeConvexHull = computeConvexHull;
const constants_1 = require("./constants");
// Spawner: creates ropes, cloth, soft bodies, balls
// All coordinates are absolute canvas coordinates
function spawnRope(points, constraints, objects, startX, startY, endX, endY, segCount, nextPointId, nextObjId) {
const dx = endX - startX;
const dy = endY - startY;
const len = Math.sqrt(dx * dx + dy * dy);
const segLen = len / segCount;
const pointStart = points.length;
const objId = nextObjId++;
for (let i = 0; i <= segCount; i++) {
const t = i / segCount;
const px = startX + dx * t;
const py = startY + dy * t;
points.push({
x: px, y: py,
prevX: px, prevY: py,
pinned: i === 0,
mass: 1,
radius: 3,
id: nextPointId++,
objId,
});
}
const constraintStart = constraints.length;
for (let i = 0; i < segCount; i++) {
constraints.push({
p1: pointStart + i,
p2: pointStart + i + 1,
restLength: segLen,
stiffness: 1,
active: true,
objId,
});
}
objects.push({
id: objId,
type: 'rope',
color: constants_1.COLORS.rope,
pointStart,
pointCount: segCount + 1,
constraintStart,
constraintCount: segCount,
});
return { nextPointId, nextObjId };
}
function spawnCloth(points, constraints, objects, x, y, cols, rows, spacing, pinPattern, nextPointId, nextObjId) {
const objId = nextObjId++;
const pointStart = points.length;
// Create grid of points
const grid = [];
for (let r = 0; r < rows; r++) {
grid[r] = [];
for (let c = 0; c < cols; c++) {
const px = x + c * spacing;
const py = y + r * spacing;
const pinned = pinPattern === 'fullEdge'
? r === 0
: (r === 0 && (c === 0 || c === cols - 1));
grid[r][c] = points.length;
points.push({
x: px, y: py,
prevX: px, prevY: py,
pinned,
mass: 1,
radius: 2,
id: nextPointId++,
objId,
});
}
}
const constraintStart = constraints.length;
// Structural constraints (horizontal + vertical)
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
// Right neighbor
if (c < cols - 1) {
constraints.push({
p1: grid[r][c],
p2: grid[r][c + 1],
restLength: spacing,
stiffness: 1,
active: true,
objId,
});
}
// Bottom neighbor
if (r < rows - 1) {
constraints.push({
p1: grid[r][c],
p2: grid[r + 1][c],
restLength: spacing,
stiffness: 1,
active: true,
objId,
});
}
}
}
// Shear constraints (diagonal)
for (let r = 0; r < rows - 1; r++) {
for (let c = 0; c < cols - 1; c++) {
const diagLen = spacing * Math.sqrt(2);
constraints.push({
p1: grid[r][c],
p2: grid[r + 1][c + 1],
restLength: diagLen,
stiffness: 0.5,
active: true,
objId,
});
constraints.push({
p1: grid[r][c + 1],
p2: grid[r + 1][c],
restLength: diagLen,
stiffness: 0.5,
active: true,
objId,
});
}
}
objects.push({
id: objId,
type: 'cloth',
color: constants_1.COLORS.cloth,
pointStart,
pointCount: rows * cols,
constraintStart,
constraintCount: constraints.length - constraintStart,
});
return { nextPointId, nextObjId };
}
function spawnSoftBody(points, constraints, objects, cx, cy, radius, vertexCount, layers, nextPointId, nextObjId) {
const objId = nextObjId++;
const pointStart = points.length;
// Create concentric rings of points
const rings = [];
for (let layer = 0; layer < layers; layer++) {
const r = layer === 0 ? 0 : radius * (layer + 1) / layers;
const verts = layer === 0 ? 1 : vertexCount;
rings[layer] = [];
for (let v = 0; v < verts; v++) {
const angle = (v / verts) * Math.PI * 2 + (layer * Math.PI / vertexCount);
const px = cx + Math.cos(angle) * r;
const py = cy + Math.sin(angle) * r;
rings[layer][v] = points.length;
points.push({
x: px, y: py,
prevX: px, prevY: py,
pinned: false,
mass: 1,
radius: 3,
id: nextPointId++,
objId,
});
}
}
const constraintStart = constraints.length;
// Edge constraints (ring connections)
for (let layer = 1; layer < layers; layer++) {
for (let v = 0; v < vertexCount; v++) {
const next = (v + 1) % vertexCount;
const p1 = rings[layer][v];
const p2 = rings[layer][next];
const a1 = (v / vertexCount) * Math.PI * 2 + (layer * Math.PI / vertexCount);
const a2 = (next / vertexCount) * Math.PI * 2 + (layer * Math.PI / vertexCount);
const r = radius * layer / layers;
const chord = 2 * r * Math.sin(Math.PI / vertexCount);
constraints.push({
p1, p2,
restLength: chord,
stiffness: 1,
active: true,
objId,
});
}
}
// Radial constraints (between layers)
for (let layer = 1; layer < layers; layer++) {
for (let v = 0; v < vertexCount; v++) {
const inner = layer === 1 ? rings[0][0] : rings[layer - 1][v];
const outer = rings[layer][v];
const rInner = layer === 1 ? 0 : radius * (layer - 1) / layers;
const rOuter = radius * layer / layers;
const radialLen = rOuter - rInner;
constraints.push({
p1: inner,
p2: outer,
restLength: radialLen,
stiffness: 1,
active: true,
objId,
});
}
}
// Internal brace constraints (cross-bracing for shape stability)
for (let layer = 1; layer < layers - 1; layer++) {
for (let v = 0; v < vertexCount; v++) {
const next = (v + 1) % vertexCount;
constraints.push({
p1: rings[layer][v],
p2: rings[layer + 1][next],
restLength: radius / layers,
stiffness: 0.5,
active: true,
objId,
});
}
}
objects.push({
id: objId,
type: 'softbody',
color: constants_1.COLORS.softBody,
pointStart,
pointCount: points.length - pointStart,
constraintStart,
constraintCount: constraints.length - constraintStart,
});
return { nextPointId, nextObjId };
}
function spawnBall(points, constraints, objects, x, y, radius, nextPointId, nextObjId) {
const objId = nextObjId++;
points.push({
x, y,
prevX: x, prevY: y,
pinned: false,
mass: 2,
radius,
id: nextPointId++,
objId,
});
objects.push({
id: objId,
type: 'ball',
color: constants_1.COLORS.ball,
pointStart: points.length - 1,
pointCount: 1,
constraintStart: constraints.length,
constraintCount: 0,
});
return { nextPointId, nextObjId };
}
// Compute convex hull for soft body rendering
function computeConvexHull(indices, points) {
if (indices.length < 3)
return indices;
// Simple convex hull (Graham scan)
const pts = indices.map(i => ({ x: points[i].x, y: points[i].y, idx: i }));
// Find bottom-most point
let bottom = 0;
for (let i = 1; i < pts.length; i++) {
if (pts[i].y > pts[bottom].y || (pts[i].y === pts[bottom].y && pts[i].x > pts[bottom].x)) {
bottom = i;
}
}
[pts[0], pts[bottom]] = [pts[bottom], pts[0]];
// Sort by polar angle
const pivot = pts[0];
pts.slice(1).sort((a, b) => {
const angleA = Math.atan2(a.y - pivot.y, a.x - pivot.x);
const angleB = Math.atan2(b.y - pivot.y, b.x - pivot.x);
return angleA - angleB;
});
const hull = [pts[0].idx, pts[1].idx];
for (let i = 2; i < pts.length; i++) {
while (hull.length > 1) {
const top = hull[hull.length - 1];
const second = hull[hull.length - 2];
const cross = (pts[i].x - points[second].x) * (points[top].y - points[second].y)
- (pts[i].y - points[second].y) * (points[top].x - points[second].x);
if (cross > 0)
break;
hull.pop();
}
hull.push(pts[i].idx);
}
return hull;
}
};
// ── module: src/input.ts ──
__mods["src/input.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.InputHandler = void 0;
const constants_1 = require("./constants");
const physics_1 = require("./physics");
class InputHandler {
constructor(canvas) {
this.state = {
mouseX: 0,
mouseY: 0,
mouseDown: false,
rightDown: false,
grabPoint: null,
tool: 'select',
paused: false,
stepOnce: false,
modifier: false,
};
this.prevGrabX = 0;
this.prevGrabY = 0;
this.grabVelX = 0;
this.grabVelY = 0;
this.dragStartX = 0;
this.dragStartY = 0;
this.isDragging = false;
this.canvas = canvas;
this.bindEvents();
}
getCanvasPos(e) {
const rect = this.canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
}
bindEvents() {
this.canvas.addEventListener('mousedown', (e) => this.onMouseDown(e));
this.canvas.addEventListener('mousemove', (e) => this.onMouseMove(e));
this.canvas.addEventListener('mouseup', (e) => this.onMouseUp(e));
this.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
// Keyboard
window.addEventListener('keydown', (e) => this.onKeyDown(e));
window.addEventListener('keyup', (e) => this.onKeyUp(e));
}
onMouseDown(e) {
const pos = this.getCanvasPos(e);
if (e.button === 0) {
this.state.mouseDown = true;
this.prevGrabX = pos.x;
this.prevGrabY = pos.y;
this.grabVelX = 0;
this.grabVelY = 0;
this.dragStartX = pos.x;
this.dragStartY = pos.y;
this.isDragging = false;
}
else if (e.button === 2) {
this.state.rightDown = true;
}
}
onMouseMove(e) {
const pos = this.getCanvasPos(e);
this.state.mouseX = pos.x;
this.state.mouseY = pos.y;
// Track grab velocity
if (this.state.grabPoint !== null) {
this.grabVelX = (pos.x - this.prevGrabX) / constants_1.PHYSICS.timestep;
this.grabVelY = (pos.y - this.prevGrabY) / constants_1.PHYSICS.timestep;
this.prevGrabX = pos.x;
this.prevGrabY = pos.y;
}
// Track if we're dragging (moved more than a few pixels)
if (this.state.mouseDown && !this.isDragging) {
const dx = pos.x - this.dragStartX;
const dy = pos.y - this.dragStartY;
if (Math.sqrt(dx * dx + dy * dy) > 5) {
this.isDragging = true;
}
}
}
onMouseUp(e) {
if (e.button === 0) {
this.state.mouseDown = false;
}
else if (e.button === 2) {
this.state.rightDown = false;
}
}
onKeyDown(e) {
if (e.code === 'Space') {
e.preventDefault();
this.state.paused = !this.state.paused;
}
if (e.code === 'ArrowRight' && this.state.paused) {
e.preventDefault();
this.state.stepOnce = true;
}
if (e.key === 'Shift' || e.key === 'Control') {
this.state.modifier = true;
}
if (e.key === 't' || e.key === 'T') {
this.state.tool = this.state.tool === 'tear' ? 'select' : 'tear';
}
}
onKeyUp(e) {
if (e.key === 'Shift' || e.key === 'Control') {
this.state.modifier = false;
}
}
// Process input each frame - returns actions to take
process(points, constraints, width, height) {
const result = {
grabPoint: this.state.grabPoint,
releaseGrab: false,
tearConstraints: [],
spawnRequest: null,
pinToggle: null,
};
// Handle tear mode (right-drag or modifier+left-drag)
if (this.state.rightDown || (this.state.mouseDown && this.state.modifier)) {
const torn = (0, physics_1.findNearbyConstraints)(constraints, points, this.state.mouseX, this.state.mouseY, constants_1.PHYSICS.tearRadius);
result.tearConstraints = torn;
return result;
}
// Handle grab
if (this.state.mouseDown) {
if (this.state.grabPoint === null && !this.isDragging) {
// Try to grab nearest point
const idx = (0, physics_1.findNearestPoint)(points, this.state.mouseX, this.state.mouseY, constants_1.PHYSICS.grabRadius);
if (idx !== null) {
this.state.grabPoint = idx;
this.prevGrabX = this.state.mouseX;
this.prevGrabY = this.state.mouseY;
result.grabPoint = idx;
}
}
// Move grabbed point
if (this.state.grabPoint !== null) {
const p = points[this.state.grabPoint];
p.x = this.state.mouseX;
p.y = this.state.mouseY;
// Update prev to match (so velocity is from drag speed)
p.prevX = this.state.mouseX - this.grabVelX * constants_1.PHYSICS.timestep;
p.prevY = this.state.mouseY - this.grabVelY * constants_1.PHYSICS.timestep;
}
}
else if (this.state.grabPoint !== null) {
// Released - throw with drag velocity
result.releaseGrab = true;
result.grabPoint = this.state.grabPoint;
this.state.grabPoint = null;
}
return result;
}
// Apply throw velocity when releasing a grab
applyThrow(points, grabPoint) {
const p = points[grabPoint];
if (!p)
return;
// Set prev position to create throw velocity
p.prevX = p.x - this.grabVelX * constants_1.PHYSICS.timestep;
p.prevY = p.y - this.grabVelY * constants_1.PHYSICS.timestep;
}
// Get spawn request when user clicks in spawn mode
getSpawnRequest() {
if (!this.state.mouseDown || this.isDragging)
return null;
// Only fire on click (no drag)
return { x: this.state.mouseX, y: this.state.mouseY };
}
getDragLine() {
if (!this.isDragging)
return null;
return {
startX: this.dragStartX,
startY: this.dragStartY,
endX: this.state.mouseX,
endY: this.state.mouseY,
};
}
getGrabVelX() { return this.grabVelX; }
getGrabVelY() { return this.grabVelY; }
}
exports.InputHandler = InputHandler;
};
// ── module: src/renderer.ts ──
__mods["src/renderer.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Renderer = void 0;
const constants_1 = require("./constants");
const physics_1 = require("./physics");
const spawner_1 = require("./spawner");
class Renderer {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.width = canvas.width;
this.height = canvas.height;
}
resize(width, height) {
this.width = width;
this.height = height;
this.canvas.width = width;
this.canvas.height = height;
}
clear(alpha) {
this.ctx.fillStyle = constants_1.COLORS.bg;
this.ctx.fillRect(0, 0, this.width, this.height);
}
drawGrid() {
this.ctx.strokeStyle = constants_1.COLORS.grid;
this.ctx.lineWidth = 0.5;
this.ctx.globalAlpha = 0.3;
const gridSize = 40;
for (let x = 0; x < this.width; x += gridSize) {
this.ctx.beginPath();
this.ctx.moveTo(x, 0);
this.ctx.lineTo(x, this.height);
this.ctx.stroke();
}
for (let y = 0; y < this.height; y += gridSize) {
this.ctx.beginPath();
this.ctx.moveTo(0, y);
this.ctx.lineTo(this.width, y);
this.ctx.stroke();
}
this.ctx.globalAlpha = 1;
}
drawBounds() {
this.ctx.strokeStyle = constants_1.COLORS.wall;
this.ctx.lineWidth = 3;
this.ctx.strokeRect(1, 1, this.width - 2, this.height - 2);
// Floor highlight
this.ctx.fillStyle = 'rgba(255,255,255,0.03)';
this.ctx.fillRect(0, this.height - 20, this.width, 20);
}
drawStaticShapes(shapes) {
for (const shape of shapes) {
if ('radius' in shape && 'w' in shape) {
const c = shape;
// Shadow
this.ctx.beginPath();
this.ctx.arc(c.x + 3, c.y + 3, c.radius, 0, Math.PI * 2);
this.ctx.fillStyle = 'rgba(0,0,0,0.3)';
this.ctx.fill();
// Shape
this.ctx.beginPath();
this.ctx.arc(c.x, c.y, c.radius, 0, Math.PI * 2);
this.ctx.fillStyle = 'rgba(100, 100, 140, 0.4)';
this.ctx.fill();
this.ctx.strokeStyle = constants_1.COLORS.wall;
this.ctx.lineWidth = 2;
this.ctx.stroke();
// Highlight
this.ctx.beginPath();
this.ctx.arc(c.x - c.radius * 0.2, c.y - c.radius * 0.2, c.radius * 0.3, 0, Math.PI * 2);
this.ctx.fillStyle = 'rgba(255,255,255,0.1)';
this.ctx.fill();
}
else {
const b = shape;
// Shadow
this.ctx.fillStyle = 'rgba(0,0,0,0.3)';
this.ctx.fillRect(b.x + 3, b.y + 3, b.w, b.h);
// Shape
this.ctx.fillStyle = 'rgba(100, 100, 140, 0.4)';
this.ctx.fillRect(b.x, b.y, b.w, b.h);
this.ctx.strokeStyle = constants_1.COLORS.wall;
this.ctx.lineWidth = 2;
this.ctx.strokeRect(b.x, b.y, b.w, b.h);
}
}
}
drawConstraint(c, points, color, showStress) {
const p1 = points[c.p1];
const p2 = points[c.p2];
if (!p1 || !p2 || !c.active)
return;
this.ctx.beginPath();
this.ctx.moveTo(p1.x, p1.y);
this.ctx.lineTo(p2.x, p2.y);
if (showStress) {
const stress = (0, physics_1.constraintStress)(c, points);
const t = Math.min(Math.max((stress - 0.9) / 0.3, 0), 1);
const r = Math.floor(t * 255);
const g = Math.floor(255 * (1 - t));
this.ctx.strokeStyle = `rgb(${r},${g},50)`;
}
else {
this.ctx.strokeStyle = color;
}
this.ctx.lineWidth = c.stiffness > 0.8 ? 1.5 : 1;
this.ctx.globalAlpha = 0.6;
this.ctx.stroke();
this.ctx.globalAlpha = 1;
}
drawPoint(p, color, radius) {
this.ctx.beginPath();
this.ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
this.ctx.fillStyle = color;
this.ctx.fill();
}
drawPin(p) {
const x = p.x, y = p.y;
// Cross
this.ctx.strokeStyle = constants_1.COLORS.pin;
this.ctx.lineWidth = 2;
this.ctx.beginPath();
this.ctx.moveTo(x - 6, y - 6);
this.ctx.lineTo(x + 6, y + 6);
this.ctx.moveTo(x + 6, y - 6);
this.ctx.lineTo(x - 6, y + 6);
this.ctx.stroke();
// Circle
this.ctx.beginPath();
this.ctx.arc(x, y, 4, 0, Math.PI * 2);
this.ctx.fillStyle = constants_1.COLORS.pin;
this.ctx.fill();
this.ctx.strokeStyle = '#fff';
this.ctx.lineWidth = 1;
this.ctx.stroke();
}
// Draw cloth as filled mesh with nice shading
drawCloth(obj, points, constraints, showStress) {
// We need to reconstruct the grid from the points
// Cloth points are stored in row-major order
// Find grid dimensions from constraint count
const pCount = obj.pointCount;
// Estimate cols and rows from structural constraints
// A grid of CxR has (C-1)*R + (R-1)*C structural constraints
// For simplicity, try common sizes
let cols = 0, rows = 0;
for (let c = 3; c <= 30; c++) {
for (let r = 3; r <= 30; r++) {
if (c * r === pCount) {
cols = c;
rows = r;
break;
}
}
if (cols > 0)
break;
}
if (cols > 0 && rows > 0) {
// Draw filled triangles for each cell
for (let r = 0; r < rows - 1; r++) {
for (let c = 0; c < cols - 1; c++) {
const i00 = obj.pointStart + r * cols + c;
const i10 = obj.pointStart + r * cols + c + 1;
const i01 = obj.pointStart + (r + 1) * cols + c;
const i11 = obj.pointStart + (r + 1) * cols + c + 1;
const p00 = points[i00];
const p10 = points[i10];
const p01 = points[i01];
const p11 = points[i11];
if (!p00 || !p10 || !p01 || !p11)
continue;
// Compute cell "height" for shading (higher = lighter)
const avgY = (p00.y + p10.y + p01.y + p11.y) / 4;
const shade = Math.max(0.15, Math.min(0.45, 0.45 - (avgY / this.height) * 0.2));
// Draw two triangles per cell
this.ctx.fillStyle = hexToRgba(obj.color, shade);
this.ctx.beginPath();
this.ctx.moveTo(p00.x, p00.y);
this.ctx.lineTo(p10.x, p10.y);
this.ctx.lineTo(p01.x, p01.y);
this.ctx.closePath();
this.ctx.fill();
this.ctx.beginPath();
this.ctx.moveTo(p10.x, p10.y);
this.ctx.lineTo(p11.x, p11.y);
this.ctx.lineTo(p01.x, p01.y);
this.ctx.closePath();
this.ctx.fill();
}
}
// Draw grid lines on top
for (let i = obj.constraintStart; i < obj.constraintStart + obj.constraintCount; i++) {
this.drawConstraint(constraints[i], points, obj.color, showStress);
}
}
else {
// Fallback: just draw constraints
for (let i = obj.constraintStart; i < obj.constraintStart + obj.constraintCount; i++) {
this.drawConstraint(constraints[i], points, obj.color, showStress);
}
}
// Draw pins
for (let i = obj.pointStart; i < obj.pointStart + obj.pointCount; i++) {
if (points[i].pinned)
this.drawPin(points[i]);
}
}
drawRope(obj, points, constraints, showStress) {
// Draw rope as a thick line
this.ctx.beginPath();
this.ctx.moveTo(points[obj.pointStart].x, points[obj.pointStart].y);
for (let i = 1; i < obj.pointCount; i++) {
this.ctx.lineTo(points[obj.pointStart + i].x, points[obj.pointStart + i].y);
}
this.ctx.strokeStyle = obj.color;
this.ctx.lineWidth = 4;
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
this.ctx.globalAlpha = 0.8;
this.ctx.stroke();
this.ctx.globalAlpha = 1;
// Draw constraint lines if stress mode
if (showStress) {
for (let i = obj.constraintStart; i < obj.constraintStart + obj.constraintCount; i++) {
this.drawConstraint(constraints[i], points, obj.color, true);
}
}
// Draw points
if (constants_1.RENDER.showPoints) {
for (let i = obj.pointStart; i < obj.pointStart + obj.pointCount; i++) {
this.drawPoint(points[i], obj.color, constants_1.RENDER.pointRadius);
if (points[i].pinned)
this.drawPin(points[i]);
}
}
else {
// Just draw pins
for (let i = obj.pointStart; i < obj.pointStart + obj.pointCount; i++) {
if (points[i].pinned)
this.drawPin(points[i]);
}
}
}
drawSoftBody(obj, points, constraints, showStress) {
const objPoints = [];
for (let i = obj.pointStart; i < obj.pointStart + obj.pointCount; i++) {
objPoints.push(i);
}
// Draw filled body with convex hull
const hull = (0, spawner_1.computeConvexHull)(objPoints, points);
if (hull.length > 2) {
// Shadow
this.ctx.beginPath();
this.ctx.moveTo(points[hull[0]].x + 3, points[hull[0]].y + 3);
for (let i = 1; i < hull.length; i++) {
this.ctx.lineTo(points[hull[i]].x + 3, points[hull[i]].y + 3);
}
this.ctx.closePath();
this.ctx.fillStyle = 'rgba(0,0,0,0.2)';
this.ctx.fill();
// Main body
this.ctx.beginPath();
this.ctx.moveTo(points[hull[0]].x, points[hull[0]].y);
for (let i = 1; i < hull.length; i++) {
this.ctx.lineTo(points[hull[i]].x, points[hull[i]].y);
}
this.ctx.closePath();
// Gradient fill
const cx = points.reduce((sum, p) => sum + p.x, 0) / points.length;
const cy = points.reduce((sum, p) => sum + p.y, 0) / points.length;
const grad = this.ctx.createRadialGradient(cx - 10, cy - 10, 0, cx, cy, 50);
grad.addColorStop(0, hexToRgba(obj.color, 0.35));
grad.addColorStop(1, hexToRgba(obj.color, 0.15));
this.ctx.fillStyle = grad;
this.ctx.fill();
this.ctx.strokeStyle = obj.color;
this.ctx.lineWidth = 2;
this.ctx.stroke();
}
// Draw internal constraints
for (let i = obj.constraintStart; i < obj.constraintStart + obj.constraintCount; i++) {
this.drawConstraint(constraints[i], points, obj.color, showStress);
}
// Draw points
for (let i = obj.pointStart; i < obj.pointStart + obj.pointCount; i++) {
this.drawPoint(points[i], obj.color, constants_1.RENDER.pointRadius);
if (points[i].pinned)
this.drawPin(points[i]);
}
}
drawBall(obj, points) {
const p = points[obj.pointStart];
if (!p)
return;
// Shadow
this.ctx.beginPath();
this.ctx.arc(p.x + 3, p.y + 3, p.radius, 0, Math.PI * 2);
this.ctx.fillStyle = 'rgba(0,0,0,0.3)';
this.ctx.fill();
// Ball
this.ctx.beginPath();
this.ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
const grad = this.ctx.createRadialGradient(p.x - p.radius * 0.3, p.y - p.radius * 0.3, 0, p.x, p.y, p.radius);
grad.addColorStop(0, hexToRgba(obj.color, 0.6));
grad.addColorStop(1, hexToRgba(obj.color, 0.2));
this.ctx.fillStyle = grad;
this.ctx.fill();
this.ctx.strokeStyle = obj.color;
this.ctx.lineWidth = 2;
this.ctx.stroke();
// Highlight
this.ctx.beginPath();
this.ctx.arc(p.x - p.radius * 0.25, p.y - p.radius * 0.25, p.radius * 0.2, 0, Math.PI * 2);
this.ctx.fillStyle = 'rgba(255,255,255,0.3)';
this.ctx.fill();
if (p.pinned)
this.drawPin(p);
}
drawObjects(objects, points, constraints, showStress) {
for (const obj of objects) {
switch (obj.type) {
case 'rope':
this.drawRope(obj, points, constraints, showStress);
break;
case 'cloth':
this.drawCloth(obj, points, constraints, showStress);
break;
case 'softbody':
this.drawSoftBody(obj, points, constraints, showStress);
break;
case 'ball':
this.drawBall(obj, points);
break;
}
}
}
drawGrabHighlight(points, grabPoint) {
if (grabPoint === null)
return;
const p = points[grabPoint];
if (!p)
return;
// Pulsing ring
const time = Date.now() / 500;
const radius = constants_1.PHYSICS.grabRadius + Math.sin(time) * 3;
this.ctx.beginPath();
this.ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
this.ctx.strokeStyle = constants_1.COLORS.grab;
this.ctx.lineWidth = 2;
this.ctx.setLineDash([4, 4]);
this.ctx.stroke();
this.ctx.setLineDash([]);
// Glow
this.ctx.beginPath();
this.ctx.arc(p.x, p.y, 8, 0, Math.PI * 2);
this.ctx.fillStyle = 'rgba(155, 89, 182, 0.4)';
this.ctx.fill();
}
drawTearHighlight(mouseX, mouseY, tearing) {
if (!tearing)
return;
this.ctx.beginPath();
this.ctx.arc(mouseX, mouseY, constants_1.PHYSICS.tearRadius, 0, Math.PI * 2);
this.ctx.strokeStyle = constants_1.COLORS.tear;
this.ctx.lineWidth = 2;
this.ctx.setLineDash([4, 4]);
this.ctx.stroke();
this.ctx.setLineDash([]);
// Red glow
this.ctx.beginPath();
this.ctx.arc(mouseX, mouseY, constants_1.PHYSICS.tearRadius, 0, Math.PI * 2);
this.ctx.fillStyle = 'rgba(255, 0, 68, 0.1)';
this.ctx.fill();
}
drawHUD(fps, physicsTime, pointCount, constraintCount, paused) {
this.ctx.fillStyle = 'rgba(0,0,0,0.5)';
this.ctx.fillRect(0, this.height - 24, this.width, 24);
this.ctx.fillStyle = constants_1.COLORS.text;
this.ctx.font = '12px monospace';
this.ctx.fillText(`FPS: ${fps}`, 10, this.height - 8);
this.ctx.fillText(`Physics: ${physicsTime.toFixed(1)}ms`, 80, this.height - 8);
this.ctx.fillText(`Points: ${pointCount}`, 180, this.height - 8);
this.ctx.fillText(`Constraints: ${constraintCount}`, 290, this.height - 8);
if (paused) {
this.ctx.fillStyle = '#ff6b6b';
this.ctx.font = 'bold 14px monospace';
this.ctx.fillText('⏸ PAUSED (Space to resume, → to step)', 420, this.height - 8);
}
}
drawSpawnLine(startX, startY, endX, endY, color) {
this.ctx.beginPath();
this.ctx.moveTo(startX, startY);
this.ctx.lineTo(endX, endY);
this.ctx.strokeStyle = color;
this.ctx.lineWidth = 2;
this.ctx.setLineDash([6, 4]);
this.ctx.stroke();
this.ctx.setLineDash([]);
// Start point
this.ctx.beginPath();
this.ctx.arc(startX, startY, 5, 0, Math.PI * 2);
this.ctx.fillStyle = color;
this.ctx.fill();
// End point
this.ctx.beginPath();
this.ctx.arc(endX, endY, 5, 0, Math.PI * 2);
this.ctx.fillStyle = color;
this.ctx.globalAlpha = 0.5;
this.ctx.fill();
this.ctx.globalAlpha = 1;
}
drawInstructions() {
this.ctx.fillStyle = 'rgba(255,255,255,0.15)';
this.ctx.font = '11px monospace';
this.ctx.textAlign = 'right';
this.ctx.fillText('Space: Pause | Shift+Drag: Tear | Right-drag: Tear | T: Toggle tear mode', this.width - 10, this.height - 8);
this.ctx.textAlign = 'left';
}
}
exports.Renderer = Renderer;
// Helper: hex color to rgba string
function hexToRgba(hex, 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})`;
}
};
// ── module: src/ui.ts ──
__mods["src/ui.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Toolbar = void 0;
const constants_1 = require("./constants");
class Toolbar {
constructor(container) {
this.buttons = new Map();
this.currentTool = 'select';
this.clothPinPattern = 'corners';
this.onToolChange = null;
this.onReset = null;
this.onGravityChange = null;
this.onIterChange = null;
this.onPinPatternChange = null;
this.onStressToggle = null;
this.onWindChange = null;
this.onPointsToggle = null;
this.btnStyle = `
padding: 4px 10px;
border: 1px solid ${constants_1.COLORS.wall};
background: ${constants_1.COLORS.toolbar};
color: ${constants_1.COLORS.text};
border-radius: 4px;
cursor: pointer;
font-size: 12px;
white-space: nowrap;
transition: background 0.15s;
`;
this.container = container;
this.build();
}
setOnToolChange(cb) { this.onToolChange = cb; }
setOnReset(cb) { this.onReset = cb; }
setOnGravityChange(cb) { this.onGravityChange = cb; }
setOnIterChange(cb) { this.onIterChange = cb; }
setOnPinPatternChange(cb) { this.onPinPatternChange = cb; }
setOnStressToggle(cb) { this.onStressToggle = cb; }
setOnWindChange(cb) { this.onWindChange = cb; }
setOnPointsToggle(cb) { this.onPointsToggle = cb; }
makeButton(label, id, title) {
const btn = document.createElement('button');
btn.textContent = label;
btn.title = title || label;
btn.style.cssText = this.btnStyle;
btn.onmouseenter = () => btn.style.background = constants_1.COLORS.toolbarHover;
btn.onmouseleave = () => {
if (!btn.dataset.active)
btn.style.background = constants_1.COLORS.toolbar;
};
btn.onclick = () => {
// Flash effect
btn.style.background = constants_1.COLORS.toolbarActive;
setTimeout(() => {
if (!btn.dataset.active)
btn.style.background = constants_1.COLORS.toolbar;
}, 150);
};
this.container.appendChild(btn);
this.buttons.set(id, btn);
return btn;
}
makeSlider(label, min, max, step, value) {
const wrap = document.createElement('div');
wrap.style.cssText = 'display:flex;align-items:center;gap:4px;font-size:11px;color:' + constants_1.COLORS.text;
const lbl = document.createElement('span');
lbl.textContent = label;
lbl.style.minWidth = '50px';
wrap.appendChild(lbl);
const slider = document.createElement('input');
slider.type = 'range';
slider.min = String(min);
slider.max = String(max);
slider.step = String(step);
slider.value = String(value);
slider.style.cssText = 'width:70px;height:14px;accent-color:#6c6cff';
wrap.appendChild(slider);
const val = document.createElement('span');
val.textContent = String(value);
val.style.cssText = 'min-width:35px;text-align:right;font-family:monospace';
wrap.appendChild(val);
slider.oninput = () => {
val.textContent = String(parseFloat(slider.value));
};
this.container.appendChild(wrap);
return slider;
}
makeSeparator() {
const sep = document.createElement('div');
sep.style.cssText = 'width:1px;height:22px;background:' + constants_1.COLORS.wall + ';margin:0 2px';
this.container.appendChild(sep);
}
build() {
// Tool buttons
const tools = [
{ label: '🖱 Select', tool: 'select' },
{ label: '🪢 Rope', tool: 'rope' },
{ label: '🧵 Cloth', tool: 'cloth' },
{ label: '🟢 Blob', tool: 'softbody' },
{ label: '🔴 Ball', tool: 'ball' },
];
for (const t of tools) {
const btn = this.makeButton(t.label, 'tool-' + t.tool);
btn.onclick = () => {
this.currentTool = t.tool;
this.updateToolButtons();
if (this.onToolChange)
this.onToolChange(t.tool);
};
if (t.tool === 'select') {
btn.style.background = constants_1.COLORS.toolbarActive;
btn.dataset.active = '1';
}
}
this.makeSeparator();
// Cloth pin pattern
const pinBtn = this.makeButton('Pin: Corners', 'pin-pattern');
pinBtn.onclick = () => {
this.clothPinPattern = this.clothPinPattern === 'corners' ? 'fullEdge' : 'corners';
pinBtn.textContent = this.clothPinPattern === 'corners' ? 'Pin: Corners' : 'Pin: Full Edge';
if (this.onPinPatternChange)
this.onPinPatternChange(this.clothPinPattern);
};
this.makeSeparator();
// Sliders
this.gravitySlider = this.makeSlider('Gravity', 0, 2000, 10, constants_1.PHYSICS.gravity);
this.gravitySlider.oninput = () => {
if (this.onGravityChange)
this.onGravityChange(parseFloat(this.gravitySlider.value));
};
this.iterSlider = this.makeSlider('Iterations', 1, 15, 1, constants_1.PHYSICS.iterations);
this.iterSlider.oninput = () => {
if (this.onIterChange)
this.onIterChange(parseInt(this.iterSlider.value));
};
this.windSlider = this.makeSlider('Wind', -500, 500, 10, 0);
this.windSlider.oninput = () => {
if (this.onWindChange)
this.onWindChange(parseFloat(this.windSlider.value));
};
this.makeSeparator();
// Toggles
const stressBtn = this.makeButton('🔴 Stress', 'stress');
stressBtn.onclick = () => {
if (this.onStressToggle)
this.onStressToggle();
};
const pointsBtn = this.makeButton('⚫ Points', 'points');
pointsBtn.onclick = () => {
if (this.onPointsToggle)
this.onPointsToggle();
};
this.makeSeparator();
// Reset
const resetBtn = this.makeButton('🔄 Reset', 'reset');
resetBtn.onclick = () => {
if (this.onReset)
this.onReset();
};
}
updateToolButtons() {
for (const [id, btn] of this.buttons) {
if (id.startsWith('tool-')) {
const isActive = id === 'tool-' + this.currentTool;
btn.style.background = isActive ? constants_1.COLORS.toolbarActive : constants_1.COLORS.toolbar;
btn.dataset.active = isActive ? '1' : '';
}
}
}
getTool() { return this.currentTool; }
getPinPattern() { return this.clothPinPattern; }
getGravity() { return parseFloat(this.gravitySlider.value); }
getIterations() { return parseInt(this.iterSlider.value); }
getWind() { return parseFloat(this.windSlider.value); }
}
exports.Toolbar = Toolbar;
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen/qwen3.6-27b","provider":"lmstudio","persona":"typescript","sessionId":"7d7d839c-5522-4399-9a07-71ebee11844b","tokensIn":1516947,"tokensOut":39453,"tokensTotal":1556400,"cost":0,"turns":47,"toolCalls":49,"failedToolCalls":0,"timestamp":"2026-08-01T16:30:11.763Z"} -->