'use strict'; /* ════════════════════════════════════════════════════════════════ ForceSandboxSim v3 — полная физика твёрдого тела Velocity-Verlet · 4 подшага · Вращение · OBB/SAT коллизии Трение с угловым импульсом · Качение · Рампа с вращением ════════════════════════════════════════════════════════════════ */ class ForceSandboxSim { static SCALE = 58; // px / metre static G = 9.81; static COLORS = ['#EF476F','#4CC9F0','#9B5DE5','#FFD166','#7BF5A4','#FF6B35']; static SUB_STEPS = 4; // physics sub-steps per rendered frame /* ── Конструктор ─────────────────────────────────────────── */ constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.bodies = []; this._nextId = 0; this._colorIdx = 0; /* Мировые параметры */ this.gravity = true; this.gVal = 9.81; this.hasFloor = true; this.hasWalls = true; this.floorMu = 0.30; this.showForces = true; this.showVelocity = true; this.showFBD = false; this.showEnergy = true; this.showTrail = true; this.showDecomp = true; this.timeScale = 1; this.airDrag = false; /* Пружины */ this.springs = []; this._nextSpringId = 0; this._springStart = null; this.newSpringK = 120; this.newSpringDamp = 4; /* Верёвки / нити / блоки */ this.ropes = []; this._nextRopeId = 0; this._ropeStart = null; this.newRopeK = 3000; // жёсткость нити (квазинерастяжимая) this.newRopeDamp = 12; /* Наклонная плоскость */ this.ramp = false; this.rampAngle = 30; this.rampMu = 0.20; this._rampGeom = null; /* Инструменты */ this.tool = 'box'; this.forceMode = 'constant'; this.newMass = 5; this.newRestitution = 0.65; /* Drag / hover */ this._drag = null; this._hovered = null; this._ghostPos = null; this._selected = null; /* Timing */ this._raf = null; this._evAbort = new AbortController(); this._last = 0; this._paused = false; this._simTime = 0; this._strobeTimer = 0; this._energyLoss = 0; /* Geometry */ this.W = 0; this.H = 0; this._floorY = 0; this.onUpdate = null; this.fit(); this._bindEvents(); } /* ── Ramp geometry ───────────────────────────────────────── */ _calcRampGeom() { if (!this.ramp) { this._rampGeom = null; return; } const { W, _floorY: fY } = this; const a = this.rampAngle * Math.PI / 180; const margin = W * 0.08, L = W * 0.78; const x1 = margin, y1 = fY; const x2 = margin + L * Math.cos(a), y2 = fY - L * Math.sin(a); const dx = x2 - x1, dy = y2 - y1; const len = Math.hypot(dx, dy); // Normal pointing ABOVE surface: rotate tangent 90° CW in screen coords const nx = dy / len, ny = -dx / len; // = (-sin a, -cos a) this._rampGeom = { x1, y1, x2, y2, nx, ny, len, cos: Math.cos(a), sin: Math.sin(a), angle: a }; } /* ── Geometry ────────────────────────────────────────────── */ fit() { const dpr = window.devicePixelRatio || 1; const W = this.canvas.offsetWidth || 700; const H = this.canvas.offsetHeight || 440; this.canvas.width = Math.round(W * dpr); this.canvas.height = Math.round(H * dpr); this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); this.W = W; this.H = H; this._floorY = H * 0.85; this._calcRampGeom(); } /* ── Lifecycle ───────────────────────────────────────────── */ start() { if (this._raf) return; this._last = performance.now(); const loop = t => { this._raf = requestAnimationFrame(loop); this._tick(t); }; this._raf = requestAnimationFrame(loop); } stop() { cancelAnimationFrame(this._raf); this._raf = null; } togglePause() { this._paused = !this._paused; } reset() { this.bodies = []; this._nextId = 0; this._colorIdx = 0; this._simTime = 0; this._energyLoss = 0; this._drag = null; this._hovered = null; this._selected = null; this.springs = []; this._springStart = null; this.ropes = []; this._ropeStart = null; this.ramp = false; this._rampGeom = null; } /* ── Ramp API ────────────────────────────────────────────── */ setRamp(on) { this.ramp = on; this._calcRampGeom(); } setRampAngle(deg) { this.rampAngle = Math.max(5, Math.min(80, deg)); this._calcRampGeom(); } setRampMu(v) { this.rampMu = v; } /* ── Body creation ───────────────────────────────────────── */ addBody(x, y, type) { type = type || this.tool; if (type === 'erase') return null; const mass = this.newMass; const color = ForceSandboxSim.COLORS[this._colorIdx++ % ForceSandboxSim.COLORS.length]; const w = type === 'box' ? 32 + mass * 2.4 : 0; const h = type === 'box' ? 28 + mass * 1.8 : 0; const r = type === 'ball' ? 14 + mass * 1.6 : 0; // Момент инерции (kg·px²): box = m(w²+h²)/12, ball = m·r²/2 const I = type === 'box' ? mass * (w * w + h * h) / 12 : 0.5 * mass * r * r; const body = { id: this._nextId++, type, x, y, vx: 0, vy: 0, angle: 0, omega: 0, // вращение mass, w, h, r, I, mu: 0.3, restitution: this.newRestitution, color, pinned: false, trail: [], forces: [], }; this.bodies.push(body); return body; } removeBody(id) { this.bodies = this.bodies.filter(b => b.id !== id); this.springs = this.springs.filter(s => s.b1id !== id && s.b2id !== id); this.ropes = this.ropes.filter(r => r.b1id !== id && r.b2id !== id); if (this._selected === id) this._selected = null; } /* ── Springs ─────────────────────────────────────────────── */ // L0m — natural length in metres (null = current distance) // opts: { lx1, ly1, lx2, ly2 } — local attachment offsets (px, default 0,0 = center) addSpring(b1id, b2id, k, L0m, damp, opts) { const b1 = this.bodies.find(b => b.id === b1id); const b2 = this.bodies.find(b => b.id === b2id); if (!b1 || !b2) return null; const S = ForceSandboxSim.SCALE; const lx1 = opts?.lx1 || 0, ly1 = opts?.ly1 || 0; const lx2 = opts?.lx2 || 0, ly2 = opts?.ly2 || 0; // World attachment positions const p1 = this._localToWorld(b1, lx1, ly1); const p2 = this._localToWorld(b2, lx2, ly2); const L0 = (L0m != null) ? L0m * S : Math.hypot(p2.x - p1.x, p2.y - p1.y); const sp = { id: this._nextSpringId++, b1id, b2id, k: k != null ? k : this.newSpringK, damp: damp != null ? damp : this.newSpringDamp, L0, lx1, ly1, lx2, ly2 }; this.springs.push(sp); return sp; } removeSpring(id) { this.springs = this.springs.filter(s => s.id !== id); } /* ── Ropes / Strings / Pulleys ───────────────────────────── */ // opts: { type:'direct'|'pulley', px, py, L0px, L0m, k, damp } // type='direct' — straight inextensible string between two bodies // type='pulley' — string over fixed pulley at (px, py), Atwood-style addRope(b1id, b2id, opts = {}) { const b1 = this.bodies.find(b => b.id === b1id); const b2 = this.bodies.find(b => b.id === b2id); if (!b1 || !b2) return null; const S = ForceSandboxSim.SCALE; const type = opts.type || 'direct'; const px = opts.px != null ? opts.px : 0; const py = opts.py != null ? opts.py : 0; let L0; if (opts.L0px != null) { L0 = opts.L0px; } else if (opts.L0m != null) { L0 = opts.L0m * S; } else if (type === 'pulley') { L0 = Math.hypot(b1.x - px, b1.y - py) + Math.hypot(b2.x - px, b2.y - py); } else { L0 = Math.hypot(b2.x - b1.x, b2.y - b1.y); } const rope = { id: this._nextRopeId++, type, b1id, b2id, L0, px, py, k: opts.k != null ? opts.k : this.newRopeK, damp: opts.damp != null ? opts.damp : this.newRopeDamp }; this.ropes.push(rope); return rope; } removeRope(id) { this.ropes = this.ropes.filter(r => r.id !== id); } clearForces(id) { const b = this.bodies.find(b => b.id === id); if (b) b.forces = []; } /* ── Presets ─────────────────────────────────────────────── */ preset(name) { this.reset(); const S = ForceSandboxSim.SCALE; const { W, H, _floorY: fY } = this; switch (name) { case 'freefall': { this.gravity = true; this.hasFloor = true; this.hasWalls = true; const b1 = this.addBody(W * 0.35, fY - 280, 'ball'); b1.mass = 3; b1.r = 14 + 3 * 1.6; b1.I = 0.5 * b1.mass * b1.r * b1.r; b1.color = '#4CC9F0'; const b2 = this.addBody(W * 0.65, fY - 280, 'ball'); b2.mass = 15; b2.r = 14 + 15 * 1.6; b2.I = 0.5 * b2.mass * b2.r * b2.r; b2.color = '#EF476F'; break; } case 'collision': { this.gravity = false; this.hasFloor = false; this.hasWalls = true; const b1 = this.addBody(W * 0.15, H * 0.45, 'ball'); b1.mass = 5; b1.r = 14 + 5 * 1.6; b1.I = 0.5 * b1.mass * b1.r * b1.r; b1.vx = 180; b1.color = '#4CC9F0'; const b2 = this.addBody(W * 0.85, H * 0.45, 'ball'); b2.mass = 12; b2.r = 14 + 12 * 1.6; b2.I = 0.5 * b2.mass * b2.r * b2.r; b2.vx = -80; b2.color = '#EF476F'; break; } case 'friction': { this.gravity = true; this.hasFloor = true; this.hasWalls = true; this.floorMu = 0.35; const b1 = this.addBody(W * 0.12, fY - 36, 'box'); b1.mass = 8; b1.w = 32 + 8 * 2.4; b1.h = 28 + 8 * 1.8; b1.I = b1.mass * (b1.w * b1.w + b1.h * b1.h) / 12; b1.vx = 240; b1.color = '#9B5DE5'; break; } case 'tug': { this.gravity = true; this.hasFloor = true; this.hasWalls = true; const b1 = this.addBody(W * 0.35, fY - 36, 'box'); b1.mass = 6; b1.w = 32 + 6 * 2.4; b1.h = 28 + 6 * 1.8; b1.I = b1.mass * (b1.w * b1.w + b1.h * b1.h) / 12; b1.color = '#EF476F'; b1.forces.push({ fx: 120 * S, fy: 0, label: 'F₁', color: '#FFD166' }); b1.forces.push({ fx: -80 * S, fy: 0, label: 'F₂', color: '#4CC9F0' }); break; } case 'balance': { this.gravity = true; this.hasFloor = true; this.hasWalls = true; const b1 = this.addBody(W * 0.5, fY - 36, 'box'); b1.mass = 10; b1.w = 32 + 10 * 2.4; b1.h = 28 + 10 * 1.8; b1.I = b1.mass * (b1.w * b1.w + b1.h * b1.h) / 12; b1.color = '#7BF5A4'; b1.forces.push({ fx: 60 * S, fy: 0, label: 'F₁', color: '#FFD166' }); b1.forces.push({ fx: -60 * S, fy: 0, label: 'F₂', color: '#4CC9F0' }); b1.forces.push({ fx: 0, fy: -50 * S, label: 'F₃', color: '#EF476F' }); break; } case 'ramp_slide': { this.gravity = true; this.hasFloor = true; this.hasWalls = true; this.ramp = true; this.rampAngle = 30; this.rampMu = 0.15; this._calcRampGeom(); const rg = this._rampGeom; if (rg) { const b1 = this.addBody(0, 0, 'box'); b1.mass = 5; b1.w = 32 + 5 * 2.4; b1.h = 28 + 5 * 1.8; b1.I = b1.mass * (b1.w * b1.w + b1.h * b1.h) / 12; b1.color = '#EF476F'; const rad1 = Math.abs(b1.w / 2 * rg.nx) + Math.abs(b1.h / 2 * rg.ny); const t1 = 0.82; b1.x = rg.x1 + (rg.x2 - rg.x1) * t1 + rg.nx * (rad1 + 2); b1.y = rg.y1 + (rg.y2 - rg.y1) * t1 + rg.ny * (rad1 + 2); } break; } case 'ramp_angle': { this.gravity = true; this.hasFloor = true; this.hasWalls = true; this.ramp = true; this.rampAngle = 45; this.rampMu = 0.30; this._calcRampGeom(); const rg2 = this._rampGeom; if (rg2) { const b1 = this.addBody(0, 0, 'ball'); b1.mass = 8; b1.r = 14 + 8 * 1.6; b1.I = 0.5 * b1.mass * b1.r * b1.r; b1.color = '#4CC9F0'; const t2 = 0.75; b1.x = rg2.x1 + (rg2.x2 - rg2.x1) * t2 + rg2.nx * (b1.r + 2); b1.y = rg2.y1 + (rg2.y2 - rg2.y1) * t2 + rg2.ny * (b1.r + 2); } break; } case 'ramp_friction': { this.gravity = true; this.hasFloor = true; this.hasWalls = true; this.ramp = true; this.rampAngle = 25; this.rampMu = 0.50; this._calcRampGeom(); const rg3 = this._rampGeom; if (rg3) { const b1 = this.addBody(0, 0, 'box'); b1.mass = 6; b1.w = 32 + 6 * 2.4; b1.h = 28 + 6 * 1.8; b1.I = b1.mass * (b1.w * b1.w + b1.h * b1.h) / 12; b1.color = '#FFD166'; const rad3 = Math.abs(b1.w / 2 * rg3.nx) + Math.abs(b1.h / 2 * rg3.ny); b1.x = rg3.x1 + (rg3.x2 - rg3.x1) * 0.8 + rg3.nx * (rad3 + 2); b1.y = rg3.y1 + (rg3.y2 - rg3.y1) * 0.8 + rg3.ny * (rad3 + 2); } break; } case 'atwood': { // Машина Атвуда: m1 ≠ m2, нить через неподвижный блок this.gravity = true; this.hasFloor = true; this.hasWalls = false; const px = W * 0.5, py = H * 0.06; const arm = H * 0.48; const b1 = this.addBody(px - 55, py + arm, 'ball'); b1.mass = 3; b1.r = 14 + 3 * 1.6; b1.I = 0.5 * b1.mass * b1.r * b1.r; b1.color = '#4CC9F0'; const b2 = this.addBody(px + 55, py + arm - 60, 'ball'); b2.mass = 8; b2.r = 14 + 8 * 1.6; b2.I = 0.5 * b2.mass * b2.r * b2.r; b2.color = '#EF476F'; this.addRope(b1.id, b2.id, { type: 'pulley', px, py }); break; } case 'two_body': { // Два тела на нити через блок: одно на горизонтальной плоскости this.gravity = true; this.hasFloor = true; this.hasWalls = false; const fY = this._floorY; const px = W * 0.75, py = fY; // блок у края стола const b1 = this.addBody(W * 0.33, fY - 28, 'box'); b1.mass = 5; b1.w = 32 + 5 * 2.4; b1.h = 28 + 5 * 1.8; b1.I = b1.mass * (b1.w * b1.w + b1.h * b1.h) / 12; b1.color = '#9B5DE5'; b1.mu = 0.05; const b2 = this.addBody(px, fY + 130, 'ball'); b2.mass = 3; b2.r = 14 + 3 * 1.6; b2.I = 0.5 * b2.mass * b2.r * b2.r; b2.color = '#EF476F'; this.addRope(b1.id, b2.id, { type: 'pulley', px, py }); break; } case 'spring_bounce': { // Два шара соединены пружиной — колебания по горизонтали this.gravity = false; this.hasFloor = false; this.hasWalls = true; const a = this.addBody(W * 0.3, H * 0.5, 'ball'); a.mass = 6; a.r = 14 + 6 * 1.6; a.I = 0.5 * a.mass * a.r * a.r; a.color = '#4CC9F0'; a.pinned = true; const b = this.addBody(W * 0.72, H * 0.5, 'ball'); b.mass = 6; b.r = 14 + 6 * 1.6; b.I = 0.5 * b.mass * b.r * b.r; b.color = '#EF476F'; this.addSpring(a.id, b.id, 80, null, 2); break; } case 'spring_chain': { // Цепочка тел, связанных пружинами — волна this.gravity = false; this.hasFloor = false; this.hasWalls = true; const n = 5; let prev = null; for (let i = 0; i < n; i++) { const bx = W * (0.12 + i * 0.16), by = H * 0.5; const bd = this.addBody(bx, by, 'ball'); bd.mass = 4; bd.r = 14 + 4 * 1.6; bd.I = 0.5 * bd.mass * bd.r * bd.r; bd.color = ForceSandboxSim.COLORS[i % ForceSandboxSim.COLORS.length]; if (i === 0) bd.pinned = true; if (prev) this.addSpring(prev.id, bd.id, 100, null, 3); prev = bd; } // Толчок последнего if (prev) prev.vx = -220; break; } case 'pendulum': { // Маятник: закреплённая точка + шар на жёсткой пружине this.gravity = true; this.hasFloor = false; this.hasWalls = true; const anchor = this.addBody(W * 0.5, H * 0.1, 'ball'); anchor.mass = 1; anchor.r = 7; anchor.I = 0.5 * anchor.mass * anchor.r * anchor.r; anchor.color = '#FFD166'; anchor.pinned = true; anchor._isAnchor = true; const bob = this.addBody(W * 0.5 + 170, H * 0.1 + 240, 'ball'); bob.mass = 6; bob.r = 14 + 6 * 1.6; bob.I = 0.5 * bob.mass * bob.r * bob.r; bob.color = '#9B5DE5'; this.addSpring(anchor.id, bob.id, 1800, null, 18); break; } case 'elastic_collision': { // Абсолютно упругое столкновение: m1 = m2, e = 1 this.gravity = false; this.hasFloor = false; this.hasWalls = true; const a = this.addBody(W * 0.2, H * 0.5, 'ball'); a.mass = 5; a.r = 14 + 5 * 1.6; a.I = 0.5 * a.mass * a.r * a.r; a.vx = 160; a.restitution = 1.0; a.color = '#4CC9F0'; const b = this.addBody(W * 0.7, H * 0.5, 'ball'); b.mass = 5; b.r = 14 + 5 * 1.6; b.I = 0.5 * b.mass * b.r * b.r; b.restitution = 1.0; b.color = '#EF476F'; break; } case 'inelastic_collision': { // Абсолютно неупругое столкновение: e = 0 this.gravity = false; this.hasFloor = false; this.hasWalls = true; const a = this.addBody(W * 0.15, H * 0.5, 'ball'); a.mass = 8; a.r = 14 + 8 * 1.6; a.I = 0.5 * a.mass * a.r * a.r; a.vx = 180; a.restitution = 0; a.color = '#4CC9F0'; const b = this.addBody(W * 0.75, H * 0.5, 'ball'); b.mass = 4; b.r = 14 + 4 * 1.6; b.I = 0.5 * b.mass * b.r * b.r; b.vx = -60; b.restitution = 0; b.color = '#EF476F'; break; } case 'newton_cradle': { // Колыбель Ньютона: 5 шаров на пружинных подвесах this.gravity = true; this.hasFloor = false; this.hasWalls = true; const n = 5, gap = 30, anchorY = H * 0.08, bobY = H * 0.55; const startX = W * 0.5 - (n - 1) * gap * 0.5; const balls = []; for (let i = 0; i < n; i++) { const ax = startX + i * gap; const anc = this.addBody(ax, anchorY, 'ball'); anc.mass = 0.5; anc.r = 5; anc.I = 0.5 * anc.mass * anc.r * anc.r; anc.color = '#FFD166'; anc.pinned = true; anc._isAnchor = true; const bl = this.addBody(ax, bobY, 'ball'); bl.mass = 5; bl.r = 14; bl.I = 0.5 * bl.mass * bl.r * bl.r; bl.restitution = 1.0; bl.color = ForceSandboxSim.COLORS[i % 6]; this.addSpring(anc.id, bl.id, 2400, null, 6); balls.push(bl); } // Поднять первый шар влево balls[0].x -= 120; balls[0].y -= 30; break; } case 'harmonic_oscillator': { // Простой гармонический осциллятор: масса на пружине this.gravity = false; this.hasFloor = false; this.hasWalls = true; const anc = this.addBody(W * 0.15, H * 0.5, 'ball'); anc.mass = 0.5; anc.r = 6; anc.I = 0.5 * anc.mass * anc.r * anc.r; anc.color = '#FFD166'; anc.pinned = true; anc._isAnchor = true; const m = this.addBody(W * 0.65, H * 0.5, 'ball'); m.mass = 4; m.r = 14 + 4 * 1.6; m.I = 0.5 * m.mass * m.r * m.r; m.color = '#7BF5A4'; this.addSpring(anc.id, m.id, 60, null, 0.5); // Начальное смещение m.x += 80; break; } case 'double_pendulum': { // Двойной маятник (хаотическое движение) this.gravity = true; this.hasFloor = false; this.hasWalls = true; const ax = W * 0.5, ay = H * 0.1; const anc = this.addBody(ax, ay, 'ball'); anc.mass = 0.5; anc.r = 6; anc.I = 0.5 * anc.mass * anc.r * anc.r; anc.color = '#FFD166'; anc.pinned = true; anc._isAnchor = true; const m1 = this.addBody(ax + 100, ay + 140, 'ball'); m1.mass = 4; m1.r = 14 + 4 * 1.6; m1.I = 0.5 * m1.mass * m1.r * m1.r; m1.color = '#4CC9F0'; const m2 = this.addBody(ax + 180, ay + 280, 'ball'); m2.mass = 3; m2.r = 14 + 3 * 1.6; m2.I = 0.5 * m2.mass * m2.r * m2.r; m2.color = '#EF476F'; // Жёсткие пружины ≈ стержни this.addSpring(anc.id, m1.id, 2200, null, 12); this.addSpring(m1.id, m2.id, 2200, null, 12); break; } case 'coupled_oscillators': { // Связанные осцилляторы: два тела + 3 пружины this.gravity = false; this.hasFloor = false; this.hasWalls = true; const a1 = this.addBody(W * 0.08, H * 0.5, 'ball'); a1.mass = 0.5; a1.r = 6; a1.I = 0.5 * a1.mass * a1.r * a1.r; a1.color = '#FFD166'; a1.pinned = true; a1._isAnchor = true; const a2 = this.addBody(W * 0.92, H * 0.5, 'ball'); a2.mass = 0.5; a2.r = 6; a2.I = 0.5 * a2.mass * a2.r * a2.r; a2.color = '#FFD166'; a2.pinned = true; a2._isAnchor = true; const m1 = this.addBody(W * 0.33, H * 0.5, 'ball'); m1.mass = 5; m1.r = 14 + 5 * 1.6; m1.I = 0.5 * m1.mass * m1.r * m1.r; m1.color = '#4CC9F0'; const m2 = this.addBody(W * 0.67, H * 0.5, 'ball'); m2.mass = 5; m2.r = 14 + 5 * 1.6; m2.I = 0.5 * m2.mass * m2.r * m2.r; m2.color = '#EF476F'; this.addSpring(a1.id, m1.id, 80, null, 1); this.addSpring(m1.id, m2.id, 40, null, 0.5); this.addSpring(m2.id, a2.id, 80, null, 1); // Толчок первого m1.x += 60; break; } case 'stacked_boxes': { // Стопка ящиков: демонстрация нормальных сил this.gravity = true; this.hasFloor = true; this.hasWalls = true; this.floorMu = 0.40; const sizes = [10, 7, 4]; for (let i = 0; i < sizes.length; i++) { const mass = sizes[i]; const bx = this.addBody(W * 0.45, 0, 'box'); bx.mass = mass; bx.w = 32 + mass * 2.4; bx.h = 28 + mass * 1.8; bx.I = bx.mass * (bx.w * bx.w + bx.h * bx.h) / 12; bx.color = ForceSandboxSim.COLORS[i]; bx.y = fY - bx.h * 0.5; for (let j = 0; j < i; j++) { const prev = this.bodies[1 + j * 1]; // не считаем правильно, сделаем проще } } // Расставим вручную от пола вверх const boxes = this.bodies; let curY = fY; for (let i = 0; i < boxes.length; i++) { const bx = boxes[i]; curY -= bx.h * 0.5; bx.y = curY; curY -= bx.h * 0.5 + 1; } break; } case 'pulley_ramp': { // Ящик на рампе + подвес через блок this.gravity = true; this.hasFloor = true; this.hasWalls = false; this.ramp = true; this.rampAngle = 30; this.rampMu = 0.15; this._calcRampGeom(); const rg = this._rampGeom; if (rg) { const bx = this.addBody(0, 0, 'box'); bx.mass = 6; bx.w = 32 + 6 * 2.4; bx.h = 28 + 6 * 1.8; bx.I = bx.mass * (bx.w * bx.w + bx.h * bx.h) / 12; bx.color = '#9B5DE5'; bx.mu = 0.15; const rad = Math.abs(bx.w / 2 * rg.nx) + Math.abs(bx.h / 2 * rg.ny); bx.x = rg.x1 + (rg.x2 - rg.x1) * 0.5 + rg.nx * (rad + 2); bx.y = rg.y1 + (rg.y2 - rg.y1) * 0.5 + rg.ny * (rad + 2); // Блок наверху рампы const px = rg.x2, py = rg.y2 - 20; // Подвешенный шар const bl = this.addBody(rg.x2 + 50, rg.y2 + 100, 'ball'); bl.mass = 3; bl.r = 14 + 3 * 1.6; bl.I = 0.5 * bl.mass * bl.r * bl.r; bl.color = '#EF476F'; this.addRope(bx.id, bl.id, { type: 'pulley', px, py }); } break; } case 'circular_motion': { // Круговое движение: шар на пружине вокруг якоря this.gravity = false; this.hasFloor = false; this.hasWalls = true; const anc = this.addBody(W * 0.5, H * 0.45, 'ball'); anc.mass = 0.5; anc.r = 6; anc.I = 0.5 * anc.mass * anc.r * anc.r; anc.color = '#FFD166'; anc.pinned = true; anc._isAnchor = true; const m = this.addBody(W * 0.5 + 150, H * 0.45, 'ball'); m.mass = 3; m.r = 14 + 3 * 1.6; m.I = 0.5 * m.mass * m.r * m.r; m.color = '#7BF5A4'; // Тангенциальная начальная скорость (вверх) m.vy = -180; this.addSpring(anc.id, m.id, 350, null, 2); break; } case 'projectile_angle': { // Запуск под углом: параболическая траектория this.gravity = true; this.hasFloor = true; this.hasWalls = false; const angle = 45 * Math.PI / 180; const v0 = 280; const bl = this.addBody(W * 0.08, fY - 20, 'ball'); bl.mass = 3; bl.r = 14 + 3 * 1.6; bl.I = 0.5 * bl.mass * bl.r * bl.r; bl.vx = v0 * Math.cos(angle); bl.vy = -v0 * Math.sin(angle); bl.color = '#9B5DE5'; break; } } } /* ── Tick ─────────────────────────────────────────────────── */ _tick(now) { let dt = Math.min((now - this._last) / 1000, 0.05); this._last = now; if (this._paused) { this.draw(); return; } dt *= this.timeScale; this._simTime += dt; this._step(dt); this.draw(); if (this.onUpdate) this.onUpdate(this.info()); } /* ════════════════════════════════════════════════════════════ RIGID-BODY HELPERS ════════════════════════════════════════════════════════════ */ // 4 угла повёрнутого прямоугольника _getCorners(b) { const c = Math.cos(b.angle), s = Math.sin(b.angle); const hw = b.w / 2, hh = b.h / 2; return [ { x: b.x + c * (-hw) - s * (-hh), y: b.y + s * (-hw) + c * (-hh) }, { x: b.x + c * ( hw) - s * (-hh), y: b.y + s * ( hw) + c * (-hh) }, { x: b.x + c * ( hw) - s * ( hh), y: b.y + s * ( hw) + c * ( hh) }, { x: b.x + c * (-hw) - s * ( hh), y: b.y + s * (-hw) + c * ( hh) }, ]; } // Скорость точки тела, заданной смещением (rx, ry) от центра масс _velAtPoint(b, rx, ry) { return { x: b.vx - b.omega * ry, y: b.vy + b.omega * rx }; } // Local-to-world: transform local offset (lx,ly) by body's rotation _localToWorld(b, lx, ly) { if (lx === 0 && ly === 0) return { x: b.x, y: b.y }; const c = Math.cos(b.angle), s = Math.sin(b.angle); return { x: b.x + c * lx - s * ly, y: b.y + s * lx + c * ly }; } // Применить импульс J·(nx,ny) в точке (rx,ry) относительно ЦМ _applyImpulse(b, J, nx, ny, rx, ry) { if (b.pinned) return; b.vx += J * nx / b.mass; b.vy += J * ny / b.mass; b.omega += (rx * ny - ry * nx) * J / b.I; // r × n = torque } // Находит точку на OBB, ближайшую к (px, py) (world space) _closestOnBox(b, px, py) { const cos = Math.cos(-b.angle), sin = Math.sin(-b.angle); const dx = px - b.x, dy = py - b.y; const lx = Math.max(-b.w / 2, Math.min(b.w / 2, cos * dx - sin * dy)); const ly = Math.max(-b.h / 2, Math.min(b.h / 2, sin * dx + cos * dy)); const cos2 = Math.cos(b.angle), sin2 = Math.sin(b.angle); return { x: b.x + cos2 * lx - sin2 * ly, y: b.y + sin2 * lx + cos2 * ly }; } // SAT для двух OBB: возвращает {nx,ny,pen,cx,cy} или null _satTest(a, b) { const ca = this._getCorners(a), cb = this._getCorners(b); const axes = [ { x: Math.cos(a.angle), y: Math.sin(a.angle) }, { x: -Math.sin(a.angle), y: Math.cos(a.angle) }, { x: Math.cos(b.angle), y: Math.sin(b.angle) }, { x: -Math.sin(b.angle), y: Math.cos(b.angle) }, ]; let minPen = Infinity, minAxis = null; for (const ax of axes) { const pA = ca.map(c => c.x * ax.x + c.y * ax.y); const pB = cb.map(c => c.x * ax.x + c.y * ax.y); const minA = Math.min(...pA), maxA = Math.max(...pA); const minB = Math.min(...pB), maxB = Math.max(...pB); const pen = Math.min(maxA, maxB) - Math.max(minA, minB); if (pen <= 0) return null; if (pen < minPen) { minPen = pen; minAxis = { ...ax }; } } // Нормаль от a к b const dab = { x: b.x - a.x, y: b.y - a.y }; if (dab.x * minAxis.x + dab.y * minAxis.y < 0) { minAxis.x = -minAxis.x; minAxis.y = -minAxis.y; } // Контактная точка: среднее по углам b внутри a и углам a внутри b const pts = []; for (const c of cb) { if (this._ptInBox(c, a)) pts.push(c); } for (const c of ca) { if (this._ptInBox(c, b)) pts.push(c); } let cx = (a.x + b.x) / 2, cy = (a.y + b.y) / 2; if (pts.length) { cx = pts.reduce((s, p) => s + p.x, 0) / pts.length; cy = pts.reduce((s, p) => s + p.y, 0) / pts.length; } return { nx: minAxis.x, ny: minAxis.y, pen: minPen, cx, cy }; } _ptInBox(p, box) { const cos = Math.cos(-box.angle), sin = Math.sin(-box.angle); const dx = p.x - box.x, dy = p.y - box.y; return Math.abs(cos * dx - sin * dy) <= box.w / 2 + 0.5 && Math.abs(sin * dx + cos * dy) <= box.h / 2 + 0.5; } /* ════════════════════════════════════════════════════════════ PHYSICS STEP (4 подшага) ════════════════════════════════════════════════════════════ */ _step(dt) { this._strobeTimer += dt; const sub = ForceSandboxSim.SUB_STEPS; const subDt = dt / sub; for (let i = 0; i < sub; i++) this._subStep(subDt); // Стробоскопические следы — один раз за полный кадр if (this._strobeTimer >= 0.12) { this._strobeTimer = 0; for (const b of this.bodies) { if (!this.showTrail || b.pinned) continue; if (Math.hypot(b.vx, b.vy) > 8) { b.trail.push({ x: b.x, y: b.y, a: b.angle }); if (b.trail.length > 40) b.trail.shift(); } } } } _subStep(dt) { const S = ForceSandboxSim.SCALE; const GV = this.gVal * S; // ── Пружинные + верёвочные ускорения ─────────────────────── // k[N/m] * ext[px] / mass[kg] = a[px/s²] (SCALE cancels) const _spAcc = (this.springs.length || this.ropes.length) ? new Map() : null; if (_spAcc) { // Пружины (двусторонние — сжатие и растяжение, с точками крепления) for (const sp of this.springs) { const b1 = this.bodies.find(b => b.id === sp.b1id); const b2 = this.bodies.find(b => b.id === sp.b2id); if (!b1 || !b2) continue; // Мировые точки крепления const p1 = this._localToWorld(b1, sp.lx1, sp.ly1); const p2 = this._localToWorld(b2, sp.lx2, sp.ly2); const dx = p2.x - p1.x, dy = p2.y - p1.y; const dist = Math.hypot(dx, dy); if (dist < 1) continue; const nx = dx / dist, ny = dy / dist; const ext = dist - sp.L0; // Скорости точек крепления (с учётом вращения) const r1x = p1.x - b1.x, r1y = p1.y - b1.y; const r2x = p2.x - b2.x, r2y = p2.y - b2.y; const v1 = this._velAtPoint(b1, r1x, r1y); const v2 = this._velAtPoint(b2, r2x, r2y); const vRel = (v2.x - v1.x) * nx + (v2.y - v1.y) * ny; const F = sp.k * ext + sp.damp * vRel; const Fx = F * nx, Fy = F * ny; if (!b1.pinned) { const e = _spAcc.get(b1.id) || { ax: 0, ay: 0, ta: 0 }; e.ax += Fx / b1.mass; e.ay += Fy / b1.mass; e.ta += (r1x * Fy - r1y * Fx) / b1.I; // момент от нецентральной силы _spAcc.set(b1.id, e); } if (!b2.pinned) { const e = _spAcc.get(b2.id) || { ax: 0, ay: 0, ta: 0 }; e.ax -= Fx / b2.mass; e.ay -= Fy / b2.mass; e.ta -= (r2x * Fy - r2y * Fx) / b2.I; // момент _spAcc.set(b2.id, e); } } // Верёвки/нити (только растяжение — slack = 0 force) for (const rp of this.ropes) { const b1 = this.bodies.find(b => b.id === rp.b1id); const b2 = this.bodies.find(b => b.id === rp.b2id); if (!b1 || !b2) continue; if (rp.type === 'direct') { const dx = b2.x - b1.x, dy = b2.y - b1.y; const dist = Math.hypot(dx, dy); if (dist < 1) continue; const ext = dist - rp.L0; if (ext <= 0) continue; // slack const nx = dx / dist, ny = dy / dist; const vRel = (b2.vx - b1.vx) * nx + (b2.vy - b1.vy) * ny; const T = rp.k * ext + rp.damp * Math.max(0, vRel); if (!b1.pinned) { const e = _spAcc.get(b1.id) || { ax:0, ay:0, ta:0 }; e.ax += T*nx/b1.mass; e.ay += T*ny/b1.mass; _spAcc.set(b1.id, e); } if (!b2.pinned) { const e = _spAcc.get(b2.id) || { ax:0, ay:0, ta:0 }; e.ax -= T*nx/b2.mass; e.ay -= T*ny/b2.mass; _spAcc.set(b2.id, e); } } else { // pulley const d1x = b1.x - rp.px, d1y = b1.y - rp.py; const r1 = Math.hypot(d1x, d1y); const d2x = b2.x - rp.px, d2y = b2.y - rp.py; const r2 = Math.hypot(d2x, d2y); if (r1 < 1 || r2 < 1) continue; const ext = (r1 + r2) - rp.L0; if (ext <= 0) continue; // slack const n1x = d1x/r1, n1y = d1y/r1; // away from pulley b1 const n2x = d2x/r2, n2y = d2y/r2; const vRel = (b1.vx*n1x + b1.vy*n1y) + (b2.vx*n2x + b2.vy*n2y); const T = rp.k * ext + rp.damp * Math.max(0, vRel); if (!b1.pinned) { const e = _spAcc.get(b1.id) || { ax:0, ay:0, ta:0 }; e.ax -= T*n1x/b1.mass; e.ay -= T*n1y/b1.mass; _spAcc.set(b1.id, e); } if (!b2.pinned) { const e = _spAcc.get(b2.id) || { ax:0, ay:0, ta:0 }; e.ax -= T*n2x/b2.mass; e.ay -= T*n2y/b2.mass; _spAcc.set(b2.id, e); } } } } for (const b of this.bodies) { if (b.pinned) { b.vx = b.vy = b.omega = 0; continue; } b._onRamp = false; // ── Интегрирование сил ── let ax = 0, ay = 0; if (this.gravity) ay += GV; for (const f of b.forces) { ax += f.fx / b.mass; ay += f.fy / b.mass; } let omegaAcc = 0; if (_spAcc) { const sf = _spAcc.get(b.id); if (sf) { ax += sf.ax; ay += sf.ay; omegaAcc += sf.ta; } } // Воздушное торможение if (this.airDrag) { const spd = Math.hypot(b.vx, b.vy); if (spd > 1) { const A = b.type === 'box' ? (b.w + b.h) * 0.5 : b.r; const drag = 0.0015 * A * spd * spd; ax -= drag * b.vx / spd / b.mass; ay -= drag * b.vy / spd / b.mass; } } // Velocity Verlet — линейное движение b.vx += ax * dt; b.x += b.vx * dt; b.vy += ay * dt; b.y += b.vy * dt; // Угловое движение: момент от пружин + лёгкое демпфирование (≈7%/с) b.omega += omegaAcc * dt; b.omega *= (1 - 0.07 * dt); b.angle += b.omega * dt; // Кэп скорости (защита от туннелирования) const spd = Math.hypot(b.vx, b.vy); if (spd > 1800) { b.vx = b.vx / spd * 1800; b.vy = b.vy / spd * 1800; } b.omega = Math.max(-35, Math.min(35, b.omega)); // ── Коллизии с поверхностями ── if (this.ramp && this._rampGeom) this._rampCollide(b, dt, GV); this._resolveFloor(b, dt, GV); this._resolveCeiling(b); this._resolveWalls(b); } // Коллизии тел друг с другом this._collide(); // Жёсткий клэмп — гарантия: ни одно тело не уходит за границы if (this.hasFloor) { const fY = this._floorY; for (const b of this.bodies) { if (b.pinned) continue; if (b.type === 'ball') { if (b.y + b.r > fY) { b.y = fY - b.r; if (b.vy > 0) b.vy = 0; } } else { const corners = this._getCorners(b); let maxPen = 0; for (const p of corners) if (p.y - fY > maxPen) maxPen = p.y - fY; if (maxPen > 0) { b.y -= maxPen; if (b.vy > 0) b.vy = 0; } } } } } /* ── Пол ─────────────────────────────────────────────────── */ _resolveFloor(b, dt, GV) { if (!this.hasFloor) return; const S = ForceSandboxSim.SCALE; const fY = this._floorY; if (b.type === 'ball') { const pen = (b.y + b.r) - fY; if (pen <= 0) return; b.y -= pen; const e = b.restitution; if (b.vy > 3) { this._energyLoss += 0.5 * b.mass * b.vy * b.vy * (1 - e * e) / (S * S); b.vy = -b.vy * e; } else { b.vy = 0; } // Трение качения: контактная точка = низ шара const rx = 0, ry = b.r; const vCx = b.vx - b.omega * ry; // скорость точки контакта (горизонт.) if (Math.abs(vCx) > 0.5) { const denomT = 1 / b.mass + ry * ry / b.I; let Jt = -vCx / denomT; const mu = Math.max(b.mu, this.floorMu); const maxJt = mu * b.mass * GV * dt; Jt = Math.sign(Jt) * Math.min(Math.abs(Jt), maxJt); b.vx += Jt / b.mass; b.omega += (-ry) * Jt / b.I; this._energyLoss += Math.abs(Jt * vCx) / (S * S); } return; } // Ящик: многоточечный контакт — усреднение всех углов у пола // (одноточечный контакт всегда брал один угол постоянный крутящий момент) const corners = this._getCorners(b); let maxPen = 0; for (const p of corners) { const dp = p.y - fY; if (dp > maxPen) maxPen = dp; } if (maxPen <= 0) return; // Собираем все углы в пределах 1.5px от максимального проникновения let cntX = 0, cntY = 0, cnt = 0; for (const p of corners) { if (p.y - fY >= maxPen - 1.5) { cntX += p.x - b.x; cntY += p.y - b.y; cnt++; } } // Для плоского ящика оба нижних угла попадают rx0 = 0, нет крутящего момента const rx0 = cntX / cnt; const ry0 = cntY / cnt; // Полная позиционная коррекция (предотвращает уход под пол) b.y -= maxPen; // Скорость угла контакта, нормаль пола = (0, -1) const vC = this._velAtPoint(b, rx0, ry0); const vn = -vC.y; // dot(vC, (0,-1)) if (vn < -2) { // Отскок: J = -(1+e)*vn/denom (vn < 0 J > 0 импульс вверх) const e = b.restitution; const rCrossN = -rx0; // rx*ny - ry*nx = rx0*(-1) - ry0*0 const denom = 1 / b.mass + rCrossN * rCrossN / b.I; const J = -(1 + e) * vn / denom; this._energyLoss += 0.5 * b.mass * vn * vn * (1 - e * e) / (denom * S * S); this._applyImpulse(b, J, 0, -1, rx0, ry0); } else if (vn < 0.5) { // Покой: убираем нормальную составляющую (J = -vn/denom kills sinking) const rCrossN = -rx0; const denom = 1 / b.mass + rCrossN * rCrossN / b.I; const J = -vn / denom; this._applyImpulse(b, J, 0, -1, rx0, ry0); } // Трение скольжения const vC2 = this._velAtPoint(b, rx0, ry0); const vCxt = vC2.x; if (Math.abs(vCxt) > 0.5) { const mu = Math.max(b.mu, this.floorMu); const N = b.mass * GV; const rCrossT = -ry0; // r × t = rx0*0 - ry0*1 const denomT = 1 / b.mass + rCrossT * rCrossT / b.I; let Jt = -vCxt / denomT; const maxJt = mu * N * dt; Jt = Math.sign(Jt) * Math.min(Math.abs(Jt), maxJt); this._applyImpulse(b, Jt, 1, 0, rx0, ry0); this._energyLoss += Math.abs(Jt * vCxt) / (S * S); } } /* ── Потолок ─────────────────────────────────────────────── */ _resolveCeiling(b) { if (!this.hasWalls) return; let top; if (b.type === 'ball') { top = b.y - b.r; } else { top = Math.min(...this._getCorners(b).map(c => c.y)); } if (top < 0) { b.y -= top; if (b.vy < 0) { b.vy = Math.abs(b.vy) * b.restitution; b.omega *= -0.5; } } } /* ── Стены ───────────────────────────────────────────────── */ _resolveWalls(b) { if (!this.hasWalls) return; const S = ForceSandboxSim.SCALE; const { W } = this; if (b.type === 'ball') { if (b.x - b.r < 0) { b.x = b.r; if (b.vx < 0) { this._energyLoss += 0.5 * b.mass * b.vx * b.vx * (1 - b.restitution * b.restitution) / (S * S); b.vx = Math.abs(b.vx) * b.restitution; } } if (b.x + b.r > W) { b.x = W - b.r; if (b.vx > 0) { this._energyLoss += 0.5 * b.mass * b.vx * b.vx * (1 - b.restitution * b.restitution) / (S * S); b.vx = -Math.abs(b.vx) * b.restitution; } } return; } const corners = this._getCorners(b); const leftmost = corners.reduce((m, c) => c.x < m.x ? c : m, corners[0]); const rightmost = corners.reduce((m, c) => c.x > m.x ? c : m, corners[0]); const _wallImpulse = (corner, nx, ny, penFix) => { const rx = corner.x - b.x, ry = corner.y - b.y; b.x -= penFix * nx; // коррекция const vC = this._velAtPoint(b, rx, ry); const vn = vC.x * nx + vC.y * ny; if (vn < -1) { const e = b.restitution; const rCN = rx * ny - ry * nx; const denom = 1 / b.mass + rCN * rCN / b.I; const J = -(1 + e) * vn / denom; this._energyLoss += 0.5 * b.mass * vn * vn * (1 - e * e) / (denom * S * S); this._applyImpulse(b, J, nx, ny, rx, ry); // Трение о стену (вертикальное) const vCt = vC.y; if (Math.abs(vCt) > 0.5) { const ty = Math.sign(vCt); const rCT = rx * ty - ry * 0; const domT = 1 / b.mass + rCT * rCT / b.I; let Jt = -vCt / domT; Jt = Math.sign(Jt) * Math.min(Math.abs(Jt), 0.3 * Math.abs(J)); this._applyImpulse(b, Jt, 0, ty, rx, ry); } } else if (vn < 0) { const rCN = rx * ny - ry * nx; const denom = 1 / b.mass + rCN * rCN / b.I; this._applyImpulse(b, -vn / denom, nx, ny, rx, ry); } }; if (leftmost.x < 0) _wallImpulse(leftmost, 1, 0, leftmost.x); if (rightmost.x > W) _wallImpulse(rightmost, -1, 0, W - rightmost.x); } /* ── Рампа с вращением ───────────────────────────────────── */ _rampCollide(b, dt, GV) { const rg = this._rampGeom; if (!rg) return; const S = ForceSandboxSim.SCALE; const dx = rg.x2 - rg.x1, dy = rg.y2 - rg.y1; let contactX, contactY, pen; const lenSq = rg.len * rg.len; if (b.type === 'ball') { const tRaw = ((b.x - rg.x1) * dx + (b.y - rg.y1) * dy) / lenSq; // Вышел за пределы рампы — отдать полу/потолку if (tRaw < -0.05 || tRaw > 1.05) { b._onRamp = false; return; } const t = Math.max(0, Math.min(1, tRaw)); const px = rg.x1 + t * dx, py = rg.y1 + t * dy; const dist = (b.x - px) * rg.nx + (b.y - py) * rg.ny; if (dist >= b.r || dist < -b.r) { b._onRamp = false; return; } pen = b.r - dist; contactX = px; contactY = py; b._rampT = tRaw; } else { // Проверяем, не вышел ли центр масс за пределы рампы const tCOM = ((b.x - rg.x1) * dx + (b.y - rg.y1) * dy) / lenSq; if (tCOM < -0.15 || tCOM > 1.15) { b._onRamp = false; return; } // Ящик: ищем угол с минимальным signed distance (максимальным проникновением) const corners = this._getCorners(b); let minDist = Infinity, minCorner = null, minTRaw = 0; for (const c of corners) { const tRaw = ((c.x - rg.x1) * dx + (c.y - rg.y1) * dy) / lenSq; const t = Math.max(0, Math.min(1, tRaw)); const px = rg.x1 + t * dx, py = rg.y1 + t * dy; const dist = (c.x - px) * rg.nx + (c.y - py) * rg.ny; if (dist < minDist) { minDist = dist; minCorner = { ...c }; contactX = px; contactY = py; minTRaw = tRaw; } } if (!minCorner || minDist >= 0) { b._onRamp = false; return; } pen = -minDist; if (pen > 30) { b._onRamp = false; return; } b._rampT = minTRaw; } if (pen <= 0) { b._onRamp = false; return; } // Полная коррекция позиции b.x += rg.nx * pen; b.y += rg.ny * pen; // Смещение точки контакта от ЦМ (b.x/b.y уже скорректированы выше) const rx = contactX - b.x; const ry = contactY - b.y; const vC = this._velAtPoint(b, rx, ry); const vn = vC.x * rg.nx + vC.y * rg.ny; if (vn < -1) { // Отскок от рампы const e = b.restitution * 0.45; const rCN = rx * rg.ny - ry * rg.nx; const denom = 1 / b.mass + rCN * rCN / b.I; const J = -(1 + e) * vn / denom; this._energyLoss += 0.5 * b.mass * vn * vn * (1 - e * e) / (denom * S * S); this._applyImpulse(b, J, rg.nx, rg.ny, rx, ry); // Трение при отскоке const vCt = { x: vC.x - vn * rg.nx, y: vC.y - vn * rg.ny }; const vtLen = Math.hypot(vCt.x, vCt.y); if (vtLen > 0.5) { const tx = vCt.x / vtLen, ty = vCt.y / vtLen; const rCT = rx * ty - ry * tx; const domT = 1 / b.mass + rCT * rCT / b.I; let Jt = -vtLen / domT; Jt = Math.sign(Jt) * Math.min(Math.abs(Jt), this.rampMu * Math.abs(J)); this._applyImpulse(b, Jt, tx, ty, rx, ry); } } else { // На поверхности рампы const rCN = rx * rg.ny - ry * rg.nx; const denom = 1 / b.mass + rCN * rCN / b.I; const J_n = -vn / denom; this._applyImpulse(b, J_n, rg.nx, rg.ny, rx, ry); // Трение по рампе (кинетическое / статическое) const vC2 = this._velAtPoint(b, rx, ry); const tx = dx / rg.len, ty = dy / rg.len; const vt = vC2.x * tx + vC2.y * ty; const N = b.mass * GV * rg.cos; const gPar = b.mass * GV * rg.sin; const fFrMax = this.rampMu * N; const rCT = rx * ty - ry * tx; const domT = 1 / b.mass + rCT * rCT / b.I; if (gPar > fFrMax) { let Jt = -vt / domT; const maxJt = this.rampMu * N * dt; Jt = Math.sign(Jt) * Math.min(Math.abs(Jt), maxJt); this._applyImpulse(b, Jt, tx, ty, rx, ry); this._energyLoss += Math.abs(Jt * vt) / (S * S); } else { // Статика: обнуляем касательную скорость в точке контакта const Jt = -vt / domT; this._applyImpulse(b, Jt, tx, ty, rx, ry); } // Качение без проскальзывания для шаров: // v_contact = v_cm + omega × r должна быть 0 // Для шара на рампе: v_t + omega * R = 0 omega = -v_t / R if (b.type === 'ball' && fFrMax >= gPar) { const vC3 = this._velAtPoint(b, rx, ry); const vtC = vC3.x * tx + vC3.y * ty; if (Math.abs(vtC) > 0.3) { // Enforce no-slip: impulse to match omega = -v_tangential / R const rCT2 = rx * ty - ry * tx; const dom2 = 1 / b.mass + rCT2 * rCT2 / b.I; const Jfix = -vtC / dom2; this._applyImpulse(b, Jfix, tx, ty, rx, ry); } } } b._onRamp = true; } /* ════════════════════════════════════════════════════════════ КОЛЛИЗИИ ТЕЛО–ТЕЛО ════════════════════════════════════════════════════════════ */ _collide() { const bodies = this.bodies; for (let i = 0; i < bodies.length; i++) { for (let j = i + 1; j < bodies.length; j++) { const a = bodies[i], b = bodies[j]; if (a.pinned && b.pinned) continue; if (a.type === 'ball' && b.type === 'ball') this._colBallBall(a, b); else if (a.type === 'ball') this._colBallBox(a, b); else if (b.type === 'ball') this._colBallBox(b, a); else this._colBoxBox(a, b); } } } _applyContactImpulse(a, b, nx, ny, cx, cy) { const S = ForceSandboxSim.SCALE; const rAx = cx - a.x, rAy = cy - a.y; const rBx = cx - b.x, rBy = cy - b.y; const vCA = this._velAtPoint(a, rAx, rAy); const vCB = this._velAtPoint(b, rBx, rBy); const dvx = vCA.x - vCB.x, dvy = vCA.y - vCB.y; const dvn = dvx * nx + dvy * ny; if (dvn <= 0) return; const e = Math.min(a.restitution, b.restitution); const rACN = rAx * ny - rAy * nx, rBCN = rBx * ny - rBy * nx; const denom = (a.pinned ? 0 : 1 / a.mass + rACN * rACN / a.I) + (b.pinned ? 0 : 1 / b.mass + rBCN * rBCN / b.I); if (denom < 1e-9) return; const J = (1 + e) * dvn / denom; const keBefore = 0.5 * a.mass * (a.vx * a.vx + a.vy * a.vy) + 0.5 * b.mass * (b.vx * b.vx + b.vy * b.vy); if (!a.pinned) this._applyImpulse(a, -J, nx, ny, rAx, rAy); if (!b.pinned) this._applyImpulse(b, J, nx, ny, rBx, rBy); const keAfter = 0.5 * a.mass * (a.vx * a.vx + a.vy * a.vy) + 0.5 * b.mass * (b.vx * b.vx + b.vy * b.vy); this._energyLoss += Math.max(0, keBefore - keAfter) / (S * S); // Трение между телами const vCA2 = this._velAtPoint(a, rAx, rAy); const vCB2 = this._velAtPoint(b, rBx, rBy); const dv2x = vCA2.x - vCB2.x, dv2y = vCA2.y - vCB2.y; const dvt = dv2x * (-ny) + dv2y * nx; // касательная (perpendicular to n) const tx = -ny, ty = nx; const rACT = rAx * ty - rAy * tx, rBCT = rBx * ty - rBy * tx; const domT = (a.pinned ? 0 : 1 / a.mass + rACT * rACT / a.I) + (b.pinned ? 0 : 1 / b.mass + rBCT * rBCT / b.I); if (domT > 1e-9 && Math.abs(dvt) > 0.5) { const mu = 0.35 * (a.mu + b.mu); let Jt = dvt / domT; Jt = Math.sign(Jt) * Math.min(Math.abs(Jt), mu * J); if (!a.pinned) this._applyImpulse(a, -Jt, tx, ty, rAx, rAy); if (!b.pinned) this._applyImpulse(b, Jt, tx, ty, rBx, rBy); } } _colBallBall(a, b) { const dx = b.x - a.x, dy = b.y - a.y; const dist = Math.hypot(dx, dy); const minD = a.r + b.r; if (dist >= minD || dist < 0.01) return; const nx = dx / dist, ny = dy / dist; const ov = minD - dist; const totM = (a.pinned ? 0 : a.mass) + (b.pinned ? 0 : b.mass); if (!a.pinned) { a.x -= nx * ov * (b.pinned ? 1 : b.mass / totM); a.y -= ny * ov * (b.pinned ? 1 : b.mass / totM); } if (!b.pinned) { b.x += nx * ov * (a.pinned ? 1 : a.mass / totM); b.y += ny * ov * (a.pinned ? 1 : a.mass / totM); } this._applyContactImpulse(a, b, nx, ny, a.x + nx * a.r, a.y + ny * a.r); } _colBallBox(ball, box) { const cp = this._closestOnBox(box, ball.x, ball.y); const dx = ball.x - cp.x, dy = ball.y - cp.y; const dist = Math.hypot(dx, dy); if (dist >= ball.r || dist < 0.001) return; const nx = dx / dist, ny = dy / dist; const pen = ball.r - dist; const totM = (ball.pinned ? 0 : ball.mass) + (box.pinned ? 0 : box.mass); if (!ball.pinned) { ball.x += nx * pen * (box.pinned ? 1 : box.mass / totM); ball.y += ny * pen * (box.pinned ? 1 : box.mass / totM); } if (!box.pinned) { box.x -= nx * pen * (ball.pinned ? 1 : ball.mass / totM); box.y -= ny * pen * (ball.pinned ? 1 : ball.mass / totM); } this._applyContactImpulse(ball, box, nx, ny, cp.x, cp.y); } _colBoxBox(a, b) { const res = this._satTest(a, b); if (!res) return; const { nx, ny, pen, cx, cy } = res; const totM = (a.pinned ? 0 : a.mass) + (b.pinned ? 0 : b.mass); if (!a.pinned) { a.x -= nx * pen * (b.pinned ? 1 : b.mass / totM); a.y -= ny * pen * (b.pinned ? 1 : b.mass / totM); } if (!b.pinned) { b.x += nx * pen * (a.pinned ? 1 : a.mass / totM); b.y += ny * pen * (a.pinned ? 1 : a.mass / totM); } this._applyContactImpulse(a, b, nx, ny, cx, cy); } /* ════════════════════════════════════════════════════════════ EVENTS ════════════════════════════════════════════════════════════ */ _bindEvents() { const c = this.canvas; const sig = { signal: this._evAbort.signal }; c.addEventListener('mousedown', e => this._onDown(e), sig); c.addEventListener('mousemove', e => this._onMove(e), sig); c.addEventListener('mouseup', e => this._onUp(e), sig); c.addEventListener('contextmenu', e => { e.preventDefault(); this._onRightClick(e); }, sig); c.addEventListener('dblclick', e => this._onDblClick(e), sig); c.addEventListener('mouseleave', () => { this._ghostPos = null; this._hovered = null; }, sig); c.addEventListener('touchstart', e => { e.preventDefault(); const t = e.touches[0]; this._onDown({ clientX: t.clientX, clientY: t.clientY, button: 0, shiftKey: false }); }, { passive: false, signal: this._evAbort.signal }); c.addEventListener('touchmove', e => { e.preventDefault(); const t = e.touches[0]; this._onMove({ clientX: t.clientX, clientY: t.clientY }); }, { passive: false, signal: this._evAbort.signal }); c.addEventListener('touchend', e => { e.preventDefault(); this._onUp({}); }, { passive: false, signal: this._evAbort.signal }); } destroy() { this.stop(); this._evAbort.abort(); } _pos(e) { const r = this.canvas.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; } _bodyAt(x, y) { for (let i = this.bodies.length - 1; i >= 0; i--) { const b = this.bodies[i]; if (b.type === 'ball') { if (Math.hypot(x - b.x, y - b.y) < b.r + 4) return b; } else { if (this._ptInBox({ x, y }, b)) return b; } } return null; } _onDown(e) { const { x, y } = this._pos(e); const body = this._bodyAt(x, y); if (this.tool === 'erase') { if (body) this.removeBody(body.id); return; } if (this.tool === 'spring') { if (body) { if (this._springStart === null) { this._springStart = body.id; } else if (this._springStart !== body.id) { this.addSpring(this._springStart, body.id); this._springStart = null; } } else { this._springStart = null; } return; } if (this.tool === 'rope') { if (body) { if (this._ropeStart === null) { this._ropeStart = body.id; } else if (this._ropeStart !== body.id) { this.addRope(this._ropeStart, body.id, { type: 'direct' }); this._ropeStart = null; } } else { this._ropeStart = null; } return; } if (this.tool === 'anchor') { // Создать небольшой закреплённый якорь (для пружин/верёвок) const a = this.addBody(x, y, 'ball'); a.mass = 0.5; a.r = 6; a.I = 0.5 * a.mass * a.r * a.r; a.color = '#FFD166'; a.pinned = true; a._isAnchor = true; return; } if (body) { this._selected = body.id; this._drag = { bodyId: body.id, startX: x, startY: y, curX: x, curY: y, type: (e.shiftKey || this.forceMode === 'impulse') ? 'impulse' : 'force' }; return; } this.addBody(x, y, this.tool); } _onMove(e) { const { x, y } = this._pos(e); this._ghostPos = { x, y }; if (this._drag) { this._drag.curX = x; this._drag.curY = y; return; } const body = this._bodyAt(x, y); this._hovered = body ? body.id : null; } _onUp(e) { if (!this._drag) return; const d = this._drag; const body = this.bodies.find(b => b.id === d.bodyId); this._drag = null; if (!body) return; const dx = d.curX - d.startX, dy = d.curY - d.startY; const len = Math.hypot(dx, dy); if (len < 8) return; const S = ForceSandboxSim.SCALE; const forceMag = len * 2.5; if (d.type === 'impulse') { body.vx += (dx / len) * forceMag * 1.8; body.vy += (dy / len) * forceMag * 1.8; } else { const fx = (dx / len) * forceMag * S, fy = (dy / len) * forceMag * S; const idx = body.forces.length + 1; const fColors = ['#FFD166','#4CC9F0','#7BF5A4','#FF6B35','#EF476F','#9B5DE5']; body.forces.push({ fx, fy, label: `F${idx}`, color: fColors[(idx - 1) % fColors.length] }); } } _onRightClick(e) { const { x, y } = this._pos(e); const body = this._bodyAt(x, y); if (body) { if (body.forces.length > 0) body.forces = []; else this.removeBody(body.id); } } _onDblClick(e) { const { x, y } = this._pos(e); const body = this._bodyAt(x, y); if (body) { body.pinned = !body.pinned; if (body.pinned) { body.vx = body.vy = body.omega = 0; } } } /* ════════════════════════════════════════════════════════════ RENDERING ════════════════════════════════════════════════════════════ */ draw() { const ctx = this.ctx; const { W, H, _floorY: fY } = this; ctx.clearRect(0, 0, W, H); this._drawBg(ctx, W, H); if (this.hasFloor) this._drawFloor(ctx, W, fY); if (this.hasWalls) this._drawWalls(ctx, W, H, fY); if (this.ramp) this._drawRamp(ctx); if (this.showTrail) this._drawTrails(ctx); this._drawRopes(ctx); this._drawSprings(ctx); this._drawBodies(ctx); if (this.showForces) this._drawForceArrows(ctx); if (this.showVelocity) this._drawVelocities(ctx); if (this._drag) this._drawDragArrow(ctx); if (this.showFBD && this._selected !== null) this._drawFBD(ctx); if (this.showEnergy) this._drawEnergyBar(ctx); if (this._ghostPos && !this._drag && !this._hovered && this.tool !== 'erase') this._drawGhost(ctx); if (this.bodies.length === 0) this._drawHint(ctx); } _drawBg(ctx, W, H) { const bg = ctx.createRadialGradient(W / 2, H * 0.3, 0, W / 2, H / 2, W * 0.82); bg.addColorStop(0, '#0d1320'); bg.addColorStop(1, '#050810'); ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); ctx.fillStyle = 'rgba(255,255,255,0.04)'; for (let x = 20; x < W; x += 40) for (let y = 20; y < H; y += 40) { ctx.beginPath(); ctx.arc(x, y, 1.2, 0, Math.PI * 2); ctx.fill(); } } _drawFloor(ctx, W, fY) { const gg = ctx.createLinearGradient(0, fY, 0, fY + 42); gg.addColorStop(0, '#1c1f2d'); gg.addColorStop(1, '#0c101a'); ctx.fillStyle = gg; ctx.fillRect(0, fY, W, 55); ctx.strokeStyle = 'rgba(155,93,229,0.42)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, fY); ctx.lineTo(W, fY); ctx.stroke(); ctx.strokeStyle = 'rgba(255,255,255,0.04)'; ctx.lineWidth = 1; for (let x = 0; x < W; x += 22) { ctx.beginPath(); ctx.moveTo(x, fY); ctx.lineTo(x + 12, fY + 12); ctx.stroke(); } ctx.font = '10px monospace'; ctx.fillStyle = 'rgba(185,210,255,0.35)'; ctx.fillText(`μ = ${this.floorMu.toFixed(2)}`, 8, fY + 18); } _drawWalls(ctx, W, H, fY) { ctx.strokeStyle = 'rgba(76,201,240,0.18)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, fY); ctx.stroke(); ctx.beginPath(); ctx.moveTo(W, 0); ctx.lineTo(W, fY); ctx.stroke(); } _drawTrails(ctx) { for (const b of this.bodies) { if (b.trail.length < 2) continue; for (let i = 0; i < b.trail.length; i++) { const alpha = (i / b.trail.length) * 0.28; const t = b.trail[i]; ctx.save(); ctx.globalAlpha = alpha; if (b.type === 'ball') { ctx.beginPath(); ctx.arc(t.x, t.y, b.r * 0.8, 0, Math.PI * 2); ctx.strokeStyle = b.color; ctx.lineWidth = 1.5; ctx.stroke(); } else { ctx.save(); ctx.translate(t.x, t.y); ctx.rotate(t.a || 0); _fsb_rrect(ctx, -b.w * 0.42, -b.h * 0.42, b.w * 0.84, b.h * 0.84, 5); ctx.strokeStyle = b.color; ctx.lineWidth = 1.5; ctx.stroke(); ctx.restore(); } ctx.restore(); } } } _drawRopes(ctx) { const hasPreview = this.tool === 'rope' && this._ropeStart !== null && this._ghostPos; if (!this.ropes.length && !hasPreview) return; ctx.save(); ctx.lineCap = 'round'; ctx.lineJoin = 'round'; for (const rp of this.ropes) { const b1 = this.bodies.find(b => b.id === rp.b1id); const b2 = this.bodies.find(b => b.id === rp.b2id); if (!b1 || !b2) continue; // Compute tension for color coding let taut = false, T = 0; if (rp.type === 'direct') { const ext = Math.hypot(b2.x - b1.x, b2.y - b1.y) - rp.L0; taut = ext > 0.5; T = taut ? rp.k * ext : 0; } else { const r1 = Math.hypot(b1.x - rp.px, b1.y - rp.py); const r2 = Math.hypot(b2.x - rp.px, b2.y - rp.py); const ext = (r1 + r2) - rp.L0; taut = ext > 0.5; T = taut ? rp.k * ext : 0; } const alpha = taut ? 0.9 : 0.4; const ropeColor = taut ? `rgba(255,209,102,${alpha})` : `rgba(180,180,180,${alpha})`; ctx.strokeStyle = ropeColor; ctx.lineWidth = taut ? 2.5 : 1.5; ctx.shadowColor = taut ? '#FFD166' : 'transparent'; ctx.shadowBlur = taut ? 5 : 0; if (rp.type === 'direct') { ctx.beginPath(); ctx.moveTo(b1.x, b1.y); ctx.lineTo(b2.x, b2.y); ctx.stroke(); } else { // Pulley: draw rope segments + pulley wheel ctx.beginPath(); ctx.moveTo(b1.x, b1.y); ctx.lineTo(rp.px, rp.py); ctx.lineTo(b2.x, b2.y); ctx.stroke(); ctx.shadowBlur = 0; // Pulley wheel ctx.beginPath(); ctx.arc(rp.px, rp.py, 12, 0, Math.PI * 2); ctx.strokeStyle = '#FFD166'; ctx.lineWidth = 2; ctx.stroke(); // Axle ctx.beginPath(); ctx.arc(rp.px, rp.py, 3, 0, Math.PI * 2); ctx.fillStyle = '#FFD166'; ctx.fill(); // Pulley mount to ceiling ctx.strokeStyle = 'rgba(255,209,102,0.5)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(rp.px, rp.py - 12); ctx.lineTo(rp.px, rp.py - 28); ctx.stroke(); ctx.beginPath(); ctx.moveTo(rp.px - 14, rp.py - 28); ctx.lineTo(rp.px + 14, rp.py - 28); ctx.stroke(); } // Tension label if (taut && T > 1) { const S = ForceSandboxSim.SCALE; const TN = (T / S).toFixed(0); const mx = rp.type === 'pulley' ? (b1.x + b2.x) / 2 : (b1.x + b2.x) / 2; const my = rp.type === 'pulley' ? rp.py + 18 : (b1.y + b2.y) / 2 - 12; ctx.shadowBlur = 0; ctx.font = '9px monospace'; ctx.fillStyle = '#FFD166'; ctx.textAlign = 'center'; ctx.fillText(`T≈${TN}Н`, mx, my); ctx.textAlign = 'left'; } } // Preview: dashed line from first body to cursor if (hasPreview) { const b = this.bodies.find(b => b.id === this._ropeStart); if (b) { ctx.shadowBlur = 0; ctx.strokeStyle = 'rgba(255,209,102,0.55)'; ctx.lineWidth = 1.5; ctx.setLineDash([6, 5]); ctx.beginPath(); ctx.moveTo(b.x, b.y); ctx.lineTo(this._ghostPos.x, this._ghostPos.y); ctx.stroke(); ctx.setLineDash([]); const br = b.type === 'ball' ? b.r + 5 : Math.hypot(b.w, b.h) * 0.5 + 5; ctx.beginPath(); ctx.arc(b.x, b.y, br, 0, Math.PI * 2); ctx.strokeStyle = 'rgba(255,209,102,0.5)'; ctx.lineWidth = 2; ctx.stroke(); } } ctx.restore(); } _drawSprings(ctx) { const hasPreview = this.tool === 'spring' && this._springStart !== null && this._ghostPos; if (!this.springs.length && !hasPreview) return; ctx.save(); ctx.lineCap = 'round'; ctx.lineJoin = 'round'; for (const sp of this.springs) { const b1 = this.bodies.find(b => b.id === sp.b1id); const b2 = this.bodies.find(b => b.id === sp.b2id); if (!b1 || !b2) continue; const p1 = this._localToWorld(b1, sp.lx1, sp.ly1); const p2 = this._localToWorld(b2, sp.lx2, sp.ly2); const x1 = p1.x, y1 = p1.y, x2 = p2.x, y2 = p2.y; const dx = x2 - x1, dy = y2 - y1; const dist = Math.hypot(dx, dy); if (dist < 4) continue; const ux = dx / dist, uy = dy / dist; const px = -uy, py = ux; // Color: cyan = relaxed, red = stretched, blue = compressed const strain = Math.max(-1.5, Math.min(1.5, (dist - sp.L0) / Math.max(sp.L0, 1))); let cr, cg, cb; if (strain >= 0) { cr = Math.round(6 + strain / 1.5 * 239); cg = Math.round(214 - strain / 1.5 * 214); cb = Math.round(224 - strain / 1.5 * 100); } else { cr = 6; cg = Math.round(214 + Math.abs(strain) / 1.5 * 41); cb = 224; } ctx.strokeStyle = `rgba(${cr},${cg},${cb},0.9)`; ctx.lineWidth = 2; ctx.shadowColor = `rgb(${cr},${cg},${cb})`; ctx.shadowBlur = 6; // Zigzag coil rendering const COILS = 8; const headLen = Math.min(dist * 0.08, 16); const zigDist = dist - 2 * headLen; const amp = Math.max(3, Math.min(14, 10 / (1 + Math.abs(strain) * 3))); ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x1 + ux * headLen, y1 + uy * headLen); for (let i = 0; i < COILS * 2; i++) { const frac = (i + 0.5) / (COILS * 2); const along = headLen + frac * zigDist; const side = (i % 2 === 0) ? amp : -amp; ctx.lineTo(x1 + ux * along + px * side, y1 + uy * along + py * side); } ctx.lineTo(x2 - ux * headLen, y2 - uy * headLen); ctx.lineTo(x2, y2); ctx.stroke(); // Label ctx.shadowBlur = 0; ctx.font = '9px monospace'; ctx.fillStyle = `rgba(${cr},${cg},${cb},0.85)`; ctx.textAlign = 'center'; const extM = (dist - sp.L0) / ForceSandboxSim.SCALE; ctx.fillText(`k=${sp.k} · ${extM >= 0 ? '+' : ''}${extM.toFixed(2)}м`, (x1 + x2) / 2 - py * 18, (y1 + y2) / 2 + px * 18); ctx.textAlign = 'left'; } // Preview: dashed line from first body to cursor if (hasPreview) { const b = this.bodies.find(b => b.id === this._springStart); if (b) { ctx.shadowBlur = 0; ctx.strokeStyle = 'rgba(6,214,224,0.6)'; ctx.lineWidth = 1.5; ctx.setLineDash([6, 5]); ctx.beginPath(); ctx.moveTo(b.x, b.y); ctx.lineTo(this._ghostPos.x, this._ghostPos.y); ctx.stroke(); ctx.setLineDash([]); // Highlight start body const br = b.type === 'ball' ? b.r + 5 : Math.hypot(b.w, b.h) * 0.5 + 5; ctx.beginPath(); ctx.arc(b.x, b.y, br, 0, Math.PI * 2); ctx.strokeStyle = 'rgba(6,214,224,0.5)'; ctx.lineWidth = 2; ctx.stroke(); } } ctx.restore(); } _drawBodies(ctx) { for (const b of this.bodies) { const isHover = b.id === this._hovered; const isSel = b.id === this._selected; ctx.save(); ctx.shadowColor = b._onRamp ? '#06D6E0' : b.color; ctx.shadowBlur = isHover ? 22 : isSel ? 18 : b._onRamp ? 14 : 10; if (b._isAnchor) { // Якорь: маленький ромб с крестиком ctx.shadowBlur = isHover ? 16 : 8; ctx.beginPath(); ctx.moveTo(b.x, b.y - 7); ctx.lineTo(b.x + 7, b.y); ctx.lineTo(b.x, b.y + 7); ctx.lineTo(b.x - 7, b.y); ctx.closePath(); ctx.fillStyle = b.color; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.lineWidth = 1.5; ctx.stroke(); ctx.restore(); continue; } if (b.type === 'ball') { ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2); const bg = ctx.createRadialGradient(b.x - b.r * 0.3, b.y - b.r * 0.3, 0, b.x, b.y, b.r); bg.addColorStop(0, _fsb_lighten(b.color, 55)); bg.addColorStop(1, b.color); ctx.fillStyle = bg; ctx.fill(); if (isHover || isSel) { ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.stroke(); } // Индикатор вращения: линия от центра шара if (Math.abs(b.omega) > 0.3) { ctx.shadowBlur = 0; ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(b.x, b.y); ctx.lineTo(b.x + Math.cos(b.angle) * b.r * 0.72, b.y + Math.sin(b.angle) * b.r * 0.72); ctx.stroke(); } } else { // Рисуем повёрнутый прямоугольник ctx.save(); ctx.translate(b.x, b.y); ctx.rotate(b.angle); _fsb_rrect(ctx, -b.w / 2, -b.h / 2, b.w, b.h, 7); const bg = ctx.createLinearGradient(-b.w / 2, -b.h / 2, b.w / 2, b.h / 2); bg.addColorStop(0, _fsb_lighten(b.color, 40)); bg.addColorStop(1, b.color); ctx.fillStyle = bg; ctx.fill(); if (isHover || isSel) { ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.stroke(); } // Точка-индикатор вращения ctx.shadowBlur = 0; ctx.beginPath(); ctx.arc(b.w * 0.28, 0, 4, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.fill(); ctx.restore(); ctx.shadowBlur = 0; } ctx.shadowBlur = 0; ctx.font = 'bold 10px monospace'; ctx.fillStyle = '#fff'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(`${b.mass}кг`, b.x, b.y); ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; if (b.pinned) { ctx.font = '14px sans-serif'; ctx.fillStyle = '#FFD166'; ctx.textAlign = 'center'; const py = b.type === 'ball' ? b.y - b.r - 10 : b.y - b.h / 2 - 10; ctx.fillText('\u25C9', b.x, py); ctx.textAlign = 'left'; } ctx.restore(); } } _drawForceArrows(ctx) { const S = ForceSandboxSim.SCALE; const GV = this.gVal * S; for (const b of this.bodies) { if (b._onRamp && this.ramp) { this._drawRampForceDecomp(ctx, b); for (const f of b.forces) { const fMag = Math.hypot(f.fx, f.fy) / S; const fLen = Math.min(fMag * 2.5, 120); if (fLen < 3) continue; this._arrow(ctx, b.x, b.y, b.x + Math.cos(Math.atan2(f.fy, f.fx)) * fLen, b.y + Math.sin(Math.atan2(f.fy, f.fx)) * fLen, f.color, f.label + '=' + fMag.toFixed(0) + 'Н', 2.2); } continue; } const ancY = b.y; if (this.gravity) { const mg = b.mass * this.gVal; this._arrow(ctx, b.x, ancY, b.x, ancY + Math.min(mg * 2.5, 80), 'rgba(180,180,180,0.45)', 'mg', 1.5); } if (this.hasFloor && this.gravity) { const bottom = b.type === 'box' ? b.y + b.h / 2 : b.y + b.r; if (Math.abs(bottom - this._floorY) < 5 && Math.abs(b.vy) < 8) { const nLen = Math.min(b.mass * this.gVal * 2.5, 80); this._arrow(ctx, b.x, ancY, b.x, ancY - nLen, 'rgba(180,180,180,0.45)', 'N', 1.5); } } for (const f of b.forces) { const fMag = Math.hypot(f.fx, f.fy) / S; const fLen = Math.min(fMag * 2.5, 120); if (fLen < 3) continue; const dir = Math.atan2(f.fy, f.fx); this._arrow(ctx, b.x, ancY, b.x + Math.cos(dir) * fLen, ancY + Math.sin(dir) * fLen, f.color, f.label + '=' + fMag.toFixed(0) + 'Н', 2.2); } if (this.hasFloor && this.gravity) { const bottom = b.type === 'box' ? b.y + b.h / 2 : b.y + b.r; if (Math.abs(bottom - this._floorY) < 5 && Math.abs(b.vx) > 5) { const fFr = Math.max(b.mu, this.floorMu) * b.mass * this.gVal; this._arrow(ctx, b.x, ancY, b.x - Math.sign(b.vx) * Math.min(fFr * 2.5, 70), ancY, 'rgba(239,71,111,0.7)', `Fтр=${fFr.toFixed(0)}`, 1.8); } } } } _drawVelocities(ctx) { const S = ForceSandboxSim.SCALE; for (const b of this.bodies) { const spd = Math.hypot(b.vx, b.vy); const hasV = spd > 10; const hasOmg = Math.abs(b.omega) > 0.15; if (!hasV && !hasOmg) continue; const topY = b.type === 'box' ? b.y - b.h / 2 - 6 : b.y - b.r - 6; const halfW = b.type === 'box' ? b.w / 2 : b.r; if (hasV) { // Вектор скорости v — жёлтый this._arrow(ctx, b.x, topY, b.x + b.vx * 0.22, topY + b.vy * 0.22, '#FFD166', `v=${(spd / S).toFixed(1)}м/с`, 2); // Вектор импульса p = mv — малиновый, смещён вниз от v // Длина пропорциональна m: тяжёлое тело длиннее стрелка const pMag = b.mass * spd / S; // кг·м/с const pScale = Math.min(0.65, 0.22 * b.mass / 5); this._arrow(ctx, b.x, topY + 14, b.x + b.vx * pScale, topY + 14 + b.vy * pScale, '#EF476F', `p=${pMag.toFixed(1)}кг·м/с`, 1.8); } // Угловая скорость ω — фиолетовая метка справа от тела if (hasOmg) { const sym = b.omega > 0 ? '\u21BB' : '\u21BA'; const labX = b.x + halfW + 7; const labY = b.type === 'box' ? b.y - b.h * 0.12 : b.y - b.r * 0.15; ctx.save(); ctx.font = 'bold 9px monospace'; ctx.fillStyle = '#9B5DE5'; ctx.shadowColor = '#9B5DE5'; ctx.shadowBlur = 4; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(`${sym} ω=${Math.abs(b.omega).toFixed(1)}рад/с`, labX, labY); ctx.shadowBlur = 0; ctx.textBaseline = 'alphabetic'; ctx.restore(); } } } _drawDragArrow(ctx) { const d = this._drag; if (!d) return; const body = this.bodies.find(b => b.id === d.bodyId); if (!body) return; const dx = d.curX - d.startX, dy = d.curY - d.startY; if (Math.hypot(dx, dy) < 5) return; ctx.save(); ctx.setLineDash([5, 5]); this._arrow(ctx, body.x, body.y, body.x + dx, body.y + dy, d.type === 'impulse' ? '#FF6B35' : '#FFD166', d.type === 'impulse' ? 'импульс' : 'сила', 2.5); ctx.setLineDash([]); ctx.restore(); } _drawFBD(ctx) { const body = this.bodies.find(b => b.id === this._selected); if (!body) return; const S = ForceSandboxSim.SCALE; const cx = this.W - 120, cy = 80, r = 55; _fsb_rrect(ctx, cx - r - 10, cy - r - 10, (r + 10) * 2, (r + 10) * 2 + 28, 8); ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1; ctx.stroke(); ctx.font = 'bold 9px monospace'; ctx.fillStyle = 'rgba(185,210,255,0.7)'; ctx.textAlign = 'center'; ctx.fillText('Диаграмма сил', cx, cy - r - 2); ctx.textAlign = 'left'; ctx.beginPath(); ctx.arc(cx, cy, 4, 0, Math.PI * 2); ctx.fillStyle = body.color; ctx.fill(); const forces = []; if (this.gravity) forces.push({ fx: 0, fy: body.mass * this.gVal, label: 'mg', color: 'rgba(180,180,180,0.7)' }); const bottom = body.type === 'box' ? body.y + body.h / 2 : body.y + body.r; if (this.hasFloor && Math.abs(bottom - this._floorY) < 5 && Math.abs(body.vy) < 8) forces.push({ fx: 0, fy: -body.mass * this.gVal, label: 'N', color: 'rgba(180,180,180,0.7)' }); for (const f of body.forces) forces.push({ fx: f.fx / S, fy: f.fy / S, label: f.label, color: f.color }); const maxF = Math.max(...forces.map(f => Math.hypot(f.fx, f.fy)), 1); for (const f of forces) { const len = (Math.hypot(f.fx, f.fy) / maxF) * (r - 8); if (len < 2) continue; const dir = Math.atan2(f.fy, f.fx); this._arrow(ctx, cx, cy, cx + Math.cos(dir) * len, cy + Math.sin(dir) * len, f.color, f.label, 1.8); } let sfx = 0, sfy = 0; for (const f of forces) { sfx += f.fx; sfy += f.fy; } const smag = Math.hypot(sfx, sfy); if (smag > 0.5) { const len = (smag / maxF) * (r - 8); ctx.save(); ctx.setLineDash([3, 3]); this._arrow(ctx, cx, cy, cx + Math.cos(Math.atan2(sfy, sfx)) * len, cy + Math.sin(Math.atan2(sfy, sfx)) * len, '#fff', `ΣF=${smag.toFixed(0)}`, 2); ctx.setLineDash([]); ctx.restore(); } // ω и p под диаграммой const spd = Math.hypot(body.vx, body.vy); ctx.font = '8px monospace'; ctx.textAlign = 'center'; ctx.fillStyle = '#EF476F'; ctx.fillText(`p=${(body.mass * spd / S).toFixed(1)} кг·м/с`, cx, cy + r + 12); if (Math.abs(body.omega) > 0.05) { const sym = body.omega > 0 ? '\u21BB' : '\u21BA'; ctx.fillStyle = '#9B5DE5'; ctx.fillText(`${sym} ω=${Math.abs(body.omega).toFixed(2)} рад/с`, cx, cy + r + 22); } ctx.textAlign = 'left'; } _drawEnergyBar(ctx) { if (!this.bodies.length) return; const S = ForceSandboxSim.SCALE, fY = this._floorY; let KE = 0, PE = 0; for (const b of this.bodies) { const v = Math.hypot(b.vx, b.vy) / S; KE += 0.5 * b.mass * v * v; // Вращательная KE: ½Iω² (I в px², переводим в м²) KE += 0.5 * (b.I / (S * S)) * b.omega * b.omega; if (this.gravity && this.hasFloor) { const bot = b.type === 'box' ? b.y + b.h / 2 : b.y + b.r; PE += b.mass * this.gVal * Math.max(0, fY - bot) / S; } } const total = KE + PE + this._energyLoss; if (total < 0.01) return; const bx = 12, by = 12, bw = 110, bh = 52; _fsb_rrect(ctx, bx, by, bw, bh, 6); ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.1)'; ctx.lineWidth = 1; ctx.stroke(); ctx.font = 'bold 8px monospace'; ctx.fillStyle = 'rgba(185,210,255,0.55)'; ctx.fillText('Энергия', bx + 4, by + 10); const barX = bx + 4, barY = by + 16, barW = bw - 8, barH = 8; const keW = (KE / total) * barW, peW = (PE / total) * barW; const lossW = barW - keW - peW; ctx.fillStyle = '#4CC9F0'; if (keW > 0) { _fsb_rrect(ctx, barX, barY, Math.max(keW, 1), barH, 2); ctx.fill(); } ctx.fillStyle = '#7BF5A4'; if (peW > 0) { _fsb_rrect(ctx, barX + keW, barY, Math.max(peW, 1), barH, 2); ctx.fill(); } ctx.fillStyle = '#EF476F'; if (lossW > 0.5) { _fsb_rrect(ctx, barX + keW + peW, barY, Math.max(lossW, 1), barH, 2); ctx.fill(); } ctx.font = '8px monospace'; ctx.fillStyle = '#4CC9F0'; ctx.fillText(`KE=${KE.toFixed(1)}Дж`, bx + 4, barY + barH + 10); ctx.fillStyle = '#7BF5A4'; ctx.fillText(`PE=${PE.toFixed(1)}`, bx + 4, barY + barH + 20); ctx.fillStyle = '#EF476F'; ctx.fillText(`Q=${this._energyLoss.toFixed(1)}`, bx + 56, barY + barH + 10); } /* ── Рампа ───────────────────────────────────────────────── */ _drawRamp(ctx) { const rg = this._rampGeom; if (!rg) return; const { _floorY: fY } = this; const tx = (rg.x2 - rg.x1) / rg.len, ty = (rg.y2 - rg.y1) / rg.len; ctx.save(); // Заливка ctx.beginPath(); ctx.moveTo(rg.x1, rg.y1); ctx.lineTo(rg.x2, rg.y2); ctx.lineTo(rg.x2, fY); ctx.closePath(); const gg = ctx.createLinearGradient(rg.x1, fY, rg.x2, rg.y2); gg.addColorStop(0, 'rgba(6,214,224,0.07)'); gg.addColorStop(1, 'rgba(6,214,224,0.02)'); ctx.fillStyle = gg; ctx.fill(); // Линия поверхности ctx.strokeStyle = '#06D6E0'; ctx.lineWidth = 2.5; ctx.shadowColor = '#06D6E0'; ctx.shadowBlur = 8; ctx.beginPath(); ctx.moveTo(rg.x1, rg.y1); ctx.lineTo(rg.x2, rg.y2); ctx.stroke(); ctx.shadowBlur = 0; // Вспомогательные линии ctx.strokeStyle = 'rgba(6,214,224,0.25)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(rg.x1, fY); ctx.lineTo(rg.x2, fY); ctx.stroke(); ctx.beginPath(); ctx.moveTo(rg.x2, rg.y2); ctx.lineTo(rg.x2, fY); ctx.stroke(); // Дуга угла ctx.strokeStyle = '#FFD166'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(rg.x1, rg.y1, 35, -rg.angle, 0); ctx.stroke(); ctx.font = 'bold 11px monospace'; ctx.fillStyle = '#FFD166'; ctx.textAlign = 'center'; ctx.fillText(`α=${this.rampAngle}°`, rg.x1 + 35 * 1.15 * Math.cos(-rg.angle / 2), rg.y1 + 35 * 1.15 * Math.sin(-rg.angle / 2)); ctx.textAlign = 'left'; // Прямой угол у основания const rm = 12; ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(rg.x2 - rm, fY); ctx.lineTo(rg.x2 - rm, fY - rm); ctx.lineTo(rg.x2, fY - rm); ctx.stroke(); // Штриховка (В твёрдый материал — противоположно нормали) ctx.strokeStyle = 'rgba(6,214,224,0.10)'; ctx.lineWidth = 1; for (let d = 20; d < rg.len; d += 22) { const sx = rg.x1 + tx * d, sy = rg.y1 + ty * d; ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(sx - rg.nx * 11, sy - rg.ny * 11); ctx.stroke(); } // μ-метка над поверхностью ctx.font = '10px monospace'; ctx.fillStyle = 'rgba(6,214,224,0.55)'; ctx.save(); ctx.translate(rg.x1 + tx * rg.len * 0.5 + rg.nx * 20, rg.y1 + ty * rg.len * 0.5 + rg.ny * 20); ctx.rotate(Math.atan2(ty, tx)); ctx.fillText(`μ = ${this.rampMu.toFixed(2)}`, 0, 0); ctx.restore(); // Свечение в точках контакта for (const b of this.bodies) { if (!b._onRamp || b._rampT === undefined) continue; const cx = rg.x1 + tx * rg.len * b._rampT; const cy = rg.y1 + ty * rg.len * b._rampT; const grd = ctx.createRadialGradient(cx, cy, 0, cx, cy, 20); grd.addColorStop(0, 'rgba(6,214,224,0.38)'); grd.addColorStop(1, 'rgba(6,214,224,0)'); ctx.beginPath(); ctx.arc(cx, cy, 20, 0, Math.PI * 2); ctx.fillStyle = grd; ctx.fill(); } ctx.restore(); } /* ── Разложение сил на рампе ─────────────────────────────── */ _drawRampForceDecomp(ctx, b) { if (!this.showDecomp || !b._onRamp || !this._rampGeom) return; const rg = this._rampGeom; const mg = b.mass * this.gVal; const tx = (rg.x2 - rg.x1) / rg.len, ty = (rg.y2 - rg.y1) / rg.len; const { nx, ny } = rg; const mgPar = mg * rg.sin, mgPerp = mg * rg.cos; const mgLen = Math.min(mg * 3, 90); const parLen = Math.min(mgPar * 3, 70), perpLen = Math.min(mgPerp * 3, 70); this._arrow(ctx, b.x, b.y, b.x, b.y + mgLen, 'rgba(255,255,255,0.5)', `mg=${mg.toFixed(0)}Н`, 2); this._arrow(ctx, b.x, b.y, b.x - tx * parLen, b.y - ty * parLen, '#EF476F', `mg·sinα=${mgPar.toFixed(0)}`, 1.8); this._arrow(ctx, b.x, b.y, b.x - nx * perpLen, b.y - ny * perpLen, '#4CC9F0', `mg·cosα=${mgPerp.toFixed(0)}`, 1.8); this._arrow(ctx, b.x, b.y, b.x + nx * perpLen, b.y + ny * perpLen, 'rgba(180,180,180,0.5)', `N=${mgPerp.toFixed(0)}`, 1.5); const fFr = this.rampMu * mgPerp; const vt = b.vx * tx + b.vy * ty; if (Math.abs(vt) > 0.5 || mgPar > fFr + 0.1) { const frDir = Math.abs(vt) > 0.5 ? -Math.sign(vt) : 1; this._arrow(ctx, b.x, b.y, b.x + tx * frDir * Math.min(fFr * 3, 55), b.y + ty * frDir * Math.min(fFr * 3, 55), '#FF6B35', `Fтр=${fFr.toFixed(0)}`, 1.8); } const netPar = mgPar - (Math.abs(vt) > 0.5 || mgPar > fFr ? fFr : mgPar); if (netPar > 0.5) { ctx.font = 'bold 10px monospace'; ctx.fillStyle = '#7BF5A4'; ctx.textAlign = 'center'; ctx.fillText(`a = ${(netPar / b.mass).toFixed(1)} м/с²`, b.x, b.y - (b.type === 'box' ? b.h / 2 : b.r) - 20); ctx.textAlign = 'left'; } } _drawGhost(ctx) { if (!this._ghostPos) return; const { x, y } = this._ghostPos; ctx.save(); ctx.globalAlpha = 0.25; if (this.tool === 'ball') { ctx.beginPath(); ctx.arc(x, y, 14 + this.newMass * 1.6, 0, Math.PI * 2); ctx.strokeStyle = '#4CC9F0'; ctx.lineWidth = 2; ctx.stroke(); } else { const w = 32 + this.newMass * 2.4, h = 28 + this.newMass * 1.8; _fsb_rrect(ctx, x - w / 2, y - h / 2, w, h, 7); ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 2; ctx.stroke(); } ctx.restore(); } _drawHint(ctx) { ctx.save(); ctx.font = '14px sans-serif'; ctx.fillStyle = 'rgba(185,210,255,0.3)'; ctx.textAlign = 'center'; ctx.fillText('Кликни — создай тело. Тяни от тела — приложи силу.', this.W / 2, this.H * 0.45); ctx.fillText('Shift+drag = импульс · ПКМ = удалить · DblClick = закрепить', this.W / 2, this.H * 0.45 + 22); ctx.fillText('Инструмент «Пружина» — кликни два тела, чтобы соединить.', this.W / 2, this.H * 0.45 + 44); ctx.textAlign = 'left'; ctx.restore(); } /* ── Arrow helper ────────────────────────────────────────── */ _arrow(ctx, x1, y1, x2, y2, color, label, lw) { const dx = x2 - x1, dy = y2 - y1; const len = Math.hypot(dx, dy); if (len < 4) return; const ux = dx / len, uy = dy / len; const hw = 5, hl = 10; ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = lw || 2; ctx.shadowColor = color; ctx.shadowBlur = 4; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2 - ux * hl, y2 - uy * hl); ctx.stroke(); ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo(x2 - ux * hl - uy * hw, y2 - uy * hl + ux * hw); ctx.lineTo(x2 - ux * hl + uy * hw, y2 - uy * hl - ux * hw); ctx.closePath(); ctx.fill(); if (label) { ctx.shadowBlur = 0; ctx.font = '9px monospace'; ctx.fillStyle = color; ctx.textAlign = 'center'; ctx.fillText(label, (x1 + x2) / 2 - uy * 12, (y1 + y2) / 2 + ux * 12); ctx.textAlign = 'left'; } ctx.restore(); } /* ── Info ────────────────────────────────────────────────── */ info() { const S = ForceSandboxSim.SCALE, fY = this._floorY; let KE = 0, PE = 0; for (const b of this.bodies) { const v = Math.hypot(b.vx, b.vy) / S; KE += 0.5 * b.mass * v * v + 0.5 * (b.I / (S * S)) * b.omega * b.omega; if (this.gravity && this.hasFloor) { const bot = b.type === 'box' ? b.y + b.h / 2 : b.y + b.r; PE += b.mass * this.gVal * Math.max(0, fY - bot) / S; } } let netF = '—'; if (this._selected !== null) { const body = this.bodies.find(b => b.id === this._selected); if (body) { let fx = 0, fy = 0; if (this.gravity) fy += body.mass * this.gVal; for (const f of body.forces) { fx += f.fx / S; fy += f.fy / S; } netF = Math.hypot(fx, fy).toFixed(1) + ' Н'; } } return { bodies: this.bodies.length, springs: this.springs.length, ropes: this.ropes.length, KE: KE.toFixed(1), PE: PE.toFixed(1), loss: this._energyLoss.toFixed(1), netF, time: this._simTime.toFixed(1) }; } } /* ── Utilities ───────────────────────────────────────────────── */ function _fsb_rrect(ctx, x, y, w, h, r) { if (w <= 0 || h <= 0) return; r = Math.min(r, w / 2, h / 2); ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } function _fsb_lighten(hex, d) { const n = parseInt(hex.slice(1), 16); const c = v => Math.max(0, Math.min(255, v)); return `rgb(${c((n >> 16) + d)},${c(((n >> 8) & 255) + d)},${c((n & 255) + d)})`; }