Files
Learn_System/frontend/js/labs/forcesandbox.js
T
Maxim Dolgolyov 6afe928c0d feat(labs): visual polish wave — LabFX foundation + 33 sims juiced up
ФУНДАМЕНТ (4 новых файла):
- _fx_core.js: LabFX namespace, glow.drawGlow, glow.pulse, haptic, shake
- _fx_particles.js: пул 1500 объектов, 6 shapes (dot/spark/ring/smoke/splash/dust)
- _fx_motion.js: tween + 12 easings + critically-damped spring
- _fx_sound.js: 9 procedural synth-звуков (click/tick/whoosh/chime/fizz/spark/bounce/pour/drone), Web Audio API
- Sound toggle в шапке lab.html с localStorage-persist

UX МИКРО (CSS + JS):
- Button states: hover scale+brightness, active scale-down, disabled grayscale
- Slider polish: custom thumb с тенью, filled-track gradient, hover/active
- Focus rings через :focus-visible
- Tooltip system .tt-host data-tt= с 400ms hover, fade-in
- Marching ants для selection
- Loading skeleton с shimmer
- Empty state .sim-empty-* паттерн
- Toast: progress bar внизу, icons по типу
- Cursor states utility classes
- View Transitions API для smooth sim-switch, fallback на CSS fade

PHASE 2 — визуальные эффекты для 33 симуляций:

Physics motion: projectile (launch whoosh + landing splash/shake/haptic + target chime), pendulum (max-extension tick + bob glow), collision (bounce + sparks + shake), angrybirds (whoosh/bounce/fizz/chime + confetti), newton (rocket flame trail + scene transitions), forcesandbox (spring glow + impact sparks)

Physics fields: emfield (field-lines glow + particle trail + lightning при high field + Gauss-drag haptic + rod motion sparks), circuit (energized-wire glow ∝ current + LED bloom + short-circuit shake/spark + heat shimmer smoke + place/erase/switch sounds), opticsbench (beam glow на всех режимах + caustics dust у фокуса + TIR/Brewster one-shot sounds)

Thermo+waves: waves (mode-switch whoosh + Mach-cone particles + spectrum harmonic chime + waveform glow), hydrostatics (pour sound + splash при погружении + valve click), isoprocess (PV-trail dust), heatengine (drone цикла + hot/cold reservoir smoke/dust + phase-change ticks), radioactive (Geiger tick throttle + decay sparks + half-life chime + α/β/γ glow)

Chemistry: chemsandbox (pour splash + fizz bubbles/dust/spark по типу реакции + shake при горении), equilibrium/electrolysis/reactions/titration/flask/ionexchange/redox (pour/fizz/spark/chime по событиям, частицы при ключевых моментах), stoichiometry (fizz bubbles + recipe-change click)

Math+geom: geometry (tool-click + object-create tick + locus glow + challenge confetti via LabFX + haptic), triangle (vertex-drop tick + special-point glow), stereo (figure-change whoosh + cross-section chime, no particles — Three.js), trigcircle (drag-haptic + pitch∝angle tick + sin/cos glow), graph/graphtransform/quadratic (slider-tick throttled + curve glow + discriminant-cross chime), probability (bounce + finish-chime burst), normaldist (pulsing shade + bell glow)

Bio+misc: celldivision (phase-change whoosh + prophase dust + anaphase poles spark + cytokinesis chime), photosynthesis (photon tick + ATP chime + glucose sparkle), bohratom (electron-jump chime ∝ levels + photon spark), orbitals/crystal (mode-change whoosh — Three.js, sound only), logic (gate-place + wire-connect + LED bloom + HIGH wire flowing dots + preset chime), gas/brownian/diffusion/states (preset whoosh, throttled tick по событиям)

Все LabFX вызовы обёрнуты в if (window.LabFX) guard — graceful degradation если фундамент не загружен. 47 JS файлов прошли syntax check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:58:49 +03:00

2118 lines
91 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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);
/* LabFX: spawn sound */
if (window.LabFX) LabFX.sound.play('click', { pitch: 1.3 });
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 rawDt = Math.min((now - this._last) / 1000, 0.05);
this._last = now;
if (window.LabFX) LabFX.particles.update(rawDt);
if (this._paused) { this.draw(); return; }
const dt = rawDt * 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 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 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;
}
// Ящик: многоточечный контакт — усреднение всех углов у пола
// (одноточечный контакт всегда брал один угол <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> постоянный крутящий момент)
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++; }
}
// Для плоского ящика оба нижних угла попадают <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 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 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> J > 0 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> импульс вверх)
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 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 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 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 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);
/* LabFX: body collision */
if (window.LabFX && J > 0.5) {
LabFX.sound.play('bounce');
LabFX.particles.emit({
ctx: this.ctx, x: cx, y: cy,
count: 8, color: '#FFF', speed: 60,
spread: Math.PI * 2, life: 300, shape: 'spark', glow: true,
});
}
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);
if (window.LabFX && this.springs.length > 0) {
LabFX.glow.drawGlow(ctx, () => this._drawSprings(ctx), { color: '#9B5DE5', intensity: 4 });
} else {
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);
/* LabFX: particles overlay */
if (window.LabFX) LabFX.particles.draw(this.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: тяжёлое тело <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> длиннее стрелка
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)})`;
}