Physics Playground
Qwen 3.6 27b MTP · composer

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 60,544 bytes · SHA-256 a079eae58654
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>2D Soft-Body Physics Playground</title>
<style>
/* ── css: layout ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #1a1a2e; font-family: 'Segoe UI', system-ui, sans-serif; color: #ccc; }
#app { position: relative; width: 100%; height: 100%; }
canvas { display: block; position: absolute; top: 0; left: 0; cursor: crosshair; }
/* Toolbar */
#toolbar {
position: absolute; top: 8px; left: 50%; transform: translateX(-50%);
display: flex; align-items: center; gap: 4px; padding: 6px 12px;
background: rgba(10, 10, 30, 0.85); border-radius: 10px;
backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,0.08);
z-index: 10; user-select: none;
}
#toolbar button {
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
color: #ddd; padding: 5px 10px; border-radius: 6px; cursor: pointer;
font-size: 13px; transition: all 0.15s; white-space: nowrap;
}
#toolbar button:hover { background: rgba(255,255,255,0.12); }
#toolbar button.active { background: rgba(80, 140, 255, 0.3); border-color: rgba(80, 140, 255, 0.6); color: #fff; }
#toolbar .sep { width: 1px; height: 20px; background: rgba(255,255,255,0.1); margin: 0 4px; }
#toolbar label { font-size: 11px; color: #aaa; cursor: pointer; display: flex; align-items: center; gap: 3px; white-space: nowrap; }
/* Controls bar */
#controls {
position: absolute; bottom: 8px; left: 50%; transform: translateX(-50%);
display: flex; align-items: center; gap: 12px; padding: 6px 14px;
background: rgba(10, 10, 30, 0.85); border-radius: 10px;
backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,0.08);
z-index: 10; user-select: none; flex-wrap: wrap; justify-content: center;
}
#controls label { font-size: 11px; color: #aaa; display: flex; align-items: center; gap: 4px; }
#controls input[type="range"] { width: 70px; height: 4px; accent-color: #508cff; }
#controls button {
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
color: #ddd; padding: 4px 8px; border-radius: 5px; cursor: pointer;
font-size: 12px; transition: all 0.15s;
}
#controls button:hover { background: rgba(255,255,255,0.12); }
#stats { font-size: 10px; color: #666; margin-left: auto; white-space: nowrap; }
/* Slider value display */
#controls span { min-width: 30px; text-align: right; font-variant-numeric: tabular-nums; }
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div id="toolbar">
<button data-tool="grab" class="active" title="Grab (G)">✋ Grab</button>
<button data-tool="rope" title="Rope (R)">🪢 Rope</button>
<button data-tool="cloth" title="Cloth (C)">🧵 Cloth</button>
<button data-tool="softbody" title="Soft Body (S)">🫧 Blob</button>
<button data-tool="ball" title="Ball (B)">⚽ Ball</button>
<span class="sep"></span>
<label><input type="checkbox" id="chkStress"> Stress</label>
<label><input type="checkbox" id="chkPoints"> Points</label>
<label><input type="checkbox" id="chkWind"> Wind</label>
</div>
<div id="controls">
<label>G: <input type="range" id="sliderGravity" min="-2000" max="3000" value="980" step="10"><span id="valG">980</span></label>
<label>Damp: <input type="range" id="sliderDamping" min="0.90" max="1.0" value="0.995" step="0.001"><span id="valD">0.995</span></label>
<label>Itr: <input type="range" id="sliderIter" min="1" max="20" value="6" step="1"><span id="valI">6</span></label>
<button id="btnPause">⏸ Pause</button>
<button id="btnStep">⏭ Step</button>
<button id="btnReset">🔄 Reset</button>
<div id="stats">FPS: -- | Phys: --ms</div>
</div>
<script>
'use strict';
// ── const: CONFIG ──
// ============================================================
// TUNABLE CONSTANTS — tweak these to adjust simulation feel
// ============================================================
const CONFIG = {
// Physics timestep (fixed)
dt: 1 / 60,
maxDtAccumulator: 0.25, // clamp accumulator on tab-switch
constraintIterations: 6, // relaxation passes per step
// Gravity & damping
gravity: 980, // pixels/s² downward
damping: 0.995, // velocity multiplier per step (energy decay)
// Collision
wallFriction: 0.3, // friction against walls/floor
collisionPadding: 2, // extra padding for point collisions
// Interaction
grabRadius: 18, // pixels to detect nearest point
tearRadius: 15, // radius for cutting constraints
throwDamping: 0.9, // velocity damping on release
// Rendering
pointRadius: 2, // size of rendered points
clothLineWidth: 1, // line width for cloth mesh
ropeLineWidth: 2.5, // line width for ropes
softBodyFillAlpha: 0.35, // fill alpha for soft bodies
// Colors per object type (HSL base hues)
colors: {
rope: { h: 210, s: 80, l: 60 }, // blue
cloth: { h: 340, s: 70, l: 55 }, // pink/magenta
softbody: { h: 160, s: 70, l: 50 }, // teal/green
ball: { h: 40, s: 90, l: 55 }, // yellow/orange
},
// Stress visualization thresholds (stretch ratio)
stressGreen: 1.0, // ratio at which color is green
stressRed: 1.3, // ratio at which color is red
// Wind
windStrength: 200, // pixels/s² horizontal force
};
// ── const: STATIC_OBJS ──
// ============================================================
// STATIC GEOMETRY — immovable objects in the scene
// ============================================================
const STATIC_OBJS = [
{ type: 'circle', cx: 0.5, cy: 0.65, r: 0.12 }, // big circle (relative to canvas)
{ type: 'box', x: 0.15, y: 0.78, w: 0.12, h: 0.04 }, // left platform
{ type: 'box', x: 0.73, y: 0.72, w: 0.12, h: 0.04 }, // right platform (higher)
];
// ── class: Point ──
// ============================================================
// POINT CLASS — a single particle in the simulation
// ============================================================
class Point {
constructor(x, y, pinned = false) {
this.x = x;
this.y = y;
this.px = x; // previous position (for Verlet velocity)
this.py = y;
this.pinned = pinned; // if true, point doesn't move
this.radius = 0; // for ball objects
this.mass = 1; // uniform mass for simplicity
}
get vx() { return this.x - this.px; }
get vy() { return this.y - this.py; }
setVelocity(vx, vy) {
this.px = this.x - vx;
this.py = this.y - vy;
}
}
// ── class: Constraint ──
// ============================================================
// CONSTRAINT CLASS — distance constraint between two points
// ============================================================
class Constraint {
constructor(p1, p2, stiffness = 1.0) {
this.p1 = p1;
this.p2 = p2;
this.restLength = Math.hypot(p2.x - p1.x, p2.y - p1.y);
this.stiffness = stiffness;
this.active = true; // set false to "cut" the constraint
}
get currentLength() {
return Math.hypot(this.p2.x - this.p1.x, this.p2.y - this.p1.y);
}
get stretchRatio() {
if (this.restLength < 0.001) return 1; // guard zero-length
return this.currentLength / this.restLength;
}
}
// ── class: PhysicsObject ──
// ============================================================
// OBJECT CLASS — a collection of points and constraints
// ============================================================
class PhysicsObject {
constructor(type, colorKey) {
this.type = type; // 'rope', 'cloth', 'softbody', 'ball'
this.colorKey = colorKey;
this.points = [];
this.constraints = [];
this.alive = true;
}
addPoint(x, y, pinned) {
const p = new Point(x, y, pinned);
this.points.push(p);
return p;
}
addConstraint(i1, i2, stiffness) {
if (i1 < 0 || i1 >= this.points.length || i2 < 0 || i2 >= this.points.length) return null;
const c = new Constraint(this.points[i1], this.points[i2], stiffness);
this.constraints.push(c);
return c;
}
destroy() {
this.alive = false;
}
}
// ── function: integratePoints ──
/**
* Integrate all points using Verlet with damping and gravity.
*/
function integratePoints(points, dt, gravity, damping, windForce) {
const dt2 = dt * dt;
for (const p of points) {
if (p.pinned) continue;
// Guard: skip if position is NaN/Infinity
if (!isFinite(p.x) || !isFinite(p.y)) {
p.x = 0;
p.y = 0;
p.px = 0;
p.py = 0;
continue;
}
// Compute velocity from position difference
let vx = (p.x - p.px) * damping;
let vy = (p.y - p.py) * damping;
// Guard extreme velocities (from tab-switch or extreme positions)
const maxVel = 5000; // pixels per frame max
if (!isFinite(vx)) vx = 0;
if (!isFinite(vy)) vy = 0;
vx = Math.max(-maxVel, Math.min(maxVel, vx));
vy = Math.max(-maxVel, Math.min(maxVel, vy));
// Apply gravity
vy += gravity * dt2;
// Apply wind if active
if (windForce !== 0) {
vx += windForce * dt2;
}
// Verlet integration: x' = x + v + a*dt²
const newX = p.x + vx;
const newY = p.y + vy;
// NaN guard
if (!isFinite(newX) || !isFinite(newY)) {
p.px = p.x;
p.py = p.y;
continue;
}
p.px = p.x;
p.py = p.y;
p.x = newX;
p.y = newY;
}
}
// ── function: solveConstraints ──
/**
* Solve distance constraints via iterative relaxation.
*/
function solveConstraints(constraints, iterations) {
for (let iter = 0; iter < iterations; iter++) {
for (const c of constraints) {
if (!c.active) continue;
const dx = c.p2.x - c.p1.x;
const dy = c.p2.y - c.p1.y;
const dist = Math.sqrt(dx * dx + dy * dy);
// Guard zero-length constraint
if (dist < 0.0001) continue;
const diff = (c.restLength - dist) / dist;
const correction = diff * c.stiffness * 0.5;
const ox = dx * correction;
const oy = dy * correction;
if (!isFinite(ox) || !isFinite(oy)) continue;
// Apply correction based on pin status
if (c.p1.pinned && c.p2.pinned) continue;
if (c.p1.pinned) {
c.p2.x += ox * 2;
c.p2.y += oy * 2;
} else if (c.p2.pinned) {
c.p1.x -= ox * 2;
c.p1.y -= oy * 2;
} else {
c.p1.x -= ox;
c.p1.y -= oy;
c.p2.x += ox;
c.p2.y += oy;
}
}
}
}
// ── function: collideWithStatic ──
/**
* Handle collisions between points and static geometry.
*/
function collideWithStatic(points, canvasW, canvasH) {
const pad = CONFIG.collisionPadding;
for (const p of points) {
if (p.pinned) continue;
// Walls and floor/ceiling
let vx = p.x - p.px;
let vy = p.y - p.py;
// Left wall
if (p.x < pad + p.radius) {
p.x = pad + p.radius;
p.px = p.x + Math.abs(vx) * CONFIG.wallFriction;
}
// Right wall
if (p.x > canvasW - pad - p.radius) {
p.x = canvasW - pad - p.radius;
p.px = p.x - Math.abs(vx) * CONFIG.wallFriction;
}
// Floor
if (p.y > canvasH - pad - p.radius) {
p.y = canvasH - pad - p.radius;
p.py = p.y + Math.abs(vy) * CONFIG.wallFriction;
// Extra floor friction for horizontal velocity
p.px = p.x - vx * 0.95;
}
// Ceiling
if (p.y < pad + p.radius) {
p.y = pad + p.radius;
p.py = p.y - Math.abs(vy) * CONFIG.wallFriction;
}
// Static circles and boxes
for (const obj of STATIC_OBJS) {
if (obj.type === 'circle') {
const cx = obj.cx * canvasW;
const cy = obj.cy * canvasH;
const r = obj.r * canvasW;
const dx = p.x - cx;
const dy = p.y - cy;
const dist = Math.sqrt(dx * dx + dy * dy);
const minDist = r + p.radius + pad;
if (dist < minDist && dist > 0.001) {
// Push point out of circle
const nx = dx / dist;
const ny = dy / dist;
const overlap = minDist - dist;
p.x += nx * overlap;
p.y += ny * overlap;
// Reflect velocity with friction
const vDotN = vx * nx + vy * ny;
if (vDotN < 0) {
const fx = vx - (1 + CONFIG.wallFriction) * vDotN * nx;
const fy = vy - (1 + CONFIG.wallFriction) * vDotN * ny;
p.px = p.x - fx;
p.py = p.y - fy;
}
}
} else if (obj.type === 'box') {
const bx = obj.x * canvasW;
const by = obj.y * canvasH;
const bw = obj.w * canvasW;
const bh = obj.h * canvasH;
// AABB collision with point
const closestX = Math.max(bx, Math.min(p.x, bx + bw));
const closestY = Math.max(by, Math.min(p.y, by + bh));
const dx = p.x - closestX;
const dy = p.y - closestY;
const distSq = dx * dx + dy * dy;
const minDist = p.radius + pad;
if (distSq < minDist * minDist && distSq > 0.001) {
const dist = Math.sqrt(distSq);
let nx, ny;
if (dist > 0.001) {
nx = dx / dist;
ny = dy / dist;
} else {
// Point is inside box - push out in dominant axis
const overlapX = Math.min(p.x - bx, bx + bw - p.x);
const overlapY = Math.min(p.y - by, by + bh - p.y);
if (overlapX < overlapY) {
nx = p.x < bx + bw / 2 ? -1 : 1;
ny = 0;
} else {
nx = 0;
ny = p.y < by + bh / 2 ? -1 : 1;
}
}
const overlap = minDist - dist;
p.x += nx * (overlap > 0 ? overlap : 1);
p.y += ny * (overlap > 0 ? overlap : 1);
// Reflect velocity with friction
const vDotN = vx * nx + vy * ny;
if (vDotN < 0) {
const fx = vx - (1 + CONFIG.wallFriction) * vDotN * nx;
const fy = vy - (1 + CONFIG.wallFriction) * vDotN * ny;
p.px = p.x - fx;
p.py = p.y - fy;
}
}
}
}
}
}
// ── function: clampPoints ──
/**
* Clamp any NaN or extreme values to prevent simulation explosion.
*/
function clampPoints(points) {
const MAX_COORD = 10000; // Any coordinate beyond this is considered escaped
for (const p of points) {
// First: handle NaN/Infinity directly
if (!isFinite(p.x) || !isFinite(p.y)) {
p.x = 0;
p.y = 0;
p.px = 0;
p.py = 0;
continue;
}
// Clamp extreme positions to origin with zero velocity
if (Math.abs(p.x) > MAX_COORD || Math.abs(p.y) > MAX_COORD) {
p.x = 0;
p.y = 0;
p.px = 0;
p.py = 0;
}
// Clamp extreme previous positions (zero velocity)
if (!isFinite(p.px) || !isFinite(p.py)) {
p.px = p.x;
p.py = p.y;
} else if (Math.abs(p.px) > MAX_COORD * 2 || Math.abs(p.py) > MAX_COORD * 2) {
// Extreme velocity would cause explosion - zero it out
p.px = p.x;
p.py = p.y;
}
}
}
// ── function: physicsStep ──
/**
* Single physics step: integrate → collide → solve constraints.
*/
function physicsStep(allPoints, allConstraints, dt, gravity, damping, iterations, windForce, canvasW, canvasH) {
// Clamp extreme positions BEFORE integration (tab-switch protection)
clampPoints(allPoints);
// Integrate positions
integratePoints(allPoints, dt, gravity, damping, windForce);
// Collision with static geometry
collideWithStatic(allPoints, canvasW, canvasH);
// Solve constraints iteratively
solveConstraints(allConstraints, iterations);
// Second collision pass after constraint solving (prevents tunneling)
collideWithStatic(allPoints, canvasW, canvasH);
// Clamp any escaped points
clampPoints(allPoints);
}
// ── function: computeKineticEnergy ──
/**
* Compute total kinetic energy of all points.
*/
function computeKineticEnergy(points) {
let ke = 0;
for (const p of points) {
const vx = p.x - p.px;
const vy = p.y - p.py;
ke += 0.5 * p.mass * (vx * vx + vy * vy);
}
return ke;
}
// ── function: checkNaN ──
/**
* Check if any point has NaN coordinates.
*/
function checkNaN(points) {
for (const p of points) {
if (!isFinite(p.x) || !isFinite(p.y)) return true;
}
return false;
}
// ── function: spawnRope ──
// ============================================================
// OBJECT SPAWNERS — create physics objects from user input
// ============================================================
/**
* Spawn a rope between two points (click-drag).
*/
function spawnRope(x1, y1, x2, y2, numSegments) {
const dx = x2 - x1;
const dy = y2 - y1;
const len = Math.sqrt(dx * dx + dy * dy);
if (len < 5) return null; // too short
const obj = new PhysicsObject('rope', 'rope');
const segLen = len / numSegments;
for (let i = 0; i <= numSegments; i++) {
const t = i / numSegments;
const px = x1 + dx * t;
const py = y1 + dy * t;
obj.addPoint(px, py, i === 0); // pin first point
}
for (let i = 0; i < numSegments; i++) {
obj.addConstraint(i, i + 1, 1.0);
}
return obj;
}
// ── function: spawnCloth ──
/**
* Spawn a cloth rectangle with structural and shear constraints.
*/
function spawnCloth(x, y, width, height, cols, rows, pinMode) {
const obj = new PhysicsObject('cloth', 'cloth');
// Store grid dimensions for rendering
obj._cols = cols;
obj._rows = rows;
const cellW = width / (cols - 1);
const cellH = height / (rows - 1);
// Create grid of points
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const px = x + c * cellW;
const py = y + r * cellH;
// Pin mode: 'corners' pins top-left and top-right, 'full' pins entire top row
let pinned = false;
if (pinMode === 'full') {
pinned = r === 0;
} else {
pinned = r === 0 && (c === 0 || c === cols - 1);
}
obj.addPoint(px, py, pinned);
}
}
// Structural constraints (horizontal + vertical)
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols - 1; c++) {
const idx = r * cols + c;
obj.addConstraint(idx, idx + 1, 1.0); // horizontal
}
}
for (let r = 0; r < rows - 1; r++) {
for (let c = 0; c < cols; c++) {
const idx = r * cols + c;
obj.addConstraint(idx, idx + cols, 1.0); // vertical
}
}
// Shear constraints (diagonal) — every other cell for stability
for (let r = 0; r < rows - 1; r++) {
for (let c = 0; c < cols - 1; c++) {
const idx = r * cols + c;
if ((r + c) % 2 === 0) {
obj.addConstraint(idx, idx + cols + 1, 0.5); // diagonal /
} else {
obj.addConstraint(idx + 1, idx + cols, 0.5); // diagonal \
}
}
}
return obj;
}
// ── function: spawnSoftBody ──
/**
* Spawn a soft body (blob) — circle of points with edge and internal constraints.
*/
function spawnSoftBody(cx, cy, radius, numPoints) {
const obj = new PhysicsObject('softbody', 'softbody');
// Create ring of points
for (let i = 0; i < numPoints; i++) {
const angle = (2 * Math.PI * i) / numPoints - Math.PI / 2;
const px = cx + radius * Math.cos(angle);
const py = cy + radius * Math.sin(angle);
obj.addPoint(px, py, false);
}
// Add center point for volume preservation
const centerIdx = numPoints;
obj.addPoint(cx, cy, false);
// Edge constraints (ring)
for (let i = 0; i < numPoints; i++) {
obj.addConstraint(i, (i + 1) % numPoints, 1.0);
}
// Spoke constraints (center to each point)
for (let i = 0; i < numPoints; i++) {
obj.addConstraint(i, centerIdx, 0.5);
}
// Cross-brace constraints for volume preservation
for (let i = 0; i < numPoints; i++) {
const j = (i + Math.floor(numPoints / 2)) % numPoints;
obj.addConstraint(i, j, 0.3);
}
// Additional internal bracing for stability
if (numPoints >= 6) {
for (let i = 0; i < numPoints; i++) {
const j = (i + Math.floor(numPoints / 4)) % numPoints;
obj.addConstraint(i, j, 0.2);
}
}
return obj;
}
// ── function: spawnBall ──
/**
* Spawn a ball (single point with radius).
*/
function spawnBall(x, y, radius) {
const obj = new PhysicsObject('ball', 'ball');
const p = obj.addPoint(x, y, false);
p.radius = radius;
return obj;
}
// ── function: spawnSoftBodyBox ──
/**
* Spawn a soft body box — rectangular blob with edge and internal constraints.
*/
function spawnSoftBodyBox(cx, cy, width, height) {
const obj = new PhysicsObject('softbody', 'softbody');
// Create points at corners and midpoints of edges
const hw = width / 2;
const hh = height / 2;
// Corners: TL=0, TR=1, BR=2, BL=3
obj.addPoint(cx - hw, cy - hh, false); // TL
obj.addPoint(cx + hw, cy - hh, false); // TR
obj.addPoint(cx + hw, cy + hh, false); // BR
obj.addPoint(cx - hw, cy + hh, false); // BL
// Edge midpoints: top=4, right=5, bottom=6, left=7
obj.addPoint(cx, cy - hh, false); // top
obj.addPoint(cx + hw, cy, false); // right
obj.addPoint(cx, cy + hh, false); // bottom
obj.addPoint(cx - hw, cy, false); // left
// Center point: 8
obj.addPoint(cx, cy, false); // center
// Edge constraints (perimeter)
const edgePairs = [[0,4],[4,1],[1,5],[5,2],[2,6],[6,3],[3,7],[7,0]];
for (const [a, b] of edgePairs) {
obj.addConstraint(a, b, 1.0);
}
// Corner-to-corner diagonals
obj.addConstraint(0, 2, 0.4);
obj.addConstraint(1, 3, 0.4);
// Spoke constraints (center to all)
for (let i = 0; i < 8; i++) {
obj.addConstraint(i, 8, 0.5);
}
// Cross-brace constraints
obj.addConstraint(0, 6, 0.3);
obj.addConstraint(1, 7, 0.3);
obj.addConstraint(2, 4, 0.3);
obj.addConstraint(3, 5, 0.3);
return obj;
}
// ── function: getColor ──
// ============================================================
// RENDERING — draw everything to canvas
// ============================================================
/**
* Get color string from config color key and optional stretch ratio.
*/
function getColor(colorKey, stretchRatio = 1.0) {
const c = CONFIG.colors[colorKey];
if (!c) return '#ffffff';
if (stretchRatio !== undefined && stretchRatio > 1.0) {
// Stress visualization: green → yellow → red based on stretch
const t = Math.min(1, Math.max(0, (stretchRatio - CONFIG.stressGreen) /
(CONFIG.stressRed - CONFIG.stressGreen)));
const h = 120 * (1 - t); // green=120 to red=0
return `hsl(${h}, 90%, ${50 + 10 * (1-t)}%)`;
}
return `hsl(${c.h}, ${c.s}%, ${c.l}%)`;
}
// ── function: drawStaticGeometry ──
/**
* Draw static geometry (walls, circles, boxes).
*/
function drawStaticGeometry(ctx, canvasW, canvasH) {
// Walls outline
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
ctx.lineWidth = 2;
ctx.strokeRect(1, 1, canvasW - 2, canvasH - 2);
// Floor highlight
const floorGrad = ctx.createLinearGradient(0, canvasH - 30, 0, canvasH);
floorGrad.addColorStop(0, 'rgba(80, 140, 255, 0)');
floorGrad.addColorStop(1, 'rgba(80, 140, 255, 0.08)');
ctx.fillStyle = floorGrad;
ctx.fillRect(0, canvasH - 30, canvasW, 30);
// Static circles and boxes
for (const obj of STATIC_OBJS) {
if (obj.type === 'circle') {
const cx = obj.cx * canvasW;
const cy = obj.cy * canvasH;
const r = obj.r * canvasW;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(100, 100, 140, 0.3)';
ctx.fill();
ctx.strokeStyle = 'rgba(150, 150, 200, 0.5)';
ctx.lineWidth = 2;
ctx.stroke();
// Inner highlight
const grad = ctx.createRadialGradient(cx - r * 0.3, cy - r * 0.3, 0, cx, cy, r);
grad.addColorStop(0, 'rgba(180, 180, 220, 0.15)');
grad.addColorStop(1, 'rgba(180, 180, 220, 0)');
ctx.fillStyle = grad;
ctx.fill();
} else if (obj.type === 'box') {
const bx = obj.x * canvasW;
const by = obj.y * canvasH;
const bw = obj.w * canvasW;
const bh = obj.h * canvasH;
ctx.fillStyle = 'rgba(100, 100, 140, 0.3)';
ctx.fillRect(bx, by, bw, bh);
ctx.strokeStyle = 'rgba(150, 150, 200, 0.5)';
ctx.lineWidth = 2;
ctx.strokeRect(bx, by, bw, bh);
}
}
}
// ── function: drawRope ──
/**
* Draw a rope object.
*/
function drawRope(ctx, obj, showStress, showPoints) {
const color = getColor('rope');
// Draw constraints as lines
ctx.strokeStyle = color;
ctx.lineWidth = CONFIG.ropeLineWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
for (const c of obj.constraints) {
if (!c.active) continue;
if (showStress) {
const ratio = c.stretchRatio;
ctx.strokeStyle = getColor('rope', ratio);
} else {
ctx.strokeStyle = color;
}
ctx.beginPath();
ctx.moveTo(c.p1.x, c.p1.y);
ctx.lineTo(c.p2.x, c.p2.y);
ctx.stroke();
}
// Draw points if enabled
if (showPoints) {
for (const p of obj.points) {
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(p.x, p.y, CONFIG.pointRadius, 0, Math.PI * 2);
ctx.fill();
}
}
}
// ── function: drawCloth ──
/**
* Draw a cloth object as filled mesh.
*/
function drawCloth(ctx, obj, showStress, showPoints) {
const pts = obj.points;
// Use stored grid dimensions if available, otherwise estimate
let cols = obj._cols || Math.round(Math.sqrt(pts.length));
let rows = obj._rows || Math.ceil(pts.length / cols);
// Build a 2D grid index map
const grid = [];
for (let r = 0; r < rows; r++) {
grid[r] = [];
for (let c = 0; c < cols && r * cols + c < pts.length; c++) {
grid[r][c] = pts[r * cols + c];
}
}
// Draw filled cells
const baseColor = CONFIG.colors.cloth;
for (let r = 0; r < rows - 1; r++) {
for (let c = 0; c < cols - 1; c++) {
if (!grid[r] || !grid[r][c] || !grid[r+1] || !grid[r+1][c]) continue;
const p00 = grid[r][c];
const p10 = grid[r][c + 1];
const p01 = grid[r + 1][c];
const p11 = grid[r + 1][c + 1];
if (!p10 || !p01 || !p11) continue;
// Compute average stretch for this cell
let avgStretch = 1.0;
let count = 0;
for (const con of obj.constraints) {
if (!con.active) continue;
const ptsInCell = [p00, p10, p01, p11];
if (ptsInCell.includes(con.p1) && ptsInCell.includes(con.p2)) {
avgStretch += con.stretchRatio - 1.0;
count++;
}
}
if (count > 0) avgStretch /= count;
// Cell color based on stress or base
let fillColor;
if (showStress && avgStretch > 1.0) {
const t = Math.min(1, Math.max(0, (avgStretch - CONFIG.stressGreen) /
(CONFIG.stressRed - CONFIG.stressGreen)));
const h = 120 * (1 - t);
fillColor = `hsla(${h}, 80%, 45%, ${0.6 + 0.3 * (1-t)})`;
} else {
// Subtle gradient based on row for depth effect
const shadeMod = (r / rows) * 8;
fillColor = `hsla(${baseColor.h}, ${baseColor.s}%, ${baseColor.l - shadeMod}%, 0.45)`;
}
ctx.fillStyle = fillColor;
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();
}
}
// Draw grid lines on top
const lineColor = showStress ? null : getColor('cloth');
ctx.lineWidth = CONFIG.clothLineWidth;
for (const c of obj.constraints) {
if (!c.active) continue;
if (showStress && c.stretchRatio > 1.0) {
const t = Math.min(1, Math.max(0, (c.stretchRatio - CONFIG.stressGreen) /
(CONFIG.stressRed - CONFIG.stressGreen)));
const h = 120 * (1 - t);
ctx.strokeStyle = `hsla(${h}, 90%, 60%, 0.7)`;
} else {
// Structural constraints brighter, shear dimmer
const isStructural = c.stiffness >= 0.95;
if (isStructural) {
ctx.strokeStyle = `hsla(${baseColor.h}, ${baseColor.s}%, ${baseColor.l + 10}%, 0.6)`;
} else {
ctx.strokeStyle = `hsla(${baseColor.h}, ${baseColor.s}%, ${baseColor.l}%, 0.25)`;
}
}
ctx.beginPath();
ctx.moveTo(c.p1.x, c.p1.y);
ctx.lineTo(c.p2.x, c.p2.y);
ctx.stroke();
}
// Draw points if enabled
if (showPoints) {
for (const p of obj.points) {
ctx.fillStyle = getColor('cloth');
ctx.beginPath();
ctx.arc(p.x, p.y, CONFIG.pointRadius, 0, Math.PI * 2);
ctx.fill();
}
}
}
// ── function: drawSoftBody ──
/**
* Draw a soft body as filled polygon.
*/
function drawSoftBody(ctx, obj, showStress, showPoints) {
const pts = obj.points;
if (pts.length < 3) return;
// The last point is the center — exclude it from the hull
const edgePts = pts.slice(0, -1);
if (edgePts.length < 3) return;
// Compute average stretch for fill color
let avgStretch = 1.0;
let count = 0;
for (const c of obj.constraints) {
if (!c.active) continue;
avgStretch += c.stretchRatio - 1.0;
count++;
}
if (count > 0) avgStretch /= count;
const baseColor = CONFIG.colors.softbody;
// Fill polygon with glow effect
let fillColor;
if (showStress && avgStretch > 1.0) {
const t = Math.min(1, Math.max(0, (avgStretch - CONFIG.stressGreen) /
(CONFIG.stressRed - CONFIG.stressGreen)));
const h = 120 * (1 - t);
fillColor = `hsla(${h}, 80%, 50%, ${CONFIG.softBodyFillAlpha})`;
} else {
fillColor = `hsla(${baseColor.h}, ${baseColor.s}%, ${baseColor.l}%, ${CONFIG.softBodyFillAlpha})`;
}
// Draw filled polygon with slight glow
ctx.save();
ctx.shadowColor = `hsla(${baseColor.h}, ${baseColor.s}%, ${baseColor.l + 20}%, 0.3)`;
ctx.shadowBlur = 8;
ctx.fillStyle = fillColor;
ctx.beginPath();
ctx.moveTo(edgePts[0].x, edgePts[0].y);
for (let i = 1; i < edgePts.length; i++) {
// Use quadratic curves for smoother appearance
const prevPt = edgePts[i - 1];
const currPt = edgePts[i];
const nextPt = edgePts[(i + 1) % edgePts.length];
// Simple line for now, but with proper winding
ctx.lineTo(currPt.x, currPt.y);
}
ctx.closePath();
ctx.fill();
ctx.restore();
// Draw constraints as lines
ctx.lineWidth = 1.5;
for (const c of obj.constraints) {
if (!c.active) continue;
if (showStress && c.stretchRatio > 1.0) {
const t = Math.min(1, Math.max(0, (c.stretchRatio - CONFIG.stressGreen) /
(CONFIG.stressRed - CONFIG.stressGreen)));
const h = 120 * (1 - t);
ctx.strokeStyle = `hsla(${h}, 90%, 60%, 0.8)`;
} else {
// Edge constraints are brighter, internal ones dimmer
const isEdge = c.stiffness >= 0.9;
if (isEdge) {
ctx.strokeStyle = `hsla(${baseColor.h}, ${baseColor.s}%, ${baseColor.l + 15}%, 0.7)`;
} else {
ctx.strokeStyle = `hsla(${baseColor.h}, ${baseColor.s - 20}%, ${baseColor.l}%, 0.2)`;
}
}
ctx.beginPath();
ctx.moveTo(c.p1.x, c.p1.y);
ctx.lineTo(c.p2.x, c.p2.y);
ctx.stroke();
}
// Draw points if enabled
if (showPoints) {
for (const p of obj.points) {
const isCenter = p === pts[pts.length - 1];
ctx.fillStyle = isCenter ? '#fff' : getColor('softbody');
ctx.beginPath();
ctx.arc(p.x, p.y, isCenter ? CONFIG.pointRadius + 1 : CONFIG.pointRadius, 0, Math.PI * 2);
ctx.fill();
}
}
}
// ── function: drawBall ──
/**
* Draw a ball object.
*/
function drawBall(ctx, obj) {
const p = obj.points[0];
if (!p || !p.radius) return;
// Ball with gradient for 3D effect
const grad = ctx.createRadialGradient(
p.x - p.radius * 0.3, p.y - p.radius * 0.3, 0,
p.x, p.y, p.radius
);
const baseColor = CONFIG.colors.ball;
grad.addColorStop(0, `hsla(${baseColor.h}, ${baseColor.s}%, 75%, 1)`);
grad.addColorStop(0.7, `hsla(${baseColor.h}, ${baseColor.s}%, ${baseColor.l}%, 1)`);
grad.addColorStop(1, `hsla(${baseColor.h}, ${baseColor.s - 10}%, ${baseColor.l - 15}%, 1)`);
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
ctx.fill();
// Outline
ctx.strokeStyle = `hsla(${baseColor.h}, ${baseColor.s}%, ${baseColor.l - 10}%, 0.5)`;
ctx.lineWidth = 1;
ctx.stroke();
}
// ── function: drawGrabHighlight ──
/**
* Draw grab highlight around grabbed point.
*/
function drawGrabHighlight(ctx, point) {
if (!point) return;
const pulse = Math.sin(Date.now() / 150) * 3 + 8;
// Outer glow ring
ctx.strokeStyle = 'rgba(255, 255, 100, 0.4)';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(point.x, point.y, pulse + 6, 0, Math.PI * 2);
ctx.stroke();
// Inner ring
ctx.strokeStyle = 'rgba(255, 255, 100, 0.8)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(point.x, point.y, pulse, 0, Math.PI * 2);
ctx.stroke();
// Center dot with glow
ctx.save();
ctx.shadowColor = 'rgba(255, 255, 100, 0.6)';
ctx.shadowBlur = 6;
ctx.fillStyle = 'rgba(255, 255, 180, 0.9)';
ctx.beginPath();
ctx.arc(point.x, point.y, 4, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
// ── function: drawPins ──
/**
* Draw pin indicators.
*/
function drawPins(ctx, points) {
for (const p of points) {
if (!p.pinned) continue;
// Small triangle indicator above pinned point with glow
const size = 5;
ctx.save();
ctx.shadowColor = 'rgba(255, 200, 50, 0.4)';
ctx.shadowBlur = 4;
ctx.fillStyle = 'rgba(255, 200, 50, 0.9)';
ctx.beginPath();
ctx.moveTo(p.x, p.y - size * 1.5);
ctx.lineTo(p.x - size, p.y - size * 3);
ctx.lineTo(p.x + size, p.y - size * 3);
ctx.closePath();
ctx.fill();
ctx.restore();
// Small circle at pin point
ctx.fillStyle = 'rgba(255, 200, 50, 0.6)';
ctx.beginPath();
ctx.arc(p.x, p.y, 3, 0, Math.PI * 2);
ctx.fill();
}
}
// ── function: drawTearIndicator ──
/**
* Draw tear/cut indicator.
*/
function drawTearIndicator(ctx, x, y) {
ctx.strokeStyle = 'rgba(255, 80, 80, 0.7)';
ctx.lineWidth = 2;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.arc(x, y, CONFIG.tearRadius, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
// Crosshair
const s = 8;
ctx.strokeStyle = 'rgba(255, 80, 80, 0.9)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(x - s, y); ctx.lineTo(x + s, y);
ctx.moveTo(x, y - s); ctx.lineTo(x, y + s);
ctx.stroke();
}
// ── function: drawSpawnPreview ──
/**
* Draw spawn preview (for rope/cloth placement).
*/
function drawSpawnPreview(ctx, x1, y1, x2, y2, tool) {
if (tool === 'rope') {
ctx.strokeStyle = `rgba(${CONFIG.colors.rope.h}, ${CONFIG.colors.rope.s}%, ${CONFIG.colors.rope.l}%, 0.5)`;
ctx.lineWidth = CONFIG.ropeLineWidth;
ctx.setLineDash([6, 4]);
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.setLineDash([]);
// Pin indicator at start
ctx.fillStyle = 'rgba(255, 200, 50, 0.7)';
ctx.beginPath();
ctx.arc(x1, y1, 4, 0, Math.PI * 2);
ctx.fill();
} else if (tool === 'cloth') {
const w = x2 - x1;
const h = y2 - y1;
ctx.strokeStyle = `hsla(${CONFIG.colors.cloth.h}, ${CONFIG.colors.cloth.s}%, ${CONFIG.colors.cloth.l}%, 0.4)`;
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.strokeRect(x1, y1, w, h);
ctx.setLineDash([]);
// Pin indicators at corners (or full top edge)
ctx.fillStyle = 'rgba(255, 200, 50, 0.7)';
ctx.beginPath();
ctx.arc(x1, y1, 4, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(x1 + w, y1, 4, 0, Math.PI * 2);
ctx.fill();
} else if (tool === 'softbody') {
const r = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
ctx.strokeStyle = `hsla(${CONFIG.colors.softbody.h}, ${CONFIG.colors.softbody.s}%, ${CONFIG.colors.softbody.l}%, 0.4)`;
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.arc(x1, y1, r, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
} else if (tool === 'ball') {
const r = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
ctx.strokeStyle = `hsla(${CONFIG.colors.ball.h}, ${CONFIG.colors.ball.s}%, ${CONFIG.colors.ball.l}%, 0.4)`;
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.arc(x1, y1, r, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
}
}
// ── function: render ──
/**
* Main render function — draws everything.
*/
function render(ctx, objects, canvasW, canvasH, showStress, showPoints,
grabbedPoint, tearPos, spawnPreview) {
// Clear with slight trail for motion blur effect
ctx.fillStyle = 'rgba(26, 26, 46, 0.85)';
ctx.fillRect(0, 0, canvasW, canvasH);
// Draw static geometry
drawStaticGeometry(ctx, canvasW, canvasH);
// Draw all objects (sorted by type for layering: balls on top)
const order = ['cloth', 'softbody', 'rope', 'ball'];
const sortedObjects = [...objects].sort((a, b) =>
order.indexOf(a.type) - order.indexOf(b.type));
for (const obj of sortedObjects) {
if (!obj.alive || obj.points.length === 0) continue;
switch (obj.type) {
case 'rope': drawRope(ctx, obj, showStress, showPoints); break;
case 'cloth': drawCloth(ctx, obj, showStress, showPoints); break;
case 'softbody': drawSoftBody(ctx, obj, showStress, showPoints); break;
case 'ball': drawBall(ctx, obj); break;
}
}
// Draw pins for all objects
for (const obj of objects) {
if (!obj.alive) continue;
drawPins(ctx, obj.points);
}
// Draw grab highlight
drawGrabHighlight(ctx, grabbedPoint);
// Draw tear indicator
if (tearPos) {
drawTearIndicator(ctx, tearPos.x, tearPos.y);
}
// Draw spawn preview
if (spawnPreview) {
drawSpawnPreview(ctx, spawnPreview.x1, spawnPreview.y1,
spawnPreview.x2, spawnPreview.y2, spawnPreview.tool);
}
}
// ── function: findNearestPoint ──
// ============================================================
// INTERACTION — mouse/touch handling for grab, tear, spawn, pin
// ============================================================
/**
* Find nearest point to a position within the grab radius.
*/
function findNearestPoint(allPoints, x, y, radius) {
let best = null;
let bestDist = radius;
for (const p of allPoints) {
const dx = p.x - x;
const dy = p.y - y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < bestDist) {
bestDist = dist;
best = p;
}
}
return best;
}
// ── function: findObjectForPoint ──
/**
* Find which object a point belongs to.
*/
function findObjectForPoint(point, objects) {
for (const obj of objects) {
if (!obj.alive) continue;
if (obj.points.includes(point)) return obj;
}
return null;
}
// ── function: cutConstraintsNear ──
/**
* Cut constraints near a position (tear/cut operation).
*/
function cutConstraintsNear(allConstraints, x, y, radius) {
let cutCount = 0;
for (const c of allConstraints) {
if (!c.active) continue;
// Check if the constraint line segment is close to the point
const dx = c.p2.x - c.p1.x;
const dy = c.p2.y - c.p1.y;
const lenSq = dx * dx + dy * dy;
if (lenSq < 0.0001) continue;
// Project point onto line segment
let t = ((x - c.p1.x) * dx + (y - c.p1.y) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
const closestX = c.p1.x + t * dx;
const closestY = c.p1.y + t * dy;
const dist = Math.sqrt((x - closestX) ** 2 + (y - closestY) ** 2);
if (dist < radius) {
c.active = false;
cutCount++;
}
}
return cutCount;
}
// ── function: togglePin ──
/**
* Toggle pin on a point.
*/
function togglePin(point) {
point.pinned = !point.pinned;
if (point.pinned) {
// Zero velocity when pinning
point.px = point.x;
point.py = point.y;
}
return point.pinned;
}
// ── function: getAllPoints ──
/**
* Collect all points and constraints from alive objects.
*/
function getAllPoints(objects) {
const pts = [];
for (const obj of objects) {
if (!obj.alive) continue;
pts.push(...obj.points);
}
return pts;
}
// ── function: getAllConstraints ──
function getAllConstraints(objects) {
const cons = [];
for (const obj of objects) {
if (!obj.alive) continue;
cons.push(...obj.constraints);
}
return cons;
}
// ── function: cleanupDeadObjects ──
/**
* Remove dead objects from the array.
*/
function cleanupDeadObjects(objects) {
// An object is "dead" if it has no active constraints and fewer than 2 points
for (let i = objects.length - 1; i >= 0; i--) {
const obj = objects[i];
if (!obj.alive) {
objects.splice(i, 1);
} else if (obj.type !== 'ball' && obj.points.length > 0) {
// Check if all constraints are cut — object has fallen apart
const activeCount = obj.constraints.filter(c => c.active).length;
if (activeCount === 0 && obj.points.length > 2) {
// Object is completely torn apart - keep points but mark as loose
// Actually, let's keep them alive so they can still interact with walls
}
}
}
}
// ── function: handleMouseDown ──
/**
* Handle mouse down — start grab or spawn.
*/
function handleMouseDown(sim, x, y) {
const allPoints = getAllPoints(sim.objects);
if (sim.currentTool === 'grab') {
// Try to grab nearest point
const pt = findNearestPoint(allPoints, x, y, CONFIG.grabRadius);
if (pt) {
sim.grabbedPoint = pt;
sim.grabStartX = x;
sim.grabStartY = y;
sim.lastMouseX = x;
sim.lastMouseY = y;
sim.dragStartTime = performance.now();
}
} else {
// Start spawning — record first click position
sim.spawnStartX = x;
sim.spawnStartY = y;
sim.isSpawning = true;
}
}
// ── function: handleMouseMove ──
/**
* Handle mouse move — drag grabbed point or update spawn preview.
*/
function handleMouseMove(sim, x, y) {
if (sim.grabbedPoint) {
// Move grabbed point to cursor position
const pt = sim.grabbedPoint;
// Track velocity for throw on release
const dx = x - sim.lastMouseX;
const dy = y - sim.lastMouseY;
pt.x = x;
pt.y = y;
pt.px = x - dx * CONFIG.throwDamping;
pt.py = y - dy * CONFIG.throwDamping;
sim.lastMouseX = x;
sim.lastMouseY = y;
} else if (sim.isSpawning) {
// Update spawn preview
sim.spawnPreview = {
tool: sim.currentTool,
x1: sim.spawnStartX,
y1: sim.spawnStartY,
x2: x,
y2: y,
};
} else if (sim.isTearing) {
// Cut constraints near cursor
const allConstraints = getAllConstraints(sim.objects);
cutConstraintsNear(allConstraints, x, y, CONFIG.tearRadius);
sim.tearPos = { x, y };
}
}
// ── function: handleMouseUp ──
/**
* Handle mouse up — release grab or complete spawn.
*/
function handleMouseUp(sim, x, y) {
if (sim.grabbedPoint) {
// Release with throw velocity already set in mouseMove
sim.grabbedPoint = null;
} else if (sim.isSpawning) {
// Complete spawn based on tool type
const sx = sim.spawnStartX;
const sy = sim.spawnStartY;
switch (sim.currentTool) {
case 'rope': {
const dx = x - sx;
const dy = y - sy;
const len = Math.sqrt(dx * dx + dy * dy);
const segments = Math.max(5, Math.min(60, Math.floor(len / 8)));
const rope = spawnRope(sx, sy, x, y, segments);
if (rope) sim.objects.push(rope);
break;
}
case 'cloth': {
const w = Math.abs(x - sx);
const h = Math.abs(y - sy);
if (w > 20 && h > 20) {
const minX = Math.min(sx, x);
const minY = Math.min(sy, y);
const cols = Math.max(4, Math.min(30, Math.floor(w / 12)));
const rows = Math.max(4, Math.min(30, Math.floor(h / 12)));
const cloth = spawnCloth(minX, minY, w, h, cols, rows, 'corners');
if (cloth) sim.objects.push(cloth);
}
break;
}
case 'softbody': {
const r = Math.sqrt((x - sx) ** 2 + (y - sy) ** 2);
if (r > 15) {
const numPts = Math.max(8, Math.min(24, Math.floor(r / 6)));
const blob = spawnSoftBody(sx, sy, r, numPts);
if (blob) sim.objects.push(blob);
} else {
// Click without drag — spawn default size soft body box
const box = spawnSoftBodyBox(sx, sy, 80, 60);
if (box) sim.objects.push(box);
}
break;
}
case 'ball': {
const r = Math.sqrt((x - sx) ** 2 + (y - sy) ** 2);
const radius = Math.max(10, Math.min(60, r > 5 ? r : 25));
const ball = spawnBall(sx, sy, radius);
if (ball) sim.objects.push(ball);
break;
}
}
sim.isSpawning = false;
sim.spawnPreview = null;
}
sim.tearPos = null;
}
// ── function: handleRightMouseDown ──
/**
* Handle right-click drag for tearing.
*/
function handleRightMouseDown(sim, x, y) {
sim.isTearing = true;
const allConstraints = getAllConstraints(sim.objects);
cutConstraintsNear(allConstraints, x, y, CONFIG.tearRadius);
sim.tearPos = { x, y };
}
// ── function: handleRightMouseMove ──
/**
* Handle right-click move for tearing.
*/
function handleRightMouseMove(sim, x, y) {
if (sim.isTearing) {
const allConstraints = getAllConstraints(sim.objects);
cutConstraintsNear(allConstraints, x, y, CONFIG.tearRadius);
sim.tearPos = { x, y };
}
}
// ── function: handleRightMouseUp ──
/**
* Handle right-click up — stop tearing.
*/
function handleRightMouseUp(sim) {
sim.isTearing = false;
sim.tearPos = null;
}
// ── function: handleModifierClick ──
/**
* Toggle pin on nearest point (Ctrl+click or Shift+click).
*/
function handleModifierClick(sim, x, y) {
const allPoints = getAllPoints(sim.objects);
const pt = findNearestPoint(allPoints, x, y, CONFIG.grabRadius + 5);
if (pt) {
togglePin(pt);
return true;
}
return false;
}
// ── function: getCanvasCoords ──
/**
* Get canvas coordinates from mouse event.
*/
function getCanvasCoords(canvas, e) {
const rect = canvas.getBoundingClientRect();
return {
x: (e.clientX - rect.left) * (canvas.width / rect.width),
y: (e.clientY - rect.top) * (canvas.height / rect.height),
};
}
// ── function: getTouchCoords ──
/**
* Get canvas coordinates from touch event.
*/
function getTouchCoords(canvas, e) {
const rect = canvas.getBoundingClientRect();
const t = e.touches[0] || e.changedTouches[0];
return {
x: (t.clientX - rect.left) * (canvas.width / rect.width),
y: (t.clientY - rect.top) * (canvas.height / rect.height),
};
}
// ── function: createSimulation ──
// ============================================================
// SIMULATION MANAGER — state management and main loop
// ============================================================
/**
* Create a new simulation instance.
*/
function createSimulation(canvasW, canvasH) {
return {
objects: [],
// Physics state
paused: false,
accumulator: 0,
lastTime: 0,
// Interaction state
currentTool: 'grab',
grabbedPoint: null,
isSpawning: false,
spawnStartX: 0,
spawnStartY: 0,
spawnPreview: null,
isTearing: false,
tearPos: null,
// Mouse tracking for throw velocity
lastMouseX: 0,
lastMouseY: 0,
dragStartTime: 0,
// Display options
showStress: false,
showPoints: false,
windEnabled: false,
// Performance tracking
fps: 0,
frameCount: 0,
lastFpsTime: 0,
physicsTimeMs: 0,
// Canvas dimensions (updated on resize)
canvasW: canvasW,
canvasH: canvasH,
};
}
// ── function: stepSimulation ──
/**
* Step the simulation by one fixed timestep.
*/
function stepSimulation(sim) {
const dt = CONFIG.dt;
const gravity = sim.windEnabled ? CONFIG.gravity : CONFIG.gravity;
const damping = parseFloat(document.getElementById('sliderDamping')?.value || CONFIG.damping);
const iterations = parseInt(document.getElementById('sliderIter')?.value || CONFIG.constraintIterations);
const windForce = sim.windEnabled ? CONFIG.windStrength * Math.sin(performance.now() / 1000) : 0;
const allPoints = getAllPoints(sim.objects);
const allConstraints = getAllConstraints(sim.objects);
if (allPoints.length === 0) return;
const t0 = performance.now();
physicsStep(allPoints, allConstraints, dt, gravity, damping, iterations, windForce,
sim.canvasW, sim.canvasH);
sim.physicsTimeMs = performance.now() - t0;
}
// ── function: updateSimulation ──
/**
* Update simulation with fixed timestep accumulator.
*/
function updateSimulation(sim, currentTime) {
if (sim.lastTime === 0) sim.lastTime = currentTime;
let frameDelta = (currentTime - sim.lastTime) / 1000;
sim.lastTime = currentTime;
// Clamp delta to prevent explosion on tab-switch
frameDelta = Math.min(frameDelta, CONFIG.maxDtAccumulator);
sim.accumulator += frameDelta;
if (!sim.paused) {
// Step physics with fixed timestep
let steps = 0;
const maxSteps = 10; // Prevent infinite loop
while (sim.accumulator >= CONFIG.dt && steps < maxSteps) {
stepSimulation(sim);
sim.accumulator -= CONFIG.dt;
steps++;
}
// If accumulator is still too large, reset it (prevents spiral of death)
if (sim.accumulator > CONFIG.maxDtAccumulator) {
sim.accumulator = 0;
}
}
// FPS tracking
sim.frameCount++;
if (currentTime - sim.lastFpsTime >= 1000) {
sim.fps = Math.round(sim.frameCount * 1000 / (currentTime - sim.lastFpsTime));
sim.frameCount = 0;
sim.lastFpsTime = currentTime;
}
}
// ── function: resetSimulation ──
/**
* Reset the simulation — clear all objects.
*/
function resetSimulation(sim) {
sim.objects = [];
sim.grabbedPoint = null;
sim.isSpawning = false;
sim.spawnPreview = null;
sim.isTearing = false;
sim.tearPos = null;
sim.accumulator = 0;
}
// ── function: spawnDemoObjects ──
/**
* Spawn initial demo objects for the playground.
*/
function spawnDemoObjects(sim) {
const w = sim.canvasW;
const h = sim.canvasH;
// A rope hanging from top-left area (pendulum-like)
const rope = spawnRope(w * 0.25, 15, w * 0.3, h * 0.45, 25);
if (rope) sim.objects.push(rope);
// A cloth pinned at corners — draped nicely
const clothW = Math.min(280, w * 0.22);
const clothH = Math.min(200, h * 0.3);
const clothX = w * 0.5 - clothW / 2;
const cloth = spawnCloth(clothX, 25, clothW, clothH, 18, 14, 'corners');
if (cloth) sim.objects.push(cloth);
// A soft body blob — placed to the right
const blob = spawnSoftBody(w * 0.78, h * 0.25, 35, 12);
if (blob) sim.objects.push(blob);
// A couple of balls at different sizes
const ball1 = spawnBall(w * 0.15, h * 0.2, 18);
if (ball1) sim.objects.push(ball1);
const ball2 = spawnBall(w * 0.9, h * 0.35, 12);
if (ball2) sim.objects.push(ball2);
}
// ── function: exposeSimAPI ──
/**
* Expose simulation API on window for headless testing.
*/
function exposeSimAPI(sim) {
window.sim = {
get points() { return getAllPoints(sim.objects); },
get constraints() { return getAllConstraints(sim.objects); },
get objects() { return sim.objects; },
step: () => {
const allPts = getAllPoints(sim.objects);
const allCons = getAllConstraints(sim.objects);
if (allPts.length === 0) return;
const dt = CONFIG.dt;
const gravity = sim.windEnabled ? CONFIG.gravity : CONFIG.gravity;
const damping = parseFloat(document.getElementById('sliderDamping')?.value || CONFIG.damping);
const iterations = parseInt(document.getElementById('sliderIter')?.value || CONFIG.constraintIterations);
const windForce = sim.windEnabled ? CONFIG.windStrength * Math.sin(performance.now() / 1000) : 0;
// Use window dimensions as fallback if sim.canvasW is not set
const cw = (typeof sim.canvasW === 'number' && isFinite(sim.canvasW)) ? sim.canvasW : window.innerWidth;
const ch = (typeof sim.canvasH === 'number' && isFinite(sim.canvasH)) ? sim.canvasH : window.innerHeight;
physicsStep(allPts, allCons, dt, gravity, damping, iterations, windForce, cw, ch);
},
reset: () => resetSimulation(sim),
spawnRope: (x1, y1, x2, y2, n) => { const r = spawnRope(x1, y1, x2, y2, n); if (r) sim.objects.push(r); return r; },
spawnCloth: (x, y, w, h, cols, rows, mode) => { const c = spawnCloth(x, y, w, h, cols, rows, mode || 'corners'); if (c) sim.objects.push(c); return c; },
spawnSoftBody: (cx, cy, r, n) => { const s = spawnSoftBody(cx, cy, r, n); if (s) sim.objects.push(s); return s; },
spawnBall: (x, y, r) => { const b = spawnBall(x, y, r); if (b) sim.objects.push(b); return b; },
cutConstraint: (idx) => {
const allC = getAllConstraints(sim.objects);
if (allC[idx]) { allC[idx].active = false; return true; }
return false;
},
getKineticEnergy: () => computeKineticEnergy(getAllPoints(sim.objects)),
hasNaN: () => checkNaN(getAllPoints(sim.objects)),
paused: {
get: () => sim.paused,
set: (v) => { sim.paused = v; }
},
};
}
// ── main ──
// ============================================================
// MAIN — initialization, event wiring, render loop
// ============================================================
(function() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// Resize canvas to fill window
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
resizeCanvas();
// Create simulation (after initial resize)
const sim = createSimulation(canvas.width, canvas.height);
spawnDemoObjects(sim);
exposeSimAPI(sim);
// Now set up resize listener that updates sim dimensions
window.addEventListener('resize', () => {
resizeCanvas();
sim.canvasW = canvas.width;
sim.canvasH = canvas.height;
});
// ---- UI Controls ----
// Tool buttons
document.querySelectorAll('#toolbar [data-tool]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelector('#toolbar .active')?.classList.remove('active');
btn.classList.add('active');
sim.currentTool = btn.dataset.tool;
// Update cursor
canvas.style.cursor = sim.currentTool === 'grab' ? 'default' : 'crosshair';
});
});
// Checkboxes
document.getElementById('chkStress').addEventListener('change', (e) => {
sim.showStress = e.target.checked;
});
document.getElementById('chkPoints').addEventListener('change', (e) => {
sim.showPoints = e.target.checked;
});
document.getElementById('chkWind').addEventListener('change', (e) => {
sim.windEnabled = e.target.checked;
});
// Sliders
const sliderG = document.getElementById('sliderGravity');
const valG = document.getElementById('valG');
sliderG.addEventListener('input', () => {
CONFIG.gravity = parseFloat(sliderG.value);
valG.textContent = sliderG.value;
});
const sliderD = document.getElementById('sliderDamping');
const valD = document.getElementById('valD');
sliderD.addEventListener('input', () => {
valD.textContent = sliderD.value;
});
const sliderI = document.getElementById('sliderIter');
const valI = document.getElementById('valI');
sliderI.addEventListener('input', () => {
CONFIG.constraintIterations = parseInt(sliderI.value);
valI.textContent = sliderI.value;
});
// Pause/Step/Reset buttons
const btnPause = document.getElementById('btnPause');
btnPause.addEventListener('click', () => {
sim.paused = !sim.paused;
btnPause.textContent = sim.paused ? '▶ Play' : '⏸ Pause';
});
document.getElementById('btnStep').addEventListener('click', () => {
if (sim.paused) {
stepSimulation(sim);
}
});
document.getElementById('btnReset').addEventListener('click', () => {
resetSimulation(sim);
spawnDemoObjects(sim);
});
// ---- Mouse Events ----
canvas.addEventListener('mousedown', (e) => {
e.preventDefault();
const coords = getCanvasCoords(canvas, e);
if (e.button === 2) {
// Right click — tear mode
handleRightMouseDown(sim, coords.x, coords.y);
} else if (e.ctrlKey || e.shiftKey) {
// Modifier + left click — toggle pin
handleModifierClick(sim, coords.x, coords.y);
} else {
// Left click — grab or spawn
handleMouseDown(sim, coords.x, coords.y);
}
});
canvas.addEventListener('mousemove', (e) => {
const coords = getCanvasCoords(canvas, e);
if (sim.isTearing) {
handleRightMouseMove(sim, coords.x, coords.y);
} else {
handleMouseMove(sim, coords.x, coords.y);
}
});
canvas.addEventListener('mouseup', (e) => {
const coords = getCanvasCoords(canvas, e);
if (e.button === 2) {
handleRightMouseUp(sim);
} else {
handleMouseUp(sim, coords.x, coords.y);
}
});
// Prevent context menu on right click
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
// ---- Touch Events ----
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
const coords = getTouchCoords(canvas, e);
if (e.touches.length === 1) {
handleMouseDown(sim, coords.x, coords.y);
}
}, { passive: false });
canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
const coords = getTouchCoords(canvas, e);
handleMouseMove(sim, coords.x, coords.y);
}, { passive: false });
canvas.addEventListener('touchend', (e) => {
e.preventDefault();
const coords = getTouchCoords(canvas, e);
handleMouseUp(sim, coords.x, coords.y);
}, { passive: false });
// ---- Keyboard Events ----
document.addEventListener('keydown', (e) => {
switch(e.key) {
case ' ':
e.preventDefault();
sim.paused = !sim.paused;
btnPause.textContent = sim.paused ? '▶ Play' : '⏸ Pause';
break;
case 'ArrowRight':
if (sim.paused) stepSimulation(sim);
break;
case 'g': case 'G':
document.querySelector('[data-tool="grab"]')?.click();
break;
case 'r': case 'R':
document.querySelector('[data-tool="rope"]')?.click();
break;
case 'c': case 'C':
document.querySelector('[data-tool="cloth"]')?.click();
break;
case 's': case 'S':
document.querySelector('[data-tool="softbody"]')?.click();
break;
case 'b': case 'B':
document.querySelector('[data-tool="ball"]')?.click();
break;
}
});
// ---- Render Loop ----
const statsEl = document.getElementById('stats');
function renderLoop(timestamp) {
updateSimulation(sim, timestamp);
// Render
render(ctx, sim.objects, canvas.width, canvas.height,
sim.showStress, sim.showPoints,
sim.grabbedPoint, sim.tearPos, sim.spawnPreview);
// Update stats display
if (sim.frameCount > 0) {
statsEl.textContent = `FPS: ${sim.fps} | Phys: ${sim.physicsTimeMs.toFixed(1)}ms`;
}
requestAnimationFrame(renderLoop);
}
requestAnimationFrame(renderLoop);
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"composer","sessionId":"1f5567c4-ad0a-4274-99b0-277b3c7f44d6","tokensIn":3183370,"tokensOut":59389,"tokensTotal":3242759,"turns":69,"toolCalls":68,"failedToolCalls":1,"timestamp":"2026-07-30T04:06:03.057Z"} -->