'use strict';
/* ══════════════════════════════════════════════════════════════
   OpticsBenchSim — unified optical bench simulation
   Merges: ThinLensSim (thinlens.js) + MirrorSim (mirror.js) + RefractionSim (refraction.js)

   Modes:
     'lens'      — thin lens: 1/f = 1/d + 1/d', M = -d'/d
     'mirror'    — curved / flat mirrors, same formula
     'refraction'— Snell's law: n₁sin θ₁ = n₂sin θ₂, TIR, dispersion

   Physics preserved verbatim from original sims.
   ══════════════════════════════════════════════════════════════ */

/* ─────────────────────────────────────────────────────────────
   0.  WAVELENGTH UTILITIES
───────────────────────────────────────────────────────────────*/

/**
 * Convert wavelength (nm, 380–780) to an sRGB CSS color string.
 * Well-known approximation by Dan Bruton (adjusted for alpha at edges).
 */
function wavelengthToRGB(nm) {
  let R = 0, G = 0, B = 0, a = 1;
  if      (nm >= 380 && nm < 440) { R = -(nm - 440) / (440 - 380); G = 0; B = 1; }
  else if (nm < 490) { R = 0; G = (nm - 440) / (490 - 440); B = 1; }
  else if (nm < 510) { R = 0; G = 1; B = -(nm - 510) / (510 - 490); }
  else if (nm < 580) { R = (nm - 510) / (580 - 510); G = 1; B = 0; }
  else if (nm < 645) { R = 1; G = -(nm - 645) / (645 - 580); B = 0; }
  else if (nm < 781) { R = 1; G = 0; B = 0; }
  if      (nm >= 700) a = 0.3 + 0.7 * (780 - nm) / (780 - 700);
  else if (nm < 420)  a = 0.3 + 0.7 * (nm - 380) / (420 - 380);
  return `rgba(${Math.round(R * 255 * a)},${Math.round(G * 255 * a)},${Math.round(B * 255 * a)},1)`;
}

/**
 * Wavelength-dependent index of refraction — simple linear model.
 * n(550nm) = n0 (glass baseline), with normal dispersion (blue > red).
 * Formula: n(λ) = n0 - 0.0002*(λ - 550)
 */
function _nAtWavelength(n0, nm) {
  return n0 - 0.0002 * (nm - 550);
}

/**
 * Return the ray color for the current global wavelength setting.
 * If white-light mode: returns null (caller must draw multi-spectral bundles).
 * Otherwise returns a CSS color string.
 */
function _obRayColor(fallback) {
  if (window._obWhiteLight) return null;
  return wavelengthToRGB(window._obWavelength || 550);
}

/** Spectral samples used for white-light / prism dispersion */
const OB_SPECTRAL = [
  { nm: 405, label: 'violet' },
  { nm: 450, label: 'blue'   },
  { nm: 510, label: 'green'  },
  { nm: 550, label: 'yellow' },
  { nm: 610, label: 'orange' },
  { nm: 660, label: 'red'    },
];

/* ─────────────────────────────────────────────────────────────
   0b.  OB_FX — VISUAL DEPTH TOGGLES (A-agent)
   Five optional visual layers controlled by checkboxes.
   State persisted in localStorage as 'ob_fx_state'.
───────────────────────────────────────────────────────────────*/

function _obFXLoad() {
  try {
    const raw = localStorage.getItem('ob_fx_state');
    if (raw) Object.assign(window.OB_FX, JSON.parse(raw));
  } catch (_e) { /* ignore */ }
}

function _obFXSave() {
  try { localStorage.setItem('ob_fx_state', JSON.stringify(window.OB_FX)); } catch (_e) { /* ignore */ }
}

/** Called by each toggle checkbox */
function obFXToggle(key, val) {
  window.OB_FX[key] = !!val;
  _obFXSave();
  _obRedraw();
}

window.OB_FX = { wavefronts: false, mist: false, flare: false, huygens: false, caustics: false };
_obFXLoad();

/* ── Mist: background smoke-particle emitter ── */
let _obMistRaf = 0, _obMistCtx = null, _obMistFrame = 0;

function _obMistTick() {
  if (!window.OB_FX.mist) { _obMistRaf = 0; return; }
  const ctx = _obMistCtx;
  if (!ctx || !window.LabFX) { _obMistRaf = 0; return; }
  _obMistFrame++;
  if (_obMistFrame % 2 === 0) {
    const W = ctx.canvas.width, H = ctx.canvas.height;
    LabFX.particles.emit({
      ctx, x: Math.random() * W, y: Math.random() * H,
      count: 1, color: 'rgba(180,200,255,0.06)', speed: 3,
      spread: Math.PI * 2, life: 3000, shape: 'smoke', size: 30, glow: false,
    });
  }
  _obMistRaf = requestAnimationFrame(_obMistTick);
}

function _obStartMist(ctx) {
  _obMistCtx = ctx;
  if (!_obMistRaf && window.OB_FX.mist && window.LabFX) {
    _obMistRaf = requestAnimationFrame(_obMistTick);
  }
}

/* ── Shared wavefront phase clock ── */
let _obWFPhase = 0, _obWFLastT = 0;

/**
 * Draw animated sinusoidal wavefront ticks along a ray segment.
 * Ticks march along the ray to simulate a travelling wave.
 * In-glass spacing compresses by 1/nMed (shows refraction at boundary).
 */
function _obDrawWavefrontTicks(ctx, x1, y1, x2, y2, nm, nMed) {
  nMed = nMed || 1;
  const dx = x2 - x1, dy = y2 - y1;
  const len = Math.hypot(dx, dy);
  if (len < 4) return;
  const lambdaPx = Math.max(8, (nm * 0.45) / nMed);
  const ux = dx / len, uy = dy / len;
  const qx = -uy, qy = ux;   // perpendicular unit vector
  const tickLen = 5;
  const col = wavelengthToRGB(nm || 550);
  const phaseOff = (_obWFPhase * lambdaPx * 2) % lambdaPx;
  ctx.save();
  ctx.globalAlpha = 0.52;
  ctx.strokeStyle = col; ctx.lineWidth = 1;
  let t = phaseOff;
  while (t < len) {
    const cx = x1 + ux * t, cy = y1 + uy * t;
    ctx.beginPath();
    ctx.moveTo(cx - qx * tickLen, cy - qy * tickLen);
    ctx.lineTo(cx + qx * tickLen, cy + qy * tickLen);
    ctx.stroke();
    t += lambdaPx;
  }
  ctx.restore();
}

/**
 * Draw lens flare: starburst + ghost reflections + chromatic ring.
 * Uses additive blending for the glow effect.
 */
function _obDrawLensFlare(ctx, srcX, srcY, W, H) {
  ctx.save();
  ctx.globalCompositeOperation = 'lighter';
  const spikes = 6, spikeLen = 40;
  for (let i = 0; i < spikes; i++) {
    const angle = (i / spikes) * Math.PI + _obWFPhase * 0.3;
    const grad = ctx.createLinearGradient(srcX, srcY,
      srcX + Math.cos(angle) * spikeLen, srcY + Math.sin(angle) * spikeLen);
    grad.addColorStop(0, 'rgba(255,255,200,0.32)');
    grad.addColorStop(1, 'rgba(255,255,200,0)');
    ctx.strokeStyle = grad; ctx.lineWidth = 1.5;
    ctx.beginPath(); ctx.moveTo(srcX, srcY);
    ctx.lineTo(srcX + Math.cos(angle) * spikeLen, srcY + Math.sin(angle) * spikeLen);
    ctx.stroke();
    ctx.beginPath(); ctx.moveTo(srcX, srcY);
    ctx.lineTo(srcX - Math.cos(angle) * spikeLen, srcY - Math.sin(angle) * spikeLen);
    ctx.stroke();
  }
  const cg = ctx.createRadialGradient(srcX, srcY, 0, srcX, srcY, 14);
  cg.addColorStop(0, 'rgba(255,255,240,0.55)');
  cg.addColorStop(1, 'rgba(255,255,240,0)');
  ctx.fillStyle = cg; ctx.beginPath(); ctx.arc(srcX, srcY, 14, 0, Math.PI * 2); ctx.fill();
  const gx = W / 2 - srcX, gy = H / 2 - srcY;
  [
    { t: 0.3, r: 18, col: 'rgba(100,80,255,0.12)' },
    { t: 0.6, r: 11, col: 'rgba(255,60,60,0.10)'  },
    { t: 1.1, r: 7,  col: 'rgba(60,220,255,0.09)' },
  ].forEach(function(g) {
    const gX = srcX + gx * g.t, gY = srcY + gy * g.t;
    const gg = ctx.createRadialGradient(gX, gY, 0, gX, gY, g.r);
    gg.addColorStop(0, g.col); gg.addColorStop(1, 'rgba(0,0,0,0)');
    ctx.fillStyle = gg; ctx.beginPath(); ctx.arc(gX, gY, g.r, 0, Math.PI * 2); ctx.fill();
  });
  ctx.globalAlpha = 0.07 + 0.04 * Math.sin(_obWFPhase);
  ctx.strokeStyle = 'rgba(200,150,255,0.5)'; ctx.lineWidth = 2;
  ctx.beginPath(); ctx.arc(srcX, srcY, 22 + 4 * Math.sin(_obWFPhase * 0.5), 0, Math.PI * 2); ctx.stroke();
  ctx.restore();
}

/**
 * Draw Huygens wavelets at a refraction/reflection boundary.
 * 5 sample points along wavefront emit expanding semi-circular arcs.
 */
function _obDrawHuygens(ctx, bx, by, normalAngle, kind) {
  const t = _obWFPhase % 1;
  const maxR = 28, nSamples = 5;
  ctx.save();
  ctx.globalAlpha = 0.22;
  for (let i = 0; i < nSamples; i++) {
    const offset = (i - (nSamples - 1) / 2) * 12;
    const wx = bx + Math.cos(normalAngle + Math.PI / 2) * offset;
    const wy = by + Math.sin(normalAngle + Math.PI / 2) * offset;
    const r = t * maxR;
    const sa = kind === 'reflection'
      ? normalAngle - Math.PI / 2
      : normalAngle + Math.PI / 2;
    ctx.strokeStyle = 'rgba(180,220,255,0.7)'; ctx.lineWidth = 1.2;
    ctx.beginPath();
    ctx.arc(wx, wy, r, sa - Math.PI / 2, sa + Math.PI / 2);
    ctx.stroke();
  }
  ctx.restore();
}

/**
 * Draw caustic curve under a lens by tracing ~20 parallel rays from infinity.
 * Each ray is slightly shifted by spherical aberration creating an extended caustic.
 */
function _obDrawCausticCurve(ctx, lx, ay, f, H) {
  if (!isFinite(f) || f <= 0) return;
  const lensH = Math.min(H * 0.38, 140);
  const nRays = 20;
  const pts = [];
  for (let i = 0; i < nRays; i++) {
    const yIn = -lensH + (2 * lensH * i) / (nRays - 1);
    const abFac = 1 - 0.0008 * (yIn / lensH) * (yIn / lensH) * Math.min(Math.abs(f), 150);
    const fEff = f * abFac;
    const sl = -yIn / fEff;
    if (Math.abs(sl) < 0.0001) continue;
    const tCross = -yIn / sl;
    if (tCross < 0 || tCross > 600) continue;
    pts.push({ x: lx + tCross, y: ay });
  }
  if (pts.length < 2) return;
  const pulse = 0.55 + 0.35 * Math.sin(_obWFPhase * Math.PI * 2);
  ctx.save();
  ctx.globalAlpha = pulse;
  ctx.globalCompositeOperation = 'lighter';
  ctx.strokeStyle = '#FFD166'; ctx.lineWidth = 2;
  ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 6;
  ctx.beginPath();
  pts.forEach(function(p, idx) { idx === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y); });
  ctx.stroke();
  ctx.restore();
}

/**
 * Unified OB_FX layer — called at end of each sim draw() BEFORE particles.draw().
 * @param {CanvasRenderingContext2D} ctx
 * @param {string} simType  'lens'|'mirror'|'refraction'|'prism'|'freebuild'
 * @param {object} [extras] {rays[], srcX, srcY, boundary, causticParams}
 */
function _drawOBFXLayer(ctx, simType, extras) {
  const now = performance.now();
  if (_obWFLastT) _obWFPhase += (now - _obWFLastT) / 1000 * 0.5;
  _obWFLastT = now;

  if (window.OB_FX.mist) _obStartMist(ctx);
  if (!extras) return;

  if (window.OB_FX.wavefronts && extras.rays) {
    const nm = window._obWavelength || 550;
    extras.rays.forEach(function(r) {
      _obDrawWavefrontTicks(ctx, r.x1, r.y1, r.x2, r.y2, nm, r.n || 1);
    });
  }
  if (window.OB_FX.flare && extras.srcX !== undefined) {
    _obDrawLensFlare(ctx, extras.srcX, extras.srcY, ctx.canvas.width, ctx.canvas.height);
  }
  if (window.OB_FX.huygens && extras.boundary) {
    const b = extras.boundary;
    _obDrawHuygens(ctx, b.bx, b.by, b.normalAngle, b.kind);
  }
  if (window.OB_FX.caustics && extras.causticParams) {
    const cp = extras.causticParams;
    _obDrawCausticCurve(ctx, cp.lx, cp.ay, cp.f, ctx.canvas.height);
  }
}

/* ─────────────────────────────────────────────────────────────
   1.  THIN LENS ENGINE  (from thinlens.js)
───────────────────────────────────────────────────────────────*/
class ThinLensSim {
  constructor(canvas) {
    this.canvas = canvas;
    this.ctx    = canvas.getContext('2d');
    this.W = 0; this.H = 0;

    this.f = 100;
    this.d = 200;
    this.h = 50;

    this._drag = null;
    this.onUpdate = null;

    /* aberration toggles (Agent OB-A3) */
    this._aberrSpherical  = false;
    this._aberrChromatic  = false;

    /* ── Lens-maker formula params (Feature 3) ── */
    this._lmSimple = true;   // true = simple f-slider mode; false = R1/R2/n mode
    this._lmR1 =  100;       // front surface radius (mm)
    this._lmR2 = -100;       // back surface radius (mm)
    this._lmN  =  1.5;       // refractive index

    /* ── Animated ray construction (Feature 1) ── */
    this._rayAnimT   = [1, 1, 1];   // progress of each of the 3 rays [0..1]
    this._rayTweens  = [];           // active tween handles

    this._bindEvents();
    new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
  }

  fit() {
    const dpr = window.devicePixelRatio || 1;
    const w = this.canvas.offsetWidth || 600;
    const h = this.canvas.offsetHeight || 400;
    this.canvas.width  = w * dpr;
    this.canvas.height = h * dpr;
    this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    this.W = w; this.H = h;
  }

  getParams() { return { f: this.f, d: this.d, h: this.h }; }
  setParams({ f, d, h } = {}) {
    if (f !== undefined) this.f = Math.max(-200, Math.min(200, +f));
    if (d !== undefined) this.d = Math.max(30, Math.min(400, +d));
    if (h !== undefined) this.h = Math.max(20, Math.min(80, +h));
    this.draw();
    this._emit();
  }

  reset() {
    this.f = 100; this.d = 200; this.h = 50;
    this.draw();
    this._emit();
  }

  /* toggle aberration modes (Agent OB-A3) */
  setAberration(type, on) {
    if (type === 'spherical') this._aberrSpherical = !!on;
    if (type === 'chromatic') this._aberrChromatic = !!on;
    this.draw();
    this._emit();
  }


  /* ═══ Lens-maker formula (Feature 3) ════════════════════════════ */
  _lmCalcF() {
    const R1 = this._lmR1, R2 = this._lmR2, n = this._lmN;
    const inv1 = Math.abs(R1) > 0.5 ? 1 / R1 : 0;
    const inv2 = Math.abs(R2) > 0.5 ? 1 / R2 : 0;
    const invF = (n - 1) * (inv1 - inv2);
    if (Math.abs(invF) < 1e-6) return 9999;
    return 1 / invF;
  }

  setLensMode(simple) {
    this._lmSimple = !!simple;
    if (!simple) {
      const k = this.f * (this._lmN - 1);
      if (Math.abs(k) > 1) { this._lmR1 = k * 2; this._lmR2 = -k * 2; }
    }
    this.draw(); this._emit();
  }

  setLMParam(name, val) {
    const v = +val;
    if (name === 'R1') this._lmR1 = Math.max(-300, Math.min(300, v));
    else if (name === 'R2') this._lmR2 = Math.max(-300, Math.min(300, v));
    else if (name === 'n') this._lmN = Math.max(1.3, Math.min(2.4, v));
    if (!this._lmSimple) this.f = Math.max(-200, Math.min(200, this._lmCalcF()));
    this.draw(); this._emit();
  }

  _lmShapeName() {
    const R1 = this._lmR1, R2 = this._lmR2;
    const flat1 = Math.abs(R1) > 280, flat2 = Math.abs(R2) > 280;
    if (flat1 && flat2) return 'плоскопараллельная';
    if (R1 > 0 && R2 < 0) return 'двояковыпуклая';
    if (R1 < 0 && R2 > 0) return 'двояковогнутая';
    if (flat1 || flat2) return 'плоско-выпуклая / вогнутая';
    return 'мениск';
  }

  _drawLensLM(ctx, lx, ay) {
    const lensH = Math.min(this.H * 0.38, 140);
    const R1 = this._lmR1, R2 = this._lmR2;
    const flat1 = Math.abs(R1) > 280, flat2 = Math.abs(R2) > 280;
    const b1 = flat1 ? 0 : Math.max(-20, Math.min(20, lensH * lensH / (2 * R1)));
    const b2 = flat2 ? 0 : Math.max(-20, Math.min(20, lensH * lensH / (2 * -R2)));
    ctx.strokeStyle = 'rgba(155,93,229,0.85)'; ctx.lineWidth = 2.5;
    ctx.beginPath(); ctx.moveTo(lx + b1, ay - lensH); ctx.quadraticCurveTo(lx + b1 * 2, ay, lx + b1, ay + lensH); ctx.stroke();
    ctx.beginPath(); ctx.moveTo(lx + b2, ay - lensH); ctx.quadraticCurveTo(lx + b2 * 2, ay, lx + b2, ay + lensH); ctx.stroke();
    ctx.strokeStyle = 'rgba(155,93,229,0.4)'; ctx.lineWidth = 1.2;
    ctx.beginPath(); ctx.moveTo(lx + b1, ay - lensH); ctx.lineTo(lx + b2, ay - lensH); ctx.stroke();
    ctx.beginPath(); ctx.moveTo(lx + b1, ay + lensH); ctx.lineTo(lx + b2, ay + lensH); ctx.stroke();
    ctx.strokeStyle = 'rgba(155,93,229,0.2)'; ctx.lineWidth = 1;
    ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.lineTo(lx, ay + lensH); ctx.stroke();
    if (this.f > 0) { this._lensArrow(ctx, lx, ay - lensH, -1); this._lensArrow(ctx, lx, ay + lensH, 1); }
    else { this._lensArrowDiv(ctx, lx, ay - lensH, -1); this._lensArrowDiv(ctx, lx, ay + lensH, 1); }
  }

