'use strict';
/* ════════════════════════════════════════════════════════════════
   NewtonSim — три закона Ньютона
   Закон I  : A — скользящий блок,     B — шар на нити
   Закон II : A — один блок F=ma,      B — сравнение масс
   Закон III: A — пушка + откат,       B — столкновение шаров, C — ракета
   ════════════════════════════════════════════════════════════════ */

class NewtonSim {

  static SCALE = 58;   // px per metre (visual)
  static G     = 9.81; // m/s²

  /* ── Конструктор ─────────────────────────────────────────── */

  constructor(canvas) {
    this.canvas = canvas;
    this.ctx    = canvas.getContext('2d');

    /* Пользовательские параметры */
    this.law    = 1;
    this.scene  = 'A';
    this.mu     = 0.20;
    this.mass1  = 5;    // кг — основной блок / шар / ядро
    this.mass2  = 12;   // кг — сравниваемый блок / пушка
    this.force  = 30;   // Н — приложенная сила (закон II)

    /* Классические задачи (law 4) */
    this.atwM1      = 5;    // кг
    this.atwM2      = 8;    // кг
    this.atwMassive = false; // идеальный блок по умолчанию
    this.rampAlpha  = 30;   // градусы
    this.rampMu     = 0.20;
    this.rampForce  = 0;    // Н внешней силы вдоль горки
    this.rollAlpha  = 20;   // градусы
    this.rollFriction = false;

    /* Состояние сцен */
    this._1A = {};
    this._1B = {};
    this._2  = {};
    this._3A = {};
    this._3B = {};
    this._3C = {};
    /* Классические задачи */
    this._atw  = {};
    this._ramp = {};
    this._roll = {};

    /* Петля */
    this._raf    = null;
    this._last   = 0;
    this._paused = false;

    /* Геометрия */
    this.W = 0; this.H = 0;
    this._g = {};

    this.onUpdate     = null;
    this.onModeChange = null;

    /* FBD toggle */
    this._fbdOn = false;

    /* ── Energy bars widget ── */
    this._energyOn     = false;
    this._frictionWork  = 0;   // cumulative friction heat (J)
    this._appliedWork   = 0;   // work done by applied force (J)
    this._energyScale   = 0;

    /* -- GraphPanel widget -- */
    this._graphsOn = false;
    this._graphUI  = null;
    this._tSim     = 0;

    /* ── TimeControl ── */
    this._tc = window.LSTimeControl ? new window.LSTimeControl(this) : null;
    this._tcScale = 1; // separate from tc.scale so we can multiply with existing dt

    this.fit();
    this._bindEvents();
  }

