'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 <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);
    /* 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: тяжёлое тело <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';
  }

  /* ── _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);
}