  _drawLMInfo(ctx, lx, ay) {
    if (this._lmSimple) return;
    const f = this.f, D = Math.abs(f) > 0.5 ? (1000 / f).toFixed(2) : '---';
    const n = this._lmN.toFixed(2);
    const R1s = Math.abs(this._lmR1) > 280 ? 'inf' : this._lmR1.toFixed(0);
    const R2s = Math.abs(this._lmR2) > 280 ? 'inf' : this._lmR2.toFixed(0);
    const shape = this._lmShapeName();
    const bx = lx + 20, by = 12, bw = 200, bh = 72;
    ctx.fillStyle = 'rgba(13,13,26,0.88)';
    ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 8); ctx.fill();
    ctx.strokeStyle = 'rgba(155,93,229,0.3)'; ctx.lineWidth = 1;
    ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 8); ctx.stroke();
    ctx.font = '10px Manrope, system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
    ctx.fillStyle = 'rgba(155,93,229,0.9)'; ctx.fillText('Lensmaker', bx + 8, by + 7);
    ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.fillText('R1=' + R1s + '  R2=' + R2s + '  n=' + n, bx + 8, by + 22);
    ctx.fillStyle = '#06D6E0'; ctx.fillText('f = ' + (isFinite(f) ? f.toFixed(1) : '---') + ' mm', bx + 8, by + 38);
    ctx.fillStyle = '#FFD166'; ctx.fillText('D = ' + D + ' dptr', bx + 104, by + 38);
    ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText(shape, bx + 8, by + 54);
  }

  /* ═══ Animated 3-ray construction (Feature 1) ═══════════════════ */
  buildRays() {
    this._rayTweens.forEach(h => h && h.cancel && h.cancel());
    this._rayTweens = [];
    this._rayAnimT = [0, 0, 0];
    const animate = (idx, onDone) => {
      if (!window.LabFX) { this._rayAnimT[idx] = 1; this.draw(); if (onDone) onDone(); return; }
      const h = LabFX.motion.tween(0, 1, 500, 'easeOutCubic',
        t => { this._rayAnimT[idx] = t; this.draw(); }, onDone);
      this._rayTweens[idx] = h;
    };
    animate(0, () => animate(1, () => animate(2, () => { if (window.LabFX) LabFX.sound.play('chime'); })));
  }

  resetRays() {
    this._rayTweens.forEach(h => h && h.cancel && h.cancel());
    this._rayTweens = [];
    this._rayAnimT = [1, 1, 1];
    this.draw();
  }

  info() {
    const { f, d, h } = this;
    const denom = d - f;
    const dPrime = Math.abs(denom) < 0.01 ? Infinity : (f * d) / denom;
    const M = Math.abs(denom) < 0.01 ? Infinity : -dPrime / d;
    const hPrime = M === Infinity ? Infinity : M * h;
    const isVirtual = dPrime < 0;
    return {
      f:    +f.toFixed(1),
      d:    +d.toFixed(1),
      dPrime: dPrime === Infinity ? Infinity : +dPrime.toFixed(1),
      M:      M === Infinity ? Infinity : +M.toFixed(3),
      imageType: isVirtual ? 'мнимое' : 'действительное',
      h:    +h.toFixed(1),
      hPrime: hPrime === Infinity ? Infinity : +Math.abs(hPrime).toFixed(1),
    };
  }

  _emit() { if (this.onUpdate) this.onUpdate(this.info()); }

  _toCanvas(sx, sy) { return { cx: this.W / 2 + sx, cy: this.H / 2 - sy }; }
  _fromCanvas(cx, cy) { return { sx: cx - this.W / 2, sy: this.H / 2 - cy }; }

  draw() {
    const ctx = this.ctx, W = this.W, H = this.H;
    if (!W || !H) return;

    const { f, d, h } = this;
    const lensX = W / 2;
    const axisY = H / 2;

    ctx.fillStyle = '#0D0D1A';
    ctx.fillRect(0, 0, W, H);

    ctx.strokeStyle = 'rgba(255,255,255,0.15)';
    ctx.lineWidth = 1;
    ctx.setLineDash([6, 4]);
    ctx.beginPath(); ctx.moveTo(0, axisY); ctx.lineTo(W, axisY); ctx.stroke();
    ctx.setLineDash([]);

    // Lens silhouette: detailed (LM) or simple mode
    if (!this._lmSimple) this._drawLensLM(ctx, lensX, axisY);
    else this._drawLens(ctx, lensX, axisY, f);
    this._drawFocalPoints(ctx, lensX, axisY, f);

    const objX = lensX - d;
    this._drawArrow(ctx, objX, axisY, objX, axisY - h, '#9B5DE5', false);

    const denom = d - f;
    let dPrime, hPrime;
    if (Math.abs(denom) < 0.5) {
      dPrime = null; hPrime = null;
    } else {
      dPrime = (f * d) / denom;
      hPrime = (-dPrime / d) * h;
    }

    /* aberrations override standard rays (Agent OB-A3) */
    if (this._aberrSpherical) {
      this._drawSphericalAberration(ctx, lensX, axisY, d, h, f);
    } else if (this._aberrChromatic) {
      this._drawChromaticAberration(ctx, lensX, axisY, d, h, f);
    } else {
      this._drawRaysAnimated(ctx, lensX, axisY, d, h, f, dPrime, hPrime);
    }

    if (!this._aberrSpherical && !this._aberrChromatic && dPrime !== null && isFinite(dPrime)) {
      const isVirtual = dPrime < 0;
      const imgX = lensX + dPrime;
      // Feature 1: real=cyan, virtual=pink/dashed
      this._drawArrow(ctx, imgX, axisY, imgX, axisY - hPrime,
        isVirtual ? 'rgba(255,133,162,0.9)' : '#06D6E0', isVirtual);
    }

    this._drawLabels(ctx, lensX, axisY, d, f, dPrime, hPrime);
    this._drawArrowLabels(ctx, lensX, axisY, d, h, dPrime, hPrime);
    this._drawLMInfo(ctx, lensX, axisY);

    // Lens caustics: emit dust near focal point when image exists (only when OB_FX.caustics is off)
    if (!window.OB_FX.caustics && window.LabFX && dPrime !== null && isFinite(dPrime) && dPrime > 0) {
      const imgX = lensX + dPrime;
      if (!this._causticFrame) this._causticFrame = 0;
      this._causticFrame++;
      if (this._causticFrame % 4 === 0) {
        LabFX.particles.emit({ ctx, x: imgX + (Math.random() - 0.5) * 10, y: axisY + (Math.random() - 0.5) * 10, count: 3, color: '#FFD166', speed: 8, spread: Math.PI * 2, life: 500, shape: 'dust', glow: true });
      }
    }
    // OB_FX visual depth layer
    {
      const objX = lensX - d;
      const fxExtras = {
        srcX: objX, srcY: axisY - h,
        causticParams: { lx: lensX, ay: axisY, f },
        rays: [
          { x1: objX, y1: axisY - h, x2: lensX, y2: axisY - h },
          { x1: lensX, y1: axisY - h, x2: lensX + (dPrime || 200), y2: dPrime ? axisY - ((-dPrime / d) * h) : axisY },
        ],
      };
      _drawOBFXLayer(ctx, 'lens', fxExtras);
    }
    if (window.LabFX) { LabFX.particles.update(1 / 60); LabFX.particles.draw(ctx); }
  }

  _drawLens(ctx, lx, ay, f) {
    const lensH = Math.min(this.H * 0.38, 140);
    const converging = f > 0;
    ctx.strokeStyle = 'rgba(155,93,229,0.8)';
    ctx.lineWidth = 2.5;
    if (converging) {
      const bulge = Math.min(18, Math.abs(f) * 0.12);
      ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.quadraticCurveTo(lx + bulge, ay, lx, ay + lensH); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.quadraticCurveTo(lx - bulge, ay, lx, ay + lensH); ctx.stroke();
      this._lensArrow(ctx, lx, ay - lensH, -1);
      this._lensArrow(ctx, lx, ay + lensH, 1);
    } else {
      const bulge = Math.min(14, Math.abs(f) * 0.1);
      ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.quadraticCurveTo(lx - bulge, ay, lx, ay + lensH); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.quadraticCurveTo(lx + bulge, ay, lx, ay + lensH); ctx.stroke();
      this._lensArrowDiv(ctx, lx, ay - lensH, -1);
      this._lensArrowDiv(ctx, lx, ay + lensH, 1);
    }
    ctx.strokeStyle = 'rgba(155,93,229,0.3)';
    ctx.lineWidth = 1;
    ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.lineTo(lx, ay + lensH); ctx.stroke();
  }

  _lensArrow(ctx, x, y, dir) {
    const sz = 7;
    ctx.fillStyle = 'rgba(155,93,229,0.8)';
    ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x - sz, y + dir * sz * 1.2); ctx.lineTo(x + sz, y + dir * sz * 1.2); ctx.closePath(); ctx.fill();
  }

  _lensArrowDiv(ctx, x, y, dir) {
    const sz = 6;
    ctx.fillStyle = 'rgba(155,93,229,0.8)';
    ctx.beginPath(); ctx.moveTo(x - sz, y); ctx.lineTo(x, y - dir * sz); ctx.lineTo(x + sz, y); ctx.closePath(); ctx.fill();
  }

  _drawFocalPoints(ctx, lx, ay, f) {
    const pts = [{ sx: f, label: "F'" }, { sx: -f, label: 'F' }, { sx: 2 * f, label: "2F'" }, { sx: -2 * f, label: '2F' }];
    for (const p of pts) {
      const px = lx + p.sx;
      if (px < 10 || px > this.W - 10) continue;
      const isFocal = !p.label.startsWith('2');
      const r = isFocal ? 5 : 3.5;
      const col = isFocal ? '#06D6E0' : 'rgba(6,214,224,0.5)';
      ctx.fillStyle = col;
      ctx.beginPath(); ctx.arc(px, ay, r, 0, Math.PI * 2); ctx.fill();
      ctx.font = '11px Manrope, system-ui, sans-serif';
      ctx.fillStyle = col; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
      ctx.fillText(p.label, px, ay + 10);
    }
  }

  _drawArrow(ctx, x1, y1, x2, y2, color, dashed) {
    ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 2.5;
    if (dashed) ctx.setLineDash([6, 4]);
    ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
    if (dashed) ctx.setLineDash([]);
    const angle = Math.atan2(y2 - y1, x2 - x1), aLen = 10;
    ctx.beginPath();
    ctx.moveTo(x2, y2);
    ctx.lineTo(x2 - aLen * Math.cos(angle - 0.35), y2 - aLen * Math.sin(angle - 0.35));
    ctx.lineTo(x2 - aLen * Math.cos(angle + 0.35), y2 - aLen * Math.sin(angle + 0.35));
    ctx.closePath(); ctx.fill();
  }

  _drawRays(ctx, lx, ay, d, h, f, dPrime, hPrime) {
    // Wavelength-aware coloring
    const _wl = window._obWhiteLight;
    const _wlNm = window._obWavelength || 550;
    if (_wl) {
      // White-light mode: draw 3 spectral bundles (R/G/B) with offset for chromatic spread
      const bundles = [{ nm: 660, dOff: 0 }, { nm: 550, dOff: 0 }, { nm: 450, dOff: 0 }];
      const n0 = 1.5; // default glass
      bundles.forEach(b => {
        const nc = _nAtWavelength(n0, b.nm);
        // focal length shifts with n — thin lens: f ~ (n-1)*C → f(λ) = f * (n0-1)/(nc-1)
        const fC = f * (n0 - 1) / (nc - 1);
        const denomC = d - fC;
        const dPC = Math.abs(denomC) < 0.5 ? null : (fC * d) / denomC;
        const hPC = dPC !== null ? (-dPC / d) * h : null;
        const col = wavelengthToRGB(b.nm);
        ctx.save(); ctx.globalAlpha = 0.65;
        this._drawRaysSingle(ctx, lx, ay, d, h, fC, dPC, hPC, col);
        ctx.restore();
      });
      return;
    }
    const _monoColor = wavelengthToRGB(_wlNm);
    const objX = lx - d, objY = ay - h;
    const colors = [_monoColor, _monoColor, _monoColor];
    const hasImage = dPrime !== null && isFinite(dPrime);
    const isVirtual = hasImage && dPrime < 0;
    ctx.lineWidth = 1.5;
    const _doGlow = (color, fn) => {
      if (window.LabFX) LabFX.glow.drawGlow(ctx, fn, { color, intensity: 8 });
      else fn();
    };

    // Ray 1: parallel to axis
    _doGlow(colors[0], () => {
      ctx.strokeStyle = colors[0]; ctx.setLineDash([]);
      ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, objY); ctx.stroke();
      if (hasImage) {
        const imgX = lx + dPrime, imgY = ay - hPrime;
        if (!isVirtual) {
          ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(imgX, imgY); ctx.stroke();
          this._extendRay(ctx, lx, objY, imgX, imgY, colors[0]);
        } else {
          const outSlope = (objY - ay) / f;
          ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(lx + 300, objY + outSlope * 300); ctx.stroke();
          ctx.setLineDash([4, 4]);
          ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(imgX, imgY); ctx.stroke();
          ctx.setLineDash([]);
        }
      }
    });

    // Ray 2: through center
    _doGlow(colors[1], () => {
      ctx.strokeStyle = colors[1]; ctx.setLineDash([]);
      const slope = (objY - ay) / (objX - lx);
      const farX = lx + 350, farY = ay + slope * 350;
      ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(farX, farY); ctx.stroke();
      if (isVirtual) {
        const backX = lx - 350, backY = ay - slope * 350;
        ctx.setLineDash([4, 4]);
        ctx.beginPath(); ctx.moveTo(lx, ay); ctx.lineTo(backX, backY); ctx.stroke();
        ctx.setLineDash([]);
      }
    });

    // Ray 3: through F
    _doGlow(colors[2], () => {
      ctx.strokeStyle = colors[2]; ctx.setLineDash([]);
      const fx = lx - f, slope = (objY - ay) / (objX - fx);
      const hitY = objY + slope * (lx - objX);
      ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, hitY); ctx.stroke();
      const endX = hasImage && !isVirtual ? Math.max(lx + dPrime + 60, lx + 300) : lx + 300;
      ctx.beginPath(); ctx.moveTo(lx, hitY); ctx.lineTo(endX, hitY); ctx.stroke();
      if (hasImage && isVirtual) {
        ctx.setLineDash([4, 4]);
        ctx.beginPath(); ctx.moveTo(lx, hitY); ctx.lineTo(lx + dPrime, ay - hPrime); ctx.stroke();
        ctx.setLineDash([]);
      }
    });
  }

  /** Single-color version of _drawRays used internally for white-light chromatic bundles */
  _drawRaysSingle(ctx, lx, ay, d, h, f, dPrime, hPrime, color) {
    const objX = lx - d, objY = ay - h;
    const hasImage = dPrime !== null && isFinite(dPrime);
    const isVirtual = hasImage && dPrime < 0;
    ctx.lineWidth = 1.5; ctx.strokeStyle = color; ctx.setLineDash([]);
    // Ray 1: parallel to axis
    ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, objY); ctx.stroke();
    if (hasImage) {
      const imgX = lx + dPrime, imgY = ay - hPrime;
      if (!isVirtual) {
        ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(imgX, imgY); ctx.stroke();
      } else {
        const outSlope = (objY - ay) / f;
        ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(lx + 200, objY + outSlope * 200); ctx.stroke();
      }
    }
    // Ray 2: through center
    const slope2 = (objY - ay) / (objX - lx);
    ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx + 250, ay + slope2 * 250); ctx.stroke();
    // Ray 3: through F (brief version)
    const fxOff = lx - f, slope3 = (objY - ay) / (objX - fxOff);
    const hitY3 = objY + slope3 * (lx - objX);
    ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, hitY3); ctx.stroke();
    ctx.beginPath(); ctx.moveTo(lx, hitY3); ctx.lineTo(lx + 250, hitY3); ctx.stroke();
    ctx.setLineDash([]);
  }

  _extendRay(ctx, x1, y1, x2, y2, color) {
    const dx = x2 - x1, dy = y2 - y1, len = Math.hypot(dx, dy);
    if (len < 1) return;
    ctx.globalAlpha = 0.3; ctx.strokeStyle = color;
    ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo(x2 + (dx / len) * 80, y2 + (dy / len) * 80); ctx.stroke();
    ctx.globalAlpha = 1;
  }

  /* ── Spherical aberration (Agent OB-A3) ──────────────────────
     5 parallel rays at different heights; f_eff(h) = f - h²/(2f).
     Marginal rays focus closer than paraxial for a converging lens.
  ─────────────────────────────────────────────────────────────── */
  _drawSphericalAberration(ctx, lx, ay, d, h, f) {
    if (Math.abs(f) < 1) return;
    const lensH  = Math.min(this.H * 0.36, 130);
    const fracs  = [-0.9, -0.5, 0, 0.5, 0.9];
    const pal    = ['#FF6B6B', '#FFD166', '#7BF5A4', '#06D6E0', '#B8A4FF'];
    const objX   = lx - d;
    ctx.save();
    ctx.lineWidth = 1.4;
    const focusPts = [];
    fracs.forEach((fr, i) => {
      const rayH   = fr * lensH;
      const fEff   = f > 0 ? f - (rayH * rayH) / (2 * f) : f + (rayH * rayH) / (2 * Math.abs(f));
      const denom  = d - fEff;
      if (Math.abs(denom) < 0.5) return;
      const dPrEff = (fEff * d) / denom;
      ctx.strokeStyle = pal[i];
      ctx.globalAlpha = 0.8;
      ctx.setLineDash([]);
      const startY = ay - rayH;
      ctx.beginPath(); ctx.moveTo(objX, startY); ctx.lineTo(lx, startY); ctx.stroke();
      if (f > 0 && isFinite(dPrEff) && dPrEff > 0) {
        const focX = lx + dPrEff;
        ctx.beginPath(); ctx.moveTo(lx, startY); ctx.lineTo(focX, ay);
        ctx.lineTo(focX + 70, ay + (startY - ay) * 0.5); ctx.stroke();
        focusPts.push({ fEff, label: i === 2 ? 'парак.' : 'краев.' });
      } else {
        ctx.beginPath(); ctx.moveTo(lx, startY);
        ctx.lineTo(lx + 280, startY + (ay - startY) * 0.6); ctx.stroke();
      }
    });
    ctx.globalAlpha = 1;
    ctx.restore();
    if (focusPts.length >= 2) {
      const fMin = Math.min(...focusPts.map(p => p.fEff)).toFixed(0);
      const fMax = Math.max(...focusPts.map(p => p.fEff)).toFixed(0);
      ctx.save();
      const bx = 12, by = 70;
      ctx.fillStyle = 'rgba(22,22,38,0.88)';
      ctx.beginPath(); ctx.roundRect(bx, by, 224, 44, 8); ctx.fill();
      ctx.font = '11px Manrope, system-ui, sans-serif';
      ctx.textAlign = 'left'; ctx.textBaseline = 'top';
      ctx.fillStyle = '#FF6B6B'; ctx.fillText('Сферическая аберрация', bx + 8, by + 6);
      ctx.fillStyle = 'rgba(255,255,255,0.55)';
      ctx.fillText('f парак.=' + fMax + '  f краев.=' + fMin, bx + 8, by + 24);
      ctx.restore();
    }
  }

  /* ── Chromatic aberration (Agent OB-A3) ──────────────────────
     f_R = f×1.02,  f_G = f,  f_B = f×0.98
     Three ray bundles drawn in R/G/B colours converging to different points.
  ─────────────────────────────────────────────────────────────── */
  _drawChromaticAberration(ctx, lx, ay, d, h, f) {
    if (Math.abs(f) < 1) return;
    const channels = [
      { scale: 1.02, color: '#FF4444', label: 'R' },
      { scale: 1.00, color: '#7BF5A4', label: 'G' },
      { scale: 0.98, color: '#4488FF', label: 'B' },
    ];
    const objX = lx - d;
    ctx.save();
    ctx.lineWidth = 1.6;
    const focusPts = [];
    channels.forEach(ch => {
      const fc     = f * ch.scale;
      const denom  = d - fc;
      if (Math.abs(denom) < 0.5) return;
      const dPrEff = (fc * d) / denom;
      const hPrEff = (-dPrEff / d) * h;
      const hasImg = isFinite(dPrEff) && dPrEff > 0;
      ctx.strokeStyle = ch.color;
      ctx.globalAlpha = 0.78;
      ctx.setLineDash([]);
      // Ray 1: parallel to axis
      ctx.beginPath(); ctx.moveTo(objX, ay - h); ctx.lineTo(lx, ay - h);
      if (hasImg) { ctx.lineTo(lx + dPrEff, ay - hPrEff); }
      ctx.stroke();
      // Ray 2: through centre
      const slopeC = h / d;
      ctx.beginPath(); ctx.moveTo(objX, ay - h); ctx.lineTo(lx, ay);
      if (hasImg) { ctx.lineTo(lx + dPrEff, ay - hPrEff); }
      else { ctx.lineTo(lx + 280, ay - slopeC * 280); }
      ctx.stroke();
      if (hasImg) focusPts.push({ x: lx + dPrEff, label: ch.label, color: ch.color, f: fc });
    });
    ctx.globalAlpha = 1;
    ctx.setLineDash([]);
    // focal markers
    focusPts.forEach(fp => {
      ctx.fillStyle = fp.color;
      ctx.beginPath(); ctx.arc(fp.x, ay, 5, 0, Math.PI * 2); ctx.fill();
      ctx.font = '10px Manrope, system-ui, sans-serif';
      ctx.fillStyle = fp.color; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
      ctx.fillText(fp.label + ' f=' + fp.f.toFixed(0), fp.x, ay - 10);
    });
    ctx.restore();
    // info box
    ctx.save();
    const bx = 12, by = 70;
    ctx.fillStyle = 'rgba(22,22,38,0.88)';
    ctx.beginPath(); ctx.roundRect(bx, by, 224, 56, 8); ctx.fill();
    ctx.font = '11px Manrope, system-ui, sans-serif';
    ctx.textAlign = 'left'; ctx.textBaseline = 'top';
    ctx.fillStyle = '#4488FF'; ctx.fillText('Хроматическая аберрация', bx + 8, by + 6);
    if (focusPts.length >= 2) {
      const spread = Math.abs(focusPts[focusPts.length - 1].x - focusPts[0].x).toFixed(0);
      ctx.fillStyle = 'rgba(255,255,255,0.55)';
      ctx.fillText('Разброс фокусов: ' + spread + ' px', bx + 8, by + 22);
      ctx.fillText('fR > fG > fB  (красный фокус дальше)', bx + 8, by + 38);
    }
    ctx.restore();
  }

  _drawLabels(ctx, lx, ay, d, f, dPrime, hPrime) {
    ctx.font = '12px Manrope, system-ui, sans-serif'; ctx.textBaseline = 'top';
    const objX = lx - d;
    ctx.fillStyle = '#9B5DE5'; ctx.textAlign = 'center';
    ctx.fillText('d = ' + d.toFixed(0), (objX + lx) / 2, ay + 26);
    ctx.fillStyle = '#06D6E0';
    ctx.fillText('f = ' + f.toFixed(0), lx, ay + 42);
    if (dPrime !== null && isFinite(dPrime)) {
      const imgX = lx + dPrime;
      ctx.fillStyle = dPrime > 0 ? '#EF476F' : '#FFD166'; ctx.textAlign = 'center';
      ctx.fillText("d' = " + dPrime.toFixed(1), (lx + imgX) / 2, ay + 26);
    }
    const info = this.info();
    const boxW = 200, boxH = 52, bx = 12, by = 12;
    ctx.fillStyle = 'rgba(22,22,38,0.85)';
    ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill();
    ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.7)';
    ctx.textAlign = 'left'; ctx.textBaseline = 'top';
    ctx.fillText("1/f = 1/d + 1/d'", bx + 10, by + 10);
    ctx.fillStyle = 'rgba(255,255,255,0.5)';
    const mStr = info.M === Infinity ? '---' : info.M.toFixed(2);
    const dpStr = info.dPrime === Infinity ? '---' : info.dPrime.toFixed(1);
    ctx.fillText('M = ' + mStr + "   d' = " + dpStr + '   ' + info.imageType, bx + 10, by + 30);
  }

  /* === _drawRaysAnimated: principal rays with per-ray progress === */
  _drawRaysAnimated(ctx, lx, ay, d, h, f, dPrime, hPrime) {
    const T = this._rayAnimT;
    if (T[0] >= 1 && T[1] >= 1 && T[2] >= 1) { this._drawRays(ctx, lx, ay, d, h, f, dPrime, hPrime); return; }
    const objX = lx - d, objY = ay - h;
    const hasImage = dPrime !== null && isFinite(dPrime);
    const isVirtual = hasImage && dPrime < 0;
    // Wavelength-aware colors for animation
    const _wlNm = window._obWavelength || 550;
    const _wl   = window._obWhiteLight;
    const _mc   = wavelengthToRGB(_wlNm);
    const COLORS = _wl ? [wavelengthToRGB(660), wavelengthToRGB(550), wavelengthToRGB(450)] : [_mc, _mc, _mc];
    ctx.lineWidth = 1.8;
    const lerp = (a, b, t) => a + (b - a) * Math.min(1, Math.max(0, t));
    const drawPts = (color, pts, t) => {
      if (t <= 0 || pts.length < 2) return;
      const totalLen = pts.reduce((s, p, i) => i === 0 ? 0 : s + Math.hypot(p[0]-pts[i-1][0], p[1]-pts[i-1][1]), 0);
      const target = totalLen * t;
      const draw = () => {
        ctx.strokeStyle = color; ctx.setLineDash([]);
        ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]);
        let drawn = 0;
        for (let i = 1; i < pts.length; i++) {
          const segLen = Math.hypot(pts[i][0]-pts[i-1][0], pts[i][1]-pts[i-1][1]);
          if (drawn + segLen <= target) { ctx.lineTo(pts[i][0], pts[i][1]); drawn += segLen; }
          else { const fr = segLen > 0 ? (target - drawn) / segLen : 0; ctx.lineTo(lerp(pts[i-1][0], pts[i][0], fr), lerp(pts[i-1][1], pts[i][1], fr)); break; }
        }
        ctx.stroke();
      };
      if (window.LabFX) LabFX.glow.drawGlow(ctx, draw, { color, intensity: 10 });
      else draw();
    };
    const FAR = lx + 360;
    const imgX = hasImage ? lx + dPrime : null, imgY = hasImage ? ay - hPrime : null;
    // Ray 1: parallel to axis -> through F'
    if (T[0] > 0) {
      let pts;
      if (!hasImage) { pts = [[objX, objY], [lx, objY], [FAR, objY]]; }
      else if (!isVirtual) { pts = [[objX, objY], [lx, objY], [imgX, imgY]]; }
      else { const s = (objY - ay) / f; pts = [[objX, objY], [lx, objY], [FAR, objY + s*(FAR-lx)]]; }
      drawPts(COLORS[0], pts, T[0]);
    }
    // Ray 2: through optical center (straight)
    if (T[1] > 0) {
      const s = (objY - ay) / (objX - lx);
      drawPts(COLORS[1], [[objX, objY], [FAR, ay + s*(FAR-lx)]], T[1]);
    }
    // Ray 3: through front focus F -> parallel after lens
    if (T[2] > 0) {
      const fx = lx - f, s = (objY - ay) / (objX - fx);
      const hitY = objY + s * (lx - objX);
      const endX = hasImage && !isVirtual ? Math.max(imgX + 60, FAR) : FAR;
      drawPts(COLORS[2], [[objX, objY], [lx, hitY], [endX, hitY]], T[2]);
    }
  }

  /* === Arrow labels: h_o, h_i, magnification Gamma === */
  _drawArrowLabels(ctx, lx, ay, d, h, dPrime, hPrime) {
    const objX = lx - d;
    ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.textBaseline = 'middle';
    ctx.fillStyle = 'rgba(155,93,229,0.85)'; ctx.textAlign = 'right';
    ctx.fillText('ho=' + h.toFixed(0), objX - 6, ay - h / 2);
    if (dPrime !== null && isFinite(dPrime)) {
      const imgX = lx + dPrime, isVirtual = dPrime < 0;
      const M = -dPrime / d;
      const Gstr = isFinite(M) ? (M >= 0 ? '+' : '') + M.toFixed(2) : '---';
      const imgColor = isVirtual ? 'rgba(255,133,162,0.85)' : 'rgba(6,214,224,0.85)';
      ctx.fillStyle = imgColor; ctx.textAlign = 'left';
      ctx.fillText("hi=" + Math.abs(hPrime).toFixed(0), imgX + 6, ay - hPrime / 2);
      ctx.fillStyle = '#FFD166'; ctx.textAlign = 'center';
      ctx.fillText('G=' + Gstr, (lx + imgX) / 2, ay + 60);
    }
  }

  _bindEvents() {
    const cv = this.canvas;
    const getPos = (e) => {
      const r = cv.getBoundingClientRect();
      const t = e.touches ? e.touches[0] : e;
      return { mx: (t.clientX - r.left) * (this.W / r.width), my: (t.clientY - r.top) * (this.H / r.height) };
    };
    const hitTest = (mx, my) => {
      const lx = this.W / 2, ay = this.H / 2;
      if (Math.hypot(mx - (lx - this.d), my - (ay - this.h)) < 20) return 'object';
      if (Math.hypot(mx - (lx - this.f), my - ay) < 16) return 'focus';
      return null;
    };
    const onDown = (e) => { const { mx, my } = getPos(e); this._drag = hitTest(mx, my); };
    const onMove = (e) => {
      if (!this._drag) return;
      if (e.cancelable) e.preventDefault();
      const { mx } = getPos(e), lx = this.W / 2;
      if (this._drag === 'object') this.d = Math.max(30, Math.min(400, lx - mx));
      else if (this._drag === 'focus') this.f = Math.max(-200, Math.min(200, lx - mx));
      this.draw(); this._emit();
    };
    const onUp = () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = null; };
    cv.addEventListener('mousedown', onDown);
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
    cv.addEventListener('touchstart', e => { if (e.touches.length === 1) onDown(e); }, { passive: true });
    cv.addEventListener('touchmove', e => onMove(e), { passive: false });
    cv.addEventListener('touchend', onUp);
    cv.addEventListener('mousemove', e => {
      if (this._drag) { cv.style.cursor = 'grabbing'; return; }
      const { mx, my } = getPos(e);
      cv.style.cursor = hitTest(mx, my) ? 'grab' : 'default';
    });
  }
}

/* ─────────────────────────────────────────────────────────────
   2.  MIRROR ENGINE  (from mirror.js)
───────────────────────────────────────────────────────────────*/
class MirrorSim {
  constructor(canvas) {
    this.canvas = canvas;
    this.ctx    = canvas.getContext('2d');
    this.W = 0; this.H = 0;

    this.type = 'concave';
    this.f    = 120;
    this.d    = 240;
    this.h    = 60;

    this._playing   = false;
    this._animT     = 1.4;
    this._animSpeed = 1;
    this._raf       = null;

    this._step = -1;

    this._showGrid       = false;
    this._showZones      = true;
    this._showNormals    = true;
    this._showDims       = true;
    this._showAngles     = true;
    this._showPhotons    = true;
    this._pointMode      = false;
    this._showSpherical  = false; /* spherical aberration toggle (Agent OB-A3) */

    this._photons       = [];
    this._photonRaf     = null;
    this._photonTimer   = 0;
    this._lastPhoTime   = 0;
    this._photonPaths   = [];

    this._prevType = 'concave';
    this._transT   = 1.0;
    this._transRaf = null;

    this._drag   = null;
    this._hoverX = -999;
    this._hoverY = -999;

    this.onUpdate  = null;
    this.onAnimate = null;

    /* Feature 2: R slider + spherical aberration toggle */
    this._R           = 240;    // radius of curvature (positive=concave, negative=convex)
    this._useR        = false;  // true = R-slider mode; false = classic type+f mode
    this._parabolic   = false;  // false = spherical mirror; true = perfect parabolic

    this._bindEvents();
    new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
  }

  fit() {
    const dpr = window.devicePixelRatio || 1;
    const w = this.canvas.offsetWidth  || 600;
    const h = this.canvas.offsetHeight || 400;
    this.canvas.width  = w * dpr;
    this.canvas.height = h * dpr;
    this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    this.W = w; this.H = h;
  }

  setType(type) {
    if (type === this.type) return;
    this._prevType = this.type;
    this.type = type;
    if (this._playing) this._stopAnim();
    this._startTransition();
    this.draw(); this._emit();
  }

  getParams() { return { f: this.f, d: this.d, h: this.h }; }
  setParams({ f, d, h } = {}) {
    if (f !== undefined) this.f = Math.max(30, Math.min(300, +f));
    if (d !== undefined) this.d = Math.max(30, Math.min(490, +d));
    if (h !== undefined) this.h = Math.max(20, Math.min(80,  +h));
    this.draw(); this._emit();
  }

  setAnimSpeed(s)  { this._animSpeed = +s || 1; }
  togglePlay()     { this._playing ? this._stopAnim() : this._startAnim(); }
  stepNext()       { this._step = Math.min(3, this._step + 1); this.draw(); }
  stepReset()      { this._step = -1; this.draw(); }
  setPointMode(on) { this._pointMode = !!on; this.draw(); this._emit(); }

  setToggle(name, val) {
    const map = { grid:'_showGrid', zones:'_showZones', normals:'_showNormals', dims:'_showDims', angles:'_showAngles', photons:'_showPhotons', spherical:'_showSpherical' };
    if (map[name]) this[map[name]] = !!val;
    if (name === 'photons') { val ? this._startPhotons() : this._stopPhotons(); }
    this.draw();
  }

  exportPng() {
    const a = document.createElement('a');
    a.href = this.canvas.toDataURL('image/png');
    a.download = 'mirror_' + this.type + '_d' + Math.round(this.d) + '.png';
    a.click();
  }

  _fSigned() {
    if (this.type === 'flat') return Infinity;
    return this.type === 'convex' ? -this.f : this.f;
  }

  info() {
    const { type, d, h } = this;
    const f = this._fSigned();
    let dPrime, M;
    if (type === 'flat') { dPrime = -d; M = 1; }
    else {
      const den = d - f;
      if (Math.abs(den) < 0.5) { dPrime = Infinity; M = Infinity; }
      else { dPrime = f * d / den; M = -dPrime / d; }
    }
    const hPrime   = M === Infinity ? Infinity : M * h;
    const isReal   = dPrime > 0 && dPrime !== Infinity;
    const imageType = dPrime === Infinity ? '∞' : isReal ? 'действительное' : 'мнимое';
    const orient   = (M === Infinity || M === 1) ? 'прямое' : M < 0 ? 'перевёрнутое' : 'прямое';
    const sizeStr  = M === Infinity ? '' : Math.abs(M) > 1.05 ? 'увеличенное' : Math.abs(M) < 0.95 ? 'уменьшенное' : 'равное';
    return {
      f:      type === 'flat' ? '∞' : (type === 'convex' ? -this.f : +this.f).toFixed(0),
      d:      +d.toFixed(1),
      dPrime: dPrime === Infinity ? Infinity : +dPrime.toFixed(1),
      M:      M === Infinity ? Infinity : +M.toFixed(3),
      imageType, orient, sizeStr,
      hPrime: hPrime === Infinity ? Infinity : +Math.abs(hPrime).toFixed(1),
      isReal,
    };
  }

  _emit() { if (this.onUpdate) this.onUpdate(this.info()); }

  _getBulge(type) {
    if (type === 'flat')    return 0;
    if (type === 'concave') return -Math.min(30, this.f * 0.18);
    return Math.min(24, this.f * 0.16);
  }

  _startTransition() {
    this._transT = 0;
    if (this._transRaf) cancelAnimationFrame(this._transRaf);
    const step = () => {
      this._transT = Math.min(1, this._transT + 0.07);
      this.draw();
      if (this._transT < 1) this._transRaf = requestAnimationFrame(step);
      else this._transRaf = null;
    };
    this._transRaf = requestAnimationFrame(step);
  }

  _startAnim() { this._playing = true; this._animLoop(); }
  _stopAnim() {
    this._playing = false;
    if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
  }

  _animLoop() {
    if (!this._playing) return;
    this._animT += 0.013 * this._animSpeed;
    const t = 0.5 - 0.5 * Math.cos(this._animT);
    if (this.type === 'concave') this.d = Math.max(30, Math.min(490, this.f * (0.35 + 2.75 * t)));
    else                         this.d = 40 + 400 * t;
    if (this.onAnimate) this.onAnimate(this.d);
    this.draw(); this._emit();
    this._raf = requestAnimationFrame(() => this._animLoop());
  }