  /* ── Геометрия ───────────────────────────────────────────── */

  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._g = {
      gY:     H * 0.73,
      cx:     W * 0.50,
      cy:     H * 0.48,
      orbitR: Math.min(W, H) * 0.255,
    };
    this._resetAll();
  }

  _resetAll() {
    this._reset1A(); this._reset1B();
    this._reset2();
    this._reset3A(); this._reset3B(); this._reset3C();
    this._resetAtwood(); this._resetRamp(); this._resetRoll();
  }

  /* ── Сброс каждой сцены ──────────────────────────────────── */

  _reset1A() {
    const { W, _g: g } = this;
    this._1A = {
      bx: W * 0.15, by: g.gY, bvx: 0, bvy: 0,
      BW: 56, BH: 46,
      trail: [], inAir: false,
    };
  }

  _reset1B() {
    const { _g: g } = this;
    const omega = 1.35;
    this._1B = {
      angle: 0, omega,
      cut: false, cutTimer: 0,
      bx: g.cx + g.orbitR, by: g.cy,
      bvx: 0, bvy: -g.orbitR * omega,
    };
  }

  _reset2() {
    const { W, _g: g } = this;
    this._2 = {
      b1x: W * 0.12, b1vx: 0,
      b2x: W * 0.12, b2vx: 0,
      history: [], t: 0, running: false,
      flash: '',   // 'fin' message
    };
  }

  _reset3A() {
    const { W, _g: g } = this;
    this._3A = {
      cx: W * 0.38, cvx: 0,
      ball: null, fired: false,
      sparks: [], forceFlash: 0,
    };
  }

  _reset3B() {
    const { W, _g: g } = this;
    const r1 = 16 + this.mass1 * 1.1;
    const r2 = 16 + this.mass2 * 1.1;
    this._3B = {
      b1: { x: W * 0.18, vx:  160, mass: this.mass1, r: r1, color: '#EF476F' },
      b2: { x: W * 0.82, vx: -100, mass: this.mass2, r: r2, color: '#4CC9F0' },
      colFlash: 0, done: false,
    };
  }

  _reset3C() {
    const { W, H } = this;
    this._3C = {
      ry: H * 0.78, rvy: 0,
      rmass: 10, fuel: 1,
      particles: [], running: false,
      stopped: false,
    };
  }

  /* ── Запуск / остановка ──────────────────────────────────── */

  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; }

  /* ── Публичный API ───────────────────────────────────────── */

  setLaw(n)    {
    this.law = n;
    this.scene = (n === 4) ? 'atwood' : 'A';
    this._resetAll();
    if (window.LabFX) LabFX.sound.play('click');
    if (this.onModeChange) this.onModeChange();
  }
  setScene(s)  { this.scene = s; this._resetAll(); if (window.LabFX) LabFX.sound.play('click'); }
  setMu(v)     { this.mu = v; }
  setMass1(v)  { this.mass1 = v; this._reset3B(); if (this.law === 4) { this._resetRamp(); } }
  setMass2(v)  { this.mass2 = v; this._reset3B(); }
  setForce(v)  { this.force = v; }

  /* ── Сеттеры для классических задач ─────────────────────────── */
  setAtwM1(v)      { this.atwM1 = v; this._resetAtwood(); }
  setAtwM2(v)      { this.atwM2 = v; this._resetAtwood(); }
  setAtwMassive(v) { this.atwMassive = v; this._resetAtwood(); }
  setRampAlpha(v)  { this.rampAlpha = v; this._resetRamp(); }
  setRampMu(v)     { this.rampMu = v; this._resetRamp(); }
  setRampForce(v)  { this.rampForce = v; this._resetRamp(); }
  setRollAlpha(v)  { this.rollAlpha = v; this._resetRoll(); }
  setRollFriction(v) { this.rollFriction = v; this._resetRoll(); }

  startAtwood()   { if (this._atw) { this._atw.running = !this._atw.running; if (window.LabFX) LabFX.sound.play('tick'); } }
  startRamp()     { if (this._ramp) { this._ramp.running = !this._ramp.running; if (window.LabFX) LabFX.sound.play('tick'); } }
  startRoll()     { if (this._roll) { this._roll.running = !this._roll.running; if (window.LabFX) LabFX.sound.play('tick'); } }

  cutString()  {
    this._1B.cut = true;
    this._1B.bvx =  -Math.sin(this._1B.angle) * this._g.orbitR * this._1B.omega;
    this._1B.bvy =   Math.cos(this._1B.angle) * this._g.orbitR * this._1B.omega;
  }

  startL2()    { this._2.running = true; }
  resetL2()    { this._reset2(); }

  fireCannon() {
    if (this._3A.fired) { this._reset3A(); return; }
    const { _g: g } = this;
    const S = NewtonSim.SCALE;
    const vBall   =  360;    // px/s
    const vCannon = -(this.mass1 / this.mass2) * vBall;
    this._3A.ball = { x: this._3A.cx + 68, y: g.gY - 22, vx: vBall, vy: -160 };
    this._3A.cvx  = vCannon;
    this._3A.fired = true;
    this._3A.forceFlash = 0.55;
    for (let i = 0; i < 24; i++) {
      const a = (Math.random() - 0.5) * 1.1 - 0.05;
      this._3A.sparks.push({
        x: this._3A.cx + 68, y: g.gY - 22,
        vx: Math.cos(a) * (180 + Math.random() * 220),
        vy: Math.sin(a) * 140 - 80 - Math.random() * 120,
        life: 1,
      });
    }
  }

  toggleRocket() {
    if (this._3C.fuel <= 0) { this._reset3C(); return; }
    this._3C.running = !this._3C.running;
    if (this.onModeChange) this.onModeChange();
  }

  togglePause() { this._paused = !this._paused; }

  preset(name) {
    switch (name) {
      case 'space':         this.mu = 0;    this._reset1A(); break;
      case 'ice':           this.mu = 0.04; this._reset1A(); break;
      case 'asphalt':       this.mu = 0.38; this._reset1A(); break;
      case 'rubber':        this.mu = 0.72; this._reset1A(); break;
      case 'light':         this.mass1 = 2;  this.force = 20; this._reset2(); break;
      case 'heavy':         this.mass1 = 18; this.force = 20; this._reset2(); break;
      case 'compare':       this.mass1 = 2; this.mass2 = 16; this.scene = 'B'; this._reset2(); break;
      case 'big_cannon':    this.mass2 = 50; this.mass1 = 1;  this._reset3A(); break;
      case 'small_cannon':  this.mass2 = 5;  this.mass1 = 4;  this._reset3A(); break;
      case 'equal_balls':   this.mass1 = 8;  this.mass2 = 8;  this._reset3B(); break;
    }
    if (this.onUpdate) this.onUpdate(this.info());
  }

  /* ── Тик ──────────────────────────────────────────────────── */

  _tick(now) {
    const rawDt = Math.min((now - this._last) / 1000, 0.05);
    this._last = now;
    if (window.LabFX) LabFX.particles.update(rawDt);

    /* TimeControl: pause / speed scale */
    let dt = rawDt;
    if (this._tc) {
      dt = this._tc.advance(rawDt);
      if (dt === 0) { this.draw(); if (this.onUpdate) this.onUpdate(this.info()); return; }
    }

    if (!this._paused) {
      if      (this.law === 1 && this.scene === 'A') this._step1A(dt);
      else if (this.law === 1)                        this._step1B(dt);
      else if (this.law === 2)                        this._step2(dt);
      else if (this.law === 4 && this.scene === 'atwood') this._stepAtwood(dt);
      else if (this.law === 4 && this.scene === 'ramp')   this._stepRamp(dt);
      else if (this.law === 4 && this.scene === 'roll')   this._stepRoll(dt);
      else if (this.scene === 'A')                    this._step3A(dt);
      else if (this.scene === 'B')                    this._step3B(dt);
      else                                            this._step3C(dt);
    }
    this._tSim += dt;
    this.draw();
    if (this.onUpdate) this.onUpdate(this.info());
    if (window.LSGraphPanel && this._graphsOn && this._graphUI && !this._paused) {
      this._graphUI.push(this._tSim, this._newtonGraphValues());
    }
  }

  /* ── Физика I-A : блок с трением ────────────────────────── */

  _step1A(dt) {
    const b = this._1A;
    const { W, _g: g } = this;
    const S  = NewtonSim.SCALE;
    const GV = NewtonSim.G * S;

    /* Гравитация */
    if (b.by < g.gY || b.bvy < 0) {
      b.bvy += GV * dt;
      b.inAir = true;
    }

    /* Интеграция */
    b.bx += b.bvx * dt;
    b.by += b.bvy * dt;

    /* Приземление */
    if (b.by >= g.gY) {
      b.by  = g.gY;
      b.bvy = Math.abs(b.bvy) > 60 ? -b.bvy * 0.42 : 0;
      b.inAir = false;
    }

    /* Трение (только на земле) */
    if (!b.inAir) {
      const speed = Math.abs(b.bvx);
      if (speed > 1) {
        const dec = this.mu * GV * dt;
        if (dec >= speed) b.bvx = 0;
        else b.bvx -= Math.sign(b.bvx) * dec;
      }
    }

    /* Стены (упругий отскок) */
    const hw = b.BW / 2;
    if (b.bx < hw)      { b.bx = hw;      b.bvx =  Math.abs(b.bvx) * 0.65; }
    if (b.bx > W - hw)  { b.bx = W - hw;  b.bvx = -Math.abs(b.bvx) * 0.65; }

    /* След */
    const speed = Math.hypot(b.bvx, b.bvy);
    if (speed > 15) {
      b.trail.push({ x: b.bx, y: Math.min(b.by, g.gY) });
      if (b.trail.length > 90) b.trail.shift();
    } else if (b.trail.length > 0) {
      b.trail.shift();
    }
  }

  /* ── Физика I-B : орбита → прямолинейное движение ────────── */

  _step1B(dt) {
    const s = this._1B;
    const { _g: g } = this;

    if (!s.cut) {
      s.angle += s.omega * dt;
      s.bx = g.cx + Math.cos(s.angle) * g.orbitR;
      s.by = g.cy + Math.sin(s.angle) * g.orbitR;
      s.bvx = -Math.sin(s.angle) * g.orbitR * s.omega;
      s.bvy =  Math.cos(s.angle) * g.orbitR * s.omega;
    } else {
      s.bx += s.bvx * dt;
      s.by += s.bvy * dt;
      s.cutTimer += dt;
      if (s.cutTimer > 4.5) this._reset1B();
    }
  }

  /* ── Физика II : F = m·a ──────────────────────────────────── */

  _step2(dt) {
    if (!this._2.running) return;
    const { W } = this;
    const S  = NewtonSim.SCALE;
    const a1 = (this.force / this.mass1) * S;
    const a2 = (this.force / this.mass2) * S;

    this._2.b1vx += a1 * dt;  this._2.b1x += this._2.b1vx * dt;
    this._2.b2vx += a2 * dt;  this._2.b2x += this._2.b2vx * dt;
    this._2.t    += dt;

    /* История для графика (примерно 20 точек/с) */
    if (this._2.t % 0.05 < dt) {
      this._2.history.push({ v1: this._2.b1vx / S, v2: this._2.b2vx / S });
      if (this._2.history.length > 130) this._2.history.shift();
    }

    /* Сброс при достижении правого края */
    if (this._2.b1x > W * 0.89 || this._2.b2x > W * 0.89) {
      this._reset2(); this._2.running = true;
    }
  }

  /* ── Физика III-A : пушка ─────────────────────────────────── */

  _step3A(dt) {
    const s = this._3A;
    const { W, _g: g } = this;
    const S  = NewtonSim.SCALE;
    const GV = NewtonSim.G * S;

    if (!s.fired) return;

    if (this._3A.forceFlash > 0) this._3A.forceFlash -= dt;

    /* Пушка тормозит */
    if (Math.abs(s.cvx) > 2) {
      const dec = this.mu * GV * dt;
      if (dec >= Math.abs(s.cvx)) s.cvx = 0;
      else s.cvx -= Math.sign(s.cvx) * dec;
    } else { s.cvx = 0; }
    s.cx = Math.max(60, Math.min(W - 80, s.cx + s.cvx * dt));

    /* Ядро (баллистика) */
    if (s.ball) {
      s.ball.vy += GV * dt;
      s.ball.x  += s.ball.vx * dt;
      s.ball.y  += s.ball.vy * dt;
      if (s.ball.y > g.gY + 40 || s.ball.x > W + 120 || s.ball.x < -120) s.ball = null;
    }

    /* Искры */
    for (const sp of s.sparks) {
      sp.x += sp.vx * dt; sp.y += sp.vy * dt;
      sp.vy += GV * 0.6 * dt; sp.life -= dt * 2;
    }
    s.sparks = s.sparks.filter(sp => sp.life > 0);
  }

  /* ── Физика III-B : столкновение ─────────────────────────── */

  _step3B(dt) {
    const { b1, b2 } = this._3B;
    const { W, _g: g } = this;
    const S = NewtonSim.SCALE;

    b1.x += b1.vx * dt;
    b2.x += b2.vx * dt;

    if (this._3B.colFlash > 0) this._3B.colFlash -= dt;

    /* Упругое столкновение */
    if (!this._3B.done) {
      const dist = b2.x - b1.x;
      if (dist < b1.r + b2.r) {
        const m1 = b1.mass, m2 = b2.mass;
        const v1 = b1.vx, v2 = b2.vx;
        b1.vx = ((m1 - m2) * v1 + 2 * m2 * v2) / (m1 + m2);
        b2.vx = ((m2 - m1) * v2 + 2 * m1 * v1) / (m1 + m2);
        const overlap = b1.r + b2.r - dist;
        b1.x -= overlap * 0.5; b2.x += overlap * 0.5;
        this._3B.colFlash = 0.55;
        this._3B.done = true;
      }
    }

    /* Минимальное трение поверхности (сохраняет видимость закона импульса) */
    if (this._3B.done) {
      [b1, b2].forEach(b => { b.vx *= (1 - dt * 0.04); });
    }

    /* Авто-сброс */
    if (b1.x < -120 || b2.x > W + 120 || (this._3B.done && Math.abs(b1.vx) < 2 && Math.abs(b2.vx) < 2)) {
      setTimeout(() => this._reset3B(), 1800);
      this._3B.done = true; // prevent double reset
    }
  }

  /* ── Физика III-C : ракета ────────────────────────────────── */

  _step3C(dt) {
    const s = this._3C;
    const { W, H } = this;
    const g_vis = NewtonSim.G * 0.42; // visual gravity (px/s²)

    /* Gravity always acts — even after fuel is out */
    if (!s.running && s.fuel <= 0 && !s.stopped) {
      s.rvy += g_vis * dt;
      s.ry  += s.rvy * dt;
      if (s.ry >= H * 0.78) { s.ry = H * 0.78; s.rvy = 0; s.stopped = true; }
      /* exhaust smoke fading */
      for (const p of s.particles) { p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt * 1.6; }
      s.particles = s.particles.filter(p => p.life > 0 && p.y < H + 20);
      return;
    }

    if (!s.running || s.fuel <= 0) {
      if (s.fuel <= 0 && !s.stopped) { s.running = false; }
      return;
    }

    const dmdt  = 0.025;   // кг/с — сгорание топлива
    const F_thr = 220;     // Н — тяга (визуальная)
    s.fuel  = Math.max(0, s.fuel - dmdt * dt);
    s.rmass = Math.max(2,  10 * s.fuel + 2);

    /* F_net = F_thrust - m·g */
    const a_thrust = F_thr / s.rmass;
    const a_net = a_thrust - g_vis;
    s.rvy -= a_net * dt;
    s.ry  += s.rvy * dt;
    if (s.ry < H * 0.08) { s.ry = H * 0.08; s.rvy = 0; }
    if (s.ry >= H * 0.78) { s.ry = H * 0.78; s.rvy = 0; }

    /* Частицы выхлопа */
    if (Math.random() < 0.55) {
      s.particles.push({
        x: W * 0.5 + (Math.random() - 0.5) * 16,
        y: s.ry + 48,
        vx: (Math.random() - 0.5) * 38,
        vy: 130 + Math.random() * 170,
        r: 2 + Math.random() * 4,
        life: 1,
      });
    }
    for (const p of s.particles) {
      p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt * 1.6;
    }
    s.particles = s.particles.filter(p => p.life > 0 && p.y < H + 20);

    /* LabFX: rocket flame trail at nozzle */
    if (window.LabFX) {
      const nozzleX = W * 0.5;
      const nozzleY = s.ry + 22;
      LabFX.particles.emit({
        ctx: this.ctx, x: nozzleX, y: nozzleY,
        count: 3, color: ['#FFD166', '#FF6B35', '#EF476F'],
        speed: 80, spread: Math.PI / 6, angle: Math.PI / 2,
        gravity: -50, life: 400, glow: true, shape: 'spark',
      });
    }
  }

  /* ── Мышь ─────────────────────────────────────────────────── */

  _bindEvents() {
    this.canvas.addEventListener('click', e => {
      if (this.law !== 1 || this.scene !== 'A') return;
      const r  = this.canvas.getBoundingClientRect();
      const x  = e.clientX - r.left;
      const y  = e.clientY - r.top;
      const b  = this._1A;
      const dx = x - b.bx, dy = y - b.by;
      const d  = Math.hypot(dx, dy);
      if (d < 4) return;
      const spd = 340;
      b.bvx = (dx / d) * spd;
      b.bvy = (dy / d) * spd;
    });
  }

  /* ═══════════════════════════════════════════════════════════
     РЕНДЕРИНГ
     ═══════════════════════════════════════════════════════════ */

  draw() {
    const ctx = this.ctx;
    const { W, H } = this;
    ctx.clearRect(0, 0, 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.save();
    ctx.font = 'bold 78px sans-serif';
    ctx.fillStyle = 'rgba(255,255,255,0.023)';
    ctx.textAlign = 'center';
    const wmarks = ['', 'ЗАКОН I', 'ЗАКОН II', 'ЗАКОН III', 'КЛАССИКА'];
    ctx.fillText(wmarks[this.law] || '', W / 2, H * 0.60);
    ctx.textAlign = 'left';
    ctx.restore();

    if      (this.law === 1 && this.scene === 'A') this._drawL1A(ctx);
    else if (this.law === 1)                        this._drawL1B(ctx);
    else if (this.law === 2)                        this._drawL2(ctx);
    else if (this.law === 4 && this.scene === 'atwood') this._drawAtwood(ctx);
    else if (this.law === 4 && this.scene === 'ramp')   this._drawRamp(ctx);
    else if (this.law === 4 && this.scene === 'roll')   this._drawRoll(ctx);
    else if (this.scene === 'A')                    this._drawL3A(ctx);
    else if (this.scene === 'B')                    this._drawL3B(ctx);
    else                                            this._drawL3C(ctx);

    /* ── Energy bars overlay ── */
    if (this._energyOn && window.LSPhysFX) {
      this._drawEnergyBarsNwt(ctx);
    }

    /* LabFX: particles overlay */
    if (window.LabFX) LabFX.particles.draw(this.ctx);
  }

  /* ── Energy bars: newton ── */

  _drawEnergyBarsNwt(ctx) {
    var en = this._calcEnergiesNwt();
    if (!en) return;
    var tot = en.ke + en.pe + en.friction;
    if (tot > this._energyScale) this._energyScale = tot;
    var PW = 188, MARGIN = 12;
    LSPhysFX.drawEnergyBars(ctx, this.W - PW - MARGIN, MARGIN, PW, 0,
      { ke: en.ke, pe: en.pe, friction: en.friction, total: this._energyScale }, {});
  }

  _calcEnergiesNwt() {
    var ke = 0, pe = 0, fr = this._frictionWork;
    var S = NewtonSim.SCALE, G = NewtonSim.G;
    /* law 1A — sliding block */
    if (this.law === 1 && this.scene === 'A') {
      var b = this._1A;
      var spd = Math.hypot(b.bvx || 0, b.bvy || 0) / S;
      ke = 0.5 * this.mass1 * spd * spd;
    } else if (this.law === 1) {
      /* law 1B — projectile */
      var b2 = this._1B;
      if (b2) {
        var spd2 = Math.hypot(b2.vx || 0, b2.vy || 0) / S;
        var h2   = Math.max(0, (this.H * 0.6 - (b2.y || 0))) / S;
        ke = 0.5 * this.mass1 * spd2 * spd2;
        pe = this.mass1 * G * h2;
      }
    } else if (this.law === 4 && this.scene === 'atwood') {
      var atw = this._atw;
      var v   = Math.abs(atw.vy || 0) / S;
      ke = 0.5 * (this.atwM1 + this.atwM2) * v * v;
      /* PE from starting positions */
      var dy1 = ((atw.y1 || 0) - (this.H * 0.3)) / S;
      var dy2 = ((atw.y2 || 0) - (this.H * 0.3)) / S;
      pe = Math.max(0, this.atwM1 * G * (-dy1) + this.atwM2 * G * (-dy2));
    } else if (this.law === 4 && this.scene === 'ramp') {
      var rmp = this._ramp;
      ke = 0.5 * this.mass1 * (rmp.bv || 0) * (rmp.bv || 0);
      var h = (rmp.bx || 0) * Math.sin(rmp.alpha || 0);
      pe = Math.max(0, this.mass1 * G * h);
    } else if (this.law === 4 && this.scene === 'roll') {
      /* rolling: use ball as representative */
      var roll = this._roll;
      var vb = roll.vBall || 0;
      /* KE includes rotational: for solid ball k=2/5, cyl=1/2, hoop=1 */
      ke = 0.5 * this.mass1 * vb * vb * (1 + 0.4) / 1; /* solid ball */
      var hRoll = Math.max(0, (roll.L || 3) * Math.sin(roll.alpha || 0) - (roll.sBall || 0) * Math.sin(roll.alpha || 0));
      pe = this.mass1 * G * hRoll;
    } else if (this.law === 2) {
      var b2s = this._2;
      var spd3 = Math.abs(b2s.v || 0);
      ke = 0.5 * this.mass1 * spd3 * spd3;
    } else if (this.law === 3) {
      var b3 = this.scene === 'A' ? this._3A : (this.scene === 'B' ? this._3B : this._3C);
      if (b3) {
        var v3a = Math.abs(b3.v1 || 0), v3b = Math.abs(b3.v2 || 0);
        ke = 0.5 * this.mass1 * v3a * v3a + 0.5 * this.mass2 * v3b * v3b;
      }
    }
    return { ke: Math.max(0, ke), pe: Math.max(0, pe), friction: Math.max(0, fr) };
  }

  /* ── Закон I — Сцена A ───────────────────────────────────── */

  _drawL1A(ctx) {
    const { W, H, _g: g } = this;
    const b = this._1A;
    const S = NewtonSim.SCALE;
    const spd = Math.hypot(b.bvx, b.bvy);

    /* Звёздный фон при μ≈0 */
    if (this.mu < 0.02) {
      this._stars(ctx);
      ctx.font = 'bold 12px sans-serif';
      ctx.fillStyle = '#7BF5A4';
      ctx.textAlign = 'center';
      ctx.fillText('Трения нет — тело движется вечно!', W / 2, H * 0.10);
      ctx.textAlign = 'left';
    } else {
      this._ground(ctx, g.gY, W);
    }

    /* След */
    if (b.trail.length > 2) {
      for (let i = 2; i < b.trail.length; i++) {
        const a = (i / b.trail.length) * 0.55;
        ctx.strokeStyle = `rgba(255,209,102,${a})`;
        ctx.lineWidth = 2.5;
        ctx.beginPath();
        ctx.moveTo(b.trail[i-1].x, b.trail[i-1].y - 2);
        ctx.lineTo(b.trail[i].x,   b.trail[i].y   - 2);
        ctx.stroke();
      }
    }

    /* Блок */
    const by = b.by - b.BH / 2;
    this._block(ctx, b.bx, by, b.BW, b.BH, '#9B5DE5', `${this.mass1} кг`);

    /* Вектор скорости */
    if (spd > 8) {
      const scale = 0.28;
      this._arrow(ctx,
        b.bx, by,
        b.bx + b.bvx * scale, by + b.bvy * scale,
        '#FFD166', 'v = ' + (spd / S).toFixed(1) + ' м/с', 2.5);
    }

    /* Сила трения (горизонтальная, только на земле) */
    if (!b.inAir && Math.abs(b.bvx) > 8 && this.mu > 0.01) {
      const fFr = this.mu * this.mass1 * NewtonSim.G;
      this._arrow(ctx,
        b.bx, by - 32,
        b.bx - Math.sign(b.bvx) * 55, by - 32,
        '#EF476F', `F тр = ${fFr.toFixed(1)} Н`, 2);
    }

    /* Подсказка */
    if (spd < 4) {
      ctx.font = '13px sans-serif'; ctx.fillStyle = 'rgba(185,210,255,0.38)';
      ctx.textAlign = 'center';
      ctx.fillText('Кликни куда угодно — придай импульс блоку', W / 2, g.gY + 28);
      ctx.textAlign = 'left';
    }

    /* μ и формула */
    ctx.font = '12px monospace'; ctx.fillStyle = 'rgba(185,210,255,0.75)';
    ctx.fillText(`μ = ${this.mu.toFixed(2)}    F тр = μ · m · g`, 18, 26);

    /* FBD: mg, N for Law I scene A */
    if (this._fbdOn && window.LSPhysFX) {
      const by2 = b.by - b.BH / 2;
      const mg2 = this.mass1 * NewtonSim.G;
      LSPhysFX.drawForceArrow(ctx, b.bx, by2, 0, 50, 'gravity', 'mg=' + mg2.toFixed(0) + 'Н');
      if (!b.inAir) {
        LSPhysFX.drawForceArrow(ctx, b.bx, by2, 0, -50, 'normal', 'N=' + mg2.toFixed(0) + 'Н');
      }
    }

    this._caption(ctx, 'Тело продолжает движение\nпока не подействует сила', W, H);
  }

  /* ── Закон I — Сцена B ───────────────────────────────────── */

  _drawL1B(ctx) {
    const { W, H, _g: g } = this;
    const s = this._1B;

    this._stars(ctx);

    /* Орбита */
    ctx.save();
    ctx.setLineDash([5, 9]);
    ctx.strokeStyle = 'rgba(100,165,255,0.20)'; ctx.lineWidth = 1.5;
    ctx.beginPath(); ctx.arc(g.cx, g.cy, g.orbitR, 0, Math.PI * 2); ctx.stroke();
    ctx.setLineDash([]); ctx.restore();

    /* Центральное тело */
    ctx.save();
    ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 22;
    ctx.beginPath(); ctx.arc(g.cx, g.cy, 20, 0, Math.PI * 2);
    const cg = ctx.createRadialGradient(g.cx - 5, g.cy - 5, 0, g.cx, g.cy, 20);
    cg.addColorStop(0, '#FFEA70'); cg.addColorStop(1, '#FF9500');
    ctx.fillStyle = cg; ctx.fill();
    ctx.restore();

    /* Нить */
    if (!s.cut) {
      ctx.strokeStyle = 'rgba(210,225,255,0.55)'; ctx.lineWidth = 1.8;
      ctx.beginPath(); ctx.moveTo(g.cx, g.cy); ctx.lineTo(s.bx, s.by); ctx.stroke();

      /* Стрелка натяжения (центростремительная) */
      const len  = g.orbitR;
      const tx   = (g.cx - s.bx) / len * 36;
      const ty   = (g.cy - s.by) / len * 36;
      const F_c  = (this.mass1 * g.orbitR * s.omega * s.omega * NewtonSim.SCALE / NewtonSim.SCALE).toFixed(1);
      this._arrow(ctx, s.bx, s.by, s.bx + tx, s.by + ty, '#4CC9F0', `T = F ц`, 1.8);
    } else {
      ctx.font = 'bold 14px sans-serif'; ctx.fillStyle = '#7BF5A4';
      ctx.textAlign = 'center';
      ctx.fillText('✂ Нить разрезана — тело летит прямолинейно!', W / 2, H * 0.10);
      ctx.textAlign = 'left';

      /* Вектор скорости по касательной */
      this._arrow(ctx, s.bx, s.by,
        s.bx + s.bvx * 0.22, s.by + s.bvy * 0.22,
        '#FFD166', 'v = const', 2.8);
    }

    /* Шар */
    ctx.save();
    ctx.shadowColor = '#4CC9F0'; ctx.shadowBlur = 14;
    ctx.beginPath(); ctx.arc(s.bx, s.by, 16, 0, Math.PI * 2);
    ctx.fillStyle = '#4CC9F0'; ctx.fill();
    ctx.restore();

    /* Вектор скорости (во время орбиты) */
    if (!s.cut) {
      this._arrow(ctx, s.bx, s.by,
        s.bx + s.bvx * 0.18, s.by + s.bvy * 0.18,
        '#FFD166', '', 2);
    }

    this._caption(ctx, 'Без силы тело движется прямолинейно\nравномерно (1-й закон Ньютона)', W, H);
  }

  /* ── Закон II ─────────────────────────────────────────────── */

  _drawL2(ctx) {
    const { W, H, _g: g } = this;
    const S  = NewtonSim.SCALE;
    const a1 = this.force / this.mass1;
    const a2 = this.force / this.mass2;

    this._ground(ctx, g.gY, W);

    /* Линия финиша */
    ctx.strokeStyle = 'rgba(255,255,255,0.13)'; ctx.lineWidth = 1;
    ctx.setLineDash([6, 7]);
    ctx.beginPath(); ctx.moveTo(W * 0.89, 0); ctx.lineTo(W * 0.89, g.gY + 8); ctx.stroke();
    ctx.setLineDash([]);

    const BW = 58, BH = 48;

    if (this.scene === 'A') {
      /* ── Один блок ── */
      const { b1x: bx, b1vx: bvx } = this._2;
      const by = g.gY - BH / 2;

      this._block(ctx, bx, by, BW, BH, '#EF476F', `${this.mass1} кг`);

      /* Сила F */
      const _fTipX = bx + BW / 2 + 48 + this.force * 0.9;
      const _fTipY = by;
      this._arrow(ctx, bx + BW / 2, by,
        _fTipX, _fTipY,
        '#EF476F', `F = ${this.force} Н`, 2.5);
      /* LabFX: spark at force arrow tip (scene II A, running) */
      if (window.LabFX && this._2.running && Math.random() < 0.25) {
        LabFX.particles.emit({
          ctx, x: _fTipX, y: _fTipY,
          count: 5, color: '#FFD166', speed: 40,
          spread: Math.PI / 2, angle: 0, life: 200,
          glow: true, shape: 'spark',
        });
      }

      /* Ускорение a */
      const aLen = 32 + a1 * 5;
      this._arrow(ctx, bx + BW / 2, by - 32,
        bx + BW / 2 + aLen, by - 32,
        '#7BF5A4', `a = ${a1.toFixed(1)} м/с²`, 2.5);

      /* Скорость v */
      if (bvx > 8) {
        const v = bvx / S;
        this._arrow(ctx, bx + BW / 2, by + 32,
          bx + BW / 2 + bvx * 0.28, by + 32,
          '#FFD166', `v = ${v.toFixed(1)} м/с`, 2);
      }

      /* Уравнение F = m·a */
      this._fma(ctx, this.force, this.mass1, a1, W / 2, H * 0.12);

      /* Мини-график v(t) */
      if (this._2.history.length > 3) {
        this._graph(ctx, this._2.history.map(h => h.v1),
          W * 0.72, 14, 145, 62, '#FFD166', 'v(t) м/с');
      }

      if (!this._2.running) {
        ctx.font = '13px sans-serif'; ctx.fillStyle = 'rgba(185,210,255,0.38)';
        ctx.textAlign = 'center';
        ctx.fillText('Нажмите «Старт» чтобы запустить', W / 2, g.gY + 28);
        ctx.textAlign = 'left';
      }

    } else {
      /* ── Сравнение двух масс ── */
      const y1 = g.gY - BH - 6, y2 = g.gY + 4;

      /* Разделитель дорожек */
      ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1;
      ctx.setLineDash([4, 7]);
      ctx.beginPath(); ctx.moveTo(0, g.gY - BH / 2 - 4); ctx.lineTo(W, g.gY - BH / 2 - 4); ctx.stroke();
      ctx.setLineDash([]);

      const bx1 = this._2.b1x, bx2 = this._2.b2x;

      this._block(ctx, bx1, y1, BW, BH, '#EF476F', `${this.mass1} кг`);
      this._block(ctx, bx2, y2, BW, BH, '#4CC9F0', `${this.mass2} кг`);

      /* Силы (одинаковые) */
      const fLen = 40 + this.force * 0.9;
      this._arrow(ctx, bx1 + BW / 2, y1 + BH / 2, bx1 + BW / 2 + fLen, y1 + BH / 2, '#EF476F', `F=${this.force}Н`, 2);
      this._arrow(ctx, bx2 + BW / 2, y2 + BH / 2, bx2 + BW / 2 + fLen, y2 + BH / 2, '#4CC9F0', `F=${this.force}Н`, 2);

      /* Ускорения */
      ctx.font = 'bold 11px monospace';
      ctx.fillStyle = '#7BF5A4'; ctx.fillText(`a₁=${a1.toFixed(1)} м/с²`, bx1 + 2, y1 - 8);
      ctx.fillStyle = '#06D6E0'; ctx.fillText(`a₂=${a2.toFixed(1)} м/с²`, bx2 + 2, y2 - 8);

      /* Вывод */
      if (this._2.running && bx1 > bx2 + 20) {
        ctx.font = 'bold 13px sans-serif'; ctx.fillStyle = '#7BF5A4';
        ctx.textAlign = 'center';
        ctx.fillText('Меньше масса → больше ускорение!', W / 2, g.gY + 26);
        ctx.textAlign = 'left';
      }

      /* Графики */
      if (this._2.history.length > 3) {
        this._graph(ctx, this._2.history.map(h => h.v1), W * 0.68, 14, 130, 58, '#EF476F', 'v₁ м/с');
        this._graph(ctx, this._2.history.map(h => h.v2), W * 0.68, 80, 130, 58, '#4CC9F0', 'v₂ м/с');
      }

      if (!this._2.running) {
        ctx.font = '13px sans-serif'; ctx.fillStyle = 'rgba(185,210,255,0.38)';
        ctx.textAlign = 'center';
        ctx.fillText('Нажмите «Старт» чтобы запустить', W / 2, g.gY + 28);
        ctx.textAlign = 'left';
      }
    }

    /* FBD: F_applied, mg, N, F_friction for Law II scene A */
    if (this._fbdOn && window.LSPhysFX && this.scene === 'A') {
      const { b1x: bx2 } = this._2;
      const BH2 = 48;
      const by2 = g.gY - BH2 / 2;
      const mg2 = this.mass1 * NewtonSim.G;
      const fFr2 = this.mu ? this.mu * mg2 : 0;
      LSPhysFX.drawForceArrow(ctx, bx2, by2, 0, 44, 'gravity', 'mg=' + mg2.toFixed(0) + 'Н');
      LSPhysFX.drawForceArrow(ctx, bx2, by2, 0, -44, 'normal', 'N=' + mg2.toFixed(0) + 'Н');
      LSPhysFX.drawForceArrow(ctx, bx2, by2, Math.min(55, this.force * 1.5), 0, 'applied', 'F=' + this.force + 'Н');
      if (fFr2 > 0.5) {
        LSPhysFX.drawForceArrow(ctx, bx2, by2, -Math.min(40, fFr2 * 1.5), 0, 'friction', 'Fтр=' + fFr2.toFixed(0) + 'Н');
      }
    }

    this._caption(ctx, 'F = m · a', W, H);
  }

  /* ── Закон III — Сцена A : пушка ────────────────────────── */

  _drawL3A(ctx) {
    const { W, H, _g: g } = this;
    const s  = this._3A;
    const S  = NewtonSim.SCALE;
    const CW = 124, CH = 42;

    this._ground(ctx, g.gY, W);

    /* Корпус пушки */
    ctx.save();
    ctx.shadowColor = '#9B5DE5'; ctx.shadowBlur = 14;
    _nwt_rrect(ctx, s.cx - CW / 2, g.gY - CH - 4, CW, CH, 8);
    const cg = ctx.createLinearGradient(0, g.gY - CH - 4, 0, g.gY - 4);
    cg.addColorStop(0, '#5a3a7a'); cg.addColorStop(1, '#3a2260');
    ctx.fillStyle = cg; ctx.fill();
    ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 1.8; ctx.stroke();
    ctx.restore();

    /* Ствол */
    ctx.save();
    ctx.shadowColor = '#9B5DE5'; ctx.shadowBlur = 7;
    _nwt_rrect(ctx, s.cx + CW / 2 - 8, g.gY - CH / 2 - 8 - 4, 58, 16, 4);
    ctx.fillStyle = '#7340a0'; ctx.fill();
    ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 1.5; ctx.stroke();
    ctx.restore();

    /* Колёса */
    [s.cx - CW / 2 + 18, s.cx + CW / 2 - 18].forEach(wx => {
      ctx.save(); ctx.shadowColor = '#9B5DE5'; ctx.shadowBlur = 6;
      ctx.beginPath(); ctx.arc(wx, g.gY, 10, 0, Math.PI * 2);
      ctx.fillStyle = '#4a2870'; ctx.fill();
      ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 1.5; ctx.stroke();
      ctx.restore();
    });

    /* Масса пушки */
    ctx.font = 'bold 11px monospace'; ctx.fillStyle = 'rgba(200,180,255,0.9)';
    ctx.textAlign = 'center';
    ctx.fillText(`M = ${this.mass2} кг`, s.cx, g.gY - CH - 16);
    ctx.textAlign = 'left';

    /* Ядро */
    if (s.ball) {
      ctx.save();
      ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 12;
      ctx.beginPath(); ctx.arc(s.ball.x, s.ball.y, 12, 0, Math.PI * 2);
      ctx.fillStyle = '#FFD166'; ctx.fill();
      ctx.restore();
      ctx.font = '10px monospace'; ctx.fillStyle = 'rgba(255,209,102,0.82)';
      ctx.textAlign = 'center';
      ctx.fillText(`m = ${this.mass1} кг`, s.ball.x, s.ball.y + 26);
      ctx.textAlign = 'left';
    }

    /* Стрелки сил (сразу после выстрела) */
    if (s.forceFlash > 0) {
      const alpha = Math.min(1, s.forceFlash * 2.5);
      const fScale = 72 * alpha;
      const ny = g.gY - CH - 32;
      /* Сила на ядро → вправо */
      this._arrow(ctx, s.cx + CW / 2 + 20, ny, s.cx + CW / 2 + 20 + fScale, ny, '#EF476F', 'F→ядро', 2.5);
      /* Реакция на пушку → влево */
      this._arrow(ctx, s.cx - CW / 2 - 20, ny, s.cx - CW / 2 - 20 - fScale, ny, '#4CC9F0', 'F→пушка', 2.5);

      ctx.save(); ctx.globalAlpha = alpha;
      ctx.font = 'bold 12px sans-serif'; ctx.fillStyle = '#FFD166';
      ctx.textAlign = 'center';
      ctx.fillText('|F→ядро| = |F→пушка|', s.cx, ny - 22);
      ctx.restore();
    }

    /* Скорости (после выстрела, когда искры погасли) */
    if (s.fired && s.sparks.length === 0 && s.forceFlash <= 0) {
      if (s.cvx !== 0) {
        this._arrow(ctx, s.cx, g.gY - CH / 2 - 4, s.cx + s.cvx * 0.35, g.gY - CH / 2 - 4,
          '#4CC9F0', `V₂=${(s.cvx/S).toFixed(1)}м/с`, 2);
      }
      if (s.ball) {
        this._arrow(ctx, s.ball.x, s.ball.y, s.ball.x + s.ball.vx * 0.12, s.ball.y + s.ball.vy * 0.12,
          '#EF476F', `V₁=${(s.ball.vx/S).toFixed(1)}м/с`, 2);
      }
      /* Сохранение импульса */
      const p1 = (this.mass1 * 360 / S).toFixed(1);
      const p2 = Math.abs(this.mass2 * (this.mass1 / this.mass2) * 360 / S).toFixed(1);
      ctx.font = '12px monospace'; ctx.fillStyle = 'rgba(185,210,255,0.82)';
      ctx.textAlign = 'center';
      ctx.fillText(`p₁ = ${p1} кг·м/с    p₂ = ${p2} кг·м/с    Δp_total = 0`, W / 2, H * 0.11);
      ctx.textAlign = 'left';
    }

    /* Искры */
    for (const sp of s.sparks) {
      ctx.save(); ctx.globalAlpha = sp.life;
      ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 8;
      ctx.beginPath(); ctx.arc(sp.x, sp.y, 3 * sp.life, 0, Math.PI * 2);
      ctx.fillStyle = sp.life > 0.5 ? '#FFD166' : '#EF476F'; ctx.fill();
      ctx.restore();
    }

    if (!s.fired) {
      ctx.font = '13px sans-serif'; ctx.fillStyle = 'rgba(185,210,255,0.38)';
      ctx.textAlign = 'center';
      ctx.fillText('Нажмите «Выстрел!» чтобы запустить ядро', W / 2, g.gY + 28);
      ctx.textAlign = 'left';
    }

    /* FBD: action/reaction via LSPhysFX for Law III scene A */
    if (this._fbdOn && window.LSPhysFX && s.fired && s.ball) {
      const ny3  = g.gY - CW - 50;
      const S3   = NewtonSim.SCALE;
      const fMag = Math.min(70, this.mass1 * 6);
      /* force on ball → right */
      LSPhysFX.drawForceArrow(ctx, s.cx + CW / 2 + 12, ny3,
        fMag, 0, 'applied', 'F→ядро');
      /* reaction on cannon → left */
      LSPhysFX.drawForceArrow(ctx, s.cx - CW / 2 - 12, ny3,
        -fMag, 0, 'impulse', 'F→пушка');
    }

    this._caption(ctx, 'Действие = Противодействие\nF₁ = −F₂', W, H);
  }

  /* ── Закон III — Сцена B : столкновение ─────────────────── */

  _drawL3B(ctx) {
    const { W, H, _g: g } = this;
    const { b1, b2, colFlash } = this._3B;
    const S = NewtonSim.SCALE;
    const cy = g.cy + 15;

    /* Дорожка */
    ctx.fillStyle = 'rgba(60,90,160,0.07)';
    ctx.fillRect(0, cy - 50, W, 100);

    /* Тени-следы */
    [b1, b2].forEach(b => {
      if (Math.abs(b.vx) < 4) return;
      ctx.save(); ctx.globalAlpha = 0.14;
      for (let s2 = 1; s2 <= 3; s2++) {
        ctx.beginPath();
        ctx.arc(b.x - b.vx * 0.06 * s2, cy, b.r * (1 - s2 * 0.1), 0, Math.PI * 2);
        ctx.fillStyle = b.color; ctx.fill();
      }
      ctx.restore();
    });

    /* Шары */
    [b1, b2].forEach(b => {
      ctx.save();
      ctx.shadowColor = b.color; ctx.shadowBlur = 16;
      ctx.beginPath(); ctx.arc(b.x, cy, b.r, 0, Math.PI * 2);
      const bg2 = ctx.createRadialGradient(b.x - b.r * 0.3, cy - b.r * 0.3, 0, b.x, cy, b.r);
      bg2.addColorStop(0, _nwt_lighten(b.color, 65));
      bg2.addColorStop(1, b.color);
      ctx.fillStyle = bg2; ctx.fill();
      ctx.restore();
      /* Масса и скорость */
      ctx.font = 'bold 12px monospace'; ctx.fillStyle = 'rgba(225,235,255,0.9)';
      ctx.textAlign = 'center';
      ctx.fillText(`${b.mass} кг`, b.x, cy + b.r + 19);
      if (Math.abs(b.vx) > 6) {
        ctx.fillStyle = '#FFD166';
        ctx.fillText(`v=${( b.vx / S ).toFixed(1)}`, b.x, cy - b.r - 8);
      }
      ctx.textAlign = 'left';
    });

    /* Вспышка сил при ударе */
    if (colFlash > 0.06) {
      const mx  = (b1.x + b2.x) / 2;
      const fY  = cy - b1.r - 28;
      const a   = Math.min(1, colFlash * 2.5);
      const len = 65 * a;
      this._arrow(ctx, mx, fY,     mx - len, fY,     '#EF476F', 'F₁₂', 2.5);
      this._arrow(ctx, mx, fY + 8, mx + len, fY + 8, '#4CC9F0', 'F₂₁', 2.5);
      ctx.save(); ctx.globalAlpha = a;
      ctx.font = 'bold 12px sans-serif'; ctx.fillStyle = '#FFD166';
      ctx.textAlign = 'center'; ctx.fillText('|F₁₂| = |F₂₁|', mx, fY - 18); ctx.restore();
    }

    /* Импульс */
    const p1 = (b1.mass * b1.vx / S).toFixed(2);
    const p2 = (b2.mass * b2.vx / S).toFixed(2);
    const pt = ((b1.mass * b1.vx + b2.mass * b2.vx) / S).toFixed(2);
    ctx.font = '12px monospace'; ctx.fillStyle = 'rgba(185,210,255,0.82)';
    ctx.textAlign = 'center';
    ctx.fillText(`p₁ = ${p1}   p₂ = ${p2}   p(сумм) = ${pt} кг·м/с`, W / 2, H * 0.12);
    ctx.textAlign = 'left';

    this._caption(ctx, 'Δp₁ = −Δp₂  (импульс сохраняется)', W, H);
  }

  /* ── Закон III — Сцена C : ракета ───────────────────────── */

  _drawL3C(ctx) {
    const { W, H } = this;
    const s  = this._3C;
    const S  = NewtonSim.SCALE;
    const rx = W / 2;

    this._stars(ctx);

    /* Частицы выхлопа */
    for (const p of s.particles) {
      ctx.save();
      ctx.globalAlpha = p.life * 0.85;
      const col = p.life > 0.6 ? '#FFD166' : p.life > 0.3 ? '#FF6B35' : '#EF476F';
      ctx.shadowColor = col; ctx.shadowBlur = 7;
      ctx.beginPath(); ctx.arc(p.x, p.y, p.r * p.life, 0, Math.PI * 2);
      ctx.fillStyle = col; ctx.fill();
      ctx.restore();
    }

    /* Ракета */
    const ry = s.ry;
    ctx.save(); ctx.shadowColor = '#4CC9F0'; ctx.shadowBlur = 18;

    /* Фюзеляж */
    _nwt_rrect(ctx, rx - 17, ry - 50, 34, 62, 7);
    const rg = ctx.createLinearGradient(rx - 17, 0, rx + 17, 0);
    rg.addColorStop(0, '#1a3a5a'); rg.addColorStop(0.5, '#4CC9F0'); rg.addColorStop(1, '#1a3a5a');
    ctx.fillStyle = rg; ctx.fill();
    ctx.strokeStyle = '#4CC9F0'; ctx.lineWidth = 1.5; ctx.stroke();

    /* Нос */
    ctx.beginPath();
    ctx.moveTo(rx, ry - 72); ctx.lineTo(rx - 15, ry - 50); ctx.lineTo(rx + 15, ry - 50);
    ctx.closePath(); ctx.fillStyle = '#06D6E0'; ctx.fill();

    /* Плавники */
    [[-1], [1]].forEach(([dx]) => {
      ctx.beginPath();
      ctx.moveTo(rx + dx * 17, ry + 12);
      ctx.lineTo(rx + dx * 32, ry + 32);
      ctx.lineTo(rx + dx * 17, ry + 22);
      ctx.closePath(); ctx.fillStyle = '#06D6E0'; ctx.fill();
    });
    /* Иллюминатор */
    ctx.beginPath(); ctx.arc(rx, ry - 20, 7, 0, Math.PI * 2);
    ctx.fillStyle = 'rgba(200,240,255,0.25)'; ctx.fill();
    ctx.strokeStyle = '#4CC9F0'; ctx.lineWidth = 1; ctx.stroke();
    ctx.restore();

    /* Стрелки сил */
    if (s.running) {
      const g_vis = NewtonSim.G * 0.42;
      const a_thrust = 220 / s.rmass;
      const a_net = (a_thrust - g_vis).toFixed(1);
      this._arrow(ctx, rx, ry - 55, rx, ry - 55 - 52, '#7BF5A4', `F тяга`, 2.5);
      this._arrow(ctx, rx - 38, ry, rx - 38, ry + 36, '#FFD166', 'mg', 1.8);
      this._arrow(ctx, rx, ry + 25, rx, ry + 80, '#EF476F', 'F газ', 2.5);
      ctx.font = '12px monospace'; ctx.fillStyle = '#7BF5A4';
      ctx.textAlign = 'center';
      ctx.fillText(`a = F/m − g = ${a_net} м/с²`, rx, ry - 110);
      ctx.textAlign = 'left';
    }
    /* Falling after fuel out — show gravity arrow */
    if (s.fuel <= 0 && !s.stopped) {
      this._arrow(ctx, rx, ry + 25, rx, ry + 65, '#EF476F', 'mg↓', 2.5);
      ctx.font = 'bold 13px sans-serif'; ctx.fillStyle = '#EF476F';
      ctx.textAlign = 'center'; ctx.fillText('Топливо кончилось — ракета падает!', W / 2, H * 0.15); ctx.textAlign = 'left';
    }

    /* Инфо */
    ctx.font = '12px monospace'; ctx.fillStyle = 'rgba(185,210,255,0.82)';
    ctx.textAlign = 'center';
    ctx.fillText(`Масса: ${s.rmass.toFixed(1)} кг  Топливо: ${(s.fuel * 100).toFixed(0)}%`, W / 2, H * 0.94);
    ctx.textAlign = 'left';

    if (s.fuel <= 0 && s.stopped) {
      ctx.font = 'bold 13px sans-serif'; ctx.fillStyle = '#FFD166';
      ctx.textAlign = 'center'; ctx.fillText('Ракета приземлилась. Нажмите «Запуск» для сброса.', W / 2, H * 0.15); ctx.textAlign = 'left';
    } else if (!s.running && s.fuel > 0) {
      ctx.font = '13px sans-serif'; ctx.fillStyle = 'rgba(185,210,255,0.38)';
      ctx.textAlign = 'center'; ctx.fillText('Нажмите «Запуск» для включения двигателя', W / 2, H * 0.50); ctx.textAlign = 'left';
    }

    this._caption(ctx, 'Газ вниз → ракета вверх\n(3-й закон Ньютона)', W, H);
  }

  /* ── Вспомогательные рисовалки ──────────────────────────── */

  _ground(ctx, gY, W) {
    const mu = this.mu;
    /* Поверхность */
    const gg = ctx.createLinearGradient(0, gY, 0, gY + 42);
    gg.addColorStop(0, mu < 0.1 ? '#182535' : mu < 0.45 ? '#1c1f2d' : '#201420');
    gg.addColorStop(1, '#0c101a');
    ctx.fillStyle = gg; ctx.fillRect(0, gY, W, 55);
    /* Линия */
    ctx.strokeStyle = mu < 0.1 ? 'rgba(76,201,240,0.42)' : mu < 0.45 ? 'rgba(155,93,229,0.42)' : 'rgba(239,71,111,0.42)';
    ctx.lineWidth = 2;
    ctx.beginPath(); ctx.moveTo(0, gY); ctx.lineTo(W, gY); 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, gY); ctx.lineTo(x + 12, gY + 12); ctx.stroke();
    }
    /* Шероховатость (при высоком трении) */
    if (mu > 0.06) {
      ctx.fillStyle = `rgba(255,255,255,${mu * 0.055})`;
      for (let x = 9; x < W; x += 20) {
        ctx.beginPath(); ctx.arc(x, gY + 5, 2.5, 0, Math.PI * 2); ctx.fill();
      }
    }
  }

  _stars(ctx) {
    const { W, H } = this;
    for (let i = 0; i < 65; i++) {
      const x = ((i * 139.5 + 7) % W);
      const y = ((i * 97.3  + 5) % (H * 0.88));
      const r = i % 8 === 0 ? 1.6 : 0.9;
      ctx.fillStyle = `rgba(255,255,255,${0.35 + (i % 3) * 0.18})`;
      ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
    }
  }

  _block(ctx, cx, cy, w, h, color, label) {
    ctx.save();
    ctx.shadowColor = color; ctx.shadowBlur = 10;
    _nwt_rrect(ctx, cx - w / 2, cy - h / 2, w, h, 7);
    const bg = ctx.createLinearGradient(cx - w/2, cy - h/2, cx + w/2, cy + h/2);
    bg.addColorStop(0, _nwt_lighten(color, 45));
    bg.addColorStop(1, color);
    ctx.fillStyle = bg; ctx.fill();
    ctx.strokeStyle = _nwt_lighten(color, 60); ctx.lineWidth = 1.5; ctx.stroke();
    ctx.shadowBlur = 0;
    ctx.font = 'bold 11px monospace'; ctx.fillStyle = '#fff';
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.fillText(label, cx, cy);
    ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
    ctx.restore();
  }

  _arrow(ctx, x1, y1, x2, y2, color, label, lw = 2) {
    const dx = x2 - x1, dy = y2 - y1;
    const len = Math.hypot(dx, dy);
    if (len < 5) return;
    const ux = dx / len, uy = dy / len;
    const hw = 7, hl = 13;
    ctx.save();
    ctx.strokeStyle = color; ctx.lineWidth = lw;
    ctx.shadowColor = color; ctx.shadowBlur = 6;
    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 = '11px monospace'; ctx.fillStyle = color;
      const lx = (x1 + x2) / 2 - uy * 16;
      const ly = (y1 + y2) / 2 + ux * 16;
      ctx.textAlign = 'center'; ctx.fillText(label, lx, ly); ctx.textAlign = 'left';
    }
    ctx.restore();
  }

  _newtonGraphValues() {
    const S = NewtonSim.SCALE;
    if (this.law === 1 && this.scene === 'A') {
      const b = this._1A;
      const x = b.bx ? b.bx / S : 0;
      const v = Math.hypot(b.bvx || 0, b.bvy || 0) / S;
      const a = this._paused ? 0 : (this.mu * NewtonSim.G);
      return [x, v, -a];
    }
    if (this.law === 2) {
      const b = this._2;
      const x = (b.b1x || 0) / S;
      const v = (b.b1vx || 0) / S;
      const a = b.running ? (this.force / this.mass1) : 0;
      return [x, v, a];
    }
    if (this.law === 4 && this.scene === 'atwood' && this._atw) {
      const atw = this._atw;
      const x = atw.y1 ? atw.y1 / S : 0;
      const v = (atw.vy || 0) / S;
      const a = atw.aPhys || 0;
      return [x, v, a];
    }
    // default: use 1A block or zero
    return [0, 0, 0];
  }

  _fma(ctx, F, m, a, cx, y) {
    ctx.save();
    ctx.font = 'bold 15px monospace';
    ctx.textAlign = 'center';
    ctx.shadowColor = '#7BF5A4'; ctx.shadowBlur = 14;
    ctx.fillStyle = 'rgba(123,245,164,0.88)';
    ctx.fillText(`F = m·a      ${F} Н = ${m} кг × ${a.toFixed(1)} м/с²`, cx, y);
    ctx.restore();
  }

  _graph(ctx, data, x, y, w, h, color, label) {
    if (data.length < 2) return;
    const max = Math.max(...data, 0.01);
    _nwt_rrect(ctx, x, y, w, h, 4);
    ctx.fillStyle = 'rgba(0,0,0,0.38)'; ctx.fill();
    ctx.strokeStyle = 'rgba(255,255,255,0.10)'; ctx.lineWidth = 1; ctx.stroke();
    ctx.strokeStyle = color; ctx.lineWidth = 1.6;
    ctx.beginPath();
    data.forEach((v, i) => {
      const px = x + (i / (data.length - 1)) * w;
      const py = y + h - 3 - (v / max) * (h - 7);
      if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
    });
    ctx.stroke();
    ctx.font = '9px monospace'; ctx.fillStyle = color;
    ctx.fillText(label, x + 3, y + 11);
    ctx.fillText(max.toFixed(1), x + 3, y + h - 3);
  }

  _caption(ctx, text, W, H) {
    ctx.save();
    ctx.font = 'italic 12px sans-serif';
    ctx.fillStyle = 'rgba(185,210,255,0.35)';
    ctx.textAlign = 'right';
    text.split('\n').forEach((line, i) => ctx.fillText(line, W - 16, H * 0.90 + i * 18));
    ctx.textAlign = 'left';
    ctx.restore();
  }

  /* ── Info ──────────────────────────────────────────────────── */

  info() {
    if (this.law === 4) return this._infoClassic();

    const S = NewtonSim.SCALE;
    const base = { law: this.law, scene: this.scene };

    if (this.law === 1 && this.scene === 'A') {
      const b   = this._1A;
      const spd = Math.hypot(b.bvx, b.bvy) / S;
      const fFr = this.mu * this.mass1 * NewtonSim.G;
      return { ...base, v: spd.toFixed(2), fFr: fFr.toFixed(2), mu: this.mu.toFixed(2), m: this.mass1 };
    }
    if (this.law === 1) {
      const s  = this._1B;
      const spd = Math.hypot(s.bvx, s.bvy) / S;
      return { ...base, v: spd.toFixed(2), cut: s.cut };
    }
    if (this.law === 2) {
      const a = this.force / this.mass1;
      const v = this._2.b1vx / S;
      return { ...base, F: this.force, m: this.mass1, a: a.toFixed(2), v: v.toFixed(2) };
    }
    if (this.scene === 'A') {
      const vBall   = this._3A.ball ? (this._3A.ball.vx / S).toFixed(1) : '—';
      const vCannon = (this._3A.cvx / S).toFixed(2);
      return { ...base, vBall, vCannon, m1: this.mass1, m2: this.mass2 };
    }
    if (this.scene === 'B') {
      const { b1, b2 } = this._3B;
      return { ...base,
        p1: (b1.mass * b1.vx / S).toFixed(2),
        p2: (b2.mass * b2.vx / S).toFixed(2),
        pt: ((b1.mass * b1.vx + b2.mass * b2.vx) / S).toFixed(2),
      };
    }
    /* III-C rocket */
    const s = this._3C;
    const g_vis = NewtonSim.G * 0.42;
    const a_net = s.running ? (220 / s.rmass - g_vis) : (s.stopped ? 0 : -g_vis);
    return { ...base,
      a:    a_net.toFixed(1),
      v:    Math.abs(s.rvy / S).toFixed(2),
      fuel: (s.fuel * 100).toFixed(0),
      m:    s.rmass.toFixed(1),
    };
  }

  /* ═══════════════════════════════════════════════════════════════
     КЛАССИЧЕСКИЕ ЗАДАЧИ (law === 4)
     Сцены: 'atwood' | 'ramp' | 'roll'
     ═══════════════════════════════════════════════════════════════ */

  /* ── Сброс сцен ─────────────────────────────────────────────── */

  _resetAtwood() {
    const { W, H } = this;
    /* Параметры */
    const m1 = this.atwM1;  // кг
    const m2 = this.atwM2;  // кг
    const G  = NewtonSim.G;
    const massive = this.atwMassive; // bool: массивный блок?
    /* Формула: a = (m2-m1)*g / (m1+m2) для идеального блока
       с массивным блоком (I = 0.5*M*R², M=2кг, R=0.04м):
       a = (m2-m1)*g / (m1+m2 + I/R²) = (m2-m1)*g / (m1+m2+1)  */
    const denom = m1 + m2 + (massive ? 1 : 0);
    const aPhys = (m2 - m1) * G / denom; // м/с² (may be negative)
    const T     = 2 * m1 * m2 * G / denom; // Н

    this._atw = {
      /* визуальные позиции грузов (px) — блок по центру сверху */
      pulleyX: W * 0.50,
      pulleyY: H * 0.14,
      ropeLen: H * 0.60,           // общая длина нити (px)
      y1: H * 0.30,                // верх груза 1 (px)
      y2: H * 0.30,                // верх груза 2 (px)
      vy: 0,                       // скорость грузов (px/s, + вниз для груза 2)
      running: false,
      aPhys, T,
      finished: false,
      t: 0,
      /* история скоростей */
      history: [],
    };
  }

  _resetRamp() {
    const { W, H } = this;
    this._ramp = {
      /* угол горки в радианах */
      alpha: this.rampAlpha * Math.PI / 180,
      /* состояние */
      bx: 0,          // позиция по склону (м)
      bv: 0,          // скорость по склону (м/с)
      running: false,
      t: 0,
      history: [],    // v(t) — для графика
      finished: false,
    };
  }

  _resetRoll() {
    const { W, H } = this;
    this._roll = {
      alpha: this.rollAlpha * Math.PI / 180,
      L: 3.0,          // длина горки, м
      /* позиции трёх тел (по склону, м) */
      sBall: 0, sCyl: 0, sHoop: 0,
      /* скорости */
      vBall: 0, vCyl: 0, vHoop: 0,
      /* угловые скорости для вращения */
      wBall: 0, wCyl: 0, wHoop: 0,
      running: false,
      t: 0,
      winner: null,    // 'ball'|'cyl'|'hoop'
      finishTimes: {},
      withFriction: this.rollFriction,
    };
  }

  /* ── Шаг физики — Машина Атвуда ─────────────────────────────── */

  _stepAtwood(dt) {
    const s = this._atw;
    if (!s.running || s.finished) return;

    const S  = NewtonSim.SCALE; // px/м
    const { H } = this;

    /* Интеграция */
    s.vy += s.aPhys * S * dt;  // px/s²  →  px/s
    s.y1 -= s.vy * dt;         // груз 1 движется вверх если vy>0
    s.y2 += s.vy * dt;         // груз 2 вниз

    s.t += dt;

    /* Ограничение хода */
    const minY = s.pulleyY + 18;
    const maxY = H * 0.88;
    if (s.y1 < minY || s.y2 > maxY || s.y1 > maxY || s.y2 < minY) {
      s.running = false;
      s.finished = true;
      if (window.LabFX) LabFX.sound.play('chime');
    }

    /* История для HUD */
    if (s.t % 0.05 < dt + 0.001) {
      const vMs = Math.abs(s.vy) / S;
      s.history.push(vMs);
      if (s.history.length > 120) s.history.shift();
    }
  }

  /* ── Шаг физики — Наклонная плоскость ──────────────────────── */

  _stepRamp(dt) {
    const s = this._ramp;
    if (!s.running || s.finished) return;

    const G     = NewtonSim.G;
    const alpha = this.rampAlpha * Math.PI / 180;
    const mu    = this.rampMu;
    const Fapp  = this.rampForce;

    const sinA  = Math.sin(alpha);
    const cosA  = Math.cos(alpha);
    const Fdrive = G * sinA + Fapp / (this.mass1); // Н/кг = м/с²
    const Ffrmax = mu * G * cosA;

    let accel = 0;
    if (Math.abs(Fdrive) <= Ffrmax) {
      /* Покоится */
      accel = 0;
      s.bv  = 0;
    } else {
      accel = Fdrive - Math.sign(Fdrive) * Ffrmax;
    }

    s.bv += accel * dt;
    /* Не скользить назад если нет внешней силы и v~0 */
    if (s.bv < 0 && Fapp <= 0) s.bv = 0;
    s.bx += s.bv * dt;
    s.t  += dt;

    const Lramp = 3.5; // м
    if (s.bx >= Lramp) {
      s.bx = Lramp; s.bv = 0; s.running = false; s.finished = true;
      if (window.LabFX) LabFX.sound.play('chime');
    }

    /* История v(t) */
    if (s.t % 0.05 < dt + 0.001) {
      s.history.push(s.bv);
      if (s.history.length > 120) s.history.shift();
    }
  }

  /* ── Шаг физики — Скатывание тел ──────────────────────────── */

  _stepRoll(dt) {
    const s = this._roll;
    if (!s.running) return;

    const G     = NewtonSim.G;
    const alpha = this.rollAlpha * Math.PI / 180;
    const sinA  = Math.sin(alpha);
    const mu    = 0.30;  // коэффициент сцепления для проскальзывания

    /* Моменты инерции: k = I/(mR²)
       Шар: k=2/5, Цилиндр: k=1/2, Обруч: k=1 */
    const bodies = [
      { key: 'Ball', k: 2/5, color: '#EF476F', label: 'Шар' },
      { key: 'Cyl',  k: 1/2, color: '#4CC9F0', label: 'Цилиндр' },
      { key: 'Hoop', k: 1,   color: '#FFD166', label: 'Обруч' },
    ];

    s.t += dt;

    for (const b of bodies) {
      if (s.finishTimes[b.key] !== undefined) continue; // уже финишировал

      let accel;
      if (!s.withFriction) {
        /* Чистое качение: a = g*sinα / (1 + k) */
        accel = G * sinA / (1 + b.k);
      } else {
        /* С возможным проскальзыванием: ограничиваем трением */
        const aRoll    = G * sinA / (1 + b.k);
        const aSlide   = G * (sinA - mu * Math.cos(alpha));
        accel = Math.max(0, Math.max(aRoll, aSlide));
      }

      s['v' + b.key] += accel * dt;
      s['s' + b.key] += s['v' + b.key] * dt;
      /* угловая скорость для рисовки */
      s['w' + b.key] += (accel / 0.08) * dt; // R=0.08м

      if (s['s' + b.key] >= s.L) {
        s['s' + b.key] = s.L;
        s.finishTimes[b.key] = s.t;
        if (!s.winner) {
          s.winner = b.key;
          if (window.LabFX) LabFX.sound.play('chime');
        }
      }
    }

    /* Все финишировали */
    if (Object.keys(s.finishTimes).length === 3) {
      s.running = false;
    }
  }

  /* ── Отрисовка — Машина Атвуда ─────────────────────────────── */

  _drawAtwood(ctx) {
    const { W, H } = this;
    const s  = this._atw;
    const S  = NewtonSim.SCALE;
    const m1 = this.atwM1;
    const m2 = this.atwM2;

    /* Потолок */
    ctx.fillStyle = '#2a3040';
    ctx.fillRect(s.pulleyX - 50, 0, 100, s.pulleyY - 10);
    ctx.fillStyle = '#3a4560';
    ctx.fillRect(0, 0, W, 10);

    /* Блок (шкив) */
    ctx.save();
    ctx.strokeStyle = '#8899cc';
    ctx.lineWidth = 3;
    ctx.beginPath();
    ctx.arc(s.pulleyX, s.pulleyY, 14, 0, Math.PI * 2);
    ctx.fillStyle = '#1e2840';
    ctx.fill(); ctx.stroke();
    ctx.restore();
    if (this.atwMassive) {
      ctx.save();
      ctx.font = 'bold 9px Manrope,sans-serif';
      ctx.fillStyle = '#aabbff';
      ctx.textAlign = 'center';
      ctx.fillText('M', s.pulleyX, s.pulleyY + 4);
      ctx.restore();
    }

    /* Нити */
    ctx.strokeStyle = '#c0c8e0';
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(s.pulleyX - 12, s.pulleyY);
    ctx.lineTo(s.pulleyX - 12, s.y1);
    ctx.moveTo(s.pulleyX + 12, s.pulleyY);
    ctx.lineTo(s.pulleyX + 12, s.y2);
    ctx.stroke();

    /* Вспомогательные размеры грузов */
    const bw1 = 18 + m1 * 2.2;
    const bh1 = 18 + m1 * 2.2;
    const bw2 = 18 + m2 * 2.2;
    const bh2 = 18 + m2 * 2.2;

    /* Груз 1 */
    _nwt_rrect(ctx, s.pulleyX - 12 - bw1 / 2, s.y1, bw1, bh1, 4);
    ctx.fillStyle = '#EF476F';
    ctx.fill();
    ctx.strokeStyle = _nwt_lighten('#EF476F', 60);
    ctx.lineWidth = 1.5;
    ctx.stroke();
    ctx.fillStyle = '#fff';
    ctx.font = `bold ${Math.min(12, bw1 * 0.5)}px Manrope,sans-serif`;
    ctx.textAlign = 'center';
    ctx.fillText(m1 + ' кг', s.pulleyX - 12, s.y1 + bh1 * 0.62);

    /* Груз 2 */
    _nwt_rrect(ctx, s.pulleyX + 12 - bw2 / 2, s.y2, bw2, bh2, 4);
    ctx.fillStyle = '#4CC9F0';
    ctx.fill();
    ctx.strokeStyle = _nwt_lighten('#4CC9F0', 60);
    ctx.lineWidth = 1.5;
    ctx.stroke();
    ctx.fillStyle = '#fff';
    ctx.font = `bold ${Math.min(12, bw2 * 0.5)}px Manrope,sans-serif`;
    ctx.textAlign = 'center';
    ctx.fillText(m2 + ' кг', s.pulleyX + 12, s.y2 + bh2 * 0.62);

    ctx.textAlign = 'left';

    /* HUD: расчёты */
    const vMs  = (Math.abs(s.vy) / S).toFixed(2);
    const aDisp = s.aPhys.toFixed(3);
    const TDisp = s.T.toFixed(2);
    const eq    = m1 === m2;

    this._atwHUD(ctx, W, H, vMs, aDisp, TDisp, eq, s);

    /* График скорости */
    if (s.history.length > 2) {
      this._graph(ctx, s.history, W - 155, H - 90, 140, 65, '#7BF5A4', 'v (м/с)');
    }
  }

  _atwHUD(ctx, W, H, vMs, aDisp, TDisp, eq, s) {
    const lines = eq
      ? ['Равновесие', `a = 0 м/с²`, `T = ${TDisp} Н`]
      : [`a = ${aDisp} м/с²`, `T = ${TDisp} Н`, `v = ${vMs} м/с`];
    ctx.save();
    ctx.font = '13px Manrope,sans-serif';
    ctx.fillStyle = 'rgba(0,0,0,0.55)';
    _nwt_rrect(ctx, 14, H - 80, 160, 64, 8);
    ctx.fill();
    ctx.fillStyle = '#c8d8ff';
    lines.forEach((l, i) => ctx.fillText(l, 22, H - 58 + i * 18));
    ctx.restore();
  }

  /* ── Отрисовка — Наклонная плоскость ───────────────────────── */

  _drawRamp(ctx) {
    const { W, H } = this;
    const s     = this._ramp;
    const S     = NewtonSim.SCALE;
    const alpha = this.rampAlpha * Math.PI / 180;
    const mu    = this.rampMu;
    const m     = this.mass1;
    const Fapp  = this.rampForce;
    const G     = NewtonSim.G;

    /* Геометрия горки */
    const baseX = W * 0.12;
    const baseY = H * 0.84;
    const Lpx   = W * 0.78;
    const Hpx   = Lpx * Math.tan(alpha);
    const topX  = baseX;
    const topY  = baseY - Hpx;

    /* Наклонная плоскость */
    ctx.beginPath();
    ctx.moveTo(baseX, baseY);
    ctx.lineTo(baseX + Lpx, baseY);
    ctx.lineTo(topX, topY);
    ctx.closePath();
    ctx.fillStyle = '#1e2840';
    ctx.fill();
    ctx.strokeStyle = '#3a4d7a';
    ctx.lineWidth = 2;
    ctx.stroke();

    /* Отметки угла */
    ctx.save();
    ctx.strokeStyle = '#7BF5A4';
    ctx.lineWidth = 1.5;
    const arcR = 36;
    ctx.beginPath();
    ctx.arc(baseX + Lpx, baseY, arcR, Math.PI, Math.PI + alpha, false);
    ctx.stroke();
    ctx.fillStyle = '#7BF5A4';
    ctx.font = '11px Manrope,sans-serif';
    ctx.fillText(this.rampAlpha + '°', baseX + Lpx - arcR * 1.55, baseY - 8);
    ctx.restore();

    /* Поверхность горки */
    ctx.save();
    ctx.strokeStyle = 'rgba(200,220,255,0.18)';
    ctx.lineWidth = 1;
    const Nlines = 7;
    for (let i = 1; i < Nlines; i++) {
      const frac = i / Nlines;
      const sx = topX + (baseX + Lpx - topX) * frac;
      const sy = topY + (baseY - topY) * frac;
      const nx =  Math.sin(alpha) * 8;
      const ny = -Math.cos(alpha) * 8;
      ctx.beginPath();
      ctx.moveTo(sx, sy);
      ctx.lineTo(sx + nx, sy + ny);
      ctx.stroke();
    }
    ctx.restore();

    /* Тело на горке */
    const bSize = 28 + m * 0.8;
    const Spos  = s.bx * S; // px от начала горки
    /* Вектор вдоль горки */
    const ux    = Math.cos(Math.PI - alpha);
    const uy    = -Math.sin(Math.PI - alpha);
    const bCx   = baseX + Lpx - Spos * Math.cos(alpha) - (bSize / 2) * Math.sin(alpha);
    const bCy   = baseY - Spos * Math.sin(alpha) + (bSize / 2) * Math.cos(alpha) - bSize / 2;

    ctx.save();
    ctx.translate(bCx + bSize / 2, bCy + bSize / 2);
    ctx.rotate(-alpha);
    _nwt_rrect(ctx, -bSize / 2, -bSize / 2, bSize, bSize, 5);
    ctx.fillStyle = '#EF476F';
    ctx.fill();
    ctx.strokeStyle = _nwt_lighten('#EF476F', 50);
    ctx.lineWidth = 1.5;
    ctx.stroke();
    ctx.fillStyle = '#fff';
    ctx.font = 'bold 10px Manrope,sans-serif';
    ctx.textAlign = 'center';
    ctx.fillText(m + ' кг', 0, 4);

    /* Векторы сил на теле */
    const sinA = Math.sin(alpha), cosA = Math.cos(alpha);
    const Fgrav  = m * G;
    const Fnorm  = Fgrav * cosA;
    const Fdrive = Fgrav * sinA;
    const Ffrmax = mu * Fnorm;
    const sliding = Fdrive > Ffrmax;
    const Ffr    = sliding ? Ffrmax : Fdrive;
    const arrowScale = 2.5;

    /* Сила тяжести (вниз) */
    this._arrow(ctx, 0, 0, 0, Fgrav * arrowScale, '#FFD166', 'mg');
    /* Нормальная реакция (перп. горке = вверх в ЛСО) */
    this._arrow(ctx, 0, 0, 0, -Fnorm * arrowScale, '#4CC9F0', 'N');
    /* Компонент по горке (вдоль горки = влево в ЛСО) */
    this._arrow(ctx, 0, 0, -Fdrive * arrowScale, 0, '#EF476F', 'mg·sinα');
    /* Трение (вправо вдоль горки если скользит) */
    if (Ffr > 0.5) {
      const frColor = sliding ? '#7BF5A4' : '#8899cc';
      this._arrow(ctx, 0, 0, Ffr * arrowScale, 0, frColor, sliding ? 'F_тр' : 'F_ст');
    }
    if (Fapp > 0.5) {
      this._arrow(ctx, 0, 0, Fapp * arrowScale / m, 0, '#FF9F1C', 'F_пр');
    }

    ctx.restore();

    /* Состояние */
    const state = sliding
      ? `Скользит  a = ${(G * (sinA - mu * cosA)).toFixed(2)} м/с²`
      : `Покоится  F_ст = ${(m * G * sinA).toFixed(1)} Н`;

    ctx.save();
    ctx.font = '12px Manrope,sans-serif';
    ctx.fillStyle = 'rgba(0,0,0,0.55)';
    _nwt_rrect(ctx, 14, H - 100, 230, 82, 8);
    ctx.fill();
    ctx.fillStyle = '#c8d8ff';
    ctx.fillText(state, 22, H - 79);
    ctx.fillText(`mg·sinα = ${(m * G * sinA).toFixed(1)} Н`, 22, H - 61);
    ctx.fillText(`μ·mg·cosα = ${(mu * m * G * cosA).toFixed(1)} Н`, 22, H - 43);
    ctx.fillText(`v = ${s.bv.toFixed(2)} м/с   t = ${s.t.toFixed(1)} с`, 22, H - 25);
    ctx.restore();

    /* График v(t) */
    if (s.history.length > 2) {
      this._graph(ctx, s.history, W - 155, H - 90, 140, 65, '#EF476F', 'v (м/с)');
    }
  }

  /* ── Отрисовка — Скатывание тел ─────────────────────────────── */

  _drawRoll(ctx) {
    const { W, H } = this;
    const s     = this._roll;
    const S     = NewtonSim.SCALE;
    const alpha = this.rollAlpha * Math.PI / 180;
    const G     = NewtonSim.G;

    /* Геометрия горки */
    const baseX = W * 0.10;
    const baseY = H * 0.82;
    const Lpx   = W * 0.82;
    const Hpx   = Lpx * Math.tan(alpha);

    ctx.beginPath();
    ctx.moveTo(baseX, baseY);
    ctx.lineTo(baseX + Lpx, baseY);
    ctx.lineTo(baseX, baseY - Hpx);
    ctx.closePath();
    ctx.fillStyle = '#1a2035';
    ctx.fill();
    ctx.strokeStyle = '#3a4d6a';
    ctx.lineWidth = 2;
    ctx.stroke();

    /* Отметка угла */
    ctx.save();
    ctx.strokeStyle = '#aabbcc';
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.arc(baseX, baseY, 32, -alpha, 0, false);
    ctx.stroke();
    ctx.fillStyle = '#aabbcc';
    ctx.font = '11px Manrope,sans-serif';
    ctx.fillText(this.rollAlpha + '°', baseX + 38, baseY - 6);
    ctx.restore();

    /* Линия финиша */
    const finishFrac = 0.96;
    const finX = baseX + Lpx * (1 - finishFrac * Math.cos(alpha) * Math.cos(alpha));
    const finY = baseY - Lpx * finishFrac * Math.sin(alpha) * Math.cos(alpha);
    ctx.save();
    ctx.strokeStyle = '#FFD16688';
    ctx.lineWidth = 2;
    ctx.setLineDash([4, 4]);
    ctx.beginPath();
    ctx.moveTo(finX, finY - 12);
    ctx.lineTo(finX, finY + 12);
    ctx.stroke();
    ctx.setLineDash([]);
    ctx.restore();

    /* Три тела */
    const bodies = [
      { key: 'Ball', k: 2/5, color: '#EF476F', label: 'Шар',     radius: 10 },
      { key: 'Cyl',  k: 1/2, color: '#4CC9F0', label: 'Цилиндр', radius: 11 },
      { key: 'Hoop', k: 1,   color: '#FFD166', label: 'Обруч',   radius: 12 },
    ];

    for (let i = 0; i < 3; i++) {
      const b    = bodies[i];
      const sPos = s['s' + b.key]; // позиция по склону (м)
      const wAng = s['w' + b.key]; // угол поворота
      const Spos = sPos * S;        // px

      /* Центр тела вдоль наклонной */
      const cx = baseX + Spos * Math.cos(alpha) + b.radius * Math.sin(alpha);
      const cy = (baseY - Hpx) + Spos * Math.cos(alpha) * Math.tan(alpha)
               - Spos * Math.sin(alpha) + (Hpx - Spos * Math.sin(alpha))
               + b.radius * Math.cos(alpha) - Hpx + Spos * Math.sin(alpha);

      /* Пересчитаем проще: точка на поверхности горки */
      const sx0 = baseX + Spos * Math.cos(alpha);
      const sy0 = baseY - Spos * Math.sin(alpha);
      /* Смещение от поверхности (нормаль к горке) */
      const nx = -Math.sin(alpha);
      const ny = -Math.cos(alpha);
      const bx = sx0 + nx * (b.radius + i * 1.5);
      const by = sy0 + ny * (b.radius + i * 1.5);

      ctx.save();
      ctx.translate(bx, by);
      ctx.rotate(-alpha + wAng);

      if (b.key === 'Hoop') {
        /* Обруч — только контур */
        ctx.beginPath();
        ctx.arc(0, 0, b.radius, 0, Math.PI * 2);
        ctx.strokeStyle = b.color;
        ctx.lineWidth = 3;
        ctx.stroke();
        /* спицы */
        ctx.strokeStyle = b.color + '80';
        ctx.lineWidth = 1;
        for (let a = 0; a < Math.PI * 2; a += Math.PI / 3) {
          ctx.beginPath();
          ctx.moveTo(0, 0);
          ctx.lineTo(Math.cos(a) * b.radius, Math.sin(a) * b.radius);
          ctx.stroke();
        }
      } else if (b.key === 'Cyl') {
        /* Цилиндр — закрашенный круг + линия диаметра */
        ctx.beginPath();
        ctx.arc(0, 0, b.radius, 0, Math.PI * 2);
        ctx.fillStyle = b.color + 'aa';
        ctx.fill();
        ctx.strokeStyle = b.color;
        ctx.lineWidth = 2;
        ctx.stroke();
        ctx.beginPath();
        ctx.moveTo(-b.radius, 0); ctx.lineTo(b.radius, 0);
        ctx.strokeStyle = '#ffffffaa';
        ctx.lineWidth = 1;
        ctx.stroke();
      } else {
        /* Шар — градиентная заливка */
        const grad = ctx.createRadialGradient(-b.radius * 0.3, -b.radius * 0.3, 1,
          0, 0, b.radius);
        grad.addColorStop(0, _nwt_lighten(b.color, 60));
        grad.addColorStop(1, b.color);
        ctx.beginPath();
        ctx.arc(0, 0, b.radius, 0, Math.PI * 2);
        ctx.fillStyle = grad;
        ctx.fill();
        ctx.strokeStyle = _nwt_lighten(b.color, 40);
        ctx.lineWidth = 1.5;
        ctx.stroke();
      }
      ctx.restore();

      /* Подпись */
      ctx.fillStyle = b.color;
      ctx.font = '9px Manrope,sans-serif';
      ctx.textAlign = 'center';
      ctx.fillText(b.label, bx, by - b.radius - 4);
    }

    ctx.textAlign = 'left';

    /* HUD */
    const sinA = Math.sin(alpha);
    const aBall = (G * sinA / (1 + 2/5)).toFixed(3);
    const aCyl  = (G * sinA / (1 + 1/2)).toFixed(3);
    const aHoop = (G * sinA / (1 + 1)).toFixed(3);

    ctx.save();
    ctx.font = '11px Manrope,sans-serif';
    ctx.fillStyle = 'rgba(0,0,0,0.55)';
    _nwt_rrect(ctx, 14, H - 102, 210, 88, 8);
    ctx.fill();
    const lines = [
      ['Шар (k=2/5):',     `a=${aBall} м/с²`, '#EF476F'],
      ['Цилиндр (k=1/2):', `a=${aCyl} м/с²`,  '#4CC9F0'],
      ['Обруч (k=1):',     `a=${aHoop} м/с²`, '#FFD166'],
    ];
    lines.forEach(([lbl, val, col], i) => {
      ctx.fillStyle = col;
      ctx.fillText(lbl, 22, H - 82 + i * 20);
      ctx.fillStyle = '#e0e8ff';
      ctx.fillText(val, 130, H - 82 + i * 20);
    });
    /* Победитель */
    if (s.winner) {
      const wLabel = bodies.find(b => b.key === s.winner)?.label || s.winner;
      ctx.fillStyle = '#7BF5A4';
      ctx.font = 'bold 12px Manrope,sans-serif';
      ctx.fillText('Первым: ' + wLabel, 22, H - 18);
    } else {
      ctx.fillStyle = '#8899aa';
      ctx.font = '11px Manrope,sans-serif';
      ctx.fillText('t = ' + s.t.toFixed(1) + ' с', 22, H - 18);
    }
    ctx.restore();
  }

  /* ── info() расширение для law=4 ──────────────────────────── */

  _infoClassic() {
    const scene = this.scene;
    const base  = { law: 4, scene };
    const G     = NewtonSim.G;
    const S     = NewtonSim.SCALE;

    if (scene === 'atwood') {
      const s = this._atw;
      return { ...base,
        a:  s.aPhys.toFixed(3),
        T:  s.T.toFixed(2),
        v:  (Math.abs(s.vy) / S).toFixed(2),
        eq: this.atwM1 === this.atwM2,
        m1: this.atwM1,
        m2: this.atwM2,
      };
    }
    if (scene === 'ramp') {
      const s     = this._ramp;
      const alpha = this.rampAlpha * Math.PI / 180;
      const sinA  = Math.sin(alpha), cosA = Math.cos(alpha);
      const m     = this.mass1;
      const Fdrive = m * G * sinA;
      const Ffrmax = this.rampMu * m * G * cosA;
      const sliding = (Fdrive + this.rampForce) > Ffrmax;
      const accel  = sliding
        ? G * (sinA - this.rampMu * cosA) + this.rampForce / m
        : 0;
      return { ...base,
        alpha: this.rampAlpha,
        accel: accel.toFixed(3),
        v:     s.bv.toFixed(2),
        Fdrive: Fdrive.toFixed(1),
        Ffrmax: Ffrmax.toFixed(1),
        sliding,
      };
    }
    /* roll */
    const s     = this._roll;
    const alpha = this.rollAlpha * Math.PI / 180;
    const sinA  = Math.sin(alpha);
    return { ...base,
      aBall: (G * sinA / (1 + 2/5)).toFixed(3),
      aCyl:  (G * sinA / (1 + 1/2)).toFixed(3),
      aHoop: (G * sinA / (1 + 1  )).toFixed(3),
      winner: s.winner,
      t: s.t.toFixed(1),
    };
  }
}

