Physics Playground
Qwen 3.6 27B MTP GGUF · 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 67,140 bytes · SHA-256 44d5b268f938
<!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;
}
body {
background: #0f1117;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', monospace;
color: #e5e7eb;
}
#app {
width: 100vw;
height: 100vh;
position: relative;
}
#canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
#toolbar {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: rgba(15, 17, 23, 0.85);
border: 1px solid rgba(107, 114, 128, 0.3);
border-radius: 12px;
backdrop-filter: blur(8px);
z-index: 10;
flex-wrap: wrap;
justify-content: center;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 4px;
}
.toolbar-group.sliders {
gap: 10px;
}
.toolbar-group + .toolbar-group {
border-left: 1px solid rgba(107, 114, 128, 0.2);
padding-left: 8px;
}
button {
background: rgba(55, 58, 70, 0.6);
border: 1px solid rgba(107, 114, 128, 0.3);
border-radius: 8px;
color: #e5e7eb;
padding: 6px 10px;
font-size: 16px;
cursor: pointer;
transition: all 0.15s;
line-height: 1;
}
button:hover {
background: rgba(75, 80, 100, 0.8);
border-color: rgba(156, 163, 175, 0.5);
}
button.active {
background: rgba(99, 102, 241, 0.4);
border-color: rgba(129, 140, 248, 0.6);
box-shadow: 0 0 8px rgba(99, 102, 241, 0.3);
}
button.active-stress {
background: rgba(239, 68, 68, 0.3);
border-color: rgba(239, 68, 68, 0.6);
}
label {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #9ca3af;
cursor: pointer;
}
label span {
white-space: nowrap;
}
input[type="range"] {
width: 60px;
height: 4px;
appearance: none;
background: rgba(107, 114, 128, 0.3);
border-radius: 2px;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #6366f1;
cursor: pointer;
}
input[type="checkbox"] {
accent-color: #6366f1;
}
.toolbar-hint {
width: 100%;
text-align: center;
font-size: 10px;
color: #4b5563;
margin-top: 4px;
letter-spacing: 0.3px;
}
#spawn-preview {
position: absolute;
pointer-events: none;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 5;
}
</style>
</head>
<body>
<div id="app">
<canvas id="canvas" width="1280" height="720"></canvas>
<div id="toolbar">
<div class="toolbar-group">
<button id="btn-rope" title="Spawn Rope (R)">🪢</button>
<button id="btn-cloth" title="Spawn Cloth (C)">🧥</button>
<button id="btn-softbody" title="Spawn Soft Body (S)">🟣</button>
<button id="btn-ball" title="Spawn Ball (B)">⚽</button>
</div>
<div class="toolbar-group">
<button id="btn-pinmode" title="Pin Mode (P)">📌 Corners</button>
</div>
<div class="toolbar-group">
<button id="btn-pause" title="Pause (Space)">⏸</button>
<button id="btn-step" title="Step (→)">⏭</button>
<button id="btn-reset" title="Reset All (Esc)">🗑</button>
</div>
<div class="toolbar-group sliders">
<label title="Toggle Gravity (G)">
<input type="checkbox" id="chk-gravity" checked="">
<span>Gravity</span>
</label>
<label title="Drag strength">
<span>G:</span>
<input type="range" id="slider-gravity" min="0" max="2000" value="980" step="10">
</label>
<label title="Solver iterations">
<span>Iter:</span>
<input type="range" id="slider-iterations" min="1" max="20" value="6" step="1">
</label>
<label title="Damping">
<span>Damp:</span>
<input type="range" id="slider-damping" min="0" max="100" value="2" step="1">
</label>
</div>
<div class="toolbar-group">
<button id="btn-stress" title="Stress Visual (V)">🌡</button>
<button id="btn-points" title="Show Points (T)">🔵</button>
<button id="btn-blur" title="Motion Blur (M)">✨</button>
</div>
<div class="toolbar-hint">
<span>Click: grab | Right-drag: tear | Shift+click: pin/unpin | Draw to spawn</span>
</div>
</div>
<div id="spawn-preview"></div>
</div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./config":"src/config.ts","./point":"src/point.ts","./constraint":"src/constraint.ts","./spawner":"src/spawner.ts","./engine":"src/engine.ts","./renderer":"src/renderer.ts","./interaction":"src/interaction.ts"},"src/constraint.ts":{"./point":"src/point.ts"},"src/spawner.ts":{"./point":"src/point.ts","./constraint":"src/constraint.ts"},"src/engine.ts":{"./point":"src/point.ts","./constraint":"src/constraint.ts","./config":"src/config.ts"},"src/renderer.ts":{"./point":"src/point.ts","./constraint":"src/constraint.ts","./spawner":"src/spawner.ts","./engine":"src/engine.ts","./config":"src/config.ts"},"src/interaction.ts":{"./point":"src/point.ts","./constraint":"src/constraint.ts","./spawner":"src/spawner.ts","./config":"src/config.ts"}};
function __require(id) {
if (__cache[id]) return __cache[id].exports;
var module = __cache[id] = { exports: {} };
var factory = __mods[id];
if (!factory) throw new Error("Module not found: " + id);
factory(module.exports, function (spec) {
var target = (__map[id] && __map[id][spec]) || spec;
return __require(target);
}, module);
return module.exports;
}
// ── module: src/main.ts ──
__mods["src/main.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// Main — Application entry point, wiring everything together
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.getSim = getSim;
const config_1 = require("./config");
const point_1 = require("./point");
const spawner_1 = require("./spawner");
const engine_1 = require("./engine");
const renderer_1 = require("./renderer");
const interaction_1 = require("./interaction");
// ---- Application State ----
let app = null;
function init() {
const canvas = document.getElementById('canvas');
if (!canvas) {
console.error('Canvas not found');
return;
}
// Size canvas to viewport (no DPR scaling to avoid coordinate confusion)
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
const engine = (0, engine_1.createEngine)(canvas.width, canvas.height);
const renderer = (0, renderer_1.createRenderer)(canvas);
const interaction = (0, interaction_1.createInteraction)(canvas);
// ---- Static geometry ----
const w = canvas.width, h = canvas.height;
// Floor bump (circle) — cloth will drape over this
engine.staticCircles.push({ x: w / 2, y: h - 130, radius: 55 });
// Left obstacle
engine.staticCircles.push({ x: w * 0.2, y: h - 200, radius: 30 });
// Right obstacle
engine.staticCircles.push({ x: w * 0.8, y: h - 240, radius: 25 });
// Platform
engine.staticBoxes.push({ x: w * 0.6, y: h - 55, width: 140, height: 10 });
// ---- Spawn default objects ----
const objects = [];
// Rope: pinned at top, hanging
const rope = (0, spawner_1.spawnRope)({ x: w * 0.25, y: 60 }, { x: w * 0.25, y: 350 }, config_1.CONFIG.ropePoints, engine.points, config_1.CONFIG.colorRope);
objects.push(rope);
// Cloth: pinned at both corners
const cloth = (0, spawner_1.spawnCloth)({ x: w * 0.35, y: 60 }, config_1.CONFIG.clothCols, config_1.CONFIG.clothRows, 10, 'corners', engine.points, engine.constraints, config_1.CONFIG.colorCloth);
objects.push(cloth);
// Soft body
const softBody = (0, spawner_1.spawnSoftBody)({ x: w * 0.7, y: 180 }, 55, config_1.CONFIG.softBodyPoints, engine.points, engine.constraints, config_1.CONFIG.colorSoftBody);
objects.push(softBody);
// Ball
const ball = (0, spawner_1.spawnBall)({ x: w * 0.55, y: 120 }, 14, engine.points, config_1.CONFIG.colorBall);
objects.push(ball);
app = {
canvas, engine, renderer, interaction, objects,
spawnMode: 'none',
lastTime: performance.now(),
frameCount: 0, fpsTime: performance.now(),
};
// ---- Interaction callbacks ----
interaction.onSpawn = (type, pos, end) => {
spawnObject(type, pos, end);
};
interaction.onPinToggle = (point) => {
point.pinned = !point.pinned;
if (point.pinned)
(0, point_1.setVelocity)(point, 0, 0, config_1.CONFIG.physicsDt);
};
// ---- Events ----
canvas.addEventListener('mousedown', (e) => (0, interaction_1.handleMouseDown)(interaction, engine.points, e));
canvas.addEventListener('mousemove', (e) => (0, interaction_1.handleMouseMove)(interaction, engine.points, e));
canvas.addEventListener('mouseup', (e) => {
(0, interaction_1.handleMouseUp)(interaction, engine.points, e);
processTearNow();
});
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
window.addEventListener('keydown', handleKeyDown);
setupToolbar();
requestAnimationFrame(gameLoop);
}
function spawnObject(type, pos, end) {
if (!app)
return;
let obj;
switch (type) {
case 'rope': {
obj = (0, spawner_1.spawnRope)(pos, end || { x: pos.x, y: pos.y + 200 }, config_1.CONFIG.ropePoints, app.engine.points, config_1.CONFIG.colorRope);
if (obj.points.length > 0) {
obj.points[0].pinned = true;
(0, point_1.setVelocity)(obj.points[0], 0, 0, config_1.CONFIG.physicsDt);
}
break;
}
case 'cloth': {
const width = end ? Math.abs(end.x - pos.x) : 180;
const height = end ? Math.abs(end.y - pos.y) : 130;
const cols = Math.max(5, Math.min(40, Math.floor(width / 8)));
const rows = Math.max(3, Math.min(30, Math.floor(height / 8)));
const spacing = Math.max(6, width / cols);
obj = (0, spawner_1.spawnCloth)(pos, cols, rows, spacing, app.interaction.pinMode, app.engine.points, app.engine.constraints, config_1.CONFIG.colorCloth);
break;
}
case 'softBody': {
obj = (0, spawner_1.spawnSoftBody)(pos, 45 + Math.random() * 25, config_1.CONFIG.softBodyPoints, app.engine.points, app.engine.constraints, config_1.CONFIG.colorSoftBody);
break;
}
case 'ball': {
obj = (0, spawner_1.spawnBall)(pos, 8 + Math.random() * 10, app.engine.points, config_1.CONFIG.colorBall);
break;
}
default: return;
}
app.objects.push(obj);
}
function processTearNow() {
if (!app)
return;
const path = app.interaction.tearPath;
if (path.length < 2)
return;
for (let i = 1; i < path.length; i++) {
(0, interaction_1.tearConstraintsNearSegment)(app.engine.constraints, app.engine.points, path[i - 1].x, path[i - 1].y, path[i].x, path[i].y);
}
for (let i = 0; i < path.length; i += 3) {
app.interaction.tearEffects.push({ x: path[i].x, y: path[i].y, life: 1.0 });
}
app.interaction.tearPath = [];
}
function handleKeyDown(e) {
if (!app)
return;
switch (e.key) {
case ' ':
e.preventDefault();
app.engine.paused = !app.engine.paused;
break;
case 'ArrowRight':
e.preventDefault();
if (app.engine.paused)
app.engine.stepOne();
break;
case 'Escape':
resetAll();
break;
case 'g':
case 'G':
config_1.CONFIG.gravityOn = !config_1.CONFIG.gravityOn;
app.engine.gravityOn = config_1.CONFIG.gravityOn;
const chk = document.getElementById('chk-gravity');
if (chk)
chk.checked = config_1.CONFIG.gravityOn;
break;
case 'r':
case 'R':
setSpawnMode('rope');
break;
case 'c':
case 'C':
setSpawnMode('cloth');
break;
case 's':
case 'S':
setSpawnMode('softBody');
break;
case 'b':
case 'B':
setSpawnMode('ball');
break;
case 'p':
case 'P':
cyclePinMode();
break;
case 'v':
case 'V':
config_1.CONFIG.showStress = !config_1.CONFIG.showStress;
const btnS = document.getElementById('btn-stress');
if (btnS)
btnS.classList.toggle('active', config_1.CONFIG.showStress);
break;
case 't':
case 'T':
config_1.CONFIG.showPoints = !config_1.CONFIG.showPoints;
const btnP = document.getElementById('btn-points');
if (btnP)
btnP.classList.toggle('active', config_1.CONFIG.showPoints);
break;
case 'm':
case 'M':
config_1.CONFIG.motionBlur = !config_1.CONFIG.motionBlur;
const btnB = document.getElementById('btn-blur');
if (btnB)
btnB.classList.toggle('active', config_1.CONFIG.motionBlur);
break;
}
}
function setSpawnMode(mode) {
if (!app)
return;
app.spawnMode = mode;
app.interaction.spawnMode = mode;
app.canvas.style.cursor = mode !== 'none' ? 'crosshair' : 'default';
['btn-rope', 'btn-cloth', 'btn-softbody', 'btn-ball'].forEach((id) => {
const btn = document.getElementById(id);
if (btn)
btn.classList.remove('active');
});
const map = { rope: 'btn-rope', cloth: 'btn-cloth', softBody: 'btn-softbody', ball: 'btn-ball' };
if (map[mode]) {
const btn = document.getElementById(map[mode]);
if (btn)
btn.classList.add('active');
}
}
function cyclePinMode() {
if (!app)
return;
app.interaction.pinMode = app.interaction.pinMode === 'corners' ? 'full' : 'corners';
const btn = document.getElementById('btn-pinmode');
if (btn)
btn.textContent = `📌 ${app.interaction.pinMode === 'corners' ? 'Corners' : 'Full'}`;
}
function resetAll() {
if (!app)
return;
app.engine.points = [];
app.engine.constraints = [];
app.objects = [];
app.spawnMode = 'none';
app.interaction.spawnMode = 'none';
(0, engine_1.resetAccumulator)();
setSpawnMode('none');
}
function setupToolbar() {
document.getElementById('btn-rope')?.addEventListener('click', () => setSpawnMode('rope'));
document.getElementById('btn-cloth')?.addEventListener('click', () => setSpawnMode('cloth'));
document.getElementById('btn-softbody')?.addEventListener('click', () => setSpawnMode('softBody'));
document.getElementById('btn-ball')?.addEventListener('click', () => setSpawnMode('ball'));
document.getElementById('btn-pinmode')?.addEventListener('click', cyclePinMode);
document.getElementById('btn-pause')?.addEventListener('click', () => { if (app)
app.engine.paused = !app.engine.paused; });
document.getElementById('btn-step')?.addEventListener('click', () => { if (app && app.engine.paused)
app.engine.stepOne(); });
document.getElementById('btn-reset')?.addEventListener('click', resetAll);
const chk = document.getElementById('chk-gravity');
chk?.addEventListener('change', (e) => { config_1.CONFIG.gravityOn = e.target.checked; if (app)
app.engine.gravityOn = config_1.CONFIG.gravityOn; });
const sGrav = document.getElementById('slider-gravity');
sGrav?.addEventListener('input', (e) => { const v = parseFloat(e.target.value); config_1.CONFIG.gravity = v; if (app)
app.engine.gravity = v; });
const sIter = document.getElementById('slider-iterations');
sIter?.addEventListener('input', (e) => { const v = parseInt(e.target.value); config_1.CONFIG.iterations = v; if (app)
app.engine.iterations = v; });
const sDamp = document.getElementById('slider-damping');
sDamp?.addEventListener('input', (e) => { const v = parseInt(e.target.value); config_1.CONFIG.damping = v / 10000; if (app)
app.engine.damping = v / 10000; });
document.getElementById('btn-stress')?.addEventListener('click', () => { config_1.CONFIG.showStress = !config_1.CONFIG.showStress; const b = document.getElementById('btn-stress'); if (b)
b.classList.toggle('active', config_1.CONFIG.showStress); });
document.getElementById('btn-points')?.addEventListener('click', () => { config_1.CONFIG.showPoints = !config_1.CONFIG.showPoints; const b = document.getElementById('btn-points'); if (b)
b.classList.toggle('active', config_1.CONFIG.showPoints); });
document.getElementById('btn-blur')?.addEventListener('click', () => { config_1.CONFIG.motionBlur = !config_1.CONFIG.motionBlur; const b = document.getElementById('btn-blur'); if (b)
b.classList.toggle('active', config_1.CONFIG.motionBlur); });
}
function gameLoop(timestamp) {
if (!app)
return;
const { engine, renderer, interaction } = app;
app.frameCount++;
if (timestamp - app.fpsTime >= 1000) {
renderer.fps = app.frameCount;
app.frameCount = 0;
app.fpsTime = timestamp;
}
// Sync config ↔ engine
config_1.CONFIG.gravity = engine.gravity;
config_1.CONFIG.iterations = engine.iterations;
config_1.CONFIG.damping = engine.damping;
config_1.CONFIG.gravityOn = engine.gravityOn;
config_1.CONFIG.windX = engine.windX;
config_1.CONFIG.windY = engine.windY;
if (!engine.paused) {
const now = performance.now();
let delta = now - app.lastTime;
if (delta > 100)
delta = 100;
app.lastTime = now;
const t0 = performance.now();
(0, engine_1.tick)(engine, delta);
renderer.stepTimeMs = performance.now() - t0;
}
else {
app.lastTime = performance.now();
renderer.stepTimeMs = 0;
}
(0, interaction_1.updateTearEffects)(interaction, 1 / 60);
renderer.pointCount = engine.points.length;
renderer.constraintCount = engine.constraints.filter(c => c.active).length;
renderer.paused = engine.paused;
(0, renderer_1.render)(renderer, engine.points, engine.constraints, app.objects, engine.staticCircles, engine.staticBoxes, interaction.grabPoint, interaction.grabPoint ? { x: interaction.lastMousePos.x, y: interaction.lastMousePos.y } : null, interaction.tearEffects.map(e => ({ x: e.x, y: e.y })), interaction.spawnMode !== 'none' && interaction.dragStart && interaction.dragCurrent ? {
start: interaction.dragStart,
end: interaction.dragCurrent,
mode: interaction.spawnMode,
} : undefined);
requestAnimationFrame(gameLoop);
}
// ---- Expose for headless testing ----
function getSim() {
if (!app)
return null;
const e = app.engine;
return {
points: e.points, constraints: e.constraints,
step: () => e.stepOne(),
getTotalKineticEnergy: () => e.getTotalKineticEnergy(),
hasNaN: () => e.hasNaN(),
isOutOfBounds: () => e.isOutOfBounds(),
objects: app.objects,
paused: () => e.paused,
pause: (v) => { e.paused = v; },
};
}
// Auto-run
init();
// Expose sim on window
window.sim = () => getSim();
};
// ── module: src/config.ts ──
__mods["src/config.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// Tunable Constants
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.CONFIG = void 0;
exports.CONFIG = {
physicsDt: 1 / 60,
maxDt: 0.2,
damping: 0.001,
iterations: 6,
gravity: 980,
gravityOn: true,
windX: 0,
windY: 0,
grabRadius: 20,
tearRadius: 18,
throwFactor: 200,
pointRadius: 2.5,
showPoints: false,
showStress: false,
motionBlur: true,
motionBlurAlpha: 0.15,
colorRope: '#ff6b6b',
colorCloth: '#4ecdc4',
colorSoftBody: '#a78bfa',
colorBall: '#fbbf24',
colorStatic: '#6b7280',
colorPin: '#f472b6',
colorTear: '#ff4444',
bgColor: '#0f1117',
gridColor: '#1a1d27',
stressGreen: '#22c55e',
stressRed: '#ef4444',
wallMargin: 5,
ropePoints: 30,
clothCols: 20,
clothRows: 15,
softBodyPoints: 12,
};
};
// ── module: src/point.ts ──
__mods["src/point.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// Point — a single Verlet particle
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.createPoint = createPoint;
exports.clonePoint = clonePoint;
exports.getVelocity = getVelocity;
exports.setVelocity = setVelocity;
exports.kineticEnergy = kineticEnergy;
let _nextId = 0;
function createPoint(x, y, pinned = false, radius = 0, mass = 1) {
return {
x, y,
px: x, py: y, // start at rest
pinned,
radius,
mass,
id: _nextId++,
};
}
function clonePoint(p) {
return { ...p, id: _nextId++ };
}
/** Compute current velocity (for throw/interaction) */
function getVelocity(p, dt) {
if (dt === 0)
return { vx: 0, vy: 0 };
return {
vx: (p.x - p.px) / dt,
vy: (p.y - p.py) / dt,
};
}
function setVelocity(p, vx, vy, dt) {
p.px = p.x - vx * dt;
p.py = p.y - vy * dt;
}
function kineticEnergy(p, dt) {
const v = getVelocity(p, dt);
return 0.5 * p.mass * (v.vx * v.vx + v.vy * v.vy);
}
};
// ── module: src/constraint.ts ──
__mods["src/constraint.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// Constraints — distance constraints with stretch tracking
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.createConstraint = createConstraint;
exports.resolveConstraint = resolveConstraint;
function createConstraint(a, b, restLength, stiffness = 1.0) {
return { a, b, restLength, stiffness, active: true, stretchRatio: 1 };
}
/** Resolve a single distance constraint — returns true if resolved */
function resolveConstraint(constraint, points, damping) {
if (!constraint.active)
return false;
if (constraint.a >= points.length || constraint.b >= points.length)
return false;
const pa = points[constraint.a];
const pb = points[constraint.b];
const dx = pb.x - pa.x;
const dy = pb.y - pa.y;
const dist = Math.sqrt(dx * dx + dy * dy);
// Guard: zero-length link → skip (both points at same location, no direction)
if (dist < 1e-8)
return false;
// Track stretch for visualization
constraint.stretchRatio = constraint.restLength > 1e-8 ? dist / constraint.restLength : 1;
// How far from rest length
const diff = (dist - constraint.restLength) / dist;
const offset = diff * 0.5 * constraint.stiffness;
// If both pinned, nothing to do
const aPinned = pa.pinned;
const bPinned = pb.pinned;
if (aPinned && bPinned)
return true;
const totalMass = pa.mass + pb.mass;
const massA = pa.mass / totalMass;
const massB = pb.mass / totalMass;
// Apply Verlet-style displacement
if (!aPinned) {
const mx = dx * offset * massA;
const my = dy * offset * massA;
pa.x += mx;
pa.y += my;
}
if (!bPinned) {
const mx = dx * offset * massB;
const my = dy * offset * massB;
pb.x -= mx;
pb.y -= my;
}
// Apply damping to relative velocity along constraint axis
if (damping > 0) {
if (!aPinned) {
pa.x += (pa.x - pa.px) * -damping;
pa.y += (pa.y - pa.py) * -damping;
}
if (!bPinned) {
pb.x += (pb.x - pb.px) * -damping;
pb.y += (pb.y - pb.py) * -damping;
}
}
return true;
}
};
// ── module: src/spawner.ts ──
__mods["src/spawner.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// Physics Object — a collection of points + constraints with metadata
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.spawnRope = spawnRope;
exports.spawnCloth = spawnCloth;
exports.spawnSoftBody = spawnSoftBody;
exports.spawnBall = spawnBall;
const point_1 = require("./point");
const constraint_1 = require("./constraint");
/**
* Spawn a rope: chain of N points pinned at the first one
*/
function spawnRope(start, end, numPoints, globalPoints, color) {
const points = [];
const constraints = [];
const dx = (end.x - start.x) / (numPoints - 1);
const dy = (end.y - start.y) / (numPoints - 1);
for (let i = 0; i < numPoints; i++) {
points.push((0, point_1.createPoint)(start.x + dx * i, start.y + dy * i, i === 0));
}
for (let i = 0; i < numPoints - 1; i++) {
const pA = points[i];
const pB = points[i + 1];
const dist = Math.sqrt((pB.x - pA.x) ** 2 + (pB.y - pA.y) ** 2);
constraints.push((0, constraint_1.createConstraint)(i, i + 1, dist));
}
// Register into global array
const offset = globalPoints.length;
for (const p of points)
globalPoints.push(p);
// Fix constraint indices
for (const c of constraints) {
c.a += offset;
c.b += offset;
}
return { type: 'rope', points, constraints, offsetInPoints: offset, color };
}
/**
* Spawn cloth: grid of points with structural + shear constraints
*/
function spawnCloth(origin, cols, rows, spacing, pinMode, globalPoints, globalConstraints, color) {
const points = [];
// Create points in a grid
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const x = origin.x + col * spacing;
const y = origin.y + row * spacing;
let pinned = false;
if (pinMode === 'corners') {
pinned = (row === 0) && (col === 0 || col === cols - 1);
}
else {
pinned = row === 0;
}
points.push((0, point_1.createPoint)(x, y, pinned));
}
}
const constraints = [];
// Structural constraints (horizontal + vertical neighbors)
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const idx = row * cols + col;
if (col > 0) {
constraints.push((0, constraint_1.createConstraint)(idx, idx - 1, spacing));
}
if (row > 0) {
constraints.push((0, constraint_1.createConstraint)(idx, idx - cols, spacing));
}
}
}
// Shear constraints (diagonals)
for (let row = 0; row < rows - 1; row++) {
for (let col = 0; col < cols - 1; col++) {
const idx = row * cols + col;
constraints.push((0, constraint_1.createConstraint)(idx, idx + cols + 1, spacing * Math.SQRT2, 0.5));
constraints.push((0, constraint_1.createConstraint)(idx + 1, idx + cols, spacing * Math.SQRT2, 0.5));
}
}
// Register into global arrays
const offset = globalPoints.length;
for (const p of points)
globalPoints.push(p);
const constraintOffset = globalConstraints.length;
for (const c of constraints) {
c.a += offset;
c.b += offset;
globalConstraints.push(c);
}
return {
type: 'cloth',
points,
constraints,
offsetInPoints: offset,
color,
clothRect: { cols, rows },
};
}
/**
* Spawn a soft body (circle/blob): ring of points with internal braces
*/
function spawnSoftBody(center, radius, numPoints, globalPoints, globalConstraints, color) {
const points = [];
// Create points in a circle
for (let i = 0; i < numPoints; i++) {
const angle = (2 * Math.PI * i) / numPoints - Math.PI / 2;
points.push((0, point_1.createPoint)(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle)));
}
const constraints = [];
// Edge constraints (ring)
for (let i = 0; i < numPoints; i++) {
const next = (i + 1) % numPoints;
const pa = points[i];
const pb = points[next];
const dist = Math.sqrt((pb.x - pa.x) ** 2 + (pb.y - pa.y) ** 2);
constraints.push((0, constraint_1.createConstraint)(i, next, dist));
}
// Internal brace constraints (star from center, plus some cross-braces)
// Virtual center: use average position constraint
// Instead, add cross-braces: point[i] to point[i + numPoints/2]
const half = Math.floor(numPoints / 2);
for (let i = 0; i < numPoints; i++) {
constraints.push((0, constraint_1.createConstraint)(i, (i + half) % numPoints, radius * 2, 0.6));
}
// Add a few more internal braces for shape stability
for (let i = 0; i < numPoints; i++) {
const skip = Math.floor(numPoints / 3);
constraints.push((0, constraint_1.createConstraint)(i, (i + skip) % numPoints, radius * Math.sin(Math.PI * skip / numPoints) * 2, 0.4));
}
// Register into global arrays
const offset = globalPoints.length;
for (const p of points)
globalPoints.push(p);
const constraintOffset = globalConstraints.length;
for (const c of constraints) {
c.a += offset;
c.b += offset;
globalConstraints.push(c);
}
return {
type: 'softBody',
points,
constraints,
offsetInPoints: offset,
color,
};
}
/**
* Spawn a ball: single point with radius
*/
function spawnBall(pos, ballRadius, globalPoints, color) {
const points = [(0, point_1.createPoint)(pos.x, pos.y, false, ballRadius)];
const constraints = [];
const offset = globalPoints.length;
for (const p of points)
globalPoints.push(p);
return { type: 'ball', points, constraints, offsetInPoints: offset, color };
}
};
// ── module: src/engine.ts ──
__mods["src/engine.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// Physics Engine — Verlet integration + constraint relaxation
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.createEngine = createEngine;
exports.tick = tick;
exports.resetAccumulator = resetAccumulator;
const point_1 = require("./point");
const constraint_1 = require("./constraint");
const config_1 = require("./config");
let _accumulator = 0;
function createEngine(width, height) {
const engine = {
points: [],
constraints: [],
staticCircles: [],
staticBoxes: [],
width,
height,
paused: false,
damping: config_1.CONFIG.damping,
iterations: config_1.CONFIG.iterations,
gravity: config_1.CONFIG.gravity,
gravityOn: config_1.CONFIG.gravityOn,
windX: config_1.CONFIG.windX,
windY: config_1.CONFIG.windY,
step: () => step(engine),
stepOne: () => step(engine),
getTotalKineticEnergy: () => getTotalKineticEnergy(engine),
hasNaN: () => hasNaN(engine),
isOutOfBounds: () => isOutOfBounds(engine),
};
return engine;
}
function step(engine) {
const dt = config_1.CONFIG.physicsDt;
const dt2 = dt * dt;
const gravity = engine.gravityOn ? engine.gravity : 0;
const damping = engine.damping;
const iterations = engine.iterations;
const points = engine.points;
const constraints = engine.constraints;
const margin = config_1.CONFIG.wallMargin;
const floorY = engine.height - margin;
const ceilY = margin;
const wallL = margin;
const wallR = engine.width - margin;
const friction = 0.3;
// ---- 1. Verlet Integration ----
for (let i = 0; i < points.length; i++) {
const p = points[i];
if (p.pinned)
continue;
const vx = p.x - p.px;
const vy = p.y - p.py;
p.px = p.x;
p.py = p.y;
p.x += vx * (1 - damping) + engine.windX * dt2;
p.y += vy * (1 - damping) + gravity * dt2 + engine.windY * dt2;
// NaN guard
if (!isFinite(p.x) || !isFinite(p.y)) {
p.x = p.px;
p.y = p.py;
p.px = p.x;
p.py = p.y;
}
}
// ---- 2. Constraint Solving (iterative relaxation) ----
for (let iter = 0; iter < iterations; iter++) {
// Distance constraints
for (let j = 0; j < constraints.length; j++) {
(0, constraint_1.resolveConstraint)(constraints[j], points, 0);
}
// Wall/floor collisions
for (let i = 0; i < points.length; i++) {
const p = points[i];
const rad = p.radius || 0;
const vx = p.x - p.px;
const vy = p.y - p.py;
// Floor
if (p.y + rad > floorY) {
p.y = floorY - rad;
const correction = p.y - p.py;
p.py = p.y - correction;
// Friction on floor contact
p.px = p.x - vx * (1 - friction);
}
// Ceiling
if (p.y - rad < ceilY) {
p.y = ceilY + rad;
const correction = p.y - p.py;
p.py = p.y - correction;
}
// Left wall
if (p.x - rad < wallL) {
p.x = wallL + rad;
const correction = p.x - p.px;
p.px = p.x - correction;
p.py = p.y - vy * (1 - friction);
}
// Right wall
if (p.x + rad > wallR) {
p.x = wallR - rad;
const correction = p.x - p.px;
p.px = p.x - correction;
p.py = p.y - vy * (1 - friction);
}
// Static circle collisions
for (const c of engine.staticCircles) {
resolvePointCircleCollision(p, c, friction);
}
// Static box collisions
for (const b of engine.staticBoxes) {
resolvePointBoxCollision(p, b, friction);
}
}
}
// ---- 3. Final NaN guard & bounds clamp ----
for (let i = 0; i < points.length; i++) {
const p = points[i];
if (!isFinite(p.x) || !isFinite(p.y)) {
p.x = engine.width / 2;
p.y = engine.height / 2;
p.px = p.x;
p.py = p.y;
}
// Hard clamp to prevent runaway
const bound = Math.max(engine.width, engine.height) * 2;
if (p.x < -bound)
p.x = -bound;
if (p.x > engine.width + bound)
p.x = engine.width + bound;
if (p.y < -bound)
p.y = -bound;
if (p.y > engine.height + bound)
p.y = engine.height + bound;
}
}
function resolvePointCircleCollision(p, circle, friction) {
if (p.pinned)
return;
const dx = p.x - circle.x;
const dy = p.y - circle.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const minDist = circle.radius + (p.radius || 0);
if (dist < minDist && dist > 1e-8) {
const vx = p.x - p.px;
const vy = p.y - p.py;
const nx = dx / dist;
const ny = dy / dist;
// Push out
p.x = circle.x + nx * minDist;
p.y = circle.y + ny * minDist;
// Reflect velocity with friction
const normalVel = vx * nx + vy * ny;
if (normalVel < 0) {
const tx = -ny; // tangent
const ty = nx;
const tangentVel = vx * tx + vy * ty;
// Restore previous position from corrected velocity
const newVx = -(normalVel * 0.5) * nx + (tangentVel * (1 - friction)) * tx;
const newVy = -(normalVel * 0.5) * ny + (tangentVel * (1 - friction)) * ty;
p.px = p.x - newVx;
p.py = p.y - newVy;
}
else {
p.px = p.x - vx;
p.py = p.y - vy;
}
}
}
function resolvePointBoxCollision(p, box, friction) {
if (p.pinned)
return;
const rad = p.radius || 0;
// AABB test with radius
const minX = box.x;
const maxX = box.x + box.width;
const minY = box.y;
const maxY = box.y + box.height;
// Find closest point on box to particle
let closestX = Math.max(minX, Math.min(p.x, maxX));
let closestY = Math.max(minY, Math.min(p.y, maxY));
const dx = p.x - closestX;
const dy = p.y - closestY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < rad && dist > 1e-8) {
const nx = dx / dist;
const ny = dy / dist;
// Push out
p.x = closestX + nx * rad;
p.y = closestY + ny * rad;
const vx = p.x - p.px;
const vy = p.y - p.py;
const normalVel = vx * nx + vy * ny;
if (normalVel < 0) {
const tx = -ny;
const ty = nx;
const tangentVel = vx * tx + vy * ty;
const newVx = -(normalVel * 0.3) * nx + (tangentVel * (1 - friction)) * tx;
const newVy = -(normalVel * 0.3) * ny + (tangentVel * (1 - friction)) * ty;
p.px = p.x - newVx;
p.py = p.y - newVy;
}
}
else if (dist < 1e-8) {
// Point is at exact corner of box — push along diagonal
p.x += rad;
p.y += rad;
}
}
function stepAccumulated(engine) {
// Run one physics step
step(engine);
}
function tick(engine, deltaMs) {
// Clamp delta to prevent explosion
let dt = deltaMs / 1000;
dt = Math.min(dt, config_1.CONFIG.maxDt);
_accumulator += dt;
// Run as many fixed physics steps as we can
const maxSteps = 10; // prevent spiral of death
let steps = 0;
while (_accumulator >= config_1.CONFIG.physicsDt && steps < maxSteps) {
stepAccumulated(engine);
_accumulator -= config_1.CONFIG.physicsDt;
steps++;
}
}
function resetAccumulator() {
_accumulator = 0;
}
function getTotalKineticEnergy(engine) {
let energy = 0;
const dt = config_1.CONFIG.physicsDt;
for (const p of engine.points) {
const v = (0, point_1.getVelocity)(p, dt);
energy += 0.5 * p.mass * (v.vx * v.vx + v.vy * v.vy);
}
return energy;
}
function hasNaN(engine) {
for (const p of engine.points) {
if (!isFinite(p.x) || !isFinite(p.y))
return true;
}
return false;
}
function isOutOfBounds(engine) {
const bound = Math.max(engine.width, engine.height) * 2;
for (const p of engine.points) {
if (Math.abs(p.x) > bound || Math.abs(p.y) > bound || p.y > engine.height + bound)
return true;
}
return false;
}
};
// ── module: src/renderer.ts ──
__mods["src/renderer.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// Renderer — Canvas 2D rendering for the physics playground
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.createRenderer = createRenderer;
exports.render = render;
const config_1 = require("./config");
function createRenderer(canvas) {
const ctx = canvas.getContext('2d');
return {
canvas,
ctx,
width: canvas.width,
height: canvas.height,
fps: 0,
stepTimeMs: 0,
pointCount: 0,
constraintCount: 0,
paused: false,
};
}
function render(r, points, constraints, objects, staticCircles, staticBoxes, grabPoint, grabPos, tearEffects, spawnPreview) {
const ctx = r.ctx;
const w = r.width;
const h = r.height;
// Motion blur trail
if (config_1.CONFIG.motionBlur) {
ctx.fillStyle = `rgba(15, 17, 23, ${config_1.CONFIG.motionBlurAlpha})`;
ctx.fillRect(0, 0, w, h);
}
else {
ctx.fillStyle = config_1.CONFIG.bgColor;
ctx.fillRect(0, 0, w, h);
}
// Subtle grid
drawGrid(ctx, w, h);
// Floor/wall indicators
drawBounds(ctx, w, h);
// Static objects
drawStaticObjects(ctx, staticCircles, staticBoxes);
// Tear effects
drawTearEffects(ctx, tearEffects);
// Objects
for (const obj of objects) {
drawObject(ctx, obj, points, constraints);
}
// Spawn preview
if (spawnPreview) {
drawSpawnPreview(ctx, spawnPreview);
}
// Grab highlight
if (grabPoint && grabPos) {
drawGrabHighlight(ctx, grabPoint, grabPos);
}
// Pins
drawPins(ctx, points);
// HUD
drawHUD(ctx, r, w, h);
}
function drawGrid(ctx, w, h) {
ctx.strokeStyle = config_1.CONFIG.gridColor;
ctx.lineWidth = 0.5;
const spacing = 60;
for (let x = spacing; x < w; x += spacing) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, h);
ctx.stroke();
}
for (let y = spacing; y < h; y += spacing) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
}
function drawBounds(ctx, w, h) {
const m = config_1.CONFIG.wallMargin;
ctx.strokeStyle = 'rgba(107, 114, 128, 0.4)';
ctx.lineWidth = 2;
// Floor line
ctx.beginPath();
ctx.moveTo(m, h - m);
ctx.lineTo(w - m, h - m);
ctx.stroke();
// Subtle glow at floor
ctx.strokeStyle = 'rgba(99, 102, 241, 0.15)';
ctx.lineWidth = 6;
ctx.beginPath();
ctx.moveTo(m, h - m);
ctx.lineTo(w - m, h - m);
ctx.stroke();
}
function drawStaticObjects(ctx, circles, boxes) {
for (const c of circles) {
// Glow
ctx.beginPath();
ctx.arc(c.x, c.y, c.radius + 4, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(107, 114, 128, 0.1)';
ctx.fill();
ctx.beginPath();
ctx.arc(c.x, c.y, c.radius, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(107, 114, 128, 0.2)';
ctx.fill();
ctx.strokeStyle = config_1.CONFIG.colorStatic;
ctx.lineWidth = 2;
ctx.stroke();
}
for (const b of boxes) {
ctx.fillStyle = 'rgba(107, 114, 128, 0.2)';
ctx.fillRect(b.x, b.y, b.width, b.height);
ctx.strokeStyle = config_1.CONFIG.colorStatic;
ctx.lineWidth = 2;
ctx.strokeRect(b.x, b.y, b.width, b.height);
}
}
function drawTearEffects(ctx, effects) {
for (const e of effects) {
// Outer glow
const grad = ctx.createRadialGradient(e.x, e.y, 0, e.x, e.y, 15);
grad.addColorStop(0, 'rgba(255, 68, 68, 0.6)');
grad.addColorStop(0.5, 'rgba(255, 68, 68, 0.2)');
grad.addColorStop(1, 'rgba(255, 68, 68, 0)');
ctx.beginPath();
ctx.arc(e.x, e.y, 15, 0, Math.PI * 2);
ctx.fillStyle = grad;
ctx.fill();
// Core
ctx.beginPath();
ctx.arc(e.x, e.y, 3, 0, Math.PI * 2);
ctx.fillStyle = '#ff6666';
ctx.fill();
}
}
function drawSpawnPreview(ctx, preview) {
const { start, end, mode } = preview;
ctx.setLineDash([6, 4]);
ctx.lineWidth = 1.5;
if (mode === 'rope') {
ctx.strokeStyle = config_1.CONFIG.colorRope + 'aa';
ctx.beginPath();
ctx.moveTo(start.x, start.y);
ctx.lineTo(end.x, end.y);
ctx.stroke();
// Pin indicator at start
ctx.setLineDash([]);
ctx.beginPath();
ctx.arc(start.x, start.y, 5, 0, Math.PI * 2);
ctx.fillStyle = config_1.CONFIG.colorPin + '88';
ctx.fill();
// Length indicator
const dist = Math.sqrt((end.x - start.x) ** 2 + (end.y - start.y) ** 2);
ctx.fillStyle = config_1.CONFIG.colorRope + '88';
ctx.font = '11px monospace';
ctx.fillText(`${Math.floor(dist)}px`, (start.x + end.x) / 2 + 8, (start.y + end.y) / 2);
}
else if (mode === 'cloth') {
ctx.strokeStyle = config_1.CONFIG.colorCloth + 'aa';
const dx = end.x - start.x;
const dy = end.y - start.y;
ctx.strokeRect(start.x, start.y, dx, dy);
// Show grid preview
ctx.setLineDash([]);
ctx.strokeStyle = config_1.CONFIG.colorCloth + '33';
ctx.lineWidth = 0.5;
const cols = Math.max(5, Math.min(40, Math.floor(Math.abs(dx) / 8)));
const rows = Math.max(3, Math.min(30, Math.floor(Math.abs(dy) / 8)));
const sx = Math.abs(dx) / cols;
const sy = Math.abs(dy) / rows;
const signX = dx >= 0 ? 1 : -1;
const signY = dy >= 0 ? 1 : -1;
for (let r = 0; r <= rows; r++) {
ctx.beginPath();
ctx.moveTo(start.x, start.y + r * sy * signY);
ctx.lineTo(start.x + dx, start.y + r * sy * signY);
ctx.stroke();
}
for (let c = 0; c <= cols; c++) {
ctx.beginPath();
ctx.moveTo(start.x + c * sx * signX, start.y);
ctx.lineTo(start.x + c * sx * signX, start.y + dy);
ctx.stroke();
}
}
else if (mode === 'softBody' || mode === 'ball') {
ctx.setLineDash([]);
ctx.beginPath();
ctx.arc(start.x, start.y, mode === 'ball' ? 15 : 50, 0, Math.PI * 2);
ctx.strokeStyle = (mode === 'ball' ? config_1.CONFIG.colorBall : config_1.CONFIG.colorSoftBody) + 'aa';
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.setLineDash([]);
}
function drawObject(ctx, obj, points, constraints) {
const color = obj.color;
const offset = obj.offsetInPoints;
if (obj.type === 'ball') {
const p = points[offset];
if (!p || !p.radius)
return;
// Ball with gradient
const grad = ctx.createRadialGradient(p.x - p.radius * 0.3, p.y - p.radius * 0.3, 0, p.x, p.y, p.radius);
grad.addColorStop(0, '#fff');
grad.addColorStop(0.3, color);
grad.addColorStop(1, color + '66');
ctx.beginPath();
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
ctx.fillStyle = grad;
ctx.fill();
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
else if (obj.type === 'rope') {
const pts = obj.points.map((_, i) => points[offset + i]).filter(Boolean);
if (pts.length < 2)
return;
// Shadow
ctx.beginPath();
ctx.moveTo(pts[0].x + 2, pts[0].y + 2);
for (let i = 1; i < pts.length; i++) {
ctx.lineTo(pts[i].x + 2, pts[i].y + 2);
}
ctx.strokeStyle = 'rgba(0,0,0,0.3)';
ctx.lineWidth = 7;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
// Main rope
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) {
ctx.lineTo(pts[i].x, pts[i].y);
}
ctx.strokeStyle = color;
ctx.lineWidth = 5;
ctx.stroke();
// Highlight
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) {
ctx.lineTo(pts[i].x, pts[i].y);
}
ctx.strokeStyle = '#ffffff44';
ctx.lineWidth = 2;
ctx.stroke();
if (config_1.CONFIG.showPoints) {
for (const p of pts)
drawPointDot(ctx, p, color);
}
}
else if (obj.type === 'cloth') {
const cols = obj.clothRect?.cols || 10;
const rows = obj.clothRect?.rows || 10;
// Draw cloth as filled mesh
for (let row = 0; row < rows - 1; row++) {
for (let col = 0; col < cols - 1; col++) {
const i00 = offset + row * cols + col;
const i01 = offset + row * cols + (col + 1);
const i10 = offset + (row + 1) * cols + col;
const i11 = offset + (row + 1) * cols + (col + 1);
const p00 = points[i00];
const p01 = points[i01];
const p10 = points[i10];
const p11 = points[i11];
if (!p00 || !p01 || !p10 || !p11)
continue;
// Compute average strain for color variation
let stress = 0;
// Check horizontal constraint for stress visualization
let stretchStress = 0;
for (const c of constraints) {
if ((c.a === i00 && c.b === i01) || (c.a === i01 && c.b === i00)) {
stretchStress = Math.abs(c.stretchRatio - 1);
break;
}
}
const stressAlpha = Math.min(stretchStress * 3, 1);
// Two triangles per quad
ctx.fillStyle = `rgba(${hexToRgb(color)}, ${0.3 + stressAlpha * 0.3})`;
ctx.beginPath();
ctx.moveTo(p00.x, p00.y);
ctx.lineTo(p01.x, p01.y);
ctx.lineTo(p10.x, p10.y);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.moveTo(p01.x, p01.y);
ctx.lineTo(p11.x, p11.y);
ctx.lineTo(p10.x, p10.y);
ctx.closePath();
ctx.fill();
}
}
// Grid lines
ctx.strokeStyle = color + '33';
ctx.lineWidth = 0.5;
for (let row = 0; row < rows; row++) {
ctx.beginPath();
for (let col = 0; col < cols; col++) {
const idx = offset + row * cols + col;
const p = points[idx];
if (!p)
continue;
if (col === 0)
ctx.moveTo(p.x, p.y);
else
ctx.lineTo(p.x, p.y);
}
ctx.stroke();
}
for (let col = 0; col < cols; col++) {
ctx.beginPath();
for (let row = 0; row < rows; row++) {
const idx = offset + row * cols + col;
const p = points[idx];
if (!p)
continue;
if (row === 0)
ctx.moveTo(p.x, p.y);
else
ctx.lineTo(p.x, p.y);
}
ctx.stroke();
}
if (config_1.CONFIG.showPoints) {
for (let i = 0; i < obj.points.length; i++) {
const p = points[offset + i];
if (p)
drawPointDot(ctx, p, color);
}
}
}
else if (obj.type === 'softBody') {
const pts = obj.points.map((_, i) => points[offset + i]).filter(Boolean);
if (pts.length < 3)
return;
// Sort by angle from centroid
let cx = 0, cy = 0;
for (const p of pts) {
cx += p.x;
cy += p.y;
}
cx /= pts.length;
cy /= pts.length;
const sorted = [...pts].sort((a, b) => Math.atan2(a.y - cy, a.x - cx) - Math.atan2(b.y - cy, b.x - cy));
// Glow
ctx.beginPath();
ctx.moveTo(sorted[0].x, sorted[0].y);
for (let i = 1; i < sorted.length; i++)
ctx.lineTo(sorted[i].x, sorted[i].y);
ctx.closePath();
ctx.fillStyle = color + '22';
ctx.fill();
// Fill
ctx.beginPath();
ctx.moveTo(sorted[0].x, sorted[0].y);
for (let i = 1; i < sorted.length; i++)
ctx.lineTo(sorted[i].x, sorted[i].y);
ctx.closePath();
// Gradient fill
const grad = ctx.createRadialGradient(cx, cy - 10, 0, cx, cy, Math.max(1, ...pts.map(p => Math.sqrt((p.x - cx) ** 2 + (p.y - cy) ** 2))));
grad.addColorStop(0, color + '88');
grad.addColorStop(1, color + '44');
ctx.fillStyle = grad;
ctx.fill();
ctx.strokeStyle = color;
ctx.lineWidth = 2.5;
ctx.stroke();
if (config_1.CONFIG.showStress) {
for (const c of obj.constraints) {
const pa = points[c.a];
const pb = points[c.b];
if (!pa || !pb)
continue;
const stress = Math.min(Math.abs(c.stretchRatio - 1) * 4, 1);
const r = Math.round(34 + stress * 221);
const g = Math.round(197 - stress * 143);
const b = Math.round(94 - stress * 50);
ctx.beginPath();
ctx.moveTo(pa.x, pa.y);
ctx.lineTo(pb.x, pb.y);
ctx.strokeStyle = `rgba(${r},${g},${b},0.7)`;
ctx.lineWidth = 1.5;
ctx.stroke();
}
}
if (config_1.CONFIG.showPoints) {
for (const p of pts)
drawPointDot(ctx, p, color);
}
}
}
function drawGrabHighlight(ctx, point, pos) {
// Dashed circle at cursor
ctx.beginPath();
ctx.arc(pos.x, pos.y, config_1.CONFIG.grabRadius, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 4]);
ctx.stroke();
ctx.setLineDash([]);
// Line from cursor to grabbed point
ctx.beginPath();
ctx.moveTo(pos.x, pos.y);
ctx.lineTo(point.x, point.y);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)';
ctx.lineWidth = 1;
ctx.setLineDash([2, 2]);
ctx.stroke();
ctx.setLineDash([]);
// Grabbed point highlight
const grad = ctx.createRadialGradient(point.x, point.y, 0, point.x, point.y, 8);
grad.addColorStop(0, 'rgba(255, 255, 255, 0.8)');
grad.addColorStop(1, 'rgba(255, 255, 255, 0)');
ctx.beginPath();
ctx.arc(point.x, point.y, 8, 0, Math.PI * 2);
ctx.fillStyle = grad;
ctx.fill();
ctx.beginPath();
ctx.arc(point.x, point.y, 4, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
}
function drawPointDot(ctx, p, color) {
ctx.beginPath();
ctx.arc(p.x, p.y, config_1.CONFIG.pointRadius, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
}
function drawPins(ctx, points) {
for (const p of points) {
if (!p.pinned)
continue;
// Pin cross
ctx.strokeStyle = config_1.CONFIG.colorPin;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(p.x - 5, p.y - 5);
ctx.lineTo(p.x + 5, p.y + 5);
ctx.moveTo(p.x + 5, p.y - 5);
ctx.lineTo(p.x - 5, p.y + 5);
ctx.stroke();
// Center dot
ctx.beginPath();
ctx.arc(p.x, p.y, 3, 0, Math.PI * 2);
ctx.fillStyle = config_1.CONFIG.colorPin;
ctx.fill();
}
}
function drawHUD(ctx, r, w, h) {
ctx.font = '11px monospace';
ctx.textAlign = 'left';
const lines = [];
lines.push(`FPS: ${r.fps}`);
lines.push(`Step: ${r.stepTimeMs.toFixed(2)}ms`);
lines.push(`Pts: ${r.pointCount} | Cons: ${r.constraintCount}`);
if (r.paused)
lines.push('⏸ PAUSED');
const startY = 16;
for (let i = 0; i < lines.length; i++) {
ctx.fillStyle = i === lines.length - 1 && r.paused ? '#fbbf24' : 'rgba(107, 114, 128, 0.8)';
ctx.fillText(lines[i], 10, startY + i * 16);
}
// Gravity indicator
if (config_1.CONFIG.gravityOn) {
ctx.fillStyle = 'rgba(107, 114, 128, 0.6)';
ctx.textAlign = 'right';
ctx.fillText(`g=${config_1.CONFIG.gravity}`, w - 10, 20);
ctx.textAlign = 'left';
}
}
// Helper: hex color to RGB values
function hexToRgb(hex) {
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 `${r},${g},${b}`;
}
};
// ── module: src/interaction.ts ──
__mods["src/interaction.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// Interaction — mouse/touch handling for grab, tear, pin, spawn
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.createInteraction = createInteraction;
exports.handleMouseDown = handleMouseDown;
exports.handleMouseMove = handleMouseMove;
exports.handleMouseUp = handleMouseUp;
exports.processTear = processTear;
exports.tearConstraintsNearSegment = tearConstraintsNearSegment;
exports.updateTearEffects = updateTearEffects;
const point_1 = require("./point");
const config_1 = require("./config");
function createInteraction(canvas) {
return {
canvas,
spawnMode: 'none',
pinMode: 'corners',
isDragging: false,
dragStart: null,
dragCurrent: null,
grabPoint: null,
grabOffset: null,
lastMousePos: { x: 0, y: 0 },
lastMouseTime: 0,
mouseVx: 0,
mouseVy: 0,
isTearing: false,
tearPath: [],
tearEffects: [],
};
}
function getMousePos(canvas, e) {
const rect = canvas.getBoundingClientRect();
return {
x: (e.clientX - rect.left) * (canvas.width / rect.width),
y: (e.clientY - rect.top) * (canvas.height / rect.height),
};
}
function findNearestPoint(points, pos, radius) {
let best = null;
let bestDist = radius;
for (let i = 0; i < points.length; i++) {
const p = points[i];
if (p.pinned)
continue; // don't grab pinned points (they're anchors)
const dx = p.x - pos.x;
const dy = p.y - pos.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < bestDist) {
bestDist = dist;
best = p;
}
}
return best;
}
function handleMouseDown(state, points, e) {
const pos = getMousePos(state.canvas, e);
// Update mouse velocity
const now = performance.now();
const dt = (now - state.lastMouseTime) / 1000;
if (dt > 0.001) {
state.mouseVx = (pos.x - state.lastMousePos.x) / dt;
state.mouseVy = (pos.y - state.lastMousePos.y) / dt;
}
state.lastMousePos = { ...pos };
state.lastMouseTime = now;
// Right-click or Ctrl+click = tear mode
if (e.button === 2 || e.ctrlKey || e.metaKey) {
state.isTearing = true;
state.tearPath = [pos];
e.preventDefault();
return;
}
// If in spawn mode, start dragging
if (state.spawnMode !== 'none') {
state.isDragging = true;
state.dragStart = pos;
state.dragCurrent = pos;
return;
}
// Try to grab a point
const grabbed = findNearestPoint(points, pos, config_1.CONFIG.grabRadius);
if (grabbed) {
state.grabPoint = grabbed;
state.grabOffset = {
x: grabbed.x - pos.x,
y: grabbed.y - pos.y,
};
// Zero out velocity while grabbing
(0, point_1.setVelocity)(grabbed, 0, 0, config_1.CONFIG.physicsDt);
e.preventDefault();
return;
}
// Shift+click = toggle pin on nearest point (even pinned ones)
if (e.shiftKey) {
const nearest = findNearestPointOrPinned(points, pos, config_1.CONFIG.grabRadius * 2);
if (nearest && state.onPinToggle) {
state.onPinToggle(nearest);
e.preventDefault();
}
}
}
function findNearestPointOrPinned(points, pos, radius) {
let best = null;
let bestDist = radius;
for (let i = 0; i < points.length; i++) {
const p = points[i];
const dx = p.x - pos.x;
const dy = p.y - pos.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < bestDist) {
bestDist = dist;
best = p;
}
}
return best;
}
function handleMouseMove(state, points, e) {
const pos = getMousePos(state.canvas, e);
// Update mouse velocity
const now = performance.now();
const dt = (now - state.lastMouseTime) / 1000;
if (dt > 0.001) {
state.mouseVx = (pos.x - state.lastMousePos.x) / dt;
state.mouseVy = (pos.y - state.lastMousePos.y) / dt;
}
state.lastMousePos = { ...pos };
state.lastMouseTime = now;
// Teardown
if (state.isTearing) {
state.tearPath.push(pos);
// Check constraints near the tear path
if (state.onTearConstraint) {
// We need access to constraints — handled in main loop
}
return;
}
// Spawn dragging
if (state.isDragging && state.dragStart) {
state.dragCurrent = pos;
return;
}
// Grab moving
if (state.grabPoint && state.grabOffset) {
const p = state.grabPoint;
const targetX = pos.x + state.grabOffset.x;
const targetY = pos.y + state.grabOffset.y;
// Set velocity from mouse movement so it carries momentum on release
p.x = targetX;
p.y = targetY;
// Clamp to canvas bounds
p.x = Math.max(config_1.CONFIG.wallMargin, Math.min(state.canvas.width - config_1.CONFIG.wallMargin, p.x));
p.y = Math.max(config_1.CONFIG.wallMargin, Math.min(state.canvas.height - config_1.CONFIG.wallMargin, p.y));
// Store previous position for velocity
p.px = p.x - state.mouseVx * config_1.CONFIG.physicsDt;
p.py = p.y - state.mouseVy * config_1.CONFIG.physicsDt;
}
}
function handleMouseUp(state, points, e) {
const pos = getMousePos(state.canvas, e);
// Release grab
if (state.grabPoint) {
// Throw with mouse velocity
const v = (0, point_1.getVelocity)(state.grabPoint, config_1.CONFIG.physicsDt);
const throwSpeed = Math.sqrt(v.vx * v.vx + v.vy * v.vy);
// Boost throw velocity for satisfying feel
state.grabPoint.px = state.grabPoint.x - (v.vx + state.mouseVx * 0.5) * config_1.CONFIG.physicsDt;
state.grabPoint.py = state.grabPoint.y - (v.vy + state.mouseVy * 0.5) * config_1.CONFIG.physicsDt;
state.grabPoint = null;
state.grabOffset = null;
}
// Finish tear — process the tear path
if (state.isTearing) {
state.tearPath.push(pos);
// Convert tear path to effects
for (let i = 0; i < state.tearPath.length; i += 2) {
state.tearEffects.push({
x: state.tearPath[i].x,
y: state.tearPath[i].y,
life: 1.0,
});
}
state.tearPath = [];
state.isTearing = false;
}
// Finish spawn drag
if (state.isDragging && state.dragStart && state.dragCurrent && state.spawnMode !== 'none') {
if (state.onSpawn) {
const dx = state.dragCurrent.x - state.dragStart.x;
const dy = state.dragCurrent.y - state.dragStart.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 10) {
// Spawn rope or cloth
if (state.spawnMode === 'rope') {
state.onSpawn('rope', state.dragStart, state.dragCurrent);
}
else if (state.spawnMode === 'cloth') {
// Cloth: dragStart is top-left, size determined by drag
const width = Math.abs(dx);
const height = Math.abs(dy);
const cols = Math.max(5, Math.min(40, Math.floor(width / 8)));
const rows = Math.max(3, Math.min(30, Math.floor(height / 8)));
state.onSpawn('cloth', state.dragStart);
}
}
else {
// Click without drag — spawn at point
if (state.spawnMode === 'ball') {
state.onSpawn('ball', state.dragStart);
}
else if (state.spawnMode === 'softBody') {
state.onSpawn('softBody', state.dragStart);
}
}
}
}
state.isDragging = false;
state.dragStart = null;
state.dragCurrent = null;
}
/** Process tear constraints — call from main with constraints array */
function processTear(state, constraints) {
if (state.tearPath.length < 2)
return;
const path = state.tearPath;
for (let i = 1; i < path.length; i++) {
const ax = path[i - 1].x;
const ay = path[i - 1].y;
const bx = path[i].x;
const by = path[i].y;
// Check each constraint for intersection with this line segment
for (let j = 0; j < constraints.length; j++) {
const c = constraints[j];
if (!c.active)
continue;
// Simple distance check: is either endpoint of the constraint close to the tear line?
const pa = c.a; // these are indices, but we need actual points
// We'll check in the main module since we need access to points array
}
}
}
/** Tear constraints near a line segment — needs points array */
function tearConstraintsNearSegment(constraints, points, ax, ay, bx, by, radius = config_1.CONFIG.tearRadius) {
let torn = 0;
for (const c of constraints) {
if (!c.active)
continue;
const pa = points[c.a];
const pb = points[c.b];
if (!pa || !pb)
continue;
// Distance from segment (ax,ay)-(bx,by) to point (pa.x, pa.y)
const dA = distToSegment(ax, ay, bx, by, pa.x, pa.y);
const dB = distToSegment(ax, ay, bx, by, pb.x, pb.y);
if (dA < radius || dB < radius) {
c.active = false;
torn++;
// Add tear effect
const mx = (pa.x + pb.x) / 2;
const my = (pa.y + pb.y) / 2;
// We'll collect these in main
}
}
return torn;
}
/** Distance from point (px, py) to line segment (ax, ay)-(bx, by) */
function distToSegment(ax, ay, bx, by, px, py) {
const dx = bx - ax;
const dy = by - ay;
const lenSq = dx * dx + dy * dy;
if (lenSq < 1e-12) {
return Math.sqrt((px - ax) ** 2 + (py - ay) ** 2);
}
let t = ((px - ax) * dx + (py - ay) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
const projX = ax + t * dx;
const projY = ay + t * dy;
return Math.sqrt((px - projX) ** 2 + (py - projY) ** 2);
}
/** Decay tear effects */
function updateTearEffects(state, dt) {
for (let i = state.tearEffects.length - 1; i >= 0; i--) {
state.tearEffects[i].life -= dt * 3;
if (state.tearEffects[i].life <= 0) {
state.tearEffects.splice(i, 1);
}
}
}
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"Qwen3.6-27B-MTP-GGUF","provider":"brigid","persona":"typescript","sessionId":"2b5cd0fa-b7cf-4266-9882-44dcf70d34dc","tokensIn":4321608,"tokensOut":52643,"tokensTotal":4374251,"cost":0,"turns":89,"toolCalls":88,"failedToolCalls":5,"timestamp":"2026-08-02T06:57:10.301Z"} -->