Physics Playground
Qwen 3.8 · typescript

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 66,595 bytes · SHA-256 42cffc530462
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Verlet Playground — Soft-Body Physics</title>
<style>
/* ── css: layout ── */
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #0b0e14; }
body { font-family: "Segoe UI", system-ui, -apple-system, sans-serif; }
#app, #stage { position: fixed; inset: 0; }
#view { position: absolute; inset: 0; width: 100%; height: 100%; display: block; cursor: crosshair; }
#view.grabbing { cursor: grabbing; }
#view.cutting { cursor: cell; }
/* ---- HUD pills (top-right) ---- */
#hud { position: absolute; top: 10px; right: 12px; display: flex; gap: 6px; pointer-events: none; z-index: 5; }
.pill {
background: rgba(16, 20, 30, 0.78); border: 1px solid rgba(255,255,255,0.09);
color: #9fb3c8; font-size: 11px; font-variant-numeric: tabular-nums;
padding: 4px 9px; border-radius: 20px; letter-spacing: 0.02em;
backdrop-filter: blur(4px); white-space: nowrap;
}
.pill b { color: #e8f1fa; font-weight: 600; }
/* ---- Toolbar (bottom) ---- */
#toolbar {
position: absolute; left: 0; right: 0; bottom: 0; z-index: 5;
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
padding: 9px 12px 11px;
background: linear-gradient(to top, rgba(10, 13, 20, 0.92), rgba(10, 13, 20, 0.66) 75%, transparent);
user-select: none;
}
.tgroup { display: flex; align-items: center; gap: 5px; }
.tgroup.sliders { gap: 12px; margin-left: auto; }
.tgroup.toggles { gap: 10px; }
.tool {
appearance: none; border: 1px solid rgba(255,255,255,0.12); border-radius: 8px;
background: rgba(255,255,255,0.05); color: #c7d5e3; font-size: 12px; font-weight: 600;
padding: 6px 11px; cursor: pointer; transition: background .12s, border-color .12s, color .12s, transform .06s;
}
.tool:hover { background: rgba(255,255,255,0.11); color: #fff; }
.tool:active { transform: translateY(1px); }
.tool.active { background: var(--tc, #4cc9f0); border-color: var(--tc, #4cc9f0); color: #081019; }
.tool:disabled { opacity: 0.35; cursor: default; }
.sliders label, .toggles label {
display: flex; align-items: center; gap: 6px; color: #8fa2b5; font-size: 11px; font-weight: 600;
}
.sliders span { min-width: 26px; color: #c7d5e3; font-variant-numeric: tabular-nums; }
input[type="range"] {
-webkit-appearance: none; width: 86px; height: 4px; border-radius: 3px;
background: #2a3546; outline: none; cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%;
background: #e8f1fa; border: 2px solid #4cc9f0;
}
input[type="checkbox"] { accent-color: #4cc9f0; width: 13px; height: 13px; cursor: pointer; }
/* ---- Hint (top-left) ---- */
#hint {
position: absolute; top: 10px; left: 12px; z-index: 5; pointer-events: none;
color: #66788c; font-size: 11px; letter-spacing: 0.02em;
background: rgba(16, 20, 30, 0.6); border: 1px solid rgba(255,255,255,0.07);
padding: 5px 10px; border-radius: 20px; backdrop-filter: blur(4px);
}
#hint b { color: #aebfd2; font-weight: 600; }
</style>
</head>
<body>
<div id="stage">
<canvas id="view"></canvas>
<div id="hud">
<span class="pill" id="hud-fps">-- fps</span>
<span class="pill" id="hud-physics">-- μs/step</span>
<span class="pill" id="hud-counts">0 pts · 0 cons</span>
</div>
<div id="toolbar">
<div class="tgroup" id="spawns">
<button class="tool" data-tool="rope" title="Rope — drag to draw a chain">Rope</button>
<button class="tool" data-tool="cloth" title="Cloth — drag a rectangle">Cloth</button>
<button class="tool" data-tool="blob" title="Soft body blob">Blob</button>
<button class="tool" data-tool="box" title="Soft body box">Box</button>
<button class="tool" data-tool="ball" title="Bouncing ball">Ball</button>
<button class="tool" data-tool="grab" title="Grab & throw (default)">Grab</button>
<button class="tool" data-tool="cut" title="Slice constraints">Cut</button>
</div>
<div class="tgroup" id="ctrls">
<button class="tool" id="btn-pause" title="Pause (Space)">Pause</button>
<button class="tool" id="btn-step" title="Step one frame (ArrowRight)" disabled="">Step</button>
<button class="tool" id="btn-reset" title="Rebuild demo scene (R)">Reset</button>
<button class="tool" id="btn-clear" title="Remove everything">Clear</button>
</div>
<div class="tgroup sliders">
<label>Gravity <input id="in-gravity" type="range" min="0" max="2500" value="1400" step="50"></label>
<label>Wind <input id="in-wind" type="range" min="-1500" max="1500" value="0" step="25"></label>
<label>Iter <input id="in-iter" type="range" min="1" max="16" value="5" step="1"><span id="v-iter">5</span></label>
<label>Damp <input id="in-damp" type="range" min="0.970" max="1" value="0.994" step="0.001"><span id="v-damp">.994</span></label>
</div>
<div class="tgroup toggles">
<label class="chk"><input id="in-pins" type="checkbox" checked="">Pins</label>
<label class="chk"><input id="in-dots" type="checkbox">Dots</label>
<label class="chk"><input id="in-stress" type="checkbox">Stress</label>
<label class="chk"><input id="in-trail" type="checkbox" checked="">Trail</label>
<label class="chk"><input id="in-palett" type="checkbox">Cloth: corners</label>
</div>
</div>
<div id="hint">Left-drag grab & throw · right-drag cut · shift+click pin · dbl-click point: remove object · space pause · → step · 1–5 tools · R reset</div>
</div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./config":"src/config.ts","./engine":"src/engine.ts","./render":"src/render.ts","./interaction":"src/interaction.ts","./factories":"src/factories.ts"},"src/engine.ts":{"./config":"src/config.ts","./types":"src/types.ts"},"src/factories.ts":{"./config":"src/config.ts","./engine":"src/engine.ts","./types":"src/types.ts"},"src/render.ts":{"./config":"src/config.ts","./engine":"src/engine.ts","./types":"src/types.ts"},"src/interaction.ts":{"./config":"src/config.ts","./engine":"src/engine.ts","./render":"src/render.ts","./factories":"src/factories.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;
// ============================================================================
// MAIN — entry: canvas setup, fixed-timestep loop, HUD/toolbar wiring,
// demo scene, and the headless `window.sim` API.
// ============================================================================
const config_1 = require("./config");
const engine_1 = require("./engine");
const render_1 = require("./render");
const interaction_1 = require("./interaction");
const factories_1 = require("./factories");
// -------------------------------------------------------------- DOM handles
const $ = (id) => document.getElementById(id);
let engine;
let renderer;
let inter;
let canvas;
let renderOn = true; // frozen by sim.freezeRender() for pure-physics timing
const els = {};
// -------------------------------------------------------------- demo scene
function buildDemo() {
const e = engine;
e.clear();
const w = e.w, h = e.h, floor = h - config_1.CFG.WALL_MARGIN;
const circle = { x: w * 0.52, y: h * 0.55, r: Math.min(h * 0.15, 100), hue: 210 };
e.statics = [circle, { x: w * 0.16, y: h * 0.34, r: 44, hue: 210 }];
e.staticBoxes = [
{ x: w * 0.78, y: floor - 84, w: 132, h: 84, hue: 210 },
];
(0, factories_1.spawnRope)(e, w * 0.17, h * 0.16, w * 0.27, h * 0.40);
(0, factories_1.spawnCloth)(e, circle.x - w * 0.13, h * 0.10, circle.x + w * 0.13, h * 0.34, true);
(0, factories_1.spawnBlob)(e, w * 0.86, h * 0.14, 46);
(0, factories_1.spawnBox)(e, w * 0.92, h * 0.40, 40);
(0, factories_1.spawnBall)(e, w * 0.36, h * 0.12, 15);
(0, factories_1.spawnBall)(e, w * 0.30, h * 0.52, 10, 82);
updateCounts();
}
// ---------------------------------------------------------------- HUD counts
let lastHudT = 0;
let fpsEMA = 60, physUs = 0;
function updateCounts() {
if (!els['hud-counts'])
return;
els['hud-counts'].textContent = `${engine.points.length} pts · ${engine.cons.length} cons`;
}
// --------------------------------------------------------------- main start
let booted = false;
function start() {
if (booted)
return; // idempotent: harness + self-invoke may both fire
booted = true;
canvas = $('view');
for (const id of ['hud-fps', 'hud-physics', 'hud-counts', 'spawns', 'ctrls',
'btn-pause', 'btn-step', 'btn-reset', 'btn-clear',
'in-gravity', 'in-wind', 'in-iter', 'in-damp', 'v-iter', 'v-damp',
'in-pins', 'in-dots', 'in-stress', 'in-trail', 'in-palett'])
els[id] = $(id);
// ---- engine / renderer / interaction --------------------------------
engine = new engine_1.Engine();
renderer = new render_1.Renderer(canvas);
inter = new interaction_1.Interaction(canvas, engine, renderer, {
onTool: t => markTool(t),
onPaused: p => {
els['btn-pause'].textContent = p ? 'Resume' : 'Pause';
els['btn-step'].disabled = !p;
},
onCounts: updateCounts,
onReset: buildDemo,
});
// ---- sizing ----------------------------------------------------------
const fit = () => {
const w = window.innerWidth, h = window.innerHeight;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
renderer.resize(w, h, dpr);
engine.resize(w, h);
};
fit();
window.addEventListener('resize', fit);
// ---- toolbar wiring ----------------------------------------------------
const tools = ['rope', 'cloth', 'blob', 'box', 'ball', 'grab', 'cut'];
for (const b of els['spawns'].querySelectorAll('[data-tool]')) {
b.addEventListener('click', () => inter.setTool(b.dataset.tool));
}
const pauseBtn = els['btn-pause'];
pauseBtn.addEventListener('click', () => inter.setPaused(!inter.paused));
els['btn-step'].addEventListener('click', () => { if (inter.paused)
engine.step(); });
els['btn-reset'].addEventListener('click', () => inter.reset());
els['btn-clear'].addEventListener('click', () => { inter.clearAll(); engine.statics = []; engine.staticBoxes = []; updateCounts(); });
const g = els['in-gravity'];
g.addEventListener('input', () => { engine.gravity = +g.value; });
const wn = els['in-wind'];
wn.addEventListener('input', () => { engine.wind = +wn.value; });
const it = els['in-iter'];
it.addEventListener('input', () => { engine.iterations = +it.value; els['v-iter'].textContent = it.value; });
const dm = els['in-damp'];
dm.addEventListener('input', () => { engine.damping = +dm.value; els['v-damp'].textContent = (+dm.value).toFixed(3).slice(1); });
const cb = (id, fn) => {
const el = els[id];
el.addEventListener('change', () => fn(el.checked));
};
cb('in-pins', b => { });
cb('in-dots', () => { });
cb('in-stress', () => { });
cb('in-trail', () => { });
cb('in-palett', b => (0, interaction_1.setClothPinEdge)(!b)); // checkbox "corners": off => edge pins
(0, interaction_1.setClothPinEdge)(!(els['in-palett'].checked)); // sync initial state
// ---- demo -------------------------------------------------------------
buildDemo();
// ---- headless API ------------------------------------------------------
window['sim'] = {
points: () => engine.points,
cons: () => engine.cons,
objs: () => engine.objs,
step: () => engine.step(),
stepFrames: (n) => { for (let i = 0; i < n; i++)
engine.step(); },
pause: () => inter.setPaused(true),
resume: () => inter.setPaused(false),
freezeRender: (on = true) => { renderOn = !on; if (!renderOn)
renderer.render(engine, { ...inter.uiForRender(), showPins: false, showDots: false, stress: false, trail: false }, 0); },
reset: () => buildDemo(),
clear: () => { inter.clearAll(); },
spawnRope: (x1, y1, x2, y2) => (0, factories_1.spawnRope)(engine, x1, y1, x2, y2),
spawnCloth: (x1, y1, x2, y2, pinEdge = true) => (0, factories_1.spawnCloth)(engine, x1, y1, x2, y2, pinEdge),
spawnBlob: (x, y, r) => (0, factories_1.spawnBlob)(engine, x, y, r),
spawnBox: (x, y, r) => (0, factories_1.spawnBox)(engine, x, y, r),
spawnBall: (x, y, r) => (0, factories_1.spawnBall)(engine, x, y, r),
setPin: (i, on) => engine.setPin(i, on),
tear: (ci) => { engine.cons[ci].dead = true; engine.pruneDead(); },
kineticEnergy: () => engine.kineticEnergy(),
isSane: () => engine.isSane(),
stats: () => ({ points: engine.points.length, cons: engine.cons.length, ke: engine.kineticEnergy(), sane: engine.isSane() }),
engine,
};
// ---- main loop: fixed-timestep accumulator -----------------------------
let last = performance.now();
let acc = 0;
let frames = 0, fpsT = last;
let totalSteps = 0, totalPhysMs = 0;
const frame = (now) => {
// clamp delta so a long tab-away can never dump a huge backlog
let dt = (now - last) / 1000;
last = now;
if (dt > config_1.CFG.MAX_FRAME)
dt = config_1.CFG.MAX_FRAME;
let physT = 0;
if (!inter.paused) {
acc += dt;
let steps = 0;
const t0 = performance.now();
while (acc >= config_1.CFG.DT && steps < config_1.CFG.MAX_STEPS) {
engine.step();
acc -= config_1.CFG.DT;
steps++;
if (steps === config_1.CFG.MAX_STEPS)
acc = 0; // drop backlog
}
physT = performance.now() - t0;
totalSteps += steps;
totalPhysMs += physT;
}
// ---- render (skippable via sim.freezeRender() for pure-physics timing) ----
if (renderOn) {
const ui = {
...inter.uiForRender(),
showPins: els['in-pins'].checked,
showDots: els['in-dots'].checked,
stress: els['in-stress'].checked,
trail: els['in-trail'].checked,
};
renderer.updateFX(dt);
renderer.render(engine, ui, now / 1000);
}
// ---- HUD ----
frames++;
if (now - fpsT >= 500) {
fpsEMA = frames * 1000 / (now - fpsT);
frames = 0;
fpsT = now;
}
if (now - lastHudT > 250) {
lastHudT = now;
els['hud-fps'].textContent = `${fpsEMA.toFixed(0)} fps`;
const perStep = totalSteps > 0 ? (totalPhysMs / totalSteps) * 1e6 : 0;
els['hud-physics'].textContent = inter.paused ? 'paused' : `${perStep.toFixed(0)} μs/step`;
}
requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
}
const TOOL_HUE = { ...config_1.CFG.HUES, grab: 200, cut: 350 };
function markTool(t) {
const btns = document.querySelectorAll('#spawns [data-tool]');
btns.forEach(b => {
const on = b.dataset.tool === t;
b.classList.toggle('active', on);
b.style.setProperty('--tc', `hsl(${TOOL_HUE[b.dataset.tool ?? 'grab']}, 85%, 62%)`);
});
}
// Self-start (the inline bundle runs this module with the DOM already parsed).
start();
};
// ── module: src/config.ts ──
__mods["src/config.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CFG = void 0;
// ============================================================================
// TUNABLE CONSTANTS — the whole feel of the sim lives in this block
// ============================================================================
exports.CFG = {
// ---- timestep ----
DT: 1 / 60, // physics timestep (s), fixed at 60 Hz
MAX_FRAME: 1 / 10, // clamp for a single rAF delta (tab-switch safety)
MAX_STEPS: 4, // max physics steps per frame (spiral-of-death guard)
// ---- dynamics ----
GRAVITY: 1400, // px/s^2 (slider 0..2500)
WIND: 0, // px/s^2 x-force (slider -1500..1500)
DAMPING: 0.994, // velocity retention per step (slider .970..1)
ITERATIONS: 5, // constraint relaxation passes per step (slider 1..16)
MAX_SPEED: 3200, // px/s hard velocity clamp (tunneling/energy guard)
SHAPE_K: 40, // soft-body shape-memory spring (s^-2): a = deviation * K
REST_FRICTION: 0.22, // tangential damping when a point touches a static shape
BALL_BOUNCE: 0.55, // tangential restitution preserved for heavy balls
// ---- interaction ----
GRAB_RADIUS: 26, // px, nearest-point search radius
CUT_RADIUS: 11, // px, half-thickness of the slice knife
GRAB_PULL: 0.65, // grab point is pulled toward cursor this fraction/step
THROW_CAP: 2800, // px/s, max velocity granted by a release
// ---- geometry / limits ----
WALL_MARGIN: 6, // inset of the bounding box walls
MAX_POINTS: 9000, // hard cap on point count
POINT_DOTS: 2.0, // px radius for point dots
BALL_RADIUS: 14, // default spawned ball radius
// ---- rendering ----
STRESS_GREEN: '#3ddc84',
STRESS_RED: '#ff4d6d',
STRESS_START: 1.0, // stretch ratio where stress color begins
TRAIL_ALPHA: 0.34, // bg fade alpha (lower = longer trail); 1 = no trail
HUES: {
rope: 197, // cyan
cloth: 335, // pink
blob: 268, // violet
box: 42, // amber
ball: 152, // green
},
};
};
// ── module: src/types.ts ──
__mods["src/types.ts"] = function (exports, require, module) {
"use strict";
// ============================================================================
// Core data structures
// ============================================================================
Object.defineProperty(exports, "__esModule", { value: true });
};
// ── module: src/engine.ts ──
__mods["src/engine.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Engine = void 0;
// ============================================================================
// ENGINE — Verlet integration + constraint relaxation (position-based)
// step(): 1) integrate 2) soft-body shape springs
// 3) N relaxation passes 4) grab 5) static collisions 6) guards
// ============================================================================
const config_1 = require("./config");
class Engine {
constructor() {
this.points = [];
this.cons = [];
this.objs = [];
this.statics = [];
this.staticBoxes = [];
this.nextId = 0;
this.w = 1280;
this.h = 720;
this.gravity = config_1.CFG.GRAVITY;
this.wind = config_1.CFG.WIND;
this.damping = config_1.CFG.DAMPING;
this.iterations = config_1.CFG.ITERATIONS;
this.dt = config_1.CFG.DT;
this.grab = null;
this.nanHits = 0; // defensive: bumped when the guard restores a bad point
this.shapeF = new Float32Array(0); // per-point shape-force scratch
}
// ------------------------------------------------------------------ adding
resize(w, h) { this.w = w; this.h = h; }
addPoint(x, y, r = 1.5, pinned = false, objId = -1) {
const p = { x, y, px: x, py: y, r, inv: pinned ? 0 : 1, pinned, obj: objId };
this.points.push(p);
return this.points.length - 1;
}
addConstraint(a, b, w = 1, kind = 'rope') {
const pa = this.points[a], pb = this.points[b];
const dx = pb.x - pa.x, dy = pb.y - pa.y;
const rest = Math.max(Math.hypot(dx, dy), 1e-6); // guard: coincident points
this.cons.push({ a, b, rest, w, kind, dead: false });
return this.cons.length - 1;
}
addObj(kind, pts, cons, hue, ring = [], shape = 'circle', shapeX = [], shapeY = [], cols = 0, rows = 0) {
const o = { id: this.nextId++, kind, pts, cons, cols, rows, hue, ring, shape, shapeX, shapeY };
this.objs.push(o);
return o;
}
setPin(i, on) {
const p = this.points[i];
p.pinned = on;
p.inv = on ? 0 : 1;
}
clear() {
this.points.length = 0;
this.cons.length = 0;
this.objs.length = 0;
this.grab = null;
}
removeObj(id) {
const o = this.objs.find(x => x.id === id);
if (!o)
return;
for (const ci of o.cons)
if (this.cons[ci])
this.cons[ci].dead = true;
for (const pi of o.pts)
this.points[pi] = undefined; // slot out
this.objs = this.objs.filter(x => x.id !== id);
this.compact();
}
/** Drop dead constraints + slotted-out points, remap indices. */
compact() {
const pMap = new Array(this.points.length).fill(-1);
const pts = [];
for (let i = 0; i < this.points.length; i++) {
const p = this.points[i];
if (!p)
continue;
pMap[i] = pts.length;
pts.push(p);
}
const cMap = new Array(this.cons.length).fill(-1);
const cons = [];
for (let i = 0; i < this.cons.length; i++) {
const c = this.cons[i];
if (c.dead)
continue;
const a = pMap[c.a], b = pMap[c.b];
if (a < 0 || b < 0)
continue;
cMap[i] = cons.length;
cons.push({ a, b, rest: c.rest, w: c.w, kind: c.kind, dead: false });
}
const objs = [];
for (const o of this.objs) {
const pts = o.pts.map(i => pMap[i]).filter(i => i >= 0);
const cons = o.cons.map(i => cMap[i]).filter(i => i >= 0);
if (pts.length === 0)
continue;
objs.push({ ...o, pts, cons, ring: o.ring.map(i => pMap[i]).filter(i => i >= 0) });
}
this.points = pts;
this.cons = cons;
this.objs = objs;
if (this.grab && this.grab.active && this.grab.p >= this.points.length)
this.grab.active = false;
}
// ------------------------------------------------------------- simulation
step(dt) {
const d = dt ?? this.dt;
const dt2 = d * d;
const maxDisp = config_1.CFG.MAX_SPEED * d;
const pts = this.points;
// ---- 1) shape-memory forces (TRUE forces: a = deviation * K) ---------
// Applied during integration so collision projection can cancel them —
// positional "creep" instead pumped energy against the floor.
const n = pts.length;
if (this.shapeF.length !== 2 * n)
this.shapeF = new Float32Array(2 * n);
const sf = this.shapeF;
sf.fill(0);
for (let oi = 0; oi < this.objs.length; oi++) {
const o = this.objs[oi];
if (!o.shape || o.ring.length === 0)
continue;
let cx = 0, cy = 0;
for (const i of o.ring) {
const p = pts[i];
cx += p.x;
cy += p.y;
}
const m = o.ring.length;
cx /= m;
cy /= m;
const K = config_1.CFG.SHAPE_K;
for (let j = 0; j < m; j++) {
const i = o.ring[j];
const p = pts[i];
if (p.pinned)
continue;
sf[2 * i] += (cx + o.shapeX[j] - p.x) * K;
sf[2 * i + 1] += (cy + o.shapeY[j] - p.y) * K;
}
}
// ---- 2) Verlet integration (gravity + wind + shape forces) ----------
for (let i = 0; i < n; i++) {
const p = pts[i];
if (p.pinned)
continue;
let vx = (p.x - p.px) * this.damping;
let vy = (p.y - p.py) * this.damping;
// hard velocity clamp — nothing can tunnel at any frame rate
const s = Math.hypot(vx, vy);
if (s > maxDisp) {
const k = maxDisp / s;
vx *= k;
vy *= k;
}
p.px = p.x;
p.py = p.y;
p.x += vx + (this.wind + sf[2 * i]) * dt2;
p.y += vy + (this.gravity + sf[2 * i + 1]) * dt2;
}
// ---- 3) constraint relaxation INTERLEAVED with collision projection --
// The solver can shove points into statics (compressed contact bands);
// projecting every pass instead of once kills the sustained rattle that
// an end-of-step collision alone leaves behind.
const cons = this.cons;
for (let it = 0; it < this.iterations; it++) {
for (let i = 0; i < cons.length; i++) {
const c = cons[i];
if (c.dead)
continue;
const a = pts[c.a], b = pts[c.b];
const dx = b.x - a.x, dy = b.y - a.y;
const d = Math.sqrt(dx * dx + dy * dy);
if (d < 1e-6)
continue; // guard: coincident points
const total = a.inv + b.inv;
if (total === 0)
continue; // both pinned
const diff = ((d - c.rest) / d) * c.w;
const wa = a.inv / total, wb = b.inv / total;
a.x += dx * diff * wa;
a.y += dy * diff * wa;
b.x -= dx * diff * wb;
b.y -= dy * diff * wb;
}
this.collide(d);
}
// ---- 4) grab: point follows cursor, carries cursor velocity ----------
if (this.grab && this.grab.active) {
const g = this.grab;
const p = pts[g.p];
if (p) {
if (p.pinned) {
// dragging a pin = sliding its anchor (no throw on release)
p.x += (g.x - p.x) * 0.9;
p.y += (g.y - p.y) * 0.9;
p.px = p.x;
p.py = p.y;
}
else {
p.x += (g.x - p.x) * config_1.CFG.GRAB_PULL;
p.y += (g.y - p.y) * config_1.CFG.GRAB_PULL;
// impart (capped) cursor velocity so release = throw
let tvx = g.vx * d, tvy = g.vy * d;
const ts = Math.hypot(tvx, tvy);
if (ts > config_1.CFG.THROW_CAP * d) {
const k = config_1.CFG.THROW_CAP * d / ts;
tvx *= k;
tvy *= k;
}
p.px = p.x - tvx;
p.py = p.y - tvy;
}
}
}
// ---- 5) final collision pass (grab may have moved a point into a
// static/wall; interleaved passes above kept everything else out)
this.collide(d);
// ---- 6) NaN / bound guards -------------------------------------------
const lim = 1e5;
for (let i = 0; i < pts.length; i++) {
const p = pts[i];
if (!isFinite(p.x) || !isFinite(p.y)) {
p.x = p.px;
p.y = p.py;
if (!isFinite(p.x) || !isFinite(p.y)) {
p.x = this.w / 2;
p.y = this.h / 3;
}
p.px = p.x;
p.py = p.y;
this.nanHits++;
continue;
}
if (p.x > lim) {
p.x = lim;
p.px = lim;
}
else if (p.x < -lim) {
p.x = -lim;
p.px = -lim;
}
if (p.y > lim) {
p.y = lim;
p.py = lim;
}
else if (p.y < -lim) {
p.y = -lim;
p.py = -lim;
}
}
}
/** Points vs. walls + static circles + static boxes (with friction). */
collide(_dt) {
const pts = this.points;
const m = config_1.CFG.WALL_MARGIN;
for (let i = 0; i < pts.length; i++) {
const p = pts[i];
if (p.pinned)
continue;
const e = p.r > 6 ? config_1.CFG.BALL_BOUNCE : 0.05; // heavy balls bounce
const f = config_1.CFG.REST_FRICTION;
// walls
if (p.x < m + p.r)
this.resolve(p, 1, 0, m + p.r - p.x, e, f);
else if (p.x > this.w - m - p.r)
this.resolve(p, -1, 0, p.x - (this.w - m - p.r), e, f);
if (p.y < m + p.r)
this.resolve(p, 0, 1, m + p.r - p.y, e, f);
else if (p.y > this.h - m - p.r)
this.resolve(p, 0, -1, p.y - (this.h - m - p.r), e, f);
// static circles
for (const c of this.statics) {
const dx = p.x - c.x, dy = p.y - c.y;
const R = c.r + p.r;
const d2 = dx * dx + dy * dy;
if (d2 >= R * R)
continue;
const d = Math.sqrt(d2);
if (d < 1e-6) {
this.resolve(p, 0, -1, R, e, f);
continue;
} // guard: at center
this.resolve(p, dx / d, dy / d, R - d, e, f);
}
// static boxes (AABB)
for (const b of this.staticBoxes) {
const bx0 = b.x, by0 = b.y, bx1 = b.x + b.w, by1 = b.y + b.h;
if (p.x > bx0 && p.x < bx1 && p.y > by0 && p.y < by1) {
const dl = p.x - bx0, dr = bx1 - p.x, dtp = p.y - by0, db = by1 - p.y;
const min = Math.min(dl, dr, dtp, db);
if (min === dl)
this.resolve(p, -1, 0, dl + p.r, e, f);
else if (min === dr)
this.resolve(p, 1, 0, dr + p.r, e, f);
else if (min === dtp)
this.resolve(p, 0, -1, dtp + p.r, e, f);
else
this.resolve(p, 0, 1, db + p.r, e, f);
}
else {
const cx = Math.max(bx0, Math.min(p.x, bx1));
const cy = Math.max(by0, Math.min(p.y, by1));
const dx = p.x - cx, dy = p.y - cy;
const d2 = dx * dx + dy * dy;
if (d2 >= p.r * p.r)
continue;
const d = Math.sqrt(d2);
if (d < 1e-6) {
this.resolve(p, 0, -1, p.r, e, f);
continue;
}
this.resolve(p, dx / d, dy / d, p.r - d, e, f);
}
}
}
}
/** Push out along (nx,ny) by `push`, project velocity: bounce + friction.
* The position push itself must NEVER create velocity (px is always
* rewritten) — leaving it would inject a phantom outward velocity every
* contact frame and sustain perpetual "rattle" on static shapes.
* Restitution is scaled down for slow impacts so resting contact settles. */
resolve(p, nx, ny, push, e, f) {
if (push <= 0)
return;
const vx = p.x - p.px, vy = p.y - p.py;
p.x += nx * push;
p.y += ny * push;
const vn = vx * nx + vy * ny;
const vtx = vx - vn * nx, vty = vy - vn * ny;
let bounce = 0;
if (vn < 0) {
const impact = -vn;
const eEff = impact < 40 ? e * (impact / 40) : e;
bounce = -vn * eEff;
}
const nvx = vtx * (1 - f) + nx * bounce;
const nvy = vty * (1 - f) + ny * bounce;
p.px = p.x - nvx;
p.py = p.y - nvy;
}
// -------------------------------------------------------------- utilities
/** Total kinetic energy (0.5 * |v|^2 summed over free points, v in px/s). */
kineticEnergy() {
let ke = 0;
const invDt = 1 / this.dt;
for (const p of this.points) {
if (p.pinned)
continue;
const vx = (p.x - p.px) * invDt, vy = (p.y - p.py) * invDt;
ke += 0.5 * (vx * vx + vy * vy);
}
return ke;
}
/** All coordinates finite and within sane bounds? */
isSane() {
for (const p of this.points) {
if (!isFinite(p.x) || !isFinite(p.y) || !isFinite(p.px) || !isFinite(p.py))
return false;
if (Math.abs(p.x) > 1e5 || Math.abs(p.y) > 1e5)
return false;
}
return true;
}
/** Slice every constraint crossed by segment (x1,y1)-(x2,y2). Returns cuts. */
cutSegment(x1, y1, x2, y2) {
let n = 0;
for (const c of this.cons) {
if (c.dead)
continue;
const a = this.points[c.a], b = this.points[c.b];
if (segSeg(x1, y1, x2, y2, a.x, a.y, b.x, b.y)) {
c.dead = true;
n++;
}
}
return n;
}
/** Drop torn constraints, remap indices, fix object lists. */
pruneDead() {
if (!this.cons.some(c => c.dead))
return;
const old = this.cons;
const map = new Array(old.length).fill(-1);
const live = [];
for (let i = 0; i < old.length; i++) {
if (!old[i].dead) {
map[i] = live.length;
live.push(old[i]);
}
}
for (const o of this.objs)
o.cons = o.cons.map(i => map[i]).filter(i => i >= 0);
this.cons = live;
}
/** Nearest free point to (x,y) within radius (radius grows with point size). */
nearestPoint(x, y, radius) {
let best = -1, bd = Infinity;
for (let i = 0; i < this.points.length; i++) {
const p = this.points[i];
const dx = p.x - x, dy = p.y - y;
const d2 = dx * dx + dy * dy;
const reach = radius + p.r;
if (d2 < reach * reach && d2 < bd) {
bd = d2;
best = i;
}
}
return best;
}
}
exports.Engine = Engine;
// Standard segment intersection (orientation test).
function segSeg(ax, ay, bx, by, cx, cy, dx, dy) {
const o1 = orient(ax, ay, bx, by, cx, cy);
const o2 = orient(ax, ay, bx, by, dx, dy);
const o3 = orient(cx, cy, dx, dy, ax, ay);
const o4 = orient(cx, cy, dx, dy, bx, by);
return o1 !== o2 && o3 !== o4;
}
function orient(ax, ay, bx, by, cx, cy) {
const v = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
if (v > 1e-9)
return 1;
if (v < -1e-9)
return -1;
return 0;
}
};
// ── module: src/factories.ts ──
__mods["src/factories.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.spawnRope = spawnRope;
exports.spawnCloth = spawnCloth;
exports.spawnBlob = spawnBlob;
exports.spawnBox = spawnBox;
exports.spawnBall = spawnBall;
exports.hueOf = hueOf;
// ============================================================================
// FACTORIES — build rope / cloth / blob / box / ball from engine calls
// ============================================================================
const config_1 = require("./config");
function ringPath(n, r, shape) {
const out = [];
if (shape === 'circle') {
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2 - Math.PI / 2;
out.push([Math.cos(a) * r, Math.sin(a) * r]);
}
}
else {
const h = r * 0.9, s = h * 2;
const per = Math.max(2, Math.round(n / 4));
for (let i = 0; i < n; i++) {
const t = (i / n) * s * 4; // perimeter parameter
let x = 0, y = 0;
if (t < s) {
x = -h + t;
y = -h;
}
else if (t < 2 * s) {
x = h;
y = -h + (t - s);
}
else if (t < 3 * s) {
x = h - (t - 2 * s);
y = h;
}
else {
x = -h;
y = h - (t - 3 * s);
}
out.push([x, y]);
}
}
return out;
}
/** Ring of points + edge constraints + internal braces; returns shape-memory offsets. */
function buildRing(e, cx, cy, r, shape, kind, hue) {
const n = shape === 'circle' ? 16 : 20;
const path = ringPath(n, r, shape);
const pts = [];
for (let i = 0; i < n; i++)
pts.push(e.addPoint(cx + path[i][0], cy + path[i][1], 1.5, false, -1));
const cons = [];
// edges
for (let i = 0; i < n; i++)
cons.push(e.addConstraint(pts[i], pts[(i + 1) % n], 1, kind));
// internal braces: every 4th chord
for (let i = 0; i < n; i += 4)
cons.push(e.addConstraint(pts[i], pts[(i + 5) % n], 0.9, kind));
// cross bracing for stiffness (ring + opposite arc)
for (let i = 0; i < n; i += 2)
cons.push(e.addConstraint(pts[i], pts[(i + n / 2) % n], 0.35, kind));
const id = e.nextId;
const o = { id, kind, pts, cons, cols: 0, rows: 0, hue, ring: [...pts],
shape, shapeX: path.map(p => p[0]), shapeY: path.map(p => p[1]) };
e.addObj(kind, pts, cons, hue, [...pts], shape, o.shapeX, o.shapeY);
return e.objs[e.objs.length - 1];
}
// ------------------------------------------------------------------ ROPE
function spawnRope(e, x1, y1, x2, y2) {
const dx = x2 - x1, dy = y2 - y1;
const len = Math.max(Math.hypot(dx, dy), 20);
const seg = 16;
const n = Math.max(3, Math.min(60, Math.round(len / seg) + 1));
const hue = config_1.CFG.HUES.rope;
const id = e.nextId;
const pts = [];
for (let i = 0; i < n; i++) {
const t = i / (n - 1);
pts.push(e.addPoint(x1 + dx * t, y1 + dy * t, 1.5, i === 0, id));
}
const cons = [];
for (let i = 0; i < n - 1; i++)
cons.push(e.addConstraint(pts[i], pts[i + 1], 1, 'rope'));
e.addObj('rope', pts, cons, hue);
return e.objs[e.objs.length - 1];
}
// ------------------------------------------------------------------ CLOTH
/** Pin mode: 'edge' = whole top row pinned, 'corners' = both top corners only. */
function spawnCloth(e, x1, y1, x2, y2, pinEdge) {
let x0 = Math.min(x1, x2), x1b = Math.max(x1, x2);
let y0 = Math.min(y1, y2), y1b = Math.max(y1, y2);
const w = Math.max(x1b - x0, 40), h = Math.max(y1b - y0, 40);
const cols = Math.max(4, Math.min(30, Math.round(w / 24) + 1));
const rows = Math.max(3, Math.min(24, Math.round(h / 24) + 1));
const hue = config_1.CFG.HUES.cloth;
const id = e.nextId;
const pts = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = x0 + (c / (cols - 1)) * w;
const y = y0 + (r / (rows - 1)) * h;
const pinned = pinEdge ? r === 0 : (r === 0 && (c === 0 || c === cols - 1));
pts.push(e.addPoint(x, y, 1.5, pinned, id));
}
}
const idx = (r, c) => r * cols + c;
const cons = [];
// structural
for (let r = 0; r < rows; r++)
for (let c = 0; c < cols; c++) {
if (c + 1 < cols)
cons.push(e.addConstraint(pts[idx(r, c)], pts[idx(r, c + 1)], 1, 'cloth'));
if (r + 1 < rows)
cons.push(e.addConstraint(pts[idx(r, c)], pts[idx(r + 1, c)], 1, 'cloth'));
}
// shear
for (let r = 0; r < rows - 1; r++)
for (let c = 0; c < cols - 1; c++) {
cons.push(e.addConstraint(pts[idx(r, c)], pts[idx(r + 1, c + 1)], 0.9, 'cloth'));
cons.push(e.addConstraint(pts[idx(r, c + 1)], pts[idx(r + 1, c)], 0.9, 'cloth'));
}
e.addObj('cloth', pts, cons, hue, [], 'circle', [], [], cols, rows);
return e.objs[e.objs.length - 1];
}
// ------------------------------------------------------- SOFT BODIES / BALL
function spawnBlob(e, x, y, r = 46) {
return buildRing(e, x, y, r, 'circle', 'blob', config_1.CFG.HUES.blob);
}
function spawnBox(e, x, y, r = 44) {
return buildRing(e, x, y, r, 'box', 'box', config_1.CFG.HUES.box);
}
function spawnBall(e, x, y, r = config_1.CFG.BALL_RADIUS, hue) {
const h = hue ?? config_1.CFG.HUES.ball;
const id = e.nextId;
const pi = e.addPoint(x, y, r, false, id);
e.addObj('ball', [pi], [], h);
return e.objs[e.objs.length - 1];
}
function hueOf(kind) { return config_1.CFG.HUES[kind]; }
};
// ── module: src/render.ts ──
__mods["src/render.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Renderer = void 0;
// ============================================================================
// RENDERER — Canvas 2D: trails, statics, objects, stress, FX, previews
// ============================================================================
const config_1 = require("./config");
class Renderer {
constructor(canvas) {
this.canvas = canvas;
this.view = { w: 1280, h: 720, dpr: 1 };
this.particles = [];
this.ghosts = [];
this.stressCache = [];
this.bgGrad = null;
this.bgKey = '';
this.firstFrame = true;
this.ctx = canvas.getContext('2d');
}
resize(w, h, dpr) {
this.view.w = w;
this.view.h = h;
this.view.dpr = dpr;
this.canvas.width = Math.round(w * dpr);
this.canvas.height = Math.round(h * dpr);
this.bgKey = '';
}
// -------------------------------------------------------------- effects
spawnSparks(x, y, hue, n = 10) {
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
const s = 40 + Math.random() * 160;
this.particles.push({
x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s - 40,
life: 0, maxLife: 0.35 + Math.random() * 0.3,
hue, size: 1.5 + Math.random() * 2,
});
}
if (this.particles.length > 400)
this.particles.splice(0, this.particles.length - 400);
}
spawnGhosts(obj, points) {
for (const i of obj.pts) {
const p = points[i];
this.ghosts.push({ x: p.x, y: p.y, life: 0.5, hue: obj.hue });
}
if (this.ghosts.length > 300)
this.ghosts.splice(0, this.ghosts.length - 300);
}
updateFX(dt) {
for (const p of this.particles) {
p.life += dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.vy += 500 * dt;
p.vx *= 0.98;
}
this.particles = this.particles.filter(p => p.life < p.maxLife);
for (const g of this.ghosts)
g.life -= dt;
this.ghosts = this.ghosts.filter(g => g.life > 0);
}
// -------------------------------------------------------------- drawing
stressColor(t) {
const i = Math.min(63, Math.max(0, (t * 63) | 0));
let c = this.stressCache[i];
if (!c) {
c = `rgb(${lerp(61, 255, i / 63) | 0},${lerp(220, 77, i / 63) | 0},${lerp(132, 109, i / 63) | 0})`;
this.stressCache[i] = c;
}
return c;
}
render(e, ui, nowSec) {
const ctx = this.ctx;
const { w, h, dpr } = this.view;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// ---- background: full clear or trail fade ---------------------------
if (this.firstFrame) {
this.firstFrame = false;
this.paintBackground(ctx);
}
if (ui.trail) {
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = `rgba(11, 14, 20, ${config_1.CFG.TRAIL_ALPHA})`;
ctx.fillRect(0, 0, w, h);
}
else {
this.paintBackground(ctx);
}
this.drawWalls(ctx, w, h);
this.drawStatics(ctx, e);
this.drawObjects(ctx, e, ui, nowSec);
this.drawPoints(ctx, e, ui);
this.drawGrab(ctx, e, ui);
if (ui.preview)
this.drawPreview(ctx, ui.preview);
this.drawCutFX(ctx, ui);
this.drawParticles(ctx);
this.drawGhosts(ctx);
if (ui.paused)
this.drawPaused(ctx, w, h);
}
paintBackground(ctx) {
const { w, h } = this.view;
const key = `${w}x${h}`;
if (this.bgKey !== key || !this.bgGrad) {
const g = ctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, '#0d1119');
g.addColorStop(1, '#0a0d13');
this.bgGrad = g;
this.bgKey = key;
}
ctx.fillStyle = this.bgGrad;
ctx.fillRect(0, 0, w, h);
}
drawWalls(ctx, w, h) {
const m = config_1.CFG.WALL_MARGIN;
ctx.strokeStyle = 'rgba(120, 160, 210, 0.16)';
ctx.lineWidth = 1.5;
ctx.strokeRect(m - 3, m - 3, w - (m - 3) * 2, h - (m - 3) * 2);
// floor glow
ctx.strokeStyle = 'rgba(140, 190, 255, 0.35)';
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(m, h - m);
ctx.lineTo(w - m, h - m);
ctx.stroke();
}
drawStatics(ctx, e) {
for (const c of e.statics) {
ctx.beginPath();
ctx.arc(c.x, c.y, c.r, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${c.hue}, 60%, 55%, 0.13)`;
ctx.fill();
ctx.strokeStyle = `hsla(${c.hue}, 75%, 65%, 0.55)`;
ctx.lineWidth = 2;
ctx.stroke();
}
for (const b of e.staticBoxes) {
ctx.fillStyle = `hsla(${b.hue}, 55%, 55%, 0.13)`;
ctx.fillRect(b.x, b.y, b.w, b.h);
ctx.strokeStyle = `hsla(${b.hue}, 70%, 65%, 0.5)`;
ctx.lineWidth = 2;
ctx.strokeRect(b.x, b.y, b.w, b.h);
}
}
drawObjects(ctx, e, ui, _now) {
for (const o of e.objs) {
if (o.kind === 'cloth')
this.drawCloth(ctx, e, o, ui);
else if (o.kind === 'rope')
this.drawChain(ctx, e, o, ui);
else if (o.kind === 'ball')
this.drawBall(ctx, e, o, ui);
else
this.drawSoft(ctx, e, o, ui);
}
}
/** Cloth: filled cells + grid lines (dots hidden by design). */
drawCloth(ctx, e, o, ui) {
const pts = e.points;
const { cols, rows } = o;
if (cols === 0 || rows === 0)
return;
const idx = (r, c) => o.pts[r * cols + c];
// filled fabric
ctx.beginPath();
for (let r = 0; r < rows - 1; r++) {
for (let c = 0; c < cols - 1; c++) {
const a = pts[idx(r, c)], b = pts[idx(r, c + 1)];
const d = pts[idx(r + 1, c + 1)], f = pts[idx(r + 1, c)];
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.lineTo(d.x, d.y);
ctx.lineTo(f.x, f.y);
ctx.closePath();
}
}
ctx.fillStyle = `hsla(${o.hue}, 85%, 60%, 0.16)`;
ctx.fill();
// grid lines (only live constraints)
ctx.lineWidth = 1.2;
ctx.beginPath();
for (const ci of o.cons) {
const c = e.cons[ci];
if (!c || c.dead)
continue;
const a = pts[c.a], b = pts[c.b];
if (c.kind === 'cloth') {
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
}
}
ctx.strokeStyle = `hsla(${o.hue}, 90%, 70%, 0.6)`;
ctx.stroke();
// stress overlay
if (ui.stress)
this.drawStress(ctx, e, o, o.hue);
}
/** Rope / chain: single stroked polyline. */
drawChain(ctx, e, o, ui) {
const pts = e.points;
// walk contiguous live segments
ctx.lineWidth = 2.5;
ctx.beginPath();
let started = false;
for (let k = 0; k < o.pts.length; k++) {
const i = o.pts[k];
if (k > 0) {
// find if a constraint connects o.pts[k-1]..o.pts[k]
const ci = o.cons.find(c => {
const cc = e.cons[c];
return cc && !cc.dead && ((cc.a === o.pts[k - 1] && cc.b === i) || (cc.b === o.pts[k - 1] && cc.a === i));
});
if (ci === undefined)
started = false;
}
const p = pts[i];
if (!started) {
ctx.moveTo(p.x, p.y);
started = true;
}
else
ctx.lineTo(p.x, p.y);
}
ctx.strokeStyle = `hsla(${o.hue}, 90%, 65%, 0.9)`;
ctx.stroke();
if (ui.stress)
this.drawStress(ctx, e, o, o.hue);
}
drawSoft(ctx, e, o, ui) {
const pts = e.points;
const ring = o.ring;
if (ring.length < 3)
return;
ctx.beginPath();
ctx.moveTo(pts[ring[0]].x, pts[ring[0]].y);
for (let i = 1; i < ring.length; i++)
ctx.lineTo(pts[ring[i]].x, pts[ring[i]].y);
ctx.closePath();
ctx.fillStyle = `hsla(${o.hue}, 80%, 65%, 0.22)`;
ctx.fill();
ctx.strokeStyle = `hsla(${o.hue}, 90%, 70%, 0.85)`;
ctx.lineWidth = 2.5;
ctx.stroke();
if (ui.stress)
this.drawStress(ctx, e, o, o.hue);
}
drawBall(ctx, e, o, _ui) {
const p = e.points[o.pts[0]];
if (!p)
return;
const g = ctx.createRadialGradient(p.x - p.r * 0.35, p.y - p.r * 0.35, p.r * 0.15, p.x, p.y, p.r);
g.addColorStop(0, `hsla(${o.hue}, 90%, 82%, 0.95)`);
g.addColorStop(1, `hsla(${o.hue}, 80%, 52%, 0.9)`);
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = g;
ctx.fill();
}
/** Color live constraints of one object by stretch (green→red). */
drawStress(ctx, e, o, hue) {
const pts = e.points;
for (const ci of o.cons) {
const c = e.cons[ci];
if (!c || c.dead)
continue;
const a = pts[c.a], b = pts[c.b];
const d = Math.hypot(b.x - a.x, b.y - a.y);
const t = Math.min(1, Math.max(0, (d / c.rest - 1) / 0.35));
ctx.strokeStyle = t > 0.04 ? this.stressColor(t) : `hsla(${hue}, 85%, 62%, 0.5)`;
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
}
drawPoints(ctx, e, ui) {
const showDots = ui.showDots;
if (!showDots && !ui.showPins)
return;
for (const p of e.points) {
if (p.pinned) {
if (!ui.showPins)
continue;
ctx.fillStyle = '#ffd166';
ctx.beginPath();
ctx.arc(p.x, p.y, 4, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.6)';
ctx.lineWidth = 1;
ctx.stroke();
}
else if (showDots) {
ctx.fillStyle = 'rgba(220, 235, 250, 0.5)';
ctx.beginPath();
ctx.arc(p.x, p.y, config_1.CFG.POINT_DOTS, 0, Math.PI * 2);
ctx.fill();
}
}
}
drawGrab(ctx, e, ui) {
if (!ui.grab || !ui.grab.active)
return;
const g = ui.grab;
const p = e.points[g.p];
// link from cursor to point
ctx.strokeStyle = 'rgba(255, 255, 255, 0.35)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(g.x, g.y);
if (p)
ctx.lineTo(p.x, p.y);
ctx.stroke();
ctx.setLineDash([]);
// ring at cursor
ctx.beginPath();
ctx.arc(g.x, g.y, config_1.CFG.GRAB_RADIUS * 0.5, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(120, 220, 255, 0.8)';
ctx.lineWidth = 2;
ctx.stroke();
if (p) {
ctx.beginPath();
ctx.arc(p.x, p.y, 5, 0, Math.PI * 2);
ctx.fillStyle = '#78dcff';
ctx.fill();
}
}
drawPreview(ctx, pv) {
ctx.setLineDash([6, 5]);
ctx.lineWidth = 1.5;
ctx.strokeStyle = 'rgba(200, 220, 240, 0.5)';
if (pv.kind === 'rope') {
ctx.beginPath();
ctx.moveTo(pv.x1, pv.y1);
ctx.lineTo(pv.x2, pv.y2);
ctx.stroke();
ctx.beginPath();
ctx.arc(pv.x1, pv.y1, 4, 0, Math.PI * 2);
ctx.fillStyle = '#4cc9f0';
ctx.fill();
}
else {
ctx.strokeRect(Math.min(pv.x1, pv.x2), Math.min(pv.y1, pv.y2), Math.abs(pv.x2 - pv.x1), Math.abs(pv.y2 - pv.y1));
}
ctx.setLineDash([]);
}
drawCutFX(ctx, ui) {
if (!ui.cutting || !ui.cutLast || !ui.mouse.down)
return;
const l = ui.cutLast, m = ui.mouse;
ctx.strokeStyle = 'rgba(255, 90, 120, 0.9)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(l.x, l.y);
ctx.lineTo(m.x, m.y);
ctx.stroke();
// knife glow at tip
const g = ctx.createRadialGradient(m.x, m.y, 0, m.x, m.y, config_1.CFG.CUT_RADIUS * 1.6);
g.addColorStop(0, 'rgba(255, 90, 120, 0.5)');
g.addColorStop(1, 'rgba(255, 90, 120, 0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(m.x, m.y, config_1.CFG.CUT_RADIUS * 1.6, 0, Math.PI * 2);
ctx.fill();
}
drawParticles(ctx) {
for (const p of this.particles) {
const t = 1 - p.life / p.maxLife;
ctx.globalAlpha = t;
ctx.fillStyle = `hsl(${p.hue}, 95%, 70%)`;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * t + 0.4, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
drawGhosts(ctx) {
for (const g of this.ghosts) {
ctx.globalAlpha = g.life * 0.6;
ctx.fillStyle = `hsl(${g.hue}, 90%, 70%)`;
ctx.beginPath();
ctx.arc(g.x, g.y, 2, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
drawPaused(ctx, w, h) {
ctx.fillStyle = 'rgba(10, 13, 20, 0.25)';
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = 'rgba(200, 220, 245, 0.9)';
ctx.font = '600 15px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('PAUSED — → to step, Space to resume', w / 2, 44);
ctx.textAlign = 'left';
}
}
exports.Renderer = Renderer;
function lerp(a, b, t) { return a + (b - a) * t; }
};
// ── module: src/interaction.ts ──
__mods["src/interaction.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Interaction = void 0;
exports.setClothPinEdge = setClothPinEdge;
// ============================================================================
// INTERACTION — mouse + keyboard: grab/throw, cut, pin, spawn tools
// ============================================================================
const config_1 = require("./config");
const factories_1 = require("./factories");
class Interaction {
constructor(canvas, engine, renderer, cb = {}) {
this.canvas = canvas;
this.engine = engine;
this.renderer = renderer;
this.cb = cb;
this.tool = 'grab';
this.paused = false;
this.mouse = { x: 0, y: 0, down: false };
this.grab = { active: false, p: -1, x: 0, y: 0, vx: 0, vy: 0, prevX: 0, prevY: 0 };
this.cut = { active: false, x: 0, y: 0, prevX: 0, prevY: 0 };
this.preview = null;
this.spawnPos = null;
this.lastCut = { x: 0, y: 0 };
this.lastT = performance.now();
this.lastClick = null;
this.bind();
}
// ----------------------------------------------------------------- binding
bind() {
const cv = this.canvas;
cv.addEventListener('contextmenu', e => e.preventDefault());
cv.addEventListener('mousedown', e => this.onDown(e));
window.addEventListener('mousemove', e => this.onMove(e));
window.addEventListener('mouseup', e => this.onUp(e));
window.addEventListener('keydown', e => this.onKey(e));
window.addEventListener('blur', () => { if (this.grab.active)
this.releaseGrab(true); });
}
pos(e) {
const r = this.canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
// ----------------------------------------------------------------- mouse
onDown(e) {
const { x, y } = this.pos(e);
this.mouse = { x, y, down: true };
this.lastT = performance.now();
// shift+click = toggle pin (always)
if (e.shiftKey) {
const i = this.engine.nearestPoint(x, y, config_1.CFG.GRAB_RADIUS);
if (i >= 0) {
const p = this.engine.points[i];
this.engine.setPin(i, !p.pinned);
if (!p.pinned) {
p.px = p.x;
p.py = p.y;
}
this.renderer.spawnSparks(p.x, p.y, p.pinned ? 48 : 200, 8);
}
this.mouse.down = false;
return;
}
if (e.button === 2 || this.tool === 'cut') {
// cut mode
this.cut = { active: true, x, y, prevX: x, prevY: y };
this.lastCut = { x, y };
cv_class(this.canvas, 'cutting', true);
return;
}
// left button
if (this.tool === 'rope' || this.tool === 'cloth') {
this.preview = { kind: this.tool, x1: x, y1: y, x2: x, y2: y };
this.spawnPos = { x, y };
}
else if (this.tool === 'blob' || this.tool === 'box' || this.tool === 'ball') {
this.spawnPos = { x, y };
}
else { // grab
const i = this.engine.nearestPoint(x, y, config_1.CFG.GRAB_RADIUS);
if (i >= 0) {
this.grab = { active: true, p: i, x, y, vx: 0, vy: 0, prevX: x, prevY: y };
this.engine.grab = this.grab;
this.canvas.classList.add('grabbing');
}
}
}
onUpLeft(x, y) {
// double-tap on a point removes its object
this.tryRemoveAt(x, y);
}
onMove(e) {
const { x, y } = this.pos(e);
const now = performance.now();
const dt = Math.max((now - this.lastT) / 1000, 1e-4);
this.lastT = now;
this.mouse.x = x;
this.mouse.y = y;
if (this.preview) {
this.preview.x2 = x;
this.preview.y2 = y;
}
else if (this.grab.active) {
const p = this.engine.points[this.grab.p];
if (this.paused) {
// paused: reposition under cursor with zero velocity (scene editing)
if (p) {
p.x = x;
p.y = y;
p.px = x;
p.py = y;
}
this.grab.vx = 0;
this.grab.vy = 0;
}
else {
// smoothed cursor velocity (for throw)
const vx = (x - this.grab.prevX) / dt;
const vy = (y - this.grab.prevY) / dt;
this.grab.vx = this.grab.vx * 0.6 + vx * 0.4;
this.grab.vy = this.grab.vy * 0.6 + vy * 0.4;
}
this.grab.x = x;
this.grab.y = y;
this.grab.prevX = x;
this.grab.prevY = y;
}
if (this.cut.active) {
this.cut.prevX = this.cut.x;
this.cut.prevY = this.cut.y;
this.cut.x = x;
this.cut.y = y;
const n = this.engine.cutSegment(this.cut.prevX, this.cut.prevY, x, y);
if (n > 0) {
this.renderer.spawnSparks(x, y, 350, 6);
this.engine.pruneDead();
}
this.lastCut = { x: this.cut.x, y: this.cut.y };
}
}
onUp(e) {
const wasDown = this.mouse.down;
this.mouse.down = false;
if (this.preview && this.spawnPos && wasDown) {
const p0 = this.spawnPos, p1 = { x: this.mouse.x, y: this.mouse.y };
if (this.preview.kind === 'rope') {
if (Math.hypot(p1.x - p0.x, p1.y - p0.y) > 14)
(0, factories_1.spawnRope)(this.engine, p0.x, p0.y, p1.x, p1.y);
}
else {
const w = Math.abs(p1.x - p0.x), h = Math.abs(p1.y - p0.y);
if (w > 24 && h > 24)
(0, factories_1.spawnCloth)(this.engine, p0.x, p0.y, p1.x, p1.y, clothPinEdge());
}
this.cb.onCounts?.();
}
else if (this.spawnPos && wasDown && this.tool !== 'grab') {
// single-click tools: spawn on release at pointer
const { x, y } = this.spawnPos;
if (this.tool === 'blob')
(0, factories_1.spawnBlob)(this.engine, x, y);
else if (this.tool === 'box')
(0, factories_1.spawnBox)(this.engine, x, y);
else if (this.tool === 'ball')
(0, factories_1.spawnBall)(this.engine, x, y);
this.cb.onCounts?.();
}
else if (this.grab.active) {
this.releaseGrab(false);
if (this.tool === 'grab')
this.onUpLeft(this.mouse.x, this.mouse.y);
}
this.preview = null;
this.spawnPos = null;
this.cut.active = false;
cv_class(this.canvas, 'cutting', false);
}
releaseGrab(silent) {
this.grab.active = false;
this.engine.grab = null;
this.canvas.classList.remove('grabbing');
if (!silent) {
// throw! (free points only — pins keep their new anchor)
const i = this.grab.p;
const p = this.engine.points[i];
if (p && !p.pinned) {
const vx = clamp(this.grab.vx, -config_1.CFG.THROW_CAP, config_1.CFG.THROW_CAP) * this.engine.dt;
const vy = clamp(this.grab.vy, -config_1.CFG.THROW_CAP, config_1.CFG.THROW_CAP) * this.engine.dt;
p.px = p.x - vx;
p.py = p.y - vy;
this.renderer.spawnSparks(p.x, p.y, 197, 6);
}
}
}
/** Double-tap (within 320ms / 10px) on an object's point removes the object.
* Implemented explicitly (no reliance on native dblclick from synthetic events). */
tryRemoveAt(x, y) {
const i = this.engine.nearestPoint(x, y, config_1.CFG.GRAB_RADIUS * 1.5);
if (i < 0)
return false;
const now = performance.now();
const lc = this.lastClick;
if (lc && now - lc.t < 320 && Math.hypot(x - lc.x, y - lc.y) < 10) {
this.lastClick = null;
this.removeObjectAt(i);
return true;
}
this.lastClick = { x, y, t: now, p: i };
return false;
}
removeObjectAt(i) {
const p = this.engine.points[i];
if (!p || p.obj < 0)
return;
const obj = this.engine.objs.find(o => o.id === p.obj);
if (!obj)
return;
this.renderer.spawnGhosts(obj, this.engine.points);
this.engine.removeObj(p.obj);
this.cb.onCounts?.();
}
// --------------------------------------------------------------- keyboard
onKey(e) {
const k = e.key;
if (k === ' ' || k === 'Spacebar') {
e.preventDefault();
this.setPaused(!this.paused);
}
else if ((k === 'ArrowRight' || k === 'ArrowUp') && this.paused) {
e.preventDefault();
this.engine.step();
}
else if (k === 'r' || k === 'R') {
this.cb.onReset?.();
}
else if (k === 'c' || k === 'C') {
this.setTool('cut');
}
else if (k === 'g' || k === 'G') {
this.setTool('grab');
}
else if (k >= '1' && k <= '5') {
const tools = ['rope', 'cloth', 'blob', 'box', 'ball'];
this.setTool(tools[+k - 1]);
}
}
// ------------------------------------------------------------- public API
setTool(t) {
if (this.tool === t && t !== 'cut')
return;
this.tool = t;
this.cb.onTool?.(t);
}
setPaused(p) {
this.paused = p;
this.cb.onPaused?.(p);
}
reset() { this.cb.onReset?.(); }
clearAll() {
this.engine.clear();
this.renderer.particles.length = 0;
this.cb.onCounts?.();
}
/** Live UI snapshot for the renderer. */
uiForRender() {
return {
grab: this.grab, mouse: this.mouse, preview: this.preview,
paused: this.paused, cutting: this.cut.active, cutLast: this.lastCut,
};
}
}
exports.Interaction = Interaction;
// ------------------------------------------------------------------ helpers
function cv_class(el, name, on) { el.classList.toggle(name, on); }
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
// cloth pin-mode preference lives on main (checkbox); default edge
let pinEdgePref = true;
function setClothPinEdge(b) { pinEdgePref = b; }
function clothPinEdge() { return pinEdgePref; }
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"Qwen3.8","provider":"brigid","persona":"typescript","sessionId":"2e1ca1a3-eee3-453c-94b4-d8a14b05704c","tokensIn":6163735,"tokensOut":191222,"tokensTotal":6354957,"cost":0,"turns":101,"toolCalls":118,"failedToolCalls":3,"timestamp":"2026-09-09T10:52:54.222Z"} -->