/* ── Утилиты ─────────────────────────────────────────────────── */

function _nwt_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 _nwt_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)})`;
}

/* ─── GraphPanel helpers ─────────────────────────── */

function newtonToggleGraphs() {
  const sim = typeof newtonSim !== 'undefined' ? newtonSim : null;
  if (!sim || !window.LSGraphPanelUI) return;
  sim._graphsOn = !sim._graphsOn;
  if (sim._graphsOn) {
    sim._tSim = 0;
    const canvasOuter = document.querySelector('#sim-dynamics .proj-canvas-outer');
    if (!canvasOuter) return;
    const S = NewtonSim.SCALE;
    sim._graphUI = new GraphPanelUI(canvasOuter, {
      maxPoints: 400,
      traces: ['x', 'v', 'a'],
      labels: ['x', 'v', 'a'],
      units: ['м', 'м/с', 'м/с²'],
      colors: ['#06D6E0', '#FFD166', '#EF476F'],
      toggleBtnId: 'btn-newton-graphs',
      title: 'Графики x/v/a'
    });
    sim._graphUI.isOn = true;
    sim._graphUI._build();
  } else {
    if (sim._graphUI) { sim._graphUI._destroy(); sim._graphUI = null; }
  }
  const btn = document.getElementById('btn-newton-graphs');
  if (btn) btn.classList.toggle('active', sim._graphsOn);
}

/* ─── lab UI init ─────────────────────────────────── */
  var newtonSim = null;
  var sandboxSim = null;
  let _dynMode = 'sandbox'; // current mode: 'sandbox' | 'law1' | 'law2' | 'law3'

  function _openDynamics(preset) {
    document.getElementById('sim-topbar-title').textContent = 'Динамика';
    _simShow('sim-dynamics');
    _simShow('ctrl-dynamics');
    requestAnimationFrame(() => requestAnimationFrame(() => {
      // init sandbox
      const sbCanvas = document.getElementById('sandbox-canvas');
      if (!sandboxSim) {
        sandboxSim = new ForceSandboxSim(sbCanvas);
        sandboxSim.onUpdate = _sbUpdateUI;
      }
      // init newton
      const nwCanvas = document.getElementById('newton-canvas');
      if (!newtonSim) {
        newtonSim = new NewtonSim(nwCanvas);
        newtonSim.onUpdate = _newtonUpdateUI;
        _dynInjectTCBar();
      }
      // activate current mode
      dynMode(_dynMode);
      if (preset) setTimeout(() => sbPreset(preset), 120);
    }));
  }

  function _dynInjectTCBar() {
    if (!window.LSTimeControl || !window.LSBuildTimeControlUI) return;
    var wrap = document.getElementById('sim-dynamics');
    if (!wrap || wrap.querySelector('.tc-bar')) return;

    /* proxy TC that routes to whichever sim is active */
    var proxyTC = {
      paused: false,
      scale: 1,
      advance: function(dt) { return this.paused ? 0 : dt * this.scale; },
      setScale: function(s) {
        this.scale = +s;
        if (newtonSim   && newtonSim._tc)   newtonSim._tc.setScale(+s);
        if (sandboxSim  && sandboxSim._tc)  sandboxSim._tc.setScale(+s);
      },
      togglePause: function() {
        this.paused = !this.paused;
        if (newtonSim   && newtonSim._tc)   newtonSim._tc.paused   = this.paused;
        if (sandboxSim  && sandboxSim._tc)  sandboxSim._tc.paused  = this.paused;
        return this.paused;
      },
    };

    var simProxy = { draw: function() {
      if (_dynMode === 'sandbox' && sandboxSim) sandboxSim.draw();
      else if (newtonSim) newtonSim.draw();
    }};
    var tcBar = window.LSBuildTimeControlUI(simProxy, proxyTC, { scrubSupported: false });

    /* Trails toggle (sandbox only — newton doesn't have generic bodies) */
    var sep = document.createElement('div');
    sep.style.cssText = 'width:1px;height:18px;background:rgba(255,255,255,0.12);flex-shrink:0';
    var trailBtn = document.createElement('button');
    trailBtn.className = 'zoom-btn';
    trailBtn.style.cssText = 'display:inline-flex;align-items:center;gap:3px;padding:3px 8px;border:1px solid rgba(255,255,255,0.15);border-radius:7px;background:rgba(28,18,48,0.8);color:#ccc;font-size:.68rem;font-weight:700;cursor:pointer;white-space:nowrap;flex-shrink:0;font-family:Manrope,sans-serif';
    trailBtn.innerHTML = '<svg class="ic" viewBox="0 0 24 24" width="13" height="13"><path d="M3 12 Q6 3 12 12 Q18 21 21 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg> Следы';
    trailBtn.title = 'Следы движения (Sandbox)';
    trailBtn.addEventListener('click', function() {
      var on = sandboxSim ? !sandboxSim.showTrail : false;
      if (sandboxSim) sandboxSim.showTrail = on;
      trailBtn.style.background  = on ? 'rgba(6,214,224,0.2)' : 'rgba(28,18,48,0.8)';
      trailBtn.style.color       = on ? '#06D6E0' : '#ccc';
      trailBtn.style.borderColor = on ? 'rgba(6,214,224,0.5)' : 'rgba(255,255,255,0.15)';
    });
    tcBar.appendChild(sep);
    tcBar.appendChild(trailBtn);

    var statsBar = wrap.querySelector('.proj-stats-bar');
    if (statsBar) wrap.insertBefore(tcBar, statsBar);
    else wrap.appendChild(tcBar);
  }

  function dynMode(mode, btn) {
    _dynMode = mode;
    const isSandbox = mode === 'sandbox';

    // toggle mode buttons
    document.querySelectorAll('.dyn-mode').forEach(b => b.classList.remove('active'));
    const modeBtn = document.getElementById('dyn-mode-' + mode);
    if (modeBtn) modeBtn.classList.add('active');

    // toggle panels
    document.getElementById('dyn-sandbox-panel').style.display = isSandbox ? '' : 'none';
    document.getElementById('dyn-newton-panel').style.display  = isSandbox ? 'none' : '';

    // toggle canvases
    document.getElementById('sandbox-canvas').style.display = isSandbox ? 'block' : 'none';
    document.getElementById('newton-canvas').style.display  = isSandbox ? 'none' : 'block';

    // toggle topbar tool groups
    document.getElementById('ctrl-dyn-sb').style.display = isSandbox ? 'contents' : 'none';
    document.getElementById('ctrl-dyn-nw').style.display = isSandbox ? 'none' : 'contents';

    if (isSandbox) {
      // stop newton, start sandbox
      if (newtonSim) newtonSim.stop();
      if (sandboxSim) { sandboxSim.fit(); sandboxSim.start(); }
      _sbUpdateUI(sandboxSim ? sandboxSim.info() : null);
    } else {
      // stop sandbox, switch newton law
      if (sandboxSim) sandboxSim.stop();
      const lawN = mode === 'law1' ? 1 : mode === 'law2' ? 2 : mode === 'law4' ? 4 : 3;
      if (newtonSim) {
        newtonSim.setLaw(lawN);
        newtonSim.fit();
        newtonSim.start();
        _newtonSyncUI();
        _newtonUpdateUI(newtonSim.info());
      }
    }
  }

  function dynPause() {
    if (_dynMode === 'sandbox') {
      if (sandboxSim) sandboxSim.togglePause();
    } else {
      if (newtonSim) newtonSim.togglePause();
    }
  }

  function dynReset() {
    if (_dynMode === 'sandbox') {
      sbReset();
    } else {
      _resetNewtonScene();
    }
  }

  const _NEWTON_SCENES = {
    1: {
      A: { desc: 'Закон инерции: тело скользит по поверхности. Нажми на canvas — толкни блок.', action: null },
      B: { desc: 'Инерция в орбите: шар вращается на нити. Отруби нить — полетит по касательной!', action: '<svg class="ic" viewBox="0 0 24 24"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg> Отрубить нить' },
      C: { desc: 'Инерция в космосе: тело движется равномерно, нет сил — нет ускорения.', action: null },
    },
    2: {
      A: { desc: 'Второй закон: F = ma. Прикладывай силу и следи за ускорением и скоростью.', action: '<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Запустить' },
      B: { desc: 'Два тела, разные массы — одинаковая сила. Сравни ускорения!', action: '<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Запустить' },
      C: { desc: 'Второй закон: изменяй силу и массу ползунками, наблюдай в реальном времени.', action: '<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Запустить' },
    },
    3: {
      A: { desc: 'Третий закон: пушка выстрелила — отдача. Импульс сохраняется!', action: 'Выстрел' },
      B: { desc: 'Третий закон: два шара сталкиваются — силы равны и противоположны.', action: '<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Столкнуть' },
      C: { desc: 'Реактивное движение: ракета выбрасывает газ — летит в обратную сторону.', action: 'Двигатель' },
    },
    4: {
      atwood: { desc: 'Машина Атвуда: два груза на нити через блок. a = (m₂-m₁)g/(m₁+m₂).', action: '<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Старт' },
      ramp:   { desc: 'Наклонная плоскость: тело скользит если mg·sinα > μ·mg·cosα.', action: '<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Старт' },
      roll:   { desc: 'Скатывание тел: шар, цилиндр, обруч. a = g·sinα/(1+k). Кто быстрее?', action: '<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Гонка' },
    },
  };

  const _NEWTON_PRESETS = {
    1: [
      { label: 'Космос',  fn: 'space' },
      { label: 'Лёд',    fn: 'ice' },
      { label: 'Асфальт', fn: 'asphalt' },
      { label: 'Резина',  fn: 'rubber' },
    ],
    2: [
      { label: 'Лёгкий',  fn: 'light' },
      { label: 'Тяжёлый', fn: 'heavy' },
      { label: 'Сравнить', fn: 'compare' },
    ],
    3: [
      { label: 'Большая пушка', fn: 'big_cannon' },
      { label: 'Маленькая',    fn: 'small_cannon' },
      { label: 'Равные шары',  fn: 'equal_balls' },
    ],
  };

  // _openNewton is now handled by _openDynamics + dynMode

  // newtonLaw is now handled by dynMode('law1'/'law2'/'law3')

  function newtonScene(s, topBtn, panelBtn) {
    if (!newtonSim) return;
    newtonSim.setScene(s);
    document.querySelectorAll('.nscene-btn').forEach(b => {
      b.classList.toggle('active', b.id === 'nscn-' + s || b.id === 'nscn-panel-' + s);
    });
    _newtonSyncUI();
    _newtonUpdateUI(newtonSim.info());
  }

  function _newtonSyncUI() {
    if (!newtonSim) return;
    const law = newtonSim.law;
    const scene = newtonSim.scene;
    const sceneData = (_NEWTON_SCENES[law] || {})[scene] || {};

    // description
    const desc = document.getElementById('newton-scene-desc');
    if (desc) desc.textContent = sceneData.desc || '';

    // action button label
    const lbl = sceneData.action || (law === 1 ? '<svg class="ic" viewBox="0 0 24 24"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg> Нить' : '<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Действие');
    document.getElementById('newton-action-label').innerHTML = lbl;
    document.getElementById('newton-action-top').innerHTML = lbl;

    /* ── Показ/скрытие панелей ── */
    const isClassic = (law === 4);

    // classic panel
    const classicPanel = document.getElementById('newton-classic-panel');
    if (classicPanel) classicPanel.style.display = isClassic ? '' : 'none';

    // standard scene row
    const sceneTitle = document.getElementById('newton-scene-title');
    const sceneRow   = document.getElementById('newton-scene-row');
    const presetsTitle = document.querySelector('#dyn-newton-panel .gp-section-title[style*="margin-top"]');
    if (sceneTitle) sceneTitle.style.display = isClassic ? 'none' : '';
    if (sceneRow)   sceneRow.style.display   = isClassic ? 'none' : '';

    // show/hide standard sliders (hidden for law 4)
    document.getElementById('newton-mu-block').style.display    = (!isClassic && law === 1 && scene === 'A') ? '' : 'none';
    document.getElementById('newton-mass1-block').style.display = (!isClassic && (law === 2 || law === 3)) ? '' : 'none';
    document.getElementById('newton-mass2-block').style.display = (!isClassic && law === 3) ? '' : 'none';
    document.getElementById('newton-force-block').style.display = (!isClassic && law === 2) ? '' : 'none';

    if (!isClassic) {
      // sync slider values from sim
      document.getElementById('sl-newton-mu').value   = newtonSim.mu;
      document.getElementById('newton-mu-val').textContent = newtonSim.mu.toFixed(2);
      document.getElementById('sl-newton-m1').value   = newtonSim.mass1;
      document.getElementById('newton-m1-val').textContent = newtonSim.mass1 + ' кг';
      document.getElementById('sl-newton-m2').value   = newtonSim.mass2;
      document.getElementById('newton-m2-val').textContent = newtonSim.mass2 + ' кг';
      document.getElementById('sl-newton-F').value    = newtonSim.force;
      document.getElementById('newton-F-val').textContent  = newtonSim.force + ' Н';
    }

    // sync scene highlight buttons — classic uses atwood/ramp/roll
    if (isClassic) {
      ['atwood','ramp','roll'].forEach(s => {
        const tb = document.getElementById('nscn-cl-' + s);
        const pb = document.getElementById('nscn-panel-cl-' + s);
        const on = s === scene;
        if (tb) tb.classList.toggle('active', on);
        if (pb) pb.classList.toggle('active', on);
      });
      // update classic sub-panel visibility
      ['atwood','ramp','roll'].forEach(s => {
        const el = document.getElementById('cl-sub-' + s);
        if (el) el.style.display = (s === scene) ? '' : 'none';
      });
      // sync classic sliders
      _syncClassicSliders();
    } else {
      // sync scene highlight buttons in both topbar and panel (standard A/B/C)
      ['A','B','C'].forEach(s => {
        const tb = document.getElementById('nscn-' + s);
        const pb = document.getElementById('nscn-panel-' + s);
        const on = s === scene;
        if (tb) tb.classList.toggle('active', on);
        if (pb) pb.classList.toggle('active', on);
      });
    }

    // presets (only for laws 1-3)
    const presetsEl = document.getElementById('newton-presets');
    const presetsSection = presetsEl && presetsEl.previousElementSibling;
    if (presetsEl) {
      presetsEl.style.display = isClassic ? 'none' : '';
      if (presetsSection && presetsSection.classList.contains('gp-section-title'))
        presetsSection.style.display = isClassic ? 'none' : '';
      const presets = (!isClassic && _NEWTON_PRESETS[law]) || [];
      presetsEl.innerHTML = presets.map(p =>
        `<button class="proj-preset-chip" onclick="newtonPreset('${p.fn}')">${p.label}</button>`
      ).join('');
    }

    // scene B/C visibility for standard mode
    const cBtn    = document.getElementById('nscn-panel-C');
    const cTopBtn = document.getElementById('nscn-C');
    const bBtn    = document.getElementById('nscn-panel-B');
    const bTopBtn = document.getElementById('nscn-B');
    const aBtn    = document.getElementById('nscn-panel-A');
    const aTopBtn = document.getElementById('nscn-A');

    // topbar scene buttons (A/B/C) hidden for classic; cl-* shown instead
    if (aBtn)    aBtn.style.display    = isClassic ? 'none' : '';
    if (aTopBtn) aTopBtn.style.display = isClassic ? 'none' : '';
    if (bBtn)    bBtn.style.display    = isClassic ? 'none' : '';
    if (bTopBtn) bTopBtn.style.display = isClassic ? 'none' : '';
    const showC = !isClassic && law === 3;
    if (cBtn)    cBtn.style.display    = showC ? '' : 'none';
    if (cTopBtn) cTopBtn.style.display = showC ? '' : 'none';

    // classic topbar scene buttons
    ['atwood','ramp','roll'].forEach(s => {
      const tb = document.getElementById('nscn-cl-' + s);
      if (tb) tb.style.display = isClassic ? '' : 'none';
    });
  }

  function _syncClassicSliders() {
    if (!newtonSim) return;
    /* Atwood */
    _setVal('sl-atw-m1',    'atw-m1-val',    newtonSim.atwM1,    v => v + ' кг');
    _setVal('sl-atw-m2',    'atw-m2-val',    newtonSim.atwM2,    v => v + ' кг');
    /* Ramp */
    _setVal('sl-ramp-alpha','ramp-alpha-val', newtonSim.rampAlpha, v => v + '°');
    _setVal('sl-ramp-mu',   'ramp-mu-val',   newtonSim.rampMu,   v => (+v).toFixed(2));
    _setVal('sl-ramp-force','ramp-force-val',newtonSim.rampForce, v => v + ' Н');
    /* Roll */
    _setVal('sl-roll-alpha','roll-alpha-val', newtonSim.rollAlpha, v => v + '°');
  }

  function _setVal(sliderId, valId, val, fmt) {
    const sl = document.getElementById(sliderId);
    const vl = document.getElementById(valId);
    if (sl) sl.value = val;
    if (vl) vl.textContent = fmt(val);
  }

  function newtonAction() {
    if (!newtonSim) return;
    const law = newtonSim.law;
    const scene = newtonSim.scene;
    if (law === 1 && scene === 'B') newtonSim.cutString();
    else if (law === 2) newtonSim.startL2();
    else if (law === 3 && scene === 'A') newtonSim.fireCannon();
    else if (law === 3 && scene === 'B') newtonSim._reset3B ? newtonSim._reset3B() : null;
    else if (law === 3 && scene === 'C') newtonSim.toggleRocket();
    else if (law === 4 && scene === 'atwood') newtonSim.startAtwood();
    else if (law === 4 && scene === 'ramp')   newtonSim.startRamp();
    else if (law === 4 && scene === 'roll')   newtonSim.startRoll();
    _newtonUpdateUI(newtonSim.info());
  }

  function _resetNewtonScene() {
    if (!newtonSim) return;
    const law = newtonSim.law;
    const scene = newtonSim.scene;
    if (law === 4) {
      if (scene === 'atwood') newtonSim._resetAtwood();
      else if (scene === 'ramp') newtonSim._resetRamp();
      else newtonSim._resetRoll();
    } else if (law === 1 && scene === 'A') newtonSim.preset('ice');
    else if (law === 1) newtonSim.setScene(scene);
    else if (law === 2) newtonSim.resetL2 ? newtonSim.resetL2() : newtonSim.setScene(scene);
    else newtonSim.setScene(scene);
    _newtonUpdateUI(newtonSim.info());
  }

  /* ── classic scene selector ─────────────────────────────────── */
  function classicScene(s) {
    if (!newtonSim) return;
    newtonSim.setScene(s);
    document.querySelectorAll('.cl-scene-btn').forEach(b => {
      b.classList.toggle('active', b.dataset.scene === s);
    });
    _newtonSyncUI();
    _newtonUpdateUI(newtonSim.info());
  }

  /* ── classic param change handlers ──────────────────────────── */
  function atwM1Change() {
    const v = +document.getElementById('sl-atw-m1').value;
    document.getElementById('atw-m1-val').textContent = v + ' кг';
    if (newtonSim) newtonSim.setAtwM1(v);
  }
  function atwM2Change() {
    const v = +document.getElementById('sl-atw-m2').value;
    document.getElementById('atw-m2-val').textContent = v + ' кг';
    if (newtonSim) newtonSim.setAtwM2(v);
  }
  function atwMassiveToggle(massive) {
    if (newtonSim) newtonSim.setAtwMassive(massive);
    document.querySelectorAll('.atw-massive-btn').forEach(b =>
      b.classList.toggle('active', b.dataset.val === (massive ? 'true' : 'false')));
  }
  function rampMassChange() {
    const v = +document.getElementById('sl-ramp-mass').value;
    const vl = document.getElementById('newton-m1-ramp-val');
    if (vl) vl.textContent = v + ' кг';
    if (newtonSim) { newtonSim.mass1 = v; newtonSim._resetRamp(); }
  }
  function rampAlphaChange() {
    const v = +document.getElementById('sl-ramp-alpha').value;
    document.getElementById('ramp-alpha-val').textContent = v + '°';
    if (newtonSim) newtonSim.setRampAlpha(v);
  }
  function rampMuChange() {
    const v = +document.getElementById('sl-ramp-mu').value;
    document.getElementById('ramp-mu-val').textContent = (+v).toFixed(2);
    if (newtonSim) newtonSim.setRampMu(v);
  }
  function rampForceChange() {
    const v = +document.getElementById('sl-ramp-force').value;
    document.getElementById('ramp-force-val').textContent = v + ' Н';
    if (newtonSim) newtonSim.setRampForce(v);
  }
  function rollAlphaChange() {
    const v = +document.getElementById('sl-roll-alpha').value;
    document.getElementById('roll-alpha-val').textContent = v + '°';
    if (newtonSim) newtonSim.setRollAlpha(v);
  }
  function rollFrictionToggle(val) {
    if (newtonSim) newtonSim.setRollFriction(val);
    document.querySelectorAll('.roll-friction-btn').forEach(b =>
      b.classList.toggle('active', b.dataset.val === (val ? 'true' : 'false')));
  }

  function newtonMuChange() {
    const v = +document.getElementById('sl-newton-mu').value;
    document.getElementById('newton-mu-val').textContent = v.toFixed(2);
    if (newtonSim) newtonSim.setMu(v);
  }

  function newtonMass1Change() {
    const v = +document.getElementById('sl-newton-m1').value;
    document.getElementById('newton-m1-val').textContent = v + ' кг';
    if (newtonSim) newtonSim.setMass1(v);
  }

  function newtonMass2Change() {
    const v = +document.getElementById('sl-newton-m2').value;
    document.getElementById('newton-m2-val').textContent = v + ' кг';
    if (newtonSim) newtonSim.setMass2(v);
  }

  function newtonForceChange() {
    const v = +document.getElementById('sl-newton-F').value;
    document.getElementById('newton-F-val').textContent = v + ' Н';
    if (newtonSim) newtonSim.setForce(v);
  }

  function newtonPreset(name) {
    if (!newtonSim) return;
    newtonSim.preset(name);
    _newtonSyncUI();
    _newtonUpdateUI(newtonSim.info());
  }

  function _newtonUpdateUI(info) {
    if (!info) return;
    const law = info.law;
    const scene = info.scene;

    if (law === 4 && scene === 'atwood') {
      document.getElementById('dbar-l1').textContent = 'Машина Атвуда';
      document.getElementById('dbar-v1').textContent = info.eq ? 'Равновесие' : 'Движение';
      document.getElementById('dbar-l2').textContent = 'Ускорение a';
      document.getElementById('dbar-v2').textContent = info.a + ' м/с²';
      document.getElementById('dbar-l3').textContent = 'Натяжение T';
      document.getElementById('dbar-v3').textContent = info.T + ' Н';
      document.getElementById('dbar-l4').textContent = 'Скорость v';
      document.getElementById('dbar-v4').textContent = info.v + ' м/с';
      document.getElementById('dbar-l5').textContent = 'm₁ / m₂';
      document.getElementById('dbar-v5').textContent = info.m1 + ' / ' + info.m2 + ' кг';
    } else if (law === 4 && scene === 'ramp') {
      document.getElementById('dbar-l1').textContent = 'Наклонная';
      document.getElementById('dbar-v1').textContent = info.sliding ? 'Скользит' : 'Покой';
      document.getElementById('dbar-l2').textContent = 'α';
      document.getElementById('dbar-v2').textContent = info.alpha + '°';
      document.getElementById('dbar-l3').textContent = 'Ускорение';
      document.getElementById('dbar-v3').textContent = info.accel + ' м/с²';
      document.getElementById('dbar-l4').textContent = 'mg·sinα';
      document.getElementById('dbar-v4').textContent = info.Fdrive + ' Н';
      document.getElementById('dbar-l5').textContent = 'μmg·cosα';
      document.getElementById('dbar-v5').textContent = info.Ffrmax + ' Н';
    } else if (law === 4 && scene === 'roll') {
      document.getElementById('dbar-l1').textContent = 'Скатывание';
      document.getElementById('dbar-v1').textContent = info.winner ? 'Финиш: ' + info.winner : 't=' + info.t + ' с';
      document.getElementById('dbar-l2').textContent = 'a(шар)';
      document.getElementById('dbar-v2').textContent = info.aBall + ' м/с²';
      document.getElementById('dbar-l3').textContent = 'a(цилиндр)';
      document.getElementById('dbar-v3').textContent = info.aCyl + ' м/с²';
      document.getElementById('dbar-l4').textContent = 'a(обруч)';
      document.getElementById('dbar-v4').textContent = info.aHoop + ' м/с²';
      document.getElementById('dbar-l5').textContent = 'α';
      document.getElementById('dbar-v5').textContent = (newtonSim ? newtonSim.rollAlpha : '—') + '°';
    } else if (law === 1 && scene === 'A') {
      document.getElementById('dbar-l1').textContent = 'Закон I-A';
      document.getElementById('dbar-v1').textContent = 'Скольжение';
      document.getElementById('dbar-l2').textContent = 'Скорость';
      document.getElementById('dbar-v2').textContent = info.v + ' м/с';
      document.getElementById('dbar-l3').textContent = 'Сила трения';
      document.getElementById('dbar-v3').textContent = info.fFr + ' Н';
      document.getElementById('dbar-l4').textContent = 'Масса';
      document.getElementById('dbar-v4').textContent = info.m + ' кг';
      document.getElementById('dbar-l5').textContent = 'μ';
      document.getElementById('dbar-v5').textContent = info.mu;
    } else if (law === 1) {
      document.getElementById('dbar-l1').textContent = 'Закон I-B';
      document.getElementById('dbar-v1').textContent = info.cut ? 'Нить срублена' : 'Вращение';
      document.getElementById('dbar-l2').textContent = 'Скорость';
      document.getElementById('dbar-v2').textContent = info.v + ' м/с';
      document.getElementById('dbar-l3').textContent = '';
      document.getElementById('dbar-v3').textContent = '—';
      document.getElementById('dbar-l4').textContent = '';
      document.getElementById('dbar-v4').textContent = '—';
      document.getElementById('dbar-l5').textContent = '';
      document.getElementById('dbar-v5').textContent = '—';
    } else if (law === 2) {
      document.getElementById('dbar-l1').textContent = 'Закон II';
      document.getElementById('dbar-v1').textContent = 'F = ma';
      document.getElementById('dbar-l2').textContent = 'Сила F';
      document.getElementById('dbar-v2').textContent = info.F + ' Н';
      document.getElementById('dbar-l3').textContent = 'Масса m';
      document.getElementById('dbar-v3').textContent = info.m + ' кг';
      document.getElementById('dbar-l4').textContent = 'Ускор. a';
      document.getElementById('dbar-v4').textContent = info.a + ' м/с²';
      document.getElementById('dbar-l5').textContent = 'Скорость';
      document.getElementById('dbar-v5').textContent = info.v + ' м/с';
    } else if (scene === 'A') {
      document.getElementById('dbar-l1').textContent = 'Закон III-A';
      document.getElementById('dbar-v1').textContent = 'Пушка';
      document.getElementById('dbar-l2').textContent = 'v снаряда';
      document.getElementById('dbar-v2').textContent = info.vBall !== '—' ? info.vBall + ' м/с' : '—';
      document.getElementById('dbar-l3').textContent = 'v пушки';
      document.getElementById('dbar-v3').textContent = info.vCannon + ' м/с';
      document.getElementById('dbar-l4').textContent = 'm снаряда';
      document.getElementById('dbar-v4').textContent = info.m1 + ' кг';
      document.getElementById('dbar-l5').textContent = 'm пушки';
      document.getElementById('dbar-v5').textContent = info.m2 + ' кг';
    } else if (scene === 'B') {
      document.getElementById('dbar-l1').textContent = 'Закон III-B';
      document.getElementById('dbar-v1').textContent = 'Удар';
      document.getElementById('dbar-l2').textContent = 'p₁';
      document.getElementById('dbar-v2').textContent = info.p1 + ' кг·м/с';
      document.getElementById('dbar-l3').textContent = 'p₂';
      document.getElementById('dbar-v3').textContent = info.p2 + ' кг·м/с';
      document.getElementById('dbar-l4').textContent = 'p суммарный';
      document.getElementById('dbar-v4').textContent = info.pt + ' кг·м/с';
      document.getElementById('dbar-l5').textContent = '';
      document.getElementById('dbar-v5').textContent = '—';
    } else {
      document.getElementById('dbar-l1').textContent = 'Закон III-C';
      document.getElementById('dbar-v1').textContent = 'Ракета';
      document.getElementById('dbar-l2').textContent = 'Ускорение';
      document.getElementById('dbar-v2').textContent = info.a + ' м/с²';
      document.getElementById('dbar-l3').textContent = 'Скорость';
      document.getElementById('dbar-v3').textContent = info.v + ' м/с';
      document.getElementById('dbar-l4').textContent = 'Масса';
      document.getElementById('dbar-v4').textContent = info.m + ' кг';
      document.getElementById('dbar-l5').textContent = 'Топливо';
      document.getElementById('dbar-v5').textContent = info.fuel + '%';
    }
  }

  // _openSandbox is now handled by _openDynamics + dynMode

  function sbTool(t, btn) {
    if (!sandboxSim) return;
    sandboxSim.tool = t;
    sandboxSim._springStart = null;
    sandboxSim._ropeStart   = null;
    document.querySelectorAll('.sb-tool-btn').forEach(b => b.classList.toggle('active', b.id === 'sbt-' + t));
    document.querySelectorAll('.sb-panel-tool').forEach(b => b.classList.toggle('active', b.id === 'sbpt-' + t));
    const canvas = document.getElementById('sandbox-canvas');
    canvas.style.cursor = t === 'erase' ? 'not-allowed'
                        : (t === 'spring' || t === 'rope') ? 'cell'
                        : t === 'anchor' ? 'copy'
                        : 'crosshair';
    document.getElementById('sb-spring-block').style.display = t === 'spring' ? '' : 'none';
  }

  function sbSpringKChange() {
    const v = +document.getElementById('sl-sb-springk').value;
    document.getElementById('sb-springk-val').textContent = v + ' Н/м';
    if (sandboxSim) sandboxSim.newSpringK = v;
  }

  function sbForceMode(m, btn) {
    if (!sandboxSim) return;
    sandboxSim.forceMode = m;
    document.querySelectorAll('.sb-fmode').forEach(b => b.classList.toggle('active', b.id === 'sbfm-' + m));
  }

  function sbMassChange() {
    const v = +document.getElementById('sl-sb-mass').value;
    document.getElementById('sb-mass-val').textContent = v + ' кг';
    if (sandboxSim) sandboxSim.newMass = v;
  }

  function sbRestChange() {
    const v = +document.getElementById('sl-sb-rest').value;
    document.getElementById('sb-rest-val').textContent = v.toFixed(2);
    if (sandboxSim) sandboxSim.newRestitution = v;
  }

  function sbFloorMuChange() {
    const v = +document.getElementById('sl-sb-floormu').value;
    document.getElementById('sb-floormu-val').textContent = v.toFixed(2);
    if (sandboxSim) sandboxSim.floorMu = v;
  }

  function sbWorldToggle() {
    if (!sandboxSim) return;
    sandboxSim.gravity  = document.getElementById('sb-gravity').checked;
    sandboxSim.hasFloor = document.getElementById('sb-floor').checked;
    sandboxSim.hasWalls = document.getElementById('sb-walls').checked;
    sandboxSim.airDrag  = document.getElementById('sb-airdrag').checked;
  }

  function sbRampToggle() {
    if (!sandboxSim) return;
    const on = document.getElementById('sb-ramp').checked;
    sandboxSim.setRamp(on);
    document.getElementById('sb-ramp-block').style.display = on ? '' : 'none';
  }

  function sbAngleChange() {
    const v = +document.getElementById('sl-sb-angle').value;
    document.getElementById('sb-angle-val').textContent = v + '°';
    if (sandboxSim) sandboxSim.setRampAngle(v);
  }

  function sbRampMuChange() {
    const v = +document.getElementById('sl-sb-rampmu').value;
    document.getElementById('sb-rampmu-val').textContent = v.toFixed(2);
    if (sandboxSim) sandboxSim.setRampMu(v);
  }

  function sbDecompToggle() {
    if (!sandboxSim) return;
    sandboxSim.showDecomp = document.getElementById('sb-decomp').checked;
  }

  function sbDisplayToggle() {
    if (!sandboxSim) return;
    sandboxSim.showForces   = document.getElementById('sb-forces').checked;
    sandboxSim.showVelocity = document.getElementById('sb-vel').checked;
    sandboxSim.showFBD      = document.getElementById('sb-fbd').checked;
    sandboxSim.showEnergy   = document.getElementById('sb-energy').checked;
    sandboxSim.showTrail    = document.getElementById('sb-trail').checked;
  }

  function sbTimeScale(v, btn) {
    if (!sandboxSim) return;
    sandboxSim.timeScale = v;
    document.querySelectorAll('.sb-time').forEach(b => b.classList.remove('active'));
    if (btn) btn.classList.add('active');
  }

  function sbPreset(name) {
    if (!sandboxSim) return;
    sandboxSim.preset(name);
    // sync world checkboxes
    document.getElementById('sb-gravity').checked  = sandboxSim.gravity;
    document.getElementById('sb-floor').checked    = sandboxSim.hasFloor;
    document.getElementById('sb-walls').checked    = sandboxSim.hasWalls;
    document.getElementById('sb-airdrag').checked  = sandboxSim.airDrag;
    document.getElementById('sl-sb-floormu').value = sandboxSim.floorMu;
    document.getElementById('sb-floormu-val').textContent = sandboxSim.floorMu.toFixed(2);
    // sync ramp
    document.getElementById('sb-ramp').checked = sandboxSim.ramp;
    document.getElementById('sb-ramp-block').style.display = sandboxSim.ramp ? '' : 'none';
    document.getElementById('sl-sb-angle').value = sandboxSim.rampAngle;
    document.getElementById('sb-angle-val').textContent = sandboxSim.rampAngle + '°';
    document.getElementById('sl-sb-rampmu').value = sandboxSim.rampMu;
    document.getElementById('sb-rampmu-val').textContent = sandboxSim.rampMu.toFixed(2);
    _sbUpdateUI(sandboxSim.info());
  }

  function sbReset() {
    if (!sandboxSim) return;
    sandboxSim.reset();
    _sbUpdateUI(sandboxSim.info());
  }

  function _sbUpdateUI(info) {
    if (!info) return;
    document.getElementById('dbar-l1').textContent = 'Тел / связей';
    document.getElementById('dbar-v1').textContent = info.bodies + ' / ' + (info.springs + info.ropes);
    document.getElementById('dbar-l2').textContent = 'KE (Дж)';
    document.getElementById('dbar-v2').textContent = info.KE;
    document.getElementById('dbar-l3').textContent = 'PE (Дж)';
    document.getElementById('dbar-v3').textContent = info.PE;
    document.getElementById('dbar-l4').textContent = 'ΣF';
    document.getElementById('dbar-v4').textContent = info.netF;
    document.getElementById('dbar-l5').textContent = 'Время';
    document.getElementById('dbar-v5').textContent = info.time + ' с';
  }

  /* ── Energy toggle: newton ── */
  function dynToggleEnergy() {
    if (nSim) {
      nSim._energyOn = !nSim._energyOn;
      const on  = nSim._energyOn;
      const btn = document.getElementById('nwt-energy-btn');
      if (btn) {
        btn.classList.toggle('active', on);
        btn.querySelector('span').textContent = on ? 'Энергия: Вкл' : 'Энергия: Выкл';
      }
      if (!on) { nSim._frictionWork = 0; nSim._energyScale = 0; }
      nSim.draw();
    }
  }

  /* ── chem sandbox ── */

