Physics Playground
Qwen 3.5 122b a10b · typescript

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 56,633 bytes · SHA-256 4b52af16f10e
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Soft-Body Physics Playground</title>
<style>
/* ── css: layout ── */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #1a1a2e;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
color: #eee;
}
#canvas-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
#sim-canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
#toolbar {
position: fixed;
top: 10px;
left: 50%;
transform: translateX(-50%);
z-index: 100;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: rgba(0, 0, 0, 0.7);
border-radius: 12px;
backdrop-filter: blur(4px);
flex-wrap: wrap;
justify-content: center;
max-width: 95vw;
}
#toolbar button {
padding: 8px 14px;
border: none;
border-radius: 6px;
background: #3d5afe;
color: white;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
#toolbar button:hover {
background: #536dfe;
transform: translateY(-1px);
}
#toolbar button:active {
transform: translateY(0);
}
#toolbar button.active {
background: #00c853;
}
#toolbar .separator {
width: 1px;
height: 24px;
background: rgba(255, 255, 255, 0.3);
margin: 0 4px;
}
#toolbar label {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #aaa;
}
#toolbar input[type="range"] {
width: 60px;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
outline: none;
}
#toolbar input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
background: #3d5afe;
border-radius: 50%;
cursor: pointer;
}
#toolbar input[type="checkbox"] {
width: 14px;
height: 14px;
cursor: pointer;
}
#info-panel {
position: fixed;
top: 10px;
right: 10px;
z-index: 100;
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 14px;
background: rgba(0, 0, 0, 0.7);
border-radius: 8px;
font-size: 12px;
font-family: 'Consolas', monospace;
backdrop-filter: blur(4px);
}
#help-text {
position: fixed;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
z-index: 100;
padding: 6px 16px;
background: rgba(0, 0, 0, 0.6);
border-radius: 6px;
font-size: 11px;
color: #888;
backdrop-filter: blur(4px);
}
</style>
</head>
<body>
<div id="canvas-container">
<canvas id="sim-canvas"></canvas>
</div>
<div id="toolbar">
<button id="btn-rope" title="Draw Rope">Rope</button>
<button id="btn-cloth" title="Draw Cloth">Cloth</button>
<button id="btn-softbody" title="Spawn Soft Body">Soft Body</button>
<button id="btn-ball" title="Spawn Ball">Ball</button>
<div class="separator"></div>
<button id="btn-pause" title="Pause (Space)">⏸</button>
<button id="btn-step" title="Step (→)">⏯</button>
<button id="btn-reset" title="Reset">↻</button>
<div class="separator"></div>
<label>Gravity: <input type="range" id="gravity-slider" min="-20" max="50" value="20"></label>
<span id="gravity-value">20</span>
<label>Iterations: <input type="range" id="iterations-slider" min="1" max="20" value="8"></label>
<span id="iter-value">8</span>
<label><input type="checkbox" id="stress-viz"> Stress</label>
<label><input type="checkbox" id="trail-viz"> Trails</label>
</div>
<div id="info-panel">
<div id="fps-counter">FPS: --</div>
<div id="step-time">Step: -- ms</div>
<div id="point-count">Points: 0</div>
</div>
<div id="help-text">
Left-drag: Grab | Right-drag: Tear | Ctrl+Click: Pin/Unpin
</div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./config":"src/config.ts","./types":"src/types.ts","./physics":"src/physics.ts","./objects":"src/objects.ts","./render":"src/render.ts"},"src/types.ts":{"./config":"src/config.ts"},"src/physics.ts":{"./config":"src/config.ts","./types":"src/types.ts"},"src/objects.ts":{"./config":"src/config.ts","./types":"src/types.ts"},"src/render.ts":{"./config":"src/config.ts","./types":"src/types.ts"}};
function __require(id) {
if (__cache[id]) return __cache[id].exports;
var module = __cache[id] = { exports: {} };
var factory = __mods[id];
if (!factory) throw new Error("Module not found: " + id);
factory(module.exports, function (spec) {
var target = (__map[id] && __map[id][spec]) || spec;
return __require(target);
}, module);
return module.exports;
}
// ── module: src/main.ts ──
__mods["src/main.ts"] = function (exports, require, module) {
"use strict";
/**
* Main Entry Point - Physics Playground
*/
Object.defineProperty(exports, "__esModule", { value: true });
const config_1 = require("./config");
const types_1 = require("./types");
const physics_1 = require("./physics");
const objects_1 = require("./objects");
const render_1 = require("./render");
// Global simulation state (exposed on window for testing)
let sim = null;
/**
* Initialize the simulation.
*/
function initSimulation() {
const canvas = document.getElementById('sim-canvas');
const ctx = canvas.getContext('2d');
// Set canvas size to window size
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
if (sim) {
sim.width = canvas.width;
sim.height = canvas.height;
// Re-create boundary walls on resize
sim.staticShapes = (0, objects_1.createBoundaryWalls)(canvas.width, canvas.height);
}
}
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
// Create initial objects
const points = [];
const constraints = [];
const staticShapes = (0, objects_1.createBoundaryWalls)(canvas.width, canvas.height);
const objects = [];
// Add a rope
const rope = (0, objects_1.createRope)(150, 100, 20, true);
objects.push(rope);
// Add cloth
const cloth = (0, objects_1.createCloth)(400, 80, 14, 10, 'corners');
objects.push(cloth);
// Add soft body
const softbody = (0, objects_1.createSoftBody)(700, 200, 50, 14);
objects.push(softbody);
// Add some balls
const ball1 = (0, objects_1.createBall)(300, 100);
const ball2 = (0, objects_1.createBall)(350, 100);
objects.push(ball1, ball2);
// Collect all points and constraints from objects
for (const obj of objects) {
if (obj.type === 'rope' || obj.type === 'cloth' || obj.type === 'softbody') {
for (const p of obj.points)
points.push(p);
for (const c of obj.constraints)
constraints.push(c);
}
else if (obj.type === 'ball') {
points.push(obj.point);
}
}
// Add a static circle obstacle
staticShapes.push((0, objects_1.createStaticCircle)(canvas.width / 2, canvas.height - 150, 60));
const state = {
points,
constraints,
staticShapes,
objects,
grabbedPoint: null,
grabOffset: { x: 0, y: 0 },
isTearing: false,
tearStart: null,
paused: false,
gravity: config_1.CONFIG.GRAVITY_DEFAULT,
iterations: 8,
showStress: false,
showTrails: false,
accumulator: 0,
lastFrameTime: performance.now(),
fps: 60,
frameCount: 0,
stepTime: 0,
canvas,
ctx,
width: canvas.width,
height: canvas.height,
};
return state;
}
/**
* Find nearest point within grab radius.
*/
function findNearestPoint(state, x, y) {
let nearest = null;
let minDist = config_1.CONFIG.GRAB_RADIUS;
for (const p of state.points) {
const dist = types_1.Vec.dist({ x, y }, { x: p.x, y: p.y });
if (dist < minDist) {
minDist = dist;
nearest = p;
}
}
return nearest;
}
/**
* Toggle pin on a point.
*/
function togglePin(state, point) {
point.pinned = !point.pinned;
}
/**
* Update physics with fixed timestep.
*/
function updatePhysics(state, dt) {
// Clamp delta time to prevent spiral of death after tab switch
dt = Math.min(dt, config_1.CONFIG.MAX_DELTA);
state.accumulator += dt;
// Maximum substeps to prevent spiral of death
const maxSubsteps = config_1.CONFIG.MAX_SUBSTEPS;
let substeps = 0;
while (state.accumulator >= config_1.CONFIG.TIMESTEP && substeps < maxSubsteps) {
const stepStart = performance.now();
// Integrate positions
(0, physics_1.integratePoints)(state, config_1.CONFIG.TIMESTEP);
// Solve constraints multiple times for stability
(0, physics_1.solveConstraints)(state);
// Resolve collisions
(0, physics_1.resolveCollisions)(state);
state.stepTime = performance.now() - stepStart;
state.accumulator -= config_1.CONFIG.TIMESTEP;
substeps++;
}
}
/**
* Render the simulation.
*/
function render(state) {
const ctx = state.ctx;
const width = state.width;
const height = state.height;
// Clear canvas
(0, render_1.clearCanvas)(ctx, width, height);
// Draw trails first (faded)
if (state.showTrails) {
(0, render_1.drawTrails)(ctx, state.points);
}
// Draw static shapes
(0, render_1.drawStaticShapes)(ctx, state.staticShapes);
// Draw constraints
(0, render_1.drawConstraints)(ctx, state.constraints, state.showStress, state.grabbedPoint);
// Draw objects (triangles/meshes)
for (const obj of state.objects) {
if (obj.type === 'cloth') {
(0, render_1.drawCloth)(ctx, obj);
}
else if (obj.type === 'softbody') {
(0, render_1.drawSoftBody)(ctx, obj);
}
}
// Draw points
(0, render_1.drawPoints)(ctx, state.points, state.grabbedPoint);
// Draw balls separately (they have custom radius)
for (const obj of state.objects) {
if (obj.type === 'ball') {
(0, render_1.drawBall)(ctx, obj);
}
}
// Draw tear indicator
if (state.isTearing && state.tearStart) {
const mouse = getMousePosition(state);
(0, render_1.drawTearIndicator)(ctx, state.tearStart, mouse);
}
// Draw grab highlight
if (state.grabbedPoint) {
(0, render_1.drawGrabHighlight)(ctx, state.grabbedPoint.x, state.grabbedPoint.y);
}
}
/**
* Get current mouse position relative to canvas.
*/
function getMousePosition(state) {
const rect = state.canvas.getBoundingClientRect();
return {
x: pointerState.x - rect.left,
y: pointerState.y - rect.top,
};
}
// Mouse/pointer state
const pointerState = {
x: 0,
y: 0,
isDown: false,
isRightDown: false,
isShiftDown: false,
isCtrlDown: false,
};
// Spawn mode
let spawnMode = 'none';
let spawnStart = null;
/**
* Handle mouse down event.
*/
function handleMouseDown(e, state) {
const rect = state.canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
pointerState.x = e.clientX;
pointerState.y = e.clientY;
pointerState.isCtrlDown = e.ctrlKey || e.metaKey;
if (e.button === 2) {
// Right click - start tearing
pointerState.isRightDown = true;
state.isTearing = true;
state.tearStart = { x, y };
return;
}
if (e.button === 0) {
// Left click
if (spawnMode !== 'none') {
// Creating a new object
spawnStart = { x, y };
pointerState.isDown = true;
return;
}
// Try to grab a point
const nearest = findNearestPoint(state, x, y);
if (nearest) {
if (pointerState.isCtrlDown) {
// Toggle pin
togglePin(state, nearest);
}
else {
state.grabbedPoint = nearest;
state.grabOffset = { x: x - nearest.x, y: y - nearest.y };
pointerState.isDown = true;
}
}
}
}
/**
* Handle mouse move event.
*/
function handleMouseMove(e, state) {
const rect = state.canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
pointerState.x = e.clientX;
pointerState.y = e.clientY;
if (spawnMode !== 'none' && spawnStart && pointerState.isDown) {
// Drawing preview could go here
return;
}
if (state.isTearing && pointerState.isRightDown) {
// Tear near current position
const nearest = findNearestPoint(state, x, y);
if (nearest) {
(0, physics_1.tearNearPoint)(state, nearest, config_1.CONFIG.TEAR_DISTANCE);
}
}
if (state.grabbedPoint && pointerState.isDown) {
// Move grabbed point to cursor position
state.grabbedPoint.x = x + state.grabOffset.x;
state.grabbedPoint.y = y + state.grabOffset.y;
state.grabbedPoint.oldX = state.grabbedPoint.x;
state.grabbedPoint.y = state.grabbedPoint.y;
}
}
/**
* Handle mouse up event.
*/
function handleMouseUp(e, state) {
if (e.button === 2) {
pointerState.isRightDown = false;
state.isTearing = false;
state.tearStart = null;
}
if (e.button === 0) {
if (spawnMode !== 'none' && spawnStart && pointerState.isDown) {
// Complete object creation
createObjectFromSpawn(state, spawnStart, { x: e.clientX - state.canvas.getBoundingClientRect().left, y: e.clientY - state.canvas.getBoundingClientRect().top });
spawnMode = 'none';
spawnStart = null;
updateToolbarButtons();
}
pointerState.isDown = false;
if (state.grabbedPoint) {
// Throw the point - give it velocity based on drag
const vx = (state.grabbedPoint.x - state.grabbedPoint.oldX);
const vy = (state.grabbedPoint.y - state.grabbedPoint.oldY);
// Apply throw velocity (multiply for more power)
state.grabbedPoint.oldX = state.grabbedPoint.x - vx * 0.5;
state.grabbedPoint.oldY = state.grabbedPoint.y - vy * 0.5;
state.grabbedPoint = null;
}
}
}
/**
* Create object based on spawn mode.
*/
function createObjectFromSpawn(state, start, end) {
const dx = end.x - start.x;
const dy = end.y - start.y;
const length = Math.sqrt(dx * dx + dy * dy);
if (spawnMode === 'rope') {
const numSegments = Math.max(5, Math.floor(length / config_1.CONFIG.ROPE_SPACING));
const rope = (0, objects_1.createRope)(start.x, start.y, numSegments, true);
state.objects.push(rope);
for (const p of rope.points)
state.points.push(p);
for (const c of rope.constraints)
state.constraints.push(c);
}
else if (spawnMode === 'cloth') {
const cols = Math.max(4, Math.floor(Math.abs(dx) / config_1.CONFIG.CLOTH_SPACING));
const rows = Math.max(3, Math.floor(Math.abs(dy) / config_1.CONFIG.CLOTH_SPACING));
const cloth = (0, objects_1.createCloth)(start.x, start.y, cols, rows, 'corners');
state.objects.push(cloth);
for (const p of cloth.points)
state.points.push(p);
for (const c of cloth.constraints)
state.constraints.push(c);
}
else if (spawnMode === 'softbody') {
const radius = Math.max(20, length / 2);
const centerX = start.x + dx / 2;
const centerY = start.y + dy / 2;
const softbody = (0, objects_1.createSoftBody)(centerX, centerY, radius, 12);
state.objects.push(softbody);
for (const p of softbody.points)
state.points.push(p);
for (const c of softbody.constraints)
state.constraints.push(c);
}
else if (spawnMode === 'ball') {
const ball = (0, objects_1.createBall)(start.x, start.y);
state.objects.push(ball);
state.points.push(ball.point);
}
}
/**
* Update toolbar button states.
*/
function updateToolbarButtons() {
for (const btn of document.querySelectorAll('#toolbar button')) {
btn.classList.remove('active');
}
if (spawnMode !== 'none') {
const btnId = `btn-${spawnMode}`;
const btn = document.getElementById(btnId);
if (btn)
btn.classList.add('active');
}
}
/**
* Game loop.
*/
function gameLoop(currentTime) {
if (!sim)
return;
const dt = (currentTime - sim.lastFrameTime) / 1000;
sim.lastFrameTime = currentTime;
// Calculate FPS
sim.frameCount++;
if (sim.frameCount % 30 === 0) {
sim.fps = Math.round(1 / dt);
updateInfoDisplay();
}
if (!sim.paused) {
updatePhysics(sim, dt);
}
render(sim);
requestAnimationFrame(gameLoop);
}
/**
* Update info panel display.
*/
function updateInfoDisplay() {
if (!sim)
return;
const fpsEl = document.getElementById('fps-counter');
const stepEl = document.getElementById('step-time');
const countEl = document.getElementById('point-count');
if (fpsEl)
fpsEl.textContent = `FPS: ${sim.fps}`;
if (stepEl)
stepEl.textContent = `Step: ${sim.stepTime.toFixed(2)} ms`;
if (countEl)
countEl.textContent = `Points: ${sim.points.length}`;
}
/**
* Reset the simulation.
*/
function resetSimulation() {
sim = initSimulation();
setupEventListeners(sim);
setupUIListeners(sim);
}
/**
* Setup event listeners.
*/
function setupEventListeners(state) {
const canvas = state.canvas;
canvas.addEventListener('mousedown', (e) => handleMouseDown(e, state));
canvas.addEventListener('mousemove', (e) => handleMouseMove(e, state));
canvas.addEventListener('mouseup', (e) => handleMouseUp(e, state));
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
// Keyboard controls
document.addEventListener('keydown', (e) => {
if (e.code === 'Space') {
state.paused = !state.paused;
const btn = document.getElementById('btn-pause');
if (btn)
btn.textContent = state.paused ? '▶' : '⏸';
}
else if (e.code === 'ArrowRight') {
// Step one frame when paused
if (state.paused) {
updatePhysics(state, config_1.CONFIG.TIMESTEP);
render(state);
}
}
else if (e.code === 'KeyR') {
resetSimulation();
}
});
}
/**
* Setup UI control listeners.
*/
function setupUIListeners(state) {
// Toolbar buttons
document.getElementById('btn-rope').addEventListener('click', () => {
spawnMode = spawnMode === 'rope' ? 'none' : 'rope';
updateToolbarButtons();
});
document.getElementById('btn-cloth').addEventListener('click', () => {
spawnMode = spawnMode === 'cloth' ? 'none' : 'cloth';
updateToolbarButtons();
});
document.getElementById('btn-softbody').addEventListener('click', () => {
spawnMode = spawnMode === 'softbody' ? 'none' : 'softbody';
updateToolbarButtons();
});
document.getElementById('btn-ball').addEventListener('click', () => {
spawnMode = spawnMode === 'ball' ? 'none' : 'ball';
updateToolbarButtons();
});
document.getElementById('btn-pause').addEventListener('click', () => {
state.paused = !state.paused;
const btn = document.getElementById('btn-pause');
btn.textContent = state.paused ? '▶' : '⏸';
});
document.getElementById('btn-step').addEventListener('click', () => {
state.paused = true;
updatePhysics(state, config_1.CONFIG.TIMESTEP);
render(state);
const btn = document.getElementById('btn-pause');
btn.textContent = '⏸';
});
document.getElementById('btn-reset').addEventListener('click', () => {
resetSimulation();
});
// Sliders
const gravitySlider = document.getElementById('gravity-slider');
const gravityValue = document.getElementById('gravity-value');
gravitySlider.addEventListener('input', () => {
state.gravity = parseFloat(gravitySlider.value);
gravityValue.textContent = gravitySlider.value;
});
const iterSlider = document.getElementById('iterations-slider');
const iterValue = document.getElementById('iter-value');
iterSlider.addEventListener('input', () => {
state.iterations = parseInt(iterSlider.value);
iterValue.textContent = iterSlider.value;
});
// Checkboxes
document.getElementById('stress-viz').addEventListener('change', (e) => {
state.showStress = e.target.checked;
});
document.getElementById('trail-viz').addEventListener('change', (e) => {
state.showTrails = e.target.checked;
});
}
/**
* Main initialization.
*/
function main() {
sim = initSimulation();
setupEventListeners(sim);
setupUIListeners(sim);
// Expose simulation on window for testing
window.sim = {
get state() { return sim; },
step() {
if (sim) {
updatePhysics(sim, config_1.CONFIG.TIMESTEP);
render(sim);
}
},
reset() { resetSimulation(); },
pause() { if (sim)
sim.paused = true; },
resume() { if (sim)
sim.paused = false; },
addRope(x, y, len = 15) {
if (!sim)
return;
const rope = (0, objects_1.createRope)(x, y, len, true);
sim.objects.push(rope);
for (const p of rope.points)
sim.points.push(p);
for (const c of rope.constraints)
sim.constraints.push(c);
},
addCloth(x, y, cols = 10, rows = 6) {
if (!sim)
return;
const cloth = (0, objects_1.createCloth)(x, y, cols, rows, 'corners');
sim.objects.push(cloth);
for (const p of cloth.points)
sim.points.push(p);
for (const c of cloth.constraints)
sim.constraints.push(c);
},
addSoftBody(x, y, radius = 40) {
if (!sim)
return;
const sb = (0, objects_1.createSoftBody)(x, y, radius, 12);
sim.objects.push(sb);
for (const p of sb.points)
sim.points.push(p);
for (const c of sb.constraints)
sim.constraints.push(c);
},
addBall(x, y) {
if (!sim)
return;
const ball = (0, objects_1.createBall)(x, y);
sim.objects.push(ball);
sim.points.push(ball.point);
},
tearPoint(point) {
if (!sim)
return;
(0, physics_1.tearNearPoint)(sim, point, 50);
},
// Debug helpers
getTotalKineticEnergy: () => sim ? (0, physics_1.getTotalKineticEnergy)(sim.points) : 0,
hasNaNValues: () => sim ? (0, physics_1.hasNaNValues)(sim) : false,
getMinY: () => sim ? (0, physics_1.getMinY)(sim.points) : Infinity,
};
requestAnimationFrame(gameLoop);
}
// Start the simulation
main();
};
// ── module: src/config.ts ──
__mods["src/config.ts"] = function (exports, require, module) {
"use strict";
/**
* Physics Configuration - Tunable Constants
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.CONFIG = void 0;
exports.CONFIG = {
// Physics timing
TIMESTEP: 1 / 60, // Fixed timestep (seconds)
MAX_SUBSTEPS: 10, // Max substeps per frame to prevent spiral of death
MAX_DELTA: 0.25, // Maximum delta time clamp (prevents tab-switch explosion)
// Verlet integration
DAMPING: 0.95, // Velocity damping (air resistance) - lower = more damping
GRAVITY_DEFAULT: 20, // Default gravity (pixels/s²)
// Constraint solving
ITERATIONS_DEFAULT: 8, // Number of constraint iterations per step
MIN_ITERATIONS: 1,
MAX_ITERATIONS: 20,
// Collision
FRICTION: 0.8, // Friction coefficient on collision
BOUNCE: 0.15, // Restitution (bounciness) - lower = less bouncy
WALL_BOUNCE: 0.2,
// Interaction
GRAB_RADIUS: 25, // Radius for grabbing points
TEAR_DISTANCE: 40, // Distance threshold for tearing when right-dragging
TEAR_FORCE_THRESHOLD: 3, // Force multiplier to tear a constraint
// Rendering
POINT_RADIUS: 3,
CONSTRAINT_WIDTH: 1,
STRESS_COLOR_MIN: [0, 255, 0], // Green for no stress
STRESS_COLOR_MAX: [255, 0, 0], // Red for max stress
TRAIL_LENGTH: 5, // Trail history length
TRAIL_FADE: 0.15, // Trail fade factor
// Object defaults
ROPE_SPACING: 15,
CLOTH_SPACING: 18,
SOFTBODY_RADIUS: 40,
BALL_RADIUS: 12,
// Colors (HSL values for vibrant materials)
COLORS: {
ROPE: [220, 80, 60], // Blue-ish
CLOTH: [160, 70, 55], // Cyan-ish
SOFTBODY: [280, 75, 60], // Purple-ish
BALL: [45, 90, 60], // Yellow-ish
PINNED: [0, 0, 80], // Dark gray
CONSTRAINT: [200, 30, 40], // Dim red
STATIC: [100, 10, 30], // Very dim
GRABBED: [120, 100, 70], // Bright green
TEARING: [0, 100, 70], // Bright cyan
},
// Canvas
CANVAS_BG: '#1a1a2e',
WALL_COLOR: 'rgba(100, 100, 150, 0.3)',
};
};
// ── module: src/types.ts ──
__mods["src/types.ts"] = function (exports, require, module) {
"use strict";
/**
* Type Definitions for the Physics Engine
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.Vec = void 0;
exports.createPoint = createPoint;
exports.createConstraint = createConstraint;
const config_1 = require("./config");
/** Helper to create a point */
function createPoint(x, y, pinned = false) {
return {
x, y,
oldX: x, oldY: y,
pinned,
radius: config_1.CONFIG.POINT_RADIUS,
mass: 1,
trail: [],
};
}
/** Helper to create a constraint */
function createConstraint(p1, p2, visible = true) {
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const length = Math.sqrt(dx * dx + dy * dy);
return {
p1, p2,
length: isNaN(length) ? 0 : length,
stiffness: 1,
visible,
currentLength: length,
};
}
/** Vector operations */
exports.Vec = {
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 }),
mul: (v, s) => ({ x: v.x * s, y: v.y * s }),
dot: (a, b) => a.x * b.x + a.y * b.y,
len: (v) => Math.sqrt(v.x * v.x + v.y * v.y),
norm: (v) => {
const len = Math.sqrt(v.x * v.x + v.y * v.y);
return len > 0.0001 ? { x: v.x / len, y: v.y / len } : { x: 0, y: 0 };
},
dist: (a, b) => {
const dx = a.x - b.x;
const dy = a.y - b.y;
return Math.sqrt(dx * dx + dy * dy);
},
};
};
// ── module: src/physics.ts ──
__mods["src/physics.ts"] = function (exports, require, module) {
"use strict";
/**
* Physics Engine - Verlet Integration with Constraint Relaxation
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.integratePoints = integratePoints;
exports.solveConstraints = solveConstraints;
exports.resolveCollisions = resolveCollisions;
exports.tearNearPoint = tearNearPoint;
exports.getTotalKineticEnergy = getTotalKineticEnergy;
exports.hasNaNValues = hasNaNValues;
exports.getMinY = getMinY;
const config_1 = require("./config");
const types_1 = require("./types");
/**
* Integrate all points using Verlet integration.
* Formula: x' = x + (x - xPrev) * damping + a * dt²
*/
function integratePoints(state, dt) {
const gravity = state.gravity;
const damping = config_1.CONFIG.DAMPING;
const dt2 = dt * dt;
const MAX_VELOCITY = 500; // Clamp max velocity to prevent explosion
for (const p of state.points) {
if (p.pinned)
continue;
// Calculate displacement (this IS velocity * dt, damped)
let dispX = (p.x - p.oldX) * damping;
let dispY = (p.y - p.oldY) * damping;
// Clamp velocity (disp/dt = velocity)
const velX = dispX / dt;
const velY = dispY / dt;
const velSq = velX * velX + velY * velY;
if (velSq > MAX_VELOCITY * MAX_VELOCITY) {
const scale = MAX_VELOCITY / Math.sqrt(velSq);
dispX *= scale;
dispY *= scale;
}
// Store old position BEFORE updating
const prevX = p.x;
const prevY = p.y;
// Update position: x' = x + displacement + gravity * dt²
p.x += dispX;
p.y += dispY + gravity * dt2;
// Update old position for next step
p.oldX = prevX;
p.oldY = prevY;
// Track trail (use prevX/prevY which is the position before this step)
if (state.showTrails && p.trail.length < config_1.CONFIG.TRAIL_LENGTH) {
p.trail.push({ x: prevX, y: prevY });
}
else if (state.showTrails && p.trail.length >= config_1.CONFIG.TRAIL_LENGTH) {
p.trail.shift();
p.trail.push({ x: prevX, y: prevY });
}
}
}
/**
* Solve all distance constraints using relaxation.
* Iterates N times for stability.
*/
function solveConstraints(state) {
const iterations = state.iterations;
const points = state.points;
const constraints = state.constraints;
for (let iter = 0; iter < iterations; iter++) {
for (const c of constraints) {
const p1 = c.p1;
const p2 = c.p2;
// Skip if both points are pinned
if (p1.pinned && p2.pinned)
continue;
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const distSq = dx * dx + dy * dy;
// Guard against zero-length or NaN
if (!isFinite(distSq) || distSq < 0.0001) {
// Points are coincident - skip to avoid division by zero
c.currentLength = 0;
continue;
}
const dist = Math.sqrt(distSq);
c.currentLength = dist;
// Calculate how much we need to move the points
const diff = (dist - c.length) / dist;
const offsetX = dx * diff * 0.5;
const offsetY = dy * diff * 0.5;
// Apply stiffness factor
const stiff = c.stiffness;
// Move points toward/away from each other
if (!p1.pinned) {
p1.x += offsetX;
p1.y += offsetY;
}
if (!p2.pinned) {
p2.x -= offsetX;
p2.y -= offsetY;
}
}
}
}
/**
* Resolve collisions between points and static shapes.
*/
function resolveCollisions(state) {
const shapes = state.staticShapes;
const bounce = config_1.CONFIG.BOUNCE;
const friction = config_1.CONFIG.FRICTION;
for (const p of state.points) {
if (p.pinned)
continue;
for (const shape of shapes) {
if (shape.type === 'wall') {
resolveWallCollision(p, shape, bounce, friction);
}
else if (shape.type === 'circle') {
resolveCircleCollision(p, shape, bounce, friction);
}
else if (shape.type === 'box') {
resolveBoxCollision(p, shape, bounce, friction);
}
}
}
}
/**
* Resolve collision with a wall (infinite line).
*/
function resolveWallCollision(p, wall, bounce, friction) {
// Vector from wall point to particle
const dx = p.x - wall.x;
const dy = p.y - wall.y;
// Distance to wall (dot product with normal)
const dist = dx * wall.nx + dy * wall.ny;
// Check if on the wrong side of the wall
if (dist < -p.radius) {
// Penetration depth
const penetration = -dist - p.radius;
// Push out along normal
p.x += wall.nx * penetration;
p.y += wall.ny * penetration;
// Calculate velocity
const vx = p.x - p.oldX;
const vy = p.y - p.oldY;
// Velocity component along normal
const vn = vx * wall.nx + vy * wall.ny;
// Only bounce if moving toward the wall
if (vn < 0) {
// Tangent vector
const tx = -wall.ny;
const ty = wall.nx;
// Velocity component along tangent
const vt = vx * tx + vy * ty;
// Reflect: new velocity = -bounce * vn * n + friction * vt * t
const newVn = -bounce * vn;
const newVt = friction * vt;
// Convert back to world coordinates
const newVx = newVn * wall.nx + newVt * tx;
const newVy = newVn * wall.ny + newVt * ty;
// Update old position to create new velocity
p.oldX = p.x - newVx * config_1.CONFIG.TIMESTEP;
p.oldY = p.y - newVy * config_1.CONFIG.TIMESTEP;
}
}
}
/**
* Resolve collision with a circle.
*/
function resolveCircleCollision(p, circle, bounce, friction) {
const dx = p.x - circle.x;
const dy = p.y - circle.y;
const distSq = dx * dx + dy * dy;
const minDist = circle.radius + p.radius;
if (distSq < minDist * minDist) {
const dist = Math.sqrt(distSq);
// Normal pointing from circle center to particle
let nx = dx / dist;
let ny = dy / dist;
// Handle coincident points
if (dist < 0.0001) {
nx = 0;
ny = -1;
}
// Push out
const penetration = minDist - dist;
p.x += nx * penetration;
p.y += ny * penetration;
// Velocity
const vx = p.x - p.oldX;
const vy = p.y - p.oldY;
// Normal velocity
const vn = vx * nx + vy * ny;
if (vn < 0) {
// Tangent
const tx = -ny;
const ty = nx;
const vt = vx * tx + vy * ty;
const newVn = -bounce * vn;
const newVt = friction * vt;
const newVx = newVn * nx + newVt * tx;
const newVy = newVn * ny + newVt * ty;
p.oldX = p.x - newVx * config_1.CONFIG.TIMESTEP;
p.oldY = p.y - newVy * config_1.CONFIG.TIMESTEP;
}
}
}
/**
* Resolve collision with an axis-aligned box.
*/
function resolveBoxCollision(p, box, bounce, friction) {
// Find closest point on box to particle
const closestX = Math.max(box.x, Math.min(p.x, box.x + box.width));
const closestY = Math.max(box.y, Math.min(p.y, box.y + box.height));
const dx = p.x - closestX;
const dy = p.y - closestY;
const distSq = dx * dx + dy * dy;
if (distSq < p.radius * p.radius) {
const dist = Math.sqrt(distSq);
// Normal from closest point to particle
let nx = dx / dist;
let ny = dy / dist;
// Handle center case
if (dist < 0.0001) {
// Particle is inside - push to nearest edge
const leftDist = p.x - box.x;
const rightDist = box.x + box.width - p.x;
const topDist = p.y - box.y;
const bottomDist = box.y + box.height - p.y;
const minEdge = Math.min(leftDist, rightDist, topDist, bottomDist);
if (minEdge === leftDist) {
nx = -1;
ny = 0;
}
else if (minEdge === rightDist) {
nx = 1;
ny = 0;
}
else if (minEdge === topDist) {
nx = 0;
ny = -1;
}
else {
nx = 0;
ny = 1;
}
}
// Push out
const penetration = p.radius - dist;
p.x += nx * penetration;
p.y += ny * penetration;
// Velocity response
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;
const newVn = -bounce * vn;
const newVt = friction * vt;
const newVx = newVn * nx + newVt * tx;
const newVy = newVn * ny + newVt * ty;
p.oldX = p.x - newVx * config_1.CONFIG.TIMESTEP;
p.oldY = p.y - newVy * config_1.CONFIG.TIMESTEP;
}
}
}
/**
* Tear constraints that are being stretched beyond threshold.
* Called during right-drag tearing interaction.
*/
function tearNearPoint(state, point, tearRadius) {
const constraints = state.constraints;
const toRemove = [];
for (const c of constraints) {
// Check if either endpoint is near the tear point
const dist1 = types_1.Vec.dist(point, { x: c.p1.x, y: c.p1.y });
const dist2 = types_1.Vec.dist(point, { x: c.p2.x, y: c.p2.y });
if (dist1 < tearRadius || dist2 < tearRadius) {
// Check if constraint is stretched
if (c.currentLength > c.length * config_1.CONFIG.TEAR_FORCE_THRESHOLD) {
toRemove.push(c);
}
}
}
// Remove torn constraints
for (const c of toRemove) {
const idx = constraints.indexOf(c);
if (idx >= 0)
constraints.splice(idx, 1);
}
}
/**
* Get total kinetic energy of the system (for debugging/validation).
*/
function getTotalKineticEnergy(points) {
let energy = 0;
for (const p of points) {
const vx = p.x - p.oldX;
const vy = p.y - p.oldY;
energy += 0.5 * p.mass * (vx * vx + vy * vy);
}
return energy;
}
/**
* Check for any NaN values in the simulation.
*/
function hasNaNValues(state) {
for (const p of state.points) {
if (!isFinite(p.x) || !isFinite(p.y) || !isFinite(p.oldX) || !isFinite(p.oldY)) {
return true;
}
}
return false;
}
/**
* Get minimum Y coordinate among all points (for floor check).
*/
function getMinY(points) {
let minY = Infinity;
for (const p of points) {
if (p.y < minY)
minY = p.y;
}
return minY;
}
};
// ── module: src/objects.ts ──
__mods["src/objects.ts"] = function (exports, require, module) {
"use strict";
/**
* Physics Object Creation - Ropes, Cloth, Soft Bodies, Balls
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.createRope = createRope;
exports.createCloth = createCloth;
exports.createSoftBody = createSoftBody;
exports.createBall = createBall;
exports.createBoundaryWalls = createBoundaryWalls;
exports.createStaticCircle = createStaticCircle;
exports.createStaticBox = createStaticBox;
const config_1 = require("./config");
const types_1 = require("./types");
/**
* Create a rope (hanging chain).
* @param startX Starting X position
* @param startY Starting Y position
* @param length Number of segments
* @param pinned Start point is pinned
*/
function createRope(startX, startY, length = 15, pinned = true) {
const points = [];
const constraints = [];
const spacing = config_1.CONFIG.ROPE_SPACING;
for (let i = 0; i <= length; i++) {
const x = startX + i * spacing;
const y = startY;
const p = (0, types_1.createPoint)(x, y, pinned && i === 0);
points.push(p);
// Connect to previous point
if (i > 0) {
const c = (0, types_1.createConstraint)(points[i - 1], points[i]);
constraints.push(c);
}
}
return {
type: 'rope',
points,
constraints,
color: [...config_1.CONFIG.COLORS.ROPE],
};
}
/**
* Create cloth (grid with structural and shear constraints).
* @param startX Top-left X
* @param startY Top-left Y
* @param cols Number of columns
* @param rows Number of rows
* @param pinPattern Pin pattern: 'corners' | 'full-top'
*/
function createCloth(startX, startY, cols = 12, rows = 8, pinPattern = 'corners') {
const spacing = config_1.CONFIG.CLOTH_SPACING;
const points = [];
const constraints = [];
const triangles = [];
// Create grid of points
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const x = startX + col * spacing;
const y = startY + row * spacing;
// Pin top edge based on pattern
let pinned = false;
if (row === 0) {
if (pinPattern === 'full-top') {
pinned = true;
}
else if (pinPattern === 'corners') {
pinned = (col === 0 || col === cols - 1);
}
}
const p = (0, types_1.createPoint)(x, y, pinned);
points.push(p);
}
}
// Helper to get point index
const idx = (col, row) => row * cols + col;
// Structural constraints (horizontal and vertical)
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const currentIdx = idx(col, row);
// Horizontal constraint
if (col < cols - 1) {
const rightIdx = idx(col + 1, row);
constraints.push((0, types_1.createConstraint)(points[currentIdx], points[rightIdx]));
}
// Vertical constraint
if (row < rows - 1) {
const downIdx = idx(col, row + 1);
constraints.push((0, types_1.createConstraint)(points[currentIdx], points[downIdx]));
}
}
}
// Shear constraints (diagonals for stability)
for (let row = 0; row < rows - 1; row++) {
for (let col = 0; col < cols - 1; col++) {
const i1 = idx(col, row);
const i2 = idx(col + 1, row + 1);
const i3 = idx(col + 1, row);
const i4 = idx(col, row + 1);
// Cross diagonals
constraints.push((0, types_1.createConstraint)(points[i1], points[i2], false));
constraints.push((0, types_1.createConstraint)(points[i3], points[i4], false));
}
}
// Create triangles for mesh rendering
for (let row = 0; row < rows - 1; row++) {
for (let col = 0; col < cols - 1; col++) {
const i1 = idx(col, row);
const i2 = idx(col + 1, row);
const i3 = idx(col, row + 1);
const i4 = idx(col + 1, row + 1);
// Two triangles per quad
triangles.push({ p1: points[i1], p2: points[i2], p3: points[i3] });
triangles.push({ p1: points[i2], p2: points[i4], p3: points[i3] });
}
}
return {
type: 'cloth',
points,
constraints,
triangles,
color: [...config_1.CONFIG.COLORS.CLOTH],
width: cols,
height: rows,
};
}
/**
* Create a soft body (circle/blob shape with internal bracing).
* @param centerX Center X
* @param centerY Center Y
* @param radius Outer radius
* @param numPoints Number of perimeter points
*/
function createSoftBody(centerX, centerY, radius = config_1.CONFIG.SOFTBODY_RADIUS, numPoints = 12) {
const points = [];
const constraints = [];
const triangles = [];
const angleStep = (Math.PI * 2) / numPoints;
// Create perimeter points
for (let i = 0; i < numPoints; i++) {
const angle = i * angleStep - Math.PI / 2; // Start from top
const x = centerX + Math.cos(angle) * radius;
const y = centerY + Math.sin(angle) * radius;
const p = (0, types_1.createPoint)(x, y, false);
points.push(p);
}
// Add center point for better deformation
const centerPoint = (0, types_1.createPoint)(centerX, centerY, false);
points.push(centerPoint);
const centerIdx = numPoints;
// Perimeter constraints (edges)
for (let i = 0; i < numPoints; i++) {
const next = (i + 1) % numPoints;
constraints.push((0, types_1.createConstraint)(points[i], points[next]));
}
// Internal constraints (connect to center)
for (let i = 0; i < numPoints; i++) {
constraints.push((0, types_1.createConstraint)(points[i], centerPoint, false));
}
// Brace constraints (skip-one connections for stiffness)
for (let i = 0; i < numPoints; i++) {
const skip1 = (i + 2) % numPoints;
const skip2 = (i + 3) % numPoints;
constraints.push((0, types_1.createConstraint)(points[i], points[skip1], false));
if (numPoints > 6) {
constraints.push((0, types_1.createConstraint)(points[i], points[skip2], false));
}
}
// Create triangles for filled rendering
for (let i = 0; i < numPoints; i++) {
const next = (i + 1) % numPoints;
triangles.push({
p1: centerPoint,
p2: points[i],
p3: points[next],
color: undefined
});
}
return {
type: 'softbody',
points,
constraints,
triangles,
color: [...config_1.CONFIG.COLORS.SOFTBODY],
};
}
/**
* Create a bouncing ball (single point with radius).
* @param x Initial X
* @param y Initial Y
*/
function createBall(x, y) {
const point = (0, types_1.createPoint)(x, y, false);
point.radius = config_1.CONFIG.BALL_RADIUS;
return {
type: 'ball',
point,
color: [...config_1.CONFIG.COLORS.BALL],
};
}
/**
* Create static walls around the canvas bounds.
* @param width Canvas width
* @param height Canvas height
*/
function createBoundaryWalls(width, height) {
const walls = [];
const margin = 50;
// Left wall (normal points right)
walls.push({
type: 'wall',
x: margin,
y: height / 2,
nx: 1,
ny: 0,
});
// Right wall (normal points left)
walls.push({
type: 'wall',
x: width - margin,
y: height / 2,
nx: -1,
ny: 0,
});
// Bottom wall/floor (normal points up)
walls.push({
type: 'wall',
x: width / 2,
y: height - margin,
nx: 0,
ny: -1,
});
// Top wall (normal points down) - optional ceiling
walls.push({
type: 'wall',
x: width / 2,
y: margin,
nx: 0,
ny: 1,
});
return walls;
}
/**
* Create a static circle obstacle.
* @param x Center X
* @param y Center Y
* @param radius Circle radius
*/
function createStaticCircle(x, y, radius) {
return {
type: 'circle',
x,
y,
radius,
};
}
/**
* Create a static box obstacle.
* @param x Top-left X
* @param y Top-left Y
* @param width Box width
* @param height Box height
*/
function createStaticBox(x, y, width, height) {
return {
type: 'box',
x,
y,
width,
height,
};
}
};
// ── module: src/render.ts ──
__mods["src/render.ts"] = function (exports, require, module) {
"use strict";
/**
* Rendering - Canvas 2D Drawing
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.hslToRgb = hslToRgb;
exports.hslArrayToString = hslArrayToString;
exports.getStressColor = getStressColor;
exports.clearCanvas = clearCanvas;
exports.drawStaticShapes = drawStaticShapes;
exports.drawConstraints = drawConstraints;
exports.drawPoints = drawPoints;
exports.drawCloth = drawCloth;
exports.drawSoftBody = drawSoftBody;
exports.drawBall = drawBall;
exports.drawTrails = drawTrails;
exports.drawTearIndicator = drawTearIndicator;
exports.drawGrabHighlight = drawGrabHighlight;
exports.drawFpsCounter = drawFpsCounter;
const config_1 = require("./config");
/**
* Convert HSL to RGB for canvas.
*/
function hslToRgb(h, s, l) {
s /= 100;
l /= 100;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs((h / 60) % 2 - 1));
const m = l - c / 2;
let r = 0, g = 0, b = 0;
if (h >= 0 && h < 60) {
r = c;
g = x;
b = 0;
}
else if (h >= 60 && h < 120) {
r = x;
g = c;
b = 0;
}
else if (h >= 120 && h < 180) {
r = 0;
g = c;
b = x;
}
else if (h >= 180 && h < 240) {
r = 0;
g = x;
b = c;
}
else if (h >= 240 && h < 300) {
r = x;
g = 0;
b = c;
}
else if (h >= 300 && h < 360) {
r = c;
g = 0;
b = x;
}
return [
Math.round((r + m) * 255),
Math.round((g + m) * 255),
Math.round((b + m) * 255),
];
}
/**
* Get color string from HSL array.
*/
function hslArrayToString(color, alpha = 1) {
return `hsla(${color[0]}, ${color[1]}%, ${color[2]}%, ${alpha})`;
}
/**
* Get stress-based color (green to red gradient).
*/
function getStressColor(stretchRatio) {
// stretchRatio = currentLength / restLength
// 1.0 = no stress (green), >1.5 = max stress (red)
const t = Math.min(Math.max((stretchRatio - 1) * 2, 0), 1);
const [r1, g1, b1] = config_1.CONFIG.STRESS_COLOR_MIN;
const [r2, g2, b2] = config_1.CONFIG.STRESS_COLOR_MAX;
const r = Math.round(r1 + (r2 - r1) * t);
const g = Math.round(g1 + (g2 - g1) * t);
const b = Math.round(b1 + (b2 - b1) * t);
return `rgb(${r}, ${g}, ${b})`;
}
/**
* Clear the canvas with background color.
*/
function clearCanvas(ctx, width, height) {
ctx.fillStyle = config_1.CONFIG.CANVAS_BG;
ctx.fillRect(0, 0, width, height);
}
/**
* Draw static shapes (walls, obstacles).
*/
function drawStaticShapes(ctx, shapes) {
ctx.strokeStyle = config_1.CONFIG.WALL_COLOR;
ctx.lineWidth = 2;
ctx.fillStyle = config_1.CONFIG.WALL_COLOR;
for (const shape of shapes) {
if (shape.type === 'wall') {
// Draw wall as a line segment
const len = 1000;
const dx = -shape.ny * len;
const dy = shape.nx * len;
ctx.beginPath();
ctx.moveTo(shape.x + dx, shape.y + dy);
ctx.lineTo(shape.x - dx, shape.y - dy);
ctx.stroke();
}
else if (shape.type === 'circle') {
ctx.beginPath();
ctx.arc(shape.x, shape.y, shape.radius, 0, Math.PI * 2);
ctx.stroke();
ctx.fill();
}
else if (shape.type === 'box') {
ctx.fillRect(shape.x, shape.y, shape.width, shape.height);
ctx.strokeRect(shape.x, shape.y, shape.width, shape.height);
}
}
}
/**
* Draw all constraints.
*/
function drawConstraints(ctx, constraints, showStress, grabbedPoint) {
ctx.lineWidth = config_1.CONFIG.CONSTRAINT_WIDTH;
for (const c of constraints) {
if (!c.visible)
continue;
if (showStress) {
// Color by stress
const ratio = c.length > 0 ? c.currentLength / c.length : 1;
ctx.strokeStyle = getStressColor(ratio);
}
else {
// Check if connected to grabbed point
if (c.p1 === grabbedPoint || c.p2 === grabbedPoint) {
ctx.strokeStyle = hslArrayToString(config_1.CONFIG.COLORS.GRABBED);
}
else if (c.color) {
ctx.strokeStyle = hslArrayToString([c.color[0], c.color[1], c.color[2]]);
}
else {
ctx.strokeStyle = hslArrayToString(config_1.CONFIG.COLORS.CONSTRAINT);
}
}
ctx.beginPath();
ctx.moveTo(c.p1.x, c.p1.y);
ctx.lineTo(c.p2.x, c.p2.y);
ctx.stroke();
}
}
/**
* Draw all points.
*/
function drawPoints(ctx, points, grabbedPoint) {
const radius = config_1.CONFIG.POINT_RADIUS;
for (const p of points) {
// Skip drawing points for cloth (render as mesh instead)
// But still draw for ropes and soft bodies
if (p === grabbedPoint) {
// Highlight grabbed point
ctx.fillStyle = hslArrayToString(config_1.CONFIG.COLORS.GRABBED);
ctx.beginPath();
ctx.arc(p.x, p.y, radius * 2, 0, Math.PI * 2);
ctx.fill();
}
else if (p.pinned) {
// Pinned points are darker
ctx.fillStyle = hslArrayToString(config_1.CONFIG.COLORS.PINNED);
ctx.beginPath();
ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
ctx.fill();
}
else if (p.color) {
ctx.fillStyle = hslArrayToString([p.color[0], p.color[1], p.color[2]]);
ctx.beginPath();
ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
ctx.fill();
}
else {
ctx.fillStyle = hslArrayToString(config_1.CONFIG.COLORS.CONSTRAINT);
ctx.beginPath();
ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
ctx.fill();
}
}
}
/**
* Draw cloth as filled triangles.
*/
function drawCloth(ctx, cloth) {
ctx.lineWidth = 1;
for (const tri of cloth.triangles) {
// Semi-transparent fill
ctx.fillStyle = hslArrayToString(cloth.color, 0.3);
ctx.strokeStyle = hslArrayToString(cloth.color, 0.6);
ctx.beginPath();
ctx.moveTo(tri.p1.x, tri.p1.y);
ctx.lineTo(tri.p2.x, tri.p2.y);
ctx.lineTo(tri.p3.x, tri.p3.y);
ctx.closePath();
ctx.fill();
ctx.stroke();
}
}
/**
* Draw soft body as filled polygon.
*/
function drawSoftBody(ctx, body) {
ctx.lineWidth = 2;
ctx.strokeStyle = hslArrayToString(body.color);
ctx.fillStyle = hslArrayToString(body.color, 0.4);
// Find perimeter points (points that have visible constraints)
const perimeterPoints = [];
const visited = new Set();
for (const c of body.constraints) {
if (c.visible) {
if (!visited.has(c.p1)) {
perimeterPoints.push(c.p1);
visited.add(c.p1);
}
if (!visited.has(c.p2)) {
perimeterPoints.push(c.p2);
visited.add(c.p2);
}
}
}
if (perimeterPoints.length > 2) {
ctx.beginPath();
ctx.moveTo(perimeterPoints[0].x, perimeterPoints[0].y);
for (let i = 1; i < perimeterPoints.length; i++) {
ctx.lineTo(perimeterPoints[i].x, perimeterPoints[i].y);
}
ctx.closePath();
ctx.fill();
ctx.stroke();
}
// Also draw internal triangles
for (const tri of body.triangles) {
ctx.fillStyle = hslArrayToString(body.color, 0.2);
ctx.beginPath();
ctx.moveTo(tri.p1.x, tri.p1.y);
ctx.lineTo(tri.p2.x, tri.p2.y);
ctx.lineTo(tri.p3.x, tri.p3.y);
ctx.closePath();
ctx.fill();
}
}
/**
* Draw a ball.
*/
function drawBall(ctx, ball) {
const { point, color } = ball;
ctx.fillStyle = hslArrayToString(color);
ctx.strokeStyle = hslArrayToString(color);
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(point.x, point.y, point.radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
/**
* Draw trails behind points.
*/
function drawTrails(ctx, points) {
for (const p of points) {
if (p.trail.length < 2)
continue;
for (let i = 0; i < p.trail.length - 1; i++) {
const t1 = p.trail[i];
const t2 = p.trail[i + 1];
const alpha = (i + 1) / p.trail.length * config_1.CONFIG.TRAIL_FADE;
ctx.strokeStyle = hslArrayToString(config_1.CONFIG.COLORS.CONSTRAINT, alpha);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(t1.x, t1.y);
ctx.lineTo(t2.x, t2.y);
ctx.stroke();
}
}
}
/**
* Draw tear indicator (line from start to current position).
*/
function drawTearIndicator(ctx, start, current) {
ctx.strokeStyle = hslArrayToString(config_1.CONFIG.COLORS.TEARING);
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
ctx.beginPath();
ctx.moveTo(start.x, start.y);
ctx.lineTo(current.x, current.y);
ctx.stroke();
ctx.setLineDash([]);
}
/**
* Draw grab cursor highlight.
*/
function drawGrabHighlight(ctx, x, y) {
ctx.strokeStyle = hslArrayToString(config_1.CONFIG.COLORS.GRABBED, 0.5);
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x, y, config_1.CONFIG.GRAB_RADIUS, 0, Math.PI * 2);
ctx.stroke();
}
/**
* Draw FPS counter text.
*/
function drawFpsCounter(ctx, fps, stepTime, pointCount) {
// This is handled by DOM elements, but we could draw on canvas too
// For now, just update DOM elements via main.ts
}
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>