'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;
/* FBD toggle — when true all forces drawn via LSPhysFX colours */
this._fbdOn = false;
/* ── Universal energy bars ── */
this._energyOn = false;
this._energyScaleFSB = 0;
/* -- GraphPanel widget -- */
this._graphsOn = false;
this._graphUI = null;
this._graphBodyIdx = 0;
/* ── TimeControl ── */
this._tc = window.LSTimeControl ? new window.LSTimeControl(this) : 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: [],
_trail2: window.LSMotionTrail ? new window.LSMotionTrail({ color, width: 2.5, maxLen: 100 }) : null,
};
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; }
/* TimeControl: scale + pause (wraps existing timeScale) */
let dt;
if (this._tc) {
dt = this._tc.advance(rawDt * this.timeScale);
if (dt === 0) { this.draw(); if (this.onUpdate) this.onUpdate(this.info()); return; }
} else {
dt = rawDt * this.timeScale;
}
this._simTime += dt;
this._step(dt);
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
if (window.LSGraphPanel && this._graphsOn && this._graphUI && this.bodies.length > 0) {
const b = this.bodies[this._graphBodyIdx] || this.bodies[0];
const S = ForceSandboxSim.SCALE;
if (b && !b.pinned) {
const spd = Math.hypot(b.vx, b.vy) / S;
const dspd = this._gpPrevSpd != null ? (spd - this._gpPrevSpd) / Math.max(dt, 1e-6) : 0;
this._gpPrevSpd = spd;
this._graphUI.push(this._simTime, [b.x / S, spd, dspd]);
}
}
}
/* ════════════════════════════════════════════════════════════
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 (b.pinned) continue;
if (this.showTrail && 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();
}
/* LSMotionTrail: continuous update regardless of speed threshold */
if (b._trail2) {
b._trail2.tick();
if (Math.hypot(b.vx, b.vy) > 2) b._trail2.push(b.x, b.y);
}
}
}
}
_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);
/* 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);
/* LSMotionTrail overlay for each body */
for (const b of this.bodies) { if (b._trail2) b._trail2.draw(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._fbdOn) this._pv_drawAllForces(ctx);
if (this.showEnergy) this._drawEnergyBar(ctx);
/* universal energy bars if enabled via toggle */
if (this._energyOn && window.LSPhysFX) this._drawEnergyBarsFSB(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);
}
/* ── Universal energy bars: forcesandbox ── */
_drawEnergyBarsFSB(ctx) {
if (!this.bodies.length) return;
const S = ForceSandboxSim.SCALE, fY = this._floorY;
let KE = 0, PE = 0, EL = 0;
for (const b of this.bodies) {
const v = Math.hypot(b.vx, b.vy) / S;
KE += 0.5 * b.mass * v * v;
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;
}
/* elastic PE from springs */
for (const sp of this.springs) {
if (sp.a === b.id || sp.b === b.id) {
const ba = this.bodies.find(x => x.id === sp.a);
const bb = this.bodies.find(x => x.id === sp.b);
if (ba && bb) {
const dx = (bb.x - ba.x) / S, dy = (bb.y - ba.y) / S;
const dist = Math.sqrt(dx*dx + dy*dy);
const dl = dist - (sp.restLen || 0.1);
EL += 0.25 * sp.k * dl * dl; /* shared 50/50 per endpoint */
}
}
}
}
var fr = this._energyLoss;
var tot = KE + PE + EL + fr;
if (tot > this._energyScaleFSB) this._energyScaleFSB = tot;
const PW = 188, MARGIN = 12;
/* place in top-right but offset down if existing bar visible */
const oy = this.showEnergy ? 72 : MARGIN;
LSPhysFX.drawEnergyBars(ctx, this.W - PW - MARGIN, oy, PW, 0,
{ ke: KE, pe: PE, elastic: EL, friction: fr, total: this._energyScaleFSB }, {});
}
_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;
}
const spColor = `rgba(${cr},${cg},${cb},0.9)`;
const amp = Math.max(3, Math.min(14, 10 / (1 + Math.abs(strain) * 3)));
/* use LSPhysFX.drawSpring if available, else fallback */
if (window.LSPhysFX) {
ctx.save();
ctx.shadowColor = `rgb(${cr},${cg},${cb})`; ctx.shadowBlur = 6;
LSPhysFX.drawSpring(ctx, x1, y1, x2, y2, {
coils: 8, amp: amp, color: spColor, lineWidth: 2,
});
ctx.restore();
} else {
const COILS = 8;
const headLen = Math.min(dist * 0.08, 16);
const zigDist = dist - 2 * headLen;
ctx.strokeStyle = spColor; ctx.lineWidth = 2;
ctx.shadowColor = `rgb(${cr},${cg},${cb})`; ctx.shadowBlur = 6;
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';
}
/* ── _pv_drawAllForces: unified FBD via LSPhysFX ───────────── */
_pv_drawAllForces(ctx) {
if (!window.LSPhysFX) return;
const S = ForceSandboxSim.SCALE;
for (const b of this.bodies) {
const cx = b.x, cy = b.type === 'box' ? b.y : b.y;
/* gravity */
if (this.gravity) {
const mg = b.mass * this.gVal;
LSPhysFX.drawForceArrow(ctx, cx, cy, 0, Math.min(60, mg * 2.5), 'gravity',
'mg=' + mg.toFixed(0) + 'Н');
/* normal from floor */
const bottom = b.type === 'box' ? b.y + b.h / 2 : b.y + b.r;
if (this.hasFloor && Math.abs(bottom - this._floorY) < 6 && Math.abs(b.vy) < 8) {
LSPhysFX.drawForceArrow(ctx, cx, cy, 0, -Math.min(60, mg * 2.5), 'normal',
'N=' + mg.toFixed(0) + 'Н');
/* kinetic friction */
if (Math.abs(b.vx) > 10) {
const fFr = Math.max(b.mu, this.floorMu) * mg;
LSPhysFX.drawForceArrow(ctx, cx, cy,
-Math.sign(b.vx) * Math.min(50, fFr * 2), 0,
'friction', 'Fтр=' + fFr.toFixed(0) + 'Н');
}
}
}
/* spring forces */
for (const sp of this.springs) {
const other = (sp.b1id === b.id) ? this.bodies.find(bb => bb.id === sp.b2id)
: (sp.b2id === b.id ? this.bodies.find(bb => bb.id === sp.b1id) : null);
if (!other) continue;
const dx = other.x - b.x, dy = other.y - b.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const ext = dist - sp.L0;
if (Math.abs(ext) < 0.5) continue;
const fMag = sp.k * ext;
const fLen = Math.min(50, Math.abs(fMag) / S * 3 + 10);
LSPhysFX.drawForceArrow(ctx, cx, cy,
(dx / dist) * fLen * Math.sign(ext),
(dy / dist) * fLen * Math.sign(ext),
'elastic', 'Fупр=' + Math.abs(fMag / S).toFixed(0) + 'Н');
}
/* applied forces */
for (const f of b.forces) {
const fMag = Math.hypot(f.fx, f.fy) / S;
const fLen = Math.min(60, fMag * 2.5);
if (fLen < 3) continue;
const dir = Math.atan2(f.fy, f.fx);
LSPhysFX.drawForceArrow(ctx, cx, cy,
Math.cos(dir) * fLen, Math.sin(dir) * fLen,
'applied', (f.label || 'F') + '=' + fMag.toFixed(0) + 'Н');
}
}
}
_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) };
}
}
/* ── Energy toggle: forcesandbox ── */
function fsbToggleEnergy() {
if (!sbSim) return;
sbSim._energyOn = !sbSim._energyOn;
const on = sbSim._energyOn;
const btn = document.getElementById('fsb-energy-btn');
if (btn) {
btn.classList.toggle('active', on);
btn.querySelector('span').textContent = on ? 'Энергия: Вкл' : 'Энергия: Выкл';
}
if (!on) sbSim._energyScaleFSB = 0;
sbSim.draw();
}
/* ── 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)})`;
}
function sandboxToggleGraphs() {
if (typeof sandboxSim === 'undefined' || !sandboxSim) return;
if (!window.LSGraphPanelUI) return;
sandboxSim._graphsOn = !sandboxSim._graphsOn;
sandboxSim._gpPrevSpd = null;
if (sandboxSim._graphsOn) {
const canvasOuter = document.querySelector('#sim-dynamics .proj-canvas-outer');
if (!canvasOuter) return;
const labels = sandboxSim.bodies.map((b, i) => (b.type === 'ball' ? 'Шар' : 'Блок') + (i + 1));
sandboxSim._graphUI = new GraphPanelUI(canvasOuter, {
maxPoints: 400,
traces: ['x', 'v', 'a'],
labels: ['x', 'v', '|a|'],
units: ['м', 'м/с', 'м/с²'],
colors: ['#06D6E0', '#FFD166', '#EF476F'],
toggleBtnId: 'btn-sandbox-graphs',
title: 'Тело',
bodySelector: labels.length ? labels : ['Тело 1'],
});
sandboxSim._graphUI.isOn = true;
sandboxSim._graphUI._build();
} else {
if (sandboxSim._graphUI) { sandboxSim._graphUI._destroy(); sandboxSim._graphUI = null; }
}
const btn = document.getElementById('btn-sandbox-graphs');
if (btn) btn.classList.toggle('active', sandboxSim._graphsOn);
}