Physics Playground
Ornith 1.5 35B A3B · 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,805 bytes · SHA-256 aef0ce342736
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Soft-Body Physics Playground</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%;
background: #0a0b12;
overflow: hidden;
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
color: #cfd4e6;
user-select: none;
-webkit-user-select: none;
}
#stage {
display: block;
position: absolute;
top: 0; left: 0;
cursor: crosshair;
}
/* Minimal toolbar, kept out of the way */
#toolbar {
position: absolute;
top: 12px; left: 12px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 10;
max-width: 232px;
}
.panel {
background: rgba(20, 22, 34, 0.78);
border: 1px solid rgba(120, 130, 170, 0.18);
border-radius: 10px;
padding: 9px 10px;
backdrop-filter: blur(6px);
box-shadow: 0 6px 22px rgba(0,0,0,0.45);
}
.panel h3 {
font-size: 10px;
letter-spacing: 1.5px;
text-transform: uppercase;
color: #7f87ad;
margin-bottom: 7px;
font-weight: 600;
}
.row { display: flex; gap: 6px; margin-bottom: 6px; flex-wrap: wrap; }
.row:last-child { margin-bottom: 0; }
button {
font-family: inherit;
font-size: 11px;
color: #d6dcf2;
background: rgba(60, 66, 95, 0.5);
border: 1px solid rgba(120, 130, 170, 0.22);
border-radius: 7px;
padding: 6px 10px;
cursor: pointer;
transition: background 0.12s, border-color 0.12s, transform 0.05s;
flex: 1;
min-width: 0;
}
button:hover { background: rgba(80, 88, 128, 0.7); border-color: rgba(150,160,205,0.45); }
button:active { transform: translateY(1px); }
button.active {
background: rgba(90, 120, 220, 0.65);
border-color: rgba(140,170,255,0.7);
color: #fff;
box-shadow: 0 0 10px rgba(110,140,255,0.35);
}
.slider-row { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; }
.slider-row:last-child { margin-bottom: 0; }
.slider-row label {
font-size: 10px;
color: #9aa2c4;
width: 62px;
flex: none;
letter-spacing: 0.5px;
}
input[type=range] {
-webkit-appearance: none;
appearance: none;
flex: 1;
height: 4px;
border-radius: 3px;
background: rgba(90, 100, 140, 0.5);
outline: none;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px; height: 14px;
border-radius: 50%;
background: #8fa0ff;
cursor: pointer;
border: 2px solid #1a1c2b;
box-shadow: 0 0 6px rgba(140,160,255,0.5);
}
input[type=range]::-moz-range-thumb {
width: 14px; height: 14px;
border-radius: 50%;
background: #8fa0ff;
cursor: pointer;
border: 2px solid #1a1c2b;
}
.val { font-size: 10px; color: #8fa0ff; width: 34px; text-align: right; flex: none; }
#readout {
font-size: 10px;
line-height: 1.6;
color: #9aa2c4;
}
#readout b { color: #cfe0ff; font-weight: 600; }
#readout .good { color: #6ee7a0; }
#readout .bad { color: #ff7a8a; }
#hint {
position: absolute;
bottom: 12px; left: 50%;
transform: translateX(-50%);
font-size: 11px;
color: #6b7294;
background: rgba(20, 22, 34, 0.6);
padding: 5px 14px;
border-radius: 20px;
border: 1px solid rgba(120,130,170,0.15);
z-index: 10;
letter-spacing: 0.4px;
pointer-events: none;
}
#hint kbd {
color: #aeb6de;
background: rgba(80,88,128,0.4);
padding: 1px 5px;
border-radius: 4px;
font-size: 10px;
border: 1px solid rgba(120,130,170,0.2);
}
</style>
</head>
<body>
<canvas id="stage"></canvas>
<div id="toolbar">
<div class="panel">
<h3>Spawn</h3>
<div class="row">
<button id="tool-rope" class="tool active">Rope</button>
<button id="tool-cloth" class="tool">Cloth</button>
</div>
<div class="row">
<button id="tool-body" class="tool">Soft Body</button>
<button id="tool-ball" class="tool">Ball</button>
</div>
<div class="row">
<button id="tool-grab" class="tool">Grab</button>
<button id="tool-cut" class="tool">Tear</button>
</div>
</div>
<div class="panel">
<h3>Physics</h3>
<div class="slider-row">
<label>Gravity</label>
<input type="range" id="sl-gravity" min="0" max="2000" value="1000" step="10">
<span id="v-gravity" class="val">1000</span>
</div>
<div class="slider-row">
<label>Iterations</label>
<input type="range" id="sl-iters" min="1" max="12" value="5" step="1">
<span id="v-iters" class="val">5</span>
</div>
<div class="slider-row">
<label>Damping</label>
<input type="range" id="sl-damping" min="900" max="1000" value="997" step="1">
<span id="v-damping" class="val">0.997</span>
</div>
<div class="slider-row">
<label>Wind</label>
<input type="range" id="sl-wind" min="-800" max="800" value="0" step="10">
<span id="v-wind" class="val">0</span>
</div>
<div class="row">
<button id="chk-stress">Stress</button>
<button id="chk-points">Points</button>
</div>
</div>
<div class="panel">
<h3>Controls</h3>
<div class="row">
<button id="btn-pause">Pause</button>
<button id="btn-step">Step</button>
</div>
<div class="row">
<button id="btn-reset">Reset</button>
<button id="btn-clear">Clear</button>
</div>
</div>
<div class="panel">
<div id="readout"></div>
</div>
</div>
<div id="hint">
<kbd>Drag</kbd> draw <kbd>Left-drag</kbd> grab <kbd>Right-drag</kbd> tear <kbd>Shift-click</kbd> pin <kbd>Space</kbd> pause <kbd>→</kbd> step
</div>
<script>
'use strict';
/* =========================================================================
TUNABLE CONSTANTS — the single source of truth for the whole sim.
========================================================================= */
const CFG = {
// Fixed physics timestep (60 Hz). Render rate decoupled from this.
DT: 1 / 60,
MAX_FRAME: 0.25, // clamp wall-clock delta so tab-switch can't explode
ACCUM_MAX: 0.25, // cap accumulator so a backlog can't detonate next frame
// Integration
DAMPING: 0.997, // velocity retention per step (energy decay -> settles)
SLOP: 0.0001, // epsilon to avoid division by zero in constraints
// Constraint relaxation
ITERATIONS: 5, // constraint solves per physics step
STIFFNESS: 1.0, // point-based dynamics: relax fully (PBD-like)
// Forces
GRAVITY: 1000, // px/s^2
WIND: 0,
WIND_GUST: 0.4, // gentle time-varying gust multiplier
// Collision
FLOOR_FRICTION: 0.90, // tangential velocity retained on floor hit
WALL_FRICTION: 0.90,
BALL_REST: 0.55, // restitution for ball
CIRCLE_REST: 0.4,
// Geometry / spawning
ROPE_SEGMENTS: 16,
ROPE_SPACING: 14,
Cloth: { cols: 9, rows: 7, spacing: 16, pinPattern: 'full' },
SOFT_RADIUS: 6,
SOFT_POINTS: 12,
BALL_RADIUS: 26,
// Interaction
GRAB_RADIUS: 28,
PIN_RADIUS: 18,
CUT_RADIUS: 14, // constraint tear proximity
// Bounds for sanity checks
MAX_COORD: 8000,
// Colors — each object type a distinct hue
COLORS: {
rope: '#ffb347',
cloth: '#4dd2ff',
soft: '#ff6ec7',
ball: '#7bff94',
pin: '#ffe14d',
grab: '#ffffff',
cut: '#ff5566',
stressLow: '#4dff9a',
stressHigh: '#ff4d58',
static: '#3a4066',
staticEdge: '#5a6299',
},
};
/* =========================================================================
MATH HELPERS
========================================================================= */
const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
const lerpColor = (a, b, t) => {
// a,b are [r,g,b] 0-255
const r = Math.round(a[0] + (b[0] - a[0]) * t);
const g = Math.round(a[1] + (b[1] - a[1]) * t);
const bl = Math.round(a[2] + (b[2] - a[2]) * t);
return `rgb(${r},${g},${bl})`;
};
const COL_A = [77, 255, 154];
const COL_B = [255, 77, 88];
/* =========================================================================
POINT — a mass node. Position-based dynamics: we store position and the
previous position; velocity is implicit (x - xPrev).
========================================================================= */
class Point {
constructor(x, y, opts = {}) {
this.x = x;
this.y = y;
this.px = x;
this.py = y;
this.ax = 0;
this.ay = 0;
this.pinned = opts.pinned || false;
this.grabbed = opts.grabbed || false;
this.mass = opts.mass || 1;
this.radius = opts.radius || 0;
this.invMass = this.pinned ? 0 : (1 / this.mass);
this.obj = opts.obj || null; // back-reference to owning object
this.vx = 0;
this.vy = 0;
}
applyForce(fx, fy, dt) {
// Accumulate acceleration (force / mass). Pinned points ignore forces.
if (this.invMass === 0) return;
this.ax += fx * this.invMass;
this.ay += fy * this.invMass;
}
integrate(dt, damping, gravity, wind) {
if (this.invMass === 0) {
// Pinned / grabbed-while-held: keep put, but propagate grab motion
// via the constraint solver (grabbed handled in Sim).
this.px = this.x;
this.py = this.y;
return;
}
// Verlet: x' = x + (x - xPrev)*damping + a*dt^2
const vx = (this.x - this.px) * damping;
const vy = (this.y - this.py) * damping;
const dt2 = dt * dt;
let nx = this.x + vx + this.ax * dt2;
let ny = this.y + vy + this.ay * dt2;
// Guard against NaN poisoning the whole sim.
if (!isFinite(nx)) nx = this.x;
if (!isFinite(ny)) ny = this.y;
this.px = this.x;
this.py = this.y;
this.x = nx;
this.y = ny;
// Implicit velocity for throwing / readout.
this.vx = (this.x - this.px);
this.vy = (this.y - this.py);
// Hard bound: kill runaway coordinates before they blow up.
if (Math.abs(this.x) > CFG.MAX_COORD) { this.x = Math.sign(this.x) * CFG.MAX_COORD; this.px = this.x; }
if (Math.abs(this.y) > CFG.MAX_COORD) { this.y = Math.sign(this.y) * CFG.MAX_COORD; this.py = this.y; }
// Reset per-frame accelerations.
this.ax = 0;
this.ay = 0;
}
}
/* =========================================================================
CONSTRAINT — a distance link between two points.
========================================================================= */
class Constraint {
constructor(p1, p2, opts = {}) {
this.p1 = p1;
this.p2 = p2;
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
this.rest = Math.sqrt(dx * dx + dy * dy) || CFG.SLOP;
this.stiffness = opts.stiffness || CFG.STIFFNESS;
this.active = true;
this.torn = false;
this.tearing = false; // temporarily disabled by a cut stroke (reversible-ish)
this.stress = 0; // 0..1+ current stretch for visualization
this.type = opts.type || 'dist';
}
// Solve a single distance constraint (PBD projection).
solve() {
if (!this.active || this.torn) return;
const p1 = this.p1, p2 = this.p2;
let dx = p2.x - p1.x;
let dy = p2.y - p1.y;
let dist = Math.sqrt(dx * dx + dy * dy);
if (dist < CFG.SLOP) {
// Zero-length / coincident points: nudge apart to avoid div-by-zero.
dx = CFG.SLOP;
dy = 0;
dist = CFG.SLOP;
}
// Track stress = how far stretched relative to rest (for viz).
this.stress = dist / this.rest;
const diff = (dist - this.rest) * this.stiffness;
const invMassSum = p1.invMass + p2.invMass;
if (invMassSum <= 0) return; // both pinned
// Normalized correction direction.
const nx = dx / dist;
const ny = dy / dist;
const corrX = nx * (diff / invMassSum);
const corrY = ny * (diff / invMassSum);
p1.x += corrX * p1.invMass;
p1.y += corrY * p1.invMass;
p2.x -= corrX * p2.invMass;
p2.y -= corrY * p2.invMass;
}
}
/* =========================================================================
STATIC GEOMETRY — non-moving collision shapes.
========================================================================= */
class StaticCircle {
constructor(x, y, r) { this.x = x; this.y = y; this.r = r; }
}
class StaticBox {
constructor(x, y, w, h) {
this.x = x; this.y = y; this.w = w; this.h = h;
this.left = x - w / 2; this.right = x + w / 2;
this.top = y - h / 2; this.bottom = y + h / 2;
}
}
/* =========================================================================
SIM — the whole world. Owns points, constraints, static shapes.
========================================================================= */
class Sim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = canvas.width;
this.H = canvas.height;
this.points = [];
this.constraints = [];
this.staticShapes = [];
this.objects = [];
this.paused = false;
this.accumulator = 0;
this.lastTime = 0;
this.framesSinceStep = 0;
// Physics params (also driven by sliders).
this.gravity = CFG.GRAVITY;
this.iterations = CFG.ITERATIONS;
this.damping = CFG.DAMPING;
this.wind = CFG.WIND;
// Render toggles.
this.showStress = false;
this.showPoints = false;
// Interaction state.
this.tool = 'rope';
this.mouse = { x: 0, y: 0, px: 0, py: 0, down: false, right: false };
this.grab = null; // { point, vx, vy }
this.cutStroke = null; // { p1, p2 } for live tear visualization
this.spawnPreview = null;
this.stats = { fps: 60, physTime: 0, steps: 0, ke: 0, points: 0 };
this.bindUI();
this.bindInput();
this.seed();
}
/* ---- world setup --------------------------------------------------- */
seed() {
const w = this.W, h = this.H;
// A couple of static shapes to drape onto.
this.staticShapes.push(new StaticCircle(w * 0.62, h * 0.6, 55));
this.staticShapes.push(new StaticBox(w * 0.32, h * 0.72, 170, 26));
// A ledge box for soft bodies to land on.
this.staticShapes.push(new StaticBox(w * 0.75, h * 0.78, 150, 24));
// A default hanging rope (hangs straight down from a pin).
this.spawnRope(w * 0.25, h * 0.2, 1);
// A default cloth.
this.spawnCloth(w * 0.5, h * 0.2);
}
resize(w, h) {
this.W = w; this.H = h;
this.canvas.width = w;
this.canvas.height = h;
}
/* ---- fixed-timestep step ------------------------------------------- */
step(dt = CFG.DT) {
const t0 = performance.now();
// 1) Integrate (Verlet) with forces.
const gust = 1 + 0.25 * Math.sin(performance.now() * 0.0013) * (Math.abs(this.wind) > 10 ? 1 : 0);
for (const p of this.points) {
p.ax = 0; p.ay = 0;
// Gravity (down +Y).
p.applyForce(0, this.gravity * p.mass, 0);
// Wind (horizontal), with slight per-point jitter for natural sway.
if (this.wind !== 0) {
const j = 1 + 0.15 * Math.sin((p.x * 0.03) + performance.now() * 0.002);
p.applyForce(this.wind * p.mass * j, 0, 0);
}
p.integrate(dt, this.damping, this.gravity, this.wind);
}
// 2) Grab constraint: pull grabbed point toward target.
if (this.grab && this.grab.point) {
const gp = this.grab.point;
// Smoothly move grabbed point; also nudge prev so release throws velocity.
gp.x = this.mouse.x;
gp.y = this.mouse.y;
gp.px = this.mouse.px;
gp.py = this.mouse.py;
gp.grabbed = true;
}
// 3) Constraint relaxation — iterate N times.
for (let k = 0; k < this.iterations; k++) {
for (const c of this.constraints) c.solve();
this.resolveCollisions();
}
this.stats.physTime = performance.now() - t0;
this.stats.steps++;
}
// Release a grab (called on mouse-up). The throw velocity is carried by the
// grabbed point's (x - px) difference set during the drag.
releaseGrab() {
if (this.grab && this.grab.point) this.grab.point.grabbed = false;
this.grab = null;
}
/* ---- collisions ---------------------------------------------------- */
resolveCollisions() {
const w = this.W, h = this.H;
for (const p of this.points) {
if (p.invMass === 0) continue;
// Ball bounces more than cloth/rope.
const rest = (p.obj && p.obj.name === 'ball') ? CFG.BALL_REST : 0.3;
const fric = 0.92;
// Floor — reflect vertical velocity, friction on horizontal.
if (p.y > h - p.radius) {
const vy = p.y - p.py;
p.y = h - p.radius;
p.py = p.y + vy * rest;
p.px = p.x - (p.x - p.px) * fric;
}
// Ceiling.
if (p.y < p.radius) {
const vy = p.y - p.py;
p.y = p.radius;
p.py = p.y + vy * rest;
p.px = p.x - (p.x - p.px) * fric;
}
// Left wall.
if (p.x < p.radius) {
const vx = p.x - p.px;
p.x = p.radius;
p.px = p.x + vx * rest;
p.py = p.y - (p.y - p.py) * fric;
}
// Right wall.
if (p.x > w - p.radius) {
const vx = p.x - p.px;
p.x = w - p.radius;
p.px = p.x + vx * rest;
p.py = p.y - (p.y - p.py) * fric;
}
// Static circles / boxes.
for (const s of this.staticShapes) {
if (s instanceof StaticCircle) this.collideCircle(p, s);
else if (s instanceof StaticBox) this.collideBox(p, s);
}
}
}
collideCircle(p, s) {
const dx = p.x - s.x, dy = p.y - s.y;
const d = Math.sqrt(dx * dx + dy * dy);
const minD = s.r + p.radius;
if (d < minD && d > CFG.SLOP) {
const nx = dx / d, ny = dy / d;
const overlap = minD - d;
p.x += nx * overlap;
p.y += ny * overlap;
// Reflect velocity for restitution + friction.
const vx = p.x - p.px, vy = p.y - p.py;
const vn = vx * nx + vy * ny;
const tx = vx - nx * vn, ty = vy - ny * vn; // tangential
p.px = p.x - (nx * vn * -CFG.CIRCLE_REST + tx * CFG.WALL_FRICTION);
p.py = p.y - (ny * vn * -CFG.CIRCLE_REST + ty * CFG.WALL_FRICTION);
}
}
collideBox(p, box) {
// Closest point on the box to the point.
const cx = clamp(p.x, box.left, box.right);
const cy = clamp(p.y, box.top, box.bottom);
const dx = p.x - cx, dy = p.y - cy;
const d2 = dx * dx + dy * dy;
const minD = p.radius;
if (d2 < minD * minD && d2 > CFG.SLOP * CFG.SLOP) {
const d = Math.sqrt(d2);
let nx, ny;
if (d >= CFG.SLOP) { nx = dx / d; ny = dy / d; }
else {
// Inside / center: push out along nearest axis.
const overL = p.x - box.left, overR = box.right - p.x;
const overT = p.y - box.top, overB = box.bottom - p.y;
const m = Math.min(overL, overR, overT, overB);
if (m === overL) { nx = -1; ny = 0; }
else if (m === overR) { nx = 1; ny = 0; }
else if (m === overT) { nx = 0; ny = -1; }
else { nx = 0; ny = 1; }
}
const overlap = minD - d;
p.x += nx * overlap;
p.y += ny * overlap;
const vx = p.x - p.px, vy = p.y - p.py;
const vn = vx * nx + vy * ny;
const tx = vx - nx * vn, ty = vy - ny * vn;
p.px = p.x - (nx * vn * -CFG.CIRCLE_REST + tx * CFG.WALL_FRICTION);
p.py = p.y - (ny * vn * -CFG.CIRCLE_REST + ty * CFG.WALL_FRICTION);
}
}
/* ---- object factories ---------------------------------------------- */
addPoint(x, y, opts) {
const p = new Point(x, y, opts);
p.obj = this.objects[this.objects.length - 1] || null;
this.points.push(p);
return p;
}
addConstraint(p1, p2, opts) {
const c = new Constraint(p1, p2, opts);
this.constraints.push(c);
return c;
}
newObject(name) {
const obj = { name, points: [], constraints: [] };
this.objects.push(obj);
return obj;
}
spawnRope(x, y, dir) {
const obj = this.newObject('rope');
const pts = [];
for (let i = 0; i < CFG.ROPE_SEGMENTS + 1; i++) {
const p = this.addPoint(x + dir * i * CFG.ROPE_SPACING, y, { mass: 1, obj });
pts.push(p);
}
for (let i = 0; i < pts.length - 1; i++) {
const c = this.addConstraint(pts[i], pts[i + 1], { stiffness: 1, type: 'rope' });
obj.constraints.push(c);
}
pts[0].pinned = true; pts[0].invMass = 0;
obj.pinned = pts[0];
return obj;
}
spawnCloth(x, y, opts = {}) {
const obj = this.newObject('cloth');
const cols = CFG.Cloth.cols, rows = CFG.Cloth.rows, sp = CFG.Cloth.spacing;
const pattern = opts.pattern || CFG.Cloth.pinPattern;
const grid = [];
const flat = [];
for (let r = 0; r < rows; r++) {
const row = [];
for (let c = 0; c < cols; c++) {
const p = this.addPoint(x + c * sp, y + r * sp, { mass: 1, obj });
row.push(p);
flat.push(p);
}
grid.push(row);
}
// Expose the flat grid for the mesh renderer.
obj.points = flat;
// Structural constraints.
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (c < cols - 1) this._clothLink(obj, grid[r][c], grid[r][c + 1]);
if (r < rows - 1) this._clothLink(obj, grid[r][c], grid[r + 1][c]);
}
}
// Shear constraints (diagonals) for shape stability.
for (let r = 0; r < rows - 1; r++) {
for (let c = 0; c < cols - 1; c++) {
if ((r + c) % 2 === 0) this._clothLink(obj, grid[r][c], grid[r + 1][c + 1], 0.8);
else this._clothLink(obj, grid[r][c + 1], grid[r + 1][c], 0.8);
}
}
// Pins.
if (pattern === 'corners') {
grid[0][0].pinned = true; grid[0][0].invMass = 0;
grid[0][cols - 1].pinned = true; grid[0][cols - 1].invMass = 0;
obj.pins = [grid[0][0], grid[0][cols - 1]];
} else { // full edge
obj.pins = [];
for (let c = 0; c < cols; c++) { grid[0][c].pinned = true; grid[0][c].invMass = 0; obj.pins.push(grid[0][c]); }
}
return obj;
}
_clothLink(obj, a, b, stiffness) {
const c = this.addConstraint(a, b, { stiffness: stiffness || 1, type: 'cloth' });
obj.constraints.push(c);
return c;
}
spawnSoftBody(x, y, r, kind) {
const obj = this.newObject('soft');
const n = CFG.SOFT_POINTS;
const pts = [];
// Perimeter points on a circle.
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, { mass: 1, radius: 2, obj });
pts.push(p);
}
// Edge constraints keep the perimeter a polygon.
for (let i = 0; i < n; i++) {
const a = pts[i], b = pts[(i + 1) % n];
this.addConstraint(a, b, { stiffness: 1, type: 'soft-edge' });
}
// Cross-braces (diagonals) give the body structural rigidity so it
// resists collapsing to a line, while still allowing free deformation.
// These are symmetric and do not inject net momentum when squashing.
const skips = [Math.floor(n / 2), Math.floor(n / 2) - 1];
for (const s of skips) {
for (let i = 0; i < n; i++) {
this.addConstraint(pts[i], pts[(i + s) % n], { stiffness: 0.6, type: 'soft-brace' });
}
}
obj.points = pts;
return obj;
}
spawnBall(x, y, r) {
const obj = this.newObject('ball');
const p = this.addPoint(x, y, { mass: 1, radius: r || CFG.BALL_RADIUS, obj });
obj.point = p;
return obj;
}
/* ---- interaction helpers ------------------------------------------- */
findNearestPoint(x, y, radius) {
let best = null, bestD = radius * radius;
for (const p of this.points) {
const dx = p.x - x, dy = p.y - y;
const d2 = dx * dx + dy * dy;
if (d2 < bestD) { bestD = d2; best = p; }
}
return best;
}
/* ---- tearing ------------------------------------------------------- */
cutAlong(a, b) {
// Remove (tear) any active constraint that intersects segment a-b within CUT_RADIUS.
const thr = CFG.CUT_RADIUS;
for (const c of this.constraints) {
if (!c.active || c.torn) continue;
if (segDist(c.p1.x, c.p1.y, c.p2.x, c.p2.y, a.x, a.y, b.x, b.y) < thr) {
c.active = false;
c.torn = true;
}
}
}
/* ---- pin toggle ---------------------------------------------------- */
togglePin(p) {
p.pinned = !p.pinned;
p.invMass = p.pinned ? 0 : 1 / p.mass;
}
/* ---- main loop ----------------------------------------------------- */
start() {
this.lastTime = performance.now();
const loop = (now) => {
const frame = now - this.lastTime;
this.lastTime = now;
// FPS measurement.
this.framesSinceStep++;
if (this.framesSinceStep % 15 === 0) {
this.stats.fps = 1000 / Math.max(frame, 1);
}
// Fixed-timestep accumulator, clamped for safety.
let dt = Math.min(frame / 1000, CFG.MAX_FRAME);
this.accumulator += dt;
if (this.accumulator > CFG.ACCUM_MAX) this.accumulator = CFG.ACCUM_MAX;
if (!this.paused) {
let guard = 0;
while (this.accumulator >= CFG.DT && guard < 8) {
this.step(CFG.DT);
this.accumulator -= CFG.DT;
guard++;
}
}
this.render();
this.updateReadout();
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
}
/* ---- rendering ----------------------------------------------------- */
render() {
const ctx = this.ctx, w = this.W, h = this.H;
// Background with subtle vignette + motion trail.
ctx.fillStyle = '#0a0b12';
ctx.fillRect(0, 0, w, h);
const g = ctx.createRadialGradient(w / 2, h * 0.4, 80, w / 2, h * 0.4, w * 0.8);
g.addColorStop(0, 'rgba(30,34,60,0.35)');
g.addColorStop(1, 'rgba(8,9,14,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
this.renderStatic();
// Render per-object type appropriately.
for (const obj of this.objects) {
if (obj.name === 'cloth') this.renderClothMesh(obj);
}
for (const obj of this.objects) {
if (obj.name === 'soft') this.renderSoftBody(obj);
}
for (const obj of this.objects) {
if (obj.name === 'rope' || obj.name === 'ball') this.renderLines(obj);
}
// Cloth structural lines (over mesh) for definition.
for (const obj of this.objects) if (obj.name === 'cloth') this.renderClothLines(obj);
if (this.showPoints) this.renderPoints();
this.renderGrabCut();
}
renderStatic() {
const ctx = this.ctx;
const w = this.W, h = this.H;
// Floor line.
ctx.strokeStyle = 'rgba(90,100,150,0.35)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, h - 1); ctx.lineTo(w, h - 1);
ctx.stroke();
// Left/right walls subtle.
ctx.strokeStyle = 'rgba(90,100,150,0.12)';
ctx.beginPath(); ctx.moveTo(1, 0); ctx.lineTo(1, h); ctx.stroke();
ctx.beginPath(); ctx.moveTo(w - 1, 0); ctx.lineTo(w - 1, h); ctx.stroke();
for (const s of this.staticShapes) {
if (s instanceof StaticCircle) {
const grd = ctx.createRadialGradient(s.x - s.r * 0.3, s.y - s.r * 0.3, s.r * 0.2, s.x, s.y, s.r);
grd.addColorStop(0, '#4a5288');
grd.addColorStop(1, '#23284a');
ctx.fillStyle = grd;
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = CFG.COLORS.staticEdge;
ctx.lineWidth = 2;
ctx.stroke();
} else if (s instanceof StaticBox) {
const grd = ctx.createLinearGradient(0, s.top, 0, s.bottom);
grd.addColorStop(0, '#3a4066');
grd.addColorStop(1, '#23284a');
ctx.fillStyle = grd;
ctx.fillRect(s.left, s.top, s.w, s.h);
ctx.strokeStyle = CFG.COLORS.staticEdge;
ctx.lineWidth = 2;
ctx.strokeRect(s.left, s.top, s.w, s.h);
}
}
}
colorForConstraint(c) {
if (!this.showStress || !c.active) return null;
const s = c.stress;
const t = clamp((s - 1) * 2, 0, 1); // stretch beyond rest
return lerpColor(COL_A, COL_B, t);
}
renderLines(obj) {
const ctx = this.ctx;
ctx.lineWidth = 2.5;
ctx.lineCap = 'round';
for (const c of obj.constraints) {
if (!c.active) continue;
if (obj.name === 'ball') continue; // ball has no constraints
const col = this.colorForConstraint(c);
ctx.strokeStyle = col || this.colorsFor(obj.name);
ctx.beginPath();
ctx.moveTo(c.p1.x, c.p1.y);
ctx.lineTo(c.p2.x, c.p2.y);
ctx.stroke();
}
}
colorsFor(name) {
return { rope: CFG.COLORS.rope, ball: CFG.COLORS.ball }[name] || '#fff';
}
renderClothMesh(obj) {
const cols = CFG.Cloth.cols, rows = CFG.Cloth.rows;
const ctx = this.ctx;
// Build grid lookup.
const grid = [];
let idx = 0;
for (let r = 0; r < rows; r++) {
const row = [];
for (let c = 0; c < cols; c++) { row.push(obj.points[idx++]); }
grid.push(row);
}
// Filled quad per cell using a flat-shaded hue.
for (let r = 0; r < rows - 1; r++) {
for (let c = 0; c < cols - 1; c++) {
const p00 = grid[r][c], p10 = grid[r][c + 1];
const p01 = grid[r + 1][c], p11 = grid[r + 1][c + 1];
// Average stretch for shading.
let stress = 0, n = 0;
const cellCon = [
[p00, p10], [p00, p01], [p10, p11], [p01, p11],
];
for (const [a, b] of cellCon) {
const dx = b.x - a.x, dy = b.y - a.y;
const d = Math.sqrt(dx * dx + dy * dy);
stress += d; n++;
}
const avg = stress / n;
const t = clamp((avg - CFG.Cloth.spacing) * 0.06, 0, 1);
const col = this.showStress
? lerpColor(COL_A, COL_B, t)
: this.clothShade(t);
ctx.fillStyle = col;
ctx.beginPath();
ctx.moveTo(p00.x, p00.y);
ctx.lineTo(p10.x, p10.y);
ctx.lineTo(p11.x, p11.y);
ctx.lineTo(p01.x, p01.y);
ctx.closePath();
ctx.fill();
}
}
}
clothShade(t) {
// Vibrant cyan-magenta gradient by stress.
const r = Math.round(77 + (255 - 77) * t);
const g = Math.round(210 + (110 - 210) * t);
const b = Math.round(255 - 60 * t);
return `rgb(${r},${g},${b})`;
}
renderClothLines(obj) {
const ctx = this.ctx;
ctx.lineWidth = 1;
ctx.globalAlpha = 0.35;
for (const c of obj.constraints) {
if (!c.active) continue;
const col = this.colorForConstraint(c);
ctx.strokeStyle = col || 'rgba(255,255,255,0.6)';
ctx.beginPath();
ctx.moveTo(c.p1.x, c.p1.y);
ctx.lineTo(c.p2.x, c.p2.y);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
renderSoftBody(obj) {
const ctx = this.ctx;
// Perimeter points are the first N.
const n = CFG.SOFT_POINTS;
const peri = obj.points.slice(0, n);
if (peri.length < 3) return;
// Filled polygon (convex-ish blob).
ctx.beginPath();
ctx.moveTo(peri[0].x, peri[0].y);
for (let i = 1; i < peri.length; i++) ctx.lineTo(peri[i].x, peri[i].y);
ctx.closePath();
const grd = ctx.createRadialGradient(
(peri[0].x + peri[Math.floor(n / 2)].x) / 2,
(peri[0].y + peri[Math.floor(n / 2)].y) / 2, 6,
obj.points.reduce((s, p) => s + p.x, 0) / obj.points.length,
obj.points.reduce((s, p) => s + p.y, 0) / obj.points.length, 60
);
grd.addColorStop(0, '#ffb3e0');
grd.addColorStop(1, CFG.COLORS.soft);
ctx.fillStyle = grd;
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.stroke();
// Eyes for character.
const cx = obj.points.reduce((s, p) => s + p.x, 0) / obj.points.length;
const cy = obj.points.reduce((s, p) => s + p.y, 0) / obj.points.length;
ctx.fillStyle = 'rgba(20,10,25,0.85)';
ctx.beginPath(); ctx.arc(cx - 10, cy - 6, 4, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(cx + 10, cy - 6, 4, 0, Math.PI * 2); ctx.fill();
}
renderPoints() {
const ctx = this.ctx;
for (const p of this.points) {
ctx.fillStyle = p.pinned ? CFG.COLORS.pin : (p.grabbed ? CFG.COLORS.grab : '#eef');
ctx.beginPath();
ctx.arc(p.x, p.y, p.pinned ? 4 : 2.5, 0, Math.PI * 2);
ctx.fill();
}
}
renderGrabCut() {
const ctx = this.ctx;
// Grab highlight.
if (this.grab && this.grab.point) {
const gp = this.grab.point;
ctx.strokeStyle = CFG.COLORS.grab;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(gp.x, gp.y, 10, 0, Math.PI * 2);
ctx.stroke();
}
// Live tear stroke.
if (this.cutStroke) {
ctx.strokeStyle = CFG.COLORS.cut;
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(this.cutStroke.p1.x, this.cutStroke.p1.y);
ctx.lineTo(this.cutStroke.p2.x, this.cutStroke.p2.y);
ctx.stroke();
}
// Tool cursor hint.
if (this.tool === 'ball' && !this.mouse.down) {
ctx.strokeStyle = 'rgba(123,255,148,0.5)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(this.mouse.x, this.mouse.y, CFG.BALL_RADIUS, 0, Math.PI * 2);
ctx.stroke();
}
}
/* ---- readout ------------------------------------------------------- */
updateReadout() {
let ke = 0;
for (const p of this.points) {
const vx = (p.x - p.px), vy = (p.y - p.py);
ke += 0.5 * p.mass * (vx * vx + vy * vy) / (CFG.DT * CFG.DT);
}
this.stats.ke = ke;
const el = document.getElementById('readout');
const stable = ke < 4000 && this.stats.steps > 5;
el.innerHTML =
`FPS: <b>${this.stats.fps.toFixed(0)}</b><br>` +
`Physics: <b>${this.stats.physTime.toFixed(2)}</b> ms / step<br>` +
`Steps: <b>${this.stats.steps}</b> Points: <b>${this.points.length}</b><br>` +
`KE: <b class="${stable ? 'good' : 'bad'}">${(ke / 1000).toFixed(2)}</b> kJ<br>` +
`Paused: <b>${this.paused ? 'yes' : 'no'}</b>`;
}
/* ---- UI wiring ----------------------------------------------------- */
bindUI() {
const $ = (id) => document.getElementById(id);
const sl = (id, key, fmt, apply) => {
const el = $(id), v = $('v-' + id.replace('sl-', ''));
const upd = () => {
const val = parseFloat(el.value);
v.textContent = fmt ? fmt(val) : val;
apply(val);
};
el.addEventListener('input', upd);
upd();
};
sl('sl-gravity', 'gravity', (v) => v, (v) => this.gravity = v);
sl('sl-iters', 'iterations', null, (v) => { this.iterations = v; });
sl('sl-damping', 'damping', (v) => (v / 1000).toFixed(3), (v) => { this.damping = v / 1000; });
sl('sl-wind', 'wind', (v) => v, (v) => { this.wind = v; });
const setTool = (t) => {
this.tool = t;
document.querySelectorAll('.tool').forEach(b => b.classList.remove('active'));
$('tool-' + t).classList.add('active');
};
['rope', 'cloth', 'body', 'ball', 'grab', 'cut'].forEach(t => {
$('tool-' + t).addEventListener('click', () => setTool(t));
});
$('chk-stress').addEventListener('click', (e) => {
this.showStress = !this.showStress;
e.target.classList.toggle('active', this.showStress);
});
$('chk-points').addEventListener('click', (e) => {
this.showPoints = !this.showPoints;
e.target.classList.toggle('active', this.showPoints);
});
$('btn-pause').addEventListener('click', () => this.togglePause());
$('btn-step').addEventListener('click', () => { this.paused = true; this.step(); this.render(); this.updateReadout(); this.syncPauseBtn(); });
$('btn-reset').addEventListener('click', () => {
this.points = []; this.constraints = []; this.objects = [];
this.seed();
});
$('btn-clear').addEventListener('click', () => {
this.points = []; this.constraints = []; this.objects = [];
});
this.syncPauseBtn();
}
syncPauseBtn() {
const b = document.getElementById('btn-pause');
b.textContent = this.paused ? 'Resume' : 'Pause';
b.classList.toggle('active', this.paused);
}
togglePause() {
this.paused = !this.paused;
this.syncPauseBtn();
}
/* ---- input --------------------------------------------------------- */
bindInput() {
const cv = this.canvas;
const pos = (e) => {
const r = cv.getBoundingClientRect();
const cx = (e.touches ? e.touches[0].clientX : e.clientX) - r.left;
const cy = (e.touches ? e.touches[0].clientY : e.clientY) - r.top;
return { x: cx, y: cy };
};
// Track modifier state for pinning.
this.shift = false;
window.addEventListener('keydown', (e) => { if (e.shiftKey) this.shift = true; });
window.addEventListener('keyup', (e) => { if (!e.shiftKey) this.shift = false; });
const onDown = (e) => {
const p = pos(e);
this.mouse.px = this.mouse.x; this.mouse.py = this.mouse.y;
this.mouse.x = p.x; this.mouse.y = p.y;
this.mouse.down = true;
this.mouse.right = (e.button === 2);
if (this.tool === 'grab') {
const near = this.findNearestPoint(p.x, p.y, CFG.GRAB_RADIUS);
if (near) {
if (this.shift) {
// Shift+click toggles a pin in place.
this.togglePin(near);
} else {
this.grab = { point: near };
near.grabbed = true;
}
}
return;
}
if (this.tool === 'cut') {
this.cutStroke = { p1: { x: p.x, y: p.y }, p2: { x: p.x, y: p.y } };
return;
}
// Spawn tools: begin drag preview.
this.spawnStart = { x: p.x, y: p.y };
this.spawnCurrent = { x: p.x, y: p.y };
};
const onMove = (e) => {
const p = pos(e);
this.mouse.px = this.mouse.x; this.mouse.py = this.mouse.y;
this.mouse.x = p.x; this.mouse.y = p.y;
if (this.cutStroke) {
this.cutStroke.p2 = { x: p.x, y: p.y };
}
};
const onUp = (e) => {
const p = this.mouse;
if (this.tool === 'grab') {
// Throw: release; velocity carried by prev-position diff.
this.releaseGrab();
}
if (this.tool === 'cut' && this.cutStroke) {
this.cutAlong(this.cutStroke.p1, this.cutStroke.p2);
this.cutStroke = null;
}
// Finalize spawn on left-up for spawn tools.
if ((this.tool === 'rope' || this.tool === 'cloth' || this.tool === 'body' || this.tool === 'ball') && this.spawnStart) {
this.finalizeSpawn(this.spawnStart, { x: p.x, y: p.y });
this.spawnStart = null;
}
this.mouse.down = false;
this.mouse.right = false;
};
cv.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
cv.addEventListener('contextmenu', (e) => e.preventDefault());
// Touch.
cv.addEventListener('touchstart', (e) => { onDown(e); e.preventDefault(); }, { passive: false });
cv.addEventListener('touchmove', (e) => { onMove(e); e.preventDefault(); }, { passive: false });
cv.addEventListener('touchend', (e) => { onUp(e); e.preventDefault(); }, { passive: false });
// Keyboard.
window.addEventListener('keydown', (e) => {
if (e.code === 'Space') { e.preventDefault(); this.togglePause(); }
else if (e.key === 'ArrowRight' || e.key === 's') { this.paused = true; this.step(); this.render(); this.updateReadout(); this.syncPauseBtn(); }
});
}
finalizeSpawn(start, end) {
const dx = end.x - start.x, dy = end.y - start.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (this.tool === 'rope') {
// Place points along the drag direction, pin the anchor. Gravity
// naturally hangs the rest. If barely dragged, hang straight down.
const segs = CFG.ROPE_SEGMENTS;
const len = Math.max(dist, segs * CFG.ROPE_SPACING);
let dirx = dist > 5 ? dx / dist : 0;
let diry = dist > 5 ? dy / dist : 1;
// Normalize so a mostly-horizontal drag still gives a sensible rope.
const mag = Math.sqrt(dirx * dirx + diry * diry) || 1;
dirx /= mag; diry /= mag;
const sp = len / segs;
const obj = this.newObject('rope');
const pts = [];
for (let i = 0; i <= segs; i++) {
const p = this.addPoint(
start.x + dirx * i * sp,
start.y + diry * i * sp,
{ mass: 1, obj }
);
pts.push(p);
}
for (let i = 0; i < pts.length - 1; i++) {
const c = this.addConstraint(pts[i], pts[i + 1], { stiffness: 1, type: 'rope' });
obj.constraints.push(c);
}
pts[0].pinned = true; pts[0].invMass = 0;
} else if (this.tool === 'cloth') {
const cols = CFG.Cloth.cols, rows = CFG.Cloth.rows, sp = CFG.Cloth.spacing;
// Size the cloth by drag, but keep it at least a sensible minimum.
const w = Math.max(dist, cols * sp);
const h = Math.max(dist, rows * sp);
this.spawnCloth(start.x, start.y, { pattern: CFG.Cloth.pinPattern });
// Scale the freshly-spawned points to fill the drag rectangle.
const obj = this.objects[this.objects.length - 1];
const grid = [];
let idx = 0;
for (let r = 0; r < rows; r++) {
const row = [];
for (let c = 0; c < cols; c++) {
const p = obj.points[idx++];
p.x = start.x + (c / (cols - 1)) * w;
p.y = start.y + (r / (rows - 1)) * h;
p.px = p.x; p.py = p.y;
row.push(p);
}
grid.push(row);
}
} else if (this.tool === 'body') {
const r = Math.max(dist, CFG.SOFT_RADIUS * 2);
this.spawnSoftBody(start.x, start.y, r, 'blob');
} else if (this.tool === 'ball') {
const r = Math.max(dist, CFG.BALL_RADIUS);
this.spawnBall(start.x, start.y, r);
}
}
}
/* ---- segment-to-segment distance for cutting ---- */
function segDist(x1, y1, x2, y2, x3, y3, x4, y4) {
// Distance between the CONSTRAINT segment (x1,y1)-(x2,y2) and the CUT
// segment (x3,y3)-(x4,y4). We test each constraint endpoint and the
// constraint midpoint against the cut segment, plus the cut endpoints
// against the constraint segment, and take the minimum — robust enough to
// tear a link whenever the stroke crosses it.
function distToSeg(px, py, ax, ay, bx, by) {
const ddx = bx - ax, ddy = by - ay;
const len2 = ddx * ddx + ddy * ddy;
if (len2 < CFG.SLOP) return Math.hypot(px - ax, py - ay);
let t = ((px - ax) * ddx + (py - ay) * ddy) / len2;
t = clamp(t, 0, 1);
return Math.hypot(px - (ax + t * ddx), py - (ay + t * ddy));
}
const d1 = distToSeg(x1, y1, x3, y3, x4, y4);
const d2 = distToSeg(x2, y2, x3, y3, x4, y4);
const mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
const d3 = distToSeg(mx, my, x3, y3, x4, y4);
const d4 = distToSeg(x3, y3, x1, y1, x2, y2);
const d5 = distToSeg(x4, y4, x1, y1, x2, y2);
return Math.min(d1, d2, d3, d4, d5);
}
/* =========================================================================
BOOT
========================================================================= */
window.addEventListener('load', () => {
const cv = document.getElementById('stage');
function fit() {
cv.width = window.innerWidth;
cv.height = window.innerHeight;
if (window.sim) window.sim.resize(cv.width, cv.height);
}
fit();
window.addEventListener('resize', fit);
const sim = new Sim(cv);
window.sim = sim; // expose for headless probing
window.CFG = CFG;
window.Point = Point;
window.Constraint = Constraint;
window.Sim = Sim;
sim.start();
});
</script>
</body>
</html>
<!-- agent-meta {"model":"bartowski/Ornith-1.5-35B-A3B","provider":"brigid","persona":"full","sessionId":"0a964860-0d8c-4977-9c98-61bcee6a1135","tokensIn":5957693,"tokensOut":85405,"tokensTotal":6043098,"cost":0,"turns":77,"toolCalls":76,"failedToolCalls":2,"timestamp":"2026-08-23T21:36:12.229Z"} -->