  _getRayPaths(mx, ay, f, dPrime, hPrime) {
    const { d, h, type } = this;
    const hasImage = dPrime !== null && isFinite(dPrime);
    const isReal   = hasImage && dPrime > 0;
    const imgX     = hasImage ? mx - dPrime : null;
    const imgY     = hasImage ? ay - (this._pointMode ? 0 : hPrime) : null;
    const objX     = mx - d;
    const objY     = ay - (this._pointMode ? 0 : h);
    const COLORS   = ['#06D6E0', '#7BF5A4', '#FFD166'];
    if (type === 'flat') {
      return [objY, ay, ay - h * 0.5].map((hy, i) => ({
        pts: [[objX, objY], [mx, hy], ...(hasImage ? [[imgX, imgY]] : [])],
        color: COLORS[i],
      }));
    }
    const hit1Y = ay - (this._pointMode ? 0 : h);
    const hit2Y = ay;
    const denom3 = d - f;
    const hit3Y  = Math.abs(denom3) < 0.5 ? null : ay + (this._pointMode ? 0 : h) * f / denom3;
    const rays = [];
    const add = (hitY, color) => {
      if (hitY === null || !isFinite(hitY) || hitY < -this.H || hitY > 2 * this.H) return;
      const pts = [[objX, objY], [mx, hitY]];
      if (hasImage) {
        if (isReal) {
          pts.push([imgX, imgY]);
          const dx = imgX - mx, dy = imgY - hitY, l = Math.hypot(dx, dy);
          if (l > 1) pts.push([imgX + dx / l * 60, imgY + dy / l * 60]);
        } else {
          const dx = imgX - mx, dy = imgY - hitY;
          if (Math.abs(dx) > 1) {
            const tL = (mx - 5) / dx;
            let endX = 5, endY = hitY - dy * tL;
            if (endY < 5 || endY > this.H - 5) {
              endY = endY < 5 ? 5 : this.H - 5;
              const tE = (hitY - endY) / dy;
              endX = Math.max(5, mx - dx * tE);
            }
            pts.push([endX, endY]);
          }
        }
      }
      rays.push({ pts, color });
    };
    add(hit1Y, COLORS[0]); add(hit2Y, COLORS[1]); add(hit3Y, COLORS[2]);
    return rays;
  }

  _startPhotons() {
    if (this._photonRaf) return;
    this._lastPhoTime = performance.now();
    this._photonLoop();
  }

  _stopPhotons() {
    if (this._photonRaf) { cancelAnimationFrame(this._photonRaf); this._photonRaf = null; }
    this._photons = [];
    this.draw();
  }

  _photonLoop() {
    const now = performance.now();
    const dt  = Math.min((now - this._lastPhoTime) / 1000, 0.1);
    this._lastPhoTime = now;
    const spd = 200;
    for (const p of this._photons) p.t = Math.min(1, p.t + dt * spd / p.len);
    this._photons = this._photons.filter(p => p.t < 1);
    this._photonTimer += dt;
    if (this._photonTimer > 0.75 && this._photonPaths.length) {
      this._photonTimer = 0;
      for (const path of this._photonPaths) {
        if (path.pts.length < 2) continue;
        let len = 0;
        for (let i = 1; i < path.pts.length; i++)
          len += Math.hypot(path.pts[i][0] - path.pts[i-1][0], path.pts[i][1] - path.pts[i-1][1]);
        if (len > 20) this._photons.push({ pts: path.pts, color: path.color, t: 0, len });
      }
    }
    if (!this._playing) this.draw();
    this._photonRaf = requestAnimationFrame(() => this._photonLoop());
  }

  draw() {
    const { ctx, W, H } = this;
    if (!W || !H) return;
    const f  = this._fSigned();
    const mx = Math.round(W * 0.62);
    const ay = H / 2;
    let dPrime = null, hPrime = null;
    if (this.type === 'flat') { dPrime = -this.d; hPrime = this._pointMode ? 0 : this.h; }
    else {
      const den = this.d - f;
      if (Math.abs(den) >= 0.5) {
        dPrime = f * this.d / den;
        hPrime = this._pointMode ? 0 : (-dPrime / this.d) * this.h;
      }
    }
    const step    = this._step;
    const showRay  = i => step === -1 || i <= step;
    const showFill = step === -1 || step >= 3;
    this._photonPaths = this._getRayPaths(mx, ay, f, dPrime, hPrime);
    ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H);
    if (this._showGrid)  this._drawGrid(ctx);
    if (this._showZones) this._drawZones(ctx, mx);
    ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1; ctx.setLineDash([6, 4]);
    ctx.beginPath(); ctx.moveTo(0, ay); ctx.lineTo(W, ay); ctx.stroke();
    ctx.setLineDash([]);
    this._drawFanRays(ctx, mx, ay, f, dPrime, hPrime, showRay, showFill);
    /* spherical aberration overlay (Agent OB-A3) */
    if (this._showSpherical && this.type !== 'flat' && isFinite(f))
      this._drawMirrorSphericalAberration(ctx, mx, ay, f);
    /* Feature 2: parabolic/spherical aberration fan */
    if (this._useR && this.type !== 'flat' && isFinite(f))
      this._drawAberrationFan(ctx, mx, ay, f);
    this._drawMirror(ctx, mx, ay);
    /* Feature 2: R and f labels on mirror */
    if (this._useR && this.type !== 'flat' && isFinite(f)) {
      ctx.save();
      ctx.font = 'bold 11px Manrope, system-ui, sans-serif';
      ctx.fillStyle = 'rgba(6,214,224,0.9)';
      ctx.textAlign = 'right'; ctx.textBaseline = 'bottom';
      ctx.fillText('R=' + this._R.toFixed(0) + '  f=' + f.toFixed(0), mx - 4, ay - 6);
      ctx.restore();
    }
    if (this.type !== 'flat') {
      this._drawFocalPoints(ctx, mx, ay, f);
      this._drawCenterC(ctx, mx, ay, f);
    }
    if (this._showNormals && this.type !== 'flat' && (step === -1 || step >= 3))
      this._drawNormals(ctx, mx, ay, f);
    if (this._showAngles && this.type !== 'flat' && step === -1)
      this._drawAngleArcs(ctx, mx, ay, f);
    if (step === -1 || step >= 1) this._drawRayLabels(ctx, mx, ay, f, step);
    const objX = mx - this.d;
    if (this._pointMode) {
      ctx.save(); ctx.shadowColor = '#9B5DE5'; ctx.shadowBlur = 10;
      ctx.fillStyle = '#9B5DE5'; ctx.beginPath(); ctx.arc(objX, ay, 5, 0, Math.PI*2); ctx.fill(); ctx.restore();
    } else { this._drawArrow(ctx, objX, ay, objX, ay - this.h, '#9B5DE5', false); }
    if (dPrime !== null && isFinite(dPrime)) {
      const imgX = mx - dPrime, imgY = ay - (this._pointMode ? 0 : hPrime);
      if (this._pointMode) {
        ctx.save(); ctx.fillStyle = dPrime > 0 ? '#EF476F' : '#FFD166';
        if (dPrime < 0) { ctx.globalAlpha = 0.55; ctx.setLineDash([4,3]); }
        ctx.beginPath(); ctx.arc(imgX, ay, 5, 0, Math.PI*2);
        dPrime > 0 ? ctx.fill() : (() => { ctx.stroke(); })();
        ctx.restore();
      } else {
        this._drawArrow(ctx, imgX, ay, imgX, imgY, dPrime > 0 ? '#EF476F' : '#FFD166', dPrime <= 0);
      }
    }
    if (this._showDims && (step === -1 || step >= 3))
      this._drawDimensions(ctx, mx, ay, f, dPrime, hPrime);
    this._drawInfoBox(ctx, f, dPrime);
    if ((step === -1 || step >= 3) && dPrime !== null) this._drawImageBadge(ctx, dPrime, hPrime);
    this._drawCriticalMarker(ctx, f);
    if (this._showDims) this._drawLegend(ctx);
    if (this._showPhotons && this._photons.length) this._drawPhotons(ctx);
    this._drawTooltip(ctx, mx, ay, f, dPrime, hPrime);
    if (step >= 0) this._drawStepOverlay(ctx, step);

    // Mirror caustics near focal point when real image exists (only when OB_FX.caustics is off)
    if (!window.OB_FX.caustics && window.LabFX && dPrime !== null && isFinite(dPrime) && dPrime > 0) {
      const focX = mx - dPrime, focY = ay - (this._pointMode ? 0 : hPrime);
      if (!this._mCausticFrame) this._mCausticFrame = 0;
      this._mCausticFrame++;
      if (this._mCausticFrame % 4 === 0) {
        LabFX.particles.emit({ ctx, x: focX + (Math.random()-0.5)*10, y: focY + (Math.random()-0.5)*10, count: 2, color: '#FFD166', speed: 6, spread: Math.PI*2, life: 500, shape: 'dust', glow: true });
      }
    }
    // OB_FX visual depth layer
    {
      const objX = mx - this.d;
      const fxExtras = {
        srcX: objX, srcY: ay - this.h,
        causticParams: f > 0 ? { lx: mx, ay, f } : null,
        rays: [
          { x1: objX, y1: ay - this.h, x2: mx, y2: ay },
        ],
        boundary: { bx: mx, by: ay, normalAngle: 0, kind: 'reflection' },
      };
      if (!fxExtras.causticParams) delete fxExtras.causticParams;
      _drawOBFXLayer(ctx, 'mirror', fxExtras);
    }
    if (window.LabFX) { LabFX.particles.update(1/60); LabFX.particles.draw(ctx); }
  }

  _drawGrid(ctx) {
    ctx.strokeStyle = 'rgba(255,255,255,0.03)'; ctx.lineWidth = 1; ctx.beginPath();
    for (let x = 0; x < this.W; x += 40) { ctx.moveTo(x,0); ctx.lineTo(x,this.H); }
    for (let y = 0; y < this.H; y += 40) { ctx.moveTo(0,y); ctx.lineTo(this.W,y); }
    ctx.stroke();
  }

  _drawZones(ctx, mx) {
    const g1 = ctx.createLinearGradient(0,0,mx,0);
    g1.addColorStop(0, 'rgba(6,214,224,0.0)'); g1.addColorStop(1, 'rgba(6,214,224,0.03)');
    ctx.fillStyle = g1; ctx.fillRect(0, 0, mx, this.H);
    const g2 = ctx.createLinearGradient(mx,0,this.W,0);
    g2.addColorStop(0, 'rgba(239,71,111,0.04)'); g2.addColorStop(1, 'rgba(239,71,111,0.0)');
    ctx.fillStyle = g2; ctx.fillRect(mx, 0, this.W-mx, this.H);
  }

  _drawMirror(ctx, mx, ay) {
    const mH = Math.min(this.H * 0.4, 150);
    ctx.save();
    const ease   = t => t < 0.5 ? 2*t*t : -1+(4-2*t)*t;
    const bulge  = this._getBulge(this._prevType) + (this._getBulge(this.type) - this._getBulge(this._prevType)) * ease(this._transT);
    ctx.strokeStyle = 'rgba(6,214,224,0.92)'; ctx.lineWidth = 3;
    ctx.shadowColor = 'rgba(6,214,224,0.45)'; ctx.shadowBlur = 8;
    ctx.beginPath(); ctx.moveTo(mx, ay - mH); ctx.quadraticCurveTo(mx + bulge, ay, mx, ay + mH); ctx.stroke();
    ctx.shadowBlur = 0;
    ctx.strokeStyle = 'rgba(6,214,224,0.15)'; ctx.lineWidth = 1.5;
    for (let i = 0; i <= 10; i++) {
      const y = ay - mH + i * mH * 2 / 10;
      ctx.beginPath(); ctx.moveTo(mx, y); ctx.lineTo(mx+14, y+10); ctx.stroke();
    }
    ctx.restore();
  }

  _drawFocalPoints(ctx, mx, ay, f) {
    const pts = [{ px: mx-f, lbl:'F', r:5 }, { px: mx-2*f, lbl:'2F', r:3.5 }];
    ctx.font = '11px Manrope, system-ui, sans-serif';
    for (const p of pts) {
      if (p.px < 4 || p.px > this.W-4) continue;
      const col = f < 0 ? 'rgba(255,209,102,0.7)' : '#06D6E0';
      ctx.fillStyle = col; ctx.beginPath(); ctx.arc(p.px, ay, p.r, 0, Math.PI*2); ctx.fill();
      ctx.fillStyle = col; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText(p.lbl, p.px, ay+9);
    }
  }

  _drawCenterC(ctx, mx, ay, f) {
    if (!isFinite(f)) return;
    const cx = mx - 2*f;
    if (cx < 4 || cx > this.W-4) return;
    const pulse = Math.abs(this.d - 2*Math.abs(f)) < Math.abs(f)*0.06;
    ctx.save();
    if (pulse) { ctx.shadowColor='rgba(255,152,0,0.9)'; ctx.shadowBlur=14; }
    ctx.fillStyle = pulse ? '#FF9800' : 'rgba(255,152,0,0.5)';
    ctx.beginPath(); ctx.arc(cx, ay, pulse ? 5 : 3.5, 0, Math.PI*2); ctx.fill();
    ctx.shadowBlur = 0;
    ctx.font = '11px Manrope, system-ui, sans-serif';
    ctx.fillStyle = pulse ? '#FF9800' : 'rgba(255,152,0,0.6)';
    ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText('C', cx, ay+9);
    ctx.restore();
  }

  _drawFanRays(ctx, mx, ay, f, dPrime, hPrime, showRay, showFill) {
    const { d, h, type } = this;
    const hasImg = dPrime !== null && isFinite(dPrime);
    const isReal = hasImg && dPrime > 0;
    const imgX   = hasImg ? mx - dPrime : null;
    const imgY   = hasImg ? ay - (this._pointMode ? 0 : hPrime) : null;
    const objX   = mx - d, objY = ay - (this._pointMode ? 0 : h);
    // Wavelength-aware color
    const _wl = window._obWhiteLight;
    const _wlNm = window._obWavelength || 550;
    const _mc = wavelengthToRGB(_wlNm);
    const COLS   = _wl ? [wavelengthToRGB(660), wavelengthToRGB(550), wavelengthToRGB(450)] : [_mc, _mc, _mc];
    const FAN    = _wl ? 'rgba(255,255,255,0.12)' : `rgba(255,255,255,0.18)`;
    if (type === 'flat') {
      const hits = [objY, ay, ay - (this._pointMode ? 0 : h)*0.5];
      hits.forEach((hy, i) => {
        if (!showRay(i)) return;
        this._flatRay(ctx, mx, ay, d, h, objX, objY, hy, COLS[i], imgX, imgY, hasImg);
      });
      return;
    }
    const hit1 = ay - (this._pointMode ? 0 : h);
    const hit2 = ay;
    const den3  = d - f;
    const hit3  = Math.abs(den3) < 0.5 ? null : ay + (this._pointMode ? 0 : h)*f/den3;
    if (showFill) {
      const fills = [(hit1+hit2)/2];
      if (hit3 !== null && isFinite(hit3)) fills.push((hit2+hit3)/2);
      for (const hy of fills) this._oneRay(ctx, mx, objX, objY, hy, FAN, 0.6, hasImg, isReal, imgX, imgY);
    }
    if (showRay(0)) this._oneRay(ctx, mx, objX, objY, hit1, COLS[0], 1.0, hasImg, isReal, imgX, imgY);
    if (showRay(1)) this._oneRay(ctx, mx, objX, objY, hit2, COLS[1], 1.0, hasImg, isReal, imgX, imgY);
    if (showRay(2)) this._oneRay(ctx, mx, objX, objY, hit3, COLS[2], 1.0, hasImg, isReal, imgX, imgY);
  }

  _oneRay(ctx, mx, ox, oy, hitY, color, alpha, hasImg, isReal, imgX, imgY) {
    if (hitY === null || !isFinite(hitY) || hitY < -this.H || hitY > 2*this.H) return;
    ctx.save(); ctx.globalAlpha = alpha;
    if (window.LabFX && alpha > 0.5) { ctx.shadowColor = color; ctx.shadowBlur = 8; }
    ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([]);
    ctx.beginPath(); ctx.moveTo(ox, oy); ctx.lineTo(mx, hitY); ctx.stroke();
    if (!hasImg) { ctx.restore(); return; }
    if (isReal) {
      ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(imgX, imgY); ctx.stroke();
      const dx = imgX-mx, dy = imgY-hitY, l = Math.hypot(dx,dy);
      if (l > 1) { ctx.globalAlpha = alpha * 0.22; ctx.beginPath(); ctx.moveTo(imgX,imgY); ctx.lineTo(imgX+dx/l*60, imgY+dy/l*60); ctx.stroke(); }
    } else {
      const dx = imgX-mx, dy = imgY-hitY;
      if (Math.abs(dx) < 1) { ctx.restore(); return; }
      const tL = (mx-5)/dx;
      let ex = 5, ey = hitY - dy*tL;
      if (ey < 5 || ey > this.H-5) { ey = ey < 5 ? 5 : this.H-5; ex = Math.max(5, mx - dx*(hitY-ey)/dy); }
      ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(ex, ey); ctx.stroke();
      ctx.globalAlpha = alpha * 0.4; ctx.setLineDash([4,4]);
      ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(imgX, imgY); ctx.stroke();
      ctx.setLineDash([]);
    }
    ctx.restore();
  }

  _flatRay(ctx, mx, ay, d, h, ox, oy, hitY, color, imgX, imgY, hasImg) {
    ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([]);
    ctx.beginPath(); ctx.moveTo(ox, oy); ctx.lineTo(mx, hitY); ctx.stroke();
    const slope = (hitY-oy)/(mx-ox);
    const farX  = Math.max(5, ox-50);
    const farY  = hitY - slope*(mx-farX);
    ctx.globalAlpha = 0.3;
    ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(farX, Math.max(5, Math.min(this.H-5, farY))); ctx.stroke();
    ctx.globalAlpha = 1;
    if (hasImg) { ctx.setLineDash([4,4]); ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(imgX, imgY); ctx.stroke(); ctx.setLineDash([]); }
    ctx.restore();
  }

  /* ── Mirror spherical aberration (Agent OB-A3) ────────────────
     For a spherical concave mirror, paraxial focal length = f.
     Marginal rays: f_eff(h) = f - h²/(4f)  (simplified).
     Draw 5 incoming parallel rays at different heights; show they
     focus at slightly different points along the axis.
  ─────────────────────────────────────────────────────────────── */
  _drawMirrorSphericalAberration(ctx, mx, ay, f) {
    const mH   = Math.min(this.H * 0.38, 140);
    const fracs = [-0.85, -0.5, 0, 0.5, 0.85];
    const pal   = ['#FF6B6B', '#FFD166', '#7BF5A4', '#06D6E0', '#B8A4FF'];
    ctx.save();
    ctx.lineWidth = 1.3;
    const focusPts = [];
    fracs.forEach((fr, i) => {
      const rayH   = fr * mH;
      const fEff   = f > 0 ? f - (rayH * rayH) / (4 * f) : f;
      const focX   = mx - fEff;
      ctx.strokeStyle = pal[i];
      ctx.globalAlpha = 0.7;
      ctx.setLineDash([]);
      const startY = ay - rayH;
      // incoming parallel ray from left
      ctx.beginPath(); ctx.moveTo(0, startY); ctx.lineTo(mx, startY); ctx.stroke();
      // reflected ray toward (approximate) focus
      if (focX > 5 && focX < mx) {
        ctx.beginPath(); ctx.moveTo(mx, startY); ctx.lineTo(focX, ay);
        ctx.lineTo(focX - 60, ay + (startY - ay) * 0.4); ctx.stroke();
        focusPts.push({ fEff, x: focX });
      }
    });
    ctx.globalAlpha = 1;
    ctx.restore();
    // info box
    if (focusPts.length >= 2) {
      const fMin = Math.min(...focusPts.map(p => p.fEff)).toFixed(0);
      const fMax = Math.max(...focusPts.map(p => p.fEff)).toFixed(0);
      ctx.save();
      const bx = 12, by = 70;
      ctx.fillStyle = 'rgba(22,22,38,0.88)';
      ctx.beginPath(); ctx.roundRect(bx, by, 224, 44, 8); ctx.fill();
      ctx.font = '11px Manrope, system-ui, sans-serif';
      ctx.textAlign = 'left'; ctx.textBaseline = 'top';
      ctx.fillStyle = '#FF6B6B'; ctx.fillText('Сферическая аберрация (зеркало)', bx + 8, by + 6);
      ctx.fillStyle = 'rgba(255,255,255,0.55)';
      ctx.fillText('f парак.=' + fMax + '  f краев.=' + fMin, bx + 8, by + 24);
      ctx.restore();
    }
  }

  _drawNormals(ctx, mx, ay, f) {
    if (!isFinite(f)) return;
    const { d, h } = this;
    const cX = mx - 2*f;
    const hits = [ay-h, ay];
    const d3 = d-f;
    if (Math.abs(d3) >= 0.5) { const y3 = ay + h*f/d3; if (isFinite(y3)) hits.push(y3); }
    ctx.save(); ctx.strokeStyle='rgba(255,255,255,0.14)'; ctx.lineWidth=1; ctx.setLineDash([4,4]);
    for (const hy of hits) {
      if (hy < -this.H || hy > 2*this.H) continue;
      const nx=cX-mx, ny=ay-hy, nl=Math.hypot(nx,ny);
      if (nl < 1) continue;
      const ux=nx/nl*28, uy=ny/nl*28;
      ctx.beginPath(); ctx.moveTo(mx-ux,hy-uy); ctx.lineTo(mx+ux,hy+uy); ctx.stroke();
    }
    ctx.setLineDash([]); ctx.restore();
  }

  _drawAngleArcs(ctx, mx, ay, f) {
    if (!isFinite(f)) return;
    const { d, h } = this;
    const hitY = ay - h;
    if (hitY < 5 || hitY > this.H-5) return;
    const cX = mx - 2*f;
    const nx = cX-mx, ny = ay-hitY, nl = Math.hypot(nx, ny);
    if (nl < 1) return;
    const normInward = Math.atan2(ny, nx);
    const normOuter  = normInward + Math.PI;
    const incDir     = Math.atan2(hitY-(ay-h), mx-(mx-d));
    const incFrom    = incDir + Math.PI;
    const r = 14;
    ctx.save(); ctx.lineWidth = 1;
    ctx.strokeStyle = 'rgba(6,214,224,0.45)';
    ctx.beginPath(); ctx.arc(mx, hitY, r, normOuter, incFrom, false); ctx.stroke();
    ctx.fillStyle = 'rgba(6,214,224,0.7)'; ctx.font = '9px Manrope, system-ui, sans-serif';
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    const mid = (normOuter+incFrom)/2;
    ctx.fillText('θ', mx+Math.cos(mid)*(r+9), hitY+Math.sin(mid)*(r+9));
    ctx.restore();
  }

  _drawRayLabels(ctx, mx, ay, f, step) {
    if (this.type === 'flat' || !isFinite(f)) return;
    const { d, h } = this;
    const hits = [ay-h, ay, null];
    const den3 = d-f;
    if (Math.abs(den3) >= 0.5) { const y3 = ay+h*f/den3; if (isFinite(y3)) hits[2] = y3; }
    const COLS = ['#06D6E0','#7BF5A4','#FFD166'];
    const LBLS = ['①','②','③'];
    ctx.font = 'bold 11px Manrope, system-ui, sans-serif'; ctx.textAlign = 'left';
    hits.forEach((hy, i) => {
      if (hy === null || !isFinite(hy) || hy < -50 || hy > this.H+50) return;
      if (step !== -1 && i > step) return;
      ctx.fillStyle = COLS[i]; ctx.textBaseline = 'middle'; ctx.fillText(LBLS[i], mx+8, hy);
    });
  }

  _drawArrow(ctx, x1, y1, x2, y2, color, dashed) {
    ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 2.5;
    if (dashed) ctx.setLineDash([6,4]);
    ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.stroke();
    if (dashed) ctx.setLineDash([]);
    const a = Math.atan2(y2-y1, x2-x1), s=10;
    ctx.beginPath(); ctx.moveTo(x2,y2);
    ctx.lineTo(x2-s*Math.cos(a-0.35), y2-s*Math.sin(a-0.35));
    ctx.lineTo(x2-s*Math.cos(a+0.35), y2-s*Math.sin(a+0.35));
    ctx.closePath(); ctx.fill();
  }

  _drawDimensions(ctx, mx, ay, f, dPrime, hPrime) {
    const { d, h } = this;
    const objX  = mx - d;
    const yBase = ay + Math.min(this.H*0.22, 60);
    ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.lineWidth = 1;
    const bracket = (x1, x2, y, lbl, col) => {
      if (x1 === x2 || x1 < 4 || x2 > this.W-4) return;
      ctx.strokeStyle = col; ctx.fillStyle = col; ctx.setLineDash([]);
      ctx.beginPath();
      ctx.moveTo(x1, y-5); ctx.lineTo(x1, y+5); ctx.moveTo(x1, y); ctx.lineTo(x2, y);
      ctx.moveTo(x2, y-5); ctx.lineTo(x2, y+5); ctx.stroke();
      ctx.textAlign='center'; ctx.textBaseline='top'; ctx.fillText(lbl, (x1+x2)/2, y+3);
    };
    bracket(objX, mx, yBase, 'd=' + d.toFixed(0), 'rgba(155,93,229,0.65)');
    if (isFinite(f) && Math.abs(f) > 5) {
      const fX = mx-f;
      if (fX > 4 && fX < this.W-4)
        bracket(Math.min(fX,mx), Math.max(fX,mx), yBase+20, 'f=' + Math.abs(f).toFixed(0), 'rgba(6,214,224,0.55)');
    }
    if (dPrime !== null && isFinite(dPrime)) {
      const ix = mx-dPrime;
      if (ix > 4 && ix < this.W-4)
        bracket(Math.min(ix,mx), Math.max(ix,mx), yBase, "d'=" + Math.abs(dPrime).toFixed(0),
          dPrime > 0 ? 'rgba(239,71,111,0.65)' : 'rgba(255,209,102,0.65)');
    }
    const xl = objX-18;
    if (xl > 4 && h > 6 && !this._pointMode) {
      ctx.strokeStyle='rgba(155,93,229,0.4)';
      ctx.beginPath(); ctx.moveTo(xl,ay); ctx.lineTo(xl,ay-h); ctx.stroke();
      ctx.fillStyle='rgba(155,93,229,0.7)'; ctx.textAlign='right'; ctx.textBaseline='middle';
      ctx.fillText('h=' + h.toFixed(0), xl-3, ay-h/2);
    }
    if (dPrime !== null && isFinite(dPrime) && !this._pointMode && Math.abs(hPrime) > 6) {
      const ix  = mx-dPrime;
      const xil = ix + (dPrime > 0 ? -18 : 18);
      if (xil > 4 && xil < this.W-4) {
        const col = dPrime > 0 ? 'rgba(239,71,111,' : 'rgba(255,209,102,';
        ctx.strokeStyle = col+'0.4)'; ctx.beginPath(); ctx.moveTo(ix,ay); ctx.lineTo(ix,ay-hPrime); ctx.stroke();
        ctx.fillStyle  = col+'0.7)';
        ctx.textAlign  = dPrime > 0 ? 'right' : 'left'; ctx.textBaseline='middle';
        ctx.fillText("h'=" + Math.abs(hPrime).toFixed(0), ix+(dPrime>0?-3:3), ay-hPrime/2);
      }
    }
  }

  _drawInfoBox(ctx, f, dPrime) {
    const info = this.info();
    const bx=12, by=12, bw=230, bh=76;
    ctx.fillStyle='rgba(13,13,26,0.9)'; ctx.beginPath(); ctx.roundRect(bx,by,bw,bh,8); ctx.fill();
    ctx.strokeStyle='rgba(255,255,255,0.06)'; ctx.lineWidth=1;
    ctx.beginPath(); ctx.roundRect(bx,by,bw,bh,8); ctx.stroke();
    ctx.font='11px Manrope, system-ui, sans-serif'; ctx.textAlign='left'; ctx.textBaseline='top';
    ctx.fillStyle='rgba(255,255,255,0.42)';
    ctx.fillText("1/f  =  1/d  +  1/d'", bx+10, by+8);
    if (isFinite(f) && dPrime !== null && isFinite(dPrime)) {
      ctx.fillStyle='rgba(6,214,224,0.88)';  ctx.fillText('1/' + Math.abs(+info.f), bx+10, by+28);
      ctx.fillStyle='rgba(255,255,255,0.28)';ctx.fillText('=',bx+60,by+28);
      ctx.fillStyle='rgba(155,93,229,0.88)'; ctx.fillText('1/' + info.d, bx+78, by+28);
      ctx.fillStyle='rgba(255,255,255,0.28)';ctx.fillText('+',bx+120,by+28);
      ctx.fillStyle= dPrime>0 ? 'rgba(239,71,111,0.88)' : 'rgba(255,209,102,0.88)';
      ctx.fillText((dPrime>0?'':'−') + '1/' + Math.abs(+info.dPrime).toFixed(0), bx+136, by+28);
    } else {
      ctx.fillStyle='rgba(255,209,102,0.75)';
      ctx.fillText('d = f  →  изображение на ∞', bx+10, by+28);
    }
    if (info.M !== Infinity) {
      ctx.fillStyle='rgba(255,255,255,0.28)'; ctx.fillText('M = ' + info.M, bx+10, by+48);
      if (isFinite(dPrime)) {
        ctx.fillStyle = dPrime > 0 ? '#EF476F' : '#FFD166';
        ctx.textAlign = 'right'; ctx.fillText(info.imageType + '  ' + info.orient, bx+bw-10, by+48);
        ctx.textAlign = 'left';
      }
    }
  }

  _drawImageBadge(ctx, dPrime, hPrime) {
    const info = this.info();
    const bw=160, bh=58, bx=this.W-bw-12, by=12;
    ctx.fillStyle='rgba(13,13,26,0.88)'; ctx.beginPath(); ctx.roundRect(bx,by,bw,bh,8); ctx.fill();
    ctx.strokeStyle='rgba(255,255,255,0.06)'; ctx.lineWidth=1;
    ctx.beginPath(); ctx.roundRect(bx,by,bw,bh,8); ctx.stroke();
    const isInf = !isFinite(dPrime);
    ctx.font='10px Manrope, system-ui, sans-serif'; ctx.textAlign='left'; ctx.textBaseline='top';
    const tc = isInf ? '#FFD166' : dPrime>0 ? '#EF476F' : '#FFD166';
    ctx.fillStyle='rgba(255,255,255,0.3)'; ctx.fillText('Тип:',    bx+10, by+8);
    ctx.fillStyle=tc;                       ctx.fillText(isInf?'∞':info.imageType, bx+44, by+8);
    if (!isInf) {
      ctx.fillStyle='rgba(255,255,255,0.3)'; ctx.fillText('Ориент.:', bx+10, by+26);
      ctx.fillStyle = info.M<0 ? 'rgba(239,71,111,0.9)' : 'rgba(123,245,164,0.9)';
      ctx.fillText(info.orient, bx+62, by+26);
      if (info.sizeStr) {
        const sc = Math.abs(+info.M)>1.05 ? '#9B5DE5' : Math.abs(+info.M)<0.95 ? '#06D6E0' : 'rgba(255,255,255,0.6)';
        ctx.fillStyle='rgba(255,255,255,0.3)'; ctx.fillText('Размер:', bx+10, by+42);
        ctx.fillStyle=sc; ctx.fillText(info.sizeStr + '  x' + Math.abs(+info.M).toFixed(2), bx+57, by+42);
      }
    }
  }

  _drawCriticalMarker(ctx, f) {
    if (!isFinite(f) || f <= 0) return;
    const eps = f*0.06;
    let text = null;
    if (Math.abs(this.d-f) < eps)     text = 'd = f : лучи параллельны, изображения нет';
    else if (Math.abs(this.d-2*f)<eps) text = 'd = 2f : изображение равное, M = −1';
    if (!text) return;
    ctx.save(); ctx.font='bold 11px Manrope, system-ui, sans-serif';
    ctx.textAlign='center'; ctx.textBaseline='top';
    const tw=ctx.measureText(text).width, bx=this.W/2-tw/2-10, by=4;
    ctx.fillStyle='rgba(255,209,102,0.15)'; ctx.beginPath(); ctx.roundRect(bx,by,tw+20,22,6); ctx.fill();
    ctx.strokeStyle='rgba(255,209,102,0.35)'; ctx.lineWidth=1;
    ctx.beginPath(); ctx.roundRect(bx,by,tw+20,22,6); ctx.stroke();
    ctx.fillStyle='#FFD166'; ctx.fillText(text, this.W/2, by+5);
    ctx.restore();
  }

  _drawLegend(ctx) {
    const items = [
      { c:'rgba(155,93,229,0.8)', t:'d > 0 — предмет перед зеркалом' },
      { c:'rgba(239,71,111,0.8)', t:"d' > 0 — действительное" },
      { c:'rgba(255,209,102,0.8)',t:"d' < 0 — мнимое" },
    ];
    const bx=12, lh=14, by=this.H - items.length*lh - 16;
    ctx.save(); ctx.font='9px Manrope, system-ui, sans-serif'; ctx.textBaseline='top';
    items.forEach(({ c, t }, i) => {
      const y = by+i*lh;
      ctx.fillStyle=c; ctx.fillRect(bx, y+3, 8, 8);
      ctx.fillStyle='rgba(255,255,255,0.32)'; ctx.textAlign='left'; ctx.fillText(t, bx+13, y);
    });
    ctx.restore();
  }

  _drawPhotons(ctx) {
    for (const p of this._photons) {
      const pos = this._photonPos(p.pts, p.t);
      if (!pos) continue;
      ctx.save(); ctx.shadowColor = p.color; ctx.shadowBlur = 8;
      ctx.fillStyle = p.color; ctx.beginPath(); ctx.arc(pos[0], pos[1], 3, 0, Math.PI*2); ctx.fill();
      ctx.restore();
    }
  }

  _photonPos(pts, t) {
    if (pts.length < 2) return null;
    let total = 0;
    const lens = [];
    for (let i=1; i<pts.length; i++) {
      const l = Math.hypot(pts[i][0]-pts[i-1][0], pts[i][1]-pts[i-1][1]);
      lens.push(l); total += l;
    }
    let dist = t * total;
    for (let i=0; i<lens.length; i++) {
      if (dist <= lens[i]) { const k = dist/lens[i]; return [pts[i][0]+(pts[i+1][0]-pts[i][0])*k, pts[i][1]+(pts[i+1][1]-pts[i][1])*k]; }
      dist -= lens[i];
    }
    return pts[pts.length-1];
  }

  _drawTooltip(ctx, mx, ay, f, dPrime, hPrime) {
    const { _hoverX:hx, _hoverY:hy } = this;
    if (hx < 0) return;
    let tip = null;
    const chk = (px, py, lbl, sub) => { if (!tip && Math.hypot(hx-px, hy-py) < 15) tip = { lbl, sub }; };
    if (isFinite(f)) {
      chk(mx-f, ay, 'Главный фокус F', 'f = ' + Math.abs(f).toFixed(0));
      chk(mx-2*f, ay, 'Центр кривизны C', 'R = 2f = ' + (2*Math.abs(f)).toFixed(0));
    }
    chk(mx-this.d, ay-(this._pointMode?0:this.h), 'Предмет', 'd = ' + this.d.toFixed(0) + ', h = ' + this.h.toFixed(0));
    if (dPrime !== null && isFinite(dPrime)) {
      const ix=mx-dPrime, iy=ay-(this._pointMode?0:hPrime);
      chk(ix, iy, 'Изображение', "d' = " + Math.abs(dPrime).toFixed(0) + ', M = ' + this.info().M);
    }
    if (!tip) return;
    ctx.save();
    ctx.font = 'bold 11px Manrope, system-ui, sans-serif';
    const tw = Math.max(ctx.measureText(tip.lbl).width, ctx.measureText(tip.sub).width);
    const bw=tw+20, bh=34;
    let tx=hx+14, ty=hy-bh-6;
    if (tx+bw > this.W-4) tx = hx-bw-14;
    if (ty < 4) ty = hy+10;
    ctx.fillStyle='rgba(13,13,26,0.95)'; ctx.strokeStyle='rgba(6,214,224,0.45)'; ctx.lineWidth=1;
    ctx.beginPath(); ctx.roundRect(tx,ty,bw,bh,6); ctx.fill(); ctx.stroke();
    ctx.fillStyle='#fff'; ctx.textAlign='left'; ctx.textBaseline='top'; ctx.fillText(tip.lbl, tx+10, ty+6);
    ctx.font='10px Manrope, system-ui, sans-serif';
    ctx.fillStyle='rgba(255,255,255,0.5)'; ctx.fillText(tip.sub, tx+10, ty+20);
    ctx.restore();
  }

  _drawStepOverlay(ctx, step) {
    const lbls = [
      '① Луч параллельно оси → отражается через F',
      '② Луч через вершину → отражается симметрично',
      '③ Луч через F → отражается параллельно',
      '   Изображение — пересечение всех отражённых лучей',
    ];
    const text = lbls[Math.min(step, lbls.length-1)];
    ctx.save(); ctx.font = '11px Manrope, system-ui, sans-serif';
    const tw = ctx.measureText(text).width;
    const bx = this.W/2-tw/2-12, by = this.H-34;
    ctx.fillStyle='rgba(13,13,26,0.9)'; ctx.beginPath(); ctx.roundRect(bx,by,tw+24,24,6); ctx.fill();
    ctx.fillStyle='#7BF5A4'; ctx.textAlign='center'; ctx.textBaseline='middle';
    ctx.fillText(text, this.W/2, by+12);
    ctx.restore();
  }

  /* === Feature 2: R-slider mode for MirrorSim === */
  setMirrorR(R) {
    this._useR = true;
    this._R = +R;
    // Derive type and f from R
    const absR = Math.abs(this._R);
    if (absR < 5) { this.type = 'flat'; }
    else if (this._R > 0) { this.type = 'concave'; this.f = absR / 2; }
    else { this.type = 'convex'; this.f = absR / 2; }
    this.draw(); this._emit();
  }

  setMirrorParabolic(on) {
    this._parabolic = !!on;
    this.draw();
  }

  /* Draw 5 parallel rays showing spherical vs parabolic aberration */
  _drawAberrationFan(ctx, mx, ay, f) {
    if (!isFinite(f) || Math.abs(f) < 5) return;
    const mH = Math.min(this.H * 0.38, 140);
    const heights = [-0.85, -0.45, 0, 0.45, 0.85];
    const COLORS = ['#FF6B6B', '#FFD166', '#7BF5A4', '#06D6E0', '#B8A4FF'];
    ctx.save(); ctx.lineWidth = 1.4;
    heights.forEach((fr, i) => {
      const rayH = fr * mH;
      // For parabolic mirror: all parallel rays focus exactly at f
      // For spherical: marginal rays (fr != 0) focus closer by h^2/(2R) approx
      const fEff = this._parabolic ? f : f - (rayH * rayH) / (2 * Math.abs(f) * 2);
      const startX = mx - this.d - 40;
      const hitY   = ay - rayH; // hits mirror at height rayH
      // Incident ray: horizontal from left to mirror
      ctx.strokeStyle = COLORS[i]; ctx.globalAlpha = 0.75;
      ctx.setLineDash([]);
      ctx.beginPath(); ctx.moveTo(startX, ay - rayH); ctx.lineTo(mx, hitY); ctx.stroke();
      // Reflected ray: goes toward focal point fEff
      const focX = mx - fEff;
      if (focX > 0 && focX < this.W) {
        ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(focX, ay);
        // extend a bit past focus
        const dx = focX - mx, dy = ay - hitY, len = Math.hypot(dx, dy);
        if (len > 1) ctx.lineTo(focX + dx/len*50, ay + dy/len*50);
        ctx.stroke();
      }
    });
    ctx.globalAlpha = 1;
    // label
    const label = this._parabolic ? 'Параболическое (идеальный фокус)' : 'Сферическое (аберрация)';
    const col   = this._parabolic ? '#7BF5A4' : '#FF6B6B';
    const bx = 12, by = this.H - 36;
    ctx.fillStyle = 'rgba(13,13,26,0.85)';
    ctx.beginPath(); ctx.roundRect(bx, by, 250, 24, 6); ctx.fill();
    ctx.font = 'bold 11px Manrope, system-ui, sans-serif';
    ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
    ctx.fillStyle = col;
    ctx.fillText(label, bx + 8, by + 12);
    ctx.restore();
  }

  _bindEvents() {
    const cv = this.canvas;
    const getPos = e => {
      const r = cv.getBoundingClientRect();
      const t = e.touches ? e.touches[0] : e;
      return { px: (t.clientX-r.left)*(this.W/r.width), py: (t.clientY-r.top)*(this.H/r.height) };
    };
    const mX  = () => Math.round(this.W*0.62);
    const aY  = () => this.H/2;
    const hitTest = (px, py) => {
      if (this._playing) return null;
      const mx=mX(), ay=aY(), f=this._fSigned();
      if (Math.hypot(px-(mx-this.d), py-(ay-(this._pointMode?0:this.h))) < 20) return 'object';
      if (this.type !== 'flat' && isFinite(f) && Math.hypot(px-(mx-f), py-ay) < 16) return 'focus';
      const info = this.info();
      if (info.dPrime !== Infinity && isFinite(info.dPrime)) {
        const ix=mx-info.dPrime, iy=ay-(this._pointMode?0:(info.hPrime||0));
        if (Math.hypot(px-ix, py-iy) < 18) return 'image';
      }
      return null;
    };
    cv.addEventListener('mousedown', e => { const {px,py}=getPos(e); this._drag=hitTest(px,py); });
    window.addEventListener('mousemove', e => {
      const {px,py} = getPos(e);
      this._hoverX = px; this._hoverY = py;
      if (this._drag) {
        if (e.cancelable) e.preventDefault();
        const mx=mX(), f=this._fSigned();
        if (this._drag === 'object') this.d = Math.max(30, Math.min(490, mx-px));
        else if (this._drag === 'focus') this.f = Math.max(30, Math.min(300, Math.abs(mx-px)));
        else if (this._drag === 'image' && isFinite(f) && this.type !== 'flat') {
          const dp = mx-px; if (Math.abs(dp-f) > 5) this.d = Math.max(30, Math.min(490, f*dp/(dp-f)));
        }
        if (this.onAnimate) this.onAnimate(this.d);
        this.draw(); this._emit();
      } else if (!this._photonRaf && !this._playing) { this.draw(); }
    });
    window.addEventListener('mouseup', () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = null; });
    cv.addEventListener('mousemove', e => {
      if (this._drag) { cv.style.cursor='grabbing'; return; }
      const {px,py}=getPos(e);
      cv.style.cursor = (hitTest(px,py) && !this._playing) ? 'grab' : 'default';
    });
    cv.addEventListener('touchstart', e => {
      if (e.touches.length===1) { const {px,py}=getPos(e); this._drag=hitTest(px,py); }
    }, { passive: true });
    cv.addEventListener('touchmove', e => {
      if (!this._drag) return;
      if (e.cancelable) e.preventDefault();
      const {px}=getPos(e), mx=mX(), f=this._fSigned();
      if (this._drag==='object') this.d=Math.max(30,Math.min(490,mx-px));
      else if (this._drag==='focus') this.f=Math.max(30,Math.min(300,Math.abs(mx-px)));
      else if (this._drag==='image' && isFinite(f) && this.type!=='flat') {
        const dp=mx-px; if (Math.abs(dp-f)>5) this.d=Math.max(30,Math.min(490,f*dp/(dp-f)));
      }
      if (this.onAnimate) this.onAnimate(this.d);
      this.draw(); this._emit();
    }, { passive: false });
    cv.addEventListener('touchend', () => { this._drag=null; });
  }
}

