Physics Playground
Qwen 3.6 27B Fable Fusion 711 Uncensored Heretic NM DAU NEO MAX MTP GGUF · 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 38,070 bytes · SHA-256 35ea1fc8b6f7
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>2D Soft-Body Physics Playground</title>
<style>
:root {
--bg: #0a0a0f;
--panel: #16161e;
--border: #2a2a36;
--text: #c8c8d4;
--accent: #4fc3f7;
--danger: #ef5350;
--success: #66bb6a;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Segoe UI', system-ui, sans-serif;
font-size: 13px;
overflow: hidden;
display: flex;
height: 100vh;
}
#sidebar {
width: 240px;
min-width: 240px;
background: var(--panel);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
overflow-y: auto;
z-index: 10;
}
.section {
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px;
}
.section-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #666;
margin-bottom: 8px;
}
.spawn-btn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: 4px;
background: #1e1e28;
color: var(--text);
cursor: pointer;
font-size: 12px;
margin-bottom: 4px;
transition: all 0.15s;
}
.spawn-btn:hover { background: #282838; border-color: var(--accent); }
.spawn-btn.active { border-color: var(--accent); background: #1a2836; box-shadow: 0 0 8px rgba(79,195,247,0.2); }
.spawn-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.controls-row {
display: flex;
gap: 6px;
margin-bottom: 6px;
}
.ctrl-btn {
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 4px;
background: #1e1e28;
color: var(--text);
cursor: pointer;
font-size: 12px;
flex: 1;
transition: all 0.15s;
}
.ctrl-btn:hover { background: #282838; border-color: var(--accent); }
.ctrl-btn.danger:hover { border-color: var(--danger); background: #281e1e; }
.slider-row {
display: flex;
flex-direction: column;
gap: 3px;
margin-bottom: 6px;
}
.slider-row label {
display: flex;
justify-content: space-between;
font-size: 11px;
color: #888;
}
.slider-row input[type="range"] {
width: 100%;
accent-color: var(--accent);
}
.toggle-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
}
.toggle-row input[type="checkbox"] {
accent-color: var(--accent);
}
#stats {
font-family: 'Courier New', monospace;
font-size: 10px;
color: #666;
line-height: 1.5;
}
#canvas-wrap {
flex: 1;
position: relative;
overflow: hidden;
}
canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
#overlay-hint {
position: absolute;
bottom: 12px;
left: 12px;
right: 12px;
display: flex;
gap: 12px;
font-size: 11px;
color: #555;
pointer-events: none;
}
#overlay-hint span {
background: rgba(10,10,15,0.8);
padding: 4px 8px;
border-radius: 3px;
}
.kbd {
background: #282838;
padding: 1px 5px;
border-radius: 3px;
border: 1px solid #3a3a46;
font-family: monospace;
font-size: 10px;
}
#tear-indicator {
position: absolute;
top: 12px;
left: 12px;
padding: 4px 10px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
}
#tear-indicator.active {
opacity: 1;
background: rgba(239,83,80,0.2);
border: 1px solid var(--danger);
color: var(--danger);
}
</style>
</head>
<body>
<div id="sidebar">
<div class="section">
<div class="section-title">Spawn</div>
<button class="spawn-btn" data-spawn="rope"><div class="spawn-dot" style="background:#4fc3f7"></div>Rope (drag to draw)</button>
<button class="spawn-btn" data-spawn="cloth"><div class="spawn-dot" style="background:#ff7043"></div>Cloth (drag rect)</button>
<button class="spawn-btn" data-spawn="softbody"><div class="spawn-dot" style="background:#66bb6a"></div>Soft Body (click)</button>
<button class="spawn-btn" data-spawn="ball"><div class="spawn-dot" style="background:#ffd54f"></div>Ball (click)</button>
<div class="toggle-row">
<span>Pin corners only</span>
<input type="checkbox" id="pinCorners">
</div>
<div class="slider-row">
<label><span>Segments</span><span id="segVal">20</span></label>
<input type="range" id="segmentSlider" min="5" max="60" value="20">
</div>
</div>
<div class="section">
<div class="section-title">Simulation</div>
<div class="controls-row">
<button class="ctrl-btn" id="btnPause">⏸ Pause</button>
<button class="ctrl-btn" id="btnStep">⏭ Step</button>
</div>
<div class="controls-row">
<button class="ctrl-btn" id="btnReset">↺ Reset</button>
<button class="ctrl-btn danger" id="btnClear">✕ Clear All</button>
</div>
<div class="slider-row">
<label><span>Gravity</span><span id="gravVal">980</span></label>
<input type="range" id="gravSlider" min="0" max="2000" value="980">
</div>
<div class="slider-row">
<label><span>Damping</span><span id="dampVal">0.985</span></label>
<input type="range" id="dampSlider" min="0.9" max="0.999" value="0.985" step="0.001">
</div>
<div class="slider-row">
<label><span>Iterations</span><span id="iterVal">5</span></label>
<input type="range" id="iterSlider" min="1" max="12" value="5">
</div>
<div class="toggle-row">
<span>Wind</span>
<input type="checkbox" id="windToggle">
</div>
<div class="slider-row" id="windRow" style="display:none">
<label><span>Wind Force</span><span id="windVal">150</span></label>
<input type="range" id="windSlider" min="0" max="500" value="150">
</div>
</div>
<div class="section">
<div class="section-title">Visual</div>
<div class="toggle-row">
<span>Stress colors</span>
<input type="checkbox" id="stressToggle">
</div>
<div class="toggle-row">
<span>Show points</span>
<input type="checkbox" id="pointsToggle">
</div>
<div class="toggle-row">
<span>Cloth fill</span>
<input type="checkbox" id="clothFillToggle" checked>
</div>
</div>
<div class="section">
<div class="section-title">Stats</div>
<div id="stats">
FPS: 0<br>
Points: 0<br>
Constraints: 0<br>
Phys time: 0.00ms
</div>
</div>
</div>
<div id="canvas-wrap">
<canvas id="canvas"></canvas>
<div id="tear-indicator">✂ CUT MODE</div>
<div id="overlay-hint">
<span><kbd class="kbd">Space</kbd> Pause</span>
<span><kbd class="kbd">←→</kbd> Step</span>
<span><kbd class="kbd">R</kbd> Reset</span>
<span><kbd class="kbd">Right-drag</kbd> Cut</span>
<span><kbd class="kbd">Shift</kbd>+click Pin</span>
<span><kbd class="kbd">Ctrl</kbd>+click Spawn pt</span>
</div>
</div>
<script>
/* ==============================================
TUNABLE CONSTANTS
============================================== */
const CFG = {
TIMESTEP: 1 / 60,
MAX_SUBSTEPS: 8,
MAX_FRAME_DELTA: 0.25,
CONSTRAINT_ITERATIONS: 5,
DAMPING: 0.985,
GRAVITY: 980,
FRICTION: 0.25,
GRAB_RADIUS: 16,
TEAR_RADIUS: 12,
TEAR_FORCE_THRESHOLD: 25,
MIN_LENGTH: 0.5,
BOUNCE: 0.3,
WIND_STRENGTH: 0,
};
const COLORS = {
rope: '#4fc3f7',
cloth: '#ff7043',
softbody: '#66bb6a',
ball: '#ffd54f',
pin: '#ffffff',
grab: '#e0e0ff',
wall: '#2a2a3a',
bg: '#0a0a0f',
};
/* ==============================================
MATH UTILS
============================================== */
const Vec2 = {
add: (a, b) => ({ x: a.x + b.x, y: a.y + b.y }),
sub: (a, b) => ({ x: a.x - b.x, y: a.y - b.y }),
scale: (v, s) => ({ x: v.x * s, y: v.y * s }),
len: (v) => Math.sqrt(v.x * v.x + v.y * v.y),
dist: (a, b) => Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2),
normalize: (v) => {
const l = Math.sqrt(v.x * v.x + v.y * v.y);
return l > 1e-6 ? { x: v.x / l, y: v.y / l } : { x: 0, y: 0 };
},
dot: (a, b) => a.x * b.x + a.y * b.y,
};
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
function stressColor(ratio) {
const r = clamp((ratio - 0.8) / 0.4, 0, 1);
const g = clamp(1 - (ratio - 0.95) / 0.3, 0, 1);
return `rgb(${Math.round(r * 255)},${Math.round(g * 255)},20)`;
}
/* ==============================================
PHYSICS CLASSES
============================================== */
class Point {
constructor(x, y, pinned = false, radius = 0, color = '#ccc') {
this.x = x;
this.y = y;
this.oldX = x;
this.oldY = y;
this.pinned = pinned;
this.radius = radius;
this.color = color;
this.mass = 1;
this.grabbed = false;
this.grabVelX = 0;
this.grabVelY = 0;
this.objectId = -1;
this.id = -1;
}
get vx() { return this.x - this.oldX; }
get vy() { return this.y - this.oldY; }
get speed() { return Math.sqrt(this.vx * this.vx + this.vy * this.vy); }
get ke() { return 0.5 * this.mass * (this.vx * this.vx + this.vy * this.vy); }
}
class Constraint {
constructor(p1, p2, length = null, color = null) {
this.p1 = p1;
this.p2 = p2;
this.length = length !== null ? length : Vec2.dist(p1, p2);
this.color = color;
this.active = true;
this.stretchRatio = 1;
this.id = -1;
}
}
/* ==============================================
SIMULATION ENGINE
============================================== */
class Simulation {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.points = [];
this.constraints = [];
this.nextPointId = 0;
this.nextConstraintId = 0;
this.nextObjectId = 0;
// Static geometry
this.staticCircles = [];
this.staticBoxes = [];
// State
this.paused = false;
this.windToggle = false;
this.showStress = false;
this.showPoints = false;
this.clothFill = true;
// Interaction
this.mouseDown = false;
this.mouseX = 0;
this.mouseY = 0;
this.lastMouseX = 0;
this.lastMouseY = 0;
this.grabbedPoint = null;
this.cutting = false;
this.spawnMode = null;
this.spawnStart = null;
// Timing
this.accumulator = 0;
this.lastTime = performance.now();
this.physicsTime = 0;
// Stats
this.frameCount = 0;
this.fps = 0;
this.fpsTimer = 0;
this.lastPhysTime = 0;
this.resize();
window.addEventListener('resize', () => this.resize());
this.setupInput();
}
resize() {
const rect = this.canvas.parentElement.getBoundingClientRect();
this.canvas.width = rect.width;
this.canvas.height = rect.height;
this.width = rect.width;
this.height = rect.height;
}
setupInput() {
const c = this.canvas;
c.addEventListener('mousedown', (e) => {
const rect = c.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
this.mouseX = x;
this.mouseY = y;
this.lastMouseX = x;
this.lastMouseY = y;
if (e.button === 2) {
e.preventDefault();
this.cutting = true;
this.mouseDown = true;
return;
}
// Shift + click: toggle pin
if (e.shiftKey) {
const pt = this.findNearestPoint(x, y, 20);
if (pt) {
pt.pinned = !pt.pinned;
}
return;
}
// Ctrl + click: spawn point at location when in spawn mode
if (e.ctrlKey && this.spawnMode === 'softbody') {
this.spawnSoftBodyAt(x, y);
return;
}
if (this.spawnMode) {
this.mouseDown = true;
this.spawnStart = { x, y };
return;
}
// Grab point
const pt = this.findNearestPoint(x, y, CFG.GRAB_RADIUS);
if (pt && !pt.pinned) {
this.grabbedPoint = pt;
this.mouseDown = true;
}
});
c.addEventListener('mousemove', (e) => {
const rect = c.getBoundingClientRect();
this.lastMouseX = this.mouseX;
this.lastMouseY = this.mouseY;
this.mouseX = e.clientX - rect.left;
this.mouseY = e.clientY - rect.top;
if (this.cutting && this.mouseDown) {
this.cutConstraintsBetween(this.lastMouseX, this.lastMouseY, this.mouseX, this.mouseY);
}
if (this.grabbedPoint && this.mouseDown) {
this.grabbedPoint.oldX = this.mouseX;
this.grabbedPoint.oldY = this.mouseY;
this.grabbedPoint.x = this.mouseX;
this.grabbedPoint.y = this.mouseY;
this.grabbedPoint.grabbed = true;
}
});
c.addEventListener('mouseup', (e) => {
if (this.grabbedPoint) {
this.grabbedPoint.grabbed = false;
// Preserve throw velocity from grab
this.grabbedPoint = null;
}
if (this.cutting) {
this.cutting = false;
}
// Handle spawn drag
if (this.spawnMode && this.spawnStart && this.mouseDown) {
const x = this.mouseX;
const y = this.mouseY;
const startX = this.spawnStart.x;
const startY = this.spawnStart.y;
const seg = parseInt(document.getElementById('segmentSlider').value);
if (this.spawnMode === 'rope') {
this.spawnRope(startX, startY, x, y, seg);
} else if (this.spawnMode === 'cloth') {
this.spawnCloth(Math.min(startX, x), Math.min(startY, y), Math.abs(x - startX), Math.abs(y - startY), seg);
}
this.spawnStart = null;
}
this.mouseDown = false;
});
c.addEventListener('contextmenu', (e) => e.preventDefault());
// Keyboard
window.addEventListener('keydown', (e) => {
if (e.code === 'Space') {
e.preventDefault();
this.paused = !this.paused;
document.getElementById('btnPause').textContent = this.paused ? '▶ Play' : '⏸ Pause';
}
if (e.code === 'ArrowLeft' || e.code === 'ArrowRight') {
if (this.paused) {
this.step();
}
}
if (e.code === 'KeyR') {
this.reset();
}
});
// UI bindings
document.querySelectorAll('.spawn-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.spawn-btn').forEach(b => b.classList.remove('active'));
if (this.spawnMode === btn.dataset.spawn) {
this.spawnMode = null;
} else {
this.spawnMode = btn.dataset.spawn;
btn.classList.add('active');
}
});
});
document.getElementById('btnPause').addEventListener('click', () => {
this.paused = !this.paused;
document.getElementById('btnPause').textContent = this.paused ? '▶ Play' : '⏸ Pause';
});
document.getElementById('btnStep').addEventListener('click', () => this.step());
document.getElementById('btnReset').addEventListener('click', () => this.reset());
document.getElementById('btnClear').addEventListener('click', () => this.clearAll());
document.getElementById('gravSlider').addEventListener('input', (e) => {
CFG.GRAVITY = parseFloat(e.target.value);
document.getElementById('gravVal').textContent = CFG.GRAVITY;
});
document.getElementById('dampSlider').addEventListener('input', (e) => {
CFG.DAMPING = parseFloat(e.target.value);
document.getElementById('dampVal').textContent = CFG.DAMPING.toFixed(3);
});
document.getElementById('iterSlider').addEventListener('input', (e) => {
CFG.CONSTRAINT_ITERATIONS = parseInt(e.target.value);
document.getElementById('iterVal').textContent = CFG.CONSTRAINT_ITERATIONS;
});
document.getElementById('segmentSlider').addEventListener('input', (e) => {
document.getElementById('segVal').textContent = e.target.value;
});
document.getElementById('windToggle').addEventListener('change', (e) => {
this.windToggle = e.target.checked;
document.getElementById('windRow').style.display = e.target.checked ? 'flex' : 'none';
});
document.getElementById('windSlider').addEventListener('input', (e) => {
CFG.WIND_STRENGTH = parseFloat(e.target.value);
document.getElementById('windVal').textContent = CFG.WIND_STRENGTH;
});
document.getElementById('stressToggle').addEventListener('change', (e) => {
this.showStress = e.target.checked;
});
document.getElementById('pointsToggle').addEventListener('change', (e) => {
this.showPoints = e.target.checked;
});
document.getElementById('clothFillToggle').addEventListener('change', (e) => {
this.clothFill = e.target.checked;
});
// Tear indicator
window.addEventListener('mousedown', (e) => {
const ind = document.getElementById('tear-indicator');
if (e.button === 2) ind.classList.add('active');
});
window.addEventListener('mouseup', (e) => {
if (e.button === 2) document.getElementById('tear-indicator').classList.remove('active');
});
}
/* ---------- Object Creation ---------- */
addPoint(x, y, pinned = false, radius = 0, color = '#ccc') {
const pt = new Point(x, y, pinned, radius, color);
pt.id = this.nextPointId++;
this.points.push(pt);
return pt;
}
addConstraint(p1, p2, length = null, color = null) {
const c = new Constraint(p1, p2, length, color);
c.id = this.nextConstraintId++;
this.constraints.push(c);
return c;
}
spawnRope(x1, y1, x2, y2, segments) {
const color = COLORS.rope;
const dx = x2 - x1;
const dy = y2 - y1;
const len = Math.sqrt(dx * dx + dy * dy);
if (len < 20) return;
const spacing = len / segments;
const nx = dx / len;
const ny = dy / len;
const pts = [];
for (let i = 0; i <= segments; i++) {
const px = x1 + nx * i * spacing;
const py = y1 + ny * i * spacing;
pts.push(this.addPoint(px, py, i === 0, 0, color));
}
for (let i = 0; i < pts.length - 1; i++) {
pts[i].objectId = this.nextObjectId;
pts[i + 1].objectId = this.nextObjectId;
this.addConstraint(pts[i], pts[i + 1], spacing, color);
}
this.nextObjectId++;
}
spawnCloth(x, y, w, h, segs) {
const color = COLORS.cloth;
if (w < 20 || h < 20) return;
const cols = Math.max(4, Math.round(w / 18));
const rows = Math.max(4, Math.round(h / 18));
const dx = w / (cols - 1);
const dy = h / (rows - 1);
const pinCornersOnly = document.getElementById('pinCorners').checked;
const pts = [];
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const px = x + col * dx;
const py = y + row * dy;
let pinned = false;
if (row === 0) {
if (pinCornersOnly) {
pinned = (col === 0 || col === cols - 1);
} else {
pinned = true;
}
}
pts.push(this.addPoint(px, py, pinned, 0, color));
}
}
const oid = this.nextObjectId++;
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const idx = row * cols + col;
pts[idx].objectId = oid;
// Structural (horizontal + vertical)
if (col < cols - 1) {
this.addConstraint(pts[idx], pts[idx + 1], dx, color);
}
if (row < rows - 1) {
this.addConstraint(pts[idx], pts[idx + cols], dy, color);
}
// Shear (diagonal) - every other to avoid over-constraining
if (col < cols - 1 && row < rows - 1) {
if ((row + col) % 2 === 0) {
this.addConstraint(pts[idx], pts[idx + cols + 1], Math.sqrt(dx * dx + dy * dy), color);
} else {
this.addConstraint(pts[idx + 1], pts[idx + cols], Math.sqrt(dx * dx + dy * dy), color);
}
}
}
}
}
spawnSoftBodyAt(x, y) {
const color = COLORS.softbody;
const segs = parseInt(document.getElementById('segmentSlider').value);
const radius = 50;
const pts = [];
// Center point
const center = this.addPoint(x, y, false, 0, color);
pts.push(center);
// Ring points
for (let i = 0; i < segs; i++) {
const angle = (i / segs) * Math.PI * 2;
const px = x + Math.cos(angle) * radius;
const py = y + Math.sin(angle) * radius;
const pt = this.addPoint(px, py, false, 3, color);
pt.objectId = this.nextObjectId;
pts.push(pt);
}
const oid = this.nextObjectId++;
center.objectId = oid;
// Edge constraints
for (let i = 1; i < pts.length; i++) {
this.addConstraint(pts[i], pts[(i % (pts.length - 1)) + 1], null, color);
}
// Radial constraints (center to ring)
for (let i = 1; i < pts.length; i++) {
this.addConstraint(center, pts[i], radius, color);
}
// Cross braces for rigidity (every 3rd)
for (let i = 1; i < pts.length; i++) {
const j = ((i + Math.floor(segs / 3)) % (pts.length - 1)) + 1;
this.addConstraint(pts[i], pts[j], null, color);
}
}
spawnSoftBody(x, y, w, h) {
this.spawnSoftBodyAt(x + w / 2, y + h / 2);
}
spawnBall(x, y) {
const color = COLORS.ball;
const r = 12;
const pt = this.addPoint(x, y, false, r, color);
pt.objectId = this.nextObjectId++;
}
/* ---------- Query ---------- */
findNearestPoint(x, y, radius) {
let best = null;
let bestDist = radius * radius;
for (let i = 0; i < this.points.length; i++) {
const p = this.points[i];
const dx = x - p.x;
const dy = y - p.y;
const d2 = dx * dx + dy * dy;
if (d2 < bestDist) {
bestDist = d2;
best = p;
}
}
return best;
}
/* ---------- Cutting ---------- */
cutConstraintsBetween(x1, y1, x2, y2) {
const segs = 5;
for (let i = 0; i <= segs; i++) {
const t = i / segs;
const cx = x1 + (x2 - x1) * t;
const cy = y1 + (y2 - y1) * t;
this.cutAtPoint(cx, cy);
}
}
cutAtPoint(x, y) {
for (let i = this.constraints.length - 1; i >= 0; i--) {
const c = this.constraints[i];
if (!c.active) continue;
if (this.pointToSegmentDist(x, y, c.p1.x, c.p1.y, c.p2.x, c.p2.y) < CFG.TEAR_RADIUS) {
c.active = false;
}
}
}
pointToSegmentDist(px, py, ax, ay, bx, by) {
const dx = bx - ax;
const dy = by - ay;
const len2 = dx * dx + dy * dy;
if (len2 < 1e-6) return Math.sqrt((px - ax) ** 2 + (py - ay) ** 2);
let t = ((px - ax) * dx + (py - ay) * dy) / len2;
t = clamp(t, 0, 1);
const cx = ax + t * dx;
const cy = ay + t * dy;
return Math.sqrt((px - cx) ** 2 + (py - cy) ** 2);
}
/* ---------- Physics Step ---------- */
integrate() {
const dt = CFG.TIMESTEP;
const dt2 = dt * dt;
const grav = CFG.GRAVITY;
const damp = CFG.DAMPING;
const wind = this.windToggle ? CFG.WIND_STRENGTH : 0;
for (let i = 0; i < this.points.length; i++) {
const p = this.points[i];
if (p.pinned || p.grabbed) continue;
const vx = (p.x - p.oldX) * damp;
const vy = (p.y - p.oldY) * damp;
p.oldX = p.x;
p.oldY = p.y;
p.x += vx + wind * dt2;
p.y += vy + grav * dt2;
// NaN guard
if (!isFinite(p.x) || !isFinite(p.y)) {
p.x = p.oldX;
p.y = p.oldY;
}
}
}
solveConstraints() {
const iterations = CFG.CONSTRAINT_ITERATIONS;
for (let iter = 0; iter < iterations; iter++) {
for (let i = 0; i < this.constraints.length; i++) {
const c = this.constraints[i];
if (!c.active) continue;
const p1 = c.p1;
const p2 = c.p2;
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < CFG.MIN_LENGTH) {
c.stretchRatio = 1;
continue;
}
c.stretchRatio = dist / c.length;
const diff = (dist - c.length) / dist;
const corrX = dx * diff * 0.5;
const corrY = dy * diff * 0.5;
if (!p1.pinned && !p1.grabbed) {
p1.x += corrX;
p1.y += corrY;
}
if (!p2.pinned && !p2.grabbed) {
p2.x -= corrX;
p2.y -= corrY;
}
}
// Collisions with static geometry
this.handleCollisions();
}
}
handleCollisions() {
const w = this.width;
const h = this.height;
const bounce = CFG.BOUNCE;
const friction = CFG.FRICTION;
for (let i = 0; i < this.points.length; i++) {
const p = this.points[i];
const r = p.radius || 0;
if (p.pinned || p.grabbed) continue;
// Walls
if (p.x < r) {
p.x = r;
if (p.oldX > p.x) p.oldX = p.x + (p.oldX - p.x) * bounce;
p.oldY = p.y - (p.y - p.oldY) * friction;
}
if (p.x > w - r) {
p.x = w - r;
if (p.oldX < p.x) p.oldX = p.x - (p.x - p.oldX) * bounce;
p.oldY = p.y - (p.y - p.oldY) * friction;
}
if (p.y < r) {
p.y = r;
if (p.oldY > p.y) p.oldY = p.y + (p.oldY - p.y) * bounce;
p.oldX = p.x - (p.x - p.oldX) * friction;
}
if (p.y > h - r) {
p.y = h - r;
if (p.oldY < p.y) p.oldY = p.y - (p.y - p.oldY) * bounce;
p.oldX = p.x - (p.x - p.oldX) * friction;
}
// Static circles
for (let j = 0; j < this.staticCircles.length; j++) {
const c = this.staticCircles[j];
const dx = p.x - c.x;
const dy = p.y - c.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const minDist = c.r + r;
if (dist < minDist && dist > 1e-6) {
const nx = dx / dist;
const ny = dy / dist;
const overlap = minDist - dist;
p.x += nx * overlap;
p.y += ny * overlap;
// Frictional bounce
const vx = p.x - p.oldX;
const vy = p.y - p.oldY;
const vn = vx * nx + vy * ny;
if (vn < 0) {
const tx = -ny;
const ty = nx;
const vt = vx * tx + vy * ty;
p.oldX = p.x - (-vn * bounce * nx + vt * (1 - friction) * tx);
p.oldY = p.y - (-vn * bounce * ny + vt * (1 - friction) * ty);
}
}
}
// Static boxes
for (let j = 0; j < this.staticBoxes.length; j++) {
const b = this.staticBoxes[j];
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;
const dy = p.y - cy;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < r && dist > 1e-6) {
const nx = dx / dist;
const ny = dy / dist;
const overlap = r - dist;
p.x += nx * overlap;
p.y += ny * overlap;
const vx = p.x - p.oldX;
const vy = p.y - p.oldY;
const vn = vx * nx + vy * ny;
if (vn < 0) {
const tx = -ny;
const ty = nx;
const vt = vx * tx + vy * ty;
p.oldX = p.x - (-vn * bounce * nx + vt * (1 - friction) * tx);
p.oldY = p.y - (-vn * bounce * ny + vt * (1 - friction) * ty);
}
}
}
}
}
step() {
this.integrate();
this.solveConstraints();
this.physicsTime += CFG.TIMESTEP;
}
/* ---------- Update Loop ---------- */
update() {
const now = performance.now();
let dt = now - this.lastTime;
this.lastTime = now;
// Clamp frame delta to prevent explosions on tab switch
dt = Math.min(dt, CFG.MAX_FRAME_DELTA * 1000);
// FPS tracking
this.frameCount++;
this.fpsTimer += dt;
if (this.fpsTimer >= 500) {
this.fps = Math.round(this.frameCount * 1000 / this.fpsTimer);
this.frameCount = 0;
this.fpsTimer = 0;
this.updateStats();
}
if (!this.paused) {
this.accumulator += dt / 1000;
let substeps = 0;
while (this.accumulator >= CFG.TIMESTEP && substeps < CFG.MAX_SUBSTEPS) {
const stepStart = performance.now();
this.step();
this.lastPhysTime = performance.now() - stepStart;
this.accumulator -= CFG.TIMESTEP;
substeps++;
}
// Prevent spiral of death
if (this.accumulator > CFG.TIMESTEP * 2) {
this.accumulator = 0;
}
}
this.render();
requestAnimationFrame(() => this.update());
}
updateStats() {
const el = document.getElementById('stats');
el.innerHTML = `FPS: ${this.fps}<br>Points: ${this.points.length}<br>Constraints: ${this.constraints.filter(c => c.active).length}<br>Phys time: ${this.lastPhysTime.toFixed(1)}ms`;
}
/* ---------- Rendering ---------- */
render() {
const ctx = this.ctx;
const w = this.width;
const h = this.height;
// Clear
ctx.fillStyle = COLORS.bg;
ctx.fillRect(0, 0, w, h);
// Grid
ctx.strokeStyle = '#14141e';
ctx.lineWidth = 1;
for (let x = 0; x < w; x += 40) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, h);
ctx.stroke();
}
for (let y = 0; y < h; y += 40) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
// Draw static geometry
this.drawStaticGeometry(ctx);
// Group constraints by object for cloth fill
const clothObjects = new Map();
// Draw constraints
ctx.lineWidth = 1.2;
for (let i = 0; i < this.constraints.length; i++) {
const c = this.constraints[i];
if (!c.active) continue;
const p1 = c.p1;
const p2 = c.p2;
if (this.showStress) {
ctx.strokeStyle = stressColor(c.stretchRatio);
} else {
ctx.strokeStyle = c.color || '#555';
}
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
// Track cloth objects
if (p1.objectId >= 0 && p1.color === COLORS.cloth) {
if (!clothObjects.has(p1.objectId)) {
clothObjects.set(p1.objectId, []);
}
clothObjects.get(p1.objectId).push({ p1: { x: p1.x, y: p1.y }, p2: { x: p2.x, y: p2.y } });
}
}
// Draw soft bodies filled
this.drawSoftBodiesFilled(ctx);
// Draw cloth filled
if (this.clothFill) {
this.drawClothFilled(ctx, clothObjects);
}
// Draw balls
ctx.lineWidth = 1;
for (let i = 0; i < this.points.length; i++) {
const p = this.points[i];
if (p.radius > 0) {
ctx.beginPath();
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
ctx.fillStyle = p.color;
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.stroke();
}
}
// Draw points
if (this.showPoints || this.points.length < 200) {
for (let i = 0; i < this.points.length; i++) {
const p = this.points[i];
if (p.radius > 0) continue;
if (p.pinned) {
ctx.fillStyle = COLORS.pin;
ctx.beginPath();
ctx.moveTo(p.x - 4, p.y);
ctx.lineTo(p.x + 4, p.y);
ctx.moveTo(p.x, p.y - 4);
ctx.lineTo(p.x, p.y + 4);
ctx.stroke();
} else if (this.showPoints) {
ctx.fillStyle = p.color;
ctx.fillRect(p.x - 1.5, p.y - 1.5, 3, 3);
}
}
}
// Draw grab highlight
if (this.grabbedPoint) {
const p = this.grabbedPoint;
ctx.strokeStyle = COLORS.grab;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(p.x, p.y, 8, 0, Math.PI * 2);
ctx.stroke();
}
// Draw spawn preview
if (this.spawnMode && this.spawnStart && this.mouseDown) {
ctx.strokeStyle = 'rgba(255,255,255,0.4)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
if (this.spawnMode === 'rope') {
ctx.beginPath();
ctx.moveTo(this.spawnStart.x, this.spawnStart.y);
ctx.lineTo(this.mouseX, this.mouseY);
ctx.stroke();
} else if (this.spawnMode === 'cloth') {
ctx.strokeRect(
Math.min(this.spawnStart.x, this.mouseX),
Math.min(this.spawnStart.y, this.mouseY),
Math.abs(this.mouseX - this.spawnStart.x),
Math.abs(this.mouseY - this.spawnStart.y)
);
}
ctx.setLineDash([]);
}
// Cut mode cursor trail
if (this.cutting) {
ctx.strokeStyle = COLORS.danger;
ctx.lineWidth = CFG.TEAR_RADIUS * 2;
ctx.globalAlpha = 0.3;
ctx.beginPath();
ctx.arc(this.mouseX, this.mouseY, CFG.TEAR_RADIUS, 0, Math.PI * 2);
ctx.stroke();
ctx.globalAlpha = 1;
}
}
drawStaticGeometry(ctx) {
// Walls (subtle)
ctx.strokeStyle = '#2a2a3a';
ctx.lineWidth = 3;
ctx.strokeRect(1.5, 1.5, this.width - 3, this.height - 3);
// Floor line
ctx.strokeStyle = '#3a3a4a';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, this.height - 1);
ctx.lineTo(this.width, this.height - 1);
ctx.stroke();
// Static circles
for (let i = 0; i < this.staticCircles.length; i++) {
const c = this.staticCircles[i];
ctx.beginPath();
ctx.arc(c.x, c.y, c.r, 0, Math.PI * 2);
ctx.fillStyle = '#1a1a24';
ctx.fill();
ctx.strokeStyle = '#4a4a5a';
ctx.lineWidth = 2;
ctx.stroke();
}
// Static boxes
for (let i = 0; i < this.staticBoxes.length; i++) {
const b = this.staticBoxes[i];
ctx.fillStyle = '#1a1a24';
ctx.fillRect(b.x, b.y, b.w, b.h);
ctx.strokeStyle = '#4a4a5a';
ctx.lineWidth = 2;
ctx.strokeRect(b.x, b.y, b.w, b.h);
}
}
drawSoftBodiesFilled(ctx) {
// Collect points by object
const objects = new Map();
for (let i = 0; i < this.points.length; i++) {
const p = this.points[i];
if (p.color === COLORS.softbody && p.objectId >= 0 && !objects.has(p.objectId)) {
objects.set(p.objectId, []);
}
if (p.color === COLORS.softbody && p.objectId >= 0) {
objects.get(p.objectId).push(p);
}
}
// Draw convex hull-ish fill for each
for (const [oid, pts] of objects) {
if (pts.length < 3) continue;
// Find center (smallest radius point or first)
let centerIdx = 0;
let minR = Infinity;
for (let i = 0; i < pts.length; i++) {
if (pts[i].radius === 0) {
centerIdx = i;
break;
}
if (pts[i].radius < minR) {
minR = pts[i].radius;
centerIdx = i;
}
}
const center = pts[centerIdx];
const ring = pts.filter(p => p !== center);
if (ring.length < 3) continue;
// Sort ring by angle
ring.sort((a, b) => {
return Math.atan2(a.y - center.y, a.x - center.x) - Math.atan2(b.y - center.y, b.x - center.x);
});
ctx.beginPath();
ctx.moveTo(ring[0].x, ring[0].y);
for (let i = 1; i < ring.length; i++) {
ctx.lineTo(ring[i].x, ring[i].y);
}
ctx.closePath();
ctx.fillStyle = 'rgba(102,187,106,0.15)';
ctx.fill();
}
}
drawClothFilled(ctx, clothObjects) {
// For each cloth object, create triangles from grid
for (const [oid, constraints] of clothObjects) {
// Collect unique points
const points = new Map();
for (const c of constraints) {
const key1 = `${c.p1.x.toFixed(0)},${c.p1.y.toFixed(0)}`;
const key2 = `${c.p2.x.toFixed(0)},${c.p2.y.toFixed(0)}`;
if (!points.has(key1)) points.set(key1, c.p1);
if (!points.has(key2)) points.set(key2, c.p2);
}
if (points.size < 4) continue;
// Simple approach: draw triangles based on structural constraints
const structConstraints = constraints.filter(c => {
// Horizontal or vertical-ish
const dx = c.p2.x - c.p1.x;
const dy = c.p2.y - c.p1.y;
return Math.abs(Math.abs(dx) - Math.abs(dy)) > Math.sqrt(dx*dx+dy*dy) * 0.3;
});
// Find grid structure - this is complex, so use convex hull fill instead
const pts = Array.from(points.values());
const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length;
const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length;
pts.sort((a, b) => {
return Math.atan2(a.y - cy, a.x - cx) - Math.atan2(b.y - cy, b.x - cx);
});
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) {
ctx.lineTo(pts[i].x, pts[i].y);
}
ctx.closePath();
ctx.fillStyle = 'rgba(255,112,67,0.1)';
ctx.fill();
}
}
/* ---------- Control ---------- */
reset() {
this.clearAll();
this.lastTime = performance.now();
this.accumulator = 0;
this.physicsTime = 0;
this.addDemoObjects();
}
clearAll() {
this.points = [];
this.constraints = [];
this.nextPointId = 0;
this.nextConstraintId = 0;
this.nextObjectId = 0;
this.grabbedPoint = null;
this.spawnStart = null;
this.updateStats();
}
addDemoObjects() {
const w = this.width;
const h = this.height;
// Static obstacle: large circle
this.staticCircles.push({ x: w * 0.4, y: h * 0.55, r: 60 });
// Another circle
this.staticCircles.push({ x: w * 0.65, y: h * 0.45, r: 40 });
// A box
this.staticBoxes.push({ x: w * 0.8, y: h * 0.75, w: 100, h: 20 });
// Rope
this.spawnRope(w * 0.15, h * 0.15, w * 0.15 + 50, h * 0.55, 30);
// Cloth
this.spawnCloth(w * 0.45, h * 0.1, w * 0.25, h * 0.35, 20);
// Soft body
this.spawnSoftBodyAt(w * 0.7, h * 0.2, 50);
// Ball
this.spawnBall(w * 0.25, h * 0.15);
}
}
/* ==============================================
INITIALIZATION
============================================== */
const canvas = document.getElementById('canvas');
const sim = new Simulation(canvas);
window.sim = sim; // Expose for debugging/probing
sim.addDemoObjects();
sim.update();
</script>
</body>
</html>
<!-- agent-meta {"model":"Qwen3.6-27B-Fable-Fusion-711-Uncensored-Heretic-NM-DAU-NEO-MAX-MTP-GGUF","provider":"brigid","persona":"full","sessionId":"78308e20-9418-4738-b4d3-dbcbc4156310","tokensIn":23941,"tokensOut":12863,"tokensTotal":36804,"cost":0,"turns":3,"toolCalls":2,"failedToolCalls":0,"timestamp":"2026-08-06T03:44:07.018Z"} -->