Files
Learn_System/frontend/js/labs/opticsbench.js
T
Maxim Dolgolyov 2a8011d68e feat(labs): opticsbench round 1 — instruments + aberrations + dispersion + chain
9 готовых пресетов приборов (OB_PRESETS):
- Лупа, Микроскоп, Телескопы Кеплера/Галилея, Камера, Перископ, Проектор
- Световод (TIR), Согнутая ложка в воде
- HUD с подписью на 5 сек при загрузке + chime/whoosh sounds

ThinLensSim — стрелка-объект + анимация 3 главных лучей:
- Slider высоты объекта h_o, расчёт h_i и Г с учётом знака
- Real (cyan) vs Virtual (pink, dashed) image
- Кнопка «Построить лучи» → tween (easeOutCubic) по 500мс каждый
- Финальный chime при сходимости

ThinLensSim — формула lensmaker (R₁, R₂, n):
- Toggle «Подробный / Простой» переключает между f-слайдером и R₁/R₂/n
- Вычисление f и диоптрий D=1000/f
- Силуэт линзы динамически меняется (биконвекс/мениск/...)

MirrorSim — переменная кривизна R:
- Slider R: -250..+250 (signed, convex/concave/flat)
- Toggle «Параболическое / Сферическое» → 5-ray aberration fan
- На спherической краевые лучи разъезжаются; на параболе — точечный фокус

FreeBuildSim — multi-lens chain (новый класс):
- Каскадный расчёт изображений: image_n становится object_(n+1)
- F_sys = f1·f2 / (f1+f2-d), общее Г = Г1·Г2·...
- 3 ray tracing через всю цепочку
- 3 пресета: микроскоп / телескоп / relay
- Новая вкладка «Цепочка линз»

ThinLensSim — сферическая и хроматическая аберрации:
- Toggle «Сферическая»: 5 параллельных лучей с f_eff(h) = f - h²/(2f), spread видно
- Toggle «Хроматическая»: 3 bundle R/G/B с f×{1.02,1.0,0.98}, focal spread метки

Wavelength slider 380–780 нм:
- wavelengthToRGB() — sRGB-приближение CIE
- Цвет лучей применён во всех 3 модулях (lens/mirror/refraction)
- Toggle «Белый свет» — 3 RGB bundle с физически корректным n(λ) сдвигом
- n(λ) = 1.55 - 0.0002*(λ-550) — линейная дисперсионная модель

PrismSim — призма (новый класс):
- Равносторонняя стеклянная призма, draggable + rotatable
- Double-Snell на двух гранях, n(λ) → веер радуги при белом свете
- Новая вкладка «Призма»

Спектрометр-панель:
- 280×80 панель с rainbow gradient 380–780 nm
- Маркер текущей длины волны + точки выхода после призмы
- Авто-показ в режиме призмы

Все добавления additive — ни один из существующих 4 режимов не сломан.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 12:16:39 +03:00

3176 lines
139 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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, 380780) 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' },
];
/* ─────────────────────────────────────────────────────────────
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
if (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 });
}
}
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
if (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 });
}
}
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;
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);
}
/* 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;
const tangDir = { x: efVec.x / efLen, y: efVec.y / efLen };
const incRad = this.incAngle * Math.PI / 180;
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;
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);
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 + ((1/nP) * cosI - cosR) * efNorm.x,
y: (1/nP) * incDir.y + ((1/nP) * cosI - cosR) * 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);
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';
});
}
}
/* ─────────────────────────────────────────────────────────────
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 _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');
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() : {}) };
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'].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'].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'].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'];
const modeCanvas = { lens: 'ob-lens-canvas', mirror: 'ob-mirror-canvas', refraction: 'ob-refr-canvas', prism: 'ob-prism-canvas', freebuild: 'ob-free-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);
}
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()); }
}
}
/* ── 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();
}
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(); }
_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 });
}
/* ── 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) : '—';
}