/* ─────────────────────────────────────────────────────────────
   3.  REFRACTION ENGINE  (from refraction.js)
───────────────────────────────────────────────────────────────*/
class RefractionSim {
  constructor(canvas) {
    this.canvas = canvas;
    this.ctx    = canvas.getContext('2d');
    this.W = 0; this.H = 0;

    this.n1    = 1.0;
    this.n2    = 1.5;
    this.angle = 30;
    this.dispersion = false;

    this._drag = false;
    this.onUpdate = null;

    this._bindEvents();
    new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
  }

  fit() {
    const dpr = window.devicePixelRatio || 1;
    const w = this.canvas.offsetWidth || 600;
    const h = this.canvas.offsetHeight || 400;
    this.canvas.width  = w * dpr;
    this.canvas.height = h * dpr;
    this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    this.W = w; this.H = h;
  }

  getParams() { return { n1: this.n1, n2: this.n2, angle: this.angle, dispersion: this.dispersion }; }
  setParams({ n1, n2, angle, dispersion } = {}) {
    if (n1    !== undefined) this.n1    = Math.max(1.0, Math.min(3.0, +n1));
    if (n2    !== undefined) this.n2    = Math.max(1.0, Math.min(3.0, +n2));
    if (angle !== undefined) this.angle = Math.max(0, Math.min(89, +angle));
    if (dispersion !== undefined) this.dispersion = !!dispersion;
    this.draw(); this._emit();
  }

  reset() { this.n1 = 1.0; this.n2 = 1.5; this.angle = 30; this.dispersion = false; this.draw(); this._emit(); }

  info() {
    const { n1, n2, angle } = this;
    const theta1Rad = angle * Math.PI / 180;
    const sinTheta2 = (n1 / n2) * Math.sin(theta1Rad);
    const isTIR = Math.abs(sinTheta2) > 1;
    const criticalAngle = n1 > n2 ? +(Math.asin(n2 / n1) * 180 / Math.PI).toFixed(1) : null;
    let angle2;
    if (isTIR) angle2 = 'ПВО';
    else angle2 = +(Math.asin(sinTheta2) * 180 / Math.PI).toFixed(1);
    return { n1: +n1.toFixed(2), n2: +n2.toFixed(2), angle1: +angle.toFixed(1), angle2, criticalAngle, isTIR };
  }

  _emit() { if (this.onUpdate) this.onUpdate(this.info()); }

  draw() {
    const ctx = this.ctx, W = this.W, H = this.H;
    if (!W || !H) return;
    const midY = H / 2, hitX = W / 2, hitY = midY;
    const gradTop = ctx.createLinearGradient(0, 0, 0, midY);
    gradTop.addColorStop(0, '#131328'); gradTop.addColorStop(1, '#1a1a3a');
    ctx.fillStyle = gradTop; ctx.fillRect(0, 0, W, midY);
    const gradBot = ctx.createLinearGradient(0, midY, 0, H);
    gradBot.addColorStop(0, '#0e1a2e'); gradBot.addColorStop(1, '#0D0D1A');
    ctx.fillStyle = gradBot; ctx.fillRect(0, midY, W, H - midY);
    ctx.save();
    ctx.shadowColor = 'rgba(155, 93, 229, 0.4)'; ctx.shadowBlur = 12;
    ctx.strokeStyle = 'rgba(155, 93, 229, 0.5)'; ctx.lineWidth = 2;
    ctx.beginPath(); ctx.moveTo(0, midY); ctx.lineTo(W, midY); ctx.stroke();
    ctx.restore();
    ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1; ctx.setLineDash([6, 4]);
    ctx.beginPath(); ctx.moveTo(hitX, 0); ctx.lineTo(hitX, H); ctx.stroke();
    ctx.setLineDash([]);
    const theta1Rad = this.angle * Math.PI / 180;
    const sinTheta2 = (this.n1 / this.n2) * Math.sin(theta1Rad);
    const isTIR = Math.abs(sinTheta2) > 1;
    let R = 1;
    if (!isTIR) {
      const theta2Rad = Math.asin(sinTheta2);
      const cosT1 = Math.cos(theta1Rad), cosT2 = Math.cos(theta2Rad);
      const rs = (this.n1 * cosT1 - this.n2 * cosT2) / (this.n1 * cosT1 + this.n2 * cosT2);
      R = rs * rs;
    }
    const rayLen = Math.max(W, H) * 0.6;
    if (this.n1 > this.n2) {
      const critRad = Math.asin(this.n2 / this.n1);
      const critDx = Math.sin(critRad), critDy = Math.cos(critRad);
      ctx.strokeStyle = 'rgba(255,209,102,0.25)'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]);
      ctx.beginPath(); ctx.moveTo(hitX, hitY); ctx.lineTo(hitX - critDx * rayLen * 0.5, hitY - critDy * rayLen * 0.5); ctx.stroke();
      ctx.setLineDash([]);
      ctx.font = '10px Manrope, system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,209,102,0.5)';
      ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
      ctx.fillText('θc=' + (critRad * 180 / Math.PI).toFixed(1) + '°', hitX - critDx * rayLen * 0.35 + 6, hitY - critDy * rayLen * 0.35);
    }
    if (this.dispersion && !isTIR) this._drawDispersion(ctx, hitX, hitY, midY, theta1Rad, rayLen);
    else this._drawMainRays(ctx, hitX, hitY, midY, theta1Rad, sinTheta2, isTIR, R, rayLen);
    this._drawAngleArcs(ctx, hitX, hitY, theta1Rad, sinTheta2, isTIR);
    this._drawMediumLabels(ctx, W, H, midY);
    this._drawInfoBox(ctx, isTIR, R);
    const incDx = Math.sin(theta1Rad), incDy = Math.cos(theta1Rad);
    const handleX = hitX - incDx * rayLen * 0.55, handleY = hitY - incDy * rayLen * 0.55;
    const grad = ctx.createRadialGradient(handleX, handleY, 0, handleX, handleY, 10);
    grad.addColorStop(0, 'rgba(155,93,229,0.4)'); grad.addColorStop(1, 'rgba(155,93,229,0)');
    ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(handleX, handleY, 10, 0, Math.PI * 2); ctx.fill();

    // TIR one-shot sound
    if (window.LabFX) {
      if (isTIR && !this._wasTIR) {
        LabFX.sound.play('spark', { volume: 0.2 });
      }
      this._wasTIR = isTIR;

      // Brewster angle: R ≈ 0 (reflected intensity near zero for s-pol)
      const _isBrew = !isTIR && R < 0.005 && this.angle > 0;
      if (_isBrew && !this._wasBrewster) {
        LabFX.sound.play('chime', { pitch: 1.5, volume: 0.3 });
      }
      this._wasBrewster = _isBrew;
    }
    // OB_FX visual depth layer
    {
      const incDx2 = Math.sin(theta1Rad), incDy2 = Math.cos(theta1Rad);
      const incStartX = hitX - incDx2 * rayLen, incStartY = hitY - incDy2 * rayLen;
      // Normal at boundary points upward (angle = -PI/2 in canvas coords)
      const normalAngle = -Math.PI / 2;
      const kind = isTIR ? 'reflection' : 'refraction';
      const fxExtras = {
        srcX: incStartX, srcY: incStartY,
        rays: [
          { x1: incStartX, y1: incStartY, x2: hitX, y2: hitY, n: this.n1 },
        ],
        boundary: { bx: hitX, by: hitY, normalAngle, kind },
      };
      _drawOBFXLayer(ctx, 'refraction', fxExtras);
    }
    if (window.LabFX) {
      LabFX.particles.update(1 / 60);
      LabFX.particles.draw(ctx);
    }
  }

  _drawMainRays(ctx, hitX, hitY, midY, theta1Rad, sinTheta2, isTIR, R, rayLen) {
    // Use wavelength color if set, otherwise fall back to purple/red/cyan scheme
    const _wl = window._obWhiteLight;
    const _wlNm = window._obWavelength || 550;
    const incColor = _wl ? '#FFFFFF' : wavelengthToRGB(_wlNm);
    const incDx = Math.sin(theta1Rad), incDy = Math.cos(theta1Rad);
    const incStartX = hitX - incDx * rayLen, incStartY = hitY - incDy * rayLen;
    if (_wl) {
      // White-light: draw spectral fan using physical n(λ) model
      this._drawRay(ctx, incStartX, incStartY, hitX, hitY, '#FFFFFF', 2.5);
      this._drawArrowhead(ctx, hitX, hitY, Math.atan2(hitY - incStartY, hitX - incStartX), '#FFFFFF');
      for (const s of OB_SPECTRAL) {
        const n2w = _nAtWavelength(this.n2, s.nm);
        const sinT2w = (this.n1 / n2w) * Math.sin(theta1Rad);
        if (Math.abs(sinT2w) > 1) continue;
        const t2w = Math.asin(sinT2w);
        const col = wavelengthToRGB(s.nm);
        ctx.globalAlpha = 0.8;
        this._drawRay(ctx, hitX, hitY, hitX + Math.sin(t2w) * rayLen, hitY + Math.cos(t2w) * rayLen, col, 1.8);
        ctx.globalAlpha = 1;
      }
      // Reflected ray (partial, semi-transparent)
      ctx.globalAlpha = 0.3;
      this._drawRay(ctx, hitX, hitY, hitX + incDx * rayLen * 0.7, hitY - incDy * rayLen * 0.7, '#FFFFFF', 1.5);
      ctx.globalAlpha = 1;
      return;
    }
    this._drawRay(ctx, incStartX, incStartY, hitX, hitY, incColor, 2.5);
    this._drawArrowhead(ctx, hitX, hitY, Math.atan2(hitY - incStartY, hitX - incStartX), incColor);
    const refDx = incDx, refDy = -incDy;
    const refEndX = hitX + refDx * rayLen, refEndY = hitY + refDy * rayLen;
    const refAlpha = isTIR ? 1.0 : Math.max(0.3, Math.sqrt(R));
    ctx.globalAlpha = refAlpha;
    this._drawRay(ctx, hitX, hitY, refEndX, refEndY, '#EF476F', 2.5);
    this._drawArrowhead(ctx, refEndX, refEndY, Math.atan2(refEndY - hitY, refEndX - hitX), '#EF476F');
    ctx.globalAlpha = 1;
    if (!isTIR) {
      const theta2Rad = Math.asin(sinTheta2);
      const refracDx = Math.sin(theta2Rad), refracDy = Math.cos(theta2Rad);
      const refracEndX = hitX + refracDx * rayLen, refracEndY = hitY + refracDy * rayLen;
      const T = 1 - R;
      ctx.globalAlpha = Math.max(0.3, Math.sqrt(T));
      // Refracted ray: use wavelength color with slight shift for refracted
      this._drawRay(ctx, hitX, hitY, refracEndX, refracEndY, incColor, 2.5);
      this._drawArrowhead(ctx, refracEndX, refracEndY, Math.atan2(refracEndY - hitY, refracEndX - hitX), incColor);
      ctx.globalAlpha = 1;
    }
  }

  _drawDispersion(ctx, hitX, hitY, midY, theta1Rad, rayLen) {
    const spectral = [
      { color: '#FF0000', wave: 656 }, { color: '#FF7F00', wave: 589 }, { color: '#FFFF00', wave: 550 },
      { color: '#00FF00', wave: 510 }, { color: '#00FFFF', wave: 475 }, { color: '#0000FF', wave: 450 },
      { color: '#8B00FF', wave: 400 },
    ];
    const incDx = Math.sin(theta1Rad), incDy = Math.cos(theta1Rad);
    const incStartX = hitX - incDx * rayLen, incStartY = hitY - incDy * rayLen;
    this._drawRay(ctx, incStartX, incStartY, hitX, hitY, '#FFFFFF', 2.5);
    this._drawArrowhead(ctx, hitX, hitY, Math.atan2(hitY - incStartY, hitX - incStartX), '#FFFFFF');
    const A = this.n2 - 4500 / (550 * 550), B = 4500;
    for (const s of spectral) {
      const n2w = A + B / (s.wave * s.wave);
      const sinT2 = (this.n1 / n2w) * Math.sin(theta1Rad);
      if (Math.abs(sinT2) > 1) continue;
      const t2 = Math.asin(sinT2), dx = Math.sin(t2), dy = Math.cos(t2);
      ctx.globalAlpha = 0.85;
      this._drawRay(ctx, hitX, hitY, hitX + dx * rayLen, hitY + dy * rayLen, s.color, 1.5);
      ctx.globalAlpha = 1;
    }
    const refDx = incDx, refDy = -incDy;
    ctx.globalAlpha = 0.35;
    this._drawRay(ctx, hitX, hitY, hitX + refDx * rayLen * 0.7, hitY + refDy * rayLen * 0.7, '#FFFFFF', 1.5);
    ctx.globalAlpha = 1;
  }

  _drawRay(ctx, x1, y1, x2, y2, color, width) {
    const drawFn = () => {
      ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round';
      ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
      ctx.save(); ctx.shadowColor = color; ctx.shadowBlur = 8; ctx.globalAlpha = 0.3;
      ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
      ctx.restore();
    };
    if (window.LabFX) LabFX.glow.drawGlow(ctx, drawFn, { color, intensity: 8 });
    else drawFn();
  }

  _drawArrowhead(ctx, x, y, angle, color) {
    const aLen = 10;
    ctx.fillStyle = color;
    ctx.beginPath(); ctx.moveTo(x, y);
    ctx.lineTo(x - aLen * Math.cos(angle - 0.3), y - aLen * Math.sin(angle - 0.3));
    ctx.lineTo(x - aLen * Math.cos(angle + 0.3), y - aLen * Math.sin(angle + 0.3));
    ctx.closePath(); ctx.fill();
  }

  _drawAngleArcs(ctx, hitX, hitY, theta1Rad, sinTheta2, isTIR) {
    const arcR = 50, font = '12px Manrope, system-ui, sans-serif';
    if (this.angle > 1) {
      ctx.strokeStyle = 'rgba(155,93,229,0.6)'; ctx.lineWidth = 1.5;
      ctx.beginPath();
      const normAngle = -Math.PI / 2, incAngle = -Math.PI / 2 - theta1Rad;
      ctx.arc(hitX, hitY, arcR, Math.min(incAngle, normAngle), Math.max(incAngle, normAngle)); ctx.stroke();
      ctx.font = font; ctx.fillStyle = '#9B5DE5'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
      const midA = normAngle - theta1Rad / 2;
      ctx.fillText('θ₁=' + this.angle.toFixed(1) + '°', hitX + (arcR + 20) * Math.cos(midA), hitY + (arcR + 20) * Math.sin(midA));
    }
    if (!isTIR && Math.abs(sinTheta2) <= 1) {
      const theta2Rad = Math.asin(sinTheta2);
      if (theta2Rad > 0.02) {
        ctx.strokeStyle = 'rgba(6,214,224,0.6)'; ctx.lineWidth = 1.5;
        ctx.beginPath();
        const normDown = Math.PI / 2, refAngle = Math.PI / 2 + theta2Rad;
        ctx.arc(hitX, hitY, arcR * 0.8, Math.min(normDown, refAngle), Math.max(normDown, refAngle)); ctx.stroke();
        const angle2Deg = theta2Rad * 180 / Math.PI;
        ctx.font = font; ctx.fillStyle = '#06D6E0'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
        const midA2 = normDown + theta2Rad / 2;
        ctx.fillText('θ₂=' + angle2Deg.toFixed(1) + '°', hitX + (arcR * 0.8 + 20) * Math.cos(midA2), hitY + (arcR * 0.8 + 20) * Math.sin(midA2));
      }
    }
  }

  _drawMediumLabels(ctx, W, H, midY) {
    ctx.font = '13px Manrope, system-ui, sans-serif'; ctx.textBaseline = 'middle';
    ctx.fillStyle = 'rgba(155,93,229,0.6)'; ctx.textAlign = 'left';
    ctx.fillText('n₁ = ' + this.n1.toFixed(2), 16, midY - 30);
    ctx.fillStyle = 'rgba(6,214,224,0.6)';
    ctx.fillText('n₂ = ' + this.n2.toFixed(2), 16, midY + 30);
    const theta1Rad = this.angle * Math.PI / 180;
    const sinT2 = (this.n1 / this.n2) * Math.sin(theta1Rad);
    if (Math.abs(sinT2) > 1) {
      ctx.font = 'bold 14px Manrope, system-ui, sans-serif'; ctx.fillStyle = '#EF476F'; ctx.textAlign = 'center';
      ctx.fillText('Полное внутреннее отражение (ПВО)', W / 2, midY + 60);
    }
  }

  _drawInfoBox(ctx, isTIR, R) {
    const boxW = 220, boxH = 72, bx = this.W - boxW - 12, by = 12;
    ctx.fillStyle = 'rgba(22,22,38,0.85)'; ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill();
    ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
    ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.fillText('n₁·sin(θ₁) = n₂·sin(θ₂)', bx + 10, by + 10);
    const info = this.info();
    ctx.fillStyle = 'rgba(255,255,255,0.5)';
    ctx.fillText('θ₁ = ' + info.angle1 + '°   θ₂ = ' + (info.isTIR ? 'ПВО' : info.angle2 + '°'), bx + 10, by + 28);
    const rPct = (R * 100).toFixed(1), tPct = ((1 - R) * 100).toFixed(1);
    ctx.fillStyle = '#EF476F'; ctx.fillText('R = ' + rPct + '%', bx + 10, by + 46);
    ctx.fillStyle = '#06D6E0'; ctx.fillText('T = ' + (isTIR ? '0' : tPct) + '%', bx + 90, by + 46);
    if (info.criticalAngle !== null) { ctx.fillStyle = '#FFD166'; ctx.fillText('θc = ' + info.criticalAngle + '°', bx + 160, by + 46); }
  }

  _bindEvents() {
    const cv = this.canvas;
    const getPos = (e) => {
      const r = cv.getBoundingClientRect();
      const t = e.touches ? e.touches[0] : e;
      return { mx: (t.clientX - r.left) * (this.W / r.width), my: (t.clientY - r.top) * (this.H / r.height) };
    };
    const hitTest = (mx, my) => {
      const hitX = this.W / 2, hitY = this.H / 2;
      if (my >= hitY) return false;
      const dist = Math.hypot(mx - hitX, my - hitY);
      return dist > 20 && dist < Math.max(this.W, this.H) * 0.6;
    };
    const angleFromMouse = (mx, my) => {
      const hitX = this.W / 2, hitY = this.H / 2;
      const dx = mx - hitX, dy = hitY - my;
      return Math.max(0, Math.min(89, Math.atan2(Math.abs(dx), dy) * 180 / Math.PI));
    };
    const onDown = (e) => { const { mx, my } = getPos(e); if (hitTest(mx, my)) this._drag = true; };
    const onMove = (e) => {
      if (!this._drag) return;
      if (e.cancelable) e.preventDefault();
      const { mx, my } = getPos(e);
      this.angle = angleFromMouse(mx, my);
      this.draw(); this._emit();
    };
    const onUp = () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = false; };
    cv.addEventListener('mousedown', onDown);
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
    cv.addEventListener('touchstart', e => { if (e.touches.length === 1) onDown(e); }, { passive: true });
    cv.addEventListener('touchmove', e => onMove(e), { passive: false });
    cv.addEventListener('touchend', onUp);
    cv.addEventListener('mousemove', e => {
      if (this._drag) { cv.style.cursor = 'grabbing'; return; }
      const { mx, my } = getPos(e);
      cv.style.cursor = hitTest(mx, my) ? 'grab' : 'default';
    });
  }
}

