Physics Playground
Qwen 3.8 · full

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 43,153 bytes · SHA-256 dde3b79ed86b
<!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</title>
<style>
:root {
--bg: #0a0e16;
--panel: #0d1424;
--line: #1e2a44;
--text: #cbd5e1;
--dim: #64748b;
--rope: #ffb02e;
--cloth: #38bdf8;
--blob: #34d399;
--box: #a3e635;
--ball: #f472b6;
--cut: #fb7185;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; overflow: hidden; }
body {
background: var(--bg);
color: var(--text);
font: 12px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
display: flex; flex-direction: column;
user-select: none; -webkit-user-select: none;
}
#bar {
display: flex; align-items: center; flex-wrap: wrap; gap: 4px 10px;
padding: 7px 12px;
background: var(--panel);
border-bottom: 1px solid var(--line);
z-index: 5;
}
.brand { font-size: 12px; font-weight: 700; letter-spacing: .14em; color: #e2e8f0; margin-right: 4px; white-space: nowrap; }
.brand em { font-style: normal; font-weight: 400; color: var(--dim); letter-spacing: .04em; }
.group { display: flex; align-items: center; gap: 5px; padding: 0 9px; border-left: 1px solid var(--line); }
button {
font: inherit; font-size: 11px;
color: #94a3b8; background: #131c31; border: 1px solid #243252;
border-radius: 6px; padding: 4px 9px; cursor: pointer;
transition: background .12s, color .12s, border-color .12s;
}
button:hover { color: #e2e8f0; border-color: #33456e; }
button.tool.active { color: var(--c, #e2e8f0); border-color: var(--c, #e2e8f0); }
button.tool.active { box-shadow: inset 0 0 14px 0 color-mix(in srgb, var(--c, #fff) 20%, transparent); }
button.ctrl { min-width: 32px; }
label.sl { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--dim); white-space: nowrap; }
label.sl b { color: #94a3b8; font-weight: 600; font-variant-numeric: tabular-nums; min-width: 32px; text-align: right; }
input[type=range] { width: 72px; accent-color: #38bdf8; cursor: pointer; }
input[type=checkbox] { accent-color: #38bdf8; cursor: pointer; }
select {
font: inherit; font-size: 11px; color: #94a3b8;
background: #131c31; border: 1px solid #243252; border-radius: 6px; padding: 3px 4px; cursor: pointer;
}
#wrap { position: relative; flex: 1; min-height: 0; }
canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#hint {
position: absolute; left: 12px; bottom: 10px;
font-size: 11px; color: rgba(148,163,184,.65);
pointer-events: none; letter-spacing: .02em;
}
#hint b { color: rgba(226,232,240,.8); font-weight: 600; }
</style>
</head>
<body>
<div id="bar">
<div class="brand">SOFT·BODY <em>playground</em></div>
<div class="group" id="tools">
<button class="tool active" data-tool="grab" style="--c:#e2e8f0" title="Grab & throw (V)">grab</button>
<button class="tool" data-tool="rope" style="--c:var(--rope)" title="Draw a rope (1)">rope</button>
<button class="tool" data-tool="cloth" style="--c:var(--cloth)" title="Place cloth (2)">cloth</button>
<button class="tool" data-tool="blob" style="--c:var(--blob)" title="Soft blob (3)">blob</button>
<button class="tool" data-tool="box" style="--c:var(--box)" title="Soft box (4)">box</button>
<button class="tool" data-tool="ball" style="--c:var(--ball)" title="Drop a ball (5)">ball</button>
<button class="tool" data-tool="cut" style="--c:var(--cut)" title="Slice constraints (X)">✂ cut</button>
</div>
<div class="group">
<label class="sl">gravity <input type="range" id="sGrav" min="0" max="3000" step="50" value="1600"><b id="vGrav">1600</b></label>
<label class="sl">iter <input type="range" id="sIter" min="1" max="12" step="1" value="5"><b id="vIter">5</b></label>
<label class="sl">wind <input type="range" id="sWind" min="-800" max="800" step="20" value="0"><b id="vWind">0</b></label>
</div>
<div class="group">
<label class="sl"><input type="checkbox" id="cGrav" checked> g</label>
<label class="sl"><input type="checkbox" id="cStress"> stress</label>
<label class="sl"><input type="checkbox" id="cTrails"> trails</label>
<label class="sl">pins <select id="selPin"><option value="edge">top edge</option><option value="corners">corners</option></select></label>
</div>
<div class="group">
<button class="ctrl" id="bPause" title="Pause / resume (Space)">⏸</button>
<button class="ctrl" id="bStep" title="Step one frame (→)">⏭</button>
<button class="ctrl" id="bReset" title="Reset scene (R)">↺</button>
</div>
</div>
<div id="wrap">
<canvas id="cv"></canvas>
<div id="hint"></div>
</div>
<script>
'use strict';
/* ================================================================
1. TUNABLE CONSTANTS
================================================================ */
const CFG = {
DT: 1 / 60, // fixed physics timestep (s)
MAX_FRAME_DELTA: 0.25, // frame-delta clamp (tab-switch safety)
MAX_STEPS_PER_FRAME: 8, // spiral-of-death guard
DAMPING: 0.995, // velocity retained per step
MAX_VEL: 60, // px/step velocity clamp (stability guard)
REST_EPS: 0.05, // px/step; velocities below this are zeroed (anti-jitter)
ITERATIONS: 5, // constraint relaxation passes per step
GRAVITY: 1600, // px/s^2
WIND: 0, // px/s^2 horizontal
FRICTION: 0.8, // tangential velocity lost on soft-point contact (high = kills rolling)
STICK_EPS: 1.5, // px/step; tangential motion below this is zeroed on contact (anti-creep/anti-roll)
FLOOR_BAND: 8, // px; friction-only band above floor/top surfaces (wider contact patch, kills rolling)
BALL_FRICTION: 0.12, // tangential velocity lost on ball contact
RESTITUTION: 0.5, // ball bounce
POINT_RADIUS: 2, // collision radius for non-ball points
GRAB_RADIUS: 28, // px; nearest-point grab distance
TEAR_CLOTH: 2.3, // stretch ratio that rips cloth
TEAR_ROPE: 3.2, // stretch ratio that snaps rope
TEAR_SOFT: 2.6, // stretch ratio that tears soft bodies
FLOOR_H: 36, // floor thickness (px)
COLORS: {
bg: '#0a0e16',
grid: 'rgba(148,163,184,0.045)',
floorEdge: 'rgba(148,163,184,0.30)',
shape: '#101a2e',
shapeEdge: 'rgba(148,163,184,0.35)',
rope: '#ffb02e',
cloth: '#38bdf8',
blob: '#34d399',
box: '#a3e635',
ball: '#f472b6',
pin: '#e2e8f0',
tear: '#fb7185',
},
};
/* Live, user-tunable state (toolbar bound to this) */
const state = {
tool: 'grab',
paused: false,
gravityOn: true,
gravity: CFG.GRAVITY,
iterations: CFG.ITERATIONS,
wind: 0,
stress: false,
trails: false,
pinMode: 'edge',
};
/* ================================================================
2. WORLD / STATIC GEOMETRY
================================================================ */
const canvas = document.getElementById('cv');
const ctx = canvas.getContext('2d');
const hintEl = document.getElementById('hint');
let W = 800, H = 600;
let staticCircles = [], staticBoxes = [];
/* Simulation data */
let points = [], constraints = [], objects = [], particles = [];
let nextId = 1;
/* ================================================================
3. UTILS
================================================================ */
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
function hexA(hex, a) {
const n = parseInt(hex.slice(1), 16);
return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + a + ')';
}
function mix(a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; }
/* Stress color: compression -> blue, rest -> green, stretched -> yellow -> red */
const stressCache = new Map();
function stressColor(s) {
const key = Math.round(clamp(s, 0.4, 1.8) * 50);
let col = stressCache.get(key);
if (col) return col;
let c;
if (s <= 1) {
const t = clamp((1 - s) / 0.45, 0, 1);
c = mix([56, 189, 248], [96, 165, 250], t);
} else {
const t = clamp((s - 1) / 0.65, 0, 1);
c = t < 0.5 ? mix([52, 211, 153], [250, 204, 21], t * 2) : mix([250, 204, 21], [244, 63, 94], (t - 0.5) * 2);
}
col = 'rgb(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ')';
stressCache.set(key, col);
return col;
}
function resize() {
const dpr = Math.min(2, window.devicePixelRatio || 1);
W = canvas.clientWidth || window.innerWidth;
H = canvas.clientHeight || window.innerHeight;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
buildStatics();
for (const p of points) {
p.x = clamp(p.x, 2, W - 2);
p.y = Math.min(p.y, H - CFG.FLOOR_H - 2);
}
}
function buildStatics() {
staticCircles = [
{ x: W * 0.40, y: H * 0.52, r: clamp(H * 0.13, 40, 100) },
{ x: W * 0.63, y: H * 0.72, r: clamp(H * 0.07, 26, 55) },
];
staticBoxes = [
{ x: Math.min(W * 0.78, W - 165), y: H * 0.80, w: 150, h: 60 },
{ x: Math.max(12, W * 0.16), y: H * 0.84, w: 120, h: 40 },
];
}
/* ================================================================
4. POINTS & CONSTRAINTS
================================================================ */
function makePoint(x, y, type, obj, radius, pinned) {
return { id: nextId++, x, y, px: x, py: y, pinned: !!pinned, grabbed: false, radius: radius || 0, type, obj };
}
function makeConstraint(a, b, type, obj, tearAt) {
const rest = Math.hypot(b.x - a.x, b.y - a.y) || 1e-6;
return { a, b, rest, type, obj, broken: false, stretch: 1, tearAt: tearAt || 0 };
}
function addObject(obj) {
objects.push(obj);
for (const p of obj.points) points.push(p);
for (const c of obj.constraints) constraints.push(c);
return obj;
}
/* ================================================================
5. OBJECT FACTORIES
================================================================ */
function spawnRopeBetween(a, b, n) {
const dist = Math.hypot(b.x - a.x, b.y - a.y);
const count = n || clamp(Math.round(dist / 13), 6, 90);
const obj = { type: 'rope', points: [], constraints: [], color: CFG.COLORS.rope, hasBroken: false };
let prev = null;
for (let i = 0; i <= count; i++) {
const t = i / count;
const p = makePoint(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, 'rope', obj, 0, i === 0);
obj.points.push(p);
if (prev) obj.constraints.push(makeConstraint(prev, p, 'link', obj, CFG.TEAR_ROPE));
prev = p;
}
return addObject(obj);
}
function spawnCloth(x, y, cols, rows, sx, sy, pinMode) {
cols = clamp(cols, 3, 30); rows = clamp(rows, 2, 24);
pinMode = pinMode || 'edge';
const obj = { type: 'cloth', points: [], constraints: [], color: CFG.COLORS.cloth, hasBroken: false, grid: null };
const grid = [];
for (let r = 0; r < rows; r++) {
grid[r] = [];
for (let c = 0; c < cols; c++) {
const pinned = pinMode === 'edge' ? r === 0 : (r === 0 && (c === 0 || c === cols - 1));
grid[r][c] = makePoint(x + c * sx, y + r * sy, 'cloth', obj, 0, pinned);
obj.points.push(grid[r][c]);
}
}
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
const p = grid[r][c];
if (c < cols - 1) obj.constraints.push(makeConstraint(p, grid[r][c + 1], 'structural', obj, CFG.TEAR_CLOTH));
if (r < rows - 1) obj.constraints.push(makeConstraint(p, grid[r + 1][c], 'structural', obj, CFG.TEAR_CLOTH));
if (c < cols - 1 && r < rows - 1) {
obj.constraints.push(makeConstraint(p, grid[r + 1][c + 1], 'shear', obj, CFG.TEAR_CLOTH));
obj.constraints.push(makeConstraint(grid[r][c + 1], grid[r + 1][c], 'shear', obj, CFG.TEAR_CLOTH));
}
}
obj.grid = grid;
return addObject(obj);
}
function spawnSoftCircle(cx, cy, r, n) {
n = clamp(n || 14, 8, 24);
const obj = { type: 'soft', points: [], constraints: [], color: CFG.COLORS.blob, hasBroken: false, outline: null };
const ring = [];
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2 - Math.PI / 2;
const p = makePoint(cx + Math.cos(a) * r, cy + Math.sin(a) * r, 'soft', obj);
ring.push(p); obj.points.push(p);
}
const center = makePoint(cx, cy, 'soft', obj);
obj.points.push(center);
for (let i = 0; i < n; i++) {
obj.constraints.push(makeConstraint(ring[i], ring[(i + 1) % n], 'edge', obj, CFG.TEAR_SOFT));
obj.constraints.push(makeConstraint(center, ring[i], 'brace', obj, CFG.TEAR_SOFT));
}
const q = Math.round(n / 4);
for (let i = 0; i < n; i += q) obj.constraints.push(makeConstraint(ring[i], ring[(i + q) % n], 'brace', obj, CFG.TEAR_SOFT));
obj.outline = ring;
return addObject(obj);
}
function spawnSoftBox(x, y, w, h) {
const cols = 5, rows = 4;
const sx = w / (cols - 1), sy = h / (rows - 1);
const obj = { type: 'soft', points: [], constraints: [], color: CFG.COLORS.box, hasBroken: false, outline: null };
const grid = [];
for (let r = 0; r < rows; r++) {
grid[r] = [];
for (let c = 0; c < cols; c++) {
grid[r][c] = makePoint(x + c * sx, y + r * sy, 'soft', obj);
obj.points.push(grid[r][c]);
}
}
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
const p = grid[r][c];
if (c < cols - 1) obj.constraints.push(makeConstraint(p, grid[r][c + 1], 'structural', obj, CFG.TEAR_SOFT));
if (r < rows - 1) obj.constraints.push(makeConstraint(p, grid[r + 1][c], 'structural', obj, CFG.TEAR_SOFT));
if (c < cols - 1 && r < rows - 1) {
obj.constraints.push(makeConstraint(p, grid[r + 1][c + 1], 'brace', obj, CFG.TEAR_SOFT));
obj.constraints.push(makeConstraint(grid[r][c + 1], grid[r + 1][c], 'brace', obj, CFG.TEAR_SOFT));
}
}
obj.outline = perimeter(grid);
return addObject(obj);
}
function spawnBall(x, y, r) {
const obj = { type: 'ball', points: [], constraints: [], color: CFG.COLORS.ball, hasBroken: false };
obj.points.push(makePoint(x, y, 'ball', obj, r || 14, false));
return addObject(obj);
}
function perimeter(grid) {
const rows = grid.length, cols = grid[0].length;
const out = [];
for (let c = 0; c < cols; c++) out.push(grid[0][c]);
for (let r = 1; r < rows; r++) out.push(grid[r][cols - 1]);
for (let c = cols - 2; c >= 0; c--) out.push(grid[rows - 1][c]);
for (let r = rows - 2; r >= 1; r--) out.push(grid[r][0]);
return out;
}
/* ================================================================
6. PHYSICS STEP (Verlet + constraint relaxation)
================================================================ */
let grab = null; // { p, x, y }
let cut = null; // { x, y } current cut point while slicing
let cutTrail = [];
let cutTrailLife = 0;
function stepPhysics(dt) {
const dt2 = dt * dt;
const g = state.gravityOn ? state.gravity : 0;
const wind = state.wind;
/* --- 6.1 Verlet integration: x' = x + (x - xPrev)*damping + a*dt^2 --- */
for (let i = 0; i < points.length; i++) {
const p = points[i];
if (p.pinned) { p.px = p.x; p.py = p.y; continue; }
if (p.grabbed) {
p.px = p.x; p.py = p.y;
p.x = grab.x; p.y = grab.y;
let vx = p.x - p.px, vy = p.y - p.py;
const v2 = vx * vx + vy * vy;
if (v2 > CFG.MAX_VEL * CFG.MAX_VEL) {
const s = CFG.MAX_VEL / Math.sqrt(v2);
p.px = p.x - vx * s; p.py = p.y - vy * s;
}
continue;
}
let vx = (p.x - p.px) * CFG.DAMPING;
let vy = (p.y - p.py) * CFG.DAMPING;
const v2 = vx * vx + vy * vy;
if (v2 > CFG.MAX_VEL * CFG.MAX_VEL) { const s = CFG.MAX_VEL / Math.sqrt(v2); vx *= s; vy *= s; }
if (v2 < CFG.REST_EPS * CFG.REST_EPS) { vx = 0; vy = 0; } // anti-jitter: settle micro-velocity
p.px = p.x; p.py = p.y;
p.x += vx + wind * dt2;
p.y += vy + g * dt2;
}
/* --- 6.2 Constraint relaxation (distance constraints, N passes) --- */
const iters = state.iterations;
for (let it = 0; it < iters; it++) {
for (let i = 0; i < constraints.length; i++) {
const c = constraints[i];
if (c.broken) continue;
const a = c.a, b = c.b;
const dx = b.x - a.x, dy = b.y - a.y;
const d2 = dx * dx + dy * dy;
if (d2 < 1e-12) continue; // NaN guard: coincident points
const d = Math.sqrt(d2);
const wa = (a.pinned || a.grabbed) ? 0 : 1;
const wb = (b.pinned || b.grabbed) ? 0 : 1;
if (!wa && !wb) continue;
const diff = (d - c.rest) / d;
const s = 1 / (wa + wb);
a.x += dx * diff * s * wa; a.y += dy * diff * s * wa;
b.x -= dx * diff * s * wb; b.y -= dy * diff * s * wb;
}
}
/* --- 6.3 Collisions with static geometry (walls, floor, circles, boxes) --- */
for (let i = 0; i < points.length; i++) collidePoint(points[i]);
/* --- 6.4 Ball vs ball --- */
ballBall();
/* --- 6.5 Stress readout + auto-tear --- */
for (let i = 0; i < constraints.length; i++) {
const c = constraints[i];
if (c.broken) continue;
const dx = c.b.x - c.a.x, dy = c.b.y - c.a.y;
const d = Math.sqrt(dx * dx + dy * dy);
c.stretch = c.rest > 1e-9 ? d / c.rest : 1;
if (c.tearAt > 0 && c.stretch > c.tearAt) tearConstraint(c);
}
}
/* Point vs static shapes, with friction (and restitution for balls) */
function collidePoint(p) {
if (p.pinned || p.grabbed) return;
const r = p.radius || CFG.POINT_RADIUS;
const isBall = p.type === 'ball';
const floorY = H - CFG.FLOOR_H;
if (p.y > floorY - r) { p.y = floorY - r; surfaceResponse(p, 0, -1, isBall); }
else if (p.y > floorY - r - CFG.FLOOR_BAND) frictionOnly(p, 0, -1, isBall);
if (p.x < r) { p.x = r; surfaceResponse(p, 1, 0, isBall); }
else if (p.x > W - r) { p.x = W - r; surfaceResponse(p, -1, 0, isBall); }
for (let i = 0; i < staticCircles.length; i++) {
const c = staticCircles[i];
const rr = c.r + r;
const dx = p.x - c.x, dy = p.y - c.y;
const d2 = dx * dx + dy * dy;
if (d2 < rr * rr) {
let d = Math.sqrt(d2);
let nx, ny;
if (d < 1e-9) { nx = 0; ny = -1; } // NaN guard: point at circle center
else { nx = dx / d; ny = dy / d; }
p.x = c.x + nx * rr; p.y = c.y + ny * rr;
surfaceResponse(p, nx, ny, isBall);
} else if (d2 < (rr + CFG.FLOOR_BAND) * (rr + CFG.FLOOR_BAND)) {
const d = Math.sqrt(d2);
frictionOnly(p, dx / d, dy / d, isBall);
}
}
for (let i = 0; i < staticBoxes.length; i++) boxCollide(p, staticBoxes[i], isBall, r);
}
/* After projecting p onto a surface with outward normal (nx,ny):
kill normal velocity (restitution for balls), damp tangential (friction) */
function surfaceResponse(p, nx, ny, isBall) {
const vx = p.x - p.px, vy = p.y - p.py;
const vn = vx * nx + vy * ny;
const tx = vx - vn * nx, ty = vy - vn * ny;
const e = (isBall && vn < -0.8) ? CFG.RESTITUTION : 0; // micro-bounces -> inelastic (no jitter)
const vn2 = vn < 0 ? -vn * e : vn;
const keep = 1 - (isBall ? CFG.BALL_FRICTION : CFG.FRICTION);
/* stick threshold: kill tiny tangential motion so resting bodies don't creep */
const t2 = tx * tx + ty * ty;
const tkeep = t2 < CFG.STICK_EPS * CFG.STICK_EPS ? 0 : keep;
p.px = p.x - (vn2 * nx + tx * tkeep);
p.py = p.y - (vn2 * ny + ty * tkeep);
}
function frictionOnly(p, nx, ny, isBall) {
const vx = p.x - p.px, vy = p.y - p.py;
const vn = vx * nx + vy * ny;
if (vn > 0) return; // moving away from the surface
const tx = vx - vn * nx, ty = vy - vn * ny;
const t2 = tx * tx + ty * ty;
if (t2 < CFG.STICK_EPS * CFG.STICK_EPS) {
p.px = p.x - vn * nx; // kill all tangential motion -> stick
p.py = p.y - vn * ny;
} else {
const keep = 1 - (isBall ? CFG.BALL_FRICTION : CFG.FRICTION);
p.px = p.x - (vn * nx + tx * keep);
p.py = p.y - (vn * ny + ty * keep);
}
}
function boxCollide(p, b, isBall, r) {
/* friction band above the top face */
if (p.x > b.x - r && p.x < b.x + b.w + r && p.y > b.y - r - CFG.FLOOR_BAND && p.y < b.y - r) {
frictionOnly(p, 0, -1, isBall);
return;
}
const cx = clamp(p.x, b.x, b.x + b.w);
const cy = clamp(p.y, b.y, b.y + b.h);
const dx = p.x - cx, dy = p.y - cy;
const d2 = dx * dx + dy * dy;
if (d2 > r * r) return;
if (d2 > 1e-12) {
const d = Math.sqrt(d2);
const nx = dx / d, ny = dy / d;
p.x = cx + nx * r; p.y = cy + ny * r;
surfaceResponse(p, nx, ny, isBall);
} else {
/* point inside the box: push out along the axis of least penetration */
const left = p.x - b.x, right = b.x + b.w - p.x;
const top = p.y - b.y, bottom = b.y + b.h - p.y;
const m = Math.min(left, right, top, bottom);
if (m === top) { p.y = b.y - r; surfaceResponse(p, 0, -1, isBall); }
else if (m === bottom) { p.y = b.y + b.h + r; surfaceResponse(p, 0, 1, isBall); }
else if (m === left) { p.x = b.x - r; surfaceResponse(p, -1, 0, isBall); }
else { p.x = b.x + b.w + r; surfaceResponse(p, 1, 0, isBall); }
}
}
function ballBall() {
const balls = [];
for (let i = 0; i < points.length; i++) if (points[i].type === 'ball') balls.push(points[i]);
for (let i = 0; i < balls.length; i++) for (let j = i + 1; j < balls.length; j++) {
const a = balls[i], b = balls[j];
const min = a.radius + b.radius;
const dx = b.x - a.x, dy = b.y - a.y;
const d2 = dx * dx + dy * dy;
if (d2 >= min * min) continue;
let d = Math.sqrt(d2);
let nx, ny;
if (d < 1e-9) { nx = 1; ny = 0; } else { nx = dx / d; ny = dy / d; }
const wa = a.pinned ? 0 : 1, wb = b.pinned ? 0 : 1;
const ws = wa + wb;
if (!ws) continue;
const push = (min - d) / ws;
a.x -= nx * push * wa; a.y -= ny * push * wa;
b.x += nx * push * wb; b.y += ny * push * wb;
const avx = a.x - a.px, avy = a.y - a.py;
const bvx = b.x - b.px, bvy = b.y - b.py;
const rel = (bvx - avx) * nx + (bvy - avy) * ny;
if (rel < 0) {
const e = -rel > 0.8 ? CFG.RESTITUTION : 0;
const jimp = (1 + e) * -rel / ws;
a.px += nx * jimp * wa; a.py += ny * jimp * wa;
b.px -= nx * jimp * wb; b.py -= ny * jimp * wb;
}
}
}
/* ================================================================
7. TEARING & CUTTING
================================================================ */
function tearConstraint(c) {
if (c.broken) return;
c.broken = true;
if (c.obj) c.obj.hasBroken = true;
spawnTearSparks((c.a.x + c.b.x) / 2, (c.a.y + c.b.y) / 2);
}
function spawnTearSparks(x, y) {
for (let i = 0; i < 6; i++) {
const a = Math.random() * Math.PI * 2;
const s = 40 + Math.random() * 100;
particles.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, life: 0.35 + Math.random() * 0.25, max: 0.6 });
}
if (particles.length > 300) particles.splice(0, particles.length - 300);
}
/* Slice every constraint whose segment crosses the cut segment (x1,y1)-(x2,y2) */
function cutSegment(x1, y1, x2, y2) {
if (Math.hypot(x2 - x1, y2 - y1) < 0.5) return 0;
let n = 0;
for (let i = 0; i < constraints.length; i++) {
const c = constraints[i];
if (c.broken) continue;
if (segSeg(x1, y1, x2, y2, c.a.x, c.a.y, c.b.x, c.b.y)) { tearConstraint(c); n++; }
}
return n;
}
function segSeg(ax, ay, bx, by, cx, cy, dx, dy) {
const d1 = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
const d2 = (bx - ax) * (dy - ay) - (by - ay) * (dx - ax);
const d3 = (dx - cx) * (ay - cy) - (dy - cy) * (ax - cx);
const d4 = (dx - cx) * (by - cy) - (dy - cy) * (bx - cx);
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) return true;
if (Math.abs(d1) < 1e-9 && onSeg(ax, ay, bx, by, cx, cy)) return true;
if (Math.abs(d2) < 1e-9 && onSeg(ax, ay, bx, by, dx, dy)) return true;
if (Math.abs(d3) < 1e-9 && onSeg(cx, cy, dx, dy, ax, ay)) return true;
if (Math.abs(d4) < 1e-9 && onSeg(cx, cy, dx, dy, bx, by)) return true;
return false;
}
function onSeg(ax, ay, bx, by, px, py) {
return Math.min(ax, bx) - 1e-9 <= px && px <= Math.max(ax, bx) + 1e-9 &&
Math.min(ay, by) - 1e-9 <= py && py <= Math.max(ay, by) + 1e-9;
}
</script>
<script>
/* ================================================================
8. INTERACTION (grab / throw / cut / pin / spawn)
================================================================ */
let mouse = { x: -999, y: -999, down: false };
let grabToolDrag = null; // { tool, sx, sy } while dragging to place
let cutActive = false, cutPrev = null;
let shiftHeld = false;
function canvasPos(e) {
const r = canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function nearestPoint(x, y, maxD, includePinned) {
let best = null, bd = maxD * maxD;
for (let i = 0; i < points.length; i++) {
const p = points[i];
if (!includePinned && p.pinned) continue;
const dx = p.x - x, dy = p.y - y, d2 = dx * dx + dy * dy;
if (d2 < bd) { bd = d2; best = p; }
}
return best;
}
function startGrab(x, y) {
const p = nearestPoint(x, y, CFG.GRAB_RADIUS, false);
if (p) {
p.grabbed = true;
p.px = p.x; p.py = p.y;
grab = { p, x, y };
}
return !!p;
}
function endGrab() {
if (grab) { grab.p.grabbed = false; grab = null; }
}
function togglePin(x, y) {
const p = nearestPoint(x, y, CFG.GRAB_RADIUS, true);
if (p) {
p.pinned = !p.pinned;
if (p.pinned) { p.px = p.x; p.py = p.y; if (grab && grab.p === p) endGrab(); }
}
}
function beginCut(x, y) {
cutActive = true;
cutPrev = { x, y };
}
function moveCut(x, y) {
if (cutPrev) cutSegment(cutPrev.x, cutPrev.y, x, y);
cutPrev = { x, y };
cutTrail.push({ x, y, t: 1 });
if (cutTrail.length > 240) cutTrail.shift();
}
function endCut() { cutActive = false; cutPrev = null; }
/* --- placement --- */
function placeObject(tool, sx, sy, ex, ey) {
const dx = ex - sx, dy = ey - sy;
const dist = Math.hypot(dx, dy);
const cx = (sx + ex) / 2, cy = (sy + ey) / 2;
let obj = null;
if (tool === 'rope') {
if (dist < 12) obj = spawnRopeBetween({ x: sx, y: sy }, { x: sx, y: Math.min(sy + 150, H - CFG.FLOOR_H - 4) });
else obj = spawnRopeBetween({ x: sx, y: sy }, { x: ex, y: ey });
} else if (tool === 'cloth') {
const w = Math.max(dist < 12 ? 130 : Math.abs(dx), 60), h = Math.max(dist < 12 ? 100 : Math.abs(dy), 44);
const cols = clamp(Math.round(w / 20), 4, 26), rows = clamp(Math.round(h / 20), 3, 18);
obj = spawnCloth(cx - w / 2, cy - h / 2, cols, rows, w / (cols - 1), h / (rows - 1), state.pinMode);
} else if (tool === 'blob') {
obj = spawnSoftCircle(cx, cy, clamp(dist < 12 ? 42 : dist / 2, 18, 95));
} else if (tool === 'box') {
const w = Math.max(dist < 12 ? 96 : Math.abs(dx), 56), h = Math.max(dist < 12 ? 72 : Math.abs(dy), 44);
obj = spawnSoftBox(cx - w / 2, cy - h / 2, w, h);
} else if (tool === 'ball') {
obj = spawnBall(cx, cy, 16);
}
return obj;
}
/* --- pointer events --- */
function onDown(x, y, button, shift) {
mouse.x = x; mouse.y = y; mouse.down = true;
const wantCut = button === 2 || state.tool === 'cut';
if (wantCut) { beginCut(x, y); return; }
if (shift && state.tool === 'grab') { togglePin(x, y); return; }
if (state.tool === 'grab') { startGrab(x, y); return; }
grabToolDrag = { tool: state.tool, sx: x, sy: y, ex: x, ey: y };
}
function onMove(x, y, shift) {
mouse.x = x; mouse.y = y; shiftHeld = shift;
if (cutActive) { moveCut(x, y); return; }
if (grab) { grab.x = x; grab.y = y; return; }
if (grabToolDrag) { grabToolDrag.ex = x; grabToolDrag.ey = y; }
}
function onUp(x, y) {
mouse.down = false;
endCut();
if (grabToolDrag) {
placeObject(grabToolDrag.tool, grabToolDrag.sx, grabToolDrag.sy, x, y);
grabToolDrag = null;
setTool('grab');
}
endGrab();
}
canvas.addEventListener('mousedown', e => { e.preventDefault(); onDown(canvasPos(e).x, canvasPos(e).y, e.button, e.shiftKey); });
window.addEventListener('mousemove', e => { const p = canvasPos(e); onMove(p.x, p.y, e.shiftKey); });
window.addEventListener('mouseup', e => { const p = canvasPos(e); onUp(p.x, p.y); });
canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('touchstart', e => {
e.preventDefault();
const t = e.touches[0], p = canvasPos(t);
onDown(p.x, p.y, 0, false);
}, { passive: false });
canvas.addEventListener('touchmove', e => {
e.preventDefault();
const t = e.touches[0], p = canvasPos(t);
onMove(p.x, p.y, false);
}, { passive: false });
canvas.addEventListener('touchend', e => { e.preventDefault(); onUp(mouse.x, mouse.y); }, { passive: false });
/* --- keyboard --- */
window.addEventListener('keydown', e => {
if (e.repeat && e.code !== 'ArrowRight') return;
switch (e.code) {
case 'Space': e.preventDefault(); setPaused(!state.paused); break;
case 'ArrowRight': e.preventDefault(); if (state.paused) { stepPhysics(CFG.DT); } break;
case 'KeyR': resetScene(); break;
case 'Digit1': setTool('rope'); break;
case 'Digit2': setTool('cloth'); break;
case 'Digit3': setTool('blob'); break;
case 'Digit4': setTool('box'); break;
case 'Digit5': setTool('ball'); break;
case 'KeyX': setTool('cut'); break;
case 'KeyV': case 'Escape': setTool('grab'); break;
}
});
/* ================================================================
9. SCENE SETUP
================================================================ */
function resetScene() {
points = []; constraints = []; objects = []; particles = [];
grab = null; grabToolDrag = null; cutTrail = []; cutActive = false;
nextId = 1;
/* pendulum rope, pinned at the left end */
spawnRopeBetween({ x: W * 0.09, y: H * 0.10 }, { x: W * 0.29, y: H * 0.10 });
/* cloth draped over the big static circle */
const cw = W * 0.24, ch = Math.min(H * 0.36, 270);
const cols = 14, rows = 9;
spawnCloth(W * 0.30, H * 0.09, cols, rows, cw / (cols - 1), ch / (rows - 1), 'edge');
/* soft blob + box + ball */
spawnSoftCircle(W * 0.70, H * 0.20, clamp(H * 0.07, 30, 52));
spawnSoftBox(W * 0.84, H * 0.26, 92, 68);
spawnBall(W * 0.18, H * 0.34, 15);
}
/* ================================================================
10. RENDERING
================================================================ */
function strokeMap() { return new Map(); }
function addSeg(m, key, x1, y1, x2, y2) {
let p = m.get(key);
if (!p) { p = new Path2D(); m.set(key, p); }
p.moveTo(x1, y1); p.lineTo(x2, y2);
}
function flushMap(m) {
ctx.lineCap = 'round';
for (const [key, path] of m) {
const bar = key.lastIndexOf('|');
ctx.strokeStyle = key.slice(0, bar);
ctx.lineWidth = parseFloat(key.slice(bar + 1));
ctx.stroke(path);
}
}
function drawObject(o, m) {
if (o.type === 'ball') {
const p = o.points[0], r = p.radius;
const g = ctx.createRadialGradient(p.x - r * 0.35, p.y - r * 0.4, r * 0.1, p.x, p.y, r);
g.addColorStop(0, 'rgba(255,255,255,0.85)');
g.addColorStop(0.3, o.color);
g.addColorStop(1, hexA(o.color, 0.5));
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, 7); ctx.fill();
return;
}
if (o.type === 'cloth' && !o.hasBroken && o.grid) {
const g = o.grid, path = new Path2D();
for (let r = 0; r < g.length - 1; r++) for (let c = 0; c < g[0].length - 1; c++) {
const p1 = g[r][c], p2 = g[r][c + 1], p3 = g[r + 1][c + 1], p4 = g[r + 1][c];
path.moveTo(p1.x, p1.y); path.lineTo(p2.x, p2.y); path.lineTo(p3.x, p3.y); path.lineTo(p4.x, p4.y); path.closePath();
}
ctx.fillStyle = hexA(o.color, 0.13);
ctx.fill(path);
}
if (o.type === 'soft' && !o.hasBroken && o.outline) {
const ol = o.outline, path = new Path2D();
path.moveTo(ol[0].x, ol[0].y);
for (let i = 1; i < ol.length; i++) path.lineTo(ol[i].x, ol[i].y);
path.closePath();
ctx.fillStyle = hexA(o.color, 0.18);
ctx.fill(path);
}
for (let i = 0; i < o.constraints.length; i++) {
const c = o.constraints[i];
if (c.broken) continue;
let col = o.color;
if (state.stress) col = stressColor(c.stretch);
const w = c.type === 'brace' ? 1 : (c.type === 'shear' ? 0.75 : 2);
const alpha = c.type === 'brace' ? 0.45 : (c.type === 'shear' ? 0.35 : 0.95);
addSeg(m, (state.stress ? col : hexA(col, alpha)) + '|' + w, c.a.x, c.a.y, c.b.x, c.b.y);
}
/* point dots (cloth dots hidden unless pinned/grabbed) */
const showDots = o.type !== 'cloth' || state.stress;
if (showDots) {
const m2 = strokeMap();
for (let i = 0; i < o.points.length; i++) {
const p = o.points[i];
if (o.type === 'cloth' && !p.pinned && !p.grabbed) continue;
addSeg(m2, (p.pinned ? CFG.COLORS.pin : hexA(o.color, 0.9)) + '|1.5', p.x, p.y, p.x + 0.01, p.y);
}
flushMap(m2);
}
}
function render() {
ctx.fillStyle = CFG.COLORS.bg;
ctx.fillRect(0, 0, W, H);
/* faint grid */
ctx.strokeStyle = CFG.COLORS.grid; ctx.lineWidth = 1;
ctx.beginPath();
for (let x = 80; x < W; x += 80) { ctx.moveTo(x + 0.5, 0); ctx.lineTo(x + 0.5, H); }
for (let y = 80; y < H; y += 80) { ctx.moveTo(0, y + 0.5); ctx.lineTo(W, y + 0.5); }
ctx.stroke();
const floorY = H - CFG.FLOOR_H;
ctx.fillStyle = 'rgba(148,163,184,0.05)';
ctx.fillRect(0, floorY, W, CFG.FLOOR_H);
ctx.strokeStyle = CFG.COLORS.floorEdge;
ctx.beginPath(); ctx.moveTo(0, floorY + 0.5); ctx.lineTo(W, floorY + 0.5); ctx.stroke();
for (const c of staticCircles) {
ctx.fillStyle = CFG.COLORS.shape; ctx.strokeStyle = CFG.COLORS.shapeEdge; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(c.x, c.y, c.r, 0, 7); ctx.fill(); ctx.stroke();
}
for (const b of staticBoxes) {
ctx.fillStyle = CFG.COLORS.shape; ctx.strokeStyle = CFG.COLORS.shapeEdge; ctx.lineWidth = 1.5;
ctx.fillRect(b.x, b.y, b.w, b.h); ctx.strokeRect(b.x + 0.5, b.y + 0.5, b.w, b.h);
}
/* motion-blur trails */
if (state.trails) {
const tm = strokeMap();
for (let i = 0; i < points.length; i++) {
const p = points[i];
const vx = p.x - p.px, vy = p.y - p.py;
if (vx * vx + vy * vy > 3) addSeg(tm, hexA(p.obj.color, 0.3) + '|1.5', p.px, p.py, p.x, p.y);
}
flushMap(tm);
}
const m = strokeMap();
for (let i = 0; i < objects.length; i++) drawObject(objects[i], m);
flushMap(m);
/* pin rings */
ctx.strokeStyle = CFG.COLORS.pin; ctx.lineWidth = 1.5;
for (let i = 0; i < points.length; i++) {
const p = points[i];
if (!p.pinned) continue;
ctx.beginPath(); ctx.arc(p.x, p.y, 4.5, 0, 7); ctx.stroke();
}
/* tear particles */
if (particles.length) {
ctx.fillStyle = CFG.COLORS.tear;
for (let i = 0; i < particles.length; i++) {
const q = particles[i];
ctx.globalAlpha = clamp(q.life / q.max, 0, 1);
ctx.fillRect(q.x - 1.5, q.y - 1.5, 3, 3);
}
ctx.globalAlpha = 1;
}
/* cut trail */
if (cutTrail.length) {
for (let i = 1; i < cutTrail.length; i++) {
const a = cutTrail[i - 1], b = cutTrail[i];
ctx.strokeStyle = hexA(CFG.COLORS.tear, clamp(b.t, 0, 1) * 0.9);
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
}
}
/* grab highlight */
if (grab) {
ctx.strokeStyle = CFG.COLORS.pin; ctx.lineWidth = 1.5;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.arc(grab.p.x, grab.p.y, CFG.GRAB_RADIUS * 0.55, 0, 7); ctx.stroke();
ctx.setLineDash([]);
ctx.strokeStyle = 'rgba(226,232,240,0.35)';
ctx.beginPath(); ctx.moveTo(grab.p.x, grab.p.y); ctx.lineTo(grab.x, grab.y); ctx.stroke();
}
/* spawn preview */
if (grabToolDrag) {
const d = grabToolDrag;
ctx.strokeStyle = hexA(CFG.COLORS[d.tool] || '#e2e8f0', 0.7);
ctx.setLineDash([6, 5]); ctx.lineWidth = 1.5;
if (d.tool === 'rope') { ctx.beginPath(); ctx.moveTo(d.sx, d.sy); ctx.lineTo(d.ex, d.ey); ctx.stroke(); }
else if (d.tool === 'cloth' || d.tool === 'box') {
ctx.strokeRect(Math.min(d.sx, d.ex), Math.min(d.sy, d.ey), Math.abs(d.ex - d.sx), Math.abs(d.ey - d.sy));
} else {
const dist = Math.hypot(d.ex - d.sx, d.ey - d.sy);
const r = d.tool === 'blob' ? clamp(dist / 2, 18, 95) : 16;
ctx.beginPath(); ctx.arc((d.sx + d.ex) / 2, (d.sy + d.ey) / 2, r, 0, 7); ctx.stroke();
}
ctx.setLineDash([]);
}
/* cursor crosshair for cut tool */
if (state.tool === 'cut' && mouse.x > 0) {
ctx.strokeStyle = hexA(CFG.COLORS.tear, 0.8); ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(mouse.x - 9, mouse.y); ctx.lineTo(mouse.x + 9, mouse.y);
ctx.moveTo(mouse.x, mouse.y - 9); ctx.lineTo(mouse.x, mouse.y + 9);
ctx.stroke();
}
/* HUD */
let active = 0;
for (let i = 0; i < constraints.length; i++) if (!constraints[i].broken) active++;
ctx.font = '11px ui-monospace, SFMono-Regular, Menlo, monospace';
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(148,163,184,0.75)';
ctx.fillText('fps ' + fps.toFixed(0) + ' step ' + stepMs.toFixed(2) + 'ms pts ' + points.length + ' con ' + active, W - 12, 20);
if (state.paused) {
ctx.fillStyle = 'rgba(251,113,133,0.9)';
ctx.fillText('PAUSED — → to step', W - 12, 36);
}
}
/* ================================================================
11. MAIN LOOP (fixed-timestep accumulator)
================================================================ */
let acc = 0, last = performance.now(), fps = 60, stepMs = 0;
function frame(now) {
const raw = (now - last) / 1000;
last = now;
if (raw > 0) fps = fps * 0.92 + (1 / raw) * 0.08;
const delta = Math.min(raw, CFG.MAX_FRAME_DELTA);
if (!state.paused) {
acc += delta;
const t0 = performance.now();
let n = 0;
while (acc >= CFG.DT && n < CFG.MAX_STEPS_PER_FRAME) { stepPhysics(CFG.DT); acc -= CFG.DT; n++; }
if (n === CFG.MAX_STEPS_PER_FRAME) acc = 0; // drop backlog instead of spiraling
stepMs = stepMs * 0.9 + (performance.now() - t0) * 0.1;
} else {
acc = 0;
}
/* age tear particles + cut trail */
for (let i = particles.length - 1; i >= 0; i--) {
const q = particles[i];
q.life -= 0.016; q.x += q.vx * 0.016; q.y += q.vy * 0.016; q.vy += 300 * 0.016;
if (q.life <= 0) particles.splice(i, 1);
}
for (let i = cutTrail.length - 1; i >= 0; i--) {
cutTrail[i].t -= 0.04;
if (cutTrail[i].t <= 0) cutTrail.splice(i, 1);
}
render();
requestAnimationFrame(frame);
}
function setPaused(v) {
state.paused = v;
const b = document.getElementById('bPause');
b.innerHTML = v ? '▶' : '⏸';
b.title = (v ? 'Resume (Space)' : 'Pause (Space)');
}
/* ================================================================
12. TOOLBAR BINDINGS
================================================================ */
const HINTS = {
grab: '<b>drag</b> point to throw · <b>shift+click</b> pin/unpin · <b>right-drag</b> cut · <b>space</b> pause · <b>→</b> step · <b>1–5</b> spawn',
rope: 'click-drag to draw a rope (pinned at start) · <b>esc</b> back to grab',
cloth: 'click-drag to place cloth · <b>esc</b> back to grab',
blob: 'click-drag to size a soft blob · <b>esc</b> back to grab',
box: 'click-drag to size a soft box · <b>esc</b> back to grab',
ball: 'click to drop a ball · <b>esc</b> back to grab',
cut: 'drag to slice constraints · <b>esc</b> back to grab',
};
function setTool(t) {
state.tool = t;
document.querySelectorAll('#tools .tool').forEach(b => b.classList.toggle('active', b.dataset.tool === t));
canvas.style.cursor = t === 'grab' ? 'default' : 'crosshair';
hintEl.innerHTML = HINTS[t] || HINTS.grab;
}
document.querySelectorAll('#tools .tool').forEach(b => b.addEventListener('click', () => setTool(b.dataset.tool)));
const sGrav = document.getElementById('sGrav'), vGrav = document.getElementById('vGrav');
const sIter = document.getElementById('sIter'), vIter = document.getElementById('vIter');
const sWind = document.getElementById('sWind'), vWind = document.getElementById('vWind');
sGrav.addEventListener('input', () => { state.gravity = +sGrav.value; vGrav.textContent = sGrav.value; });
sIter.addEventListener('input', () => { state.iterations = +sIter.value; vIter.textContent = sIter.value; });
sWind.addEventListener('input', () => { state.wind = +sWind.value; vWind.textContent = sWind.value; });
document.getElementById('cGrav').addEventListener('change', e => { state.gravityOn = e.target.checked; });
document.getElementById('cStress').addEventListener('change', e => { state.stress = e.target.checked; });
document.getElementById('cTrails').addEventListener('change', e => { state.trails = e.target.checked; });
document.getElementById('selPin').addEventListener('change', e => { state.pinMode = e.target.value; });
document.getElementById('bPause').addEventListener('click', () => setPaused(!state.paused));
document.getElementById('bStep').addEventListener('click', () => { if (state.paused) stepPhysics(CFG.DT); });
document.getElementById('bReset').addEventListener('click', resetScene);
/* ================================================================
13. HEADLESS API (window.sim)
================================================================ */
window.sim = {
get points() { return points; },
get constraints() { return constraints; },
get objects() { return objects; },
get cfg() { return CFG; },
get state() { return state; },
step(n) { n = n || 1; for (let i = 0; i < n; i++) stepPhysics(CFG.DT); },
stepOnce() { stepPhysics(CFG.DT); },
reset() { resetScene(); },
setPaused(v) { setPaused(!!v); },
isPaused() { return state.paused; },
floorY() { return H - CFG.FLOOR_H; },
spawnRope(a, b, n) { return spawnRopeBetween(a, b, n); },
spawnCloth(x, y, cols, rows, sx, sy, mode) { return spawnCloth(x, y, cols, rows, sx, sy, mode); },
spawnBlob(x, y, r) { return spawnSoftCircle(x, y, r); },
spawnBox(x, y, w, h) { return spawnSoftBox(x, y, w, h); },
spawnBall(x, y, r) { return spawnBall(x, y, r); },
tear(c) { tearConstraint(c); },
kineticEnergy() {
let ke = 0;
for (let i = 0; i < points.length; i++) {
const p = points[i];
const vx = (p.x - p.px) / CFG.DT, vy = (p.y - p.py) / CFG.DT;
ke += 0.5 * (vx * vx + vy * vy);
}
return ke;
},
maxCoord() {
let m = 0;
for (let i = 0; i < points.length; i++) m = Math.max(m, Math.abs(points[i].x), Math.abs(points[i].y));
return m;
},
};
/* ================================================================
14. BOOT
================================================================ */
window.addEventListener('resize', resize);
resize();
resetScene();
setTool('grab');
requestAnimationFrame(frame);
</script>
</body>
</html>
<!-- agent-meta {"model":"Qwen3.8","provider":"brigid","persona":"full","sessionId":"0f376a26-d6a4-41a3-a3d5-84dda6329a21","tokensIn":2955323,"tokensOut":123615,"tokensTotal":3078938,"cost":0,"turns":50,"toolCalls":55,"failedToolCalls":0,"timestamp":"2026-09-01T23:35:57.213Z"} -->