6afe928c0d
ФУНДАМЕНТ (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>
2118 lines
91 KiB
JavaScript
2118 lines
91 KiB
JavaScript
'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)})`;
|
||
}
|