/* ─────────────────────────────────────────────────────────────
   4.  FREE-BUILD MULTI-LENS SIM  (Agent OB-A3)
       Cascaded image formation — each lens treats the previous
       image as its object. Two-lens effective focal length is
       F = f1*f2 / (f1+f2-d).
───────────────────────────────────────────────────────────────*/
class FreeBuildSim {
  constructor(canvas) {
    this.canvas = canvas;
    this.ctx    = canvas.getContext('2d');
    this.W = 0; this.H = 0;
    /* elements: { x_frac, f, id } sorted left→right at draw time */
    this.elements = [
      { x_frac: 0.30, f: 120, id: 1 },
      { x_frac: 0.65, f: 90,  id: 2 },
    ];
    this.objFrac  = 0.10;
    this.objH     = 60;
    this._drag    = null;
    this.onUpdate = null;
    this._bindEvents();
    new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
  }

  fit() {
    const dpr = window.devicePixelRatio || 1;
    const w = this.canvas.offsetWidth  || 600;
    const h = this.canvas.offsetHeight || 400;
    this.canvas.width  = w * dpr;
    this.canvas.height = h * dpr;
    this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    this.W = w; this.H = h;
  }

  /* Returns { stages, totalM, sysFocal, elems } */
  _computeChain() {
    const W    = this.W;
    const elems = [...this.elements].sort((a, b) => a.x_frac - b.x_frac);
    let curX = this.objFrac * W;
    let curH = this.objH;
    const stages = [];
    let totalM = 1;
    for (const el of elems) {
      const lensX = el.x_frac * W;
      const dO    = lensX - curX;
      if (Math.abs(dO) < 1) continue;
      const f     = el.f;
      const denom = dO - f;
      let dI, imgH, M;
      if (Math.abs(denom) < 0.5) {
        dI = Infinity; imgH = Infinity; M = Infinity;
      } else {
        dI   = (f * dO) / denom;
        M    = -(dI / dO);
        imgH = M * curH;
      }
      const imgX  = lensX + dI;
      const isVirt = isFinite(dI) && dI < 0;
      stages.push({ objX: curX, objH: curH, lensX, f, imgX, imgH, dO, dI, M, isVirt, el });
      totalM *= (isFinite(M) ? M : 1);
      curX = isFinite(imgX) ? imgX : lensX + 1;
      curH = isFinite(imgH) ? imgH : curH;
    }
    let sysFocal = null;
    if (elems.length === 2) {
      const f1 = elems[0].f, f2 = elems[1].f;
      const d  = (elems[1].x_frac - elems[0].x_frac) * W;
      const dn = f1 + f2 - d;
      if (Math.abs(dn) > 0.5) sysFocal = (f1 * f2) / dn;
    }
    return { stages, totalM, sysFocal, elems };
  }

  draw() {
    const { ctx, W, H } = this;
    if (!W || !H) return;
    const ay = H / 2;
    ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H);
    // axis
    ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1; ctx.setLineDash([6, 4]);
    ctx.beginPath(); ctx.moveTo(0, ay); ctx.lineTo(W, ay); ctx.stroke();
    ctx.setLineDash([]);

    const { stages, totalM, sysFocal, elems } = this._computeChain();
    const objX = this.objFrac * W;
    // object arrow
    this._fbArrow(ctx, objX, ay, objX, ay - this.objH, '#9B5DE5', false);
    // lenses + focal markers
    elems.forEach(el => {
      const lx = el.x_frac * W;
      this._fbLens(ctx, lx, ay, el.f);
      const fp = lx + el.f;
      if (fp > 10 && fp < W - 10) {
        ctx.fillStyle = 'rgba(6,214,224,0.5)'; ctx.beginPath(); ctx.arc(fp, ay, 3.5, 0, Math.PI * 2); ctx.fill();
      }
    });
    // chain rays
    this._drawChainRays(ctx, ay, stages);
    // intermediate image arrows
    const imgPal = ['#EF476F', '#FFD166', '#7BF5A4', '#06D6E0'];
    stages.forEach((s, i) => {
      if (!isFinite(s.dI)) return;
      const col = imgPal[i % imgPal.length];
      this._fbArrow(ctx, s.imgX, ay, s.imgX, ay - s.imgH, col, s.isVirt);
      ctx.font = '10px Manrope, system-ui, sans-serif';
      ctx.fillStyle = col; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
      ctx.fillText('Изобр.' + (i + 1), s.imgX, ay - Math.abs(s.imgH) - 6);
    });
    this._drawInfoPanel(ctx, stages, totalM, sysFocal);
    // OB_FX visual depth layer
    {
      const objX = this.objFrac * this.W;
      _drawOBFXLayer(ctx, 'freebuild', { srcX: objX, srcY: ay - this.objH });
    }
  }

  /* Trace 3 characteristic rays through all lenses (thin-lens matrix method) */
  _drawChainRays(ctx, ay, stages) {
    if (!stages.length) return;
    const colors = ['#06D6E0', '#7BF5A4', '#FFD166'];
    const s0 = stages[0];
    const W  = this.W;
    const rays = [
      { curY: ay - s0.objH, slope: 0 },                                  // parallel to axis
      { curY: ay - s0.objH, slope: (ay - (ay - s0.objH)) / (s0.lensX - s0.objX) }, // through lens centre
      { curY: ay - s0.objH, slope: (ay - (ay - s0.objH)) / ((s0.lensX - s0.f) - s0.objX) }, // through F
    ];
    rays.forEach((ray, ri) => {
      const col = colors[ri];
      const doGlow = (fn) => {
        if (window.LabFX) LabFX.glow.drawGlow(ctx, fn, { color: col, intensity: 6 });
        else fn();
      };
      doGlow(() => {
        ctx.strokeStyle = col; ctx.lineWidth = 1.4; ctx.setLineDash([]);
        let curX = s0.objX;
        let curY = ray.curY;
        let slope = isFinite(ray.slope) ? ray.slope : 0;
        ctx.beginPath(); ctx.moveTo(curX, curY);
        stages.forEach(st => {
          const lx   = st.lensX;
          const hitY = curY + slope * (lx - curX);
          ctx.lineTo(lx, hitY);
          // thin-lens refraction: slope_out = slope_in - (hitY - ay) / f
          const hRel  = hitY - ay;
          slope = slope - hRel / st.f;
          curX  = lx;
          curY  = hitY;
        });
        const extX = Math.min(W + 10, curX + 400);
        ctx.lineTo(extX, curY + slope * (extX - curX));
        ctx.stroke();
      });
    });
  }

  _fbLens(ctx, lx, ay, f) {
    const lh   = Math.min(this.H * 0.36, 130);
    const conv = f > 0;
    ctx.strokeStyle = 'rgba(155,93,229,0.8)'; ctx.lineWidth = 2.5;
    if (conv) {
      const b = Math.min(16, Math.abs(f) * 0.1);
      ctx.beginPath(); ctx.moveTo(lx, ay - lh); ctx.quadraticCurveTo(lx + b, ay, lx, ay + lh); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(lx, ay - lh); ctx.quadraticCurveTo(lx - b, ay, lx, ay + lh); ctx.stroke();
    } else {
      const b = Math.min(12, Math.abs(f) * 0.08);
      ctx.beginPath(); ctx.moveTo(lx, ay - lh); ctx.quadraticCurveTo(lx - b, ay, lx, ay + lh); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(lx, ay - lh); ctx.quadraticCurveTo(lx + b, ay, lx, ay + lh); ctx.stroke();
    }
    ctx.strokeStyle = 'rgba(155,93,229,0.25)'; ctx.lineWidth = 1;
    ctx.beginPath(); ctx.moveTo(lx, ay - lh); ctx.lineTo(lx, ay + lh); ctx.stroke();
    // label f value
    ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.fillStyle = 'rgba(155,93,229,0.9)';
    ctx.textAlign = 'center'; ctx.textBaseline = 'top';
    ctx.fillText('f=' + f.toFixed(0), lx, ay + lh + 4);
  }

  _fbArrow(ctx, x1, y1, x2, y2, color, dashed) {
    ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 2.5;
    if (dashed) ctx.setLineDash([5, 4]);
    ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
    ctx.setLineDash([]);
    if (Math.abs(y2 - y1) < 2) return;
    const angle = Math.atan2(y2 - y1, x2 - x1), aLen = 9;
    ctx.beginPath();
    ctx.moveTo(x2, y2);
    ctx.lineTo(x2 - aLen * Math.cos(angle - 0.35), y2 - aLen * Math.sin(angle - 0.35));
    ctx.lineTo(x2 - aLen * Math.cos(angle + 0.35), y2 - aLen * Math.sin(angle + 0.35));
    ctx.closePath(); ctx.fill();
  }

  _drawInfoPanel(ctx, stages, totalM, sysFocal) {
    const bx = 8, by = 8, bw = 192, lh = 16;
    const rows = [{ text: 'Объект', col: '#9B5DE5' }];
    stages.forEach((s, i) => {
      rows.push({ text: 'Лин.' + (i + 1) + ' f=' + s.f.toFixed(0), col: 'rgba(155,93,229,0.85)' });
      const dpStr = isFinite(s.dI)  ? s.dI.toFixed(0)  : '∞';
      const mStr  = isFinite(s.M)   ? s.M.toFixed(2)   : '∞';
      rows.push({ text: "  Изобр." + (i + 1) + "  d'=" + dpStr + ' M=' + mStr, col: isFinite(s.M) ? (s.isVirt ? '#FFD166' : '#EF476F') : '#888' });
    });
    const bh = rows.length * lh + 46;
    ctx.save();
    ctx.fillStyle = 'rgba(22,22,38,0.88)';
    ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 8); ctx.fill();
    ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
    let cy = by + 8;
    rows.forEach(r => { ctx.fillStyle = r.col; ctx.fillText(r.text, bx + 8, cy); cy += lh; });
    cy += 4;
    ctx.fillStyle = 'rgba(255,255,255,0.75)';
    ctx.fillText('Г = ' + (isFinite(totalM) ? totalM.toFixed(3) : '∞'), bx + 8, cy); cy += lh;
    if (sysFocal !== null) {
      ctx.fillStyle = '#06D6E0';
      ctx.fillText('F сист. = ' + sysFocal.toFixed(0) + ' px', bx + 8, cy);
    }
    ctx.restore();
  }

  _bindEvents() {
    const cv = this.canvas;
    const getPos = (e) => {
      const r = cv.getBoundingClientRect();
      const t = e.touches ? e.touches[0] : e;
      return { mx: (t.clientX - r.left) * (this.W / r.width), my: (t.clientY - r.top) * (this.H / r.height) };
    };
    const hitTest = (mx, my) => {
      const ay = this.H / 2;
      for (let i = 0; i < this.elements.length; i++) {
        const lx = this.elements[i].x_frac * this.W;
        if (Math.abs(mx - lx) < 14 && Math.abs(my - ay) < 100) return { what: 'lens', idx: i };
      }
      const ox = this.objFrac * this.W;
      if (Math.hypot(mx - ox, my - (ay - this.objH)) < 18) return { what: 'object' };
      return null;
    };
    cv.addEventListener('mousedown', e => { const p = getPos(e); this._drag = hitTest(p.mx, p.my); });
    window.addEventListener('mousemove', e => {
      if (!this._drag) return;
      if (e.cancelable) e.preventDefault();
      const { mx } = getPos(e);
      const frac = Math.max(0.02, Math.min(0.98, mx / this.W));
      if (this._drag.what === 'object') { this.objFrac = frac; }
      else if (this._drag.what === 'lens') { this.elements[this._drag.idx].x_frac = frac; }
      this.draw();
      if (this.onUpdate) this.onUpdate(this._computeChain());
    });
    window.addEventListener('mouseup', () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = null; });
    cv.addEventListener('mousemove', e => {
      if (this._drag) { cv.style.cursor = 'grabbing'; return; }
      const { mx, my } = getPos(e);
      cv.style.cursor = hitTest(mx, my) ? 'grab' : 'default';
    });
    cv.addEventListener('touchstart', e => {
      if (e.touches.length === 1) { const p = getPos(e); this._drag = hitTest(p.mx, p.my); }
    }, { passive: true });
    cv.addEventListener('touchmove', e => {
      if (!this._drag) return;
      if (e.cancelable) e.preventDefault();
      const { mx } = getPos(e);
      const frac = Math.max(0.02, Math.min(0.98, mx / this.W));
      if (this._drag.what === 'object') this.objFrac = frac;
      else if (this._drag.what === 'lens') this.elements[this._drag.idx].x_frac = frac;
      this.draw();
    }, { passive: false });
    cv.addEventListener('touchend', () => { this._drag = null; });
  }

  addLens(f) {
    const lastFrac = this.elements.length ? Math.max(...this.elements.map(e => e.x_frac)) : 0.5;
    const newFrac  = Math.min(0.92, lastFrac + 0.18);
    const id       = (this.elements.reduce((m, e) => Math.max(m, e.id), 0)) + 1;
    this.elements.push({ x_frac: newFrac, f: +f || 100, id });
    this.draw();
  }
  removeLens() {
    if (this.elements.length > 1) this.elements.pop();
    this.draw();
  }
  setLensF(idx, f) {
    if (this.elements[idx]) { this.elements[idx].f = Math.max(-300, Math.min(300, +f || 100)); this.draw(); }
  }
}

/* ─────────────────────────────────────────────────────────────
   4b. PRISM ENGINE
───────────────────────────────────────────────────────────────*/
class PrismSim {
  constructor(canvas) {
    this.canvas = canvas;
    this.ctx    = canvas.getContext('2d');
    this.W = 0; this.H = 0;
    this.apexAngle = 60;
    this.n0        = 1.5;
    this.rotation  = 0;
    this.incAngle  = 30;
    this._drag    = null;
    this.onUpdate = null;
    this._bindEvents();
    new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
  }

  fit() {
    const dpr = window.devicePixelRatio || 1;
    const w = this.canvas.offsetWidth || 600;
    const h = this.canvas.offsetHeight || 400;
    this.canvas.width  = w * dpr;
    this.canvas.height = h * dpr;
    this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    this.W = w; this.H = h;
  }

  getParams() { return { apexAngle: this.apexAngle, n0: this.n0, rotation: this.rotation, incAngle: this.incAngle }; }
  setParams({ apexAngle, n0, rotation, incAngle } = {}) {
    if (apexAngle !== undefined) this.apexAngle = Math.max(20, Math.min(80, +apexAngle));
    if (n0        !== undefined) this.n0        = Math.max(1.3, Math.min(2.5, +n0));
    if (rotation  !== undefined) this.rotation  = +rotation % 360;
    if (incAngle  !== undefined) this.incAngle  = Math.max(0, Math.min(80, +incAngle));
    this.draw(); this._emit();
  }

  reset() { this.apexAngle = 60; this.n0 = 1.5; this.rotation = 0; this.incAngle = 30; this.draw(); this._emit(); }
  _emit() { if (this.onUpdate) this.onUpdate(this.getParams()); }

  draw() {
    const ctx = this.ctx, W = this.W, H = this.H;
    if (!W || !H) return;

    ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H);
    ctx.strokeStyle = 'rgba(255,255,255,0.1)'; ctx.lineWidth = 1; ctx.setLineDash([6,4]);
    ctx.beginPath(); ctx.moveTo(0, H/2); ctx.lineTo(W, H/2); ctx.stroke();
    ctx.setLineDash([]);

    const cx = W / 2, cy = H / 2;
    const size = Math.min(W, H) * 0.30;
    const rotRad = this.rotation * Math.PI / 180;
    const topA   = -Math.PI / 2 + rotRad;
    const v = [0, 1, 2].map(i => ({
      x: cx + size * Math.cos(topA + i * (2 * Math.PI / 3)),
      y: cy + size * Math.sin(topA + i * (2 * Math.PI / 3)),
    }));

    // Prism body
    const prismGrad = ctx.createLinearGradient(v[0].x, v[0].y, v[1].x, v[1].y);
    prismGrad.addColorStop(0, 'rgba(100,180,255,0.07)');
    prismGrad.addColorStop(0.5, 'rgba(100,180,255,0.16)');
    prismGrad.addColorStop(1, 'rgba(100,180,255,0.07)');
    ctx.beginPath(); ctx.moveTo(v[0].x, v[0].y); ctx.lineTo(v[1].x, v[1].y); ctx.lineTo(v[2].x, v[2].y); ctx.closePath();
    ctx.fillStyle = prismGrad; ctx.fill();
    ctx.strokeStyle = 'rgba(100,180,255,0.5)'; ctx.lineWidth = 2; ctx.stroke();
    ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.fillStyle = 'rgba(100,180,255,0.7)';
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.fillText('n = ' + this.n0.toFixed(2), cx, cy + 4);

    const isWhite  = window._obWhiteLight;
    const monoNm   = window._obWavelength || 550;
    const spectral = isWhite ? OB_SPECTRAL : [{ nm: monoNm }];

    // Entry face: v[0] → v[2]; inward normal toward center
    const efVec = { x: v[2].x - v[0].x, y: v[2].y - v[0].y };
    const efLen = Math.hypot(efVec.x, efVec.y);
    let efNorm  = { x: -efVec.y / efLen, y: efVec.x / efLen };
    if (efNorm.x * (cx - v[0].x) + efNorm.y * (cy - v[0].y) < 0) {
      efNorm = { x: -efNorm.x, y: -efNorm.y };
    }
    const eX = (v[0].x + v[2].x) / 2, eY = (v[0].y + v[2].y) / 2;
    /* tangDir = 90° CW rotation of efNorm so that positive incAngle
       tilts the ray toward the apex side (natural source-from-left setup) */
    const tangDir = { x: efNorm.y, y: -efNorm.x };
    const incRad  = this.incAngle * Math.PI / 180;
    /* incDir = propagation direction (INTO the prism) */
    const incDir  = {
      x: Math.cos(incRad) * efNorm.x + Math.sin(incRad) * tangDir.x,
      y: Math.cos(incRad) * efNorm.y + Math.sin(incRad) * tangDir.y,
    };
    const incDLen = Math.hypot(incDir.x, incDir.y);
    incDir.x /= incDLen; incDir.y /= incDLen;

    const rayLen = W * 0.45;
    /* draw incoming ray from outside (source side) to entry midpoint */
    this._drawRayLine(ctx, eX - incDir.x * rayLen, eY - incDir.y * rayLen, eX, eY,
      isWhite ? '#FFFFFF' : wavelengthToRGB(monoNm), 2.5);

    // Exit face: v[0] → v[1]; outward normal
    const exfVec = { x: v[1].x - v[0].x, y: v[1].y - v[0].y };
    const exfLen = Math.hypot(exfVec.x, exfVec.y);
    let exfNorm = { x: -exfVec.y / exfLen, y: exfVec.x / exfLen };
    if (exfNorm.x * (cx - v[0].x) + exfNorm.y * (cy - v[0].y) > 0) {
      exfNorm = { x: -exfNorm.x, y: -exfNorm.y };
    }

    const exitPts = [];
    for (const s of spectral) {
      const nP  = _nAtWavelength(this.n0, s.nm);
      const col = wavelengthToRGB(s.nm);
      /* Snell vector form: l = incDir (propagation), n = -efNorm (toward incident medium)
         cosθ_i = -n·l = efNorm·l;  r = μl + (μcosθ_i - cosθ_t)·n */
      const cosI = (incDir.x * efNorm.x + incDir.y * efNorm.y);
      const sinR = Math.sqrt(Math.max(0, 1 - cosI * cosI)) / nP;
      if (sinR > 1) continue;
      const cosR = Math.sqrt(1 - sinR * sinR);
      const rDir = {
        x: (1/nP) * incDir.x + (cosR - (1/nP) * cosI) * efNorm.x,
        y: (1/nP) * incDir.y + (cosR - (1/nP) * cosI) * efNorm.y,
      };
      const rDLen = Math.hypot(rDir.x, rDir.y);
      rDir.x /= rDLen; rDir.y /= rDLen;

      // Intersect with exit face segment v[0]→v[1]
      const det = rDir.x * exfVec.y - rDir.y * exfVec.x;
      if (Math.abs(det) < 0.001) continue;
      const d0x = v[0].x - eX, d0y = v[0].y - eY;
      const tRay  = (d0x * exfVec.y - d0y * exfVec.x) / det;
      const sFace = (d0x * rDir.y - d0y * rDir.x) / det;
      if (tRay < 0 || sFace < -0.01 || sFace > 1.01) continue;
      const exitX = eX + tRay * rDir.x, exitY = eY + tRay * rDir.y;

      ctx.globalAlpha = isWhite ? 0.65 : 1;
      this._drawRayLine(ctx, eX, eY, exitX, exitY, col, isWhite ? 1.5 : 2);
      ctx.globalAlpha = 1;

      // Snell at exit face: n1=nP, n2=1 (vector form)
      const inward = { x: -exfNorm.x, y: -exfNorm.y };
      const cosEx  = -(rDir.x * inward.x + rDir.y * inward.y);
      const sinEx2 = nP * Math.sqrt(Math.max(0, 1 - cosEx * cosEx));
      if (sinEx2 > 1) continue; // TIR
      const cosEx2 = Math.sqrt(1 - sinEx2 * sinEx2);
      const eDir = {
        x: nP * rDir.x + (nP * cosEx - cosEx2) * inward.x,
        y: nP * rDir.y + (nP * cosEx - cosEx2) * inward.y,
      };
      const eDLen = Math.hypot(eDir.x, eDir.y);
      eDir.x /= eDLen; eDir.y /= eDLen;

      ctx.globalAlpha = isWhite ? 0.85 : 1;
      this._drawRayLine(ctx, exitX, exitY, exitX + eDir.x * rayLen, exitY + eDir.y * rayLen, col, isWhite ? 2 : 2.5);
      ctx.globalAlpha = 1;
      exitPts.push({ nm: s.nm, col });
    }

    // Drag hint ring
    ctx.strokeStyle = 'rgba(100,180,255,0.12)'; ctx.lineWidth = 1; ctx.setLineDash([3,4]);
    ctx.beginPath(); ctx.arc(cx, cy, size * 1.18, 0, Math.PI * 2); ctx.stroke();
    ctx.setLineDash([]);

    // Info box
    ctx.fillStyle = 'rgba(22,22,38,0.88)'; ctx.beginPath(); ctx.roundRect(12, 12, 224, 52, 8); ctx.fill();
    ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
    ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.fillText('Призма: A = ' + this.apexAngle + '°', 22, 22);
    ctx.fillStyle = 'rgba(100,180,255,0.8)'; ctx.fillText('n(λ) = ' + this.n0.toFixed(2) + ' − 0.0002·(λ−550)', 22, 40);

