Physics Playground
thinkingcap qwen3.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 58,873 bytes · SHA-256 c12ddea60781
<!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: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
user-select: none;
}
#app {
width: 100%;
height: 100%;
position: relative;
}
#sim-canvas {
display: block;
cursor: crosshair;
}
/* ─── Toolbar ───────────────────────────────────────────────────── */
#toolbar {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: rgba(20, 20, 40, 0.85);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
z-index: 10;
}
.tool-btn {
background: rgba(255, 255, 255, 0.08);
color: #ccc;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
padding: 6px 10px;
font-size: 14px;
cursor: pointer;
transition: all 0.15s ease;
}
.tool-btn:hover {
background: rgba(255, 255, 255, 0.15);
color: #fff;
}
.tool-btn.active {
background: rgba(78, 205, 196, 0.3);
border-color: rgba(78, 205, 196, 0.5);
color: #4ecdc4;
}
.toolbar-sep {
width: 1px;
height: 24px;
background: rgba(255, 255, 255, 0.15);
margin: 0 4px;
}
.slider {
width: 70px;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
outline: none;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
background: #4ecdc4;
border-radius: 50%;
cursor: pointer;
}
.slider::-moz-range-thumb {
width: 14px;
height: 14px;
background: #4ecdc4;
border-radius: 50%;
cursor: pointer;
border: none;
}
.toggle {
width: 32px;
height: 18px;
-webkit-appearance: none;
appearance: none;
background: rgba(255, 255, 255, 0.15);
border-radius: 9px;
outline: none;
cursor: pointer;
position: relative;
transition: background 0.2s;
}
.toggle:checked {
background: #4ecdc4;
}
.toggle::before {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: white;
border-radius: 50%;
transition: transform 0.2s;
}
.toggle:checked::before {
transform: translateX(14px);
}
label {
color: #999;
font-size: 11px;
cursor: pointer;
}
</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","./renderer":"src/renderer.ts","./input":"src/input.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/renderer.ts":{"./constants":"src/constants.ts","./types":"src/types.ts","./physics":"src/physics.ts"},"src/input.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 renderer_1 = require("./renderer");
const input_1 = require("./input");
const ui_1 = require("./ui");
const C = constants_1.CONSTANTS;
// ─── Simulation State ──────────────────────────────────────────────
let points = [];
let constraints = [];
let objects = [];
let staticCircles = [];
let staticBoxes = [];
// ─── Canvas & Context ──────────────────────────────────────────────
const canvas = (0, renderer_1.setupCanvas)();
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
// ─── UI ────────────────────────────────────────────────────────────
let toolbar = null;
let paused = false;
let spawnStartX = 0, spawnStartY = 0;
let isSpawningRope = false;
// ─── Timing ────────────────────────────────────────────────────────
let lastTime = 0;
let accumulator = 0;
let fpsCounter = 60;
let stepTimeMs = 0;
let frameCount = 0;
let fpsTimer = 0;
// ─── Input ─────────────────────────────────────────────────────────
const input = (0, input_1.createInputState)();
// ─── Initialize Simulation ─────────────────────────────────────────
function initSim() {
points = [];
constraints = [];
objects = [];
// Add static geometry: a circle obstacle and a box platform
const cw = window.innerWidth;
const ch = window.innerHeight;
staticCircles = [
{ x: cw * 0.5, y: ch * 0.65, radius: 45 },
{ x: cw * 0.25, y: ch * 0.45, radius: 30 },
];
staticBoxes = [
{ x: cw * 0.7, y: ch * 0.75, w: 120, h: 15 },
];
// Spawn demo objects
(0, spawner_1.spawnCloth)(points, constraints, objects, cw * 0.3, 60);
(0, spawner_1.spawnRope)(points, constraints, objects, cw * 0.7, 40, cw * 0.7, 200);
(0, spawner_1.spawnSoftBody)(points, constraints, objects, cw * 0.5, ch * 0.3);
(0, spawner_1.spawnBall)(points, constraints, objects, cw * 0.15, 80);
}
// ─── Reset Simulation ──────────────────────────────────────────────
function resetSim() {
initSim();
}
// ─── Resize Handler ────────────────────────────────────────────────
window.addEventListener('resize', () => {
(0, renderer_1.resizeCanvas)(canvas);
});
// ─── Main Loop ─────────────────────────────────────────────────────
function gameLoop(timestamp) {
if (!lastTime)
lastTime = timestamp;
let delta = (timestamp - lastTime) / 1000;
lastTime = timestamp;
// Clamp delta to prevent explosion on tab-switch
delta = Math.min(delta, C.maxFrameDelta);
// FPS counter
frameCount++;
fpsTimer += delta;
if (fpsTimer >= 0.5) {
fpsCounter = Math.round(frameCount / fpsTimer);
frameCount = 0;
fpsTimer = 0;
}
const cw = window.innerWidth;
const ch = window.innerHeight;
// Update constants from UI sliders
C.gravity = parseFloat(toolbar?.gravitySlider.value ?? '980');
C.iterations = parseInt(toolbar?.iterationsSlider.value ?? '5', 10);
C.stressVisualization = toolbar?.stressToggle.checked ?? false;
// Fixed timestep accumulator
if (!paused) {
accumulator += delta;
const stepStart = performance.now();
while (accumulator >= C.dt) {
(0, physics_1.physicsStep)(points, constraints, staticCircles, staticBoxes, cw, ch, C.gravity);
accumulator -= C.dt;
}
stepTimeMs = performance.now() - stepStart;
}
else {
stepTimeMs = 0;
}
// Process input (grab/tear)
handleInput();
// Render
(0, renderer_1.resizeCanvas)(canvas);
(0, renderer_1.render)(ctx, points, constraints, objects, staticCircles, staticBoxes, cw, ch, input.grabPointIdx, fpsCounter, stepTimeMs, paused);
requestAnimationFrame(gameLoop);
}
// ─── Input Handling ────────────────────────────────────────────────
function handleInput() {
// Grab logic (left click)
if (input.mouseDown && input.grabPointIdx === null && !isSpawningRope) {
const idx = (0, input_1.findNearestPoint)(points, objects, input.mouseX, input.mouseY, C.grabRadius);
if (idx !== null) {
// Grab the point
input.grabPointIdx = idx;
input.dragVelX = 0;
input.dragVelY = 0;
}
else if (!paused && toolbar?.selectedTool) {
// Spawn new object at click position
spawnObject(toolbar.selectedTool, input.mouseX, input.mouseY);
}
}
// Rope spawning (drag to draw)
if (isSpawningRope && !input.mouseDown) {
isSpawningRope = false;
return;
}
// Tear logic (right click or shift+click)
if (input.rightMouseDown || (input.shiftKey && input.mouseDown)) {
const cIdx = (0, input_1.findNearestConstraint)(constraints, points, input.mouseX, input.mouseY, C.tearRadius);
if (cIdx !== null) {
constraints[cIdx].broken = true;
}
}
// Apply grab position to point
if (input.grabPointIdx !== null && input.grabPointIdx < points.length) {
const p = points[input.grabPointIdx];
p.x = input.mouseX;
p.y = input.mouseY;
p.prevX = input.mouseX - input.dragVelX * 3;
p.prevY = input.mouseY - input.dragVelY * 3;
}
}
// ─── Spawn Object at Position ──────────────────────────────────────
function spawnObject(tool, x, y) {
switch (tool) {
case 'rope':
isSpawningRope = true;
spawnStartX = x;
spawnStartY = y;
break;
case 'cloth':
(0, spawner_1.spawnCloth)(points, constraints, objects, x - C.clothCols * C.clothSpacing / 2, y);
break;
case 'softbody':
(0, spawner_1.spawnSoftBody)(points, constraints, objects, x, y);
break;
case 'ball':
(0, spawner_1.spawnBall)(points, constraints, objects, x, y);
break;
}
}
// ─── Keyboard Controls ─────────────────────────────────────────────
window.addEventListener('keydown', (e) => {
if (e.code === 'Space') {
e.preventDefault();
paused = !paused;
const pauseBtn = document.getElementById('pause-btn');
if (pauseBtn)
pauseBtn.textContent = paused ? '▶' : '⏸';
}
if (e.code === 'ArrowRight' && paused) {
// Step one frame
const cw = window.innerWidth;
const ch = window.innerHeight;
(0, physics_1.physicsStep)(points, constraints, staticCircles, staticBoxes, cw, ch, C.gravity);
}
});
// ─── Canvas Click Handler for Rope Spawning ────────────────────────
function setupCanvasEvents() {
canvas.addEventListener('mousedown', (e) => {
if (e.button === 0 && toolbar?.selectedTool === 'rope') {
isSpawningRope = true;
spawnStartX = input.mouseX;
spawnStartY = input.mouseY;
}
});
canvas.addEventListener('mouseup', (e) => {
if (e.button === 0 && isSpawningRope) {
isSpawningRope = false;
// Spawn rope from start to end position
const dx = input.mouseX - spawnStartX;
const dy = input.mouseY - spawnStartY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 20) {
(0, spawner_1.spawnRope)(points, constraints, objects, spawnStartX, spawnStartY, input.mouseX, input.mouseY);
}
else {
// Click without drag: spawn a short hanging rope
(0, spawner_1.spawnRope)(points, constraints, objects, spawnStartX, spawnStartY, spawnStartX, spawnStartY + 150);
}
}
});
}
// ─── Expose Simulation on Window for Probing ────────────────────────
const simObj = {
get points() { return points; },
get constraints() { return constraints; },
get objects() { return objects; },
step() {
const cw = window.innerWidth;
const ch = window.innerHeight;
(0, physics_1.physicsStep)(points, constraints, staticCircles, staticBoxes, cw, ch, C.gravity);
},
reset: resetSim,
get kineticEnergy() { return (0, physics_1.totalKineticEnergy)(points); },
};
// Assign to window for headless probing
window.sim = simObj;
// ─── Start ─────────────────────────────────────────────────────────
function start() {
// Setup DOM
document.getElementById('app').appendChild(canvas);
// Toolbar
toolbar = (0, ui_1.createToolbar)();
(0, ui_1.setupToolbarEvents)(toolbar);
document.getElementById('app').appendChild(toolbar.container);
// Button handlers
const pauseBtn = document.getElementById('pause-btn');
if (pauseBtn) {
pauseBtn.addEventListener('click', () => {
paused = !paused;
pauseBtn.textContent = paused ? '▶' : '⏸';
});
}
const stepBtn = document.getElementById('step-btn');
if (stepBtn) {
stepBtn.addEventListener('click', () => {
if (paused) {
const cw = window.innerWidth;
const ch = window.innerHeight;
(0, physics_1.physicsStep)(points, constraints, staticCircles, staticBoxes, cw, ch, C.gravity);
}
});
}
const resetBtn = document.getElementById('reset-btn');
if (resetBtn) {
resetBtn.addEventListener('click', resetSim);
}
// Input setup
(0, input_1.setupInput)(canvas, input);
setupCanvasEvents();
// Initialize simulation
initSim();
// Start game loop
requestAnimationFrame(gameLoop);
}
// Auto-start when module loads
start();
};
// ── module: src/constants.ts ──
__mods["src/constants.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CONSTANTS = void 0;
// ─── Tunable Constants ──────────────────────────────────────────────
exports.CONSTANTS = {
// Physics timestep (fixed) — 60 Hz
dt: 1 / 60,
// Damping per step (1.0 = no damping, < 1.0 = energy loss)
damping: 0.955,
// Constraint solver iterations per physics step
iterations: 5,
// Gravity strength (pixels/s²)
gravity: 980,
// Maximum frame delta to prevent explosion on tab-switch
maxFrameDelta: 1 / 20,
// Grab radius in pixels
grabRadius: 15,
// Tear/cut radius in pixels
tearRadius: 12,
// Point rendering radius
pointRadius: 2.5,
// Cloth point visibility (false = render as mesh)
showPoints: false,
// Stress visualization toggle
stressVisualization: false,
// Motion blur alpha (0 = no trail, higher = more trail)
motionBlurAlpha: 0.15,
// ─── Colors ──────────────────────────────────────────────────────
colors: {
background: '#1a1a2e',
grid: 'rgba(255,255,255,0.03)',
rope: '#ff6b6b',
cloth: '#4ecdc4',
softBody: '#ffe66d',
ball: '#a8e6cf',
pin: '#ffffff',
grabHighlight: 'rgba(255, 107, 107, 0.3)',
tearEffect: '#ff4757',
wallStroke: 'rgba(255,255,255,0.15)',
staticCircleFill: 'rgba(255,255,255,0.08)',
staticCircleStroke: 'rgba(255,255,255,0.2)',
},
// ─── Object Defaults ─────────────────────────────────────────────
ropeSegments: 30,
ropeSpacing: 12,
clothCols: 20,
clothRows: 14,
clothSpacing: 14,
softBodyRadius: 50,
softBodyPoints: 16,
ballRadius: 20,
// ─── Bounds ──────────────────────────────────────────────────────
coordBound: 1e6, // NaN guard threshold
};
};
// ── module: src/types.ts ──
__mods["src/types.ts"] = function (exports, require, module) {
"use strict";
// ─── Core 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.collideWalls = collideWalls;
exports.collideStaticCircles = collideStaticCircles;
exports.collideStaticBoxes = collideStaticBoxes;
exports.physicsStep = physicsStep;
exports.totalKineticEnergy = totalKineticEnergy;
exports.constraintStretch = constraintStretch;
const constants_1 = require("./constants");
const C = constants_1.CONSTANTS;
// ─── NaN / Infinity Guard ──────────────────────────────────────────
function isFiniteCoord(v) {
return Number.isFinite(v) && Math.abs(v) < C.coordBound;
}
function sanitizePoint(p) {
if (!isFiniteCoord(p.x) || !isFiniteCoord(p.y)) {
p.x = 0;
p.y = 0;
p.prevX = 0;
p.prevY = 0;
}
if (!isFiniteCoord(p.prevX) || !isFiniteCoord(p.prevY)) {
p.prevX = p.x;
p.prevY = p.y;
}
}
// ─── Verlet Integration Step ────────────────────────────────────────
function integrate(points, dt, gravity) {
const damping = C.damping;
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;
p.y += vy + gravity * dt * dt;
sanitizePoint(p);
}
}
// ─── Distance Constraint Solver ─────────────────────────────────────
function solveConstraints(points, constraints) {
const iterations = C.iterations;
for (let iter = 0; iter < iterations; iter++) {
for (let i = 0; i < constraints.length; i++) {
const c = constraints[i];
if (c.broken)
continue;
const pa = points[c.p0];
const pb = points[c.p1];
const dx = pb.x - pa.x;
const dy = pb.y - pa.y;
const distSq = dx * dx + dy * dy;
// Guard against zero-length or NaN distances
if (distSq < 1e-8 || !Number.isFinite(distSq))
continue;
const dist = Math.sqrt(distSq);
const diff = (c.restLength - dist) / dist;
// Weight by inverse mass (equal mass here, so split evenly unless pinned)
const wa = pa.pinned ? 0 : 0.5;
const wb = pb.pinned ? 0 : 0.5;
const wSum = wa + wb;
if (wSum < 1e-8)
continue;
const corrA = diff * wa / wSum;
const corrB = diff * wb / wSum;
pa.x -= dx * corrA;
pa.y -= dy * corrA;
pb.x += dx * corrB;
pb.y += dy * corrB;
}
}
}
// ─── Wall/Floor Collision ──────────────────────────────────────────
function collideWalls(points, canvasW, canvasH) {
const friction = 0.85;
for (let i = 0; i < points.length; i++) {
const p = points[i];
if (p.pinned)
continue;
// Floor (account for point radius, minimum clearance of 2px)
const floorOffset = Math.max(2, (p.radius || 0));
if (p.y > canvasH - floorOffset) {
p.y = canvasH - floorOffset;
p.prevX = p.x + (p.prevX - p.x) * friction;
p.prevY = p.y;
}
// Ceiling
if (p.y < 2) {
p.y = 2;
p.prevX = p.x + (p.prevX - p.x) * friction;
p.prevY = p.y;
}
// Left wall
if (p.x < 2) {
p.x = 2;
p.prevX = p.x;
p.prevY = p.y + (p.prevY - p.y) * friction;
}
// Right wall
if (p.x > canvasW - 2) {
p.x = canvasW - 2;
p.prevX = p.x;
p.prevY = p.y + (p.prevY - p.y) * friction;
}
sanitizePoint(p);
}
}
// ─── Static Circle Collision ────────────────────────────────────────
function collideStaticCircles(points, circles) {
const friction = 0.85;
for (let ci = 0; ci < circles.length; ci++) {
const circ = circles[ci];
for (let pi = 0; pi < points.length; pi++) {
const p = points[pi];
if (p.pinned)
continue;
const dx = p.x - circ.x;
const dy = p.y - circ.y;
const distSq = dx * dx + dy * dy;
const minDist = circ.radius + (p.radius || 0);
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 = circ.x + nx * minDist;
p.y = circ.y + ny * minDist;
// Apply friction to 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 = p.x - (vx - normalVel * nx) * friction;
p.prevY = p.y - (vy - normalVel * ny) * friction;
}
else {
p.prevX = p.x - vx * friction;
p.prevY = p.y - vy * friction;
}
sanitizePoint(p);
}
}
}
}
// ─── Static Box Collision ──────────────────────────────────────────
function collideStaticBoxes(points, boxes) {
const friction = 0.85;
for (let bi = 0; bi < boxes.length; bi++) {
const box = boxes[bi];
const cx = box.x + box.w / 2;
const cy = box.y + box.h / 2;
for (let pi = 0; pi < points.length; pi++) {
const p = points[pi];
if (p.pinned)
continue;
// Find closest point on AABB
const closestX = Math.max(box.x, Math.min(p.x, box.x + box.w));
const closestY = Math.max(box.y, Math.min(p.y, box.y + box.h));
const dx = p.x - closestX;
const dy = p.y - closestY;
const distSq = dx * dx + dy * dy;
const minDist = p.radius || 0;
if (distSq < minDist * minDist && distSq > 1e-8) {
const dist = Math.sqrt(distSq);
let nx = dx / dist;
let ny = dy / dist;
// If point is inside the box, push out based on center direction
if (dist < 0.5) {
const toCenterX = p.x - cx;
const toCenterY = p.y - cy;
const tcDist = Math.sqrt(toCenterX * toCenterX + toCenterY * toCenterY);
if (tcDist > 1e-8) {
nx = toCenterX / tcDist;
ny = toCenterY / tcDist;
}
else {
nx = 0;
ny = -1;
}
}
p.x += nx * (minDist + 1);
p.y += ny * (minDist + 1);
const vx = p.x - p.prevX;
const vy = p.y - p.prevY;
const normalVel = vx * nx + vy * ny;
if (normalVel < 0) {
p.prevX = p.x - (vx - normalVel * nx) * friction;
p.prevY = p.y - (vy - normalVel * ny) * friction;
}
else {
p.prevX = p.x - vx * friction;
p.prevY = p.y - vy * friction;
}
sanitizePoint(p);
}
}
}
}
// ─── Full Physics Step ──────────────────────────────────────────────
function physicsStep(points, constraints, staticCircles, staticBoxes, canvasW, canvasH, gravity) {
integrate(points, C.dt, gravity);
for (let iter = 0; iter < C.iterations; iter++) {
solveConstraints(points, constraints);
collideWalls(points, canvasW, canvasH);
collideStaticCircles(points, staticCircles);
collideStaticBoxes(points, staticBoxes);
}
// Final NaN sweep
for (let i = 0; i < points.length; i++) {
sanitizePoint(points[i]);
}
}
// ─── Kinetic Energy Calculator ──────────────────────────────────────
function totalKineticEnergy(points) {
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;
const vy = p.y - p.prevY;
energy += 0.5 * p.mass * (vx * vx + vy * vy);
}
return energy;
}
// ─── Constraint Stretch Ratio ──────────────────────────────────────
function constraintStretch(constraint, points) {
const pa = points[constraint.p0];
const pb = points[constraint.p1];
const dx = pb.x - pa.x;
const dy = pb.y - pa.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 1e-8 || !Number.isFinite(dist))
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.removeObject = removeObject;
const constants_1 = require("./constants");
const C = constants_1.CONSTANTS;
let nextId = 0;
function makePoint(x, y) {
return { x, y, prevX: x, prevY: y, pinned: false, mass: 1 };
}
// ─── Rope Spawner ──────────────────────────────────────────────────
function spawnRope(points, constraints, objects, startX, startY, endX, endY) {
const segments = C.ropeSegments;
const spacing = C.ropeSpacing;
// Calculate actual distance and adjust segment count if needed
const dx = endX - startX;
const dy = endY - startY;
const totalLen = Math.sqrt(dx * dx + dy * dy);
const segCount = Math.max(3, Math.min(segments, Math.floor(totalLen / spacing)));
const pointStart = points.length;
for (let i = 0; i <= segCount; i++) {
const t = i / segCount;
const px = startX + dx * t;
const py = startY + dy * t;
points.push(makePoint(px, py));
}
// Pin the first point
points[pointStart].pinned = true;
for (let i = 0; i < segCount; i++) {
constraints.push({
p0: pointStart + i,
p1: pointStart + i + 1,
restLength: spacing,
broken: false,
});
}
objects.push({
id: `rope-${nextId++}`,
type: 'rope',
pointStart,
constraintStart: constraints.length - segCount,
pointCount: segCount + 1,
constraintCount: segCount,
color: C.colors.rope,
});
}
// ─── Cloth Spawner ─────────────────────────────────────────────────
function spawnCloth(points, constraints, objects, x, y, pinPattern = 'corners') {
const cols = C.clothCols;
const rows = C.clothRows;
const spacing = C.clothSpacing;
const pointStart = points.length;
// Create grid of points
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const px = x + col * spacing;
const py = y + row * spacing;
points.push(makePoint(px, py));
}
}
// Pin top edge based on pattern
if (pinPattern === 'fullEdge') {
for (let col = 0; col < cols; col++) {
points[pointStart + col].pinned = true;
}
}
else {
// Pin corners only
points[pointStart].pinned = true;
points[pointStart + cols - 1].pinned = true;
}
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;
// Horizontal link
if (col < cols - 1) {
constraints.push({ p0: idx, p1: idx + 1, restLength: spacing, broken: false });
}
// Vertical link
if (row < rows - 1) {
constraints.push({ p0: idx, p1: idx + cols, restLength: spacing, broken: false });
}
}
}
// Shear constraints (diagonal) for stability
for (let row = 0; row < rows - 1; row++) {
for (let col = 0; col < cols - 1; col++) {
const idx = pointStart + row * cols + col;
// Diagonal constraints
constraints.push({ p0: idx, p1: idx + cols + 1, restLength: spacing * Math.SQRT2, broken: false });
}
}
objects.push({
id: `cloth-${nextId++}`,
type: 'cloth',
pointStart,
constraintStart,
pointCount: rows * cols,
constraintCount: constraints.length - constraintStart,
color: C.colors.cloth,
});
}
// ─── Soft Body Spawner ─────────────────────────────────────────────
function spawnSoftBody(points, constraints, objects, cx, cy, radius) {
const r = radius ?? C.softBodyRadius;
const nPts = C.softBodyPoints;
const pointStart = points.length;
// Create circular arrangement of points
for (let i = 0; i < nPts; i++) {
const angle = (2 * Math.PI * i) / nPts - Math.PI / 2;
const px = cx + r * Math.cos(angle);
const py = cy + r * Math.sin(angle);
points.push(makePoint(px, py));
}
const constraintStart = constraints.length;
// Edge constraints (perimeter)
for (let i = 0; i < nPts; i++) {
const next = (i + 1) % nPts;
const p0 = points[pointStart + i];
const p1 = points[pointStart + next];
const dx = p1.x - p0.x;
const dy = p1.y - p0.y;
constraints.push({
p0: pointStart + i,
p1: pointStart + next,
restLength: Math.sqrt(dx * dx + dy * dy),
broken: false,
});
}
// Internal brace constraints (skip-2 connections for shape retention)
for (let i = 0; i < nPts; i++) {
const skip2 = (i + 3) % nPts;
const p0 = points[pointStart + i];
const p1 = points[pointStart + skip2];
const dx = p1.x - p0.x;
const dy = p1.y - p0.y;
constraints.push({
p0: pointStart + i,
p1: pointStart + skip2,
restLength: Math.sqrt(dx * dx + dy * dy),
broken: false,
});
}
objects.push({
id: `softbody-${nextId++}`,
type: 'softBody',
pointStart,
constraintStart,
pointCount: nPts, // no center point
constraintCount: constraints.length - constraintStart,
color: C.colors.softBody,
});
}
// ─── Ball Spawner ──────────────────────────────────────────────────
function spawnBall(points, constraints, objects, x, y, radius) {
const r = radius ?? C.ballRadius;
const pointStart = points.length;
const p = makePoint(x, y);
p.radius = r;
points.push(p);
objects.push({
id: `ball-${nextId++}`,
type: 'ball',
pointStart,
constraintStart: constraints.length,
pointCount: 1,
constraintCount: 0,
color: C.colors.ball,
});
}
// ─── Remove Object ─────────────────────────────────────────────────
function removeObject(points, constraints, objects, objId) {
const idx = objects.findIndex(o => o.id === objId);
if (idx < 0)
return;
// Mark constraints as broken instead of removing to avoid index issues
for (let i = 0; i < objects[idx].constraintCount; i++) {
constraints[objects[idx].constraintStart + i].broken = true;
}
// Remove points by setting them off-screen and pinned
for (let i = 0; i < objects[idx].pointCount; i++) {
const p = points[objects[idx].pointStart + i];
p.x = -1e6;
p.y = -1e6;
p.prevX = -1e6;
p.prevY = -1e6;
p.pinned = true;
}
objects.splice(idx, 1);
}
};
// ── module: src/renderer.ts ──
__mods["src/renderer.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.setupCanvas = setupCanvas;
exports.render = render;
exports.resizeCanvas = resizeCanvas;
const constants_1 = require("./constants");
const physics_1 = require("./physics");
const C = constants_1.CONSTANTS;
// ─── Canvas Setup ──────────────────────────────────────────────────
function setupCanvas() {
const canvas = document.createElement('canvas');
canvas.id = 'sim-canvas';
canvas.style.display = 'block';
return canvas;
}
function resizeCanvas(canvas) {
const dpr = window.devicePixelRatio || 1;
const w = window.innerWidth;
const h = window.innerHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
}
// ─── Stress Color Helper ────────────────────────────────────────────
function stressColor(ratio) {
// Green (1.0) → Yellow (1.2) → Red (1.5+)
const t = Math.min(1, Math.max(0, (ratio - 1) / 0.5));
const r = Math.floor(t * 255);
const g = Math.floor((1 - t) * 255);
return `rgb(${r},${g},64)`;
}
// ─── Main Render Function ──────────────────────────────────────────
function render(ctx, points, constraints, objects, staticCircles, staticBoxes, canvasW, canvasH, grabPointIdx, fps, stepTimeMs, paused) {
const dpr = window.devicePixelRatio || 1;
// Motion blur trail
ctx.fillStyle = `rgba(${parseInt(C.colors.background.slice(1, 3), 16)},${parseInt(C.colors.background.slice(3, 5), 16)},${parseInt(C.colors.background.slice(5, 7), 16)},${C.motionBlurAlpha})`;
ctx.fillRect(0, 0, canvasW * dpr, canvasH * dpr);
// Clear fully on first frame or when not using motion blur
if (C.motionBlurAlpha < 0.01) {
ctx.fillStyle = C.colors.background;
ctx.fillRect(0, 0, canvasW * dpr, canvasH * dpr);
}
ctx.save();
ctx.scale(dpr, dpr);
// ─── Draw Walls ──────────────────────────────────────────────────
drawWalls(ctx, canvasW, canvasH);
// ─── Draw Static Geometry ────────────────────────────────────────
for (const circ of staticCircles) {
ctx.beginPath();
ctx.arc(circ.x, circ.y, circ.radius, 0, Math.PI * 2);
ctx.fillStyle = C.colors.staticCircleFill;
ctx.fill();
ctx.strokeStyle = C.colors.staticCircleStroke;
ctx.lineWidth = 1.5;
ctx.stroke();
}
for (const box of staticBoxes) {
ctx.fillStyle = C.colors.staticCircleFill;
ctx.fillRect(box.x, box.y, box.w, box.h);
ctx.strokeStyle = C.colors.staticCircleStroke;
ctx.lineWidth = 1.5;
ctx.strokeRect(box.x, box.y, box.w, box.h);
}
// ─── Draw Objects ────────────────────────────────────────────────
for (const obj of objects) {
switch (obj.type) {
case 'rope':
drawRope(ctx, points, constraints, obj);
break;
case 'cloth':
drawCloth(ctx, points, constraints, obj);
break;
case 'softBody':
drawSoftBody(ctx, points, constraints, obj);
break;
case 'ball':
drawBall(ctx, points, obj);
break;
}
}
// ─── Draw Grab Highlight ─────────────────────────────────────────
if (grabPointIdx !== null && grabPointIdx < points.length) {
const gp = points[grabPointIdx];
ctx.beginPath();
ctx.arc(gp.x, gp.y, C.grabRadius + 5, 0, Math.PI * 2);
ctx.fillStyle = C.colors.grabHighlight;
ctx.fill();
}
// ─── Draw Pins ───────────────────────────────────────────────────
for (const obj of objects) {
for (let i = 0; i < obj.pointCount; i++) {
const p = points[obj.pointStart + i];
if (!p.pinned)
continue;
ctx.beginPath();
ctx.arc(p.x, p.y, 4, 0, Math.PI * 2);
ctx.fillStyle = C.colors.pin;
ctx.fill();
// Crosshair
ctx.strokeStyle = C.colors.pin;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(p.x - 6, p.y);
ctx.lineTo(p.x + 6, p.y);
ctx.moveTo(p.x, p.y - 6);
ctx.lineTo(p.x, p.y + 6);
ctx.stroke();
}
}
// ─── HUD ─────────────────────────────────────────────────────────
drawHUD(ctx, canvasW, fps, stepTimeMs, paused);
ctx.restore();
}
// ─── Rope Drawing ──────────────────────────────────────────────────
function drawRope(ctx, points, constraints, obj) {
const showStress = C.stressVisualization;
for (let i = 0; i < obj.constraintCount; i++) {
const c = constraints[obj.constraintStart + i];
if (c.broken)
continue;
ctx.beginPath();
ctx.moveTo(points[c.p0].x, points[c.p0].y);
ctx.lineTo(points[c.p1].x, points[c.p1].y);
if (showStress) {
const stretch = (0, physics_1.constraintStretch)(c, points);
ctx.strokeStyle = stressColor(stretch);
}
else {
ctx.strokeStyle = obj.color;
}
ctx.lineWidth = 2.5;
ctx.lineCap = 'round';
ctx.stroke();
}
// Draw endpoints as small dots
for (let i = 0; i < obj.pointCount; i++) {
const p = points[obj.pointStart + i];
ctx.beginPath();
ctx.arc(p.x, p.y, C.pointRadius, 0, Math.PI * 2);
ctx.fillStyle = obj.color;
ctx.fill();
}
}
// ─── Cloth Drawing ─────────────────────────────────────────────────
function drawCloth(ctx, points, constraints, obj) {
const cols = C.clothCols;
const rows = C.clothRows;
// Draw filled mesh (quads)
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;
const p00 = points[i00];
const p10 = points[i10];
const p01 = points[i01];
const p11 = points[i11];
// Check if any constraint in this quad is broken (for tear visualization)
let torn = false;
for (let ci = 0; ci < obj.constraintCount && !torn; ci++) {
const c = constraints[obj.constraintStart + ci];
if (!c.broken)
continue;
const cpSet = new Set([c.p0, c.p1]);
if ([i00, i10, i01, i11].some(idx => cpSet.has(idx))) {
torn = true;
}
}
ctx.beginPath();
ctx.moveTo(p00.x, p00.y);
ctx.lineTo(p10.x, p10.y);
ctx.lineTo(p11.x, p11.y);
ctx.lineTo(p01.x, p01.y);
ctx.closePath();
if (torn) {
ctx.fillStyle = 'rgba(255, 71, 87, 0.3)';
}
else {
ctx.fillStyle = obj.color + '40'; // ~25% alpha
}
ctx.fill();
// Grid lines
ctx.strokeStyle = obj.color + '80'; // ~50% alpha
ctx.lineWidth = 0.7;
ctx.stroke();
}
}
// Draw points if enabled
if (C.showPoints) {
for (let i = 0; i < obj.pointCount; i++) {
const p = points[obj.pointStart + i];
ctx.beginPath();
ctx.arc(p.x, p.y, C.pointRadius, 0, Math.PI * 2);
ctx.fillStyle = obj.color;
ctx.fill();
}
}
}
// ─── Soft Body Drawing ─────────────────────────────────────────────
function drawSoftBody(ctx, points, constraints, obj) {
const nPts = C.softBodyPoints;
// Draw filled perimeter polygon
ctx.beginPath();
for (let i = 0; i < nPts; 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();
ctx.fillStyle = obj.color + '50'; // ~31% alpha
ctx.fill();
// Draw edge constraints
for (let i = 0; i < nPts; i++) {
const next = (i + 1) % nPts;
const p0 = points[obj.pointStart + i];
const p1 = points[obj.pointStart + next];
ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.lineTo(p1.x, p1.y);
ctx.strokeStyle = obj.color;
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.stroke();
}
// Draw internal constraints (faint) — skip-2 bracing
for (let i = 0; i < nPts; i++) {
const skip2 = (i + 3) % nPts;
const p0 = points[obj.pointStart + i];
const p1 = points[obj.pointStart + skip2];
ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.lineTo(p1.x, p1.y);
ctx.strokeStyle = obj.color + '25';
ctx.lineWidth = 0.5;
ctx.stroke();
}
// Draw points
for (let i = 0; i < nPts; i++) {
const p = points[obj.pointStart + i];
ctx.beginPath();
ctx.arc(p.x, p.y, C.pointRadius, 0, Math.PI * 2);
ctx.fillStyle = obj.color;
ctx.fill();
}
}
// ─── Ball Drawing ──────────────────────────────────────────────────
function drawBall(ctx, points, obj) {
const p = points[obj.pointStart];
const r = p.radius || C.ballRadius;
// Glow effect
const gradient = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r * 1.5);
gradient.addColorStop(0, obj.color + 'cc');
gradient.addColorStop(0.7, obj.color + '44');
gradient.addColorStop(1, obj.color + '00');
ctx.beginPath();
ctx.arc(p.x, p.y, r * 1.5, 0, Math.PI * 2);
ctx.fillStyle = gradient;
ctx.fill();
// Solid ball
ctx.beginPath();
ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
ctx.fillStyle = obj.color;
ctx.fill();
ctx.strokeStyle = obj.color + 'aa';
ctx.lineWidth = 1.5;
ctx.stroke();
}
// ─── Wall Drawing ──────────────────────────────────────────────────
function drawWalls(ctx, w, h) {
ctx.strokeStyle = C.colors.wallStroke;
ctx.lineWidth = 3;
// Floor
ctx.beginPath();
ctx.moveTo(0, h - 1);
ctx.lineTo(w, h - 1);
ctx.stroke();
// Walls (subtle)
ctx.lineWidth = 2;
ctx.globalAlpha = 0.5;
ctx.beginPath();
ctx.moveTo(1, 0);
ctx.lineTo(1, h);
ctx.moveTo(w - 1, 0);
ctx.lineTo(w - 1, h);
ctx.stroke();
ctx.globalAlpha = 1;
}
// ─── HUD Drawing ───────────────────────────────────────────────────
function drawHUD(ctx, w, fps, stepTimeMs, paused) {
ctx.font = '12px monospace';
ctx.fillStyle = 'rgba(255,255,255,0.6)';
const lines = [
`FPS: ${fps}`,
`Physics step: ${stepTimeMs.toFixed(1)}ms`,
paused ? '⏸ PAUSED' : '',
].filter(Boolean);
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], 12, 20 + i * 18);
}
}
};
// ── module: src/input.ts ──
__mods["src/input.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createInputState = createInputState;
exports.findNearestPoint = findNearestPoint;
exports.findNearestConstraint = findNearestConstraint;
exports.setupInput = setupInput;
exports.processInput = processInput;
const constants_1 = require("./constants");
const C = constants_1.CONSTANTS;
function createInputState() {
return {
mouseX: 0, mouseY: 0,
mouseDown: false, rightMouseDown: false,
shiftKey: false,
grabPointIdx: null,
dragVelX: 0, dragVelY: 0,
lastMouseX: 0, lastMouseY: 0,
};
}
// ─── Find Nearest Point ─────────────────────────────────────────────
function findNearestPoint(points, objects, x, y, radius) {
let bestIdx = -1;
let bestDistSq = radius * radius;
for (const obj of objects) {
for (let i = 0; i < obj.pointCount; i++) {
const p = points[obj.pointStart + i];
if (p.pinned)
continue; // Don't grab pinned points
const dx = p.x - x;
const dy = p.y - y;
const distSq = dx * dx + dy * dy;
if (distSq < bestDistSq) {
bestDistSq = distSq;
bestIdx = obj.pointStart + i;
}
}
}
return bestIdx >= 0 ? bestIdx : null;
}
// ─── Find Nearest Constraint for Tearing ────────────────────────────
function findNearestConstraint(constraints, points, x, y, radius) {
let bestIdx = -1;
let bestDistSq = radius * radius;
for (let i = 0; i < constraints.length; i++) {
const c = constraints[i];
if (c.broken)
continue;
// Distance from point to line segment
const pa = points[c.p0];
const pb = points[c.p1];
const dx = pb.x - pa.x;
const dy = pb.y - pa.y;
const lenSq = dx * dx + dy * dy;
if (lenSq < 1e-8)
continue;
// Project point onto line segment
let t = ((x - pa.x) * dx + (y - pa.y) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
const closestX = pa.x + t * dx;
const closestY = pa.y + t * dy;
const distDx = x - closestX;
const distDy = y - closestY;
const distSq = distDx * distDx + distDy * distDy;
if (distSq < bestDistSq) {
bestDistSq = distSq;
bestIdx = i;
}
}
return bestIdx >= 0 ? bestIdx : null;
}
// ─── Setup Event Listeners ──────────────────────────────────────────
function setupInput(canvas, input) {
const dpr = () => window.devicePixelRatio || 1;
// Mouse move
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
input.lastMouseX = input.mouseX;
input.lastMouseY = input.mouseY;
input.mouseX = e.clientX - rect.left;
input.mouseY = e.clientY - rect.top;
// Track drag velocity for throw
if (input.mouseDown && input.grabPointIdx !== null) {
input.dragVelX = input.mouseX - input.lastMouseX;
input.dragVelY = input.mouseY - input.lastMouseY;
}
});
// Mouse down (left click)
canvas.addEventListener('mousedown', (e) => {
if (e.button === 0) {
input.mouseDown = true;
}
else if (e.button === 2) {
input.rightMouseDown = true;
}
});
// Mouse up
window.addEventListener('mouseup', (e) => {
if (e.button === 0) {
input.mouseDown = false;
input.grabPointIdx = null;
}
else if (e.button === 2) {
input.rightMouseDown = false;
}
});
// Prevent context menu on right click
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
// Keyboard modifiers
window.addEventListener('keydown', (e) => {
if (e.key === 'Shift')
input.shiftKey = true;
});
window.addEventListener('keyup', (e) => {
if (e.key === 'Shift')
input.shiftKey = false;
});
// Touch support
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
const touch = e.touches[0];
const rect = canvas.getBoundingClientRect();
input.mouseX = touch.clientX - rect.left;
input.mouseY = touch.clientY - rect.top;
input.lastMouseX = input.mouseX;
input.lastMouseY = input.mouseY;
input.mouseDown = true;
}, { passive: false });
canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
const touch = e.touches[0];
const rect = canvas.getBoundingClientRect();
input.lastMouseX = input.mouseX;
input.lastMouseY = input.mouseY;
input.mouseX = touch.clientX - rect.left;
input.mouseY = touch.clientY - rect.top;
if (input.grabPointIdx !== null) {
input.dragVelX = input.mouseX - input.lastMouseX;
input.dragVelY = input.mouseY - input.lastMouseY;
}
}, { passive: false });
canvas.addEventListener('touchend', () => {
input.mouseDown = false;
input.grabPointIdx = null;
});
}
// ─── Process Input Each Frame ──────────────────────────────────────
function processInput(input, points, constraints) {
// Grab logic (left click)
if (input.mouseDown && input.grabPointIdx === null) {
const idx = findNearestPoint(points, [], input.mouseX, input.mouseY, C.grabRadius);
// We need to pass objects but we'll handle it in main loop instead
// For now, just store the position for later use
}
// Tear logic (right click or shift+drag)
if (input.rightMouseDown || (input.shiftKey && input.mouseDown)) {
const idx = findNearestConstraint(constraints, points, input.mouseX, input.mouseY, C.tearRadius);
if (idx !== null) {
constraints[idx].broken = true;
}
}
// Apply grab position to point
if (input.grabPointIdx !== null && input.grabPointIdx < points.length) {
const p = points[input.grabPointIdx];
p.x = input.mouseX;
p.y = input.mouseY;
p.prevX = input.mouseX - input.dragVelX * 3; // Throw velocity multiplier
p.prevY = input.mouseY - input.dragVelY * 3;
}
}
};
// ── module: src/ui.ts ──
__mods["src/ui.ts"] = function (exports, require, module) {
"use strict";
// ─── Toolbar & Controls UI ──────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.createToolbar = createToolbar;
exports.setupToolbarEvents = setupToolbarEvents;
const TOOL_DEFS = [
{ id: 'rope', label: '🪢 Rope', title: 'Click-drag to draw a rope' },
{ id: 'cloth', label: '🧵 Cloth', title: 'Click to place cloth' },
{ id: 'softbody', label: '🫧 Soft Body', title: 'Click to spawn soft body' },
{ id: 'ball', label: '⚽ Ball', title: 'Click to drop a ball' },
];
function createToolbar() {
const container = document.createElement('div');
container.id = 'toolbar';
// Tool buttons
for (const tool of TOOL_DEFS) {
const btn = document.createElement('button');
btn.className = 'tool-btn';
btn.dataset.tool = tool.id;
btn.textContent = tool.label;
btn.title = tool.title;
container.appendChild(btn);
}
// Separator
const sep = document.createElement('div');
sep.className = 'toolbar-sep';
container.appendChild(sep);
// Gravity slider
const gravLabel = document.createElement('label');
gravLabel.textContent = 'Gravity';
gravLabel.title = 'Toggle and adjust gravity strength';
container.appendChild(gravLabel);
const gravSlider = document.createElement('input');
gravSlider.type = 'range';
gravSlider.min = '0';
gravSlider.max = '2000';
gravSlider.value = '980';
gravSlider.step = '10';
gravSlider.className = 'slider';
container.appendChild(gravSlider);
// Iterations slider
const iterLabel = document.createElement('label');
iterLabel.textContent = 'Iterations';
iterLabel.title = 'Constraint solver iterations (higher = more stable)';
container.appendChild(iterLabel);
const iterSlider = document.createElement('input');
iterSlider.type = 'range';
iterSlider.min = '1';
iterSlider.max = '12';
iterSlider.value = '5';
iterSlider.step = '1';
iterSlider.className = 'slider';
container.appendChild(iterSlider);
// Stress toggle
const stressLabel = document.createElement('label');
stressLabel.textContent = 'Stress';
stressLabel.title = 'Color constraints by stretch ratio';
container.appendChild(stressLabel);
const stressToggle = document.createElement('input');
stressToggle.type = 'checkbox';
stressToggle.className = 'toggle';
container.appendChild(stressToggle);
// Pause button
const pauseBtn = document.createElement('button');
pauseBtn.className = 'tool-btn';
pauseBtn.id = 'pause-btn';
pauseBtn.textContent = '⏸';
pauseBtn.title = 'Pause (Space)';
container.appendChild(pauseBtn);
// Step button
const stepBtn = document.createElement('button');
stepBtn.className = 'tool-btn';
stepBtn.id = 'step-btn';
stepBtn.textContent = '⏭';
stepBtn.title = 'Step one frame (when paused)';
container.appendChild(stepBtn);
// Reset button
const resetBtn = document.createElement('button');
resetBtn.className = 'tool-btn';
resetBtn.id = 'reset-btn';
resetBtn.textContent = '🔄';
resetBtn.title = 'Reset simulation';
container.appendChild(resetBtn);
return { container, selectedTool: 'rope', gravitySlider: gravSlider, iterationsSlider: iterSlider, stressToggle };
}
function setupToolbarEvents(toolbar) {
// Tool selection
toolbar.container.addEventListener('click', (e) => {
const btn = e.target.closest('.tool-btn');
if (!btn || !btn.dataset.tool)
return;
// Deselect all
toolbar.container.querySelectorAll('.tool-btn[data-tool]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
toolbar.selectedTool = btn.dataset.tool;
});
// Select first tool by default
const firstBtn = toolbar.container.querySelector('.tool-btn[data-tool]');
if (firstBtn)
firstBtn.classList.add('active');
}
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"thinkingcap-qwen3.6-27b","provider":"lmstudio","persona":"typescript","sessionId":"8ef7bf8f-6408-4c6c-bc7b-462c1c8a9b32","tokensIn":2215468,"tokensOut":35367,"tokensTotal":2250835,"cost":0,"turns":67,"toolCalls":68,"failedToolCalls":0,"timestamp":"2026-07-31T20:19:48.800Z"} -->