Physics Playground
Qwen 3.8 27B 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 55,414 bytes · SHA-256 c3a4f189bfe1
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Soft-Body Physics Playground — Verlet</title>
<style>
/* ── css: layout ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; overflow: hidden; background: #0b0f17; }
body {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: #b8c7e0;
user-select: none;
-webkit-user-select: none;
}
#app { display: flex; flex-direction: column; height: 100vh; }
#topbar {
display: flex; align-items: center; gap: 14px;
padding: 7px 12px;
background: #0e1420;
border-bottom: 1px solid #1c2638;
flex-wrap: wrap;
}
#brand { font-size: 13px; font-weight: 700; letter-spacing: 2px; color: #e8f0ff; white-space: nowrap; }
#brand .sub { color: #4d6285; font-weight: 400; letter-spacing: 1px; font-size: 10px; margin-left: 6px; }
.toolgroup { display: flex; gap: 4px; }
.tool {
background: #131b2b; color: #8fa3c4; border: 1px solid #223049;
border-radius: 6px; padding: 5px 9px; font: 12px ui-monospace, monospace;
cursor: pointer; transition: all .12s;
}
.tool:hover { background: #1a2438; color: #d3e2ff; }
.tool.active { background: #24406b; border-color: #3f6db3; color: #eaf3ff; box-shadow: 0 0 10px rgba(70,130,220,.25) inset; }
.ctrl { display: flex; gap: 10px; margin-left: auto; flex-wrap: wrap; }
.chk { font-size: 11px; color: #8fa3c4; cursor: pointer; display: flex; align-items: center; gap: 4px; }
.chk input { accent-color: #4d8fe0; cursor: pointer; }
#midbar {
display: flex; align-items: center; gap: 16px;
padding: 5px 12px;
background: #0c111c;
border-bottom: 1px solid #17202f;
flex-wrap: wrap;
}
.slider { font-size: 11px; color: #7e92b5; display: flex; align-items: center; gap: 6px; }
.slider input[type=range] { width: 110px; accent-color: #4d8fe0; cursor: pointer; }
.slider .val { color: #d3e2ff; min-width: 34px; }
.slider.g { display: flex; }
.slider.g input[type=checkbox] { accent-color: #4d8fe0; }
.btns { display: flex; gap: 5px; margin-left: auto; }
.btns button {
background: #131b2b; color: #b8c7e0; border: 1px solid #223049;
border-radius: 6px; padding: 5px 10px; font: 11px ui-monospace, monospace; cursor: pointer;
}
.btns button:hover { background: #1c2740; color: #fff; }
#stage { flex: 1; position: relative; min-height: 320px; }
#sim { position: absolute; inset: 0; display: block; touch-action: none; }
#bottombar {
display: flex; align-items: center; justify-content: space-between;
padding: 5px 12px;
background: #0e1420;
border-top: 1px solid #1c2638;
font-size: 11px;
}
#hud { color: #6fdd9a; letter-spacing: .5px; }
#help { color: #46587a; }
@media (max-width: 900px) {
#help { display: none; }
.ctrl { margin-left: 0; }
.btns { margin-left: 0; }
}
</style>
</head>
<body>
<div id="app">
<header id="topbar">
<div id="brand">SOFT-BODY PLAYGROUND <span class="sub">verlet · pbd</span></div>
<div class="toolgroup" id="tools">
<button class="tool active" data-tool="grab" title="Grab / throw (V)">🖐 grab</button>
<button class="tool" data-tool="rope" title="Drag to draw a rope (O)">🪢 rope</button>
<button class="tool" data-tool="cloth" title="Drag a rectangle — cloth (C)">🕸 cloth</button>
<button class="tool" data-tool="soft" title="Drag a circle — soft body (B)">🟡 blob</button>
<button class="tool" data-tool="ball" title="Click — ball (X)">🔴 ball</button>
<button class="tool" data-tool="tear" title="Drag to slice (T)">✂ tear</button>
</div>
<div class="ctrl">
<label class="chk"><input type="checkbox" id="chk-stress"> stress</label>
<label class="chk"><input type="checkbox" id="chk-points"> points</label>
<label class="chk"><input type="checkbox" id="chk-trail"> trail</label>
<label class="chk" title="Pin the whole top edge instead of both corners"><input type="checkbox" id="chk-full"> cloth: full edge</label>
</div>
</header>
<div id="midbar">
<label class="slider g">
<input type="checkbox" id="chk-gravity" checked=""> g:
<input type="range" id="sld-gravity" min="0" max="2400" step="50" value="1200">
<span class="val" id="val-gravity">1200</span>
</label>
<label class="slider">
iter:
<input type="range" id="sld-iter" min="1" max="12" step="1" value="5">
<span class="val" id="val-iter">5</span>
</label>
<label class="slider">
wind:
<input type="range" id="sld-wind" min="-100" max="100" step="5" value="0">
<span class="val" id="val-wind">0</span>
</label>
<div class="btns">
<button id="btn-pause" title="Space">❚❚ Pause</button>
<button id="btn-step" title="ArrowRight — step one frame while paused">Step ▸</button>
<button id="btn-reset" title="R">Reset</button>
<button id="btn-clear">Clear</button>
</div>
</div>
<main id="stage"><canvas id="sim"></canvas></main>
<footer id="bottombar">
<div id="hud">—</div>
<div id="help">LMB grab · RMB or Shift-drag tear · Shift/P + click pin · Space pause · → step · V/C/O/X/B/T tools</div>
</footer>
</div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./engine":"src/engine.ts","./config":"src/config.ts","./scene":"src/scene.ts","./render":"src/render.ts","./input":"src/input.ts","./ui":"src/ui.ts"},"src/engine.ts":{"./config":"src/config.ts"},"src/scene.ts":{"./engine":"src/engine.ts"},"src/render.ts":{"./engine":"src/engine.ts","./config":"src/config.ts"},"src/input.ts":{"./engine":"src/engine.ts","./config":"src/config.ts"},"src/ui.ts":{"./engine":"src/engine.ts","./input":"src/input.ts","./scene":"src/scene.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 — canvas setup, fixed-timestep loop, HUD, window.sim
// ============================================================
const engine_1 = require("./engine");
const config_1 = require("./config");
const scene_1 = require("./scene");
const render_1 = require("./render");
const input_1 = require("./input");
const ui_1 = require("./ui");
const stage = document.getElementById('stage');
const canvas = document.getElementById('sim');
const stageEl = document.getElementById('stage');
function measure() {
const r = stageEl.getBoundingClientRect();
return { W: Math.max(640, Math.round(r.width)), H: Math.max(480, Math.round(r.height)) };
}
const sim = new engine_1.Sim();
const fx = { tears: [] };
let W = 0, H = 0;
const { W: iw, H: ih } = measure();
W = iw;
H = ih;
(0, scene_1.buildDemo)(sim, W, H);
(0, render_1.ensureCanvasSize)(canvas, W, H);
const ctx = canvas.getContext('2d');
const inputOpts = { tearHeld: false, pinHeld: false, clothFullEdge: false };
const input = new input_1.Input(canvas, sim, inputOpts, {
onTear: (x, y, count) => {
for (let i = 0; i < count && fx.tears.length < 90; i++) {
fx.tears.push({ x: x + (Math.random() - 0.5) * 18, y: y + (Math.random() - 0.5) * 18, born: performance.now() });
}
},
onPin: () => { },
onGrab: () => { },
onRelease: () => { },
onSpawn: (tool, x1, y1, x2, y2) => {
if (tool === 'rope')
sim.spawnRope(x1, y1, x2, y2);
else if (tool === 'cloth')
sim.spawnCloth(x1, y1, x2, y2, inputOpts.clothFullEdge);
else if (tool === 'soft') {
const r = Math.max(24, Math.min(90, Math.hypot(x2 - x1, y2 - y1) * 0.5));
sim.spawnSoft((x1 + x2) / 2, (y1 + y2) / 2, r);
}
else if (tool === 'ball') {
const r = Math.max(10, Math.min(26, Math.hypot(x2 - x1, y2 - y1) * 0.5));
sim.spawnBall(x1, y1, r);
}
},
onToolEnd: () => { },
});
// re-layout on resize: keep bounds in sync (sim keeps running)
const onResize = () => {
const { W: nw, H: nh } = measure();
if (nw === W && nh === H)
return;
W = nw;
H = nh;
sim.bounds = { minX: 8, maxX: W - 8, minY: 8, maxY: H - 14 };
(0, render_1.ensureCanvasSize)(canvas, W, H);
};
window.addEventListener('resize', onResize);
// FPS + physics time readout
let frames = 0, lastFps = performance.now(), fps = 60;
let physMs = 0;
const physAvg = { v: 0 };
(0, ui_1.wireUI)(sim, input, canvas, W, H, {
reset: () => (0, scene_1.buildDemo)(sim, W, H),
clearAll: () => { },
}, () => ({ fps, ms: physAvg.v, points: sim.points.length, constraints: sim.constraints.filter(c => c.active).length, objects: sim.objects.length }));
let last = performance.now();
let acc = 0;
let rafScheduled = false;
function frame(now) {
// ---- fixed timestep accumulator with max delta clamp ----
let delta = (now - last) / 1000;
last = now;
if (delta > config_1.CFG.MAX_FRAME_DELTA)
delta = config_1.CFG.MAX_FRAME_DELTA; // tab-switch guard
if (!sim.paused) {
acc += delta;
let steps = 0;
const t0 = performance.now();
while (acc >= config_1.CFG.DT && steps < config_1.CFG.MAX_CATCHUP) {
sim.step();
acc -= config_1.CFG.DT;
steps++;
}
if (steps >= config_1.CFG.MAX_CATCHUP)
acc = 0; // drop excess time, never spiral
const ms = performance.now() - t0;
physAvg.v = physMs ? physMs * 0.9 + ms * 0.1 : ms;
physMs = ms;
}
// ---- render ----
const p = input.preview();
(0, render_1.render)(ctx, sim, W, H, {
showStress: document.getElementById('chk-stress').checked,
showPoints: document.getElementById('chk-points').checked,
trail: document.getElementById('chk-trail').checked,
hover: input.hover,
grab: input.activeGrab(),
now,
}, fx);
if (p)
drawPreview(ctx, p.tool, p.ax, p.ay, p.bx, p.by);
const t = input.activeTear();
if (t) {
ctx.strokeStyle = '#ff8fb3';
ctx.lineWidth = 2.5;
ctx.setLineDash([6, 4]);
ctx.beginPath();
ctx.moveTo(t.x, t.y);
ctx.lineTo(t.x, t.y + 1);
ctx.stroke();
ctx.setLineDash([]);
}
if (sim.paused)
drawPausedBadge(ctx, W);
frames++;
if (now - lastFps >= 500) {
fps = Math.round(frames * 1000 / (now - lastFps));
frames = 0;
lastFps = now;
}
if (!rafScheduled) {
rafScheduled = true;
requestAnimationFrame((t) => { rafScheduled = false; frame(t); });
}
}
function drawPreview(ctx, tool, ax, ay, bx, by) {
ctx.save();
ctx.setLineDash([5, 4]);
ctx.lineWidth = 2;
if (tool === 'rope') {
ctx.strokeStyle = '#38d9ff';
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(bx, by);
ctx.stroke();
}
else if (tool === 'cloth') {
ctx.strokeStyle = '#ff5fd0';
ctx.strokeRect(Math.min(ax, bx), Math.min(ay, by), Math.abs(bx - ax), Math.abs(by - ay));
}
else if (tool === 'soft') {
ctx.strokeStyle = '#ffc94d';
const r = Math.max(24, Math.min(90, Math.hypot(bx - ax, by - ay) * 0.5));
ctx.beginPath();
ctx.arc((ax + bx) / 2, (ay + by) / 2, r, 0, 6.2832);
ctx.stroke();
}
else if (tool === 'ball') {
ctx.strokeStyle = '#ff5c5c';
const r = Math.max(10, Math.min(26, Math.hypot(bx - ax, by - ay) * 0.5));
ctx.beginPath();
ctx.arc(ax, ay, r, 0, 6.2832);
ctx.stroke();
}
ctx.restore();
}
function drawPausedBadge(ctx, W) {
ctx.fillStyle = 'rgba(10,14,22,0.75)';
ctx.fillRect(W / 2 - 120, 40, 240, 30);
ctx.fillStyle = '#cfe3ff';
ctx.font = '13px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.fillText('PAUSED — → steps one frame', W / 2, 60);
ctx.textAlign = 'left';
}
// expose for headless probing / console play
window.sim = {
sim,
get points() { return sim.points; },
get constraints() { return sim.constraints; },
get objects() { return sim.objects; },
get statics() { return sim.statics; },
get paused() { return sim.paused; },
set paused(v) { sim.paused = v; },
step: (n = 1) => { for (let i = 0; i < n; i++)
sim.step(); },
frame: (now) => frame(now ?? performance.now()),
clear: () => sim.clear(),
spawnRope: (a, b, c, d) => sim.spawnRope(a, b, c, d),
spawnCloth: (a, b, c, d, full = false) => sim.spawnCloth(a, b, c, d, full),
spawnSoft: (x, y, r) => sim.spawnSoft(x, y, r ?? 46),
spawnBall: (x, y, r) => sim.spawnBall(x, y, r ?? 14),
tear: (a, b, c, d) => sim.tear(a, b, c, d),
togglePin: (x, y) => sim.togglePin(x, y),
grab: (x, y) => sim.nearestPoint(x, y, config_1.CFG.GRAB_RADIUS),
reset: () => (0, scene_1.buildDemo)(sim, W, H),
getStats: () => ({
fps, ms: physAvg.v, points: sim.points.length,
constraints: sim.constraints.filter(c => c.active).length,
objects: sim.objects.length,
kinetic: kineticEnergy(sim),
paused: sim.paused,
}),
};
function kineticEnergy(s) {
const dt = config_1.CFG.DT;
let e = 0;
for (const p of s.points) {
if (p.pinned)
continue;
const vx = (p.x - p.px) / dt, vy = (p.y - p.py) / dt;
e += 0.5 * (1 / Math.max(p.invMass, 1e-9)) * (vx * vx + vy * vy);
}
return e;
}
requestAnimationFrame((t) => { last = t; requestAnimationFrame(frame); });
function start() { }
};
// ── module: src/config.ts ──
__mods["src/config.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.TRAIL_ALPHA = exports.COLORS = exports.CFG = void 0;
exports.hexA = hexA;
exports.clamp = clamp;
// ============================================================
// TUNABLE CONSTANTS — everything gameplay/physics relevant
// ============================================================
exports.CFG = {
DT: 1 / 60, // fixed physics timestep (60 Hz)
MAX_FRAME_DELTA: 0.25, // clamp on accumulated frame delta (tab-switch safety)
MAX_CATCHUP: 5, // max physics steps per rendered frame
GRAVITY: 1200, // px / s^2
DAMPING: 0.9965, // velocity retained per step
ITERATIONS: 5, // constraint relaxation passes per step
MAX_SPEED: 45, // px per step — hard velocity cap (anti-tunneling)
FRICTION: 0.18, // tangential velocity lost per contact
TEAR_RADIUS: 14, // px — constraints this close to the cut line are severed
GRAB_RADIUS: 30, // px — click this near a point to grab it
CELL_SIZE: 64, // spatial hash cell for point-vs-point collision
HOVER_RADIUS: 22, // px — hover highlight reach
};
exports.COLORS = {
bg: '#0b0f17',
wall: '#232c3b',
floorFill: 'rgba(44,56,80,0.16)',
static: '#131a29',
staticLine: '#31405e',
rope: '#38d9ff',
cloth: '#ff5fd0',
soft: '#ffc94d',
ball: '#ff5c5c',
pin: '#eaf2ff',
hover: '#7dffce',
stress: ['#3dffa0', '#a4ff4d', '#ffe45c', '#ff9e40', '#ff4d5e'], // green -> red
};
exports.TRAIL_ALPHA = 0.38; // bg repaint alpha when motion-blur trail is on
function hexA(hex, a) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r},${g},${b},${a})`;
}
function clamp(v, lo, hi) {
return v < lo ? lo : v > hi ? hi : v;
}
};
// ── module: src/engine.ts ──
__mods["src/engine.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Sim = void 0;
exports.segSeg = segSeg;
exports.distPtSeg = distPtSeg;
// ============================================================
// CORE ENGINE — Verlet integration + constraint relaxation
// Position-based dynamics: integrate, then iterate
// (distance constraints, static collisions, point collisions)
// ============================================================
const config_1 = require("./config");
// ---------- geometry helpers ----------
function cross(ax, ay, bx, by, cx, cy) {
return (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
}
/** strict segment/segment intersection */
function segSeg(ax, ay, bx, by, cx, cy, dx, dy) {
const o1 = cross(ax, ay, bx, by, cx, cy);
const o2 = cross(ax, ay, bx, by, dx, dy);
const o3 = cross(cx, cy, dx, dy, ax, ay);
const o4 = cross(cx, cy, dx, dy, bx, by);
return o1 * o2 < 0 && o3 * o4 < 0;
}
/** point-to-segment distance (segment may be degenerate) */
function distPtSeg(px, py, ax, ay, bx, by) {
const dx = bx - ax, dy = by - ay;
const l2 = dx * dx + dy * dy;
let t = l2 > 1e-12 ? ((px - ax) * dx + (py - ay) * dy) / l2 : 0;
t = (0, config_1.clamp)(t, 0, 1);
const qx = ax + dx * t, qy = ay + dy * t;
return Math.hypot(px - qx, py - qy);
}
// ============================================================
// SIM
// ============================================================
class Sim {
constructor() {
this.points = [];
this.constraints = [];
this.objects = [];
this.statics = [];
this.bounds = { minX: 8, maxX: 800, minY: 8, maxY: 600 };
this.gravity = config_1.CFG.GRAVITY;
this.wind = 0; // -100..100
this.damping = config_1.CFG.DAMPING;
this.iterations = config_1.CFG.ITERATIONS;
this.paused = false;
this.time = 0;
this.stepCount = 0;
this.nextId = 1;
/** point-vs-point collision via spatial hash (gives balls/soft bodies volume) */
this.grid = new Map();
}
// ---------- lifecycle ----------
clear() {
this.points.length = 0;
this.constraints.length = 0;
this.objects.length = 0;
this.statics.length = 0;
this.time = 0;
this.stepCount = 0;
this.nextId = 1;
}
addPoint(x, y, invMass = 1, r = 3, bounce = 0.05, pp = true) {
this.points.push({ x, y, px: x, py: y, invMass, r, bounce, pp, pinned: false, obj: -1 });
return this.points.length - 1;
}
addConstraint(a, b, kind, obj) {
const pa = this.points[a], pb = this.points[b];
const d = Math.hypot(pb.x - pa.x, pb.y - pa.y);
this.constraints.push({ a, b, rest: Math.max(d, 0.001), active: true, kind, obj });
return this.constraints.length - 1;
}
// ---------- spawning ----------
spawnRope(x1, y1, x2, y2) {
const id = this.nextId++;
const dx = x2 - x1, dy = y2 - y1;
const len = Math.max(Math.hypot(dx, dy), 40);
const n = Math.min(60, Math.max(6, Math.round(len / 16)));
const pts = [];
for (let i = 0; i <= n; i++) {
const t = i / n;
const idx = this.addPoint(x1 + dx * t, y1 + dy * t, 1, 3, 0.05, false);
this.points[idx].obj = id;
if (i === 0)
this.points[idx].pinned = true;
pts.push(idx);
}
const cons = [];
for (let i = 0; i < n; i++)
cons.push(this.addConstraint(pts[i], pts[i + 1], 'struct', id));
// small weight on the free end gives a pendulum feel
const tip = this.addPoint(x2, y2, 0.35, 10, 0.25);
this.points[tip].obj = id;
cons.push(this.addConstraint(pts[n], tip, 'link', id));
const obj = { id, type: 'rope', color: config_1.COLORS.rope, pts, cons, tip };
this.objects.push(obj);
return obj;
}
spawnCloth(x1, y1, x2, y2, pinFullEdge) {
const id = this.nextId++;
const ax = Math.min(x1, x2), ay = Math.min(y1, y2);
const w = Math.max(Math.abs(x2 - x1), 80);
const h = Math.max(Math.abs(y2 - y1), 80);
const cols = Math.min(40, Math.max(4, Math.round(w / 22)));
const rows = Math.min(32, Math.max(4, Math.round(h / 22)));
const idx = [];
for (let r = 0; r < rows; r++) {
const row = [];
for (let c = 0; c < cols; c++) {
const p = this.addPoint(ax + (w / (cols - 1)) * c, ay + (h / (rows - 1)) * r, 1, 5, 0.05, false);
this.points[p].obj = id;
row.push(p);
}
idx.push(row);
}
const cons = [];
const hC = [];
const vC = [];
for (let r = 0; r < rows; r++) {
hC.push([]);
for (let c = 0; c < cols - 1; c++)
hC[r].push(this.addConstraint(idx[r][c], idx[r][c + 1], 'struct', id));
}
for (let r = 0; r < rows - 1; r++) {
vC.push([]);
for (let c = 0; c < cols; c++)
vC[r].push(this.addConstraint(idx[r][c], idx[r + 1][c], 'struct', id));
}
for (let r = 0; r < rows - 1; r++) {
for (let c = 0; c < cols - 1; c++) {
this.addConstraint(idx[r][c], idx[r + 1][c + 1], 'shear', id);
this.addConstraint(idx[r][c + 1], idx[r + 1][c], 'shear', id);
}
}
const cells = [];
for (let r = 0; r < rows - 1; r++) {
for (let c = 0; c < cols - 1; c++) {
cells.push([
hC[r][c], hC[r + 1][c], vC[r][c], vC[r][c + 1],
idx[r][c], idx[r][c + 1], idx[r + 1][c + 1], idx[r + 1][c],
]);
}
}
if (pinFullEdge) {
for (let c = 0; c < cols; c++)
this.points[idx[0][c]].pinned = true;
}
else {
this.points[idx[0][0]].pinned = true;
this.points[idx[0][cols - 1]].pinned = true;
}
const pts = [];
for (const row of idx)
pts.push(...row);
const consIdx = [];
for (let i = 0; i < this.constraints.length; i++)
if (this.constraints[i].obj === id)
consIdx.push(i);
const obj = {
id, type: 'cloth', color: config_1.COLORS.cloth,
pts: pts, cons: consIdx, cells, cols, rows,
};
this.objects.push(obj);
return obj;
}
spawnSoft(x, y, r) {
const id = this.nextId++;
r = (0, config_1.clamp)(r, 24, 120);
const n = 16;
const ring = [];
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2 - Math.PI / 2;
const p = this.addPoint(x + Math.cos(a) * r, y + Math.sin(a) * r, 1, 4, 0.05);
this.points[p].obj = id;
ring.push(p);
}
const center = this.addPoint(x, y, 1, 2, 0.05);
this.points[center].obj = id;
const cons = [];
for (let i = 0; i < n; i++) {
cons.push(this.addConstraint(ring[i], ring[(i + 1) % n], 'edge', id));
cons.push(this.addConstraint(ring[i], center, 'brace', id));
}
for (let i = 0; i < n / 2; i++)
cons.push(this.addConstraint(ring[i], ring[(i + n / 2) % n], 'brace', id));
const obj = { id, type: 'soft', color: config_1.COLORS.soft, pts: [...ring, center], cons, ring, center };
this.objects.push(obj);
return obj;
}
spawnBall(x, y, r) {
const id = this.nextId++;
r = (0, config_1.clamp)(r, 10, 30);
const p = this.addPoint(x, y, 1, r, 0.6);
this.points[p].obj = id;
const obj = { id, type: 'ball', color: config_1.COLORS.ball, pts: [p], cons: [], radius: r };
this.objects.push(obj);
return obj;
}
// ---------- interaction queries ----------
nearestPoint(x, y, maxDist = config_1.CFG.GRAB_RADIUS) {
let best = -1;
let bd = maxDist * maxDist;
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;
if (d2 < bd) {
bd = d2;
best = i;
}
}
return best;
}
/** toggle pin on the nearest point; returns index or -1 */
togglePin(x, y, maxDist = config_1.CFG.GRAB_RADIUS) {
const i = this.nearestPoint(x, y, maxDist);
if (i < 0)
return -1;
const p = this.points[i];
p.pinned = !p.pinned;
if (p.pinned) {
p.px = p.x;
p.py = p.y;
}
return i;
}
/** sever every constraint hit by the segment (or within TEAR_RADIUS of it) */
tear(x1, y1, x2, y2) {
const hits = [];
let count = 0;
for (let i = 0; i < this.constraints.length; i++) {
const c = this.constraints[i];
if (!c.active)
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) ||
distPtSeg(a.x, a.y, x1, y1, x2, y2) < config_1.CFG.TEAR_RADIUS ||
distPtSeg(b.x, b.y, x1, y1, x2, y2) < config_1.CFG.TEAR_RADIUS) {
c.active = false;
count++;
if (hits.length < 80)
hits.push({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
}
}
return { count, hits };
}
// ---------- physics step (fixed DT) ----------
step() {
const dt = config_1.CFG.DT, dt2 = dt * dt;
this.time += dt;
this.stepCount++;
const pts = this.points;
const g = this.gravity, w = this.windAccel(), damp = this.damping;
// -- Verlet integration --
for (let i = 0; i < pts.length; i++) {
const p = pts[i];
if (p.pinned) {
p.px = p.x;
p.py = p.y;
continue;
}
let vx = (p.x - p.px) * damp;
let vy = (p.y - p.py) * damp;
const sp2 = vx * vx + vy * vy;
if (sp2 > config_1.CFG.MAX_SPEED * config_1.CFG.MAX_SPEED) {
const s = config_1.CFG.MAX_SPEED / Math.sqrt(sp2);
vx *= s;
vy *= s;
}
p.px = p.x;
p.py = p.y;
p.x += vx + w * dt2;
p.y += vy + g * dt2;
}
// -- constraint relaxation + collisions --
for (let it = 0; it < this.iterations; it++) {
this.solveConstraints();
this.collideStatics();
this.collidePoints();
}
this.sanitize();
}
windAccel() {
if (this.wind === 0)
return 0;
const t = this.time;
const gust = 0.55 * Math.sin(t * 1.4) + 0.3 * Math.sin(t * 3.7 + 1.2) + 0.15 * Math.sin(t * 7.1);
return this.wind * 7 * gust;
}
solveConstraints() {
const cs = this.constraints, pts = this.points;
for (let i = 0; i < cs.length; i++) {
const c = cs[i];
if (!c.active)
continue;
const pa = pts[c.a], pb = pts[c.b];
const dx = pb.x - pa.x, dy = pb.y - pa.y;
const d2 = dx * dx + dy * dy;
if (d2 < 1e-12)
continue; // coincident points — skip
const d = Math.sqrt(d2);
const wa = pa.pinned ? 0 : pa.invMass;
const wb = pb.pinned ? 0 : pb.invMass;
const wsum = wa + wb;
if (wsum <= 0)
continue; // both pinned — skip
const k = (d - c.rest) / (d * wsum);
pa.x += dx * k * wa;
pa.y += dy * k * wa;
pb.x -= dx * k * wb;
pb.y -= dy * k * wb;
}
}
/** push a point out of a plane; adjust implicit velocity (bounce + friction) */
applyContact(p, nx, ny, pen) {
if (pen <= 0 || p.pinned)
return;
const vx = p.x - p.px, vy = p.y - p.py;
p.x += nx * pen;
p.y += ny * pen;
const vn = vx * nx + vy * ny;
if (vn < 0) {
const tx = vx - vn * nx, ty = vy - vn * ny;
const f = 1 - config_1.CFG.FRICTION;
p.px = p.x - (tx * f - vn * p.bounce * nx);
p.py = p.y - (ty * f - vn * p.bounce * ny);
}
}
collideStatics() {
const b = this.bounds, pts = this.points;
for (let i = 0; i < pts.length; i++) {
const p = pts[i];
// bounding walls / floor / ceiling
if (p.y > b.maxY - p.r)
this.applyContact(p, 0, -1, p.y - (b.maxY - p.r));
if (p.y < b.minY + p.r)
this.applyContact(p, 0, 1, (b.minY + p.r) - p.y);
if (p.x < b.minX + p.r)
this.applyContact(p, 1, 0, (b.minX + p.r) - p.x);
if (p.x > b.maxX - p.r)
this.applyContact(p, -1, 0, p.x - (b.maxX - p.r));
// static obstacles
for (let s = 0; s < this.statics.length; s++) {
const st = this.statics[s];
if (st.kind === 'circle')
this.collideCircle(p, st);
else
this.collideBox(p, st);
}
}
}
collideCircle(p, s) {
const dx = p.x - s.x, dy = p.y - s.y;
const rr = s.r + p.r;
const d2 = dx * dx + dy * dy;
if (d2 >= rr * rr)
return;
const d = Math.sqrt(d2);
if (d < 1e-9) {
this.applyContact(p, 0, -1, rr);
return;
} // coincident — push up
this.applyContact(p, dx / d, dy / d, rr - d);
}
collideBox(p, s) {
const cx = (0, config_1.clamp)(p.x, s.x, s.x + s.w);
const cy = (0, config_1.clamp)(p.y, s.y, s.y + s.h);
const dx = p.x - cx, dy = p.y - cy;
const d2 = dx * dx + dy * dy;
if (d2 > p.r * p.r)
return;
if (d2 < 1e-12) {
// point inside the box — exit through the nearest face
const dl = p.x - s.x, dr = s.x + s.w - p.x;
const dt = p.y - s.y, db = s.y + s.h - p.y;
if (dl <= dr && dl <= dt && dl <= db)
this.applyContact(p, -1, 0, dl + p.r);
else if (dr <= dl && dr <= dt && dr <= db)
this.applyContact(p, 1, 0, dr + p.r);
else if (dt <= dl && dt <= dr && dt <= db)
this.applyContact(p, 0, -1, dt + p.r);
else
this.applyContact(p, 0, 1, db + p.r);
}
else {
const d = Math.sqrt(d2);
this.applyContact(p, dx / d, dy / d, p.r - d);
}
}
collidePoints() {
const pts = this.points, cell = config_1.CFG.CELL_SIZE;
const grid = this.grid;
grid.clear();
const key = (cx, cy) => (cx + 2048) * 8192 + (cy + 2048);
for (let i = 0; i < pts.length; i++) {
const p = pts[i];
if (!p.pp)
continue;
const cx = Math.floor(p.x / cell), cy = Math.floor(p.y / cell);
const k = key(cx, cy);
let arr = grid.get(k);
if (!arr) {
arr = [];
grid.set(k, arr);
}
arr.push(i);
}
for (let i = 0; i < pts.length; i++) {
const p = pts[i];
if (!p.pp)
continue;
const cx = Math.floor(p.x / cell), cy = Math.floor(p.y / cell);
for (let gx = cx - 1; gx <= cx + 1; gx++) {
for (let gy = cy - 1; gy <= cy + 1; gy++) {
const arr = grid.get(key(gx, gy));
if (!arr)
continue;
for (let n = 0; n < arr.length; n++) {
const j = arr[n];
if (j <= i)
continue;
const q = pts[j];
const dx = q.x - p.x, dy = q.y - p.y;
const rr = p.r + q.r;
const d2 = dx * dx + dy * dy;
if (d2 >= rr * rr || d2 < 1e-12)
continue;
const d = Math.sqrt(d2);
const nx = dx / d, ny = dy / d;
const wa = p.pinned ? 0 : p.invMass;
const wb = q.pinned ? 0 : q.invMass;
const wsum = wa + wb;
if (wsum <= 0)
continue;
const pen = (rr - d) / wsum;
p.x -= nx * pen * wa;
p.y -= ny * pen * wa;
q.x += nx * pen * wb;
q.y += ny * pen * wb;
}
}
}
}
}
/** no NaN / Infinity escapes — restore to previous (finite) position */
sanitize() {
const L = 1e6;
for (let i = 0; i < this.points.length; i++) {
const p = this.points[i];
if (!Number.isFinite(p.x))
p.x = Number.isFinite(p.px) ? p.px : 0;
if (!Number.isFinite(p.y))
p.y = Number.isFinite(p.py) ? p.py : 0;
if (!Number.isFinite(p.px))
p.px = p.x;
if (!Number.isFinite(p.py))
p.py = p.y;
p.x = (0, config_1.clamp)(p.x, -L, L);
p.y = (0, config_1.clamp)(p.y, -L, L);
p.px = (0, config_1.clamp)(p.px, -L, L);
p.py = (0, config_1.clamp)(p.py, -L, L);
}
}
}
exports.Sim = Sim;
};
// ── module: src/scene.ts ──
__mods["src/scene.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.setupScene = setupScene;
exports.buildDemo = buildDemo;
function setupScene(sim, W, H) {
sim.bounds = { minX: 8, maxX: W - 8, minY: 8, maxY: H - 14 };
sim.statics.length = 0;
// platform on the left — a cloth dropped here drapes over it
sim.statics.push({ kind: 'box', x: W * 0.16, y: H * 0.62, w: W * 0.16, h: 22 });
// pedestal the big static circle sits on
sim.statics.push({ kind: 'box', x: W * 0.5 - 55, y: H * 0.75, w: 110, h: H * 0.23 });
// the big static circle — its top overlaps the demo cloth's lower edge so it drapes over it
sim.statics.push({ kind: 'circle', x: W * 0.5, y: H * 0.75 - 74, r: 74 });
}
/** the default playground layout — pendulum rope, draped cloth, blob, ball */
function buildDemo(sim, W, H) {
sim.clear();
setupScene(sim, W, H);
sim.spawnRope(W * 0.14, H * 0.13, W * 0.14, H * 0.5);
sim.spawnCloth(W * 0.415, H * 0.10, W * 0.585, H * 0.44, true);
sim.spawnSoft(W * 0.76, H * 0.3, 46);
sim.spawnBall(W * 0.87, H * 0.2, 16);
}
};
// ── module: src/render.ts ──
__mods["src/render.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.render = render;
exports.clearEffects = clearEffects;
exports.ensureCanvasSize = ensureCanvasSize;
const config_1 = require("./config");
function render(ctx, sim, W, H, opts, fx) {
const P = sim.points;
// ---- background / motion-blur trail ----
if (opts.trail) {
ctx.fillStyle = config_1.COLORS.bg;
ctx.globalAlpha = config_1.TRAIL_ALPHA;
ctx.fillRect(0, 0, W, H);
ctx.globalAlpha = 1;
}
else {
ctx.fillStyle = config_1.COLORS.bg;
ctx.fillRect(0, 0, W, H);
}
drawBounds(ctx, sim, W, H);
drawStatics(ctx, sim);
// group constraints by object for per-material stroke color
const byObj = new Map();
for (let i = 0; i < sim.constraints.length; i++) {
const c = sim.constraints[i];
if (!c.active)
continue;
let arr = byObj.get(c.obj);
if (!arr) {
arr = [];
byObj.set(c.obj, arr);
}
arr.push(i);
}
for (const obj of sim.objects) {
const list = byObj.get(obj.id);
if (!list || !list.length)
continue;
drawConstraintSet(ctx, sim, obj, list, opts.showStress);
}
// fill bodies
for (const obj of sim.objects) {
if (obj.type === 'ball')
drawBall(ctx, P[obj.pts[0]], obj.color);
else if (obj.type === 'soft' && obj.ring)
drawSoftBody(ctx, obj.ring.map(i => P[i]), obj.color);
}
// pins (all objects)
ctx.fillStyle = config_1.COLORS.pin;
for (let i = 0; i < P.length; i++) {
if (P[i].pinned) {
ctx.beginPath();
ctx.arc(P[i].x, P[i].y, 3.2, 0, 6.2832);
ctx.fill();
}
}
// points (when enabled — rope/soft/ball always show dots)
if (opts.showPoints) {
ctx.fillStyle = 'rgba(230,240,255,0.9)';
for (let i = 0; i < P.length; i++) {
const p = P[i];
ctx.beginPath();
ctx.arc(p.x, p.y, 2.2, 0, 6.2832);
ctx.fill();
}
}
// hover + grab highlights
if (opts.hover >= 0 && opts.hover !== opts.grab) {
const p = P[opts.hover];
ctx.strokeStyle = (0, config_1.hexA)(config_1.COLORS.hover, 0.9);
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(p.x, p.y, 7, 0, 6.2832);
ctx.stroke();
}
if (opts.grab >= 0) {
const p = P[opts.grab];
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(p.x, p.y, 8.5, 0, 6.2832);
ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.beginPath();
ctx.arc(p.x, p.y, 4, 0, 6.2832);
ctx.fill();
}
// tear sparks
const now = opts.now;
for (let i = fx.tears.length - 1; i >= 0; i--) {
const t = fx.tears[i];
const age = (now - t.born) / 450;
if (age >= 1) {
fx.tears.splice(i, 1);
continue;
}
ctx.strokeStyle = (0, config_1.hexA)('#ff8fb3', 1 - age);
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(t.x, t.y, 4 + age * 10, 0, 6.2832);
ctx.stroke();
}
}
function drawBounds(ctx, sim, W, H) {
const b = sim.bounds;
ctx.fillStyle = config_1.COLORS.floorFill;
ctx.fillRect(0, b.maxY, W, H - b.maxY);
ctx.strokeStyle = config_1.COLORS.wall;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(b.minX, 0);
ctx.lineTo(b.minX, b.maxY);
ctx.moveTo(b.maxX, 0);
ctx.lineTo(b.maxX, b.maxY);
ctx.moveTo(0, b.maxY);
ctx.lineTo(W, b.maxY);
ctx.stroke();
}
function drawStatics(ctx, sim) {
for (const s of sim.statics) {
ctx.fillStyle = config_1.COLORS.static;
ctx.strokeStyle = config_1.COLORS.staticLine;
ctx.lineWidth = 1.5;
if (s.kind === 'circle') {
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, 6.2832);
ctx.fill();
ctx.stroke();
}
else {
ctx.fillRect(s.x, s.y, s.w, s.h);
ctx.strokeRect(s.x, s.y, s.w, s.h);
}
}
}
function drawConstraintSet(ctx, sim, obj, list, showStress) {
const P = sim.points;
if (obj.type === 'cloth') {
if (showStress) {
ctx.lineWidth = 1.2;
for (const ci of list) {
const c = sim.constraints[ci];
const a = P[c.a], b = P[c.b];
const d = Math.hypot(b.x - a.x, b.y - a.y);
const t = d > 1e-6 ? (d / c.rest - 1) : 0;
ctx.strokeStyle = stressColor(t);
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
}
else {
ctx.strokeStyle = (0, config_1.hexA)(obj.color, 0.75);
ctx.lineWidth = 1.1;
ctx.beginPath();
for (const ci of list) {
const c = sim.constraints[ci];
const a = P[c.a], b = P[c.b];
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
}
ctx.stroke();
}
}
else {
ctx.strokeStyle = obj.color;
ctx.lineWidth = obj.type === 'rope' ? 3 : 2.4;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
if (showStress) {
for (const ci of list) {
const c = sim.constraints[ci];
const a = P[c.a], b = P[c.b];
const d = Math.hypot(b.x - a.x, b.y - a.y);
const t = d > 1e-6 ? (d / c.rest - 1) : 0;
ctx.strokeStyle = stressColor(t);
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
}
else {
ctx.beginPath();
if (obj.type === 'rope') {
ctx.moveTo(P[obj.pts[0]].x, P[obj.pts[0]].y);
for (let i = 1; i < obj.pts.length; i++)
ctx.lineTo(P[obj.pts[i]].x, P[obj.pts[i]].y);
if (obj.tip != null)
ctx.lineTo(P[obj.tip].x, P[obj.tip].y);
}
else if (obj.ring) {
ctx.moveTo(P[obj.ring[0]].x, P[obj.ring[0]].y);
for (let i = 1; i < obj.ring.length; i++)
ctx.lineTo(P[obj.ring[i]].x, P[obj.ring[i]].y);
ctx.closePath();
}
ctx.stroke();
}
// dots
if (obj.type === 'rope') {
ctx.fillStyle = obj.color;
const a = P[obj.pts[0]];
ctx.beginPath();
ctx.arc(a.x, a.y, 3, 0, 6.2832);
ctx.fill();
if (obj.tip != null) {
const t = P[obj.tip];
ctx.beginPath();
ctx.arc(t.x, t.y, t.r * 0.55, 0, 6.2832);
ctx.fill();
}
}
else if (obj.ring && obj.center != null) {
ctx.fillStyle = (0, config_1.hexA)(obj.color, 0.8);
const c = P[obj.center];
ctx.beginPath();
ctx.arc(c.x, c.y, 2.5, 0, 6.2832);
ctx.fill();
}
}
}
function drawSoftBody(ctx, ring, color) {
const P = ring;
ctx.beginPath();
ctx.moveTo(P[0].x, P[0].y);
for (let i = 1; i < P.length; i++)
ctx.lineTo(P[i].x, P[i].y);
ctx.closePath();
ctx.fillStyle = (0, config_1.hexA)(color, 0.2);
ctx.fill();
}
function drawBall(ctx, p, color) {
const r = p.r;
ctx.fillStyle = (0, config_1.hexA)(color, 0.92);
ctx.beginPath();
ctx.arc(p.x, p.y, r, 0, 6.2832);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.beginPath();
ctx.arc(p.x - r * 0.3, p.y - r * 0.35, r * 0.28, 0, 6.2832);
ctx.fill();
}
function stressColor(t) {
// t = (dist/rest - 1); stretch > 0 red, compression < 0 stays greenish
const v = clamp01(Math.abs(t) * 3.2);
const stops = config_1.COLORS.stress;
const f = v * (stops.length - 1);
const i = Math.min(stops.length - 2, Math.floor(f));
const k = f - i;
return lerpHex(stops[i], stops[i + 1], k);
}
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
function lerpHex(a, b, t) {
const ar = parseInt(a.slice(1, 3), 16), ag = parseInt(a.slice(3, 5), 16), ab = parseInt(a.slice(5, 7), 16);
const br = parseInt(b.slice(1, 3), 16), bg = parseInt(b.slice(3, 5), 16), bb = parseInt(b.slice(5, 7), 16);
const r = Math.round(ar + (br - ar) * t);
const g = Math.round(ag + (bg - ag) * t);
const bl = Math.round(ab + (bb - ab) * t);
return `rgb(${r},${g},${bl})`;
}
function clearEffects(fx) { fx.tears.length = 0; }
function ensureCanvasSize(canvas, W, H) {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const pw = Math.round(W * dpr), ph = Math.round(H * dpr);
if (canvas.width !== pw || canvas.height !== ph) {
canvas.width = pw;
canvas.height = ph;
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
}
}
};
// ── module: src/input.ts ──
__mods["src/input.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Input = void 0;
const config_1 = require("./config");
class Input {
constructor(canvas, sim, opts, hooks) {
this.canvas = canvas;
this.sim = sim;
this.opts = opts;
this.hooks = hooks;
this.tool = 'grab';
this.hover = -1;
this.down = false;
this.mode = 'grab';
this.grabIdx = -1;
this.sx = 0;
this.sy = 0;
this.lx = 0;
this.ly = 0;
this.vx = 0;
this.vy = 0;
this.spawnFrom = null;
this.lastTear = { x: 0, y: 0 };
canvas.addEventListener('pointerdown', (e) => this.pointerdown(e));
canvas.addEventListener('pointermove', (e) => this.pointermove(e));
window.addEventListener('pointerup', (e) => this.pointerup(e));
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
}
toCanvas(e) {
const r = this.canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
pointerdown(e) {
if (this.down)
return;
const { x, y } = this.toCanvas(e);
this.down = true;
this.sx = x;
this.sy = y;
this.lx = x;
this.ly = y;
this.vx = 0;
this.vy = 0;
// right button always tears
if (e.button === 2 || (this.opts.tearHeld && e.button === 0)) {
this.mode = 'tear';
this.lastTear = { x, y };
const res = this.sim.tear(x - 1, y - 1, x + 1, y + 1);
this.hooks.onTear(x, y, res.count);
return;
}
if (e.button !== 0)
return;
if (this.opts.pinHeld) {
this.mode = 'pin';
const idx = this.sim.togglePin(x, y);
if (idx >= 0)
this.hooks.onPin(x, y, idx);
return;
}
const spawn = this.tool === 'rope' ? 'rope' : this.tool === 'cloth' ? 'cloth' :
this.tool === 'soft' ? 'soft' : this.tool === 'ball' ? 'ball' : null;
if (spawn === 'rope' || spawn === 'cloth') {
this.mode = 'spawn';
return;
}
if (spawn === 'soft' || spawn === 'ball') {
this.mode = 'spawn';
this.spawnFrom = spawn;
return;
}
// grab tool
const idx = this.sim.nearestPoint(x, y, config_1.CFG.GRAB_RADIUS);
if (idx < 0)
return;
this.mode = 'grab';
this.grabIdx = idx;
const p = this.sim.points[idx];
p.px = p.x;
p.py = p.y;
this.hooks.onGrab(idx);
}
pointermove(e) {
const { x, y } = this.toCanvas(e);
if (!this.down) {
this.hover = this.sim.nearestPoint(x, y, config_1.CFG.HOVER_RADIUS);
this.canvas.style.cursor = this.hover >= 0 ? 'grab' : this.tool === 'grab' ? 'default' : 'crosshair';
return;
}
const dx = x - this.lx, dy = y - this.ly;
this.vx = dx;
this.vy = dy;
this.lx = x;
this.ly = y;
if (this.mode === 'tear') {
const res = this.sim.tear(this.lastTear.x, this.lastTear.y, x, y);
if (res.count > 0)
this.hooks.onTear(x, y, res.count);
this.lastTear = { x, y };
return;
}
if (this.mode === 'grab' && this.grabIdx >= 0) {
const p = this.sim.points[this.grabIdx];
// set position + implicit velocity so the solver reacts live
p.px = p.x;
p.py = p.y;
p.x = x;
p.y = y;
this.canvas.style.cursor = 'grabbing';
}
}
pointerup(e) {
if (!this.down)
return;
this.down = false;
const { x, y } = this.toCanvas(e);
if (this.mode === 'grab' && this.grabIdx >= 0) {
const p = this.sim.points[this.grabIdx];
p.px = p.x - this.vx;
p.py = p.y - this.vy;
this.hooks.onRelease(this.grabIdx);
this.grabIdx = -1;
}
else if (this.mode === 'spawn' && this.spawnFrom) {
this.hooks.onSpawn(this.spawnFrom, this.sx, this.sy, x, y);
this.spawnFrom = null;
this.hooks.onToolEnd();
}
else if (this.mode === 'spawn') {
if (Math.hypot(x - this.sx, y - this.sy) > 20) {
this.hooks.onSpawn(this.tool, this.sx, this.sy, x, y);
this.hooks.onToolEnd();
}
}
if (this.mode === 'grab')
this.canvas.style.cursor = this.hover >= 0 ? 'grab' : 'default';
this.mode = 'grab';
}
/** live preview drag: {tool, ax, ay, bx, by} | null */
preview() {
if (!this.down || this.mode !== 'spawn')
return null;
return { tool: this.tool, ax: this.sx, ay: this.sy, bx: this.lx, by: this.ly };
}
activeGrab() { return this.grabIdx; }
activeTear() {
return this.down && this.mode === 'tear' ? { x: this.lx, y: this.ly } : null;
}
}
exports.Input = Input;
};
// ── module: src/ui.ts ──
__mods["src/ui.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildDemo = void 0;
exports.wireUI = wireUI;
const scene_1 = require("./scene");
Object.defineProperty(exports, "buildDemo", { enumerable: true, get: function () { return scene_1.buildDemo; } });
const $ = (id) => document.getElementById(id);
function wireUI(sim, input, canvas, W, H, signals, getStats) {
// ---- tool buttons ----
const toolBtns = Array.from(document.querySelectorAll('[data-tool]'));
const setTool = (t) => {
input.tool = t;
toolBtns.forEach(b => b.classList.toggle('active', b.dataset.tool === t));
};
toolBtns.forEach(b => b.addEventListener('click', () => setTool(b.dataset.tool)));
// ---- checkboxes ----
const chkStress = $('chk-stress');
const chkPoints = $('chk-points');
const chkTrail = $('chk-trail');
const chkFull = $('chk-full');
const chkGravity = $('chk-gravity');
chkFull.addEventListener('change', () => { input.opts.clothFullEdge = chkFull.checked; });
// ---- sliders ----
const gravSlider = $('sld-gravity');
const iterSlider = $('sld-iter');
const windSlider = $('sld-wind');
const valGrav = $('val-gravity');
const valIter = $('val-iter');
const valWind = $('val-wind');
chkGravity.checked = sim.gravity > 0;
const syncGravity = () => {
sim.gravity = chkGravity.checked ? parseFloat(gravSlider.value) : 0;
gravSlider.disabled = !chkGravity.checked;
if (valGrav)
valGrav.textContent = sim.gravity === 0 ? 'off' : String(Math.round(sim.gravity));
};
chkGravity.addEventListener('change', syncGravity);
gravSlider.addEventListener('input', syncGravity);
syncGravity();
iterSlider.value = String(sim.iterations);
if (valIter)
valIter.textContent = String(sim.iterations);
iterSlider.addEventListener('input', () => {
sim.iterations = parseInt(iterSlider.value, 10);
if (valIter)
valIter.textContent = iterSlider.value;
});
windSlider.value = String(sim.wind);
if (valWind)
valWind.textContent = '0';
windSlider.addEventListener('input', () => {
sim.wind = parseFloat(windSlider.value);
if (valWind)
valWind.textContent = String(Math.round(sim.wind));
});
// ---- pause / step / reset / clear ----
const btnPause = $('btn-pause');
const btnStep = $('btn-step');
const pauseLabel = () => { btnPause.textContent = sim.paused ? '▶ Play' : '❚❚ Pause'; };
const setPaused = (v) => { sim.paused = v; pauseLabel(); };
btnPause.addEventListener('click', () => setPaused(!sim.paused));
btnStep.addEventListener('click', () => { if (!sim.paused)
setPaused(true); sim.step(); });
$('btn-reset').addEventListener('click', () => signals.reset());
$('btn-clear').addEventListener('click', () => { sim.clear(); signals.clearAll(); });
// ---- keyboard ----
const onKey = (e) => {
const tag = e.target?.tagName;
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') {
if (e.code === 'Space')
e.target.blur();
else
return;
}
switch (e.code) {
case 'Space':
e.preventDefault();
setPaused(!sim.paused);
break;
case 'ArrowRight':
e.preventDefault();
if (sim.paused)
sim.step();
break;
case 'KeyR':
signals.reset();
break;
case 'KeyV':
setTool('grab');
break;
case 'KeyT':
setTool('tear');
break;
case 'KeyC':
setTool('cloth');
break;
case 'KeyB':
setTool('soft');
break;
case 'KeyX':
setTool('ball');
break;
case 'KeyO':
setTool('rope');
break;
case 'KeyP':
input.opts.pinHeld = true;
break;
case 'KeyShift':
input.opts.tearHeld = true;
break;
}
};
const onKeyUp = (e) => {
if (e.code === 'KeyP')
input.opts.pinHeld = false;
if (e.code === 'KeyShift')
input.opts.tearHeld = false;
};
window.addEventListener('keydown', onKey);
window.addEventListener('keyup', onKeyUp);
// ---- HUD (throttled) ----
const hud = $('hud');
let lastHud = 0;
const hudTick = (now) => {
if (now - lastHud < 250)
return;
lastHud = now;
const s = getStats();
hud.textContent = `${s.fps} fps · phys ${s.ms.toFixed(1)} ms · ${s.points} pts · ${s.constraints} cons · ${s.objects} objs${sim.paused ? ' · PAUSED' : ''}`;
};
const interval = setInterval(hudTick, 250);
return () => {
toolBtns.forEach(b => b.removeEventListener('click', (e) => setTool(b.dataset.tool)));
window.removeEventListener('keydown', onKey);
window.removeEventListener('keyup', onKeyUp);
clearInterval(interval);
};
}
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"unsloth/Qwen3.8-27B-GGUF","provider":"brigid","persona":"typescript","sessionId":"fe08d2bd-533e-46d1-a6cb-5531b5815f82","tokensIn":1347958,"tokensOut":137190,"tokensTotal":1485148,"cost":0,"turns":41,"toolCalls":61,"failedToolCalls":0,"timestamp":"2026-08-15T19:03:51.811Z"} -->