    _obUpdateSpectrometer(exitPts);
    // OB_FX visual depth layer (prism: source flare + wavefronts)
    _drawOBFXLayer(ctx, 'prism', { srcX: this.W * 0.1, srcY: this.H / 2 });
    if (window.LabFX) { LabFX.particles.update(1/60); LabFX.particles.draw(ctx); }
  }

  _drawRayLine(ctx, x1, y1, x2, y2, color, width) {
    const fn = () => {
      ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round';
      ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
    };
    if (window.LabFX) LabFX.glow.drawGlow(ctx, fn, { color, intensity: 6 });
    else fn();
  }

  _bindEvents() {
    const cv = this.canvas;
    const getPos = (e) => {
      const r = cv.getBoundingClientRect();
      const t = e.touches ? e.touches[0] : e;
      return { mx: (t.clientX - r.left) * (this.W / r.width), my: (t.clientY - r.top) * (this.H / r.height) };
    };
    const hitTest = (mx, my) => Math.hypot(mx - this.W/2, my - this.H/2) < Math.min(this.W, this.H) * 0.35;
    const onDown = (e) => {
      const { mx, my } = getPos(e);
      if (hitTest(mx, my)) this._drag = { sx: mx, sy: my, sRot: this.rotation, sInc: this.incAngle };
    };
    const onMove = (e) => {
      if (!this._drag) return;
      if (e.cancelable) e.preventDefault();
      const { mx, my } = getPos(e);
      this.rotation = this._drag.sRot + (mx - this._drag.sx) * 0.4;
      this.incAngle = Math.max(0, Math.min(80, this._drag.sInc + (my - this._drag.sy) * 0.3));
      this.draw(); this._emit();
    };
    const onUp = () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = null; };
    cv.addEventListener('mousedown', onDown);
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
    cv.addEventListener('touchstart', e => { if (e.touches.length === 1) onDown(e); }, { passive: true });
    cv.addEventListener('touchmove', e => onMove(e), { passive: false });
    cv.addEventListener('touchend', onUp);
    cv.addEventListener('mousemove', e => {
      if (this._drag) { cv.style.cursor = 'grabbing'; return; }
      const { mx, my } = getPos(e);
      cv.style.cursor = hitTest(mx, my) ? 'grab' : 'default';
    });
  }
}

/* ─────────────────────────────────────────────────────────────
   4d. INTERFERENCE SIM — Newton's rings / Thin film / Polarization
   Agent C — additive only, class InterferenceSim
─────────────────────────────────────────────────────────────*/
class InterferenceSim {
  constructor(canvas) {
    this.canvas  = canvas;
    this.ctx     = canvas.getContext('2d');
    this.W = 0; this.H = 0;
    this.subMode = 'newton';
    // Newton rings
    this.nR    = 200;
    this.nNmax = 12;
    // Thin film
    this.tfT      = 400;
    this.tfN      = 1.33;
    this.tfTheta  = 0;
    this.tfPreset = 'soap';
    // Polarization
    this.polTheta = 45;
    this.polSrc   = 'unpolarized';
    this._polTick = 0;
    this._polRaf  = null;
    this.onUpdate = null;
    new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement || canvas);
  }

  fit() {
    const p = this.canvas.parentElement;
    if (!p) return;
    const r = p.getBoundingClientRect();
    this.W = this.canvas.width  = r.width  || p.offsetWidth  || 600;
    this.H = this.canvas.height = r.height || p.offsetHeight || 400;
  }

  setSubMode(sm) {
    this.subMode = sm;
    if (sm === 'polarization') {
      this._polStart();
    } else {
      this._polStop();
    }
    this.draw();
    if (this.onUpdate) this.onUpdate();
  }

  /* ── Newton Rings ──────────────────────────────────────── */
  _drawNewton() {
    const { ctx, W, H } = this;
    const nm    = window._obWavelength || 550;
    const R     = this.nR;
    const nMax  = this.nNmax;
    const white = window._obWhiteLight;

    ctx.clearRect(0, 0, W, H);
    ctx.fillStyle = '#08081a';
    ctx.fillRect(0, 0, W, H);

    const topH = Math.floor(H * 0.60);
    const cx = W / 2, cy = topH / 2;
    const maxR_mm = Math.sqrt(nMax * nm * 1e-6 * R);
    const scale   = Math.min(cx * 0.85, cy * 0.85) / (maxR_mm || 1);

    for (let n = nMax; n >= 0; n--) {
      const lambdas = white ? [420, 470, 510, 550, 590, 620, 680] : [nm];
      for (const lam of lambdas) {
        const rDark   = Math.sqrt(n * lam * 1e-6 * R) * scale;
        const rBright = Math.sqrt((n + 0.5) * lam * 1e-6 * R) * scale;
        if (rDark > 0.5) {
          ctx.beginPath();
          ctx.arc(cx, cy, rDark, 0, Math.PI * 2);
          ctx.strokeStyle = white
            ? wavelengthToRGB(lam).replace(')', ',0.5)').replace('rgb', 'rgba')
            : '#000000';
          ctx.lineWidth = white ? 1.2 : 1.5;
          ctx.stroke();
        }
        if (rBright > 0.5) {
          const al = white ? 0.22 : 0.55;
          ctx.beginPath();
          ctx.arc(cx, cy, rBright, 0, Math.PI * 2);
          ctx.strokeStyle = wavelengthToRGB(lam).replace(')', ',' + al + ')').replace('rgb', 'rgba');
          ctx.lineWidth = 2.5;
          ctx.stroke();
        }
      }
    }

    ctx.beginPath(); ctx.arc(cx, cy, 4, 0, Math.PI * 2);
    ctx.fillStyle = '#000000'; ctx.fill();

    if (window.LabFX && LabFX.glow && !white) {
      const r1b = Math.sqrt(0.5 * nm * 1e-6 * R) * scale;
      const ringColor = wavelengthToRGB(nm);
      LabFX.glow.drawGlow(ctx, function() {
        ctx.beginPath();
        ctx.arc(cx, cy, r1b, 0, Math.PI * 2);
        ctx.strokeStyle = ringColor;
        ctx.lineWidth = 2;
        ctx.stroke();
      }, { color: ringColor, intensity: 18 });
    }

    ctx.beginPath(); ctx.arc(cx, cy, maxR_mm * scale * 1.05, 0, Math.PI * 2);
    ctx.strokeStyle = '#334455'; ctx.lineWidth = 1; ctx.stroke();

    const crossY0 = topH + 8;
    const crossH  = H - crossY0 - 40;
    if (crossH < 30) return;

    ctx.fillStyle = '#0d0d20';
    ctx.fillRect(0, crossY0, W, crossH + 36);

    const glassY  = crossY0 + crossH - 10;
    ctx.fillStyle = '#1a3a5c';
    ctx.fillRect(cx - maxR_mm * scale * 1.1, glassY, maxR_mm * scale * 2.2, 10);

    const sagitta = (maxR_mm * maxR_mm) / (2 * R);
    const sagPx   = sagitta * scale;
    ctx.beginPath();
    ctx.ellipse(cx, glassY - 1 - sagPx, maxR_mm * scale * 1.1, sagPx + 6, 0, 0, Math.PI);
    ctx.fillStyle = 'rgba(100,180,255,0.15)'; ctx.fill();
    ctx.strokeStyle = '#4499cc'; ctx.lineWidth = 1.5; ctx.stroke();

    for (let n = 0; n <= nMax; n++) {
      const rD = Math.sqrt(n * nm * 1e-6 * R) * scale;
      if (rD < 1) continue;
      ctx.beginPath();
      ctx.moveTo(cx + rD, glassY); ctx.lineTo(cx + rD, glassY + 8);
      ctx.moveTo(cx - rD, glassY); ctx.lineTo(cx - rD, glassY + 8);
      ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; ctx.stroke();
    }

    ctx.font = '600 11px monospace'; ctx.fillStyle = '#667788'; ctx.textAlign = 'center';
    ctx.fillText('Cross-section', cx, crossY0 + 14);

    const r1d = Math.sqrt(nm * 1e-6 * R).toFixed(3);
    this._drawHUD(ctx, W, H,
      'r1 = sqrt(lam*R) = ' + r1d + ' mm  |  R=' + R + 'mm  |  lam=' + nm + 'nm');
  }

  /* ── Thin Film ─────────────────────────────────────────── */
  _thinFilmColor(t_nm, n_film, theta_deg) {
    const sinR = Math.sin(theta_deg * Math.PI / 180) / n_film;
    const cosR = Math.sqrt(Math.max(0, 1 - sinR * sinR));
    const opd  = 2 * n_film * t_nm * cosR;
    let rS = 0, gS = 0, bS = 0;
    for (let lam = 380; lam <= 780; lam += 5) {
      const phase = Math.PI * opd / lam;
      const I = Math.cos(phase) * Math.cos(phase);
      const rgb = wavelengthToRGB(lam);
      const m = rgb.match(/d+/g);
      if (!m) continue;
      rS += I * +m[0]; gS += I * +m[1]; bS += I * +m[2];
    }
    const sc = 255 / Math.max(rS, gS, bS, 1);
    return 'rgb(' + Math.round(rS * sc) + ',' + Math.round(gS * sc) + ',' + Math.round(bS * sc) + ')';
  }

  _drawThinFilm() {
    const { ctx, W, H } = this;
    const t     = this.tfT;
    const nf    = this.tfN;
    const theta = this.tfTheta;

    ctx.clearRect(0, 0, W, H);
    ctx.fillStyle = '#08081a';
    ctx.fillRect(0, 0, W, H);

    const midY   = H * 0.40;
    const filmH  = Math.max(28, H * 0.12);
    const margin = W * 0.10;
    const ang    = theta * Math.PI / 180;
    const skew   = Math.tan(ang) * filmH * 0.5;

    const grad = ctx.createLinearGradient(margin, 0, W - margin, 0);
    for (let i = 0; i <= 20; i++) {
      const frac = i / 20;
      grad.addColorStop(frac, this._thinFilmColor(t * (0.3 + 0.7 * frac), nf, theta));
    }

    ctx.save();
    ctx.beginPath();
    ctx.moveTo(margin - skew,     midY - filmH / 2);
    ctx.lineTo(W - margin - skew, midY - filmH / 2);
    ctx.lineTo(W - margin + skew, midY + filmH / 2);
    ctx.lineTo(margin + skew,     midY + filmH / 2);
    ctx.closePath();
    ctx.fillStyle = grad; ctx.fill();
    ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 1; ctx.stroke();
    ctx.restore();

    ctx.font = '700 11px sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.7)';
    ctx.textAlign = 'center';
    ctx.fillText('t=' + t + 'nm  n=' + nf.toFixed(2), W / 2, midY);

    const ax2 = W * 0.25, ay2 = midY - filmH / 2;
    const ax1 = ax2 - Math.cos(ang) * 40, ay1 = ay2 - Math.sin(ang) * 40 - 20;
    ctx.beginPath(); ctx.moveTo(ax1, ay1); ctx.lineTo(ax2, ay2);
    ctx.strokeStyle = '#8ab4e8'; ctx.lineWidth = 1.5; ctx.stroke();

    const col = this._thinFilmColor(t, nf, theta);
    ctx.beginPath(); ctx.moveTo(ax2, ay2); ctx.lineTo(ax2 - Math.cos(ang) * 40, ay1);
    ctx.strokeStyle = col; ctx.lineWidth = 2; ctx.stroke();

    const dx2 = Math.sin(ang) * filmH / nf;
    ctx.beginPath();
    ctx.moveTo(ax2 + dx2, ay2 + filmH);
    ctx.lineTo(ax2 + dx2 - Math.cos(ang) * 40, ay1 + filmH - 20);
    ctx.strokeStyle = col; ctx.lineWidth = 2;
    ctx.setLineDash([4, 3]); ctx.stroke(); ctx.setLineDash([]);

    const tvX0 = W * 0.55, tvW2 = W * 0.38;
    const tvY0 = H * 0.05, tvH2 = H * 0.60;
    ctx.fillStyle = '#0d0d22'; ctx.strokeStyle = '#2a2a4a'; ctx.lineWidth = 1;
    ctx.beginPath();
    if (ctx.roundRect) ctx.roundRect(tvX0, tvY0, tvW2, tvH2, 8);
    else ctx.rect(tvX0, tvY0, tvW2, tvH2);
    ctx.fill(); ctx.stroke();
    ctx.font = '600 10px sans-serif'; ctx.fillStyle = '#555'; ctx.textAlign = 'center';
    ctx.fillText('Top view', tvX0 + tvW2 / 2, tvY0 + 14);

    const tvRows = 28, tvCols = 36;
    const cW = tvW2 / tvCols, cH = (tvH2 - 20) / tvRows;
    for (let r = 0; r < tvRows; r++) {
      for (let c = 0; c < tvCols; c++) {
        ctx.fillStyle = this._thinFilmColor(t * (0.5 + c / tvCols), nf, theta * (r / tvRows));
        ctx.fillRect(tvX0 + c * cW, tvY0 + 20 + r * cH, cW + 0.5, cH + 0.5);
      }
    }

    const sinR2 = Math.sin(ang) / nf;
    const cosR2 = Math.sqrt(Math.max(0, 1 - sinR2 * sinR2));
    const opd2  = (2 * nf * t * cosR2).toFixed(0);
    this._drawHUD(ctx, W, H,
      '2nt*cos(th_r)=' + opd2 + 'nm  |  t=' + t + 'nm  n=' + nf.toFixed(2) + '  th=' + theta + 'deg');
  }

  /* ── Polarization ──────────────────────────────────────── */
  _polStart() {
    if (this._polRaf) return;
    const loop = () => { this._polTick++; this.draw(); this._polRaf = requestAnimationFrame(loop); };
    this._polRaf = requestAnimationFrame(loop);
  }

  _polStop() {
    if (this._polRaf) { cancelAnimationFrame(this._polRaf); this._polRaf = null; }
  }

  _drawPolarization() {
    const { ctx, W, H } = this;
    const theta   = this.polTheta * Math.PI / 180;
    const I_rel   = Math.cos(theta) * Math.cos(theta);
    const tick    = this._polTick;
    const white   = window._obWhiteLight;
    const nm      = window._obWavelength || 550;
    const beamCol = white ? '#ffffff' : wavelengthToRGB(nm);

    ctx.clearRect(0, 0, W, H);
    ctx.fillStyle = '#08081a';
    ctx.fillRect(0, 0, W, H);

    const axisY = H * 0.45;
    const stH   = H * 0.38;
    const st    = [
      { x: W * 0.12, label: 'Источник',       isFilter: false                    },
      { x: W * 0.38, label: 'Поляризатор P1',  isFilter: true,  angle: 0          },
      { x: W * 0.64, label: 'Анализатор P2',   isFilter: true,  angle: this.polTheta },
      { x: W * 0.88, label: 'Детектор',        isFilter: false                    },
    ];

    ctx.beginPath();
    ctx.moveTo(st[0].x - 20, axisY); ctx.lineTo(st[3].x + 20, axisY);
    ctx.strokeStyle = '#1a1a35'; ctx.lineWidth = 1; ctx.stroke();

    const segs = [
      { x0: st[0].x, x1: st[1].x, amp: 1,     unpol: this.polSrc === 'unpolarized', ang: 0           },
      { x0: st[1].x, x1: st[2].x, amp: 1,     unpol: false,                          ang: 0           },
      { x0: st[2].x, x1: st[3].x, amp: I_rel, unpol: false,                          ang: this.polTheta },
    ];

    for (const seg of segs) {
      const nA = 20;
      const sdx = (seg.x1 - seg.x0) / nA;
      for (let i = 0; i <= nA; i++) {
        const bx    = seg.x0 + i * sdx;
        const phase = (bx * 0.08 - tick * 0.04) % (Math.PI * 2);
        const bAmp  = stH * 0.28 * seg.amp;
        if (seg.unpol) {
          for (let d = 0; d < 4; d++) {
            const a  = d * Math.PI / 4;
            const oy = Math.sin(phase + d * 0.7) * bAmp;
            ctx.beginPath(); ctx.moveTo(bx, axisY);
            ctx.lineTo(bx + oy * Math.sin(a) * 0.25, axisY + oy * Math.cos(a));
            ctx.strokeStyle = 'rgba(200,200,255,0.22)'; ctx.lineWidth = 1; ctx.stroke();
          }
        } else {
          const oy = Math.sin(phase) * bAmp;
          const a  = seg.ang * Math.PI / 180;
          const py = oy * Math.cos(a), px = oy * Math.sin(a) * 0.35;
          ctx.beginPath();
          ctx.moveTo(bx - px, axisY - py); ctx.lineTo(bx + px, axisY + py);
          ctx.strokeStyle = (I_rel < 0.01 && seg.amp < 0.5)
            ? 'rgba(80,80,120,0.5)'
            : beamCol.replace(')', ',0.75)').replace('rgb', 'rgba');
          ctx.lineWidth = 1.5; ctx.stroke();
          if (i % 3 === 0 && bAmp > 2) {
            ctx.beginPath(); ctx.arc(bx + px, axisY + py, 2, 0, Math.PI * 2);
            ctx.fillStyle = beamCol; ctx.fill();
          }
        }
      }
    }

    for (const s of st) {
      if (!s.isFilter) continue;
      const a = s.angle * Math.PI / 180;
      ctx.save(); ctx.translate(s.x, axisY);
      ctx.fillStyle = 'rgba(80,120,200,0.18)';
      ctx.fillRect(-4, -stH / 2, 8, stH);
      ctx.strokeStyle = '#4466aa'; ctx.lineWidth = 1.5; ctx.strokeRect(-4, -stH / 2, 8, stH);
      const axLen = stH * 0.45;
      ctx.beginPath();
      ctx.moveTo(-Math.sin(a) * axLen, -Math.cos(a) * axLen);
      ctx.lineTo( Math.sin(a) * axLen,  Math.cos(a) * axLen);
      ctx.strokeStyle = '#7aaeff'; ctx.lineWidth = 2; ctx.stroke();
      ctx.restore();
      ctx.font = '700 10px monospace'; ctx.fillStyle = '#7aaeff'; ctx.textAlign = 'center';
      ctx.fillText(s.angle + 'deg', s.x, axisY + stH / 2 + 14);
    }

    for (const s of st) {
      ctx.font = '600 10px sans-serif'; ctx.fillStyle = '#667788'; ctx.textAlign = 'center';
      ctx.fillText(s.label, s.x, axisY - stH / 2 - 8);
    }

    const barX = W * 0.91, barW = 16;
    const barY0 = axisY - stH / 2;
    ctx.fillStyle = '#111122'; ctx.fillRect(barX, barY0, barW, stH);
    const fillH2 = stH * I_rel;
    if (fillH2 > 0) {
      const bg = ctx.createLinearGradient(barX, barY0 + stH - fillH2, barX, barY0 + stH);
      bg.addColorStop(0, beamCol); bg.addColorStop(1, 'rgba(0,0,0,0.2)');
      ctx.fillStyle = bg; ctx.fillRect(barX, barY0 + stH - fillH2, barW, fillH2);
    }
    ctx.strokeStyle = '#334455'; ctx.lineWidth = 1; ctx.strokeRect(barX, barY0, barW, stH);
    ctx.font = '600 9px monospace'; ctx.fillStyle = '#aaaaaa'; ctx.textAlign = 'center';
    ctx.fillText('I', barX + barW / 2, barY0 - 5);

    if (this.polTheta >= 88) {
      ctx.font = '700 13px sans-serif'; ctx.fillStyle = '#EF476F'; ctx.textAlign = 'center';
      ctx.fillText('Полное гашение', W / 2, H * 0.85);
    }

    ctx.font = '10px sans-serif'; ctx.fillStyle = '#444466'; ctx.textAlign = 'right';
    ctx.fillText('Угол Брюстера: отражённый свет поляризован (см. Преломление)', W - 10, H - 10);

    const pct = (I_rel * 100).toFixed(1);
    this._drawHUD(ctx, W, H,
      'I/I0=cos2(th)=cos2(' + this.polTheta + 'deg)=' + I_rel.toFixed(3) + '  (' + pct + '%)');
  }

  _drawHUD(ctx, W, H, text) {
    const pad = 8, fs = 11;
    ctx.font = '600 ' + fs + 'px monospace';
    const tw = ctx.measureText(text).width;
    const bx = (W - tw) / 2 - pad, by = H - 32;
    const bw = tw + pad * 2, bh = fs + pad * 2;
    ctx.fillStyle = 'rgba(10,10,30,0.82)';
    ctx.beginPath();
    if (ctx.roundRect) ctx.roundRect(bx, by, bw, bh, 5);
    else ctx.rect(bx, by, bw, bh);
    ctx.fill();
    ctx.fillStyle = '#c8d8ff';
    ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
    ctx.fillText(text, bx + pad, by + bh / 2);
    ctx.textBaseline = 'alphabetic';
  }

  draw() {
    if (this.subMode === 'newton')            this._drawNewton();
    else if (this.subMode === 'thinfilm')     this._drawThinFilm();
    else if (this.subMode === 'polarization') this._drawPolarization();
  }
}

/* ─────────────────────────────────────────────────────────────
   4c. SPECTROMETER PANEL
───────────────────────────────────────────────────────────────*/
function _obDrawSpectrometer() {
  const canvas = document.getElementById('ob-spectrometer-canvas');
  if (!canvas) return;
  const ctx = canvas.getContext('2d');
  const W = canvas.offsetWidth || 360, H = canvas.offsetHeight || 72;
  const dpr = window.devicePixelRatio || 1;
  canvas.width = W * dpr; canvas.height = H * dpr;
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  ctx.fillStyle = '#0a0a18'; ctx.fillRect(0, 0, W, H);
  const barX1 = 24, barX2 = W - 24, barY = 22, barH = 20;
  const grad = ctx.createLinearGradient(barX1, 0, barX2, 0);
  for (let nm = 380; nm <= 780; nm += 10) {
    grad.addColorStop((nm - 380) / 400, wavelengthToRGB(nm));
  }
  ctx.fillStyle = grad; ctx.fillRect(barX1, barY, barX2 - barX1, barH);
  ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 1;
  ctx.strokeRect(barX1, barY, barX2 - barX1, barH);
  ctx.font = '8px Manrope, system-ui, sans-serif'; ctx.textBaseline = 'top'; ctx.textAlign = 'center';
  [400, 450, 500, 550, 600, 650, 700, 750].forEach(nm => {
    const x = barX1 + (nm - 380) / 400 * (barX2 - barX1);
    ctx.fillStyle = 'rgba(255,255,255,0.45)'; ctx.fillText(nm, x, barY + barH + 3);
    ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 0.5;
    ctx.beginPath(); ctx.moveTo(x, barY + barH); ctx.lineTo(x, barY + barH + 2.5); ctx.stroke();
  });
  const wlNm = window._obWavelength || 550;
  const wlX  = barX1 + (wlNm - 380) / 400 * (barX2 - barX1);
  ctx.strokeStyle = '#FFFFFF'; ctx.lineWidth = 1.5;
  ctx.beginPath(); ctx.moveTo(wlX, barY - 5); ctx.lineTo(wlX, barY + barH + 5); ctx.stroke();
  ctx.font = 'bold 9px Manrope, system-ui, sans-serif'; ctx.textBaseline = 'bottom'; ctx.textAlign = 'center';
  ctx.fillStyle = '#FFFFFF'; ctx.fillText(wlNm + ' нм', wlX, barY - 6);
  if (window._obSpectrometerDots) {
    for (const dot of window._obSpectrometerDots) {
      const x = barX1 + (dot.nm - 380) / 400 * (barX2 - barX1);
      ctx.fillStyle = dot.col;
      ctx.beginPath(); ctx.arc(x, barY - 9, 4, 0, Math.PI * 2); ctx.fill();
      ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 0.8;
      ctx.beginPath(); ctx.arc(x, barY - 9, 4, 0, Math.PI * 2); ctx.stroke();
    }
  }
}

function _obUpdateSpectrometer(exitPts) {
  window._obSpectrometerDots = exitPts || [];
  _obDrawSpectrometer();
}

/* ─────────────────────────────────────────────────────────────
   5.  LAB UI INIT — Оптическая скамья
───────────────────────────────────────────────────────────────*/

var lensSim    = null;
var mirrorSim  = null;
var refrSim    = null;
var prismSim   = null;
var freeSim    = null; /* multi-lens free-build (Agent OB-A3) */
var ifSim      = null; /* interference/polarization (Agent C) */
var _obMode    = 'lens'; // current active mode within opticsbench

/* Wavelength state — shared across all modes */
window._obWavelength  = 550;  // default 550 nm (green/yellow)
window._obWhiteLight  = false; // monochromatic by default

/* Open opticsbench, optionally setting a mode ('lens'|'mirror'|'refraction') */
function _openOpticsBench(mode) {
  mode = mode || 'lens';
  _obMode = mode;
  document.getElementById('sim-topbar-title').textContent = 'Оптическая скамья';
  _simShow('sim-opticsbench');
  _registerSimState('opticsbench', () => _obGetState(), st => _obApplyState(st));
  if (_embedMode) _startStateEmit('opticsbench');
  // Sync OB_FX checkboxes with persisted state
  ['wavefronts', 'mist', 'flare', 'huygens', 'caustics'].forEach(function(k) {
    const el = document.getElementById('obfx-' + k);
    if (el) el.checked = !!window.OB_FX[k];
  });
  requestAnimationFrame(() => requestAnimationFrame(() => {
    obSwitchMode(mode, true);
  }));
}

function _obGetState() {
  if (_obMode === 'lens')       return { mode: 'lens',       ...(lensSim   ? lensSim.getParams()   : {}) };
  if (_obMode === 'mirror')     return { mode: 'mirror',     ...(mirrorSim ? mirrorSim.getParams()  : {}) };
  if (_obMode === 'refraction') return { mode: 'refraction', ...(refrSim   ? refrSim.getParams()    : {}) };
  if (_obMode === 'waves')      return { mode: 'waves' };
  return { mode: _obMode };
}

function _obApplyState(st) {
  if (!st) return;
  const m = st.mode || _obMode;
  obSwitchMode(m, true);
  const { mode: _m, ...params } = st;
  if (m === 'lens'       && lensSim)   lensSim.setParams(params);
  if (m === 'mirror'     && mirrorSim) mirrorSim.setParams(params);
  if (m === 'refraction' && refrSim)   refrSim.setParams(params);
}

