'use strict';
/* ══════════════════════════════════════════════════════════════
   MirrorSim v3
   Flat / Concave / Convex  ·  1/f = 1/d + 1/d'  ·  M = -d'/d
   Features: fan rays, normals, angle arcs, ray labels ①②③,
   center C, zones, grid, photon animation, step mode,
   speed control, point mode, drag image, hover tooltips,
   mirror transition, unified infobox, legend, export PNG
   ══════════════════════════════════════════════════════════════ */

class MirrorSim {
  constructor(canvas) {
    this.canvas = canvas;
    this.ctx    = canvas.getContext('2d');
    this.W = 0; this.H = 0;

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

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

    // step mode (-1 = all, 0..3 = progressive)
    this._step = -1;

    // display toggles
    this._showGrid    = false;
    this._showZones   = true;
    this._showNormals = true;
    this._showDims    = true;
    this._showAngles  = true;
    this._showPhotons = true;
    this._pointMode   = false;

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

    // mirror transition
    this._prevType = 'concave';
    this._transT   = 1.0;
    this._transRaf = null;

    // drag & hover
    this._drag   = null;
    this._hoverX = -999;
    this._hoverY = -999;

    // callbacks
    this.onUpdate  = null;
    this.onAnimate = null;

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

  /* ── Public API ──────────────────────────────── */

  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',
    };
    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();
  }

  /* ── Physics ─────────────────────────────────── */

  _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()); }

  /* ── Mirror transition ───────────────────────── */

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

  /* ── Object animation ────────────────────────── */

  _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());
  }

  /* ── Photon system ───────────────────────────── */

  _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(); // animation loop handles draw when playing
    this._photonRaf = requestAnimationFrame(() => this._photonLoop());
  }

  /* ── Main draw ───────────────────────────────── */

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

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

    if (this._showGrid)  this._drawGrid(ctx);
    if (this._showZones) this._drawZones(ctx, mx);

    /* 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([]);

    /* fan rays */
    this._drawFanRays(ctx, mx, ay, f, dPrime, hPrime, showRay, showFill);

    /* mirror */
    this._drawMirror(ctx, mx, ay);

    /* focal pts + C */
    if (this.type !== 'flat') {
      this._drawFocalPoints(ctx, mx, ay, f);
      this._drawCenterC(ctx, mx, ay, f);
    }

    /* normals */
    if (this._showNormals && this.type !== 'flat' && (step === -1 || step >= 3))
      this._drawNormals(ctx, mx, ay, f);

    /* angle arcs (only in full view) */
    if (this._showAngles && this.type !== 'flat' && step === -1)
      this._drawAngleArcs(ctx, mx, ay, f);

    /* ray labels */
    if (step === -1 || step >= 1)
      this._drawRayLabels(ctx, mx, ay, f, step);

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

    /* image */
    if (dPrime !== null && isFinite(dPrime)) {
      const imgX = mx - dPrime;
      const 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);
      }
    }

    /* dims */
    if (this._showDims && (step === -1 || step >= 3))
      this._drawDimensions(ctx, mx, ay, f, dPrime, hPrime);

    /* infobox */
    this._drawInfoBox(ctx, f, dPrime);

    /* badge */
    if ((step === -1 || step >= 3) && dPrime !== null)
      this._drawImageBadge(ctx, dPrime, hPrime);

    /* critical marker */
    this._drawCriticalMarker(ctx, f);

    /* legend */
    if (this._showDims) this._drawLegend(ctx);

    /* photons */
    if (this._showPhotons && this._photons.length)
      this._drawPhotons(ctx);

    /* tooltip */
    this._drawTooltip(ctx, mx, ay, f, dPrime, hPrime);

    /* step overlay */
    if (step >= 0) this._drawStepOverlay(ctx, step);
  }

  /* ── Grid & Zones ────────────────────────────── */

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

  /* ── Mirror surface ──────────────────────────── */

  _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();
  }

  /* ── Focal points ────────────────────────────── */

  _drawFocalPoints(ctx, mx, ay, f) {
    const behind = f < 0;
    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 = behind ? '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);
    }
  }

  /* ── Center of curvature C ───────────────────── */

  _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();
  }

  /* ── Fan rays ────────────────────────────────── */

  _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;
    const objY   = ay - (this._pointMode ? 0 : h);
    const COLS   = ['#06D6E0','#7BF5A4','#FFD166'];
    const FAN    = '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;
    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();
  }

  /* ── Normals ─────────────────────────────────── */

  _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();
  }

  /* ── Angle arcs ──────────────────────────────── */

  _drawAngleArcs(ctx, mx, ay, f) {
    if (!isFinite(f)) return;
    const { d, h } = this;
    const hitY = ay - h; // use ray 1 hit point
    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);           // toward C
    const normOuter  = normInward + Math.PI;          // outward normal
    const incDir     = Math.atan2(hitY-(ay-h), mx-(mx-d)); // incident FROM object
    const incFrom    = incDir + Math.PI;               // direction FROM mirror to object

    const r = 14;
    ctx.save();
    ctx.lineWidth = 1;

    // arc on incident side
    ctx.strokeStyle = 'rgba(6,214,224,0.45)';
    ctx.beginPath();
    let a1 = normOuter, a2 = incFrom;
    // normalize so arc goes the short way
    ctx.arc(mx, hitY, r, a1, a2, false);
    ctx.stroke();

    // θ label
    ctx.fillStyle = 'rgba(6,214,224,0.7)';
    ctx.font = '9px Manrope, system-ui, sans-serif';
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    const mid = (a1+a2)/2;
    ctx.fillText('θ', mx+Math.cos(mid)*(r+9), hitY+Math.sin(mid)*(r+9));

    ctx.restore();
  }

  /* ── Ray labels ①②③ ──────────────────────────── */

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

  /* ── Arrow ───────────────────────────────────── */

  _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();
  }

  /* ── Dimension annotations ───────────────────── */

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

  /* ── Unified info box ────────────────────────── */

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

  /* ── Image badge ─────────────────────────────── */

  _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}  ×${Math.abs(+info.M).toFixed(2)}`, bx+57, by+42);
      }
    }
  }

  /* ── Critical marker ─────────────────────────── */

  _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();
  }

  /* ── Legend ──────────────────────────────────── */

  _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();
  }

  /* ── Photon drawing ──────────────────────────── */

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

  /* ── Hover tooltip ───────────────────────────── */

  _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();
  }

  /* ── Step overlay ────────────────────────────── */

  _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();
  }

  /* ── Events ──────────────────────────────────── */

  _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(); // redraw for tooltip
      }
    });

    window.addEventListener('mouseup', () => { 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; });
  }
}

/* ─── lab UI init ─────────────────────────────────── */
  var mirrorSim = null;

  function _openMirror() {
    document.getElementById('sim-topbar-title').textContent = 'Зеркала';
    _simShow('sim-mirrors');
    _registerSimState('mirrors', () => mirrorSim?.getParams(), st => mirrorSim?.setParams(st));
    if (_embedMode) _startStateEmit('mirrors');
    requestAnimationFrame(() => requestAnimationFrame(() => {
      if (!mirrorSim) {
        mirrorSim = new MirrorSim(document.getElementById('mirror-canvas'));
        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();
    }));
  }

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

  /* ── isoprocesses ── */