/* Switch between modes — mirrors emSwitchMode pattern */
function obSwitchMode(mode, silent) {
  if (!silent && window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.3, volume: 0.3 });
  _obMode = mode;

  /* tab button styling */
  ['lens', 'mirror', 'refraction', 'prism', 'freebuild', 'waves', 'interf'].forEach(m => {
    const btn = document.getElementById('ob-tab-' + m);
    if (btn) btn.classList.toggle('active', m === mode);
  });

  /* show/hide per-mode control panels */
  ['ob-ctrl-lens', 'ob-ctrl-mirror', 'ob-ctrl-refraction', 'ob-ctrl-prism', 'ob-ctrl-freebuild', 'ob-ctrl-waves', 'ob-ctrl-interf'].forEach(id => {
    const el = document.getElementById(id);
    if (el) el.style.display = 'none';
  });
  const activeCtrl = document.getElementById('ob-ctrl-' + mode);
  if (activeCtrl) activeCtrl.style.display = '';

  /* show/hide stats bar sections */
  ['ob-stats-lens', 'ob-stats-mirror', 'ob-stats-refr', 'ob-stats-prism', 'ob-stats-freebuild', 'ob-stats-waves', 'ob-stats-interf'].forEach(id => {
    const el = document.getElementById(id);
    if (el) el.style.display = 'none';
  });
  const statsSuffix = mode === 'refraction' ? 'refr' : mode;
  const activeStats = document.getElementById('ob-stats-' + statsSuffix);
  if (activeStats) activeStats.style.display = 'flex';

  /* canvas visibility */
  const canvasIds = ['ob-lens-canvas', 'ob-mirror-canvas', 'ob-refr-canvas', 'ob-prism-canvas', 'ob-free-canvas', 'ob-waves-canvas', 'ob-interf-canvas'];
  const modeCanvas = { lens: 'ob-lens-canvas', mirror: 'ob-mirror-canvas', refraction: 'ob-refr-canvas', prism: 'ob-prism-canvas', freebuild: 'ob-free-canvas', waves: 'ob-waves-canvas', interf: 'ob-interf-canvas' };
  canvasIds.forEach(id => { const el = document.getElementById(id); if (el) el.style.display = 'none'; });
  const activeCanvas = document.getElementById(modeCanvas[mode] || '');
  if (activeCanvas) activeCanvas.style.display = '';

  /* spectrometer panel: show for prism mode */
  const specPanel = document.getElementById('ob-spectrometer-panel');
  if (specPanel) specPanel.style.display = (mode === 'prism') ? '' : 'none';

  /* init engine if not yet done, then (re-)draw */
  if (mode === 'lens') {
    if (!lensSim) {
      const cv = document.getElementById('ob-lens-canvas');
      lensSim = new ThinLensSim(cv);
      lensSim.onUpdate = _lensUpdateUI;
    }
    lensSim.fit(); lensSim.draw(); lensSim._emit();
  } else if (mode === 'mirror') {
    if (!mirrorSim) {
      const cv = document.getElementById('ob-mirror-canvas');
      mirrorSim = new MirrorSim(cv);
      mirrorSim.onUpdate = _mirrorUpdateUI;
      mirrorSim.onAnimate = (d) => {
        const sl = document.getElementById('sl-mirror-d');
        const lbl = document.getElementById('mirror-d-val');
        if (sl) sl.value = Math.round(d);
        if (lbl) lbl.textContent = Math.round(d);
      };
    }
    mirrorSim.fit(); mirrorSim.draw(); mirrorSim._emit();
    if (mirrorSim._showPhotons && !mirrorSim._photonRaf) mirrorSim._startPhotons();
  } else if (mode === 'refraction') {
    if (!refrSim) {
      const cv = document.getElementById('ob-refr-canvas');
      refrSim = new RefractionSim(cv);
      refrSim.onUpdate = _refrUpdateUI;
    }
    refrSim.fit(); refrSim.draw(); refrSim._emit();
  } else if (mode === 'prism') {
    if (!prismSim) {
      const cv = document.getElementById('ob-prism-canvas');
      if (cv) prismSim = new PrismSim(cv);
      /* enable white-light dispersion by default on first prism entry */
      if (window._obWhiteLight === false) {
        window._obWhiteLight = true;
        const wlBtn = document.getElementById('ob-prism-white-btn');
        if (wlBtn) wlBtn.classList.add('active');
      }
    }
    if (prismSim) { prismSim.fit(); prismSim.draw(); }
    _obDrawSpectrometer();
  } else if (mode === 'freebuild') {  /* Agent OB-A3 — multi-lens free build */
    if (!freeSim) {
      const cv = document.getElementById('ob-free-canvas');
      if (cv) {
        freeSim = new FreeBuildSim(cv);
        freeSim.onUpdate = _freeUpdateUI;
      }
    }
    if (freeSim) { freeSim.fit(); freeSim.draw(); _freeUpdateUI(freeSim._computeChain()); }
  } else if (mode === 'waves') {  /* Agent B1 — diffraction & interference */
    if (!diffrSim) {
      const cv = document.getElementById('ob-waves-canvas');
      if (cv) diffrSim = new DiffractionSim(cv);
    }
    if (diffrSim) {
      diffrSim.fit();
      diffrSim.draw();
      diffrSim._updateHUD();
    }
  } else if (mode === 'interf') {  /* Agent C — interference / polarization */
    if (!ifSim) {
      const cv = document.getElementById('ob-interf-canvas');
      if (cv) { ifSim = new InterferenceSim(cv); ifSim.onUpdate = _ifUpdateUI; }
    }
    if (ifSim) { ifSim.fit(); ifSim.draw(); }
    _ifUpdateUI();
  }
}

/* ── Wavelength controls ── */
function obSetWavelength(nm) {
  window._obWavelength = Math.max(380, Math.min(780, +nm));
  const lbl = document.getElementById('ob-wl-val');
  if (lbl) lbl.textContent = window._obWavelength + ' нм';
  _obRedraw();
}

function obToggleWhiteLight(on) {
  window._obWhiteLight = !!on;
  const slRow = document.getElementById('ob-wl-slider-row');
  if (slRow) slRow.style.opacity = on ? '0.4' : '1';
  _obRedraw();
}


/* ── Interference mode UI callbacks (Agent C) ── */
function _ifUpdateUI() {
  if (!ifSim) return;
  const subMode = ifSim.subMode;
  ['if-ctrl-newton', 'if-ctrl-thinfilm', 'if-ctrl-polarization'].forEach(id => {
    const el = document.getElementById(id);
    if (el) el.style.display = 'none';
  });
  const active = document.getElementById('if-ctrl-' + subMode);
  if (active) active.style.display = '';
  ['if-sub-newton', 'if-sub-thinfilm', 'if-sub-polarization'].forEach(id => {
    const el = document.getElementById(id);
    if (el) el.classList.toggle('active', id === 'if-sub-' + subMode);
  });
}

function ifSwitchSub(sub) {
  if (window.LabFX) LabFX.sound.play('chime');
  if (!ifSim) return;
  ifSim.setSubMode(sub);
  _ifUpdateUI();
}

function ifNewtParam(key, val) {
  if (!ifSim) return;
  const v = parseFloat(val);
  if (key === 'R') { ifSim.nR = v; document.getElementById('if-newton-r-val').textContent = v; }
  else if (key === 'nmax') { ifSim.nNmax = Math.round(v); document.getElementById('if-newton-n-val').textContent = Math.round(v); }
  ifSim.draw();
}

function ifThinFilmParam(key, val) {
  if (!ifSim) return;
  const v = parseFloat(val);
  if (key === 't')     { ifSim.tfT = v;     document.getElementById('if-tf-t-val').textContent = v; }
  else if (key === 'n') { ifSim.tfN = v;     document.getElementById('if-tf-n-val').textContent = v.toFixed(2); }
  else if (key === 'theta') { ifSim.tfTheta = v; document.getElementById('if-tf-th-val').textContent = v; }
  ifSim.draw();
}

function ifThinFilmPreset(name) {
  if (!ifSim) return;
  const presets = {
    soap:    { n: 1.33, label: 'Мыльная плёнка' },
    oil:     { n: 1.50, label: 'Масло на воде'  },
    coating: { n: 1.38, label: 'Антибликовое покрытие' },
  };
  const p = presets[name];
  if (!p) return;
  ifSim.tfN = p.n;
  ifSim.tfPreset = name;
  const slN = document.getElementById('sl-if-tf-n');
  if (slN) slN.value = p.n;
  const lbN = document.getElementById('if-tf-n-val');
  if (lbN) lbN.textContent = p.n.toFixed(2);
  ifSim.draw();
  if (window.LabFX) LabFX.sound.play('chime');
}

function ifPolParam(key, val) {
  if (!ifSim) return;
  const v = parseFloat(val);
  if (key === 'theta') { ifSim.polTheta = v; document.getElementById('if-pol-th-val').textContent = v; }
  ifSim.draw();
}

function ifPolSrc(val) {
  if (!ifSim) return;
  ifSim.polSrc = val;
  ifSim.draw();
}

function _obRedraw() {
  if (_obMode === 'lens'       && lensSim)   { lensSim.draw(); }
  if (_obMode === 'mirror'     && mirrorSim)  { mirrorSim.draw(); }
  if (_obMode === 'refraction' && refrSim)    { refrSim.draw(); }
  if (_obMode === 'prism'      && prismSim)   { prismSim.draw(); }
  if (_obMode === 'freebuild'  && freeSim)    { freeSim.draw(); }
  if (_obMode === 'waves'      && diffrSim)   { diffrSim.draw(); diffrSim._updateHUD(); }
  if (_obMode === 'interf'     && ifSim)      { ifSim.draw(); }
  _obDrawSpectrometer();
  // Update prism stats bar
  const el = (id, txt) => { const e = document.getElementById(id); if (e) e.textContent = txt; };
  el('prismbar-wl', (window._obWavelength || 550) + ' нм');
  el('prismbar-mode', window._obWhiteLight ? 'Белый' : 'Моно');
  if (prismSim) el('prismbar-n', prismSim.n0.toFixed(2));
}

/* ── Prism controls ── */
function prismParam(name, val) {
  const v = parseFloat(val);
  const ids = { n0: 'prism-n0-val', incAngle: 'prism-inc-val' };
  const el = document.getElementById(ids[name]);
  if (el) el.textContent = name === 'n0' ? v.toFixed(2) : v;
  if (prismSim) prismSim.setParams({ [name]: v });
}

function prismPreset(n0, incAngle) {
  document.getElementById('sl-prism-n0').value = n0;
  document.getElementById('prism-n0-val').textContent = n0.toFixed(2);
  document.getElementById('sl-prism-inc').value = incAngle;
  document.getElementById('prism-inc-val').textContent = incAngle;
  if (prismSim) prismSim.setParams({ n0, incAngle });
}

function prismToggleWhite(on, btn) {
  window._obWhiteLight = !!on;
  const wb = document.getElementById('ob-prism-white-btn');
  const mb = document.getElementById('ob-prism-mono-btn');
  if (wb) wb.classList.toggle('active', !!on);
  if (mb) mb.classList.toggle('active', !on);
  if (prismSim) prismSim.draw();
  _obDrawSpectrometer();
}

/* ── Thin Lens controls ── */
function lensParam(name, val) {
  const v = parseFloat(val);
  const ids = { f: 'lens-f-val', d: 'lens-d-val', h: 'lens-h-val' };
  const el = document.getElementById(ids[name]);
  if (el) el.textContent = v;
  if (lensSim) lensSim.setParams({ [name]: v });
}

function lensPreset(f, d, h) {
  document.getElementById('sl-lens-f').value = f; document.getElementById('lens-f-val').textContent = f;
  document.getElementById('sl-lens-d').value = d; document.getElementById('lens-d-val').textContent = d;
  document.getElementById('sl-lens-h').value = h; document.getElementById('lens-h-val').textContent = h;
  if (lensSim) lensSim.setParams({ f, d, h });
}

/* ── Lens animated ray + LM controls (Feature 1 & 3) ── */
function lensToggleLM(on) {
  const sliders = document.getElementById('ob-lm-sliders');
  const fRow    = document.querySelector('#ob-ctrl-lens .proj-slider-row');
  if (sliders) sliders.style.display = on ? '' : 'none';
  // hide/show simple f slider
  const fSlRow = document.getElementById('sl-lens-f');
  if (fSlRow && fSlRow.parentElement) fSlRow.parentElement.style.display = on ? 'none' : '';
  if (lensSim) lensSim.setLensMode(!on);
  if (on && lensSim) {
    // sync sliders to current LM params
    const r1 = lensSim._lmR1, r2 = lensSim._lmR2, n = lensSim._lmN;
    const s1 = document.getElementById('sl-lm-r1'), l1 = document.getElementById('lm-r1-val');
    const s2 = document.getElementById('sl-lm-r2'), l2 = document.getElementById('lm-r2-val');
    const sn = document.getElementById('sl-lm-n'),  ln = document.getElementById('lm-n-val');
    if (s1) s1.value = r1; if (l1) l1.textContent = r1.toFixed(0);
    if (s2) s2.value = r2; if (l2) l2.textContent = r2.toFixed(0);
    if (sn) sn.value = n;  if (ln) ln.textContent = n.toFixed(2);
  }
}

function lensLMParam(name, val) {
  const v = parseFloat(val);
  const lblMap = { R1: 'lm-r1-val', R2: 'lm-r2-val', n: 'lm-n-val' };
  const el = document.getElementById(lblMap[name]);
  if (el) el.textContent = name === 'n' ? v.toFixed(2) : v.toFixed(0);
  if (lensSim) {
    lensSim.setLMParam(name, v);
    // update f display
    const fl = document.getElementById('lens-f-val');
    if (fl) fl.textContent = lensSim.f.toFixed(0);
  }
}

/* ── Mirror R-slider + parabolic controls (Feature 2) ── */
function mirrorToggleR(on) {
  const rRow = document.getElementById('ob-mirror-R-row');
  if (rRow) rRow.style.display = on ? '' : 'none';
  const pbtn = document.getElementById('mirror-parab-btn');
  if (pbtn) pbtn.style.display = on ? '' : 'none';
  if (mirrorSim) mirrorSim._useR = !!on;
  if (on && mirrorSim) {
    const sv = document.getElementById('sl-mirror-R');
    const lv = document.getElementById('mirror-R-val');
    if (sv) sv.value = mirrorSim._R;
    if (lv) lv.textContent = mirrorSim._R;
    mirrorSim.setMirrorR(mirrorSim._R);
  } else if (mirrorSim) { mirrorSim.draw(); }
}

function mirrorRParam(val) {
  const v = parseFloat(val);
  const el = document.getElementById('mirror-R-val');
  if (el) el.textContent = v;
  if (mirrorSim) mirrorSim.setMirrorR(v);
}

function mirrorToggleParabolic(btn) {
  if (!mirrorSim) return;
  mirrorSim._parabolic = !mirrorSim._parabolic;
  if (btn) btn.textContent = mirrorSim._parabolic ? 'Параболическое' : 'Сферическое';
  if (btn) btn.style.color = mirrorSim._parabolic ? '#7BF5A4' : '#888';
  mirrorSim.draw();
}

function _lensUpdateUI(info) {
  const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
  v('lensbar-v1', info.f);
  v('lensbar-v2', info.dPrime === Infinity ? '∞' : info.dPrime);
  v('lensbar-v3', info.M === Infinity ? '∞' : info.M);
  v('lensbar-v4', info.imageType);
}

/* ── Mirror controls ── */
function mirrorType(type, el) {
  document.querySelectorAll('.mirror-type-btn').forEach(b => b.classList.remove('active'));
  if (el) el.classList.add('active');
  const fRow = document.getElementById('mirror-f-row');
  if (fRow) fRow.style.display = type === 'flat' ? 'none' : 'flex';
  if (mirrorSim) mirrorSim.setType(type);
  const pb = document.getElementById('mirror-play-btn');
  if (pb) { pb.textContent = 'Анимация'; }
  const sl = document.getElementById('sl-mirror-d');
  if (sl) sl.disabled = false;
}

function mirrorParam(name, val) {
  const v = parseFloat(val);
  const ids = { f: 'mirror-f-val', d: 'mirror-d-val', h: 'mirror-h-val' };
  const el = document.getElementById(ids[name]);
  if (el) el.textContent = v;
  if (mirrorSim) mirrorSim.setParams({ [name]: v });
}

function mirrorPreset(name) {
  const P = {
    flat:    { type: 'flat',    f: 120, d: 200, h: 60 },
    far:     { type: 'concave', f: 100, d: 280, h: 60 },
    '2f':    { type: 'concave', f: 100, d: 200, h: 60 },
    between: { type: 'concave', f: 100, d: 140, h: 60 },
    near:    { type: 'concave', f: 100, d: 60,  h: 60 },
    convex:  { type: 'convex',  f: 100, d: 200, h: 60 },
  };
  const p = P[name]; if (!p) return;
  document.querySelectorAll('.mirror-type-btn').forEach(b => b.classList.remove('active'));
  const tb = document.getElementById('mtype-' + p.type);
  if (tb) tb.classList.add('active');
  const fRow = document.getElementById('mirror-f-row');
  if (fRow) fRow.style.display = p.type === 'flat' ? 'none' : 'flex';
  document.getElementById('sl-mirror-f').value = p.f; document.getElementById('mirror-f-val').textContent = p.f;
  document.getElementById('sl-mirror-d').value = p.d; document.getElementById('mirror-d-val').textContent = p.d;
  document.getElementById('sl-mirror-h').value = p.h; document.getElementById('mirror-h-val').textContent = p.h;
  if (mirrorSim) { mirrorSim.setType(p.type); mirrorSim.setParams({ f: p.f, d: p.d, h: p.h }); }
}

function mirrorTogglePlay(btn) {
  if (!mirrorSim) return;
  mirrorSim.togglePlay();
  const playing = mirrorSim._playing;
  if (btn) btn.textContent = playing ? 'Стоп' : 'Анимация';
  const sl = document.getElementById('sl-mirror-d');
  if (sl) sl.disabled = playing;
}

function mirrorSetSpeed(val)     { if (mirrorSim) mirrorSim.setAnimSpeed(parseFloat(val)); }
function mirrorToggle(name, val) { if (mirrorSim) mirrorSim.setToggle(name, val); }
function mirrorStepNext()        { if (mirrorSim) mirrorSim.stepNext(); }
function mirrorStepReset()       { if (mirrorSim) mirrorSim.stepReset(); }
function mirrorSetPointMode(val) { if (mirrorSim) mirrorSim.setPointMode(val); }

function _mirrorUpdateUI(info) {
  const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
  v('mirrorbar-v1', info.f);
  v('mirrorbar-v5', Math.round(info.d));
  v('mirrorbar-v2', info.dPrime === Infinity ? '∞' : info.dPrime);
  v('mirrorbar-v3', info.M === Infinity ? '∞' : info.M);
  v('mirrorbar-v4', info.imageType);
}

/* ── Refraction controls ── */
function refrParam(name, val) {
  const v = parseFloat(val);
  const ids = { n1: 'refr-n1-val', n2: 'refr-n2-val', angle: 'refr-angle-val' };
  const el = document.getElementById(ids[name]);
  if (el) el.textContent = name === 'angle' ? v : v.toFixed(2);
  if (refrSim) refrSim.setParams({ [name]: v });
}

function refrPreset(n1, n2, angle) {
  document.getElementById('sl-refr-n1').value = n1; document.getElementById('refr-n1-val').textContent = n1.toFixed(2);
  document.getElementById('sl-refr-n2').value = n2; document.getElementById('refr-n2-val').textContent = n2.toFixed(2);
  document.getElementById('sl-refr-angle').value = angle; document.getElementById('refr-angle-val').textContent = angle;
  if (refrSim) refrSim.setParams({ n1, n2, angle });
}

function _refrUpdateUI(info) {
  const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
  v('refrbar-v1', info.angle1 + '°');
  v('refrbar-v2', info.isTIR ? 'ПВО' : info.angle2 + '°');
  v('refrbar-v3', info.criticalAngle !== null ? info.criticalAngle + '°' : '—');
  v('refrbar-v4', info.isTIR ? 'Да' : 'Нет');
}

/* ── dispersion toggle ── */
function refrDispersion(on) {
  if (refrSim) refrSim.setParams({ dispersion: on });
}

/* ─────────────────────────────────────────────────────────────
   5.  PRE-BUILT INSTRUMENT SCENES  (obLoadPreset / OB_PRESETS)
───────────────────────────────────────────────────────────────*/

/* Each preset: { label, desc, mode, build(sim) }
   build() receives the already-initialised sim object for the target mode.
   Supported modes: 'lens' | 'mirror' | 'refraction' */
var OB_PRESETS = {

  /* ── 1. Лупа ─────────────────────────────────────────────── */
  magnifier: {
    label: 'Лупа',
    desc:  'Объект ближе фокуса (d < f). Мнимое увеличенное прямое изображение.',
    mode:  'lens',
    build: function(sim) {
      sim.setParams({ f: 50, d: 30, h: 50 });
      _obSyncLensUI(50, 30, 50);
    }
  },

  /* ── 2. Микроскоп ─────────────────────────────────────────── */
  microscope: {
    label: 'Микроскоп',
    desc:  'Объектив f = 10, предмет чуть дальше F. Действительное изображение увеличено.',
    mode:  'lens',
    build: function(sim) {
      /* Show the objective stage: object just outside F of objective */
      sim.setParams({ f: 10, d: 14, h: 30 });
      _obSyncLensUI(10, 14, 30);
    }
  },

  /* ── 3. Телескоп Кеплера ──────────────────────────────────── */
  keplerian: {
    label: 'Телескоп Кеплера',
    desc:  'Две собирающие линзы конфокально (fуб = 200, fок = 30). Объект на ∞.',
    mode:  'lens',
    build: function(sim) {
      /* Show the eyepiece stage receiving collimated light: f_ok = 30,
         "parallel-ray" object simulated by placing d very large (390 max) */
      sim.setParams({ f: 30, d: 390, h: 55 });
      _obSyncLensUI(30, 390, 55);
    }
  },

  /* ── 4. Телескоп Галилея ──────────────────────────────────── */
  galilean: {
    label: 'Телескоп Галилея',
    desc:  'Собирающий объектив f = 200 + рассеивающий окуляр f = −40. Прямое изображение.',
    mode:  'lens',
    build: function(sim) {
      /* Diverging eyepiece: f = -40, object at 390 (parallel rays) */
      sim.setParams({ f: -40, d: 390, h: 55 });
      _obSyncLensUI(-40, 390, 55);
    }
  },

  /* ── 5. Камера / Глаз ─────────────────────────────────────── */
  camera: {
    label: 'Камера / Глаз',
    desc:  'Линза f = 40, предмет d = 120. Действительное уменьшенное изображение на матрице.',
    mode:  'lens',
    build: function(sim) {
      sim.setParams({ f: 40, d: 120, h: 60 });
      _obSyncLensUI(40, 120, 60);
    }
  },

  /* ── 6. Перископ ─────────────────────────────────────────────
     Uses MirrorSim with flat mirror to show 45° reflection concept.
     A true two-mirror periscope can't be drawn in the single-mirror
     engine, so we show one flat mirror at 45° (flat type, d large). */
  periscope: {
    label: 'Перископ',
    desc:  'Плоское зеркало под 45°. Луч отражается под прямым углом.',
    mode:  'mirror',
    build: function(sim) {
      sim.setType('flat');
      sim.setParams({ d: 300, h: 60 });
      _obSyncMirrorUI('flat', 120, 300, 60);
    }
  },

  /* ── 7. Слайд-проектор ─────────────────────────────────────── */
  projector: {
    label: 'Слайд-проектор',
    desc:  'Объектив f = 80, слайд d = 100 (чуть дальше F). Действительное увеличенное изображение.',
    mode:  'lens',
    build: function(sim) {
      sim.setParams({ f: 80, d: 100, h: 40 });
      _obSyncLensUI(80, 100, 40);
    }
  },

  /* ── 8. Световод (ПВО) ──────────────────────────────────────
     Shows TIR inside a dense medium (n2 > n1).
     Use refraction mode: n1=1.5 (glass), n2=1.0 (air), angle > critical. */
  fiber: {
    label: 'Световод (ПВО)',
    desc:  'Стекло n = 1.5 → воздух n = 1. Угол > критического — полное внутреннее отражение.',
    mode:  'refraction',
    build: function(sim) {
      /* critical angle = arcsin(1/1.5) ≈ 41.8°; use 50° to clearly show TIR */
      sim.setParams({ n1: 1.5, n2: 1.0, angle: 50, dispersion: false });
      _obSyncRefrUI(1.5, 1.0, 50);
    }
  },

  /* ── 9. Ложка в воде ────────────────────────────────────────
     Shows apparent-depth illusion: light from object in water (n=1.33)
     refracts at boundary going into air. Observer sees virtual image. */
  spoon: {
    label: 'Ложка в воде',
    desc:  'Свет от предмета в воде (n = 1.33) преломляется в воздух. Мнимое изображение кажется ближе.',
    mode:  'refraction',
    build: function(sim) {
      sim.setParams({ n1: 1.33, n2: 1.0, angle: 30, dispersion: false });
      _obSyncRefrUI(1.33, 1.0, 30);
    }
  }
};

/* ── UI sync helpers (keep sliders in sync after preset load) ── */
function _obSyncLensUI(f, d, h) {
  var sl;
  sl = document.getElementById('sl-lens-f'); if (sl) sl.value = f;
  sl = document.getElementById('sl-lens-d'); if (sl) sl.value = d;
  sl = document.getElementById('sl-lens-h'); if (sl) sl.value = h;
  var el;
  el = document.getElementById('lens-f-val'); if (el) el.textContent = f;
  el = document.getElementById('lens-d-val'); if (el) el.textContent = d;
  el = document.getElementById('lens-h-val'); if (el) el.textContent = h;
}

function _obSyncMirrorUI(type, f, d, h) {
  document.querySelectorAll('.mirror-type-btn').forEach(function(b) { b.classList.remove('active'); });
  var tb = document.getElementById('mtype-' + type);
  if (tb) tb.classList.add('active');
  var fRow = document.getElementById('mirror-f-row');
  if (fRow) fRow.style.display = type === 'flat' ? 'none' : 'flex';
  var sl;
  sl = document.getElementById('sl-mirror-f'); if (sl) sl.value = f;
  sl = document.getElementById('sl-mirror-d'); if (sl) sl.value = d;
  sl = document.getElementById('sl-mirror-h'); if (sl) sl.value = h;
  var el;
  el = document.getElementById('mirror-f-val'); if (el) el.textContent = f;
  el = document.getElementById('mirror-d-val'); if (el) el.textContent = d;
  el = document.getElementById('mirror-h-val'); if (el) el.textContent = h;
}

function _obSyncRefrUI(n1, n2, angle) {
  var sl;
  sl = document.getElementById('sl-refr-n1');    if (sl) sl.value = n1;
  sl = document.getElementById('sl-refr-n2');    if (sl) sl.value = n2;
  sl = document.getElementById('sl-refr-angle'); if (sl) sl.value = angle;
  var el;
  el = document.getElementById('refr-n1-val');    if (el) el.textContent = n1.toFixed(2);
  el = document.getElementById('refr-n2-val');    if (el) el.textContent = n2.toFixed(2);
  el = document.getElementById('refr-angle-val'); if (el) el.textContent = angle;
}

/* ── HUD toast shown for 5 s after preset load ── */
function _obShowPresetHUD(label, desc) {
  var existing = document.getElementById('ob-preset-hud');
  if (existing) existing.remove();

  var hud = document.createElement('div');
  hud.id = 'ob-preset-hud';
  hud.style.cssText = [
    'position:absolute', 'top:10px', 'left:50%', 'transform:translateX(-50%)',
    'background:rgba(18,18,30,.92)', 'border:1px solid rgba(100,220,255,.35)',
    'border-radius:10px', 'padding:8px 16px', 'z-index:99',
    'pointer-events:none', 'text-align:center',
    'box-shadow:0 4px 20px rgba(0,0,0,.5)',
    'transition:opacity .4s'
  ].join(';');
  hud.innerHTML = '<div style="font-size:.85rem;font-weight:700;color:var(--cyan,#67e8f9);margin-bottom:2px">' +
    label + '</div>' +
    '<div style="font-size:.73rem;color:#aaa;max-width:320px;line-height:1.35">' + desc + '</div>';

  /* Attach to the canvas wrapper so it overlays correctly */
  var wrap = document.querySelector('#sim-opticsbench .proj-canvas-outer');
  if (wrap) { wrap.style.position = 'relative'; wrap.appendChild(hud); }

  /* Fade out after 5 s */
  var timer = setTimeout(function() {
    hud.style.opacity = '0';
    setTimeout(function() { if (hud.parentNode) hud.remove(); }, 420);
  }, 5000);
  hud._timer = timer;
}

/* ── Main entry point called from HTML buttons ── */
function obLoadPreset(name) {
  var preset = OB_PRESETS[name];
  if (!preset) return;

  /* Switch to the correct mode first, then build */
  obSwitchMode(preset.mode, true);

  /* Give the canvas time to initialise if it was just created */
  requestAnimationFrame(function() {
    var sim = preset.mode === 'lens'       ? lensSim
            : preset.mode === 'mirror'     ? mirrorSim
            : preset.mode === 'refraction' ? refrSim
            : null;
    if (!sim) return;
    preset.build(sim);

    /* Sound */
    if (window.LabFX) LabFX.sound.play('chime');

    /* Tab highlight */
    ['lens', 'mirror', 'refraction'].forEach(function(m) {
      var btn = document.getElementById('ob-tab-' + m);
      if (btn) btn.classList.toggle('active', m === preset.mode);
    });

    /* HUD */
    _obShowPresetHUD(preset.label, preset.desc);

    /* Highlight the active preset chip */
    document.querySelectorAll('.ob-preset-chip').forEach(function(c) {
      c.classList.toggle('active', c.dataset.preset === name);
    });
  });
}

/* ── Clear: reset current mode to defaults ── */
function obClearPreset() {
  /* Deactivate all chips */
  document.querySelectorAll('.ob-preset-chip').forEach(function(c) {
    c.classList.remove('active');
  });
  /* Remove HUD if visible */
  var hud = document.getElementById('ob-preset-hud');
  if (hud) hud.remove();
  /* Reset active sim */
  if (_obMode === 'lens'       && lensSim)   lensSim.reset();
  if (_obMode === 'mirror'     && mirrorSim) { mirrorSim.setType('concave'); mirrorSim.reset(); }
  if (_obMode === 'refraction' && refrSim)   refrSim.reset();
  /* Re-sync UI sliders to defaults */
  if (_obMode === 'lens')       _obSyncLensUI(100, 200, 50);
  if (_obMode === 'mirror')     _obSyncMirrorUI('concave', 120, 240, 60);
  if (_obMode === 'refraction') _obSyncRefrUI(1.0, 1.5, 30);
  if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 0.9, volume: 0.25 });
}

/* ── Aberration toggles for ThinLensSim (Agent OB-A3) ── */
function lensAberration(type, on) {
  if (lensSim) lensSim.setAberration(type, on);
}

/* ── Mirror spherical aberration toggle (Agent OB-A3) ── */
function mirrorAberration(on) {
  if (mirrorSim) mirrorSim.setToggle('spherical', on);
}

/* ── Free-build multi-lens controls (Agent OB-A3) ── */
function freeAddLens() {
  if (freeSim) freeSim.addLens(100);
}
function freeRemoveLens() {
  if (freeSim) freeSim.removeLens();
}
function freeLensF(idx, val) {
  const v = parseFloat(val);
  const el = document.getElementById('free-lens' + idx + '-fval');
  if (el) el.textContent = v;
  if (freeSim) freeSim.setLensF(idx, v);
}
function freePreset(name) {
  const presets = {
    microscope:  { els: [{ x_frac: 0.30, f: 40 }, { x_frac: 0.70, f: 80 }], obj: 0.10 },
    telescope:   { els: [{ x_frac: 0.25, f: 160 }, { x_frac: 0.78, f: 50 }], obj: 0.05 },
    relay:       { els: [{ x_frac: 0.28, f: 100 }, { x_frac: 0.55, f: 100 }, { x_frac: 0.80, f: 100 }], obj: 0.08 },
  };
  const p = presets[name];
  if (!p || !freeSim) return;
  freeSim.elements = p.els.map((e, i) => ({ ...e, id: i + 1 }));
  freeSim.objFrac  = p.obj;
  freeSim.draw();
  if (freeSim.onUpdate) freeSim.onUpdate(freeSim._computeChain());
}
function _freeUpdateUI(chain) {
  const el = document.getElementById('freebar-mag');
  if (el) el.textContent = isFinite(chain.totalM) ? chain.totalM.toFixed(3) : '∞';
  const el2 = document.getElementById('freebar-sys');
  if (el2) el2.textContent = chain.sysFocal !== null ? chain.sysFocal.toFixed(0) : '—';
}

/* ─────────────────────────────────────────────────────────────
   6.  DIFFRACTION SIM — Волновая оптика (Юнг / Однощелевая / Решётка)
───────────────────────────────────────────────────────────────*/

/**
 * DiffractionSim — handles 3 wave-optics sub-experiments on a single canvas.
 * Sub-modes:
 *   'young'   — Young double-slit interference
 *   'single'  — Single-slit diffraction
 *   'grating' — Diffraction grating (N slits)
 */
class DiffractionSim {
  constructor(canvas) {
    this._cv  = canvas;
    this._ctx = canvas.getContext('2d');
    this._sub = 'young';   // active sub-experiment
    this._raf = null;

    /* Young params */
    this._d_young  = 40;   // slit separation µm
    this._L_young  = 1.0;  // screen distance m

    /* Single-slit params */
    this._a_single = 80;   // slit width µm

    /* Grating params */
    this._N_grating = 10;  // number of slits
    this._d_grating = 2.0; // grating period µm
    this._a_grating = 0.5; // slit width µm (for envelope)

    this.fit();
  }

  fit() {
    const cv = this._cv;
    const pr = window.devicePixelRatio || 1;
    const w  = cv.offsetWidth  || 700;
    const h  = cv.offsetHeight || 380;
    cv.width  = Math.round(w * pr);
    cv.height = Math.round(h * pr);
    this._ctx.setTransform(pr, 0, 0, pr, 0, 0);
    this._W = w;
    this._H = h;
  }

  setSub(sub) {
    this._sub = sub;
    this.draw();
  }

  /* ── public setters called from HTML controls ── */
  setParam(name, val) {
    const v = parseFloat(val);
    if (name === 'd_young')   this._d_young   = v;
    if (name === 'L_young')   this._L_young   = v;
    if (name === 'a_single')  this._a_single  = v;
    if (name === 'N_grating') this._N_grating = Math.round(v);
    if (name === 'd_grating') this._d_grating = v;
    if (name === 'a_grating') this._a_grating = v;
    this.draw();
    this._updateHUD();
  }

  /* ── main draw dispatcher ── */
  draw() {
    const ctx = this._ctx;
    const W = this._W, H = this._H;
    ctx.clearRect(0, 0, W, H);

    /* dark background */
    ctx.fillStyle = '#0a0a16';
    ctx.fillRect(0, 0, W, H);

    if (this._sub === 'young')   this._drawYoung();
    else if (this._sub === 'single') this._drawSingle();
    else if (this._sub === 'grating') this._drawGrating();
  }

  /* ─────────────────────────────────────────────
     Young double-slit
  ─────────────────────────────────────────────── */
  _drawYoung() {
    const ctx = this._ctx;
    const W = this._W, H = this._H;
    const λ_nm = window._obWavelength || 550;
    const λ    = λ_nm * 1e-9;           // m
    const d    = this._d_young  * 1e-6; // m
    const L    = this._L_young;         // m
    const wl   = window._obWhiteLight;

    /* layout */
    const slitX  = Math.round(W * 0.22);
    const screenX = Math.round(W * 0.75);
    const graphH  = Math.round(H * 0.26);
    const simH    = H - graphH - 1;
    const cy      = Math.round(simH / 2);

    /* source (left) */
    this._drawSource(slitX - Math.round(W * 0.15), cy);

    /* draw incoming beam */
    ctx.save();
    ctx.strokeStyle = wl ? 'rgba(255,255,220,0.35)' : this._wlColor(λ_nm, 0.35);
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(0, cy);
    ctx.lineTo(slitX - 2, cy);
    ctx.stroke();
    ctx.restore();

    /* slit barrier */
    const slitGapPx = 18; // visual gap per slit
    const sepPx = Math.round(Math.max(12, Math.min(60, this._d_young * 0.8)));
    this._drawSlitBarrier(slitX, cy, simH, sepPx, slitGapPx, 2);

    /* wavefronts from two slits */
    const slit1Y = cy - sepPx / 2;
    const slit2Y = cy + sepPx / 2;
    this._drawWavefronts(slitX, slit1Y, slitX, slit2Y, screenX - slitX, λ_nm, wl, simH);

    /* intensity screen */
    const screenPoints = this._youngIntensity(λ, d, L, W, simH);
    this._drawScreen(screenX, simH, screenPoints, λ_nm, wl);

    /* graph */
    this._drawGraph(0, simH, W, graphH, screenPoints, λ_nm, wl);

    /* fringe spacing label */
    const dy_mm = (λ * L / d * 1e3).toFixed(2);
    this._drawHUDLine(ctx, W, H, `Δy = λL/d = ${dy_mm} мм`);

    /* axis labels */
    this._drawLabel(ctx, slitX, simH - 10, 'Щели');
    this._drawLabel(ctx, screenX, simH - 10, 'Экран');
  }

  _youngIntensity(λ, d, L, W, simH) {
    const N = 300;
    const halfH = simH * 0.46;
    const pts = [];
    for (let i = 0; i <= N; i++) {
      const y_px = (i / N) * simH - simH / 2; // px, centred
      const y_m  = y_px / (simH / 2) * 0.05;  // map ±simH/2 → ±50mm
      const sinT = y_m / Math.sqrt(y_m * y_m + L * L);
      const phi  = Math.PI * d * sinT / λ;
      const I    = Math.cos(phi) * Math.cos(phi);
      pts.push({ y_px, I });
    }
    return pts;
  }

  /* ─────────────────────────────────────────────
     Single-slit diffraction
  ─────────────────────────────────────────────── */
  _drawSingle() {
    const ctx = this._ctx;
    const W = this._W, H = this._H;
    const λ_nm = window._obWavelength || 550;
    const λ    = λ_nm * 1e-9;
    const a    = this._a_single * 1e-6;
    const L    = 1.0; // fixed 1 m
    const wl   = window._obWhiteLight;

    const slitX  = Math.round(W * 0.22);
    const screenX = Math.round(W * 0.75);
    const graphH  = Math.round(H * 0.26);
    const simH    = H - graphH - 1;
    const cy      = Math.round(simH / 2);
    const slitGapPx = Math.round(Math.max(6, Math.min(30, this._a_single * 0.18)));

    this._drawSource(slitX - Math.round(W * 0.15), cy);

    ctx.save();
    ctx.strokeStyle = wl ? 'rgba(255,255,220,0.35)' : this._wlColor(λ_nm, 0.35);
    ctx.lineWidth = 2;
    ctx.beginPath(); ctx.moveTo(0, cy); ctx.lineTo(slitX - 2, cy); ctx.stroke();
    ctx.restore();

    /* single slit barrier */
    this._drawSingleSlitBarrier(slitX, cy, simH, slitGapPx);

    /* wavefronts — single source */
    this._drawWavefronts(slitX, cy, slitX, cy, screenX - slitX, λ_nm, wl, simH);

    const screenPoints = this._singleIntensity(λ, a, L, simH);
    this._drawScreen(screenX, simH, screenPoints, λ_nm, wl);
    this._drawGraph(0, simH, W, graphH, screenPoints, λ_nm, wl);

    /* angular width of central maximum */
    const ang_rad  = (2 * λ / a);
    const ang_deg  = (ang_rad * 180 / Math.PI).toFixed(2);
    this._drawHUDLine(ctx, W, H, `Центр. макс: 2λ/a = ${ang_deg}°`);

    this._drawLabel(ctx, slitX, simH - 10, 'Щель');
    this._drawLabel(ctx, screenX, simH - 10, 'Экран');
  }

  _singleIntensity(λ, a, L, simH) {
    const N = 300;
    const pts = [];
    for (let i = 0; i <= N; i++) {
      const y_px = (i / N) * simH - simH / 2;
      const y_m  = y_px / (simH / 2) * 0.05;
      const sinT = y_m / Math.sqrt(y_m * y_m + L * L);
      const alpha = Math.PI * a * sinT / λ;
      const I = alpha === 0 ? 1 : Math.pow(Math.sin(alpha) / alpha, 2);
      pts.push({ y_px, I });
    }
    return pts;
  }

  /* ─────────────────────────────────────────────
     Diffraction grating
  ─────────────────────────────────────────────── */
  _drawGrating() {
    const ctx = this._ctx;
    const W = this._W, H = this._H;
    const λ_nm = window._obWavelength || 550;
    const λ    = λ_nm * 1e-9;
    const d    = this._d_grating * 1e-6;
    const a    = this._a_grating * 1e-6;
    const N    = this._N_grating;
    const L    = 1.0;
    const wl   = window._obWhiteLight;

    const slitX  = Math.round(W * 0.22);
    const screenX = Math.round(W * 0.75);
    const graphH  = Math.round(H * 0.26);
    const simH    = H - graphH - 1;
    const cy      = Math.round(simH / 2);

    this._drawSource(slitX - Math.round(W * 0.15), cy);

    ctx.save();
    ctx.strokeStyle = wl ? 'rgba(255,255,220,0.35)' : this._wlColor(λ_nm, 0.35);
    ctx.lineWidth = 2;
    ctx.beginPath(); ctx.moveTo(0, cy); ctx.lineTo(slitX - 2, cy); ctx.stroke();
    ctx.restore();

    /* grating barrier — multiple slits */
    this._drawGratingBarrier(slitX, cy, simH, N);

    const screenPoints = wl
      ? this._gratingIntensityWhite(d, a, N, L, simH)
      : this._gratingIntensity(λ, d, a, N, L, simH, λ_nm);

    this._drawScreen(screenX, simH, screenPoints, λ_nm, wl);
    this._drawGraph(0, simH, W, graphH, screenPoints, λ_nm, wl);

    /* order labels on screen */
    this._drawGratingOrders(ctx, screenX, cy, d, λ, L, simH);

    /* resolving power at order 1 */
    const R = N * 1;
    this._drawHUDLine(ctx, W, H, `R = Nn = ${R} (порядок 1)  d·sinθ = nλ`);

    this._drawLabel(ctx, slitX, simH - 10, 'Решётка');
    this._drawLabel(ctx, screenX, simH - 10, 'Экран');
  }

  _gratingIntensity(λ, d, a, N, L, simH, λ_nm) {
    const pts = [];
    for (let i = 0; i <= 300; i++) {
      const y_px = (i / 300) * simH - simH / 2;
      const y_m  = y_px / (simH / 2) * 0.05;
      const sinT = y_m / Math.sqrt(y_m * y_m + L * L);
      /* multi-slit principal maxima */
      const psi  = Math.PI * d * sinT / λ;
      const Imulti = psi === 0 ? 1 : Math.pow(Math.sin(N * psi) / (N * Math.sin(psi)), 2);
      /* single-slit envelope */
      const alpha  = Math.PI * a * sinT / λ;
      const Ienv   = alpha === 0 ? 1 : Math.pow(Math.sin(alpha) / alpha, 2);
      const I = Imulti * Ienv;
      pts.push({ y_px, I, color: this._wlColor(λ_nm, I) });
    }
    return pts;
  }

  _gratingIntensityWhite(d, a, N, L, simH) {
    /* Combine 7 wavelengths for white-light display */
    const wls = [400, 450, 490, 530, 570, 620, 680];
    const pts = [];
    for (let i = 0; i <= 300; i++) {
      const y_px = (i / 300) * simH - simH / 2;
      const y_m  = y_px / (simH / 2) * 0.05;
      const sinT = y_m / Math.sqrt(y_m * y_m + L * L);
      /* blend colors from all wavelengths */
      let r = 0, g = 0, b = 0, maxI = 0;
      wls.forEach(nm => {
        const λ_w = nm * 1e-9;
        const psi = Math.PI * d * sinT / λ_w;
        const Im  = psi === 0 ? 1 : Math.pow(Math.sin(N * psi) / (N * Math.sin(psi)), 2);
        const alpha = Math.PI * a * sinT / λ_w;
        const Ie  = alpha === 0 ? 1 : Math.pow(Math.sin(alpha) / alpha, 2);
        const I   = Im * Ie;
        if (I > maxI) maxI = I;
        const rgb = this._wlRGB(nm);
        r += rgb[0] * I; g += rgb[1] * I; b += rgb[2] * I;
      });
      const sc = maxI > 0 ? 1 / (wls.length * maxI + 0.01) * maxI * wls.length : 0;
      pts.push({
        y_px,
        I: maxI,
        color: `rgba(${Math.round(Math.min(255, r / wls.length))},${Math.round(Math.min(255, g / wls.length))},${Math.round(Math.min(255, b / wls.length))},${Math.min(1, maxI + 0.02)})`
      });
    }
    return pts;
  }

  _drawGratingOrders(ctx, screenX, cy, d, λ, L, simH) {
    const maxOrder = 3;
    ctx.save();
    ctx.font = '10px sans-serif';
    ctx.fillStyle = 'rgba(255,255,150,0.85)';
    ctx.textAlign = 'center';
    for (let n = -maxOrder; n <= maxOrder; n++) {
      const sinT = n * λ / d;
      if (Math.abs(sinT) >= 1) continue;
      const y_m  = Math.tan(Math.asin(sinT)) * L;
      const y_px = y_m / 0.05 * (simH / 2) + cy;
      if (y_px < 4 || y_px > simH - 4) continue;
      ctx.fillText(`${n >= 0 ? '+' : ''}${n}`, screenX + 18, y_px + 4);
    }
    ctx.restore();
  }

  /* ─────────────────────────────────────────────
     Common drawing helpers
  ─────────────────────────────────────────────── */

  _drawSource(x, cy) {
    const ctx = this._ctx;
    ctx.save();
    ctx.beginPath();
    ctx.arc(x, cy, 7, 0, Math.PI * 2);
    ctx.fillStyle = '#fff9c0';
    ctx.shadowColor = '#fff5a0';
    ctx.shadowBlur = 16;
    ctx.fill();
    ctx.shadowBlur = 0;
    ctx.restore();
    /* label */
    ctx.save();
    ctx.font = '10px sans-serif';
    ctx.fillStyle = '#aaa';
    ctx.textAlign = 'center';
    ctx.fillText('Источник', x, cy + 20);
    ctx.restore();
  }

  _drawSlitBarrier(x, cy, simH, sepPx, gapPx, slitCount) {
    const ctx = this._ctx;
    const half = sepPx / 2;
    const hw   = 6;
    ctx.save();
    ctx.fillStyle = '#3a3a5a';
    /* top block */
    ctx.fillRect(x - hw, 0, hw * 2, cy - half - gapPx / 2);
    /* middle block (between slits) */
    if (slitCount === 2) {
      ctx.fillRect(x - hw, cy - half + gapPx / 2, hw * 2, sepPx - gapPx);
    }
    /* bottom block */
    ctx.fillRect(x - hw, cy + half + gapPx / 2, hw * 2, simH - (cy + half + gapPx / 2));
    /* slit highlight lines */
    ctx.strokeStyle = 'rgba(255,255,200,0.5)';
    ctx.lineWidth = 1;
    [cy - half - gapPx / 2, cy - half + gapPx / 2, cy + half - gapPx / 2, cy + half + gapPx / 2].forEach(yy => {
      ctx.beginPath(); ctx.moveTo(x - hw - 2, yy); ctx.lineTo(x + hw + 2, yy); ctx.stroke();
    });
    ctx.restore();
  }

  _drawSingleSlitBarrier(x, cy, simH, gapPx) {
    const ctx = this._ctx;
    const hw = 6;
    ctx.save();
    ctx.fillStyle = '#3a3a5a';
    ctx.fillRect(x - hw, 0, hw * 2, cy - gapPx / 2);
    ctx.fillRect(x - hw, cy + gapPx / 2, hw * 2, simH - cy - gapPx / 2);
    ctx.strokeStyle = 'rgba(255,255,200,0.5)';
    ctx.lineWidth = 1;
    [cy - gapPx / 2, cy + gapPx / 2].forEach(yy => {
      ctx.beginPath(); ctx.moveTo(x - hw - 2, yy); ctx.lineTo(x + hw + 2, yy); ctx.stroke();
    });
    ctx.restore();
  }

  _drawGratingBarrier(x, cy, simH, N) {
    const ctx = this._ctx;
    const hw = 6;
    const totalH = simH * 0.7;
    const startY = cy - totalH / 2;
    ctx.save();
    ctx.fillStyle = '#3a3a5a';
    ctx.fillRect(x - hw, 0, hw * 2, simH);
    /* punch slit holes */
    const slitH = Math.max(2, Math.floor(totalH / (N * 2.5)));
    const step  = totalH / N;
    ctx.fillStyle = '#0a0a16';
    for (let i = 0; i < N; i++) {
      const sy = startY + i * step + (step - slitH) / 2;
      ctx.fillRect(x - hw, sy, hw * 2, slitH);
    }
    ctx.restore();
  }

  _drawWavefronts(slitX, s1y, slitX2, s2y, maxR, λ_nm, wl, simH) {
    const ctx = this._ctx;
    const baseColor = wl ? 'rgba(255,255,200,' : null;
    const nArcs = 7;
    const srcPts = s1y === s2y ? [{ x: slitX, y: s1y }] : [{ x: slitX, y: s1y }, { x: slitX2, y: s2y }];

    srcPts.forEach(src => {
      ctx.save();
      for (let i = 1; i <= nArcs; i++) {
        const r   = (i / nArcs) * maxR * 0.92;
        const a   = wl ? (0.4 - i * 0.04) : (0.45 - i * 0.05);
        ctx.strokeStyle = wl ? `rgba(255,255,180,${Math.max(0.04, a)})` : this._wlColor(λ_nm, Math.max(0.04, a));
        ctx.lineWidth = 1;
        ctx.beginPath();
        ctx.arc(src.x, src.y, r, -Math.PI / 2, Math.PI / 2);
        ctx.stroke();
      }
      ctx.restore();
    });
  }

  _drawScreen(screenX, simH, pts, λ_nm, wl) {
    const ctx = this._ctx;
    const bw = 16; // bar width

    /* glow pass for bright fringes */
    if (window.LabFX && window.LabFX.glow) {
      /* simple manual glow: shadow blur */
    }

    ctx.save();
    /* background of screen area */
    ctx.fillStyle = '#08080f';
    ctx.fillRect(screenX - 1, 0, bw + 2, simH);

    pts.forEach(({ y_px, I, color }) => {
      const yy = Math.round(y_px + simH / 2);
      const col = color || (wl ? `rgba(255,255,200,${I.toFixed(3)})` : this._wlColor(λ_nm, I));
      ctx.fillStyle = col;
      ctx.fillRect(screenX, yy, bw, 2);

      /* glow for bright pixels */
      if (I > 0.55) {
        ctx.save();
        ctx.shadowColor = color || this._wlColor(λ_nm, 1);
        ctx.shadowBlur  = 8 + I * 10;
        ctx.fillStyle = col;
        ctx.fillRect(screenX + 1, yy, bw - 2, 2);
        ctx.restore();
      }
    });
    ctx.restore();
  }

  _drawGraph(x0, y0, W, H, pts, λ_nm, wl) {
    const ctx = this._ctx;
    const padL = 38, padR = 20, padT = 8, padB = 18;
    const gx = x0 + padL, gy = y0 + padT;
    const gw = W - padL - padR, gh = H - padT - padB;

    /* graph background */
    ctx.save();
    ctx.fillStyle = '#080810';
    ctx.fillRect(x0, y0, W, H);
    ctx.fillStyle = '#0d0d1e';
    ctx.fillRect(gx, gy, gw, gh);

    /* axes */
    ctx.strokeStyle = '#2a2a50';
    ctx.lineWidth = 1;
    ctx.beginPath(); ctx.moveTo(gx, gy); ctx.lineTo(gx, gy + gh); ctx.lineTo(gx + gw, gy + gh); ctx.stroke();
    /* y-axis label */
    ctx.save();
    ctx.translate(x0 + 12, gy + gh / 2);
    ctx.rotate(-Math.PI / 2);
    ctx.fillStyle = '#666';
    ctx.font = '9px sans-serif';
    ctx.textAlign = 'center';
    ctx.fillText('I(y)', 0, 0);
    ctx.restore();

    /* intensity curve */
    const maxI = pts.reduce((m, p) => Math.max(m, p.I), 0) || 1;
    ctx.beginPath();
    let first = true;
    pts.forEach(({ y_px, I }) => {
      const px = gx + (y_px / (this._H / 2) + 1) / 2 * gw;
      const py = gy + gh - (I / maxI) * gh * 0.92;
      if (first) { ctx.moveTo(px, py); first = false; } else ctx.lineTo(px, py);
    });
    ctx.strokeStyle = wl ? '#ffe066' : this._wlColor(λ_nm, 0.9);
    ctx.lineWidth = 1.5;
    ctx.stroke();

    /* axis label */
    ctx.fillStyle = '#555';
    ctx.font = '9px sans-serif';
    ctx.textAlign = 'center';
    ctx.fillText('y', gx + gw / 2, gy + gh + padB - 4);
    ctx.textAlign = 'right';
    ctx.fillText('1', gx - 3, gy + gh * 0.08);
    ctx.restore();
  }

  _drawHUDLine(ctx, W, H, text) {
    ctx.save();
    ctx.fillStyle = 'rgba(10,10,22,0.7)';
    ctx.fillRect(4, H - 22, W - 8, 18);
    ctx.fillStyle = '#9ad8ff';
    ctx.font = '11px monospace';
    ctx.textAlign = 'left';
    ctx.fillText(text, 10, H - 7);
    ctx.restore();
  }

  _drawLabel(ctx, x, y, text) {
    ctx.save();
    ctx.fillStyle = '#666';
    ctx.font = '10px sans-serif';
    ctx.textAlign = 'center';
    ctx.fillText(text, x, y);
    ctx.restore();
  }

  /* color helpers */
  _wlColor(nm, alpha) {
    const [r, g, b] = this._wlRGB(nm);
    return `rgba(${r},${g},${b},${(alpha || 1).toFixed(3)})`;
  }

  _wlRGB(nm) {
    const c = wavelengthToRGB(nm);
    /* wavelengthToRGB returns 'rgb(r,g,b)' — parse it */
    const m = c.match(/(\d+),\s*(\d+),\s*(\d+)/);
    if (m) return [+m[1], +m[2], +m[3]];
    return [200, 200, 200];
  }

  _updateHUD() {
    /* update stats bar */
    const λ_nm = window._obWavelength || 550;
    const λ    = λ_nm * 1e-9;
    const el = id => document.getElementById(id);
    if (this._sub === 'young') {
      const d = this._d_young * 1e-6, L = this._L_young;
      const dy = (λ * L / d * 1e3).toFixed(3);
      if (el('diffbar-info')) el('diffbar-info').textContent = `Δy = ${dy} мм`;
    } else if (this._sub === 'single') {
      const a = this._a_single * 1e-6;
      const ang = (2 * λ / a * 180 / Math.PI).toFixed(2);
      if (el('diffbar-info')) el('diffbar-info').textContent = `2λ/a = ${ang}°`;
    } else if (this._sub === 'grating') {
      const R = this._N_grating * 1;
      if (el('diffbar-info')) el('diffbar-info').textContent = `R = Nn = ${R}`;
    }
    if (el('diffbar-sub')) el('diffbar-sub').textContent = {
      young: 'Юнг',
      single: 'Однощелевая',
      grating: 'Решётка'
    }[this._sub] || '';
    if (el('diffbar-wl')) el('diffbar-wl').textContent = (window._obWhiteLight ? 'Белый' : λ_nm + ' нм');
  }
}

/* ── DiffractionSim singleton + UI wiring ── */

var diffrSim = null;

function diffrSwitchSub(sub) {
  if (window.LabFX) LabFX.sound.play('chime', { volume: 0.4 });
  /* button styling */
  ['young', 'single', 'grating'].forEach(s => {
    const btn = document.getElementById('diffr-sub-' + s);
    if (btn) btn.classList.toggle('active', s === sub);
  });
  /* param panel visibility */
  ['young', 'single', 'grating'].forEach(s => {
    const pnl = document.getElementById('ob-diffr-' + s + '-params');
    if (pnl) pnl.style.display = s === sub ? '' : 'none';
  });
  if (diffrSim) {
    diffrSim.setSub(sub);
    diffrSim._updateHUD();
  }
}

function diffrParam(name, val) {
  /* update value label */
  const labelId = 'diffr-' + name.replace('_', '-') + '-val';
  const lbl = document.getElementById(labelId);
  if (lbl) {
    const v = parseFloat(val);
    /* N_grating → integer; L/d_grating/a_grating → 1 decimal; others → 0 */
    const decimals = name.includes('N_') ? -1 : (name === 'L_young' || name === 'd_grating' || name === 'a_grating') ? 1 : 0;
    lbl.textContent = decimals < 0 ? Math.round(v) : v.toFixed(decimals);
  }
  if (diffrSim) diffrSim.setParam(name, val);
}

function diffrReset() {
  if (!diffrSim) return;
  diffrSim._d_young   = 40;
  diffrSim._L_young   = 1.0;
  diffrSim._a_single  = 80;
  diffrSim._N_grating = 10;
  diffrSim._d_grating = 2.0;
  diffrSim._a_grating = 0.5;
  /* reset sliders */
  ['d-young', 'L-young', 'a-single', 'N-grating', 'd-grating', 'a-grating'].forEach(k => {
    const sl = document.getElementById('sl-diffr-' + k);
    if (sl) {
      const defaults = {
        'd-young': 40, 'L-young': 10, 'a-single': 80,
        'N-grating': 10, 'd-grating': 20, 'a-grating': 5
      };
      if (defaults[k] !== undefined) sl.value = defaults[k];
    }
    const lbl = document.getElementById('diffr-' + k + '-val');
    if (lbl) {
      const dv = { 'd-young': 40, 'L-young': '1.0', 'a-single': 80, 'N-grating': 10, 'd-grating': '2.0', 'a-grating': '0.5' };
      if (dv[k] !== undefined) lbl.textContent = dv[k];
    }
  });
  diffrSim.draw();
  diffrSim._updateHUD();
}
