Files
Learn_System/frontend/js/labs/opticsbench.js
T
Maxim Dolgolyov 81d4c15442 feat(opticsbench): учебное построение характеристических лучей
Для «Предмет» + «Характ. лучи» (один предмет, одна линза):
- подписи лучей 1/2/3 у предмета
- точка изображения = пересечение финальных отрезков лучей 1 и 2
- стрелка-изображение (основание на оси → вершина в точке изображения)
- мнимое изображение: пунктирные продления расходящихся лучей назад к
  мнимой точке (слева от линзы); подпись «изображение»/«мнимое изобр.»
- проверено численно: предмет за 2F → реальное справа, внутри F → мнимое слева
- bump opticsbench.js?v=10

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:33:46 +03:00

5263 lines
224 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' },
];
/* ─────────────────────────────────────────────────────────────
0b. OB_FX — VISUAL DEPTH TOGGLES (A-agent)
Five optional visual layers controlled by checkboxes.
State persisted in localStorage as 'ob_fx_state'.
───────────────────────────────────────────────────────────────*/
function _obFXLoad() {
try {
const raw = localStorage.getItem('ob_fx_state');
if (raw) Object.assign(window.OB_FX, JSON.parse(raw));
} catch (_e) { /* ignore */ }
}
function _obFXSave() {
try { localStorage.setItem('ob_fx_state', JSON.stringify(window.OB_FX)); } catch (_e) { /* ignore */ }
}
/** Called by each toggle checkbox */
function obFXToggle(key, val) {
window.OB_FX[key] = !!val;
_obFXSave();
_obRedraw();
}
window.OB_FX = { wavefronts: false, mist: false, flare: false, huygens: false, caustics: false };
_obFXLoad();
/* ── Mist: background smoke-particle emitter ── */
let _obMistRaf = 0, _obMistCtx = null, _obMistFrame = 0;
function _obMistTick() {
if (!window.OB_FX.mist) { _obMistRaf = 0; return; }
const ctx = _obMistCtx;
if (!ctx || !window.LabFX) { _obMistRaf = 0; return; }
_obMistFrame++;
if (_obMistFrame % 2 === 0) {
const W = ctx.canvas.width, H = ctx.canvas.height;
LabFX.particles.emit({
ctx, x: Math.random() * W, y: Math.random() * H,
count: 1, color: 'rgba(180,200,255,0.06)', speed: 3,
spread: Math.PI * 2, life: 3000, shape: 'smoke', size: 30, glow: false,
});
}
_obMistRaf = requestAnimationFrame(_obMistTick);
}
function _obStartMist(ctx) {
_obMistCtx = ctx;
if (!_obMistRaf && window.OB_FX.mist && window.LabFX) {
_obMistRaf = requestAnimationFrame(_obMistTick);
}
}
/* ── Shared wavefront phase clock ── */
let _obWFPhase = 0, _obWFLastT = 0;
/**
* Draw animated sinusoidal wavefront ticks along a ray segment.
* Ticks march along the ray to simulate a travelling wave.
* In-glass spacing compresses by 1/nMed (shows refraction at boundary).
*/
function _obDrawWavefrontTicks(ctx, x1, y1, x2, y2, nm, nMed) {
nMed = nMed || 1;
const dx = x2 - x1, dy = y2 - y1;
const len = Math.hypot(dx, dy);
if (len < 4) return;
const lambdaPx = Math.max(8, (nm * 0.45) / nMed);
const ux = dx / len, uy = dy / len;
const qx = -uy, qy = ux; // perpendicular unit vector
const tickLen = 5;
const col = wavelengthToRGB(nm || 550);
const phaseOff = (_obWFPhase * lambdaPx * 2) % lambdaPx;
ctx.save();
ctx.globalAlpha = 0.52;
ctx.strokeStyle = col; ctx.lineWidth = 1;
let t = phaseOff;
while (t < len) {
const cx = x1 + ux * t, cy = y1 + uy * t;
ctx.beginPath();
ctx.moveTo(cx - qx * tickLen, cy - qy * tickLen);
ctx.lineTo(cx + qx * tickLen, cy + qy * tickLen);
ctx.stroke();
t += lambdaPx;
}
ctx.restore();
}
/**
* Draw lens flare: starburst + ghost reflections + chromatic ring.
* Uses additive blending for the glow effect.
*/
function _obDrawLensFlare(ctx, srcX, srcY, W, H) {
ctx.save();
ctx.globalCompositeOperation = 'lighter';
const spikes = 6, spikeLen = 40;
for (let i = 0; i < spikes; i++) {
const angle = (i / spikes) * Math.PI + _obWFPhase * 0.3;
const grad = ctx.createLinearGradient(srcX, srcY,
srcX + Math.cos(angle) * spikeLen, srcY + Math.sin(angle) * spikeLen);
grad.addColorStop(0, 'rgba(255,255,200,0.32)');
grad.addColorStop(1, 'rgba(255,255,200,0)');
ctx.strokeStyle = grad; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(srcX, srcY);
ctx.lineTo(srcX + Math.cos(angle) * spikeLen, srcY + Math.sin(angle) * spikeLen);
ctx.stroke();
ctx.beginPath(); ctx.moveTo(srcX, srcY);
ctx.lineTo(srcX - Math.cos(angle) * spikeLen, srcY - Math.sin(angle) * spikeLen);
ctx.stroke();
}
const cg = ctx.createRadialGradient(srcX, srcY, 0, srcX, srcY, 14);
cg.addColorStop(0, 'rgba(255,255,240,0.55)');
cg.addColorStop(1, 'rgba(255,255,240,0)');
ctx.fillStyle = cg; ctx.beginPath(); ctx.arc(srcX, srcY, 14, 0, Math.PI * 2); ctx.fill();
const gx = W / 2 - srcX, gy = H / 2 - srcY;
[
{ t: 0.3, r: 18, col: 'rgba(100,80,255,0.12)' },
{ t: 0.6, r: 11, col: 'rgba(255,60,60,0.10)' },
{ t: 1.1, r: 7, col: 'rgba(60,220,255,0.09)' },
].forEach(function(g) {
const gX = srcX + gx * g.t, gY = srcY + gy * g.t;
const gg = ctx.createRadialGradient(gX, gY, 0, gX, gY, g.r);
gg.addColorStop(0, g.col); gg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = gg; ctx.beginPath(); ctx.arc(gX, gY, g.r, 0, Math.PI * 2); ctx.fill();
});
ctx.globalAlpha = 0.07 + 0.04 * Math.sin(_obWFPhase);
ctx.strokeStyle = 'rgba(200,150,255,0.5)'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(srcX, srcY, 22 + 4 * Math.sin(_obWFPhase * 0.5), 0, Math.PI * 2); ctx.stroke();
ctx.restore();
}
/**
* Draw Huygens wavelets at a refraction/reflection boundary.
* 5 sample points along wavefront emit expanding semi-circular arcs.
*/
function _obDrawHuygens(ctx, bx, by, normalAngle, kind) {
const t = _obWFPhase % 1;
const maxR = 28, nSamples = 5;
ctx.save();
ctx.globalAlpha = 0.22;
for (let i = 0; i < nSamples; i++) {
const offset = (i - (nSamples - 1) / 2) * 12;
const wx = bx + Math.cos(normalAngle + Math.PI / 2) * offset;
const wy = by + Math.sin(normalAngle + Math.PI / 2) * offset;
const r = t * maxR;
const sa = kind === 'reflection'
? normalAngle - Math.PI / 2
: normalAngle + Math.PI / 2;
ctx.strokeStyle = 'rgba(180,220,255,0.7)'; ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(wx, wy, r, sa - Math.PI / 2, sa + Math.PI / 2);
ctx.stroke();
}
ctx.restore();
}
/**
* Draw caustic curve under a lens by tracing ~20 parallel rays from infinity.
* Each ray is slightly shifted by spherical aberration creating an extended caustic.
*/
function _obDrawCausticCurve(ctx, lx, ay, f, H) {
if (!isFinite(f) || f <= 0) return;
const lensH = Math.min(H * 0.38, 140);
const nRays = 20;
const pts = [];
for (let i = 0; i < nRays; i++) {
const yIn = -lensH + (2 * lensH * i) / (nRays - 1);
const abFac = 1 - 0.0008 * (yIn / lensH) * (yIn / lensH) * Math.min(Math.abs(f), 150);
const fEff = f * abFac;
const sl = -yIn / fEff;
if (Math.abs(sl) < 0.0001) continue;
const tCross = -yIn / sl;
if (tCross < 0 || tCross > 600) continue;
pts.push({ x: lx + tCross, y: ay });
}
if (pts.length < 2) return;
const pulse = 0.55 + 0.35 * Math.sin(_obWFPhase * Math.PI * 2);
ctx.save();
ctx.globalAlpha = pulse;
ctx.globalCompositeOperation = 'lighter';
ctx.strokeStyle = '#FFD166'; ctx.lineWidth = 2;
ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 6;
ctx.beginPath();
pts.forEach(function(p, idx) { idx === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y); });
ctx.stroke();
ctx.restore();
}
/**
* Unified OB_FX layer — called at end of each sim draw() BEFORE particles.draw().
* @param {CanvasRenderingContext2D} ctx
* @param {string} simType 'lens'|'mirror'|'refraction'|'prism'|'freebuild'
* @param {object} [extras] {rays[], srcX, srcY, boundary, causticParams}
*/
function _drawOBFXLayer(ctx, simType, extras) {
const now = performance.now();
if (_obWFLastT) _obWFPhase += (now - _obWFLastT) / 1000 * 0.5;
_obWFLastT = now;
if (window.OB_FX.mist) _obStartMist(ctx);
if (!extras) return;
if (window.OB_FX.wavefronts && extras.rays) {
const nm = window._obWavelength || 550;
extras.rays.forEach(function(r) {
_obDrawWavefrontTicks(ctx, r.x1, r.y1, r.x2, r.y2, nm, r.n || 1);
});
}
if (window.OB_FX.flare && extras.srcX !== undefined) {
_obDrawLensFlare(ctx, extras.srcX, extras.srcY, ctx.canvas.width, ctx.canvas.height);
}
if (window.OB_FX.huygens && extras.boundary) {
const b = extras.boundary;
_obDrawHuygens(ctx, b.bx, b.by, b.normalAngle, b.kind);
}
if (window.OB_FX.caustics && extras.causticParams) {
const cp = extras.causticParams;
_obDrawCausticCurve(ctx, cp.lx, cp.ay, cp.f, ctx.canvas.height);
}
}
/* ─────────────────────────────────────────────────────────────
1. THIN LENS ENGINE (from thinlens.js)
───────────────────────────────────────────────────────────────*/
class ThinLensSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
this.f = 100;
this.d = 200;
this.h = 50;
this._drag = null;
this.onUpdate = null;
/* aberration toggles (Agent OB-A3) */
this._aberrSpherical = false;
this._aberrChromatic = false;
/* ── Lens-maker formula params (Feature 3) ── */
this._lmSimple = true; // true = simple f-slider mode; false = R1/R2/n mode
this._lmR1 = 100; // front surface radius (mm)
this._lmR2 = -100; // back surface radius (mm)
this._lmN = 1.5; // refractive index
/* ── Animated ray construction (Feature 1) ── */
this._rayAnimT = [1, 1, 1]; // progress of each of the 3 rays [0..1]
this._rayTweens = []; // active tween handles
this._bindEvents();
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
fit() {
const dpr = window.devicePixelRatio || 1;
const w = this.canvas.offsetWidth || 600;
const h = this.canvas.offsetHeight || 400;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = w; this.H = h;
}
getParams() { return { f: this.f, d: this.d, h: this.h }; }
setParams({ f, d, h } = {}) {
if (f !== undefined) this.f = Math.max(-200, Math.min(200, +f));
if (d !== undefined) this.d = Math.max(30, Math.min(400, +d));
if (h !== undefined) this.h = Math.max(20, Math.min(80, +h));
this.draw();
this._emit();
}
reset() {
this.f = 100; this.d = 200; this.h = 50;
this.draw();
this._emit();
}
/* toggle aberration modes (Agent OB-A3) */
setAberration(type, on) {
if (type === 'spherical') this._aberrSpherical = !!on;
if (type === 'chromatic') this._aberrChromatic = !!on;
this.draw();
this._emit();
}
/* ═══ Lens-maker formula (Feature 3) ════════════════════════════ */
_lmCalcF() {
const R1 = this._lmR1, R2 = this._lmR2, n = this._lmN;
const inv1 = Math.abs(R1) > 0.5 ? 1 / R1 : 0;
const inv2 = Math.abs(R2) > 0.5 ? 1 / R2 : 0;
const invF = (n - 1) * (inv1 - inv2);
if (Math.abs(invF) < 1e-6) return 9999;
return 1 / invF;
}
setLensMode(simple) {
this._lmSimple = !!simple;
if (!simple) {
const k = this.f * (this._lmN - 1);
if (Math.abs(k) > 1) { this._lmR1 = k * 2; this._lmR2 = -k * 2; }
}
this.draw(); this._emit();
}
setLMParam(name, val) {
const v = +val;
if (name === 'R1') this._lmR1 = Math.max(-300, Math.min(300, v));
else if (name === 'R2') this._lmR2 = Math.max(-300, Math.min(300, v));
else if (name === 'n') this._lmN = Math.max(1.3, Math.min(2.4, v));
if (!this._lmSimple) this.f = Math.max(-200, Math.min(200, this._lmCalcF()));
this.draw(); this._emit();
}
_lmShapeName() {
const R1 = this._lmR1, R2 = this._lmR2;
const flat1 = Math.abs(R1) > 280, flat2 = Math.abs(R2) > 280;
if (flat1 && flat2) return 'плоскопараллельная';
if (R1 > 0 && R2 < 0) return 'двояковыпуклая';
if (R1 < 0 && R2 > 0) return 'двояковогнутая';
if (flat1 || flat2) return 'плоско-выпуклая / вогнутая';
return 'мениск';
}
_drawLensLM(ctx, lx, ay) {
const lensH = Math.min(this.H * 0.38, 140);
const R1 = this._lmR1, R2 = this._lmR2;
const flat1 = Math.abs(R1) > 280, flat2 = Math.abs(R2) > 280;
const b1 = flat1 ? 0 : Math.max(-20, Math.min(20, lensH * lensH / (2 * R1)));
const b2 = flat2 ? 0 : Math.max(-20, Math.min(20, lensH * lensH / (2 * -R2)));
ctx.strokeStyle = 'rgba(155,93,229,0.85)'; ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.moveTo(lx + b1, ay - lensH); ctx.quadraticCurveTo(lx + b1 * 2, ay, lx + b1, ay + lensH); ctx.stroke();
ctx.beginPath(); ctx.moveTo(lx + b2, ay - lensH); ctx.quadraticCurveTo(lx + b2 * 2, ay, lx + b2, ay + lensH); ctx.stroke();
ctx.strokeStyle = 'rgba(155,93,229,0.4)'; ctx.lineWidth = 1.2;
ctx.beginPath(); ctx.moveTo(lx + b1, ay - lensH); ctx.lineTo(lx + b2, ay - lensH); ctx.stroke();
ctx.beginPath(); ctx.moveTo(lx + b1, ay + lensH); ctx.lineTo(lx + b2, ay + lensH); ctx.stroke();
ctx.strokeStyle = 'rgba(155,93,229,0.2)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.lineTo(lx, ay + lensH); ctx.stroke();
if (this.f > 0) { this._lensArrow(ctx, lx, ay - lensH, -1); this._lensArrow(ctx, lx, ay + lensH, 1); }
else { this._lensArrowDiv(ctx, lx, ay - lensH, -1); this._lensArrowDiv(ctx, lx, ay + lensH, 1); }
}
_drawLMInfo(ctx, lx, ay) {
if (this._lmSimple) return;
const f = this.f, D = Math.abs(f) > 0.5 ? (1000 / f).toFixed(2) : '---';
const n = this._lmN.toFixed(2);
const R1s = Math.abs(this._lmR1) > 280 ? 'inf' : this._lmR1.toFixed(0);
const R2s = Math.abs(this._lmR2) > 280 ? 'inf' : this._lmR2.toFixed(0);
const shape = this._lmShapeName();
const bx = lx + 20, by = 12, bw = 200, bh = 72;
ctx.fillStyle = 'rgba(13,13,26,0.88)';
ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 8); ctx.fill();
ctx.strokeStyle = 'rgba(155,93,229,0.3)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 8); ctx.stroke();
ctx.font = '10px Manrope, system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillStyle = 'rgba(155,93,229,0.9)'; ctx.fillText('Lensmaker', bx + 8, by + 7);
ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.fillText('R1=' + R1s + ' R2=' + R2s + ' n=' + n, bx + 8, by + 22);
ctx.fillStyle = '#06D6E0'; ctx.fillText('f = ' + (isFinite(f) ? f.toFixed(1) : '---') + ' mm', bx + 8, by + 38);
ctx.fillStyle = '#FFD166'; ctx.fillText('D = ' + D + ' dptr', bx + 104, by + 38);
ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText(shape, bx + 8, by + 54);
}
/* ═══ Animated 3-ray construction (Feature 1) ═══════════════════ */
buildRays() {
this._rayTweens.forEach(h => h && h.cancel && h.cancel());
this._rayTweens = [];
this._rayAnimT = [0, 0, 0];
const animate = (idx, onDone) => {
if (!window.LabFX) { this._rayAnimT[idx] = 1; this.draw(); if (onDone) onDone(); return; }
const h = LabFX.motion.tween(0, 1, 500, 'easeOutCubic',
t => { this._rayAnimT[idx] = t; this.draw(); }, onDone);
this._rayTweens[idx] = h;
};
animate(0, () => animate(1, () => animate(2, () => { if (window.LabFX) LabFX.sound.play('chime'); })));
}
resetRays() {
this._rayTweens.forEach(h => h && h.cancel && h.cancel());
this._rayTweens = [];
this._rayAnimT = [1, 1, 1];
this.draw();
}
info() {
const { f, d, h } = this;
const denom = d - f;
const dPrime = Math.abs(denom) < 0.01 ? Infinity : (f * d) / denom;
const M = Math.abs(denom) < 0.01 ? Infinity : -dPrime / d;
const hPrime = M === Infinity ? Infinity : M * h;
const isVirtual = dPrime < 0;
return {
f: +f.toFixed(1),
d: +d.toFixed(1),
dPrime: dPrime === Infinity ? Infinity : +dPrime.toFixed(1),
M: M === Infinity ? Infinity : +M.toFixed(3),
imageType: isVirtual ? 'мнимое' : 'действительное',
h: +h.toFixed(1),
hPrime: hPrime === Infinity ? Infinity : +Math.abs(hPrime).toFixed(1),
};
}
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
_toCanvas(sx, sy) { return { cx: this.W / 2 + sx, cy: this.H / 2 - sy }; }
_fromCanvas(cx, cy) { return { sx: cx - this.W / 2, sy: this.H / 2 - cy }; }
draw() {
const ctx = this.ctx, W = this.W, H = this.H;
if (!W || !H) return;
const { f, d, h } = this;
const lensX = W / 2;
const axisY = H / 2;
ctx.fillStyle = '#0D0D1A';
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 1;
ctx.setLineDash([6, 4]);
ctx.beginPath(); ctx.moveTo(0, axisY); ctx.lineTo(W, axisY); ctx.stroke();
ctx.setLineDash([]);
// Lens silhouette: detailed (LM) or simple mode
if (!this._lmSimple) this._drawLensLM(ctx, lensX, axisY);
else this._drawLens(ctx, lensX, axisY, f);
this._drawFocalPoints(ctx, lensX, axisY, f);
const objX = lensX - d;
this._drawArrow(ctx, objX, axisY, objX, axisY - h, '#9B5DE5', false);
const denom = d - f;
let dPrime, hPrime;
if (Math.abs(denom) < 0.5) {
dPrime = null; hPrime = null;
} else {
dPrime = (f * d) / denom;
hPrime = (-dPrime / d) * h;
}
/* aberrations override standard rays (Agent OB-A3) */
if (this._aberrSpherical) {
this._drawSphericalAberration(ctx, lensX, axisY, d, h, f);
} else if (this._aberrChromatic) {
this._drawChromaticAberration(ctx, lensX, axisY, d, h, f);
} else {
this._drawRaysAnimated(ctx, lensX, axisY, d, h, f, dPrime, hPrime);
}
if (!this._aberrSpherical && !this._aberrChromatic && dPrime !== null && isFinite(dPrime)) {
const isVirtual = dPrime < 0;
const imgX = lensX + dPrime;
// Feature 1: real=cyan, virtual=pink/dashed
this._drawArrow(ctx, imgX, axisY, imgX, axisY - hPrime,
isVirtual ? 'rgba(255,133,162,0.9)' : '#06D6E0', isVirtual);
}
this._drawLabels(ctx, lensX, axisY, d, f, dPrime, hPrime);
this._drawArrowLabels(ctx, lensX, axisY, d, h, dPrime, hPrime);
this._drawLMInfo(ctx, lensX, axisY);
// Lens caustics: emit dust near focal point when image exists (only when OB_FX.caustics is off)
if (!window.OB_FX.caustics && window.LabFX && dPrime !== null && isFinite(dPrime) && dPrime > 0) {
const imgX = lensX + dPrime;
if (!this._causticFrame) this._causticFrame = 0;
this._causticFrame++;
if (this._causticFrame % 4 === 0) {
LabFX.particles.emit({ ctx, x: imgX + (Math.random() - 0.5) * 10, y: axisY + (Math.random() - 0.5) * 10, count: 3, color: '#FFD166', speed: 8, spread: Math.PI * 2, life: 500, shape: 'dust', glow: true });
}
}
// OB_FX visual depth layer
{
const objX = lensX - d;
const fxExtras = {
srcX: objX, srcY: axisY - h,
causticParams: { lx: lensX, ay: axisY, f },
rays: [
{ x1: objX, y1: axisY - h, x2: lensX, y2: axisY - h },
{ x1: lensX, y1: axisY - h, x2: lensX + (dPrime || 200), y2: dPrime ? axisY - ((-dPrime / d) * h) : axisY },
],
};
_drawOBFXLayer(ctx, 'lens', fxExtras);
}
if (window.LabFX) { LabFX.particles.update(1 / 60); LabFX.particles.draw(ctx); }
}
_drawLens(ctx, lx, ay, f) {
const lensH = Math.min(this.H * 0.38, 140);
const converging = f > 0;
ctx.strokeStyle = 'rgba(155,93,229,0.8)';
ctx.lineWidth = 2.5;
if (converging) {
const bulge = Math.min(18, Math.abs(f) * 0.12);
ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.quadraticCurveTo(lx + bulge, ay, lx, ay + lensH); ctx.stroke();
ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.quadraticCurveTo(lx - bulge, ay, lx, ay + lensH); ctx.stroke();
this._lensArrow(ctx, lx, ay - lensH, -1);
this._lensArrow(ctx, lx, ay + lensH, 1);
} else {
const bulge = Math.min(14, Math.abs(f) * 0.1);
ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.quadraticCurveTo(lx - bulge, ay, lx, ay + lensH); ctx.stroke();
ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.quadraticCurveTo(lx + bulge, ay, lx, ay + lensH); ctx.stroke();
this._lensArrowDiv(ctx, lx, ay - lensH, -1);
this._lensArrowDiv(ctx, lx, ay + lensH, 1);
}
ctx.strokeStyle = 'rgba(155,93,229,0.3)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.lineTo(lx, ay + lensH); ctx.stroke();
}
_lensArrow(ctx, x, y, dir) {
const sz = 7;
ctx.fillStyle = 'rgba(155,93,229,0.8)';
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x - sz, y + dir * sz * 1.2); ctx.lineTo(x + sz, y + dir * sz * 1.2); ctx.closePath(); ctx.fill();
}
_lensArrowDiv(ctx, x, y, dir) {
const sz = 6;
ctx.fillStyle = 'rgba(155,93,229,0.8)';
ctx.beginPath(); ctx.moveTo(x - sz, y); ctx.lineTo(x, y - dir * sz); ctx.lineTo(x + sz, y); ctx.closePath(); ctx.fill();
}
_drawFocalPoints(ctx, lx, ay, f) {
const pts = [{ sx: f, label: "F'" }, { sx: -f, label: 'F' }, { sx: 2 * f, label: "2F'" }, { sx: -2 * f, label: '2F' }];
for (const p of pts) {
const px = lx + p.sx;
if (px < 10 || px > this.W - 10) continue;
const isFocal = !p.label.startsWith('2');
const r = isFocal ? 5 : 3.5;
const col = isFocal ? '#06D6E0' : 'rgba(6,214,224,0.5)';
ctx.fillStyle = col;
ctx.beginPath(); ctx.arc(px, ay, r, 0, Math.PI * 2); ctx.fill();
ctx.font = '11px Manrope, system-ui, sans-serif';
ctx.fillStyle = col; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(p.label, px, ay + 10);
}
}
_drawArrow(ctx, x1, y1, x2, y2, color, dashed) {
ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 2.5;
if (dashed) ctx.setLineDash([6, 4]);
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
if (dashed) ctx.setLineDash([]);
const angle = Math.atan2(y2 - y1, x2 - x1), aLen = 10;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - aLen * Math.cos(angle - 0.35), y2 - aLen * Math.sin(angle - 0.35));
ctx.lineTo(x2 - aLen * Math.cos(angle + 0.35), y2 - aLen * Math.sin(angle + 0.35));
ctx.closePath(); ctx.fill();
}
_drawRays(ctx, lx, ay, d, h, f, dPrime, hPrime) {
// Wavelength-aware coloring
const _wl = window._obWhiteLight;
const _wlNm = window._obWavelength || 550;
if (_wl) {
// White-light mode: draw 3 spectral bundles (R/G/B) with offset for chromatic spread
const bundles = [{ nm: 660, dOff: 0 }, { nm: 550, dOff: 0 }, { nm: 450, dOff: 0 }];
const n0 = 1.5; // default glass
bundles.forEach(b => {
const nc = _nAtWavelength(n0, b.nm);
// focal length shifts with n — thin lens: f ~ (n-1)*C → f(λ) = f * (n0-1)/(nc-1)
const fC = f * (n0 - 1) / (nc - 1);
const denomC = d - fC;
const dPC = Math.abs(denomC) < 0.5 ? null : (fC * d) / denomC;
const hPC = dPC !== null ? (-dPC / d) * h : null;
const col = wavelengthToRGB(b.nm);
ctx.save(); ctx.globalAlpha = 0.65;
this._drawRaysSingle(ctx, lx, ay, d, h, fC, dPC, hPC, col);
ctx.restore();
});
return;
}
const _monoColor = wavelengthToRGB(_wlNm);
const objX = lx - d, objY = ay - h;
const colors = [_monoColor, _monoColor, _monoColor];
const hasImage = dPrime !== null && isFinite(dPrime);
const isVirtual = hasImage && dPrime < 0;
ctx.lineWidth = 1.5;
const _doGlow = (color, fn) => {
if (window.LabFX) LabFX.glow.drawGlow(ctx, fn, { color, intensity: 8 });
else fn();
};
// Ray 1: parallel to axis
_doGlow(colors[0], () => {
ctx.strokeStyle = colors[0]; ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, objY); ctx.stroke();
if (hasImage) {
const imgX = lx + dPrime, imgY = ay - hPrime;
if (!isVirtual) {
ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(imgX, imgY); ctx.stroke();
this._extendRay(ctx, lx, objY, imgX, imgY, colors[0]);
} else {
const outSlope = (objY - ay) / f;
ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(lx + 300, objY + outSlope * 300); ctx.stroke();
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(imgX, imgY); ctx.stroke();
ctx.setLineDash([]);
}
}
});
// Ray 2: through center
_doGlow(colors[1], () => {
ctx.strokeStyle = colors[1]; ctx.setLineDash([]);
const slope = (objY - ay) / (objX - lx);
const farX = lx + 350, farY = ay + slope * 350;
ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(farX, farY); ctx.stroke();
if (isVirtual) {
const backX = lx - 350, backY = ay - slope * 350;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(lx, ay); ctx.lineTo(backX, backY); ctx.stroke();
ctx.setLineDash([]);
}
});
// Ray 3: through F
_doGlow(colors[2], () => {
ctx.strokeStyle = colors[2]; ctx.setLineDash([]);
const fx = lx - f, slope = (objY - ay) / (objX - fx);
const hitY = objY + slope * (lx - objX);
ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, hitY); ctx.stroke();
const endX = hasImage && !isVirtual ? Math.max(lx + dPrime + 60, lx + 300) : lx + 300;
ctx.beginPath(); ctx.moveTo(lx, hitY); ctx.lineTo(endX, hitY); ctx.stroke();
if (hasImage && isVirtual) {
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(lx, hitY); ctx.lineTo(lx + dPrime, ay - hPrime); ctx.stroke();
ctx.setLineDash([]);
}
});
}
/** Single-color version of _drawRays used internally for white-light chromatic bundles */
_drawRaysSingle(ctx, lx, ay, d, h, f, dPrime, hPrime, color) {
const objX = lx - d, objY = ay - h;
const hasImage = dPrime !== null && isFinite(dPrime);
const isVirtual = hasImage && dPrime < 0;
ctx.lineWidth = 1.5; ctx.strokeStyle = color; ctx.setLineDash([]);
// Ray 1: parallel to axis
ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, objY); ctx.stroke();
if (hasImage) {
const imgX = lx + dPrime, imgY = ay - hPrime;
if (!isVirtual) {
ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(imgX, imgY); ctx.stroke();
} else {
const outSlope = (objY - ay) / f;
ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(lx + 200, objY + outSlope * 200); ctx.stroke();
}
}
// Ray 2: through center
const slope2 = (objY - ay) / (objX - lx);
ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx + 250, ay + slope2 * 250); ctx.stroke();
// Ray 3: through F (brief version)
const fxOff = lx - f, slope3 = (objY - ay) / (objX - fxOff);
const hitY3 = objY + slope3 * (lx - objX);
ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, hitY3); ctx.stroke();
ctx.beginPath(); ctx.moveTo(lx, hitY3); ctx.lineTo(lx + 250, hitY3); ctx.stroke();
ctx.setLineDash([]);
}
_extendRay(ctx, x1, y1, x2, y2, color) {
const dx = x2 - x1, dy = y2 - y1, len = Math.hypot(dx, dy);
if (len < 1) return;
ctx.globalAlpha = 0.3; ctx.strokeStyle = color;
ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo(x2 + (dx / len) * 80, y2 + (dy / len) * 80); ctx.stroke();
ctx.globalAlpha = 1;
}
/* ── Spherical aberration (Agent OB-A3) ──────────────────────
5 parallel rays at different heights; f_eff(h) = f - h²/(2f).
Marginal rays focus closer than paraxial for a converging lens.
─────────────────────────────────────────────────────────────── */
_drawSphericalAberration(ctx, lx, ay, d, h, f) {
if (Math.abs(f) < 1) return;
const lensH = Math.min(this.H * 0.36, 130);
const fracs = [-0.9, -0.5, 0, 0.5, 0.9];
const pal = ['#FF6B6B', '#FFD166', '#7BF5A4', '#06D6E0', '#B8A4FF'];
const objX = lx - d;
ctx.save();
ctx.lineWidth = 1.4;
const focusPts = [];
fracs.forEach((fr, i) => {
const rayH = fr * lensH;
const fEff = f > 0 ? f - (rayH * rayH) / (2 * f) : f + (rayH * rayH) / (2 * Math.abs(f));
const denom = d - fEff;
if (Math.abs(denom) < 0.5) return;
const dPrEff = (fEff * d) / denom;
ctx.strokeStyle = pal[i];
ctx.globalAlpha = 0.8;
ctx.setLineDash([]);
const startY = ay - rayH;
ctx.beginPath(); ctx.moveTo(objX, startY); ctx.lineTo(lx, startY); ctx.stroke();
if (f > 0 && isFinite(dPrEff) && dPrEff > 0) {
const focX = lx + dPrEff;
ctx.beginPath(); ctx.moveTo(lx, startY); ctx.lineTo(focX, ay);
ctx.lineTo(focX + 70, ay + (startY - ay) * 0.5); ctx.stroke();
focusPts.push({ fEff, label: i === 2 ? 'парак.' : 'краев.' });
} else {
ctx.beginPath(); ctx.moveTo(lx, startY);
ctx.lineTo(lx + 280, startY + (ay - startY) * 0.6); ctx.stroke();
}
});
ctx.globalAlpha = 1;
ctx.restore();
if (focusPts.length >= 2) {
const fMin = Math.min(...focusPts.map(p => p.fEff)).toFixed(0);
const fMax = Math.max(...focusPts.map(p => p.fEff)).toFixed(0);
ctx.save();
const bx = 12, by = 70;
ctx.fillStyle = 'rgba(22,22,38,0.88)';
ctx.beginPath(); ctx.roundRect(bx, by, 224, 44, 8); ctx.fill();
ctx.font = '11px Manrope, system-ui, sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillStyle = '#FF6B6B'; ctx.fillText('Сферическая аберрация', bx + 8, by + 6);
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.fillText('f парак.=' + fMax + ' f краев.=' + fMin, bx + 8, by + 24);
ctx.restore();
}
}
/* ── Chromatic aberration (Agent OB-A3) ──────────────────────
f_R = f×1.02, f_G = f, f_B = f×0.98
Three ray bundles drawn in R/G/B colours converging to different points.
─────────────────────────────────────────────────────────────── */
_drawChromaticAberration(ctx, lx, ay, d, h, f) {
if (Math.abs(f) < 1) return;
const channels = [
{ scale: 1.02, color: '#FF4444', label: 'R' },
{ scale: 1.00, color: '#7BF5A4', label: 'G' },
{ scale: 0.98, color: '#4488FF', label: 'B' },
];
const objX = lx - d;
ctx.save();
ctx.lineWidth = 1.6;
const focusPts = [];
channels.forEach(ch => {
const fc = f * ch.scale;
const denom = d - fc;
if (Math.abs(denom) < 0.5) return;
const dPrEff = (fc * d) / denom;
const hPrEff = (-dPrEff / d) * h;
const hasImg = isFinite(dPrEff) && dPrEff > 0;
ctx.strokeStyle = ch.color;
ctx.globalAlpha = 0.78;
ctx.setLineDash([]);
// Ray 1: parallel to axis
ctx.beginPath(); ctx.moveTo(objX, ay - h); ctx.lineTo(lx, ay - h);
if (hasImg) { ctx.lineTo(lx + dPrEff, ay - hPrEff); }
ctx.stroke();
// Ray 2: through centre
const slopeC = h / d;
ctx.beginPath(); ctx.moveTo(objX, ay - h); ctx.lineTo(lx, ay);
if (hasImg) { ctx.lineTo(lx + dPrEff, ay - hPrEff); }
else { ctx.lineTo(lx + 280, ay - slopeC * 280); }
ctx.stroke();
if (hasImg) focusPts.push({ x: lx + dPrEff, label: ch.label, color: ch.color, f: fc });
});
ctx.globalAlpha = 1;
ctx.setLineDash([]);
// focal markers
focusPts.forEach(fp => {
ctx.fillStyle = fp.color;
ctx.beginPath(); ctx.arc(fp.x, ay, 5, 0, Math.PI * 2); ctx.fill();
ctx.font = '10px Manrope, system-ui, sans-serif';
ctx.fillStyle = fp.color; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText(fp.label + ' f=' + fp.f.toFixed(0), fp.x, ay - 10);
});
ctx.restore();
// info box
ctx.save();
const bx = 12, by = 70;
ctx.fillStyle = 'rgba(22,22,38,0.88)';
ctx.beginPath(); ctx.roundRect(bx, by, 224, 56, 8); ctx.fill();
ctx.font = '11px Manrope, system-ui, sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillStyle = '#4488FF'; ctx.fillText('Хроматическая аберрация', bx + 8, by + 6);
if (focusPts.length >= 2) {
const spread = Math.abs(focusPts[focusPts.length - 1].x - focusPts[0].x).toFixed(0);
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.fillText('Разброс фокусов: ' + spread + ' px', bx + 8, by + 22);
ctx.fillText('fR > fG > fB (красный фокус дальше)', bx + 8, by + 38);
}
ctx.restore();
}
_drawLabels(ctx, lx, ay, d, f, dPrime, hPrime) {
ctx.font = '12px Manrope, system-ui, sans-serif'; ctx.textBaseline = 'top';
const objX = lx - d;
ctx.fillStyle = '#9B5DE5'; ctx.textAlign = 'center';
ctx.fillText('d = ' + d.toFixed(0), (objX + lx) / 2, ay + 26);
ctx.fillStyle = '#06D6E0';
ctx.fillText('f = ' + f.toFixed(0), lx, ay + 42);
if (dPrime !== null && isFinite(dPrime)) {
const imgX = lx + dPrime;
ctx.fillStyle = dPrime > 0 ? '#EF476F' : '#FFD166'; ctx.textAlign = 'center';
ctx.fillText("d' = " + dPrime.toFixed(1), (lx + imgX) / 2, ay + 26);
}
const info = this.info();
const boxW = 200, boxH = 52, bx = 12, by = 12;
ctx.fillStyle = 'rgba(22,22,38,0.85)';
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill();
ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText("1/f = 1/d + 1/d'", bx + 10, by + 10);
ctx.fillStyle = 'rgba(255,255,255,0.5)';
const mStr = info.M === Infinity ? '---' : info.M.toFixed(2);
const dpStr = info.dPrime === Infinity ? '---' : info.dPrime.toFixed(1);
ctx.fillText('M = ' + mStr + " d' = " + dpStr + ' ' + info.imageType, bx + 10, by + 30);
}
/* === _drawRaysAnimated: principal rays with per-ray progress === */
_drawRaysAnimated(ctx, lx, ay, d, h, f, dPrime, hPrime) {
const T = this._rayAnimT;
if (T[0] >= 1 && T[1] >= 1 && T[2] >= 1) { this._drawRays(ctx, lx, ay, d, h, f, dPrime, hPrime); return; }
const objX = lx - d, objY = ay - h;
const hasImage = dPrime !== null && isFinite(dPrime);
const isVirtual = hasImage && dPrime < 0;
// Wavelength-aware colors for animation
const _wlNm = window._obWavelength || 550;
const _wl = window._obWhiteLight;
const _mc = wavelengthToRGB(_wlNm);
const COLORS = _wl ? [wavelengthToRGB(660), wavelengthToRGB(550), wavelengthToRGB(450)] : [_mc, _mc, _mc];
ctx.lineWidth = 1.8;
const lerp = (a, b, t) => a + (b - a) * Math.min(1, Math.max(0, t));
const drawPts = (color, pts, t) => {
if (t <= 0 || pts.length < 2) return;
const totalLen = pts.reduce((s, p, i) => i === 0 ? 0 : s + Math.hypot(p[0]-pts[i-1][0], p[1]-pts[i-1][1]), 0);
const target = totalLen * t;
const draw = () => {
ctx.strokeStyle = color; ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]);
let drawn = 0;
for (let i = 1; i < pts.length; i++) {
const segLen = Math.hypot(pts[i][0]-pts[i-1][0], pts[i][1]-pts[i-1][1]);
if (drawn + segLen <= target) { ctx.lineTo(pts[i][0], pts[i][1]); drawn += segLen; }
else { const fr = segLen > 0 ? (target - drawn) / segLen : 0; ctx.lineTo(lerp(pts[i-1][0], pts[i][0], fr), lerp(pts[i-1][1], pts[i][1], fr)); break; }
}
ctx.stroke();
};
if (window.LabFX) LabFX.glow.drawGlow(ctx, draw, { color, intensity: 10 });
else draw();
};
const FAR = lx + 360;
const imgX = hasImage ? lx + dPrime : null, imgY = hasImage ? ay - hPrime : null;
// Ray 1: parallel to axis -> through F'
if (T[0] > 0) {
let pts;
if (!hasImage) { pts = [[objX, objY], [lx, objY], [FAR, objY]]; }
else if (!isVirtual) { pts = [[objX, objY], [lx, objY], [imgX, imgY]]; }
else { const s = (objY - ay) / f; pts = [[objX, objY], [lx, objY], [FAR, objY + s*(FAR-lx)]]; }
drawPts(COLORS[0], pts, T[0]);
}
// Ray 2: through optical center (straight)
if (T[1] > 0) {
const s = (objY - ay) / (objX - lx);
drawPts(COLORS[1], [[objX, objY], [FAR, ay + s*(FAR-lx)]], T[1]);
}
// Ray 3: through front focus F -> parallel after lens
if (T[2] > 0) {
const fx = lx - f, s = (objY - ay) / (objX - fx);
const hitY = objY + s * (lx - objX);
const endX = hasImage && !isVirtual ? Math.max(imgX + 60, FAR) : FAR;
drawPts(COLORS[2], [[objX, objY], [lx, hitY], [endX, hitY]], T[2]);
}
}
/* === Arrow labels: h_o, h_i, magnification Gamma === */
_drawArrowLabels(ctx, lx, ay, d, h, dPrime, hPrime) {
const objX = lx - d;
ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.textBaseline = 'middle';
ctx.fillStyle = 'rgba(155,93,229,0.85)'; ctx.textAlign = 'right';
ctx.fillText('ho=' + h.toFixed(0), objX - 6, ay - h / 2);
if (dPrime !== null && isFinite(dPrime)) {
const imgX = lx + dPrime, isVirtual = dPrime < 0;
const M = -dPrime / d;
const Gstr = isFinite(M) ? (M >= 0 ? '+' : '') + M.toFixed(2) : '---';
const imgColor = isVirtual ? 'rgba(255,133,162,0.85)' : 'rgba(6,214,224,0.85)';
ctx.fillStyle = imgColor; ctx.textAlign = 'left';
ctx.fillText("hi=" + Math.abs(hPrime).toFixed(0), imgX + 6, ay - hPrime / 2);
ctx.fillStyle = '#FFD166'; ctx.textAlign = 'center';
ctx.fillText('G=' + Gstr, (lx + imgX) / 2, ay + 60);
}
}
_bindEvents() {
const cv = this.canvas;
const getPos = (e) => {
const r = cv.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
return { mx: (t.clientX - r.left) * (this.W / r.width), my: (t.clientY - r.top) * (this.H / r.height) };
};
const hitTest = (mx, my) => {
const lx = this.W / 2, ay = this.H / 2;
if (Math.hypot(mx - (lx - this.d), my - (ay - this.h)) < 20) return 'object';
if (Math.hypot(mx - (lx - this.f), my - ay) < 16) return 'focus';
return null;
};
const onDown = (e) => { const { mx, my } = getPos(e); this._drag = hitTest(mx, my); };
const onMove = (e) => {
if (!this._drag) return;
if (e.cancelable) e.preventDefault();
const { mx } = getPos(e), lx = this.W / 2;
if (this._drag === 'object') this.d = Math.max(30, Math.min(400, lx - mx));
else if (this._drag === 'focus') this.f = Math.max(-200, Math.min(200, lx - mx));
this.draw(); this._emit();
};
const onUp = () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = null; };
cv.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
cv.addEventListener('touchstart', e => { if (e.touches.length === 1) onDown(e); }, { passive: true });
cv.addEventListener('touchmove', e => onMove(e), { passive: false });
cv.addEventListener('touchend', onUp);
cv.addEventListener('mousemove', e => {
if (this._drag) { cv.style.cursor = 'grabbing'; return; }
const { mx, my } = getPos(e);
cv.style.cursor = hitTest(mx, my) ? 'grab' : 'default';
});
}
}
/* ─────────────────────────────────────────────────────────────
2. MIRROR ENGINE (from mirror.js)
───────────────────────────────────────────────────────────────*/
class MirrorSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
this.type = 'concave';
this.f = 120;
this.d = 240;
this.h = 60;
this._playing = false;
this._animT = 1.4;
this._animSpeed = 1;
this._raf = null;
this._step = -1;
this._showGrid = false;
this._showZones = true;
this._showNormals = true;
this._showDims = true;
this._showAngles = true;
this._showPhotons = true;
this._pointMode = false;
this._showSpherical = false; /* spherical aberration toggle (Agent OB-A3) */
this._photons = [];
this._photonRaf = null;
this._photonTimer = 0;
this._lastPhoTime = 0;
this._photonPaths = [];
this._prevType = 'concave';
this._transT = 1.0;
this._transRaf = null;
this._drag = null;
this._hoverX = -999;
this._hoverY = -999;
this.onUpdate = null;
this.onAnimate = null;
/* Feature 2: R slider + spherical aberration toggle */
this._R = 240; // radius of curvature (positive=concave, negative=convex)
this._useR = false; // true = R-slider mode; false = classic type+f mode
this._parabolic = false; // false = spherical mirror; true = perfect parabolic
this._bindEvents();
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
fit() {
const dpr = window.devicePixelRatio || 1;
const w = this.canvas.offsetWidth || 600;
const h = this.canvas.offsetHeight || 400;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = w; this.H = h;
}
setType(type) {
if (type === this.type) return;
this._prevType = this.type;
this.type = type;
if (this._playing) this._stopAnim();
this._startTransition();
this.draw(); this._emit();
}
getParams() { return { f: this.f, d: this.d, h: this.h }; }
setParams({ f, d, h } = {}) {
if (f !== undefined) this.f = Math.max(30, Math.min(300, +f));
if (d !== undefined) this.d = Math.max(30, Math.min(490, +d));
if (h !== undefined) this.h = Math.max(20, Math.min(80, +h));
this.draw(); this._emit();
}
setAnimSpeed(s) { this._animSpeed = +s || 1; }
togglePlay() { this._playing ? this._stopAnim() : this._startAnim(); }
stepNext() { this._step = Math.min(3, this._step + 1); this.draw(); }
stepReset() { this._step = -1; this.draw(); }
setPointMode(on) { this._pointMode = !!on; this.draw(); this._emit(); }
setToggle(name, val) {
const map = { grid:'_showGrid', zones:'_showZones', normals:'_showNormals', dims:'_showDims', angles:'_showAngles', photons:'_showPhotons', spherical:'_showSpherical' };
if (map[name]) this[map[name]] = !!val;
if (name === 'photons') { val ? this._startPhotons() : this._stopPhotons(); }
this.draw();
}
exportPng() {
const a = document.createElement('a');
a.href = this.canvas.toDataURL('image/png');
a.download = 'mirror_' + this.type + '_d' + Math.round(this.d) + '.png';
a.click();
}
_fSigned() {
if (this.type === 'flat') return Infinity;
return this.type === 'convex' ? -this.f : this.f;
}
info() {
const { type, d, h } = this;
const f = this._fSigned();
let dPrime, M;
if (type === 'flat') { dPrime = -d; M = 1; }
else {
const den = d - f;
if (Math.abs(den) < 0.5) { dPrime = Infinity; M = Infinity; }
else { dPrime = f * d / den; M = -dPrime / d; }
}
const hPrime = M === Infinity ? Infinity : M * h;
const isReal = dPrime > 0 && dPrime !== Infinity;
const imageType = dPrime === Infinity ? '∞' : isReal ? 'действительное' : 'мнимое';
const orient = (M === Infinity || M === 1) ? 'прямое' : M < 0 ? 'перевёрнутое' : 'прямое';
const sizeStr = M === Infinity ? '' : Math.abs(M) > 1.05 ? 'увеличенное' : Math.abs(M) < 0.95 ? 'уменьшенное' : 'равное';
return {
f: type === 'flat' ? '∞' : (type === 'convex' ? -this.f : +this.f).toFixed(0),
d: +d.toFixed(1),
dPrime: dPrime === Infinity ? Infinity : +dPrime.toFixed(1),
M: M === Infinity ? Infinity : +M.toFixed(3),
imageType, orient, sizeStr,
hPrime: hPrime === Infinity ? Infinity : +Math.abs(hPrime).toFixed(1),
isReal,
};
}
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
_getBulge(type) {
if (type === 'flat') return 0;
if (type === 'concave') return -Math.min(30, this.f * 0.18);
return Math.min(24, this.f * 0.16);
}
_startTransition() {
this._transT = 0;
if (this._transRaf) cancelAnimationFrame(this._transRaf);
const step = () => {
this._transT = Math.min(1, this._transT + 0.07);
this.draw();
if (this._transT < 1) this._transRaf = requestAnimationFrame(step);
else this._transRaf = null;
};
this._transRaf = requestAnimationFrame(step);
}
_startAnim() { this._playing = true; this._animLoop(); }
_stopAnim() {
this._playing = false;
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
_animLoop() {
if (!this._playing) return;
this._animT += 0.013 * this._animSpeed;
const t = 0.5 - 0.5 * Math.cos(this._animT);
if (this.type === 'concave') this.d = Math.max(30, Math.min(490, this.f * (0.35 + 2.75 * t)));
else this.d = 40 + 400 * t;
if (this.onAnimate) this.onAnimate(this.d);
this.draw(); this._emit();
this._raf = requestAnimationFrame(() => this._animLoop());
}
_getRayPaths(mx, ay, f, dPrime, hPrime) {
const { d, h, type } = this;
const hasImage = dPrime !== null && isFinite(dPrime);
const isReal = hasImage && dPrime > 0;
const imgX = hasImage ? mx - dPrime : null;
const imgY = hasImage ? ay - (this._pointMode ? 0 : hPrime) : null;
const objX = mx - d;
const objY = ay - (this._pointMode ? 0 : h);
const COLORS = ['#06D6E0', '#7BF5A4', '#FFD166'];
if (type === 'flat') {
return [objY, ay, ay - h * 0.5].map((hy, i) => ({
pts: [[objX, objY], [mx, hy], ...(hasImage ? [[imgX, imgY]] : [])],
color: COLORS[i],
}));
}
const hit1Y = ay - (this._pointMode ? 0 : h);
const hit2Y = ay;
const denom3 = d - f;
const hit3Y = Math.abs(denom3) < 0.5 ? null : ay + (this._pointMode ? 0 : h) * f / denom3;
const rays = [];
const add = (hitY, color) => {
if (hitY === null || !isFinite(hitY) || hitY < -this.H || hitY > 2 * this.H) return;
const pts = [[objX, objY], [mx, hitY]];
if (hasImage) {
if (isReal) {
pts.push([imgX, imgY]);
const dx = imgX - mx, dy = imgY - hitY, l = Math.hypot(dx, dy);
if (l > 1) pts.push([imgX + dx / l * 60, imgY + dy / l * 60]);
} else {
const dx = imgX - mx, dy = imgY - hitY;
if (Math.abs(dx) > 1) {
const tL = (mx - 5) / dx;
let endX = 5, endY = hitY - dy * tL;
if (endY < 5 || endY > this.H - 5) {
endY = endY < 5 ? 5 : this.H - 5;
const tE = (hitY - endY) / dy;
endX = Math.max(5, mx - dx * tE);
}
pts.push([endX, endY]);
}
}
}
rays.push({ pts, color });
};
add(hit1Y, COLORS[0]); add(hit2Y, COLORS[1]); add(hit3Y, COLORS[2]);
return rays;
}
_startPhotons() {
if (this._photonRaf) return;
this._lastPhoTime = performance.now();
this._photonLoop();
}
_stopPhotons() {
if (this._photonRaf) { cancelAnimationFrame(this._photonRaf); this._photonRaf = null; }
this._photons = [];
this.draw();
}
_photonLoop() {
const now = performance.now();
const dt = Math.min((now - this._lastPhoTime) / 1000, 0.1);
this._lastPhoTime = now;
const spd = 200;
for (const p of this._photons) p.t = Math.min(1, p.t + dt * spd / p.len);
this._photons = this._photons.filter(p => p.t < 1);
this._photonTimer += dt;
if (this._photonTimer > 0.75 && this._photonPaths.length) {
this._photonTimer = 0;
for (const path of this._photonPaths) {
if (path.pts.length < 2) continue;
let len = 0;
for (let i = 1; i < path.pts.length; i++)
len += Math.hypot(path.pts[i][0] - path.pts[i-1][0], path.pts[i][1] - path.pts[i-1][1]);
if (len > 20) this._photons.push({ pts: path.pts, color: path.color, t: 0, len });
}
}
if (!this._playing) this.draw();
this._photonRaf = requestAnimationFrame(() => this._photonLoop());
}
draw() {
const { ctx, W, H } = this;
if (!W || !H) return;
const f = this._fSigned();
const mx = Math.round(W * 0.62);
const ay = H / 2;
let dPrime = null, hPrime = null;
if (this.type === 'flat') { dPrime = -this.d; hPrime = this._pointMode ? 0 : this.h; }
else {
const den = this.d - f;
if (Math.abs(den) >= 0.5) {
dPrime = f * this.d / den;
hPrime = this._pointMode ? 0 : (-dPrime / this.d) * this.h;
}
}
const step = this._step;
const showRay = i => step === -1 || i <= step;
const showFill = step === -1 || step >= 3;
this._photonPaths = this._getRayPaths(mx, ay, f, dPrime, hPrime);
ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H);
if (this._showGrid) this._drawGrid(ctx);
if (this._showZones) this._drawZones(ctx, mx);
ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1; ctx.setLineDash([6, 4]);
ctx.beginPath(); ctx.moveTo(0, ay); ctx.lineTo(W, ay); ctx.stroke();
ctx.setLineDash([]);
this._drawFanRays(ctx, mx, ay, f, dPrime, hPrime, showRay, showFill);
/* spherical aberration overlay (Agent OB-A3) */
if (this._showSpherical && this.type !== 'flat' && isFinite(f))
this._drawMirrorSphericalAberration(ctx, mx, ay, f);
/* Feature 2: parabolic/spherical aberration fan */
if (this._useR && this.type !== 'flat' && isFinite(f))
this._drawAberrationFan(ctx, mx, ay, f);
this._drawMirror(ctx, mx, ay);
/* Feature 2: R and f labels on mirror */
if (this._useR && this.type !== 'flat' && isFinite(f)) {
ctx.save();
ctx.font = 'bold 11px Manrope, system-ui, sans-serif';
ctx.fillStyle = 'rgba(6,214,224,0.9)';
ctx.textAlign = 'right'; ctx.textBaseline = 'bottom';
ctx.fillText('R=' + this._R.toFixed(0) + ' f=' + f.toFixed(0), mx - 4, ay - 6);
ctx.restore();
}
if (this.type !== 'flat') {
this._drawFocalPoints(ctx, mx, ay, f);
this._drawCenterC(ctx, mx, ay, f);
}
if (this._showNormals && this.type !== 'flat' && (step === -1 || step >= 3))
this._drawNormals(ctx, mx, ay, f);
if (this._showAngles && this.type !== 'flat' && step === -1)
this._drawAngleArcs(ctx, mx, ay, f);
if (step === -1 || step >= 1) this._drawRayLabels(ctx, mx, ay, f, step);
const objX = mx - this.d;
if (this._pointMode) {
ctx.save(); ctx.shadowColor = '#9B5DE5'; ctx.shadowBlur = 10;
ctx.fillStyle = '#9B5DE5'; ctx.beginPath(); ctx.arc(objX, ay, 5, 0, Math.PI*2); ctx.fill(); ctx.restore();
} else { this._drawArrow(ctx, objX, ay, objX, ay - this.h, '#9B5DE5', false); }
if (dPrime !== null && isFinite(dPrime)) {
const imgX = mx - dPrime, imgY = ay - (this._pointMode ? 0 : hPrime);
if (this._pointMode) {
ctx.save(); ctx.fillStyle = dPrime > 0 ? '#EF476F' : '#FFD166';
if (dPrime < 0) { ctx.globalAlpha = 0.55; ctx.setLineDash([4,3]); }
ctx.beginPath(); ctx.arc(imgX, ay, 5, 0, Math.PI*2);
dPrime > 0 ? ctx.fill() : (() => { ctx.stroke(); })();
ctx.restore();
} else {
this._drawArrow(ctx, imgX, ay, imgX, imgY, dPrime > 0 ? '#EF476F' : '#FFD166', dPrime <= 0);
}
}
if (this._showDims && (step === -1 || step >= 3))
this._drawDimensions(ctx, mx, ay, f, dPrime, hPrime);
this._drawInfoBox(ctx, f, dPrime);
if ((step === -1 || step >= 3) && dPrime !== null) this._drawImageBadge(ctx, dPrime, hPrime);
this._drawCriticalMarker(ctx, f);
if (this._showDims) this._drawLegend(ctx);
if (this._showPhotons && this._photons.length) this._drawPhotons(ctx);
this._drawTooltip(ctx, mx, ay, f, dPrime, hPrime);
if (step >= 0) this._drawStepOverlay(ctx, step);
// Mirror caustics near focal point when real image exists (only when OB_FX.caustics is off)
if (!window.OB_FX.caustics && window.LabFX && dPrime !== null && isFinite(dPrime) && dPrime > 0) {
const focX = mx - dPrime, focY = ay - (this._pointMode ? 0 : hPrime);
if (!this._mCausticFrame) this._mCausticFrame = 0;
this._mCausticFrame++;
if (this._mCausticFrame % 4 === 0) {
LabFX.particles.emit({ ctx, x: focX + (Math.random()-0.5)*10, y: focY + (Math.random()-0.5)*10, count: 2, color: '#FFD166', speed: 6, spread: Math.PI*2, life: 500, shape: 'dust', glow: true });
}
}
// OB_FX visual depth layer
{
const objX = mx - this.d;
const fxExtras = {
srcX: objX, srcY: ay - this.h,
causticParams: f > 0 ? { lx: mx, ay, f } : null,
rays: [
{ x1: objX, y1: ay - this.h, x2: mx, y2: ay },
],
boundary: { bx: mx, by: ay, normalAngle: 0, kind: 'reflection' },
};
if (!fxExtras.causticParams) delete fxExtras.causticParams;
_drawOBFXLayer(ctx, 'mirror', fxExtras);
}
if (window.LabFX) { LabFX.particles.update(1/60); LabFX.particles.draw(ctx); }
}
_drawGrid(ctx) {
ctx.strokeStyle = 'rgba(255,255,255,0.03)'; ctx.lineWidth = 1; ctx.beginPath();
for (let x = 0; x < this.W; x += 40) { ctx.moveTo(x,0); ctx.lineTo(x,this.H); }
for (let y = 0; y < this.H; y += 40) { ctx.moveTo(0,y); ctx.lineTo(this.W,y); }
ctx.stroke();
}
_drawZones(ctx, mx) {
const g1 = ctx.createLinearGradient(0,0,mx,0);
g1.addColorStop(0, 'rgba(6,214,224,0.0)'); g1.addColorStop(1, 'rgba(6,214,224,0.03)');
ctx.fillStyle = g1; ctx.fillRect(0, 0, mx, this.H);
const g2 = ctx.createLinearGradient(mx,0,this.W,0);
g2.addColorStop(0, 'rgba(239,71,111,0.04)'); g2.addColorStop(1, 'rgba(239,71,111,0.0)');
ctx.fillStyle = g2; ctx.fillRect(mx, 0, this.W-mx, this.H);
}
_drawMirror(ctx, mx, ay) {
const mH = Math.min(this.H * 0.4, 150);
ctx.save();
const ease = t => t < 0.5 ? 2*t*t : -1+(4-2*t)*t;
const bulge = this._getBulge(this._prevType) + (this._getBulge(this.type) - this._getBulge(this._prevType)) * ease(this._transT);
ctx.strokeStyle = 'rgba(6,214,224,0.92)'; ctx.lineWidth = 3;
ctx.shadowColor = 'rgba(6,214,224,0.45)'; ctx.shadowBlur = 8;
ctx.beginPath(); ctx.moveTo(mx, ay - mH); ctx.quadraticCurveTo(mx + bulge, ay, mx, ay + mH); ctx.stroke();
ctx.shadowBlur = 0;
ctx.strokeStyle = 'rgba(6,214,224,0.15)'; ctx.lineWidth = 1.5;
for (let i = 0; i <= 10; i++) {
const y = ay - mH + i * mH * 2 / 10;
ctx.beginPath(); ctx.moveTo(mx, y); ctx.lineTo(mx+14, y+10); ctx.stroke();
}
ctx.restore();
}
_drawFocalPoints(ctx, mx, ay, f) {
const pts = [{ px: mx-f, lbl:'F', r:5 }, { px: mx-2*f, lbl:'2F', r:3.5 }];
ctx.font = '11px Manrope, system-ui, sans-serif';
for (const p of pts) {
if (p.px < 4 || p.px > this.W-4) continue;
const col = f < 0 ? 'rgba(255,209,102,0.7)' : '#06D6E0';
ctx.fillStyle = col; ctx.beginPath(); ctx.arc(p.px, ay, p.r, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = col; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText(p.lbl, p.px, ay+9);
}
}
_drawCenterC(ctx, mx, ay, f) {
if (!isFinite(f)) return;
const cx = mx - 2*f;
if (cx < 4 || cx > this.W-4) return;
const pulse = Math.abs(this.d - 2*Math.abs(f)) < Math.abs(f)*0.06;
ctx.save();
if (pulse) { ctx.shadowColor='rgba(255,152,0,0.9)'; ctx.shadowBlur=14; }
ctx.fillStyle = pulse ? '#FF9800' : 'rgba(255,152,0,0.5)';
ctx.beginPath(); ctx.arc(cx, ay, pulse ? 5 : 3.5, 0, Math.PI*2); ctx.fill();
ctx.shadowBlur = 0;
ctx.font = '11px Manrope, system-ui, sans-serif';
ctx.fillStyle = pulse ? '#FF9800' : 'rgba(255,152,0,0.6)';
ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText('C', cx, ay+9);
ctx.restore();
}
_drawFanRays(ctx, mx, ay, f, dPrime, hPrime, showRay, showFill) {
const { d, h, type } = this;
const hasImg = dPrime !== null && isFinite(dPrime);
const isReal = hasImg && dPrime > 0;
const imgX = hasImg ? mx - dPrime : null;
const imgY = hasImg ? ay - (this._pointMode ? 0 : hPrime) : null;
const objX = mx - d, objY = ay - (this._pointMode ? 0 : h);
// Wavelength-aware color
const _wl = window._obWhiteLight;
const _wlNm = window._obWavelength || 550;
const _mc = wavelengthToRGB(_wlNm);
const COLS = _wl ? [wavelengthToRGB(660), wavelengthToRGB(550), wavelengthToRGB(450)] : [_mc, _mc, _mc];
const FAN = _wl ? 'rgba(255,255,255,0.12)' : `rgba(255,255,255,0.18)`;
if (type === 'flat') {
const hits = [objY, ay, ay - (this._pointMode ? 0 : h)*0.5];
hits.forEach((hy, i) => {
if (!showRay(i)) return;
this._flatRay(ctx, mx, ay, d, h, objX, objY, hy, COLS[i], imgX, imgY, hasImg);
});
return;
}
const hit1 = ay - (this._pointMode ? 0 : h);
const hit2 = ay;
const den3 = d - f;
const hit3 = Math.abs(den3) < 0.5 ? null : ay + (this._pointMode ? 0 : h)*f/den3;
if (showFill) {
const fills = [(hit1+hit2)/2];
if (hit3 !== null && isFinite(hit3)) fills.push((hit2+hit3)/2);
for (const hy of fills) this._oneRay(ctx, mx, objX, objY, hy, FAN, 0.6, hasImg, isReal, imgX, imgY);
}
if (showRay(0)) this._oneRay(ctx, mx, objX, objY, hit1, COLS[0], 1.0, hasImg, isReal, imgX, imgY);
if (showRay(1)) this._oneRay(ctx, mx, objX, objY, hit2, COLS[1], 1.0, hasImg, isReal, imgX, imgY);
if (showRay(2)) this._oneRay(ctx, mx, objX, objY, hit3, COLS[2], 1.0, hasImg, isReal, imgX, imgY);
}
_oneRay(ctx, mx, ox, oy, hitY, color, alpha, hasImg, isReal, imgX, imgY) {
if (hitY === null || !isFinite(hitY) || hitY < -this.H || hitY > 2*this.H) return;
ctx.save(); ctx.globalAlpha = alpha;
if (window.LabFX && alpha > 0.5) { ctx.shadowColor = color; ctx.shadowBlur = 8; }
ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(ox, oy); ctx.lineTo(mx, hitY); ctx.stroke();
if (!hasImg) { ctx.restore(); return; }
if (isReal) {
ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(imgX, imgY); ctx.stroke();
const dx = imgX-mx, dy = imgY-hitY, l = Math.hypot(dx,dy);
if (l > 1) { ctx.globalAlpha = alpha * 0.22; ctx.beginPath(); ctx.moveTo(imgX,imgY); ctx.lineTo(imgX+dx/l*60, imgY+dy/l*60); ctx.stroke(); }
} else {
const dx = imgX-mx, dy = imgY-hitY;
if (Math.abs(dx) < 1) { ctx.restore(); return; }
const tL = (mx-5)/dx;
let ex = 5, ey = hitY - dy*tL;
if (ey < 5 || ey > this.H-5) { ey = ey < 5 ? 5 : this.H-5; ex = Math.max(5, mx - dx*(hitY-ey)/dy); }
ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(ex, ey); ctx.stroke();
ctx.globalAlpha = alpha * 0.4; ctx.setLineDash([4,4]);
ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(imgX, imgY); ctx.stroke();
ctx.setLineDash([]);
}
ctx.restore();
}
_flatRay(ctx, mx, ay, d, h, ox, oy, hitY, color, imgX, imgY, hasImg) {
ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(ox, oy); ctx.lineTo(mx, hitY); ctx.stroke();
const slope = (hitY-oy)/(mx-ox);
const farX = Math.max(5, ox-50);
const farY = hitY - slope*(mx-farX);
ctx.globalAlpha = 0.3;
ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(farX, Math.max(5, Math.min(this.H-5, farY))); ctx.stroke();
ctx.globalAlpha = 1;
if (hasImg) { ctx.setLineDash([4,4]); ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(imgX, imgY); ctx.stroke(); ctx.setLineDash([]); }
ctx.restore();
}
/* ── Mirror spherical aberration (Agent OB-A3) ────────────────
For a spherical concave mirror, paraxial focal length = f.
Marginal rays: f_eff(h) = f - h²/(4f) (simplified).
Draw 5 incoming parallel rays at different heights; show they
focus at slightly different points along the axis.
─────────────────────────────────────────────────────────────── */
_drawMirrorSphericalAberration(ctx, mx, ay, f) {
const mH = Math.min(this.H * 0.38, 140);
const fracs = [-0.85, -0.5, 0, 0.5, 0.85];
const pal = ['#FF6B6B', '#FFD166', '#7BF5A4', '#06D6E0', '#B8A4FF'];
ctx.save();
ctx.lineWidth = 1.3;
const focusPts = [];
fracs.forEach((fr, i) => {
const rayH = fr * mH;
const fEff = f > 0 ? f - (rayH * rayH) / (4 * f) : f;
const focX = mx - fEff;
ctx.strokeStyle = pal[i];
ctx.globalAlpha = 0.7;
ctx.setLineDash([]);
const startY = ay - rayH;
// incoming parallel ray from left
ctx.beginPath(); ctx.moveTo(0, startY); ctx.lineTo(mx, startY); ctx.stroke();
// reflected ray toward (approximate) focus
if (focX > 5 && focX < mx) {
ctx.beginPath(); ctx.moveTo(mx, startY); ctx.lineTo(focX, ay);
ctx.lineTo(focX - 60, ay + (startY - ay) * 0.4); ctx.stroke();
focusPts.push({ fEff, x: focX });
}
});
ctx.globalAlpha = 1;
ctx.restore();
// info box
if (focusPts.length >= 2) {
const fMin = Math.min(...focusPts.map(p => p.fEff)).toFixed(0);
const fMax = Math.max(...focusPts.map(p => p.fEff)).toFixed(0);
ctx.save();
const bx = 12, by = 70;
ctx.fillStyle = 'rgba(22,22,38,0.88)';
ctx.beginPath(); ctx.roundRect(bx, by, 224, 44, 8); ctx.fill();
ctx.font = '11px Manrope, system-ui, sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillStyle = '#FF6B6B'; ctx.fillText('Сферическая аберрация (зеркало)', bx + 8, by + 6);
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.fillText('f парак.=' + fMax + ' f краев.=' + fMin, bx + 8, by + 24);
ctx.restore();
}
}
_drawNormals(ctx, mx, ay, f) {
if (!isFinite(f)) return;
const { d, h } = this;
const cX = mx - 2*f;
const hits = [ay-h, ay];
const d3 = d-f;
if (Math.abs(d3) >= 0.5) { const y3 = ay + h*f/d3; if (isFinite(y3)) hits.push(y3); }
ctx.save(); ctx.strokeStyle='rgba(255,255,255,0.14)'; ctx.lineWidth=1; ctx.setLineDash([4,4]);
for (const hy of hits) {
if (hy < -this.H || hy > 2*this.H) continue;
const nx=cX-mx, ny=ay-hy, nl=Math.hypot(nx,ny);
if (nl < 1) continue;
const ux=nx/nl*28, uy=ny/nl*28;
ctx.beginPath(); ctx.moveTo(mx-ux,hy-uy); ctx.lineTo(mx+ux,hy+uy); ctx.stroke();
}
ctx.setLineDash([]); ctx.restore();
}
_drawAngleArcs(ctx, mx, ay, f) {
if (!isFinite(f)) return;
const { d, h } = this;
const hitY = ay - h;
if (hitY < 5 || hitY > this.H-5) return;
const cX = mx - 2*f;
const nx = cX-mx, ny = ay-hitY, nl = Math.hypot(nx, ny);
if (nl < 1) return;
const normInward = Math.atan2(ny, nx);
const normOuter = normInward + Math.PI;
const incDir = Math.atan2(hitY-(ay-h), mx-(mx-d));
const incFrom = incDir + Math.PI;
const r = 14;
ctx.save(); ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(6,214,224,0.45)';
ctx.beginPath(); ctx.arc(mx, hitY, r, normOuter, incFrom, false); ctx.stroke();
ctx.fillStyle = 'rgba(6,214,224,0.7)'; ctx.font = '9px Manrope, system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const mid = (normOuter+incFrom)/2;
ctx.fillText('θ', mx+Math.cos(mid)*(r+9), hitY+Math.sin(mid)*(r+9));
ctx.restore();
}
_drawRayLabels(ctx, mx, ay, f, step) {
if (this.type === 'flat' || !isFinite(f)) return;
const { d, h } = this;
const hits = [ay-h, ay, null];
const den3 = d-f;
if (Math.abs(den3) >= 0.5) { const y3 = ay+h*f/den3; if (isFinite(y3)) hits[2] = y3; }
const COLS = ['#06D6E0','#7BF5A4','#FFD166'];
const LBLS = ['①','②','③'];
ctx.font = 'bold 11px Manrope, system-ui, sans-serif'; ctx.textAlign = 'left';
hits.forEach((hy, i) => {
if (hy === null || !isFinite(hy) || hy < -50 || hy > this.H+50) return;
if (step !== -1 && i > step) return;
ctx.fillStyle = COLS[i]; ctx.textBaseline = 'middle'; ctx.fillText(LBLS[i], mx+8, hy);
});
}
_drawArrow(ctx, x1, y1, x2, y2, color, dashed) {
ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 2.5;
if (dashed) ctx.setLineDash([6,4]);
ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.stroke();
if (dashed) ctx.setLineDash([]);
const a = Math.atan2(y2-y1, x2-x1), s=10;
ctx.beginPath(); ctx.moveTo(x2,y2);
ctx.lineTo(x2-s*Math.cos(a-0.35), y2-s*Math.sin(a-0.35));
ctx.lineTo(x2-s*Math.cos(a+0.35), y2-s*Math.sin(a+0.35));
ctx.closePath(); ctx.fill();
}
_drawDimensions(ctx, mx, ay, f, dPrime, hPrime) {
const { d, h } = this;
const objX = mx - d;
const yBase = ay + Math.min(this.H*0.22, 60);
ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.lineWidth = 1;
const bracket = (x1, x2, y, lbl, col) => {
if (x1 === x2 || x1 < 4 || x2 > this.W-4) return;
ctx.strokeStyle = col; ctx.fillStyle = col; ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(x1, y-5); ctx.lineTo(x1, y+5); ctx.moveTo(x1, y); ctx.lineTo(x2, y);
ctx.moveTo(x2, y-5); ctx.lineTo(x2, y+5); ctx.stroke();
ctx.textAlign='center'; ctx.textBaseline='top'; ctx.fillText(lbl, (x1+x2)/2, y+3);
};
bracket(objX, mx, yBase, 'd=' + d.toFixed(0), 'rgba(155,93,229,0.65)');
if (isFinite(f) && Math.abs(f) > 5) {
const fX = mx-f;
if (fX > 4 && fX < this.W-4)
bracket(Math.min(fX,mx), Math.max(fX,mx), yBase+20, 'f=' + Math.abs(f).toFixed(0), 'rgba(6,214,224,0.55)');
}
if (dPrime !== null && isFinite(dPrime)) {
const ix = mx-dPrime;
if (ix > 4 && ix < this.W-4)
bracket(Math.min(ix,mx), Math.max(ix,mx), yBase, "d'=" + Math.abs(dPrime).toFixed(0),
dPrime > 0 ? 'rgba(239,71,111,0.65)' : 'rgba(255,209,102,0.65)');
}
const xl = objX-18;
if (xl > 4 && h > 6 && !this._pointMode) {
ctx.strokeStyle='rgba(155,93,229,0.4)';
ctx.beginPath(); ctx.moveTo(xl,ay); ctx.lineTo(xl,ay-h); ctx.stroke();
ctx.fillStyle='rgba(155,93,229,0.7)'; ctx.textAlign='right'; ctx.textBaseline='middle';
ctx.fillText('h=' + h.toFixed(0), xl-3, ay-h/2);
}
if (dPrime !== null && isFinite(dPrime) && !this._pointMode && Math.abs(hPrime) > 6) {
const ix = mx-dPrime;
const xil = ix + (dPrime > 0 ? -18 : 18);
if (xil > 4 && xil < this.W-4) {
const col = dPrime > 0 ? 'rgba(239,71,111,' : 'rgba(255,209,102,';
ctx.strokeStyle = col+'0.4)'; ctx.beginPath(); ctx.moveTo(ix,ay); ctx.lineTo(ix,ay-hPrime); ctx.stroke();
ctx.fillStyle = col+'0.7)';
ctx.textAlign = dPrime > 0 ? 'right' : 'left'; ctx.textBaseline='middle';
ctx.fillText("h'=" + Math.abs(hPrime).toFixed(0), ix+(dPrime>0?-3:3), ay-hPrime/2);
}
}
}
_drawInfoBox(ctx, f, dPrime) {
const info = this.info();
const bx=12, by=12, bw=230, bh=76;
ctx.fillStyle='rgba(13,13,26,0.9)'; ctx.beginPath(); ctx.roundRect(bx,by,bw,bh,8); ctx.fill();
ctx.strokeStyle='rgba(255,255,255,0.06)'; ctx.lineWidth=1;
ctx.beginPath(); ctx.roundRect(bx,by,bw,bh,8); ctx.stroke();
ctx.font='11px Manrope, system-ui, sans-serif'; ctx.textAlign='left'; ctx.textBaseline='top';
ctx.fillStyle='rgba(255,255,255,0.42)';
ctx.fillText("1/f = 1/d + 1/d'", bx+10, by+8);
if (isFinite(f) && dPrime !== null && isFinite(dPrime)) {
ctx.fillStyle='rgba(6,214,224,0.88)'; ctx.fillText('1/' + Math.abs(+info.f), bx+10, by+28);
ctx.fillStyle='rgba(255,255,255,0.28)';ctx.fillText('=',bx+60,by+28);
ctx.fillStyle='rgba(155,93,229,0.88)'; ctx.fillText('1/' + info.d, bx+78, by+28);
ctx.fillStyle='rgba(255,255,255,0.28)';ctx.fillText('+',bx+120,by+28);
ctx.fillStyle= dPrime>0 ? 'rgba(239,71,111,0.88)' : 'rgba(255,209,102,0.88)';
ctx.fillText((dPrime>0?'':'') + '1/' + Math.abs(+info.dPrime).toFixed(0), bx+136, by+28);
} else {
ctx.fillStyle='rgba(255,209,102,0.75)';
ctx.fillText('d = f → изображение на ∞', bx+10, by+28);
}
if (info.M !== Infinity) {
ctx.fillStyle='rgba(255,255,255,0.28)'; ctx.fillText('M = ' + info.M, bx+10, by+48);
if (isFinite(dPrime)) {
ctx.fillStyle = dPrime > 0 ? '#EF476F' : '#FFD166';
ctx.textAlign = 'right'; ctx.fillText(info.imageType + ' ' + info.orient, bx+bw-10, by+48);
ctx.textAlign = 'left';
}
}
}
_drawImageBadge(ctx, dPrime, hPrime) {
const info = this.info();
const bw=160, bh=58, bx=this.W-bw-12, by=12;
ctx.fillStyle='rgba(13,13,26,0.88)'; ctx.beginPath(); ctx.roundRect(bx,by,bw,bh,8); ctx.fill();
ctx.strokeStyle='rgba(255,255,255,0.06)'; ctx.lineWidth=1;
ctx.beginPath(); ctx.roundRect(bx,by,bw,bh,8); ctx.stroke();
const isInf = !isFinite(dPrime);
ctx.font='10px Manrope, system-ui, sans-serif'; ctx.textAlign='left'; ctx.textBaseline='top';
const tc = isInf ? '#FFD166' : dPrime>0 ? '#EF476F' : '#FFD166';
ctx.fillStyle='rgba(255,255,255,0.3)'; ctx.fillText('Тип:', bx+10, by+8);
ctx.fillStyle=tc; ctx.fillText(isInf?'∞':info.imageType, bx+44, by+8);
if (!isInf) {
ctx.fillStyle='rgba(255,255,255,0.3)'; ctx.fillText('Ориент.:', bx+10, by+26);
ctx.fillStyle = info.M<0 ? 'rgba(239,71,111,0.9)' : 'rgba(123,245,164,0.9)';
ctx.fillText(info.orient, bx+62, by+26);
if (info.sizeStr) {
const sc = Math.abs(+info.M)>1.05 ? '#9B5DE5' : Math.abs(+info.M)<0.95 ? '#06D6E0' : 'rgba(255,255,255,0.6)';
ctx.fillStyle='rgba(255,255,255,0.3)'; ctx.fillText('Размер:', bx+10, by+42);
ctx.fillStyle=sc; ctx.fillText(info.sizeStr + ' x' + Math.abs(+info.M).toFixed(2), bx+57, by+42);
}
}
}
_drawCriticalMarker(ctx, f) {
if (!isFinite(f) || f <= 0) return;
const eps = f*0.06;
let text = null;
if (Math.abs(this.d-f) < eps) text = 'd = f : лучи параллельны, изображения нет';
else if (Math.abs(this.d-2*f)<eps) text = 'd = 2f : изображение равное, M = −1';
if (!text) return;
ctx.save(); ctx.font='bold 11px Manrope, system-ui, sans-serif';
ctx.textAlign='center'; ctx.textBaseline='top';
const tw=ctx.measureText(text).width, bx=this.W/2-tw/2-10, by=4;
ctx.fillStyle='rgba(255,209,102,0.15)'; ctx.beginPath(); ctx.roundRect(bx,by,tw+20,22,6); ctx.fill();
ctx.strokeStyle='rgba(255,209,102,0.35)'; ctx.lineWidth=1;
ctx.beginPath(); ctx.roundRect(bx,by,tw+20,22,6); ctx.stroke();
ctx.fillStyle='#FFD166'; ctx.fillText(text, this.W/2, by+5);
ctx.restore();
}
_drawLegend(ctx) {
const items = [
{ c:'rgba(155,93,229,0.8)', t:'d > 0 — предмет перед зеркалом' },
{ c:'rgba(239,71,111,0.8)', t:"d' > 0 — действительное" },
{ c:'rgba(255,209,102,0.8)',t:"d' < 0 — мнимое" },
];
const bx=12, lh=14, by=this.H - items.length*lh - 16;
ctx.save(); ctx.font='9px Manrope, system-ui, sans-serif'; ctx.textBaseline='top';
items.forEach(({ c, t }, i) => {
const y = by+i*lh;
ctx.fillStyle=c; ctx.fillRect(bx, y+3, 8, 8);
ctx.fillStyle='rgba(255,255,255,0.32)'; ctx.textAlign='left'; ctx.fillText(t, bx+13, y);
});
ctx.restore();
}
_drawPhotons(ctx) {
for (const p of this._photons) {
const pos = this._photonPos(p.pts, p.t);
if (!pos) continue;
ctx.save(); ctx.shadowColor = p.color; ctx.shadowBlur = 8;
ctx.fillStyle = p.color; ctx.beginPath(); ctx.arc(pos[0], pos[1], 3, 0, Math.PI*2); ctx.fill();
ctx.restore();
}
}
_photonPos(pts, t) {
if (pts.length < 2) return null;
let total = 0;
const lens = [];
for (let i=1; i<pts.length; i++) {
const l = Math.hypot(pts[i][0]-pts[i-1][0], pts[i][1]-pts[i-1][1]);
lens.push(l); total += l;
}
let dist = t * total;
for (let i=0; i<lens.length; i++) {
if (dist <= lens[i]) { const k = dist/lens[i]; return [pts[i][0]+(pts[i+1][0]-pts[i][0])*k, pts[i][1]+(pts[i+1][1]-pts[i][1])*k]; }
dist -= lens[i];
}
return pts[pts.length-1];
}
_drawTooltip(ctx, mx, ay, f, dPrime, hPrime) {
const { _hoverX:hx, _hoverY:hy } = this;
if (hx < 0) return;
let tip = null;
const chk = (px, py, lbl, sub) => { if (!tip && Math.hypot(hx-px, hy-py) < 15) tip = { lbl, sub }; };
if (isFinite(f)) {
chk(mx-f, ay, 'Главный фокус F', 'f = ' + Math.abs(f).toFixed(0));
chk(mx-2*f, ay, 'Центр кривизны C', 'R = 2f = ' + (2*Math.abs(f)).toFixed(0));
}
chk(mx-this.d, ay-(this._pointMode?0:this.h), 'Предмет', 'd = ' + this.d.toFixed(0) + ', h = ' + this.h.toFixed(0));
if (dPrime !== null && isFinite(dPrime)) {
const ix=mx-dPrime, iy=ay-(this._pointMode?0:hPrime);
chk(ix, iy, 'Изображение', "d' = " + Math.abs(dPrime).toFixed(0) + ', M = ' + this.info().M);
}
if (!tip) return;
ctx.save();
ctx.font = 'bold 11px Manrope, system-ui, sans-serif';
const tw = Math.max(ctx.measureText(tip.lbl).width, ctx.measureText(tip.sub).width);
const bw=tw+20, bh=34;
let tx=hx+14, ty=hy-bh-6;
if (tx+bw > this.W-4) tx = hx-bw-14;
if (ty < 4) ty = hy+10;
ctx.fillStyle='rgba(13,13,26,0.95)'; ctx.strokeStyle='rgba(6,214,224,0.45)'; ctx.lineWidth=1;
ctx.beginPath(); ctx.roundRect(tx,ty,bw,bh,6); ctx.fill(); ctx.stroke();
ctx.fillStyle='#fff'; ctx.textAlign='left'; ctx.textBaseline='top'; ctx.fillText(tip.lbl, tx+10, ty+6);
ctx.font='10px Manrope, system-ui, sans-serif';
ctx.fillStyle='rgba(255,255,255,0.5)'; ctx.fillText(tip.sub, tx+10, ty+20);
ctx.restore();
}
_drawStepOverlay(ctx, step) {
const lbls = [
'① Луч параллельно оси → отражается через F',
'② Луч через вершину → отражается симметрично',
'③ Луч через F → отражается параллельно',
' Изображение — пересечение всех отражённых лучей',
];
const text = lbls[Math.min(step, lbls.length-1)];
ctx.save(); ctx.font = '11px Manrope, system-ui, sans-serif';
const tw = ctx.measureText(text).width;
const bx = this.W/2-tw/2-12, by = this.H-34;
ctx.fillStyle='rgba(13,13,26,0.9)'; ctx.beginPath(); ctx.roundRect(bx,by,tw+24,24,6); ctx.fill();
ctx.fillStyle='#7BF5A4'; ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText(text, this.W/2, by+12);
ctx.restore();
}
/* === Feature 2: R-slider mode for MirrorSim === */
setMirrorR(R) {
this._useR = true;
this._R = +R;
// Derive type and f from R
const absR = Math.abs(this._R);
if (absR < 5) { this.type = 'flat'; }
else if (this._R > 0) { this.type = 'concave'; this.f = absR / 2; }
else { this.type = 'convex'; this.f = absR / 2; }
this.draw(); this._emit();
}
setMirrorParabolic(on) {
this._parabolic = !!on;
this.draw();
}
/* Draw 5 parallel rays showing spherical vs parabolic aberration */
_drawAberrationFan(ctx, mx, ay, f) {
if (!isFinite(f) || Math.abs(f) < 5) return;
const mH = Math.min(this.H * 0.38, 140);
const heights = [-0.85, -0.45, 0, 0.45, 0.85];
const COLORS = ['#FF6B6B', '#FFD166', '#7BF5A4', '#06D6E0', '#B8A4FF'];
ctx.save(); ctx.lineWidth = 1.4;
heights.forEach((fr, i) => {
const rayH = fr * mH;
// For parabolic mirror: all parallel rays focus exactly at f
// For spherical: marginal rays (fr != 0) focus closer by h^2/(2R) approx
const fEff = this._parabolic ? f : f - (rayH * rayH) / (2 * Math.abs(f) * 2);
const startX = mx - this.d - 40;
const hitY = ay - rayH; // hits mirror at height rayH
// Incident ray: horizontal from left to mirror
ctx.strokeStyle = COLORS[i]; ctx.globalAlpha = 0.75;
ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(startX, ay - rayH); ctx.lineTo(mx, hitY); ctx.stroke();
// Reflected ray: goes toward focal point fEff
const focX = mx - fEff;
if (focX > 0 && focX < this.W) {
ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(focX, ay);
// extend a bit past focus
const dx = focX - mx, dy = ay - hitY, len = Math.hypot(dx, dy);
if (len > 1) ctx.lineTo(focX + dx/len*50, ay + dy/len*50);
ctx.stroke();
}
});
ctx.globalAlpha = 1;
// label
const label = this._parabolic ? 'Параболическое (идеальный фокус)' : 'Сферическое (аберрация)';
const col = this._parabolic ? '#7BF5A4' : '#FF6B6B';
const bx = 12, by = this.H - 36;
ctx.fillStyle = 'rgba(13,13,26,0.85)';
ctx.beginPath(); ctx.roundRect(bx, by, 250, 24, 6); ctx.fill();
ctx.font = 'bold 11px Manrope, system-ui, sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillStyle = col;
ctx.fillText(label, bx + 8, by + 12);
ctx.restore();
}
_bindEvents() {
const cv = this.canvas;
const getPos = e => {
const r = cv.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
return { px: (t.clientX-r.left)*(this.W/r.width), py: (t.clientY-r.top)*(this.H/r.height) };
};
const mX = () => Math.round(this.W*0.62);
const aY = () => this.H/2;
const hitTest = (px, py) => {
if (this._playing) return null;
const mx=mX(), ay=aY(), f=this._fSigned();
if (Math.hypot(px-(mx-this.d), py-(ay-(this._pointMode?0:this.h))) < 20) return 'object';
if (this.type !== 'flat' && isFinite(f) && Math.hypot(px-(mx-f), py-ay) < 16) return 'focus';
const info = this.info();
if (info.dPrime !== Infinity && isFinite(info.dPrime)) {
const ix=mx-info.dPrime, iy=ay-(this._pointMode?0:(info.hPrime||0));
if (Math.hypot(px-ix, py-iy) < 18) return 'image';
}
return null;
};
cv.addEventListener('mousedown', e => { const {px,py}=getPos(e); this._drag=hitTest(px,py); });
window.addEventListener('mousemove', e => {
const {px,py} = getPos(e);
this._hoverX = px; this._hoverY = py;
if (this._drag) {
if (e.cancelable) e.preventDefault();
const mx=mX(), f=this._fSigned();
if (this._drag === 'object') this.d = Math.max(30, Math.min(490, mx-px));
else if (this._drag === 'focus') this.f = Math.max(30, Math.min(300, Math.abs(mx-px)));
else if (this._drag === 'image' && isFinite(f) && this.type !== 'flat') {
const dp = mx-px; if (Math.abs(dp-f) > 5) this.d = Math.max(30, Math.min(490, f*dp/(dp-f)));
}
if (this.onAnimate) this.onAnimate(this.d);
this.draw(); this._emit();
} else if (!this._photonRaf && !this._playing) { this.draw(); }
});
window.addEventListener('mouseup', () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = null; });
cv.addEventListener('mousemove', e => {
if (this._drag) { cv.style.cursor='grabbing'; return; }
const {px,py}=getPos(e);
cv.style.cursor = (hitTest(px,py) && !this._playing) ? 'grab' : 'default';
});
cv.addEventListener('touchstart', e => {
if (e.touches.length===1) { const {px,py}=getPos(e); this._drag=hitTest(px,py); }
}, { passive: true });
cv.addEventListener('touchmove', e => {
if (!this._drag) return;
if (e.cancelable) e.preventDefault();
const {px}=getPos(e), mx=mX(), f=this._fSigned();
if (this._drag==='object') this.d=Math.max(30,Math.min(490,mx-px));
else if (this._drag==='focus') this.f=Math.max(30,Math.min(300,Math.abs(mx-px)));
else if (this._drag==='image' && isFinite(f) && this.type!=='flat') {
const dp=mx-px; if (Math.abs(dp-f)>5) this.d=Math.max(30,Math.min(490,f*dp/(dp-f)));
}
if (this.onAnimate) this.onAnimate(this.d);
this.draw(); this._emit();
}, { passive: false });
cv.addEventListener('touchend', () => { this._drag=null; });
}
}
/* ─────────────────────────────────────────────────────────────
3. REFRACTION ENGINE (from refraction.js)
───────────────────────────────────────────────────────────────*/
class RefractionSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
this.n1 = 1.0;
this.n2 = 1.5;
this.angle = 30;
this.dispersion = false;
this._drag = false;
this.onUpdate = null;
this._bindEvents();
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
fit() {
const dpr = window.devicePixelRatio || 1;
const w = this.canvas.offsetWidth || 600;
const h = this.canvas.offsetHeight || 400;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = w; this.H = h;
}
getParams() { return { n1: this.n1, n2: this.n2, angle: this.angle, dispersion: this.dispersion }; }
setParams({ n1, n2, angle, dispersion } = {}) {
if (n1 !== undefined) this.n1 = Math.max(1.0, Math.min(3.0, +n1));
if (n2 !== undefined) this.n2 = Math.max(1.0, Math.min(3.0, +n2));
if (angle !== undefined) this.angle = Math.max(0, Math.min(89, +angle));
if (dispersion !== undefined) this.dispersion = !!dispersion;
this.draw(); this._emit();
}
reset() { this.n1 = 1.0; this.n2 = 1.5; this.angle = 30; this.dispersion = false; this.draw(); this._emit(); }
info() {
const { n1, n2, angle } = this;
const theta1Rad = angle * Math.PI / 180;
const sinTheta2 = (n1 / n2) * Math.sin(theta1Rad);
const isTIR = Math.abs(sinTheta2) > 1;
const criticalAngle = n1 > n2 ? +(Math.asin(n2 / n1) * 180 / Math.PI).toFixed(1) : null;
let angle2;
if (isTIR) angle2 = 'ПВО';
else angle2 = +(Math.asin(sinTheta2) * 180 / Math.PI).toFixed(1);
return { n1: +n1.toFixed(2), n2: +n2.toFixed(2), angle1: +angle.toFixed(1), angle2, criticalAngle, isTIR };
}
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
draw() {
const ctx = this.ctx, W = this.W, H = this.H;
if (!W || !H) return;
const midY = H / 2, hitX = W / 2, hitY = midY;
const gradTop = ctx.createLinearGradient(0, 0, 0, midY);
gradTop.addColorStop(0, '#131328'); gradTop.addColorStop(1, '#1a1a3a');
ctx.fillStyle = gradTop; ctx.fillRect(0, 0, W, midY);
const gradBot = ctx.createLinearGradient(0, midY, 0, H);
gradBot.addColorStop(0, '#0e1a2e'); gradBot.addColorStop(1, '#0D0D1A');
ctx.fillStyle = gradBot; ctx.fillRect(0, midY, W, H - midY);
ctx.save();
ctx.shadowColor = 'rgba(155, 93, 229, 0.4)'; ctx.shadowBlur = 12;
ctx.strokeStyle = 'rgba(155, 93, 229, 0.5)'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(0, midY); ctx.lineTo(W, midY); ctx.stroke();
ctx.restore();
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1; ctx.setLineDash([6, 4]);
ctx.beginPath(); ctx.moveTo(hitX, 0); ctx.lineTo(hitX, H); ctx.stroke();
ctx.setLineDash([]);
const theta1Rad = this.angle * Math.PI / 180;
const sinTheta2 = (this.n1 / this.n2) * Math.sin(theta1Rad);
const isTIR = Math.abs(sinTheta2) > 1;
let R = 1;
if (!isTIR) {
const theta2Rad = Math.asin(sinTheta2);
const cosT1 = Math.cos(theta1Rad), cosT2 = Math.cos(theta2Rad);
const rs = (this.n1 * cosT1 - this.n2 * cosT2) / (this.n1 * cosT1 + this.n2 * cosT2);
R = rs * rs;
}
const rayLen = Math.max(W, H) * 0.6;
if (this.n1 > this.n2) {
const critRad = Math.asin(this.n2 / this.n1);
const critDx = Math.sin(critRad), critDy = Math.cos(critRad);
ctx.strokeStyle = 'rgba(255,209,102,0.25)'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(hitX, hitY); ctx.lineTo(hitX - critDx * rayLen * 0.5, hitY - critDy * rayLen * 0.5); ctx.stroke();
ctx.setLineDash([]);
ctx.font = '10px Manrope, system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,209,102,0.5)';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText('θc=' + (critRad * 180 / Math.PI).toFixed(1) + '°', hitX - critDx * rayLen * 0.35 + 6, hitY - critDy * rayLen * 0.35);
}
if (this.dispersion && !isTIR) this._drawDispersion(ctx, hitX, hitY, midY, theta1Rad, rayLen);
else this._drawMainRays(ctx, hitX, hitY, midY, theta1Rad, sinTheta2, isTIR, R, rayLen);
this._drawAngleArcs(ctx, hitX, hitY, theta1Rad, sinTheta2, isTIR);
this._drawMediumLabels(ctx, W, H, midY);
this._drawInfoBox(ctx, isTIR, R);
const incDx = Math.sin(theta1Rad), incDy = Math.cos(theta1Rad);
const handleX = hitX - incDx * rayLen * 0.55, handleY = hitY - incDy * rayLen * 0.55;
const grad = ctx.createRadialGradient(handleX, handleY, 0, handleX, handleY, 10);
grad.addColorStop(0, 'rgba(155,93,229,0.4)'); grad.addColorStop(1, 'rgba(155,93,229,0)');
ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(handleX, handleY, 10, 0, Math.PI * 2); ctx.fill();
// TIR one-shot sound
if (window.LabFX) {
if (isTIR && !this._wasTIR) {
LabFX.sound.play('spark', { volume: 0.2 });
}
this._wasTIR = isTIR;
// Brewster angle: R ≈ 0 (reflected intensity near zero for s-pol)
const _isBrew = !isTIR && R < 0.005 && this.angle > 0;
if (_isBrew && !this._wasBrewster) {
LabFX.sound.play('chime', { pitch: 1.5, volume: 0.3 });
}
this._wasBrewster = _isBrew;
}
// OB_FX visual depth layer
{
const incDx2 = Math.sin(theta1Rad), incDy2 = Math.cos(theta1Rad);
const incStartX = hitX - incDx2 * rayLen, incStartY = hitY - incDy2 * rayLen;
// Normal at boundary points upward (angle = -PI/2 in canvas coords)
const normalAngle = -Math.PI / 2;
const kind = isTIR ? 'reflection' : 'refraction';
const fxExtras = {
srcX: incStartX, srcY: incStartY,
rays: [
{ x1: incStartX, y1: incStartY, x2: hitX, y2: hitY, n: this.n1 },
],
boundary: { bx: hitX, by: hitY, normalAngle, kind },
};
_drawOBFXLayer(ctx, 'refraction', fxExtras);
}
if (window.LabFX) {
LabFX.particles.update(1 / 60);
LabFX.particles.draw(ctx);
}
}
_drawMainRays(ctx, hitX, hitY, midY, theta1Rad, sinTheta2, isTIR, R, rayLen) {
// Use wavelength color if set, otherwise fall back to purple/red/cyan scheme
const _wl = window._obWhiteLight;
const _wlNm = window._obWavelength || 550;
const incColor = _wl ? '#FFFFFF' : wavelengthToRGB(_wlNm);
const incDx = Math.sin(theta1Rad), incDy = Math.cos(theta1Rad);
const incStartX = hitX - incDx * rayLen, incStartY = hitY - incDy * rayLen;
if (_wl) {
// White-light: draw spectral fan using physical n(λ) model
this._drawRay(ctx, incStartX, incStartY, hitX, hitY, '#FFFFFF', 2.5);
this._drawArrowhead(ctx, hitX, hitY, Math.atan2(hitY - incStartY, hitX - incStartX), '#FFFFFF');
for (const s of OB_SPECTRAL) {
const n2w = _nAtWavelength(this.n2, s.nm);
const sinT2w = (this.n1 / n2w) * Math.sin(theta1Rad);
if (Math.abs(sinT2w) > 1) continue;
const t2w = Math.asin(sinT2w);
const col = wavelengthToRGB(s.nm);
ctx.globalAlpha = 0.8;
this._drawRay(ctx, hitX, hitY, hitX + Math.sin(t2w) * rayLen, hitY + Math.cos(t2w) * rayLen, col, 1.8);
ctx.globalAlpha = 1;
}
// Reflected ray (partial, semi-transparent)
ctx.globalAlpha = 0.3;
this._drawRay(ctx, hitX, hitY, hitX + incDx * rayLen * 0.7, hitY - incDy * rayLen * 0.7, '#FFFFFF', 1.5);
ctx.globalAlpha = 1;
return;
}
this._drawRay(ctx, incStartX, incStartY, hitX, hitY, incColor, 2.5);
this._drawArrowhead(ctx, hitX, hitY, Math.atan2(hitY - incStartY, hitX - incStartX), incColor);
const refDx = incDx, refDy = -incDy;
const refEndX = hitX + refDx * rayLen, refEndY = hitY + refDy * rayLen;
const refAlpha = isTIR ? 1.0 : Math.max(0.3, Math.sqrt(R));
ctx.globalAlpha = refAlpha;
this._drawRay(ctx, hitX, hitY, refEndX, refEndY, '#EF476F', 2.5);
this._drawArrowhead(ctx, refEndX, refEndY, Math.atan2(refEndY - hitY, refEndX - hitX), '#EF476F');
ctx.globalAlpha = 1;
if (!isTIR) {
const theta2Rad = Math.asin(sinTheta2);
const refracDx = Math.sin(theta2Rad), refracDy = Math.cos(theta2Rad);
const refracEndX = hitX + refracDx * rayLen, refracEndY = hitY + refracDy * rayLen;
const T = 1 - R;
ctx.globalAlpha = Math.max(0.3, Math.sqrt(T));
// Refracted ray: use wavelength color with slight shift for refracted
this._drawRay(ctx, hitX, hitY, refracEndX, refracEndY, incColor, 2.5);
this._drawArrowhead(ctx, refracEndX, refracEndY, Math.atan2(refracEndY - hitY, refracEndX - hitX), incColor);
ctx.globalAlpha = 1;
}
}
_drawDispersion(ctx, hitX, hitY, midY, theta1Rad, rayLen) {
const spectral = [
{ color: '#FF0000', wave: 656 }, { color: '#FF7F00', wave: 589 }, { color: '#FFFF00', wave: 550 },
{ color: '#00FF00', wave: 510 }, { color: '#00FFFF', wave: 475 }, { color: '#0000FF', wave: 450 },
{ color: '#8B00FF', wave: 400 },
];
const incDx = Math.sin(theta1Rad), incDy = Math.cos(theta1Rad);
const incStartX = hitX - incDx * rayLen, incStartY = hitY - incDy * rayLen;
this._drawRay(ctx, incStartX, incStartY, hitX, hitY, '#FFFFFF', 2.5);
this._drawArrowhead(ctx, hitX, hitY, Math.atan2(hitY - incStartY, hitX - incStartX), '#FFFFFF');
const A = this.n2 - 4500 / (550 * 550), B = 4500;
for (const s of spectral) {
const n2w = A + B / (s.wave * s.wave);
const sinT2 = (this.n1 / n2w) * Math.sin(theta1Rad);
if (Math.abs(sinT2) > 1) continue;
const t2 = Math.asin(sinT2), dx = Math.sin(t2), dy = Math.cos(t2);
ctx.globalAlpha = 0.85;
this._drawRay(ctx, hitX, hitY, hitX + dx * rayLen, hitY + dy * rayLen, s.color, 1.5);
ctx.globalAlpha = 1;
}
const refDx = incDx, refDy = -incDy;
ctx.globalAlpha = 0.35;
this._drawRay(ctx, hitX, hitY, hitX + refDx * rayLen * 0.7, hitY + refDy * rayLen * 0.7, '#FFFFFF', 1.5);
ctx.globalAlpha = 1;
}
_drawRay(ctx, x1, y1, x2, y2, color, width) {
const drawFn = () => {
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.save(); ctx.shadowColor = color; ctx.shadowBlur = 8; ctx.globalAlpha = 0.3;
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.restore();
};
if (window.LabFX) LabFX.glow.drawGlow(ctx, drawFn, { color, intensity: 8 });
else drawFn();
}
_drawArrowhead(ctx, x, y, angle, color) {
const aLen = 10;
ctx.fillStyle = color;
ctx.beginPath(); ctx.moveTo(x, y);
ctx.lineTo(x - aLen * Math.cos(angle - 0.3), y - aLen * Math.sin(angle - 0.3));
ctx.lineTo(x - aLen * Math.cos(angle + 0.3), y - aLen * Math.sin(angle + 0.3));
ctx.closePath(); ctx.fill();
}
_drawAngleArcs(ctx, hitX, hitY, theta1Rad, sinTheta2, isTIR) {
const arcR = 50, font = '12px Manrope, system-ui, sans-serif';
if (this.angle > 1) {
ctx.strokeStyle = 'rgba(155,93,229,0.6)'; ctx.lineWidth = 1.5;
ctx.beginPath();
const normAngle = -Math.PI / 2, incAngle = -Math.PI / 2 - theta1Rad;
ctx.arc(hitX, hitY, arcR, Math.min(incAngle, normAngle), Math.max(incAngle, normAngle)); ctx.stroke();
ctx.font = font; ctx.fillStyle = '#9B5DE5'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const midA = normAngle - theta1Rad / 2;
ctx.fillText('θ₁=' + this.angle.toFixed(1) + '°', hitX + (arcR + 20) * Math.cos(midA), hitY + (arcR + 20) * Math.sin(midA));
}
if (!isTIR && Math.abs(sinTheta2) <= 1) {
const theta2Rad = Math.asin(sinTheta2);
if (theta2Rad > 0.02) {
ctx.strokeStyle = 'rgba(6,214,224,0.6)'; ctx.lineWidth = 1.5;
ctx.beginPath();
const normDown = Math.PI / 2, refAngle = Math.PI / 2 + theta2Rad;
ctx.arc(hitX, hitY, arcR * 0.8, Math.min(normDown, refAngle), Math.max(normDown, refAngle)); ctx.stroke();
const angle2Deg = theta2Rad * 180 / Math.PI;
ctx.font = font; ctx.fillStyle = '#06D6E0'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const midA2 = normDown + theta2Rad / 2;
ctx.fillText('θ₂=' + angle2Deg.toFixed(1) + '°', hitX + (arcR * 0.8 + 20) * Math.cos(midA2), hitY + (arcR * 0.8 + 20) * Math.sin(midA2));
}
}
}
_drawMediumLabels(ctx, W, H, midY) {
ctx.font = '13px Manrope, system-ui, sans-serif'; ctx.textBaseline = 'middle';
ctx.fillStyle = 'rgba(155,93,229,0.6)'; ctx.textAlign = 'left';
ctx.fillText('n₁ = ' + this.n1.toFixed(2), 16, midY - 30);
ctx.fillStyle = 'rgba(6,214,224,0.6)';
ctx.fillText('n₂ = ' + this.n2.toFixed(2), 16, midY + 30);
const theta1Rad = this.angle * Math.PI / 180;
const sinT2 = (this.n1 / this.n2) * Math.sin(theta1Rad);
if (Math.abs(sinT2) > 1) {
ctx.font = 'bold 14px Manrope, system-ui, sans-serif'; ctx.fillStyle = '#EF476F'; ctx.textAlign = 'center';
ctx.fillText('Полное внутреннее отражение (ПВО)', W / 2, midY + 60);
}
}
_drawInfoBox(ctx, isTIR, R) {
const boxW = 220, boxH = 72, bx = this.W - boxW - 12, by = 12;
ctx.fillStyle = 'rgba(22,22,38,0.85)'; ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill();
ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.fillText('n₁·sin(θ₁) = n₂·sin(θ₂)', bx + 10, by + 10);
const info = this.info();
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillText('θ₁ = ' + info.angle1 + '° θ₂ = ' + (info.isTIR ? 'ПВО' : info.angle2 + '°'), bx + 10, by + 28);
const rPct = (R * 100).toFixed(1), tPct = ((1 - R) * 100).toFixed(1);
ctx.fillStyle = '#EF476F'; ctx.fillText('R = ' + rPct + '%', bx + 10, by + 46);
ctx.fillStyle = '#06D6E0'; ctx.fillText('T = ' + (isTIR ? '0' : tPct) + '%', bx + 90, by + 46);
if (info.criticalAngle !== null) { ctx.fillStyle = '#FFD166'; ctx.fillText('θc = ' + info.criticalAngle + '°', bx + 160, by + 46); }
}
_bindEvents() {
const cv = this.canvas;
const getPos = (e) => {
const r = cv.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
return { mx: (t.clientX - r.left) * (this.W / r.width), my: (t.clientY - r.top) * (this.H / r.height) };
};
const hitTest = (mx, my) => {
const hitX = this.W / 2, hitY = this.H / 2;
if (my >= hitY) return false;
const dist = Math.hypot(mx - hitX, my - hitY);
return dist > 20 && dist < Math.max(this.W, this.H) * 0.6;
};
const angleFromMouse = (mx, my) => {
const hitX = this.W / 2, hitY = this.H / 2;
const dx = mx - hitX, dy = hitY - my;
return Math.max(0, Math.min(89, Math.atan2(Math.abs(dx), dy) * 180 / Math.PI));
};
const onDown = (e) => { const { mx, my } = getPos(e); if (hitTest(mx, my)) this._drag = true; };
const onMove = (e) => {
if (!this._drag) return;
if (e.cancelable) e.preventDefault();
const { mx, my } = getPos(e);
this.angle = angleFromMouse(mx, my);
this.draw(); this._emit();
};
const onUp = () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = false; };
cv.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
cv.addEventListener('touchstart', e => { if (e.touches.length === 1) onDown(e); }, { passive: true });
cv.addEventListener('touchmove', e => onMove(e), { passive: false });
cv.addEventListener('touchend', onUp);
cv.addEventListener('mousemove', e => {
if (this._drag) { cv.style.cursor = 'grabbing'; return; }
const { mx, my } = getPos(e);
cv.style.cursor = hitTest(mx, my) ? 'grab' : 'default';
});
}
}
/* ─────────────────────────────────────────────────────────────
4. FREE-BUILD MULTI-LENS SIM (Agent OB-A3)
Cascaded image formation — each lens treats the previous
image as its object. Two-lens effective focal length is
F = f1*f2 / (f1+f2-d).
───────────────────────────────────────────────────────────────*/
class FreeBuildSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
/* elements: { x_frac, f, id } sorted left→right at draw time */
this.elements = [
{ x_frac: 0.30, f: 120, id: 1 },
{ x_frac: 0.65, f: 90, id: 2 },
];
this.objFrac = 0.10;
this.objH = 60;
this._drag = null;
this.onUpdate = null;
this._bindEvents();
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
fit() {
const dpr = window.devicePixelRatio || 1;
const w = this.canvas.offsetWidth || 600;
const h = this.canvas.offsetHeight || 400;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = w; this.H = h;
}
/* Returns { stages, totalM, sysFocal, elems } */
_computeChain() {
const W = this.W;
const elems = [...this.elements].sort((a, b) => a.x_frac - b.x_frac);
let curX = this.objFrac * W;
let curH = this.objH;
const stages = [];
let totalM = 1;
for (const el of elems) {
const lensX = el.x_frac * W;
const dO = lensX - curX;
if (Math.abs(dO) < 1) continue;
const f = el.f;
const denom = dO - f;
let dI, imgH, M;
if (Math.abs(denom) < 0.5) {
dI = Infinity; imgH = Infinity; M = Infinity;
} else {
dI = (f * dO) / denom;
M = -(dI / dO);
imgH = M * curH;
}
const imgX = lensX + dI;
const isVirt = isFinite(dI) && dI < 0;
stages.push({ objX: curX, objH: curH, lensX, f, imgX, imgH, dO, dI, M, isVirt, el });
totalM *= (isFinite(M) ? M : 1);
curX = isFinite(imgX) ? imgX : lensX + 1;
curH = isFinite(imgH) ? imgH : curH;
}
let sysFocal = null;
if (elems.length === 2) {
const f1 = elems[0].f, f2 = elems[1].f;
const d = (elems[1].x_frac - elems[0].x_frac) * W;
const dn = f1 + f2 - d;
if (Math.abs(dn) > 0.5) sysFocal = (f1 * f2) / dn;
}
return { stages, totalM, sysFocal, elems };
}
draw() {
const { ctx, W, H } = this;
if (!W || !H) return;
const ay = H / 2;
ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H);
// axis
ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1; ctx.setLineDash([6, 4]);
ctx.beginPath(); ctx.moveTo(0, ay); ctx.lineTo(W, ay); ctx.stroke();
ctx.setLineDash([]);
const { stages, totalM, sysFocal, elems } = this._computeChain();
const objX = this.objFrac * W;
// object arrow
this._fbArrow(ctx, objX, ay, objX, ay - this.objH, '#9B5DE5', false);
// lenses + focal markers
elems.forEach(el => {
const lx = el.x_frac * W;
this._fbLens(ctx, lx, ay, el.f);
const fp = lx + el.f;
if (fp > 10 && fp < W - 10) {
ctx.fillStyle = 'rgba(6,214,224,0.5)'; ctx.beginPath(); ctx.arc(fp, ay, 3.5, 0, Math.PI * 2); ctx.fill();
}
});
// chain rays
this._drawChainRays(ctx, ay, stages);
// intermediate image arrows
const imgPal = ['#EF476F', '#FFD166', '#7BF5A4', '#06D6E0'];
stages.forEach((s, i) => {
if (!isFinite(s.dI)) return;
const col = imgPal[i % imgPal.length];
this._fbArrow(ctx, s.imgX, ay, s.imgX, ay - s.imgH, col, s.isVirt);
ctx.font = '10px Manrope, system-ui, sans-serif';
ctx.fillStyle = col; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText('Изобр.' + (i + 1), s.imgX, ay - Math.abs(s.imgH) - 6);
});
this._drawInfoPanel(ctx, stages, totalM, sysFocal);
// OB_FX visual depth layer
{
const objX = this.objFrac * this.W;
_drawOBFXLayer(ctx, 'freebuild', { srcX: objX, srcY: ay - this.objH });
}
}
/* Trace 3 characteristic rays through all lenses (thin-lens matrix method) */
_drawChainRays(ctx, ay, stages) {
if (!stages.length) return;
const colors = ['#06D6E0', '#7BF5A4', '#FFD166'];
const s0 = stages[0];
const W = this.W;
const rays = [
{ curY: ay - s0.objH, slope: 0 }, // parallel to axis
{ curY: ay - s0.objH, slope: (ay - (ay - s0.objH)) / (s0.lensX - s0.objX) }, // through lens centre
{ curY: ay - s0.objH, slope: (ay - (ay - s0.objH)) / ((s0.lensX - s0.f) - s0.objX) }, // through F
];
rays.forEach((ray, ri) => {
const col = colors[ri];
const doGlow = (fn) => {
if (window.LabFX) LabFX.glow.drawGlow(ctx, fn, { color: col, intensity: 6 });
else fn();
};
doGlow(() => {
ctx.strokeStyle = col; ctx.lineWidth = 1.4; ctx.setLineDash([]);
let curX = s0.objX;
let curY = ray.curY;
let slope = isFinite(ray.slope) ? ray.slope : 0;
ctx.beginPath(); ctx.moveTo(curX, curY);
stages.forEach(st => {
const lx = st.lensX;
const hitY = curY + slope * (lx - curX);
ctx.lineTo(lx, hitY);
// thin-lens refraction: slope_out = slope_in - (hitY - ay) / f
const hRel = hitY - ay;
slope = slope - hRel / st.f;
curX = lx;
curY = hitY;
});
const extX = Math.min(W + 10, curX + 400);
ctx.lineTo(extX, curY + slope * (extX - curX));
ctx.stroke();
});
});
}
_fbLens(ctx, lx, ay, f) {
const lh = Math.min(this.H * 0.36, 130);
const conv = f > 0;
ctx.strokeStyle = 'rgba(155,93,229,0.8)'; ctx.lineWidth = 2.5;
if (conv) {
const b = Math.min(16, Math.abs(f) * 0.1);
ctx.beginPath(); ctx.moveTo(lx, ay - lh); ctx.quadraticCurveTo(lx + b, ay, lx, ay + lh); ctx.stroke();
ctx.beginPath(); ctx.moveTo(lx, ay - lh); ctx.quadraticCurveTo(lx - b, ay, lx, ay + lh); ctx.stroke();
} else {
const b = Math.min(12, Math.abs(f) * 0.08);
ctx.beginPath(); ctx.moveTo(lx, ay - lh); ctx.quadraticCurveTo(lx - b, ay, lx, ay + lh); ctx.stroke();
ctx.beginPath(); ctx.moveTo(lx, ay - lh); ctx.quadraticCurveTo(lx + b, ay, lx, ay + lh); ctx.stroke();
}
ctx.strokeStyle = 'rgba(155,93,229,0.25)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(lx, ay - lh); ctx.lineTo(lx, ay + lh); ctx.stroke();
// label f value
ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.fillStyle = 'rgba(155,93,229,0.9)';
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText('f=' + f.toFixed(0), lx, ay + lh + 4);
}
_fbArrow(ctx, x1, y1, x2, y2, color, dashed) {
ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 2.5;
if (dashed) ctx.setLineDash([5, 4]);
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.setLineDash([]);
if (Math.abs(y2 - y1) < 2) return;
const angle = Math.atan2(y2 - y1, x2 - x1), aLen = 9;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - aLen * Math.cos(angle - 0.35), y2 - aLen * Math.sin(angle - 0.35));
ctx.lineTo(x2 - aLen * Math.cos(angle + 0.35), y2 - aLen * Math.sin(angle + 0.35));
ctx.closePath(); ctx.fill();
}
_drawInfoPanel(ctx, stages, totalM, sysFocal) {
const bx = 8, by = 8, bw = 192, lh = 16;
const rows = [{ text: 'Объект', col: '#9B5DE5' }];
stages.forEach((s, i) => {
rows.push({ text: 'Лин.' + (i + 1) + ' f=' + s.f.toFixed(0), col: 'rgba(155,93,229,0.85)' });
const dpStr = isFinite(s.dI) ? s.dI.toFixed(0) : '∞';
const mStr = isFinite(s.M) ? s.M.toFixed(2) : '∞';
rows.push({ text: " Изобр." + (i + 1) + " d'=" + dpStr + ' M=' + mStr, col: isFinite(s.M) ? (s.isVirt ? '#FFD166' : '#EF476F') : '#888' });
});
const bh = rows.length * lh + 46;
ctx.save();
ctx.fillStyle = 'rgba(22,22,38,0.88)';
ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 8); ctx.fill();
ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
let cy = by + 8;
rows.forEach(r => { ctx.fillStyle = r.col; ctx.fillText(r.text, bx + 8, cy); cy += lh; });
cy += 4;
ctx.fillStyle = 'rgba(255,255,255,0.75)';
ctx.fillText('Г = ' + (isFinite(totalM) ? totalM.toFixed(3) : '∞'), bx + 8, cy); cy += lh;
if (sysFocal !== null) {
ctx.fillStyle = '#06D6E0';
ctx.fillText('F сист. = ' + sysFocal.toFixed(0) + ' px', bx + 8, cy);
}
ctx.restore();
}
_bindEvents() {
const cv = this.canvas;
const getPos = (e) => {
const r = cv.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
return { mx: (t.clientX - r.left) * (this.W / r.width), my: (t.clientY - r.top) * (this.H / r.height) };
};
const hitTest = (mx, my) => {
const ay = this.H / 2;
for (let i = 0; i < this.elements.length; i++) {
const lx = this.elements[i].x_frac * this.W;
if (Math.abs(mx - lx) < 14 && Math.abs(my - ay) < 100) return { what: 'lens', idx: i };
}
const ox = this.objFrac * this.W;
if (Math.hypot(mx - ox, my - (ay - this.objH)) < 18) return { what: 'object' };
return null;
};
cv.addEventListener('mousedown', e => { const p = getPos(e); this._drag = hitTest(p.mx, p.my); });
window.addEventListener('mousemove', e => {
if (!this._drag) return;
if (e.cancelable) e.preventDefault();
const { mx } = getPos(e);
const frac = Math.max(0.02, Math.min(0.98, mx / this.W));
if (this._drag.what === 'object') { this.objFrac = frac; }
else if (this._drag.what === 'lens') { this.elements[this._drag.idx].x_frac = frac; }
this.draw();
if (this.onUpdate) this.onUpdate(this._computeChain());
});
window.addEventListener('mouseup', () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = null; });
cv.addEventListener('mousemove', e => {
if (this._drag) { cv.style.cursor = 'grabbing'; return; }
const { mx, my } = getPos(e);
cv.style.cursor = hitTest(mx, my) ? 'grab' : 'default';
});
cv.addEventListener('touchstart', e => {
if (e.touches.length === 1) { const p = getPos(e); this._drag = hitTest(p.mx, p.my); }
}, { passive: true });
cv.addEventListener('touchmove', e => {
if (!this._drag) return;
if (e.cancelable) e.preventDefault();
const { mx } = getPos(e);
const frac = Math.max(0.02, Math.min(0.98, mx / this.W));
if (this._drag.what === 'object') this.objFrac = frac;
else if (this._drag.what === 'lens') this.elements[this._drag.idx].x_frac = frac;
this.draw();
}, { passive: false });
cv.addEventListener('touchend', () => { this._drag = null; });
}
addLens(f) {
const lastFrac = this.elements.length ? Math.max(...this.elements.map(e => e.x_frac)) : 0.5;
const newFrac = Math.min(0.92, lastFrac + 0.18);
const id = (this.elements.reduce((m, e) => Math.max(m, e.id), 0)) + 1;
this.elements.push({ x_frac: newFrac, f: +f || 100, id });
this.draw();
}
removeLens() {
if (this.elements.length > 1) this.elements.pop();
this.draw();
}
setLensF(idx, f) {
if (this.elements[idx]) { this.elements[idx].f = Math.max(-300, Math.min(300, +f || 100)); this.draw(); }
}
}
/* ─────────────────────────────────────────────────────────────
4a-BIS. OPTICAL BENCH CONSTRUCTOR — general 2D ray tracer
Mixed elements (lens, mirror, aperture, screen, prism) + sources.
───────────────────────────────────────────────────────────────*/
class BenchSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
this.onUpdate = null;
this._drag = null;
this._nextId = 1;
// source: object arrow by default. `ang` (deg) aims point/single/laser/parallel.
this.source = { kind: 'object', xf: 0.07, yf: 0, h: 70, spread: 0.32, rays: 9, ang: 0, rayMode: 'char' };
// elements along the bench, positioned by x-fraction; centred on the axis
this.elements = [
this._mk('lens', { xf: 0.40, f: 130, ap: 95 }),
this._mk('screen', { xf: 0.86 }),
];
this.selectedId = '__src'; // source selected by default so its controls show on open
this._bindEvents();
this._ro = new ResizeObserver(() => { this.fit(); this.draw(); });
this._ro.observe(canvas.parentElement || canvas);
}
_mk(type, p) {
const id = this._nextId++;
const base = { id, type, xf: p.xf != null ? p.xf : 0.5 };
if (type === 'lens') return { ...base, f: p.f != null ? p.f : 130, ap: p.ap || 95 };
if (type === 'mirror') return { ...base, kind: p.kind || 'concave', R: p.R != null ? p.R : 320, ap: p.ap || 95 };
if (type === 'aperture') return { ...base, gap: p.gap != null ? p.gap : 40 };
if (type === 'screen') return { ...base };
if (type === 'prism') return { ...base, apex: p.apex != null ? p.apex : 50, n: p.n != null ? p.n : 1.52, size: p.size || 90 };
if (type === 'interface') return { ...base, n1: p.n1 != null ? p.n1 : 1, n2: p.n2 != null ? p.n2 : 1.5, ap: p.ap || 110 };
if (type === 'slab') return { ...base, n: p.n != null ? p.n : 1.5, t: p.t != null ? p.t : 60, ap: p.ap || 95 };
return base;
}
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;
}
/* ── element API (used by the inspector) ── */
addElement(type) {
const xf = Math.min(0.92, (this.elements.length ? Math.max(...this.elements.map(e => e.xf)) : 0.4) + 0.14);
const el = this._mk(type, { xf });
this.elements.push(el);
this.selectedId = el.id;
this._changed();
return el;
}
removeElement(id) {
this.elements = this.elements.filter(e => e.id !== id);
if (this.selectedId === id) this.selectedId = null;
this._changed();
}
selectElement(id) { this.selectedId = id; this._changed(); }
updateElement(id, key, val) {
const el = this.elements.find(e => e.id === id);
if (!el) return;
el[key] = (key === 'kind') ? val : +val;
this._redraw(); // canvas only — never rebuild the inspector mid-slider-drag
}
setSource(key, val) {
this.source[key] = (key === 'kind' || key === 'rayMode') ? val : +val; // string keys vs numeric
this._redraw();
}
getSelected() { return this.elements.find(e => e.id === this.selectedId) || null; }
_redraw() { this.draw(); }
_changed() { this.draw(); if (this.onUpdate) this.onUpdate(); } // draw + rebuild inspector
/* ── geometry helpers ── */
_ex(el) { return el.xf * this.W; }
_ay() { return this.H / 2; }
_sy() { return this._ay() + (this.source.yf || 0) * (this.H / 2 - 14); } // source y (vertical position)
/* Emit the initial rays from the source. */
_emitRays() {
const ay = this._sy(); // emission height respects the source vertical position
const sx = this.source.xf * this.W;
const rays = [];
// white light → one sub-ray per spectral sample (they coincide until a prism disperses them)
const wls = window._obWhiteLight ? OB_SPECTRAL.map(s => s.nm) : [window._obWavelength || 540];
const push = (x, y, ang, role) => {
for (const wl of wls) rays.push({ x, y, dx: Math.cos(ang), dy: Math.sin(ang), wl, role: role || null, pts: [{ x, y }], alive: true, bounces: 0 });
};
const aim = (this.source.ang || 0) * Math.PI / 180;
if (this.source.kind === 'single') {
push(sx, ay, aim); // one aimable ray
} else if (this.source.kind === 'laser') {
const n = 3, hh = 7; // narrow collimated beam
const px = -Math.sin(aim), py = Math.cos(aim); // perpendicular to aim
for (let i = 0; i < n; i++) {
const o = (i - (n - 1) / 2) * hh;
push(sx + px * o, ay + py * o, aim);
}
} else if (this.source.kind === 'parallel') {
const n = 9, hh = 90;
const px = -Math.sin(aim), py = Math.cos(aim);
for (let i = 0; i < n; i++) {
const o = -hh + (2 * hh) * (i / (n - 1));
push(sx + px * o, ay + py * o, aim);
}
} else if (this.source.kind === 'point') {
const n = this.source.rays, A = this.source.spread;
for (let i = 0; i < n; i++) push(sx, ay, aim - A + 2 * A * (i / (n - 1)));
} else { // object arrow
const axis = this._ay(); // optical axis (lens centre / focus sit here)
const tipY = ay - this.source.h, baseY = ay;
const firstLens = this.elements.filter(e => e.type === 'lens').sort((a, b) => a.xf - b.xf)[0];
if (this.source.rayMode === 'char' && firstLens) {
// textbook construction: 23 characteristic rays from the tip + axial ray from the base
const lensX = firstLens.xf * this.W, f = firstLens.f;
const aimAt = (tx, ty) => Math.atan2(ty - tipY, tx - sx);
push(sx, tipY, 0, 'char1'); // 1) parallel to axis → through far focus F'
push(sx, tipY, aimAt(lensX, axis), 'char2'); // 2) through the optical centre → straight
const Fx = lensX - f; // front focal point
if (f > 0 && Fx > sx + 5) push(sx, tipY, aimAt(Fx, axis), 'char3'); // 3) through F → emerges parallel
push(sx, baseY, 0, 'base'); // base lies on the axis
} else {
// physical bundle: a fan from tip and base
const n = Math.max(2, this.source.rays | 0), A = this.source.spread;
[tipY, baseY].forEach(y0 => {
for (let i = 0; i < n; i++) push(sx, y0, -A + 2 * A * (i / (n - 1)));
});
}
}
return rays;
}
/* Trace one ray through the system, filling ray.pts. */
_traceRay(ray) {
const eps = 0.5, maxSteps = 40;
const elems = this.elements;
for (let step = 0; step < maxSteps && ray.alive; step++) {
// find nearest element plane ahead
let best = null;
for (const el of elems) {
const ex = this._ex(el);
if (Math.abs(ray.dx) < 1e-6) continue;
const t = (ex - ray.x) / ray.dx;
if (t > eps && (!best || t < best.t)) best = { t, el, ex };
}
// boundary intersection
const tBound = this._boundT(ray);
if (!best || tBound < best.t) {
const hx = ray.x + ray.dx * tBound, hy = ray.y + ray.dy * tBound;
ray.pts.push({ x: hx, y: hy }); ray.alive = false; break;
}
// advance to element
const hx = ray.x + ray.dx * best.t, hy = ray.y + ray.dy * best.t;
ray.x = hx; ray.y = hy;
const interacted = this._interact(ray, best.el, hy - this._ay());
ray.pts.push({ x: ray.x, y: ray.y });
if (!interacted) { ray.x += ray.dx * eps; ray.y += ray.dy * eps; } // missed → step past
if (ray.bounces > 16) ray.alive = false;
}
return ray;
}
_boundT(ray) {
const ts = [];
if (ray.dx > 1e-9) ts.push((this.W - ray.x) / ray.dx);
else if (ray.dx < -1e-9) ts.push((0 - ray.x) / ray.dx);
if (ray.dy > 1e-9) ts.push((this.H - ray.y) / ray.dy);
else if (ray.dy < -1e-9) ts.push((0 - ray.y) / ray.dy);
return ts.length ? Math.min(...ts.filter(t => t > 0)) : 1e6;
}
/* Apply an element. Returns true if it interacted (false = ray missed it). */
_interact(ray, el, yRel) {
const norm = (x, y) => { const l = Math.hypot(x, y) || 1; ray.dx = x / l; ray.dy = y / l; };
if (el.type === 'lens') {
if (Math.abs(yRel) > el.ap) { ray.alive = false; return true; } // mount blocks outside aperture
const sgn = Math.sign(ray.dx) || 1;
const w = ray.dy / Math.abs(ray.dx);
norm(sgn, w - yRel / el.f);
return true;
}
if (el.type === 'interface') {
if (Math.abs(yRel) > el.ap) return false;
// Snell at a vertical plane (normal = x). Tangential (y) component scales by n_i/n_t.
const goingRight = ray.dx > 0;
const ni = goingRight ? el.n1 : el.n2;
const nt = goingRight ? el.n2 : el.n1;
const dyT = (ni / nt) * ray.dy; // sinθ_t (tangential preserved)
if (Math.abs(dyT) >= 1) { ray.dx = -ray.dx; ray.bounces++; return true; } // total internal reflection
const sgn = Math.sign(ray.dx) || 1;
ray.dy = dyT; ray.dx = sgn * Math.sqrt(Math.max(0, 1 - dyT * dyT));
return true;
}
if (el.type === 'slab') {
if (Math.abs(yRel) > el.ap) return false;
// parallel plate: ray exits parallel but laterally shifted (refract in, travel t, refract out)
const sinI = ray.dy; // |d|=1 → y-comp is sinθ from axis
const sinT = sinI / el.n;
const tanT = sinT / Math.sqrt(Math.max(1e-6, 1 - sinT * sinT));
const sgn = Math.sign(ray.dx) || 1;
ray.pts.push({ x: ray.x, y: ray.y }); // entry on the front face
ray.x += sgn * el.t; // emerge on the far face
ray.y += tanT * el.t; // inside-the-glass vertical travel
return true; // direction unchanged (parallel faces); tracer pushes exit
}
if (el.type === 'mirror') {
if (Math.abs(yRel) > el.ap) { ray.alive = false; return true; }
ray.dx = -ray.dx; // reflect about the vertical plane
ray.bounces++;
if (el.kind !== 'plane') {
const fM = (el.kind === 'concave' ? 1 : -1) * el.R / 2;
const sgn = Math.sign(ray.dx) || 1;
const w = ray.dy / Math.abs(ray.dx);
norm(sgn, w - yRel / fM);
}
return true;
}
if (el.type === 'aperture') {
if (Math.abs(yRel) > el.gap) ray.alive = false; // blocked by the stop
return true;
}
if (el.type === 'screen') {
ray.hitY = ray.y; ray.hitEl = el.id; ray.alive = false; // absorbed, hit recorded
return true;
}
if (el.type === 'prism') {
return this._prismInteract(ray, el, yRel);
}
return true;
}
// Thin-prism deviation δ = (n1)·A toward the base, with chromatic dispersion
// via n(λ) — different wavelengths bend differently → spectrum fan.
_prismInteract(ray, el, yRel) {
if (Math.abs(yRel) > el.size) return false;
const n = (typeof _nAtWavelength === 'function') ? _nAtWavelength(el.n, ray.wl) : el.n;
const A = el.apex * Math.PI / 180;
const dev = (n - 1) * A; // radians, toward the base (+y)
const sgn = Math.sign(ray.dx) || 1;
const ang = Math.atan2(ray.dy, ray.dx) + sgn * dev;
ray.dx = Math.cos(ang); ray.dy = Math.sin(ang);
return true;
}
draw() {
const { ctx, W, H } = this;
if (!W || !H) return;
const ay = this._ay();
ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H);
// optical 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([]);
// trace rays — colour each by its wavelength (so dispersion shows as a fan)
const rays = this._emitRays();
const white = !!window._obWhiteLight;
ctx.lineWidth = 1.1;
for (const ray of rays) {
this._traceRay(ray);
ctx.strokeStyle = (typeof wavelengthToRGB === 'function') ? wavelengthToRGB(ray.wl) : '#06D6E0';
ctx.globalAlpha = white ? 0.5 : 0.82;
ctx.beginPath();
ray.pts.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y));
ctx.stroke();
}
ctx.globalAlpha = 1;
// source + elements
this._drawSource(ctx, ay);
for (const el of this.elements) this._drawElement(ctx, el, ay);
// image formed on screens (where rays land)
this._drawScreenHits(ctx, rays);
// textbook construction overlay (labels, image arrow, dashed extensions)
if (this.source.kind === 'object' && (this.source.rayMode || 'char') === 'char') {
this._drawCharConstruction(ctx, rays, ay);
}
if (typeof _drawOBFXLayer === 'function') {
// FX anchored at the actual source point (only the object arrow has a raised tip)
const fxY = this._sy() - (this.source.kind === 'object' ? this.source.h : 0);
_drawOBFXLayer(ctx, 'freebuild', { srcX: this.source.xf * W, srcY: fxY });
}
}
// Glowing spots on each screen where rays land → the image becomes visible.
_drawScreenHits(ctx, rays) {
const screens = this.elements.filter(e => e.type === 'screen');
if (!screens.length) return;
ctx.save();
ctx.globalCompositeOperation = 'lighter'; // additive → overlapping rays brighten
for (const sc of screens) {
const x = this._ex(sc);
for (const r of rays) {
if (r.hitEl !== sc.id) continue;
const col = (typeof wavelengthToRGB === 'function') ? wavelengthToRGB(r.wl) : '#fff';
const g = ctx.createRadialGradient(x, r.hitY, 0, x, r.hitY, 9);
g.addColorStop(0, col); g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g; ctx.globalAlpha = 0.5;
ctx.beginPath(); ctx.arc(x, r.hitY, 9, 0, Math.PI * 2); ctx.fill();
}
}
ctx.restore();
}
_lineIntersect(a, b) {
const den = a.dx * b.dy - a.dy * b.dx;
if (Math.abs(den) < 1e-9) return null; // parallel → no finite intersection
const t = ((b.x - a.x) * b.dy - (b.y - a.y) * b.dx) / den;
return { x: a.x + t * a.dx, y: a.y + t * a.dy };
}
// Textbook overlay for single-lens characteristic construction:
// labels 1/2/3, the image arrow, and dashed back-extensions for a virtual image.
_drawCharConstruction(ctx, rays, axis) {
const lenses = this.elements.filter(e => e.type === 'lens');
if (lenses.length !== 1) return; // construction is only clean for one lens
const lensX = this._ex(lenses[0]);
ctx.save();
// ── ray labels 1/2/3 near the object ──
ctx.font = 'bold 11px Manrope, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const labelRay = (role, txt) => {
const r = rays.find(x => x.role === role); if (!r || r.pts.length < 2) return;
const p0 = r.pts[0], p1 = r.pts[1];
const d = Math.hypot(p1.x - p0.x, p1.y - p0.y) || 1;
const k = Math.min(40, d * 0.45);
const lx = p0.x + (p1.x - p0.x) / d * k, ly = p0.y + (p1.y - p0.y) / d * k;
ctx.fillStyle = 'rgba(13,13,26,0.7)'; ctx.beginPath(); ctx.arc(lx, ly, 8, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#7BF5A4'; ctx.fillText(txt, lx, ly);
};
labelRay('char1', '1'); labelRay('char2', '2'); labelRay('char3', '3');
// ── image point = intersection of the final segments of rays 1 and 2 ──
const finalLine = (role) => {
const r = rays.find(x => x.role === role); if (!r || r.pts.length < 2) return null;
const b = r.pts[r.pts.length - 1], a = r.pts[r.pts.length - 2];
return { x: a.x, y: a.y, dx: b.x - a.x, dy: b.y - a.y };
};
const L1 = finalLine('char1'), L2 = finalLine('char2');
const P = (L1 && L2) ? this._lineIntersect(L1, L2) : null;
if (P && isFinite(P.x) && isFinite(P.y)) {
const real = P.x > lensX + 2; // real image forms to the right of the lens
const col = real ? '#EF476F' : '#FFD166';
if (!real) {
// virtual image: extend the diverging rays backward (dashed) to the apparent source P
ctx.strokeStyle = col; ctx.setLineDash([5, 4]); ctx.lineWidth = 1;
[L1, L2].forEach(L => { if (!L) return; ctx.beginPath(); ctx.moveTo(L.x, L.y); ctx.lineTo(P.x, P.y); ctx.stroke(); });
ctx.setLineDash([]);
}
this._arrow(ctx, P.x, axis, P.x, P.y, col); // image arrow: base (axis) → tip (P)
ctx.fillStyle = col; ctx.beginPath(); ctx.arc(P.x, P.y, 3.5, 0, Math.PI * 2); ctx.fill();
ctx.font = '10px Manrope, system-ui, sans-serif';
ctx.fillText(real ? 'изображение' : 'мнимое изобр.', P.x, P.y + (P.y < axis ? -14 : 16));
}
ctx.restore();
}
_drawSource(ctx, _ayIgnored) {
const ay = this._sy(); // draw at the source vertical position
const sx = this.source.xf * this.W;
const aim = (this.source.ang || 0) * Math.PI / 180;
ctx.save();
if (this.source.kind === 'object') {
this._arrow(ctx, sx, ay, sx, ay - this.source.h, '#9B5DE5');
} else {
const isLaser = this.source.kind === 'laser', isSingle = this.source.kind === 'single';
ctx.fillStyle = (this.source.kind === 'point' || isSingle) ? '#FFD166' : '#9B5DE5';
ctx.beginPath(); ctx.arc(sx, ay, isLaser ? 4 : 5, 0, Math.PI * 2); ctx.fill();
if (this.source.kind === 'parallel' || isLaser) {
const px = -Math.sin(aim), py = Math.cos(aim), hh = isLaser ? 10 : 90;
ctx.strokeStyle = isLaser ? '#FF5B5B' : '#9B5DE5'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(sx - px * hh, ay - py * hh); ctx.lineTo(sx + px * hh, ay + py * hh); ctx.stroke();
}
// aim arrow for single / laser / point
if (isSingle || isLaser || this.source.kind === 'point') {
this._arrow(ctx, sx, ay, sx + 22 * Math.cos(aim), ay + 22 * Math.sin(aim), isLaser ? '#FF5B5B' : '#FFD166');
}
}
const sel = this.selectedId === '__src';
ctx.fillStyle = sel ? '#fff' : 'rgba(155,93,229,0.9)';
ctx.font = '10px Manrope, system-ui, sans-serif'; ctx.textAlign = 'center';
ctx.fillText('источник', sx, ay + 16);
ctx.restore();
}
_drawElement(ctx, el, ay) {
const x = this._ex(el);
const sel = el.id === this.selectedId;
ctx.save();
ctx.lineWidth = sel ? 3 : 2;
if (el.type === 'lens') {
const conv = el.f >= 0;
ctx.strokeStyle = sel ? '#fff' : '#06D6E0';
ctx.beginPath(); ctx.moveTo(x, ay - el.ap); ctx.lineTo(x, ay + el.ap); ctx.stroke();
// arrow tips to denote converging/diverging
const tip = conv ? 7 : -7;
[[ay - el.ap, 1], [ay + el.ap, -1]].forEach(([yy, s]) => {
ctx.beginPath(); ctx.moveTo(x, yy); ctx.lineTo(x - tip, yy + s * 7); ctx.moveTo(x, yy); ctx.lineTo(x + tip, yy + s * 7); ctx.stroke();
});
// focal markers F and 2F on both sides of the lens
if (conv) {
ctx.fillStyle = 'rgba(6,214,224,0.6)';
[el.f, -el.f, 2 * el.f, -2 * el.f].forEach((d, i) => {
const fx = x + d; if (fx < 6 || fx > this.W - 6) return;
ctx.beginPath(); ctx.arc(fx, ay, 3, 0, Math.PI * 2); ctx.fill();
this._elLabel(ctx, fx, ay - 16, i < 2 ? 'F' : '2F');
});
}
this._elLabel(ctx, x, ay + el.ap + 14, (conv ? 'линза +' : 'линза ') + Math.abs(el.f).toFixed(0));
} else if (el.type === 'interface') {
ctx.fillStyle = 'rgba(96,165,250,0.10)'; ctx.fillRect(x, ay - el.ap, this.W - x, 2 * el.ap);
ctx.strokeStyle = sel ? '#fff' : '#60a5fa';
ctx.beginPath(); ctx.moveTo(x, ay - el.ap); ctx.lineTo(x, ay + el.ap); ctx.stroke();
this._elLabel(ctx, x, ay + el.ap + 14, 'граница ' + el.n1.toFixed(2) + ' | ' + el.n2.toFixed(2));
} else if (el.type === 'slab') {
ctx.fillStyle = 'rgba(123,245,164,0.10)'; ctx.fillRect(x, ay - el.ap, el.t, 2 * el.ap);
ctx.strokeStyle = sel ? '#fff' : '#7BF5A4';
ctx.strokeRect(x, ay - el.ap, el.t, 2 * el.ap);
this._elLabel(ctx, x + el.t / 2, ay + el.ap + 14, 'пластина n=' + el.n.toFixed(2));
} else if (el.type === 'mirror') {
ctx.strokeStyle = sel ? '#fff' : '#A8E063';
ctx.beginPath();
if (el.kind === 'plane') { ctx.moveTo(x, ay - el.ap); ctx.lineTo(x, ay + el.ap); }
else {
const bow = (el.kind === 'concave' ? -1 : 1) * 14;
ctx.moveTo(x, ay - el.ap);
ctx.quadraticCurveTo(x + bow, ay, x, ay + el.ap);
}
ctx.stroke();
// hatch backside
ctx.strokeStyle = 'rgba(168,224,99,0.4)'; ctx.lineWidth = 1;
for (let yy = -el.ap; yy < el.ap; yy += 12) { ctx.beginPath(); ctx.moveTo(x, ay + yy); ctx.lineTo(x + 6, ay + yy + 6); ctx.stroke(); }
this._elLabel(ctx, x, ay + el.ap + 14, 'зеркало ' + ({ plane: 'плоск', concave: 'вогн', convex: 'выпукл' }[el.kind]));
} else if (el.type === 'aperture') {
ctx.strokeStyle = sel ? '#fff' : '#EF476F'; ctx.lineWidth = sel ? 5 : 4;
ctx.beginPath(); ctx.moveTo(x, ay - 110); ctx.lineTo(x, ay - el.gap); ctx.moveTo(x, ay + el.gap); ctx.lineTo(x, ay + 110); ctx.stroke();
this._elLabel(ctx, x, ay + 124, 'диафрагма');
} else if (el.type === 'screen') {
ctx.strokeStyle = sel ? '#fff' : 'rgba(255,255,255,0.7)'; ctx.lineWidth = sel ? 5 : 4;
ctx.beginPath(); ctx.moveTo(x, ay - 110); ctx.lineTo(x, ay + 110); ctx.stroke();
this._elLabel(ctx, x, ay + 124, 'экран');
} else if (el.type === 'prism') {
ctx.strokeStyle = sel ? '#fff' : '#FFD166'; ctx.fillStyle = 'rgba(255,209,102,0.12)';
ctx.beginPath(); ctx.moveTo(x, ay - el.size); ctx.lineTo(x + el.size * 0.7, ay + el.size); ctx.lineTo(x - el.size * 0.7, ay + el.size); ctx.closePath();
ctx.fill(); ctx.stroke();
this._elLabel(ctx, x, ay + el.size + 14, 'призма');
}
ctx.restore();
}
_elLabel(ctx, x, y, text) {
ctx.fillStyle = 'rgba(255,255,255,0.6)'; ctx.font = '10px Manrope, system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText(text, x, y);
}
_arrow(ctx, x0, y0, x1, y1, color) {
ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke();
const a = Math.atan2(y1 - y0, x1 - x0);
ctx.beginPath(); ctx.moveTo(x1, y1);
ctx.lineTo(x1 - 9 * Math.cos(a - 0.4), y1 - 9 * Math.sin(a - 0.4));
ctx.lineTo(x1 - 9 * Math.cos(a + 0.4), y1 - 9 * Math.sin(a + 0.4));
ctx.closePath(); ctx.fill();
}
/* ── interaction: drag + select ── */
_bindEvents() {
const cv = this.canvas;
this._listeners = [];
const on = (t, ty, fn, o) => { t.addEventListener(ty, fn, o); this._listeners.push([t, ty, fn, o]); };
const pos = (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 hit = (mx, my) => {
const ay = this._ay();
// source first (it can sit off-axis), grab around its actual vertical position
const sx = this.source.xf * this.W, sy = this._sy();
if (Math.abs(mx - sx) < 16 && Math.abs(my - sy) < 70) return { kind: 'src' };
for (const el of this.elements) {
if (Math.abs(mx - this._ex(el)) < 14 && Math.abs(my - ay) < 120) return { kind: 'el', id: el.id };
}
return null;
};
on(cv, 'pointerdown', e => {
const { mx, my } = pos(e);
const h = hit(mx, my);
this._drag = h;
if (h) {
this.selectedId = h.kind === 'src' ? '__src' : h.id;
try { cv.setPointerCapture(e.pointerId); } catch (_) {}
this._changed();
} else { this.selectedId = null; this._changed(); }
});
on(cv, 'pointermove', e => {
if (!this._drag) { const { mx, my } = pos(e); cv.style.cursor = hit(mx, my) ? 'grab' : 'default'; return; }
const { mx, my } = pos(e);
const xf = Math.max(0.02, Math.min(0.98, mx / this.W));
if (this._drag.kind === 'src') {
this.source.xf = xf;
// vertical drag too → move the source up/down off the axis
const yf = (my - this._ay()) / (this.H / 2 - 14);
this.source.yf = Math.max(-0.95, Math.min(0.95, yf));
} else { const el = this.elements.find(x => x.id === this._drag.id); if (el) el.xf = xf; }
this._redraw(); // position drag → redraw canvas, keep inspector intact
});
on(cv, 'pointerup', e => { this._drag = null; try { cv.releasePointerCapture(e.pointerId); } catch (_) {} });
}
exportPng() {
try { return this.canvas.toDataURL('image/png'); } catch (_) { return null; }
}
dispose() {
if (this._ro) { this._ro.disconnect(); this._ro = null; }
if (this._listeners) { for (const [t, ty, fn, o] of this._listeners) t.removeEventListener(ty, fn, o); this._listeners = []; }
}
/* ── state (for snapshot / embed) ── */
getState() { return { source: { ...this.source }, elements: this.elements.map(e => ({ ...e })) }; }
setState(st) {
if (!st) return;
if (st.source) this.source = { ...this.source, ...st.source };
if (Array.isArray(st.elements)) {
this.elements = st.elements.map(e => ({ ...e }));
this._nextId = this.elements.reduce((m, e) => Math.max(m, e.id || 0), 0) + 1;
}
this._changed();
}
}
/* ─────────────────────────────────────────────────────────────
4b. PRISM ENGINE
───────────────────────────────────────────────────────────────*/
class PrismSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
this.apexAngle = 60;
this.n0 = 1.5;
this.rotation = 0;
this.incAngle = 30;
this._drag = null;
this.onUpdate = null;
this._bindEvents();
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
fit() {
const dpr = window.devicePixelRatio || 1;
const w = this.canvas.offsetWidth || 600;
const h = this.canvas.offsetHeight || 400;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = w; this.H = h;
}
getParams() { return { apexAngle: this.apexAngle, n0: this.n0, rotation: this.rotation, incAngle: this.incAngle }; }
setParams({ apexAngle, n0, rotation, incAngle } = {}) {
if (apexAngle !== undefined) this.apexAngle = Math.max(20, Math.min(80, +apexAngle));
if (n0 !== undefined) this.n0 = Math.max(1.3, Math.min(2.5, +n0));
if (rotation !== undefined) this.rotation = +rotation % 360;
if (incAngle !== undefined) this.incAngle = Math.max(0, Math.min(80, +incAngle));
this.draw(); this._emit();
}
reset() { this.apexAngle = 60; this.n0 = 1.5; this.rotation = 0; this.incAngle = 30; this.draw(); this._emit(); }
_emit() { if (this.onUpdate) this.onUpdate(this.getParams()); }
draw() {
const ctx = this.ctx, W = this.W, H = this.H;
if (!W || !H) return;
ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = 'rgba(255,255,255,0.1)'; ctx.lineWidth = 1; ctx.setLineDash([6,4]);
ctx.beginPath(); ctx.moveTo(0, H/2); ctx.lineTo(W, H/2); ctx.stroke();
ctx.setLineDash([]);
const cx = W / 2, cy = H / 2;
const size = Math.min(W, H) * 0.30;
const rotRad = this.rotation * Math.PI / 180;
const topA = -Math.PI / 2 + rotRad;
const v = [0, 1, 2].map(i => ({
x: cx + size * Math.cos(topA + i * (2 * Math.PI / 3)),
y: cy + size * Math.sin(topA + i * (2 * Math.PI / 3)),
}));
// Prism body
const prismGrad = ctx.createLinearGradient(v[0].x, v[0].y, v[1].x, v[1].y);
prismGrad.addColorStop(0, 'rgba(100,180,255,0.07)');
prismGrad.addColorStop(0.5, 'rgba(100,180,255,0.16)');
prismGrad.addColorStop(1, 'rgba(100,180,255,0.07)');
ctx.beginPath(); ctx.moveTo(v[0].x, v[0].y); ctx.lineTo(v[1].x, v[1].y); ctx.lineTo(v[2].x, v[2].y); ctx.closePath();
ctx.fillStyle = prismGrad; ctx.fill();
ctx.strokeStyle = 'rgba(100,180,255,0.5)'; ctx.lineWidth = 2; ctx.stroke();
ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.fillStyle = 'rgba(100,180,255,0.7)';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('n = ' + this.n0.toFixed(2), cx, cy + 4);
const isWhite = window._obWhiteLight;
const monoNm = window._obWavelength || 550;
const spectral = isWhite ? OB_SPECTRAL : [{ nm: monoNm }];
// Entry face: v[0] → v[2]; inward normal toward center
const efVec = { x: v[2].x - v[0].x, y: v[2].y - v[0].y };
const efLen = Math.hypot(efVec.x, efVec.y);
let efNorm = { x: -efVec.y / efLen, y: efVec.x / efLen };
if (efNorm.x * (cx - v[0].x) + efNorm.y * (cy - v[0].y) < 0) {
efNorm = { x: -efNorm.x, y: -efNorm.y };
}
const eX = (v[0].x + v[2].x) / 2, eY = (v[0].y + v[2].y) / 2;
/* tangDir = 90° CW rotation of efNorm so that positive incAngle
tilts the ray toward the apex side (natural source-from-left setup) */
const tangDir = { x: efNorm.y, y: -efNorm.x };
const incRad = this.incAngle * Math.PI / 180;
/* incDir = propagation direction (INTO the prism) */
const incDir = {
x: Math.cos(incRad) * efNorm.x + Math.sin(incRad) * tangDir.x,
y: Math.cos(incRad) * efNorm.y + Math.sin(incRad) * tangDir.y,
};
const incDLen = Math.hypot(incDir.x, incDir.y);
incDir.x /= incDLen; incDir.y /= incDLen;
const rayLen = W * 0.45;
/* draw incoming ray from outside (source side) to entry midpoint */
this._drawRayLine(ctx, eX - incDir.x * rayLen, eY - incDir.y * rayLen, eX, eY,
isWhite ? '#FFFFFF' : wavelengthToRGB(monoNm), 2.5);
// Exit face: v[0] → v[1]; outward normal
const exfVec = { x: v[1].x - v[0].x, y: v[1].y - v[0].y };
const exfLen = Math.hypot(exfVec.x, exfVec.y);
let exfNorm = { x: -exfVec.y / exfLen, y: exfVec.x / exfLen };
if (exfNorm.x * (cx - v[0].x) + exfNorm.y * (cy - v[0].y) > 0) {
exfNorm = { x: -exfNorm.x, y: -exfNorm.y };
}
const exitPts = [];
for (const s of spectral) {
const nP = _nAtWavelength(this.n0, s.nm);
const col = wavelengthToRGB(s.nm);
/* Snell vector form: l = incDir (propagation), n = -efNorm (toward incident medium)
cosθ_i = -n·l = efNorm·l; r = μl + (μcosθ_i - cosθ_t)·n */
const cosI = (incDir.x * efNorm.x + incDir.y * efNorm.y);
const sinR = Math.sqrt(Math.max(0, 1 - cosI * cosI)) / nP;
if (sinR > 1) continue;
const cosR = Math.sqrt(1 - sinR * sinR);
const rDir = {
x: (1/nP) * incDir.x + (cosR - (1/nP) * cosI) * efNorm.x,
y: (1/nP) * incDir.y + (cosR - (1/nP) * cosI) * efNorm.y,
};
const rDLen = Math.hypot(rDir.x, rDir.y);
rDir.x /= rDLen; rDir.y /= rDLen;
// Intersect with exit face segment v[0]→v[1]
const det = rDir.x * exfVec.y - rDir.y * exfVec.x;
if (Math.abs(det) < 0.001) continue;
const d0x = v[0].x - eX, d0y = v[0].y - eY;
const tRay = (d0x * exfVec.y - d0y * exfVec.x) / det;
const sFace = (d0x * rDir.y - d0y * rDir.x) / det;
if (tRay < 0 || sFace < -0.01 || sFace > 1.01) continue;
const exitX = eX + tRay * rDir.x, exitY = eY + tRay * rDir.y;
ctx.globalAlpha = isWhite ? 0.65 : 1;
this._drawRayLine(ctx, eX, eY, exitX, exitY, col, isWhite ? 1.5 : 2);
ctx.globalAlpha = 1;
// Snell at exit face: n1=nP, n2=1 (vector form)
const inward = { x: -exfNorm.x, y: -exfNorm.y };
const cosEx = -(rDir.x * inward.x + rDir.y * inward.y);
const sinEx2 = nP * Math.sqrt(Math.max(0, 1 - cosEx * cosEx));
if (sinEx2 > 1) continue; // TIR
const cosEx2 = Math.sqrt(1 - sinEx2 * sinEx2);
const eDir = {
x: nP * rDir.x + (nP * cosEx - cosEx2) * inward.x,
y: nP * rDir.y + (nP * cosEx - cosEx2) * inward.y,
};
const eDLen = Math.hypot(eDir.x, eDir.y);
eDir.x /= eDLen; eDir.y /= eDLen;
ctx.globalAlpha = isWhite ? 0.85 : 1;
this._drawRayLine(ctx, exitX, exitY, exitX + eDir.x * rayLen, exitY + eDir.y * rayLen, col, isWhite ? 2 : 2.5);
ctx.globalAlpha = 1;
exitPts.push({ nm: s.nm, col });
}
// Drag hint ring
ctx.strokeStyle = 'rgba(100,180,255,0.12)'; ctx.lineWidth = 1; ctx.setLineDash([3,4]);
ctx.beginPath(); ctx.arc(cx, cy, size * 1.18, 0, Math.PI * 2); ctx.stroke();
ctx.setLineDash([]);
// Info box
ctx.fillStyle = 'rgba(22,22,38,0.88)'; ctx.beginPath(); ctx.roundRect(12, 12, 224, 52, 8); ctx.fill();
ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.fillText('Призма: A = ' + this.apexAngle + '°', 22, 22);
ctx.fillStyle = 'rgba(100,180,255,0.8)'; ctx.fillText('n(λ) = ' + this.n0.toFixed(2) + ' 0.0002·(λ−550)', 22, 40);
_obUpdateSpectrometer(exitPts);
// OB_FX visual depth layer (prism: source flare + wavefronts)
_drawOBFXLayer(ctx, 'prism', { srcX: this.W * 0.1, srcY: this.H / 2 });
if (window.LabFX) { LabFX.particles.update(1/60); LabFX.particles.draw(ctx); }
}
_drawRayLine(ctx, x1, y1, x2, y2, color, width) {
const fn = () => {
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
};
if (window.LabFX) LabFX.glow.drawGlow(ctx, fn, { color, intensity: 6 });
else fn();
}
_bindEvents() {
const cv = this.canvas;
const getPos = (e) => {
const r = cv.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
return { mx: (t.clientX - r.left) * (this.W / r.width), my: (t.clientY - r.top) * (this.H / r.height) };
};
const hitTest = (mx, my) => Math.hypot(mx - this.W/2, my - this.H/2) < Math.min(this.W, this.H) * 0.35;
const onDown = (e) => {
const { mx, my } = getPos(e);
if (hitTest(mx, my)) this._drag = { sx: mx, sy: my, sRot: this.rotation, sInc: this.incAngle };
};
const onMove = (e) => {
if (!this._drag) return;
if (e.cancelable) e.preventDefault();
const { mx, my } = getPos(e);
this.rotation = this._drag.sRot + (mx - this._drag.sx) * 0.4;
this.incAngle = Math.max(0, Math.min(80, this._drag.sInc + (my - this._drag.sy) * 0.3));
this.draw(); this._emit();
};
const onUp = () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = null; };
cv.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
cv.addEventListener('touchstart', e => { if (e.touches.length === 1) onDown(e); }, { passive: true });
cv.addEventListener('touchmove', e => onMove(e), { passive: false });
cv.addEventListener('touchend', onUp);
cv.addEventListener('mousemove', e => {
if (this._drag) { cv.style.cursor = 'grabbing'; return; }
const { mx, my } = getPos(e);
cv.style.cursor = hitTest(mx, my) ? 'grab' : 'default';
});
}
}
/* ─────────────────────────────────────────────────────────────
4d. INTERFERENCE SIM — Newton's rings / Thin film / Polarization
Agent C — additive only, class InterferenceSim
─────────────────────────────────────────────────────────────*/
class InterferenceSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
this.subMode = 'newton';
// Newton rings
this.nR = 200;
this.nNmax = 12;
// Thin film
this.tfT = 400;
this.tfN = 1.33;
this.tfTheta = 0;
this.tfPreset = 'soap';
// Polarization
this.polTheta = 45;
this.polSrc = 'unpolarized';
this._polTick = 0;
this._polRaf = null;
this.onUpdate = null;
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement || canvas);
}
fit() {
const p = this.canvas.parentElement;
if (!p) return;
const r = p.getBoundingClientRect();
this.W = this.canvas.width = r.width || p.offsetWidth || 600;
this.H = this.canvas.height = r.height || p.offsetHeight || 400;
}
setSubMode(sm) {
this.subMode = sm;
if (sm === 'polarization') {
this._polStart();
} else {
this._polStop();
}
this.draw();
if (this.onUpdate) this.onUpdate();
}
/* ── Newton Rings ──────────────────────────────────────── */
_drawNewton() {
const { ctx, W, H } = this;
const nm = window._obWavelength || 550;
const R = this.nR;
const nMax = this.nNmax;
const white = window._obWhiteLight;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#08081a';
ctx.fillRect(0, 0, W, H);
const topH = Math.floor(H * 0.60);
const cx = W / 2, cy = topH / 2;
const maxR_mm = Math.sqrt(nMax * nm * 1e-6 * R);
const scale = Math.min(cx * 0.85, cy * 0.85) / (maxR_mm || 1);
for (let n = nMax; n >= 0; n--) {
const lambdas = white ? [420, 470, 510, 550, 590, 620, 680] : [nm];
for (const lam of lambdas) {
const rDark = Math.sqrt(n * lam * 1e-6 * R) * scale;
const rBright = Math.sqrt((n + 0.5) * lam * 1e-6 * R) * scale;
if (rDark > 0.5) {
ctx.beginPath();
ctx.arc(cx, cy, rDark, 0, Math.PI * 2);
ctx.strokeStyle = white
? wavelengthToRGB(lam).replace(')', ',0.5)').replace('rgb', 'rgba')
: '#000000';
ctx.lineWidth = white ? 1.2 : 1.5;
ctx.stroke();
}
if (rBright > 0.5) {
const al = white ? 0.22 : 0.55;
ctx.beginPath();
ctx.arc(cx, cy, rBright, 0, Math.PI * 2);
ctx.strokeStyle = wavelengthToRGB(lam).replace(')', ',' + al + ')').replace('rgb', 'rgba');
ctx.lineWidth = 2.5;
ctx.stroke();
}
}
}
ctx.beginPath(); ctx.arc(cx, cy, 4, 0, Math.PI * 2);
ctx.fillStyle = '#000000'; ctx.fill();
if (window.LabFX && LabFX.glow && !white) {
const r1b = Math.sqrt(0.5 * nm * 1e-6 * R) * scale;
const ringColor = wavelengthToRGB(nm);
LabFX.glow.drawGlow(ctx, function() {
ctx.beginPath();
ctx.arc(cx, cy, r1b, 0, Math.PI * 2);
ctx.strokeStyle = ringColor;
ctx.lineWidth = 2;
ctx.stroke();
}, { color: ringColor, intensity: 18 });
}
ctx.beginPath(); ctx.arc(cx, cy, maxR_mm * scale * 1.05, 0, Math.PI * 2);
ctx.strokeStyle = '#334455'; ctx.lineWidth = 1; ctx.stroke();
const crossY0 = topH + 8;
const crossH = H - crossY0 - 40;
if (crossH < 30) return;
ctx.fillStyle = '#0d0d20';
ctx.fillRect(0, crossY0, W, crossH + 36);
const glassY = crossY0 + crossH - 10;
ctx.fillStyle = '#1a3a5c';
ctx.fillRect(cx - maxR_mm * scale * 1.1, glassY, maxR_mm * scale * 2.2, 10);
const sagitta = (maxR_mm * maxR_mm) / (2 * R);
const sagPx = sagitta * scale;
ctx.beginPath();
ctx.ellipse(cx, glassY - 1 - sagPx, maxR_mm * scale * 1.1, sagPx + 6, 0, 0, Math.PI);
ctx.fillStyle = 'rgba(100,180,255,0.15)'; ctx.fill();
ctx.strokeStyle = '#4499cc'; ctx.lineWidth = 1.5; ctx.stroke();
for (let n = 0; n <= nMax; n++) {
const rD = Math.sqrt(n * nm * 1e-6 * R) * scale;
if (rD < 1) continue;
ctx.beginPath();
ctx.moveTo(cx + rD, glassY); ctx.lineTo(cx + rD, glassY + 8);
ctx.moveTo(cx - rD, glassY); ctx.lineTo(cx - rD, glassY + 8);
ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; ctx.stroke();
}
ctx.font = '600 11px monospace'; ctx.fillStyle = '#667788'; ctx.textAlign = 'center';
ctx.fillText('Cross-section', cx, crossY0 + 14);
const r1d = Math.sqrt(nm * 1e-6 * R).toFixed(3);
this._drawHUD(ctx, W, H,
'r1 = sqrt(lam*R) = ' + r1d + ' mm | R=' + R + 'mm | lam=' + nm + 'nm');
}
/* ── Thin Film ─────────────────────────────────────────── */
_thinFilmColor(t_nm, n_film, theta_deg) {
const sinR = Math.sin(theta_deg * Math.PI / 180) / n_film;
const cosR = Math.sqrt(Math.max(0, 1 - sinR * sinR));
const opd = 2 * n_film * t_nm * cosR;
let rS = 0, gS = 0, bS = 0;
for (let lam = 380; lam <= 780; lam += 5) {
const phase = Math.PI * opd / lam;
const I = Math.cos(phase) * Math.cos(phase);
const rgb = wavelengthToRGB(lam);
const m = rgb.match(/d+/g);
if (!m) continue;
rS += I * +m[0]; gS += I * +m[1]; bS += I * +m[2];
}
const sc = 255 / Math.max(rS, gS, bS, 1);
return 'rgb(' + Math.round(rS * sc) + ',' + Math.round(gS * sc) + ',' + Math.round(bS * sc) + ')';
}
_drawThinFilm() {
const { ctx, W, H } = this;
const t = this.tfT;
const nf = this.tfN;
const theta = this.tfTheta;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#08081a';
ctx.fillRect(0, 0, W, H);
const midY = H * 0.40;
const filmH = Math.max(28, H * 0.12);
const margin = W * 0.10;
const ang = theta * Math.PI / 180;
const skew = Math.tan(ang) * filmH * 0.5;
const grad = ctx.createLinearGradient(margin, 0, W - margin, 0);
for (let i = 0; i <= 20; i++) {
const frac = i / 20;
grad.addColorStop(frac, this._thinFilmColor(t * (0.3 + 0.7 * frac), nf, theta));
}
ctx.save();
ctx.beginPath();
ctx.moveTo(margin - skew, midY - filmH / 2);
ctx.lineTo(W - margin - skew, midY - filmH / 2);
ctx.lineTo(W - margin + skew, midY + filmH / 2);
ctx.lineTo(margin + skew, midY + filmH / 2);
ctx.closePath();
ctx.fillStyle = grad; ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 1; ctx.stroke();
ctx.restore();
ctx.font = '700 11px sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.textAlign = 'center';
ctx.fillText('t=' + t + 'nm n=' + nf.toFixed(2), W / 2, midY);
const ax2 = W * 0.25, ay2 = midY - filmH / 2;
const ax1 = ax2 - Math.cos(ang) * 40, ay1 = ay2 - Math.sin(ang) * 40 - 20;
ctx.beginPath(); ctx.moveTo(ax1, ay1); ctx.lineTo(ax2, ay2);
ctx.strokeStyle = '#8ab4e8'; ctx.lineWidth = 1.5; ctx.stroke();
const col = this._thinFilmColor(t, nf, theta);
ctx.beginPath(); ctx.moveTo(ax2, ay2); ctx.lineTo(ax2 - Math.cos(ang) * 40, ay1);
ctx.strokeStyle = col; ctx.lineWidth = 2; ctx.stroke();
const dx2 = Math.sin(ang) * filmH / nf;
ctx.beginPath();
ctx.moveTo(ax2 + dx2, ay2 + filmH);
ctx.lineTo(ax2 + dx2 - Math.cos(ang) * 40, ay1 + filmH - 20);
ctx.strokeStyle = col; ctx.lineWidth = 2;
ctx.setLineDash([4, 3]); ctx.stroke(); ctx.setLineDash([]);
const tvX0 = W * 0.55, tvW2 = W * 0.38;
const tvY0 = H * 0.05, tvH2 = H * 0.60;
ctx.fillStyle = '#0d0d22'; ctx.strokeStyle = '#2a2a4a'; ctx.lineWidth = 1;
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(tvX0, tvY0, tvW2, tvH2, 8);
else ctx.rect(tvX0, tvY0, tvW2, tvH2);
ctx.fill(); ctx.stroke();
ctx.font = '600 10px sans-serif'; ctx.fillStyle = '#555'; ctx.textAlign = 'center';
ctx.fillText('Top view', tvX0 + tvW2 / 2, tvY0 + 14);
const tvRows = 28, tvCols = 36;
const cW = tvW2 / tvCols, cH = (tvH2 - 20) / tvRows;
for (let r = 0; r < tvRows; r++) {
for (let c = 0; c < tvCols; c++) {
ctx.fillStyle = this._thinFilmColor(t * (0.5 + c / tvCols), nf, theta * (r / tvRows));
ctx.fillRect(tvX0 + c * cW, tvY0 + 20 + r * cH, cW + 0.5, cH + 0.5);
}
}
const sinR2 = Math.sin(ang) / nf;
const cosR2 = Math.sqrt(Math.max(0, 1 - sinR2 * sinR2));
const opd2 = (2 * nf * t * cosR2).toFixed(0);
this._drawHUD(ctx, W, H,
'2nt*cos(th_r)=' + opd2 + 'nm | t=' + t + 'nm n=' + nf.toFixed(2) + ' th=' + theta + 'deg');
}
/* ── Polarization ──────────────────────────────────────── */
_polStart() {
if (this._polRaf) return;
const loop = () => { this._polTick++; this.draw(); this._polRaf = requestAnimationFrame(loop); };
this._polRaf = requestAnimationFrame(loop);
}
_polStop() {
if (this._polRaf) { cancelAnimationFrame(this._polRaf); this._polRaf = null; }
}
_drawPolarization() {
const { ctx, W, H } = this;
const theta = this.polTheta * Math.PI / 180;
const I_rel = Math.cos(theta) * Math.cos(theta);
const tick = this._polTick;
const white = window._obWhiteLight;
const nm = window._obWavelength || 550;
const beamCol = white ? '#ffffff' : wavelengthToRGB(nm);
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#08081a';
ctx.fillRect(0, 0, W, H);
const axisY = H * 0.45;
const stH = H * 0.38;
const st = [
{ x: W * 0.12, label: 'Источник', isFilter: false },
{ x: W * 0.38, label: 'Поляризатор P1', isFilter: true, angle: 0 },
{ x: W * 0.64, label: 'Анализатор P2', isFilter: true, angle: this.polTheta },
{ x: W * 0.88, label: 'Детектор', isFilter: false },
];
ctx.beginPath();
ctx.moveTo(st[0].x - 20, axisY); ctx.lineTo(st[3].x + 20, axisY);
ctx.strokeStyle = '#1a1a35'; ctx.lineWidth = 1; ctx.stroke();
const segs = [
{ x0: st[0].x, x1: st[1].x, amp: 1, unpol: this.polSrc === 'unpolarized', ang: 0 },
{ x0: st[1].x, x1: st[2].x, amp: 1, unpol: false, ang: 0 },
{ x0: st[2].x, x1: st[3].x, amp: I_rel, unpol: false, ang: this.polTheta },
];
for (const seg of segs) {
const nA = 20;
const sdx = (seg.x1 - seg.x0) / nA;
for (let i = 0; i <= nA; i++) {
const bx = seg.x0 + i * sdx;
const phase = (bx * 0.08 - tick * 0.04) % (Math.PI * 2);
const bAmp = stH * 0.28 * seg.amp;
if (seg.unpol) {
for (let d = 0; d < 4; d++) {
const a = d * Math.PI / 4;
const oy = Math.sin(phase + d * 0.7) * bAmp;
ctx.beginPath(); ctx.moveTo(bx, axisY);
ctx.lineTo(bx + oy * Math.sin(a) * 0.25, axisY + oy * Math.cos(a));
ctx.strokeStyle = 'rgba(200,200,255,0.22)'; ctx.lineWidth = 1; ctx.stroke();
}
} else {
const oy = Math.sin(phase) * bAmp;
const a = seg.ang * Math.PI / 180;
const py = oy * Math.cos(a), px = oy * Math.sin(a) * 0.35;
ctx.beginPath();
ctx.moveTo(bx - px, axisY - py); ctx.lineTo(bx + px, axisY + py);
ctx.strokeStyle = (I_rel < 0.01 && seg.amp < 0.5)
? 'rgba(80,80,120,0.5)'
: beamCol.replace(')', ',0.75)').replace('rgb', 'rgba');
ctx.lineWidth = 1.5; ctx.stroke();
if (i % 3 === 0 && bAmp > 2) {
ctx.beginPath(); ctx.arc(bx + px, axisY + py, 2, 0, Math.PI * 2);
ctx.fillStyle = beamCol; ctx.fill();
}
}
}
}
for (const s of st) {
if (!s.isFilter) continue;
const a = s.angle * Math.PI / 180;
ctx.save(); ctx.translate(s.x, axisY);
ctx.fillStyle = 'rgba(80,120,200,0.18)';
ctx.fillRect(-4, -stH / 2, 8, stH);
ctx.strokeStyle = '#4466aa'; ctx.lineWidth = 1.5; ctx.strokeRect(-4, -stH / 2, 8, stH);
const axLen = stH * 0.45;
ctx.beginPath();
ctx.moveTo(-Math.sin(a) * axLen, -Math.cos(a) * axLen);
ctx.lineTo( Math.sin(a) * axLen, Math.cos(a) * axLen);
ctx.strokeStyle = '#7aaeff'; ctx.lineWidth = 2; ctx.stroke();
ctx.restore();
ctx.font = '700 10px monospace'; ctx.fillStyle = '#7aaeff'; ctx.textAlign = 'center';
ctx.fillText(s.angle + 'deg', s.x, axisY + stH / 2 + 14);
}
for (const s of st) {
ctx.font = '600 10px sans-serif'; ctx.fillStyle = '#667788'; ctx.textAlign = 'center';
ctx.fillText(s.label, s.x, axisY - stH / 2 - 8);
}
const barX = W * 0.91, barW = 16;
const barY0 = axisY - stH / 2;
ctx.fillStyle = '#111122'; ctx.fillRect(barX, barY0, barW, stH);
const fillH2 = stH * I_rel;
if (fillH2 > 0) {
const bg = ctx.createLinearGradient(barX, barY0 + stH - fillH2, barX, barY0 + stH);
bg.addColorStop(0, beamCol); bg.addColorStop(1, 'rgba(0,0,0,0.2)');
ctx.fillStyle = bg; ctx.fillRect(barX, barY0 + stH - fillH2, barW, fillH2);
}
ctx.strokeStyle = '#334455'; ctx.lineWidth = 1; ctx.strokeRect(barX, barY0, barW, stH);
ctx.font = '600 9px monospace'; ctx.fillStyle = '#aaaaaa'; ctx.textAlign = 'center';
ctx.fillText('I', barX + barW / 2, barY0 - 5);
if (this.polTheta >= 88) {
ctx.font = '700 13px sans-serif'; ctx.fillStyle = '#EF476F'; ctx.textAlign = 'center';
ctx.fillText('Полное гашение', W / 2, H * 0.85);
}
ctx.font = '10px sans-serif'; ctx.fillStyle = '#444466'; ctx.textAlign = 'right';
ctx.fillText('Угол Брюстера: отражённый свет поляризован (см. Преломление)', W - 10, H - 10);
const pct = (I_rel * 100).toFixed(1);
this._drawHUD(ctx, W, H,
'I/I0=cos2(th)=cos2(' + this.polTheta + 'deg)=' + I_rel.toFixed(3) + ' (' + pct + '%)');
}
_drawHUD(ctx, W, H, text) {
const pad = 8, fs = 11;
ctx.font = '600 ' + fs + 'px monospace';
const tw = ctx.measureText(text).width;
const bx = (W - tw) / 2 - pad, by = H - 32;
const bw = tw + pad * 2, bh = fs + pad * 2;
ctx.fillStyle = 'rgba(10,10,30,0.82)';
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(bx, by, bw, bh, 5);
else ctx.rect(bx, by, bw, bh);
ctx.fill();
ctx.fillStyle = '#c8d8ff';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(text, bx + pad, by + bh / 2);
ctx.textBaseline = 'alphabetic';
}
draw() {
if (this.subMode === 'newton') this._drawNewton();
else if (this.subMode === 'thinfilm') this._drawThinFilm();
else if (this.subMode === 'polarization') this._drawPolarization();
}
}
/* ─────────────────────────────────────────────────────────────
4c. SPECTROMETER PANEL
───────────────────────────────────────────────────────────────*/
function _obDrawSpectrometer() {
const canvas = document.getElementById('ob-spectrometer-canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const W = canvas.offsetWidth || 360, H = canvas.offsetHeight || 72;
const dpr = window.devicePixelRatio || 1;
canvas.width = W * dpr; canvas.height = H * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = '#0a0a18'; ctx.fillRect(0, 0, W, H);
const barX1 = 24, barX2 = W - 24, barY = 22, barH = 20;
const grad = ctx.createLinearGradient(barX1, 0, barX2, 0);
for (let nm = 380; nm <= 780; nm += 10) {
grad.addColorStop((nm - 380) / 400, wavelengthToRGB(nm));
}
ctx.fillStyle = grad; ctx.fillRect(barX1, barY, barX2 - barX1, barH);
ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 1;
ctx.strokeRect(barX1, barY, barX2 - barX1, barH);
ctx.font = '8px Manrope, system-ui, sans-serif'; ctx.textBaseline = 'top'; ctx.textAlign = 'center';
[400, 450, 500, 550, 600, 650, 700, 750].forEach(nm => {
const x = barX1 + (nm - 380) / 400 * (barX2 - barX1);
ctx.fillStyle = 'rgba(255,255,255,0.45)'; ctx.fillText(nm, x, barY + barH + 3);
ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 0.5;
ctx.beginPath(); ctx.moveTo(x, barY + barH); ctx.lineTo(x, barY + barH + 2.5); ctx.stroke();
});
const wlNm = window._obWavelength || 550;
const wlX = barX1 + (wlNm - 380) / 400 * (barX2 - barX1);
ctx.strokeStyle = '#FFFFFF'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(wlX, barY - 5); ctx.lineTo(wlX, barY + barH + 5); ctx.stroke();
ctx.font = 'bold 9px Manrope, system-ui, sans-serif'; ctx.textBaseline = 'bottom'; ctx.textAlign = 'center';
ctx.fillStyle = '#FFFFFF'; ctx.fillText(wlNm + ' нм', wlX, barY - 6);
if (window._obSpectrometerDots) {
for (const dot of window._obSpectrometerDots) {
const x = barX1 + (dot.nm - 380) / 400 * (barX2 - barX1);
ctx.fillStyle = dot.col;
ctx.beginPath(); ctx.arc(x, barY - 9, 4, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 0.8;
ctx.beginPath(); ctx.arc(x, barY - 9, 4, 0, Math.PI * 2); ctx.stroke();
}
}
}
function _obUpdateSpectrometer(exitPts) {
window._obSpectrometerDots = exitPts || [];
_obDrawSpectrometer();
}
/* ─────────────────────────────────────────────────────────────
5. LAB UI INIT — Оптическая скамья
───────────────────────────────────────────────────────────────*/
var lensSim = null;
var mirrorSim = null;
var refrSim = null;
var prismSim = null;
var freeSim = null; /* multi-lens free-build (legacy, superseded by benchSim) */
var benchSim = null; /* optical bench constructor (general ray tracer) */
var ifSim = null; /* interference/polarization (Agent C) */
var _obMode = 'lens'; // current active mode within opticsbench
/* Wavelength state — shared across all modes */
window._obWavelength = 550; // default 550 nm (green/yellow)
window._obWhiteLight = false; // monochromatic by default
/* Open opticsbench, optionally setting a mode ('lens'|'mirror'|'refraction') */
function _openOpticsBench(mode) {
mode = mode || 'lens';
_obMode = mode;
document.getElementById('sim-topbar-title').textContent = 'Оптическая скамья';
_simShow('sim-opticsbench');
_registerSimState('opticsbench', () => _obGetState(), st => _obApplyState(st));
if (_embedMode) _startStateEmit('opticsbench');
// Sync OB_FX checkboxes with persisted state
['wavefronts', 'mist', 'flare', 'huygens', 'caustics'].forEach(function(k) {
const el = document.getElementById('obfx-' + k);
if (el) el.checked = !!window.OB_FX[k];
});
requestAnimationFrame(() => requestAnimationFrame(() => {
obSwitchMode(mode, true);
}));
}
function _obGetState() {
if (_obMode === 'lens') return { mode: 'lens', ...(lensSim ? lensSim.getParams() : {}) };
if (_obMode === 'mirror') return { mode: 'mirror', ...(mirrorSim ? mirrorSim.getParams() : {}) };
if (_obMode === 'refraction') return { mode: 'refraction', ...(refrSim ? refrSim.getParams() : {}) };
if (_obMode === 'freebuild') return { mode: 'freebuild', bench: benchSim ? benchSim.getState() : null };
if (_obMode === 'waves') return { mode: 'waves' };
return { mode: _obMode };
}
function _obApplyState(st) {
if (!st) return;
const m = st.mode || _obMode;
obSwitchMode(m, true);
const { mode: _m, ...params } = st;
if (m === 'lens' && lensSim) lensSim.setParams(params);
if (m === 'mirror' && mirrorSim) mirrorSim.setParams(params);
if (m === 'refraction' && refrSim) refrSim.setParams(params);
if (m === 'freebuild' && benchSim && st.bench) { benchSim.setState(st.bench); _benchUpdateUI(); }
}
/* Switch between modes — mirrors emSwitchMode pattern */
function obSwitchMode(mode, silent) {
if (!silent && window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.3, volume: 0.3 });
_obMode = mode;
/* tab button styling */
['lens', 'mirror', 'refraction', 'prism', 'freebuild', 'waves', 'interf'].forEach(m => {
const btn = document.getElementById('ob-tab-' + m);
if (btn) btn.classList.toggle('active', m === mode);
});
/* show/hide per-mode control panels */
['ob-ctrl-lens', 'ob-ctrl-mirror', 'ob-ctrl-refraction', 'ob-ctrl-prism', 'ob-ctrl-freebuild', 'ob-ctrl-waves', 'ob-ctrl-interf'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
});
const activeCtrl = document.getElementById('ob-ctrl-' + mode);
if (activeCtrl) activeCtrl.style.display = '';
/* show/hide stats bar sections */
['ob-stats-lens', 'ob-stats-mirror', 'ob-stats-refr', 'ob-stats-prism', 'ob-stats-freebuild', 'ob-stats-waves', 'ob-stats-interf'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
});
const statsSuffix = mode === 'refraction' ? 'refr' : mode;
const activeStats = document.getElementById('ob-stats-' + statsSuffix);
if (activeStats) activeStats.style.display = 'flex';
/* canvas visibility */
const canvasIds = ['ob-lens-canvas', 'ob-mirror-canvas', 'ob-refr-canvas', 'ob-prism-canvas', 'ob-free-canvas', 'ob-waves-canvas', 'ob-interf-canvas'];
const modeCanvas = { lens: 'ob-lens-canvas', mirror: 'ob-mirror-canvas', refraction: 'ob-refr-canvas', prism: 'ob-prism-canvas', freebuild: 'ob-free-canvas', waves: 'ob-waves-canvas', interf: 'ob-interf-canvas' };
canvasIds.forEach(id => { const el = document.getElementById(id); if (el) el.style.display = 'none'; });
const activeCanvas = document.getElementById(modeCanvas[mode] || '');
if (activeCanvas) activeCanvas.style.display = '';
/* spectrometer panel: show for prism mode */
const specPanel = document.getElementById('ob-spectrometer-panel');
if (specPanel) specPanel.style.display = (mode === 'prism') ? '' : 'none';
/* init engine if not yet done, then (re-)draw */
if (mode === 'lens') {
if (!lensSim) {
const cv = document.getElementById('ob-lens-canvas');
lensSim = new ThinLensSim(cv);
lensSim.onUpdate = _lensUpdateUI;
}
lensSim.fit(); lensSim.draw(); lensSim._emit();
} else if (mode === 'mirror') {
if (!mirrorSim) {
const cv = document.getElementById('ob-mirror-canvas');
mirrorSim = new MirrorSim(cv);
mirrorSim.onUpdate = _mirrorUpdateUI;
mirrorSim.onAnimate = (d) => {
const sl = document.getElementById('sl-mirror-d');
const lbl = document.getElementById('mirror-d-val');
if (sl) sl.value = Math.round(d);
if (lbl) lbl.textContent = Math.round(d);
};
}
mirrorSim.fit(); mirrorSim.draw(); mirrorSim._emit();
if (mirrorSim._showPhotons && !mirrorSim._photonRaf) mirrorSim._startPhotons();
} else if (mode === 'refraction') {
if (!refrSim) {
const cv = document.getElementById('ob-refr-canvas');
refrSim = new RefractionSim(cv);
refrSim.onUpdate = _refrUpdateUI;
}
refrSim.fit(); refrSim.draw(); refrSim._emit();
} else if (mode === 'prism') {
if (!prismSim) {
const cv = document.getElementById('ob-prism-canvas');
if (cv) prismSim = new PrismSim(cv);
/* enable white-light dispersion by default on first prism entry */
if (window._obWhiteLight === false) {
window._obWhiteLight = true;
const wlBtn = document.getElementById('ob-prism-white-btn');
if (wlBtn) wlBtn.classList.add('active');
}
}
if (prismSim) { prismSim.fit(); prismSim.draw(); }
_obDrawSpectrometer();
} else if (mode === 'freebuild') { /* Optical bench constructor (BenchSim) */
if (!benchSim) {
const cv = document.getElementById('ob-free-canvas');
if (cv) {
benchSim = new BenchSim(cv);
benchSim.onUpdate = _benchUpdateUI;
}
}
if (benchSim) { benchSim.fit(); benchSim.draw(); _benchUpdateUI(); }
} else if (mode === 'waves') { /* Agent B1 — diffraction & interference */
if (!diffrSim) {
const cv = document.getElementById('ob-waves-canvas');
if (cv) diffrSim = new DiffractionSim(cv);
}
if (diffrSim) {
diffrSim.fit();
diffrSim.draw();
diffrSim._updateHUD();
}
} else if (mode === 'interf') { /* Agent C — interference / polarization */
if (!ifSim) {
const cv = document.getElementById('ob-interf-canvas');
if (cv) { ifSim = new InterferenceSim(cv); ifSim.onUpdate = _ifUpdateUI; }
}
if (ifSim) { ifSim.fit(); ifSim.draw(); }
_ifUpdateUI();
}
}
/* ── Wavelength controls ── */
function obSetWavelength(nm) {
window._obWavelength = Math.max(380, Math.min(780, +nm));
const lbl = document.getElementById('ob-wl-val');
if (lbl) lbl.textContent = window._obWavelength + ' нм';
_obRedraw();
}
function obToggleWhiteLight(on) {
window._obWhiteLight = !!on;
const slRow = document.getElementById('ob-wl-slider-row');
if (slRow) slRow.style.opacity = on ? '0.4' : '1';
_obRedraw();
}
/* ── Interference mode UI callbacks (Agent C) ── */
function _ifUpdateUI() {
if (!ifSim) return;
const subMode = ifSim.subMode;
['if-ctrl-newton', 'if-ctrl-thinfilm', 'if-ctrl-polarization'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
});
const active = document.getElementById('if-ctrl-' + subMode);
if (active) active.style.display = '';
['if-sub-newton', 'if-sub-thinfilm', 'if-sub-polarization'].forEach(id => {
const el = document.getElementById(id);
if (el) el.classList.toggle('active', id === 'if-sub-' + subMode);
});
}
function ifSwitchSub(sub) {
if (window.LabFX) LabFX.sound.play('chime');
if (!ifSim) return;
ifSim.setSubMode(sub);
_ifUpdateUI();
}
function ifNewtParam(key, val) {
if (!ifSim) return;
const v = parseFloat(val);
if (key === 'R') { ifSim.nR = v; document.getElementById('if-newton-r-val').textContent = v; }
else if (key === 'nmax') { ifSim.nNmax = Math.round(v); document.getElementById('if-newton-n-val').textContent = Math.round(v); }
ifSim.draw();
}
function ifThinFilmParam(key, val) {
if (!ifSim) return;
const v = parseFloat(val);
if (key === 't') { ifSim.tfT = v; document.getElementById('if-tf-t-val').textContent = v; }
else if (key === 'n') { ifSim.tfN = v; document.getElementById('if-tf-n-val').textContent = v.toFixed(2); }
else if (key === 'theta') { ifSim.tfTheta = v; document.getElementById('if-tf-th-val').textContent = v; }
ifSim.draw();
}
function ifThinFilmPreset(name) {
if (!ifSim) return;
const presets = {
soap: { n: 1.33, label: 'Мыльная плёнка' },
oil: { n: 1.50, label: 'Масло на воде' },
coating: { n: 1.38, label: 'Антибликовое покрытие' },
};
const p = presets[name];
if (!p) return;
ifSim.tfN = p.n;
ifSim.tfPreset = name;
const slN = document.getElementById('sl-if-tf-n');
if (slN) slN.value = p.n;
const lbN = document.getElementById('if-tf-n-val');
if (lbN) lbN.textContent = p.n.toFixed(2);
ifSim.draw();
if (window.LabFX) LabFX.sound.play('chime');
}
function ifPolParam(key, val) {
if (!ifSim) return;
const v = parseFloat(val);
if (key === 'theta') { ifSim.polTheta = v; document.getElementById('if-pol-th-val').textContent = v; }
ifSim.draw();
}
function ifPolSrc(val) {
if (!ifSim) return;
ifSim.polSrc = val;
ifSim.draw();
}
function _obRedraw() {
if (_obMode === 'lens' && lensSim) { lensSim.draw(); }
if (_obMode === 'mirror' && mirrorSim) { mirrorSim.draw(); }
if (_obMode === 'refraction' && refrSim) { refrSim.draw(); }
if (_obMode === 'prism' && prismSim) { prismSim.draw(); }
if (_obMode === 'freebuild' && benchSim) { benchSim.draw(); }
if (_obMode === 'waves' && diffrSim) { diffrSim.draw(); diffrSim._updateHUD(); }
if (_obMode === 'interf' && ifSim) { ifSim.draw(); }
_obDrawSpectrometer();
// Update prism stats bar
const el = (id, txt) => { const e = document.getElementById(id); if (e) e.textContent = txt; };
el('prismbar-wl', (window._obWavelength || 550) + ' нм');
el('prismbar-mode', window._obWhiteLight ? 'Белый' : 'Моно');
if (prismSim) el('prismbar-n', prismSim.n0.toFixed(2));
}
/* ── Prism controls ── */
function prismParam(name, val) {
const v = parseFloat(val);
const ids = { n0: 'prism-n0-val', incAngle: 'prism-inc-val' };
const el = document.getElementById(ids[name]);
if (el) el.textContent = name === 'n0' ? v.toFixed(2) : v;
if (prismSim) prismSim.setParams({ [name]: v });
}
function prismPreset(n0, incAngle) {
document.getElementById('sl-prism-n0').value = n0;
document.getElementById('prism-n0-val').textContent = n0.toFixed(2);
document.getElementById('sl-prism-inc').value = incAngle;
document.getElementById('prism-inc-val').textContent = incAngle;
if (prismSim) prismSim.setParams({ n0, incAngle });
}
function prismToggleWhite(on, btn) {
window._obWhiteLight = !!on;
const wb = document.getElementById('ob-prism-white-btn');
const mb = document.getElementById('ob-prism-mono-btn');
if (wb) wb.classList.toggle('active', !!on);
if (mb) mb.classList.toggle('active', !on);
if (prismSim) prismSim.draw();
_obDrawSpectrometer();
}
/* ── Thin Lens controls ── */
function lensParam(name, val) {
const v = parseFloat(val);
const ids = { f: 'lens-f-val', d: 'lens-d-val', h: 'lens-h-val' };
const el = document.getElementById(ids[name]);
if (el) el.textContent = v;
if (lensSim) lensSim.setParams({ [name]: v });
}
function lensPreset(f, d, h) {
document.getElementById('sl-lens-f').value = f; document.getElementById('lens-f-val').textContent = f;
document.getElementById('sl-lens-d').value = d; document.getElementById('lens-d-val').textContent = d;
document.getElementById('sl-lens-h').value = h; document.getElementById('lens-h-val').textContent = h;
if (lensSim) lensSim.setParams({ f, d, h });
}
/* ── Lens animated ray + LM controls (Feature 1 & 3) ── */
function lensToggleLM(on) {
const sliders = document.getElementById('ob-lm-sliders');
const fRow = document.querySelector('#ob-ctrl-lens .proj-slider-row');
if (sliders) sliders.style.display = on ? '' : 'none';
// hide/show simple f slider
const fSlRow = document.getElementById('sl-lens-f');
if (fSlRow && fSlRow.parentElement) fSlRow.parentElement.style.display = on ? 'none' : '';
if (lensSim) lensSim.setLensMode(!on);
if (on && lensSim) {
// sync sliders to current LM params
const r1 = lensSim._lmR1, r2 = lensSim._lmR2, n = lensSim._lmN;
const s1 = document.getElementById('sl-lm-r1'), l1 = document.getElementById('lm-r1-val');
const s2 = document.getElementById('sl-lm-r2'), l2 = document.getElementById('lm-r2-val');
const sn = document.getElementById('sl-lm-n'), ln = document.getElementById('lm-n-val');
if (s1) s1.value = r1; if (l1) l1.textContent = r1.toFixed(0);
if (s2) s2.value = r2; if (l2) l2.textContent = r2.toFixed(0);
if (sn) sn.value = n; if (ln) ln.textContent = n.toFixed(2);
}
}
function lensLMParam(name, val) {
const v = parseFloat(val);
const lblMap = { R1: 'lm-r1-val', R2: 'lm-r2-val', n: 'lm-n-val' };
const el = document.getElementById(lblMap[name]);
if (el) el.textContent = name === 'n' ? v.toFixed(2) : v.toFixed(0);
if (lensSim) {
lensSim.setLMParam(name, v);
// update f display
const fl = document.getElementById('lens-f-val');
if (fl) fl.textContent = lensSim.f.toFixed(0);
}
}
/* ── Mirror R-slider + parabolic controls (Feature 2) ── */
function mirrorToggleR(on) {
const rRow = document.getElementById('ob-mirror-R-row');
if (rRow) rRow.style.display = on ? '' : 'none';
const pbtn = document.getElementById('mirror-parab-btn');
if (pbtn) pbtn.style.display = on ? '' : 'none';
if (mirrorSim) mirrorSim._useR = !!on;
if (on && mirrorSim) {
const sv = document.getElementById('sl-mirror-R');
const lv = document.getElementById('mirror-R-val');
if (sv) sv.value = mirrorSim._R;
if (lv) lv.textContent = mirrorSim._R;
mirrorSim.setMirrorR(mirrorSim._R);
} else if (mirrorSim) { mirrorSim.draw(); }
}
function mirrorRParam(val) {
const v = parseFloat(val);
const el = document.getElementById('mirror-R-val');
if (el) el.textContent = v;
if (mirrorSim) mirrorSim.setMirrorR(v);
}
function mirrorToggleParabolic(btn) {
if (!mirrorSim) return;
mirrorSim._parabolic = !mirrorSim._parabolic;
if (btn) btn.textContent = mirrorSim._parabolic ? 'Параболическое' : 'Сферическое';
if (btn) btn.style.color = mirrorSim._parabolic ? '#7BF5A4' : '#888';
mirrorSim.draw();
}
function _lensUpdateUI(info) {
const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
v('lensbar-v1', info.f);
v('lensbar-v2', info.dPrime === Infinity ? '∞' : info.dPrime);
v('lensbar-v3', info.M === Infinity ? '∞' : info.M);
v('lensbar-v4', info.imageType);
}
/* ── Mirror controls ── */
function mirrorType(type, el) {
document.querySelectorAll('.mirror-type-btn').forEach(b => b.classList.remove('active'));
if (el) el.classList.add('active');
const fRow = document.getElementById('mirror-f-row');
if (fRow) fRow.style.display = type === 'flat' ? 'none' : 'flex';
if (mirrorSim) mirrorSim.setType(type);
const pb = document.getElementById('mirror-play-btn');
if (pb) { pb.textContent = 'Анимация'; }
const sl = document.getElementById('sl-mirror-d');
if (sl) sl.disabled = false;
}
function mirrorParam(name, val) {
const v = parseFloat(val);
const ids = { f: 'mirror-f-val', d: 'mirror-d-val', h: 'mirror-h-val' };
const el = document.getElementById(ids[name]);
if (el) el.textContent = v;
if (mirrorSim) mirrorSim.setParams({ [name]: v });
}
function mirrorPreset(name) {
const P = {
flat: { type: 'flat', f: 120, d: 200, h: 60 },
far: { type: 'concave', f: 100, d: 280, h: 60 },
'2f': { type: 'concave', f: 100, d: 200, h: 60 },
between: { type: 'concave', f: 100, d: 140, h: 60 },
near: { type: 'concave', f: 100, d: 60, h: 60 },
convex: { type: 'convex', f: 100, d: 200, h: 60 },
};
const p = P[name]; if (!p) return;
document.querySelectorAll('.mirror-type-btn').forEach(b => b.classList.remove('active'));
const tb = document.getElementById('mtype-' + p.type);
if (tb) tb.classList.add('active');
const fRow = document.getElementById('mirror-f-row');
if (fRow) fRow.style.display = p.type === 'flat' ? 'none' : 'flex';
document.getElementById('sl-mirror-f').value = p.f; document.getElementById('mirror-f-val').textContent = p.f;
document.getElementById('sl-mirror-d').value = p.d; document.getElementById('mirror-d-val').textContent = p.d;
document.getElementById('sl-mirror-h').value = p.h; document.getElementById('mirror-h-val').textContent = p.h;
if (mirrorSim) { mirrorSim.setType(p.type); mirrorSim.setParams({ f: p.f, d: p.d, h: p.h }); }
}
function mirrorTogglePlay(btn) {
if (!mirrorSim) return;
mirrorSim.togglePlay();
const playing = mirrorSim._playing;
if (btn) btn.textContent = playing ? 'Стоп' : 'Анимация';
const sl = document.getElementById('sl-mirror-d');
if (sl) sl.disabled = playing;
}
function mirrorSetSpeed(val) { if (mirrorSim) mirrorSim.setAnimSpeed(parseFloat(val)); }
function mirrorToggle(name, val) { if (mirrorSim) mirrorSim.setToggle(name, val); }
function mirrorStepNext() { if (mirrorSim) mirrorSim.stepNext(); }
function mirrorStepReset() { if (mirrorSim) mirrorSim.stepReset(); }
function mirrorSetPointMode(val) { if (mirrorSim) mirrorSim.setPointMode(val); }
function _mirrorUpdateUI(info) {
const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
v('mirrorbar-v1', info.f);
v('mirrorbar-v5', Math.round(info.d));
v('mirrorbar-v2', info.dPrime === Infinity ? '∞' : info.dPrime);
v('mirrorbar-v3', info.M === Infinity ? '∞' : info.M);
v('mirrorbar-v4', info.imageType);
}
/* ── Refraction controls ── */
function refrParam(name, val) {
const v = parseFloat(val);
const ids = { n1: 'refr-n1-val', n2: 'refr-n2-val', angle: 'refr-angle-val' };
const el = document.getElementById(ids[name]);
if (el) el.textContent = name === 'angle' ? v : v.toFixed(2);
if (refrSim) refrSim.setParams({ [name]: v });
}
function refrPreset(n1, n2, angle) {
document.getElementById('sl-refr-n1').value = n1; document.getElementById('refr-n1-val').textContent = n1.toFixed(2);
document.getElementById('sl-refr-n2').value = n2; document.getElementById('refr-n2-val').textContent = n2.toFixed(2);
document.getElementById('sl-refr-angle').value = angle; document.getElementById('refr-angle-val').textContent = angle;
if (refrSim) refrSim.setParams({ n1, n2, angle });
}
function _refrUpdateUI(info) {
const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
v('refrbar-v1', info.angle1 + '°');
v('refrbar-v2', info.isTIR ? 'ПВО' : info.angle2 + '°');
v('refrbar-v3', info.criticalAngle !== null ? info.criticalAngle + '°' : '—');
v('refrbar-v4', info.isTIR ? 'Да' : 'Нет');
}
/* ── dispersion toggle ── */
function refrDispersion(on) {
if (refrSim) refrSim.setParams({ dispersion: on });
}
/* ─────────────────────────────────────────────────────────────
5. PRE-BUILT INSTRUMENT SCENES (obLoadPreset / OB_PRESETS)
───────────────────────────────────────────────────────────────*/
/* Each preset: { label, desc, mode, build(sim) }
build() receives the already-initialised sim object for the target mode.
Supported modes: 'lens' | 'mirror' | 'refraction' */
var OB_PRESETS = {
/* ── 1. Лупа ─────────────────────────────────────────────── */
magnifier: {
label: 'Лупа',
desc: 'Объект ближе фокуса (d < f). Мнимое увеличенное прямое изображение.',
mode: 'lens',
build: function(sim) {
sim.setParams({ f: 50, d: 30, h: 50 });
_obSyncLensUI(50, 30, 50);
}
},
/* ── 2. Микроскоп ─────────────────────────────────────────── */
microscope: {
label: 'Микроскоп',
desc: 'Объектив f = 10, предмет чуть дальше F. Действительное изображение увеличено.',
mode: 'lens',
build: function(sim) {
/* Show the objective stage: object just outside F of objective */
sim.setParams({ f: 10, d: 14, h: 30 });
_obSyncLensUI(10, 14, 30);
}
},
/* ── 3. Телескоп Кеплера ──────────────────────────────────── */
keplerian: {
label: 'Телескоп Кеплера',
desc: 'Две собирающие линзы конфокально (fуб = 200, fок =30). Объект на ∞.',
mode: 'lens',
build: function(sim) {
/* Show the eyepiece stage receiving collimated light: f_ok = 30,
"parallel-ray" object simulated by placing d very large (390 max) */
sim.setParams({ f: 30, d: 390, h: 55 });
_obSyncLensUI(30, 390, 55);
}
},
/* ── 4. Телескоп Галилея ──────────────────────────────────── */
galilean: {
label: 'Телескоп Галилея',
desc: 'Собирающий объектив f = 200 + рассеивающий окуляр f = −40. Прямое изображение.',
mode: 'lens',
build: function(sim) {
/* Diverging eyepiece: f = -40, object at 390 (parallel rays) */
sim.setParams({ f: -40, d: 390, h: 55 });
_obSyncLensUI(-40, 390, 55);
}
},
/* ── 5. Камера / Глаз ─────────────────────────────────────── */
camera: {
label: 'Камера / Глаз',
desc: 'Линза f=40, предмет d = 120. Действительное уменьшенное изображение на матрице.',
mode: 'lens',
build: function(sim) {
sim.setParams({ f: 40, d: 120, h: 60 });
_obSyncLensUI(40, 120, 60);
}
},
/* ── 6. Перископ ─────────────────────────────────────────────
Uses MirrorSim with flat mirror to show 45° reflection concept.
A true two-mirror periscope can't be drawn in the single-mirror
engine, so we show one flat mirror at 45° (flat type, d large). */
periscope: {
label: 'Перископ',
desc: 'Плоское зеркало под 45°. Луч отражается под прямым углом.',
mode: 'mirror',
build: function(sim) {
sim.setType('flat');
sim.setParams({ d: 300, h: 60 });
_obSyncMirrorUI('flat', 120, 300, 60);
}
},
/* ── 7. Слайд-проектор ─────────────────────────────────────── */
projector: {
label: 'Слайд-проектор',
desc: 'Объектив f=80, слайд d = 100 (чуть дальше F). Действительное увеличенное изображение.',
mode: 'lens',
build: function(sim) {
sim.setParams({ f: 80, d: 100, h: 40 });
_obSyncLensUI(80, 100, 40);
}
},
/* ── 8. Световод (ПВО) ──────────────────────────────────────
Shows TIR inside a dense medium (n2 > n1).
Use refraction mode: n1=1.5 (glass), n2=1.0 (air), angle > critical. */
fiber: {
label: 'Световод (ПВО)',
desc: 'Стекло n =1.5 → воздух n = 1. Угол > критического — полное внутреннее отражение.',
mode: 'refraction',
build: function(sim) {
/* critical angle = arcsin(1/1.5) ≈ 41.8°; use 50° to clearly show TIR */
sim.setParams({ n1: 1.5, n2: 1.0, angle: 50, dispersion: false });
_obSyncRefrUI(1.5, 1.0, 50);
}
},
/* ── 9. Ложка в воде ────────────────────────────────────────
Shows apparent-depth illusion: light from object in water (n=1.33)
refracts at boundary going into air. Observer sees virtual image. */
spoon: {
label: 'Ложка в воде',
desc: 'Свет от предмета в воде (n = 1.33) преломляется в воздух. Мнимое изображение кажется ближе.',
mode: 'refraction',
build: function(sim) {
sim.setParams({ n1: 1.33, n2: 1.0, angle: 30, dispersion: false });
_obSyncRefrUI(1.33, 1.0, 30);
}
}
};
/* ── UI sync helpers (keep sliders in sync after preset load) ── */
function _obSyncLensUI(f, d, h) {
var sl;
sl = document.getElementById('sl-lens-f'); if (sl) sl.value = f;
sl = document.getElementById('sl-lens-d'); if (sl) sl.value = d;
sl = document.getElementById('sl-lens-h'); if (sl) sl.value = h;
var el;
el = document.getElementById('lens-f-val'); if (el) el.textContent = f;
el = document.getElementById('lens-d-val'); if (el) el.textContent = d;
el = document.getElementById('lens-h-val'); if (el) el.textContent = h;
}
function _obSyncMirrorUI(type, f, d, h) {
document.querySelectorAll('.mirror-type-btn').forEach(function(b) { b.classList.remove('active'); });
var tb = document.getElementById('mtype-' + type);
if (tb) tb.classList.add('active');
var fRow = document.getElementById('mirror-f-row');
if (fRow) fRow.style.display = type === 'flat' ? 'none' : 'flex';
var sl;
sl = document.getElementById('sl-mirror-f'); if (sl) sl.value = f;
sl = document.getElementById('sl-mirror-d'); if (sl) sl.value = d;
sl = document.getElementById('sl-mirror-h'); if (sl) sl.value = h;
var el;
el = document.getElementById('mirror-f-val'); if (el) el.textContent = f;
el = document.getElementById('mirror-d-val'); if (el) el.textContent = d;
el = document.getElementById('mirror-h-val'); if (el) el.textContent = h;
}
function _obSyncRefrUI(n1, n2, angle) {
var sl;
sl = document.getElementById('sl-refr-n1'); if (sl) sl.value = n1;
sl = document.getElementById('sl-refr-n2'); if (sl) sl.value = n2;
sl = document.getElementById('sl-refr-angle'); if (sl) sl.value = angle;
var el;
el = document.getElementById('refr-n1-val'); if (el) el.textContent = n1.toFixed(2);
el = document.getElementById('refr-n2-val'); if (el) el.textContent = n2.toFixed(2);
el = document.getElementById('refr-angle-val'); if (el) el.textContent = angle;
}
/* ── HUD toast shown for 5 s after preset load ── */
function _obShowPresetHUD(label, desc) {
var existing = document.getElementById('ob-preset-hud');
if (existing) existing.remove();
var hud = document.createElement('div');
hud.id = 'ob-preset-hud';
hud.style.cssText = [
'position:absolute', 'top:10px', 'left:50%', 'transform:translateX(-50%)',
'background:rgba(18,18,30,.92)', 'border:1px solid rgba(100,220,255,.35)',
'border-radius:10px', 'padding:8px 16px', 'z-index:99',
'pointer-events:none', 'text-align:center',
'box-shadow:0 4px 20px rgba(0,0,0,.5)',
'transition:opacity .4s'
].join(';');
hud.innerHTML = '<div style="font-size:.85rem;font-weight:700;color:var(--cyan,#67e8f9);margin-bottom:2px">' +
label + '</div>' +
'<div style="font-size:.73rem;color:#aaa;max-width:320px;line-height:1.35">' + desc + '</div>';
/* Attach to the canvas wrapper so it overlays correctly */
var wrap = document.querySelector('#sim-opticsbench .proj-canvas-outer');
if (wrap) { wrap.style.position = 'relative'; wrap.appendChild(hud); }
/* Fade out after 5 s */
var timer = setTimeout(function() {
hud.style.opacity = '0';
setTimeout(function() { if (hud.parentNode) hud.remove(); }, 420);
}, 5000);
hud._timer = timer;
}
/* ── Main entry point called from HTML buttons ── */
function obLoadPreset(name) {
var preset = OB_PRESETS[name];
if (!preset) return;
/* Switch to the correct mode first, then build */
obSwitchMode(preset.mode, true);
/* Give the canvas time to initialise if it was just created */
requestAnimationFrame(function() {
var sim = preset.mode === 'lens' ? lensSim
: preset.mode === 'mirror' ? mirrorSim
: preset.mode === 'refraction' ? refrSim
: null;
if (!sim) return;
preset.build(sim);
/* Sound */
if (window.LabFX) LabFX.sound.play('chime');
/* Tab highlight */
['lens', 'mirror', 'refraction'].forEach(function(m) {
var btn = document.getElementById('ob-tab-' + m);
if (btn) btn.classList.toggle('active', m === preset.mode);
});
/* HUD */
_obShowPresetHUD(preset.label, preset.desc);
/* Highlight the active preset chip */
document.querySelectorAll('.ob-preset-chip').forEach(function(c) {
c.classList.toggle('active', c.dataset.preset === name);
});
});
}
/* ── Clear: reset current mode to defaults ── */
function obClearPreset() {
/* Deactivate all chips */
document.querySelectorAll('.ob-preset-chip').forEach(function(c) {
c.classList.remove('active');
});
/* Remove HUD if visible */
var hud = document.getElementById('ob-preset-hud');
if (hud) hud.remove();
/* Reset active sim */
if (_obMode === 'lens' && lensSim) lensSim.reset();
if (_obMode === 'mirror' && mirrorSim) { mirrorSim.setType('concave'); mirrorSim.reset(); }
if (_obMode === 'refraction' && refrSim) refrSim.reset();
/* Re-sync UI sliders to defaults */
if (_obMode === 'lens') _obSyncLensUI(100, 200, 50);
if (_obMode === 'mirror') _obSyncMirrorUI('concave', 120, 240, 60);
if (_obMode === 'refraction') _obSyncRefrUI(1.0, 1.5, 30);
if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 0.9, volume: 0.25 });
}
/* ── Aberration toggles for ThinLensSim (Agent OB-A3) ── */
function lensAberration(type, on) {
if (lensSim) lensSim.setAberration(type, on);
}
/* ── Mirror spherical aberration toggle (Agent OB-A3) ── */
function mirrorAberration(on) {
if (mirrorSim) mirrorSim.setToggle('spherical', on);
}
/* ── Free-build multi-lens controls (Agent OB-A3) ── */
function freeAddLens() {
if (freeSim) freeSim.addLens(100);
}
function freeRemoveLens() {
if (freeSim) freeSim.removeLens();
}
function freeLensF(idx, val) {
const v = parseFloat(val);
const el = document.getElementById('free-lens' + idx + '-fval');
if (el) el.textContent = v;
if (freeSim) freeSim.setLensF(idx, v);
}
function freePreset(name) {
const presets = {
microscope: { els: [{ x_frac: 0.30, f: 40 }, { x_frac: 0.70, f: 80 }], obj: 0.10 },
telescope: { els: [{ x_frac: 0.25, f: 160 }, { x_frac: 0.78, f: 50 }], obj: 0.05 },
relay: { els: [{ x_frac: 0.28, f: 100 }, { x_frac: 0.55, f: 100 }, { x_frac: 0.80, f: 100 }], obj: 0.08 },
};
const p = presets[name];
if (!p || !freeSim) return;
freeSim.elements = p.els.map((e, i) => ({ ...e, id: i + 1 }));
freeSim.objFrac = p.obj;
freeSim.draw();
if (freeSim.onUpdate) freeSim.onUpdate(freeSim._computeChain());
}
function _freeUpdateUI(chain) {
const el = document.getElementById('freebar-mag');
if (el) el.textContent = isFinite(chain.totalM) ? chain.totalM.toFixed(3) : '∞';
const el2 = document.getElementById('freebar-sys');
if (el2) el2.textContent = chain.sysFocal !== null ? chain.sysFocal.toFixed(0) : '—';
}
/* ── Optical bench constructor (BenchSim) UI ── */
function _benchElName(e) {
if (e.type === 'lens') return (e.f >= 0 ? 'Линза +' : 'Линза ') + Math.abs(e.f).toFixed(0);
if (e.type === 'mirror') return 'Зеркало ' + ({ plane: 'плоск', concave: 'вогн', convex: 'выпукл' }[e.kind] || '');
if (e.type === 'aperture') return 'Диафрагма';
if (e.type === 'screen') return 'Экран';
if (e.type === 'prism') return 'Призма';
if (e.type === 'interface') return 'Граница сред';
if (e.type === 'slab') return 'Пластина';
return e.type;
}
// Labelled slider with a live numeric value (no panel rebuild → drag stays smooth).
function _benchCtl(label, id, key, min, max, step, val, isSource) {
const vid = 'bv_' + (isSource ? 's' : id) + '_' + key;
const call = isSource ? "benchSourceParam('" + key + "',this.value)" : "benchUpdate(" + id + ",'" + key + "',this.value)";
return '<div class="proj-slider-row" style="margin-bottom:6px">' +
'<label style="font-size:.72rem;color:#ccc;width:104px">' + label + ' <b id="' + vid + '" style="color:var(--cyan)">' + val + '</b></label>' +
'<input type="range" min="' + min + '" max="' + max + '" step="' + step + '" value="' + val +
'" oninput="' + call + ';var b=document.getElementById(\'' + vid + '\');if(b)b.textContent=this.value" style="flex:1"></div>';
}
function _benchBtnRow(opts, isActive, onClick) {
return '<div style="display:flex;flex-wrap:wrap;gap:3px;margin-bottom:6px">' +
opts.map(o => { const [k, lbl] = o.split(':');
return '<button class="preset-btn' + (isActive(k) ? ' active' : '') + '" style="flex:1;font-size:.66rem" onclick="' + onClick(k) + '">' + lbl + '</button>';
}).join('') + '</div>';
}
function _benchPropsHTML() {
if (!benchSim) return '';
if (benchSim.selectedId === '__src') {
const s = benchSim.source;
let h = '<div class="gp-section-title" style="margin:4px 0 6px">Источник</div>';
h += _benchBtnRow(['object:Предмет', 'point:Точка', 'parallel:Параллель', 'single:Луч', 'laser:Лазер'],
k => s.kind === k, k => "benchSourceKind('" + k + "')");
h += _benchCtl('Положение ↕', 0, 'yf', -0.9, 0.9, 0.02, +(s.yf || 0).toFixed(2), true); // vertical position (any kind)
if (s.kind === 'object') {
h += _benchCtl('Размер стрелки', 0, 'h', 20, 120, 2, s.h, true);
// ray mode: textbook characteristic rays vs physical bundle
h += _benchBtnRow(['char:Характ. лучи', 'bundle:Пучок'],
k => (s.rayMode || 'char') === k, k => "benchSourceParam('rayMode','" + k + "');_benchUpdateUI()");
if ((s.rayMode || 'char') === 'bundle') {
h += _benchCtl('Лучей', 0, 'rays', 3, 15, 1, s.rays, true);
h += _benchCtl('Раствор', 0, 'spread', 0.1, 0.6, 0.02, s.spread, true);
} else {
h += '<div class="pp-hint">23 характеристических луча от вершины + осевой от основания (как в учебнике).</div>';
}
}
if (s.kind === 'point') h += _benchCtl('Раствор', 0, 'spread', 0.1, 0.6, 0.02, s.spread, true);
if (s.kind !== 'object') h += _benchCtl('Угол°', 0, 'ang', -60, 60, 1, s.ang || 0, true);
return h;
}
const e = benchSim.getSelected();
if (!e) return '<div class="pp-hint">Выберите элемент или источник (клик по схеме)</div>';
let h = '<div class="gp-section-title" style="margin:4px 0 6px">' + _benchElName(e) + '</div>';
if (e.type === 'lens') {
h += _benchCtl('f, px', e.id, 'f', -300, 300, 5, e.f);
h += _benchCtl('Апертура', e.id, 'ap', 30, 130, 5, e.ap);
} else if (e.type === 'mirror') {
h += _benchBtnRow(['plane:Плоск', 'concave:Вогн', 'convex:Выпукл'],
k => e.kind === k, k => "benchUpdate(" + e.id + ",'kind','" + k + "');_benchUpdateUI()");
if (e.kind !== 'plane') h += _benchCtl('R, px', e.id, 'R', 100, 600, 10, e.R);
h += _benchCtl('Апертура', e.id, 'ap', 30, 130, 5, e.ap);
} else if (e.type === 'aperture') {
h += _benchCtl('Зазор', e.id, 'gap', 5, 110, 2, e.gap);
} else if (e.type === 'prism') {
h += _benchCtl('Угол', e.id, 'apex', 20, 70, 1, e.apex);
h += _benchCtl('n', e.id, 'n', 1.3, 1.9, 0.01, e.n);
h += _benchCtl('Размер', e.id, 'size', 50, 130, 5, e.size);
} else if (e.type === 'interface') {
h += _benchCtl('n слева', e.id, 'n1', 1.0, 2.4, 0.01, e.n1);
h += _benchCtl('n справа', e.id, 'n2', 1.0, 2.4, 0.01, e.n2);
} else if (e.type === 'slab') {
h += _benchCtl('n', e.id, 'n', 1.1, 2.0, 0.01, e.n);
h += _benchCtl('Толщина', e.id, 't', 20, 140, 5, e.t);
} else if (e.type === 'screen') {
h += '<div class="pp-hint">Экран ловит изображение.</div>';
}
h += '<button class="preset-btn" style="width:100%;margin-top:6px;color:#EF476F" onclick="benchRemove(' + e.id + ')">Удалить элемент</button>';
return h;
}
function _benchUpdateUI() {
if (!benchSim) return;
const listEl = document.getElementById('bench-list');
if (listEl) {
// permanent "Источник" chip so the source is always selectable (not only via canvas)
const srcChip = '<button class="preset-btn' + (benchSim.selectedId === '__src' ? ' active' : '') +
'" style="font-size:.68rem" onclick="benchSelect(\'__src\')">Источник</button>';
listEl.innerHTML = srcChip + benchSim.elements.map(e =>
'<button class="preset-btn' + (e.id === benchSim.selectedId ? ' active' : '') +
'" style="font-size:.68rem" onclick="benchSelect(' + e.id + ')">' + _benchElName(e) + '</button>'
).join('');
}
const propsEl = document.getElementById('bench-props');
if (propsEl) propsEl.innerHTML = _benchPropsHTML();
}
function benchAdd(type) { if (benchSim) { benchSim.addElement(type); _benchUpdateUI(); } }
function benchRemove(id) { if (benchSim) { benchSim.removeElement(id); _benchUpdateUI(); } }
function benchSelect(id) { if (benchSim) { benchSim.selectElement(id); _benchUpdateUI(); } }
function benchUpdate(id, k, v) { if (benchSim) benchSim.updateElement(id, k, v); }
function benchSourceKind(k) { if (benchSim) { benchSim.setSource('kind', k); _benchUpdateUI(); } }
function benchSourceParam(k, v){ if (benchSim) benchSim.setSource(k, v); }
function benchClear() {
if (!benchSim) return;
benchSim.elements = []; benchSim.selectedId = null; benchSim._changed(); _benchUpdateUI();
}
function benchExportPng() {
if (!benchSim) return;
const url = benchSim.exportPng();
if (!url) return;
const a = document.createElement('a');
a.href = url; a.download = 'optical-bench.png';
document.body.appendChild(a); a.click(); document.body.removeChild(a);
}
function benchPreset(name) {
if (!benchSim) return;
const P = {
microscope: { source: { kind: 'object', xf: 0.06, h: 40, spread: 0.32, rays: 9 },
elements: [{ type: 'lens', xf: 0.30, f: 45, ap: 90 }, { type: 'lens', xf: 0.66, f: 90, ap: 95 }, { type: 'screen', xf: 0.92 }] },
telescope: { source: { kind: 'parallel', xf: 0.05, h: 0, spread: 0.2, rays: 9 },
elements: [{ type: 'lens', xf: 0.28, f: 200, ap: 95 }, { type: 'lens', xf: 0.74, f: 60, ap: 80 }] },
projector: { source: { kind: 'object', xf: 0.10, h: 80, spread: 0.34, rays: 9 },
elements: [{ type: 'lens', xf: 0.40, f: 120, ap: 100 }, { type: 'screen', xf: 0.92 }] },
folded: { source: { kind: 'object', xf: 0.08, h: 60, spread: 0.3, rays: 9 },
elements: [{ type: 'lens', xf: 0.34, f: 150, ap: 90 }, { type: 'mirror', xf: 0.82, kind: 'concave', R: 320, ap: 100 }, { type: 'screen', xf: 0.50 }] },
};
const p = P[name]; if (!p) return;
let id = 1;
benchSim.source = { ...p.source };
benchSim.elements = p.elements.map(e => ({ id: id++, ...e }));
benchSim._nextId = id;
benchSim.selectedId = null;
benchSim._changed();
_benchUpdateUI();
}
/* ─────────────────────────────────────────────────────────────
6. DIFFRACTION SIM — Волновая оптика (Юнг / Однощелевая / Решётка)
───────────────────────────────────────────────────────────────*/
/**
* DiffractionSim — handles 3 wave-optics sub-experiments on a single canvas.
* Sub-modes:
* 'young' — Young double-slit interference
* 'single' — Single-slit diffraction
* 'grating' — Diffraction grating (N slits)
*/
class DiffractionSim {
constructor(canvas) {
this._cv = canvas;
this._ctx = canvas.getContext('2d');
this._sub = 'young'; // active sub-experiment
this._raf = null;
/* Young params */
this._d_young = 40; // slit separation µm
this._L_young = 1.0; // screen distance m
/* Single-slit params */
this._a_single = 80; // slit width µm
/* Grating params */
this._N_grating = 10; // number of slits
this._d_grating = 2.0; // grating period µm
this._a_grating = 0.5; // slit width µm (for envelope)
this.fit();
}
fit() {
const cv = this._cv;
const pr = window.devicePixelRatio || 1;
const w = cv.offsetWidth || 700;
const h = cv.offsetHeight || 380;
cv.width = Math.round(w * pr);
cv.height = Math.round(h * pr);
this._ctx.setTransform(pr, 0, 0, pr, 0, 0);
this._W = w;
this._H = h;
}
setSub(sub) {
this._sub = sub;
this.draw();
}
/* ── public setters called from HTML controls ── */
setParam(name, val) {
const v = parseFloat(val);
if (name === 'd_young') this._d_young = v;
if (name === 'L_young') this._L_young = v;
if (name === 'a_single') this._a_single = v;
if (name === 'N_grating') this._N_grating = Math.round(v);
if (name === 'd_grating') this._d_grating = v;
if (name === 'a_grating') this._a_grating = v;
this.draw();
this._updateHUD();
}
/* ── main draw dispatcher ── */
draw() {
const ctx = this._ctx;
const W = this._W, H = this._H;
ctx.clearRect(0, 0, W, H);
/* dark background */
ctx.fillStyle = '#0a0a16';
ctx.fillRect(0, 0, W, H);
if (this._sub === 'young') this._drawYoung();
else if (this._sub === 'single') this._drawSingle();
else if (this._sub === 'grating') this._drawGrating();
}
/* ─────────────────────────────────────────────
Young double-slit
─────────────────────────────────────────────── */
_drawYoung() {
const ctx = this._ctx;
const W = this._W, H = this._H;
const λ_nm = window._obWavelength || 550;
const λ = λ_nm * 1e-9; // m
const d = this._d_young * 1e-6; // m
const L = this._L_young; // m
const wl = window._obWhiteLight;
/* layout */
const slitX = Math.round(W * 0.22);
const screenX = Math.round(W * 0.75);
const graphH = Math.round(H * 0.26);
const simH = H - graphH - 1;
const cy = Math.round(simH / 2);
/* source (left) */
this._drawSource(slitX - Math.round(W * 0.15), cy);
/* draw incoming beam */
ctx.save();
ctx.strokeStyle = wl ? 'rgba(255,255,220,0.35)' : this._wlColor(λ_nm, 0.35);
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, cy);
ctx.lineTo(slitX - 2, cy);
ctx.stroke();
ctx.restore();
/* slit barrier */
const slitGapPx = 18; // visual gap per slit
const sepPx = Math.round(Math.max(12, Math.min(60, this._d_young * 0.8)));
this._drawSlitBarrier(slitX, cy, simH, sepPx, slitGapPx, 2);
/* wavefronts from two slits */
const slit1Y = cy - sepPx / 2;
const slit2Y = cy + sepPx / 2;
this._drawWavefronts(slitX, slit1Y, slitX, slit2Y, screenX - slitX, λ_nm, wl, simH);
/* intensity screen */
const screenPoints = this._youngIntensity(λ, d, L, W, simH);
this._drawScreen(screenX, simH, screenPoints, λ_nm, wl);
/* graph */
this._drawGraph(0, simH, W, graphH, screenPoints, λ_nm, wl);
/* fringe spacing label */
const dy_mm = (λ * L / d * 1e3).toFixed(2);
this._drawHUDLine(ctx, W, H, `Δy = λL/d = ${dy_mm} мм`);
/* axis labels */
this._drawLabel(ctx, slitX, simH - 10, 'Щели');
this._drawLabel(ctx, screenX, simH - 10, 'Экран');
}
_youngIntensity(λ, d, L, W, simH) {
const N = 300;
const halfH = simH * 0.46;
const pts = [];
for (let i = 0; i <= N; i++) {
const y_px = (i / N) * simH - simH / 2; // px, centred
const y_m = y_px / (simH / 2) * 0.05; // map ±simH/2 → ±50mm
const sinT = y_m / Math.sqrt(y_m * y_m + L * L);
const phi = Math.PI * d * sinT / λ;
const I = Math.cos(phi) * Math.cos(phi);
pts.push({ y_px, I });
}
return pts;
}
/* ─────────────────────────────────────────────
Single-slit diffraction
─────────────────────────────────────────────── */
_drawSingle() {
const ctx = this._ctx;
const W = this._W, H = this._H;
const λ_nm = window._obWavelength || 550;
const λ = λ_nm * 1e-9;
const a = this._a_single * 1e-6;
const L = 1.0; // fixed 1 m
const wl = window._obWhiteLight;
const slitX = Math.round(W * 0.22);
const screenX = Math.round(W * 0.75);
const graphH = Math.round(H * 0.26);
const simH = H - graphH - 1;
const cy = Math.round(simH / 2);
const slitGapPx = Math.round(Math.max(6, Math.min(30, this._a_single * 0.18)));
this._drawSource(slitX - Math.round(W * 0.15), cy);
ctx.save();
ctx.strokeStyle = wl ? 'rgba(255,255,220,0.35)' : this._wlColor(λ_nm, 0.35);
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(0, cy); ctx.lineTo(slitX - 2, cy); ctx.stroke();
ctx.restore();
/* single slit barrier */
this._drawSingleSlitBarrier(slitX, cy, simH, slitGapPx);
/* wavefronts — single source */
this._drawWavefronts(slitX, cy, slitX, cy, screenX - slitX, λ_nm, wl, simH);
const screenPoints = this._singleIntensity(λ, a, L, simH);
this._drawScreen(screenX, simH, screenPoints, λ_nm, wl);
this._drawGraph(0, simH, W, graphH, screenPoints, λ_nm, wl);
/* angular width of central maximum */
const ang_rad = (2 * λ / a);
const ang_deg = (ang_rad * 180 / Math.PI).toFixed(2);
this._drawHUDLine(ctx, W, H, `Центр. макс: 2λ/a = ${ang_deg}°`);
this._drawLabel(ctx, slitX, simH - 10, 'Щель');
this._drawLabel(ctx, screenX, simH - 10, 'Экран');
}
_singleIntensity(λ, a, L, simH) {
const N = 300;
const pts = [];
for (let i = 0; i <= N; i++) {
const y_px = (i / N) * simH - simH / 2;
const y_m = y_px / (simH / 2) * 0.05;
const sinT = y_m / Math.sqrt(y_m * y_m + L * L);
const alpha = Math.PI * a * sinT / λ;
const I = alpha === 0 ? 1 : Math.pow(Math.sin(alpha) / alpha, 2);
pts.push({ y_px, I });
}
return pts;
}
/* ─────────────────────────────────────────────
Diffraction grating
─────────────────────────────────────────────── */
_drawGrating() {
const ctx = this._ctx;
const W = this._W, H = this._H;
const λ_nm = window._obWavelength || 550;
const λ = λ_nm * 1e-9;
const d = this._d_grating * 1e-6;
const a = this._a_grating * 1e-6;
const N = this._N_grating;
const L = 1.0;
const wl = window._obWhiteLight;
const slitX = Math.round(W * 0.22);
const screenX = Math.round(W * 0.75);
const graphH = Math.round(H * 0.26);
const simH = H - graphH - 1;
const cy = Math.round(simH / 2);
this._drawSource(slitX - Math.round(W * 0.15), cy);
ctx.save();
ctx.strokeStyle = wl ? 'rgba(255,255,220,0.35)' : this._wlColor(λ_nm, 0.35);
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(0, cy); ctx.lineTo(slitX - 2, cy); ctx.stroke();
ctx.restore();
/* grating barrier — multiple slits */
this._drawGratingBarrier(slitX, cy, simH, N);
const screenPoints = wl
? this._gratingIntensityWhite(d, a, N, L, simH)
: this._gratingIntensity(λ, d, a, N, L, simH, λ_nm);
this._drawScreen(screenX, simH, screenPoints, λ_nm, wl);
this._drawGraph(0, simH, W, graphH, screenPoints, λ_nm, wl);
/* order labels on screen */
this._drawGratingOrders(ctx, screenX, cy, d, λ, L, simH);
/* resolving power at order 1 */
const R = N * 1;
this._drawHUDLine(ctx, W, H, `R = Nn = ${R} (порядок 1)  d·sinθ = nλ`);
this._drawLabel(ctx, slitX, simH - 10, 'Решётка');
this._drawLabel(ctx, screenX, simH - 10, 'Экран');
}
_gratingIntensity(λ, d, a, N, L, simH, λ_nm) {
const pts = [];
for (let i = 0; i <= 300; i++) {
const y_px = (i / 300) * simH - simH / 2;
const y_m = y_px / (simH / 2) * 0.05;
const sinT = y_m / Math.sqrt(y_m * y_m + L * L);
/* multi-slit principal maxima */
const psi = Math.PI * d * sinT / λ;
const Imulti = psi === 0 ? 1 : Math.pow(Math.sin(N * psi) / (N * Math.sin(psi)), 2);
/* single-slit envelope */
const alpha = Math.PI * a * sinT / λ;
const Ienv = alpha === 0 ? 1 : Math.pow(Math.sin(alpha) / alpha, 2);
const I = Imulti * Ienv;
pts.push({ y_px, I, color: this._wlColor(λ_nm, I) });
}
return pts;
}
_gratingIntensityWhite(d, a, N, L, simH) {
/* Combine 7 wavelengths for white-light display */
const wls = [400, 450, 490, 530, 570, 620, 680];
const pts = [];
for (let i = 0; i <= 300; i++) {
const y_px = (i / 300) * simH - simH / 2;
const y_m = y_px / (simH / 2) * 0.05;
const sinT = y_m / Math.sqrt(y_m * y_m + L * L);
/* blend colors from all wavelengths */
let r = 0, g = 0, b = 0, maxI = 0;
wls.forEach(nm => {
const λ_w = nm * 1e-9;
const psi = Math.PI * d * sinT / λ_w;
const Im = psi === 0 ? 1 : Math.pow(Math.sin(N * psi) / (N * Math.sin(psi)), 2);
const alpha = Math.PI * a * sinT / λ_w;
const Ie = alpha === 0 ? 1 : Math.pow(Math.sin(alpha) / alpha, 2);
const I = Im * Ie;
if (I > maxI) maxI = I;
const rgb = this._wlRGB(nm);
r += rgb[0] * I; g += rgb[1] * I; b += rgb[2] * I;
});
const sc = maxI > 0 ? 1 / (wls.length * maxI + 0.01) * maxI * wls.length : 0;
pts.push({
y_px,
I: maxI,
color: `rgba(${Math.round(Math.min(255, r / wls.length))},${Math.round(Math.min(255, g / wls.length))},${Math.round(Math.min(255, b / wls.length))},${Math.min(1, maxI + 0.02)})`
});
}
return pts;
}
_drawGratingOrders(ctx, screenX, cy, d, λ, L, simH) {
const maxOrder = 3;
ctx.save();
ctx.font = '10px sans-serif';
ctx.fillStyle = 'rgba(255,255,150,0.85)';
ctx.textAlign = 'center';
for (let n = -maxOrder; n <= maxOrder; n++) {
const sinT = n * λ / d;
if (Math.abs(sinT) >= 1) continue;
const y_m = Math.tan(Math.asin(sinT)) * L;
const y_px = y_m / 0.05 * (simH / 2) + cy;
if (y_px < 4 || y_px > simH - 4) continue;
ctx.fillText(`${n >= 0 ? '+' : ''}${n}`, screenX + 18, y_px + 4);
}
ctx.restore();
}
/* ─────────────────────────────────────────────
Common drawing helpers
─────────────────────────────────────────────── */
_drawSource(x, cy) {
const ctx = this._ctx;
ctx.save();
ctx.beginPath();
ctx.arc(x, cy, 7, 0, Math.PI * 2);
ctx.fillStyle = '#fff9c0';
ctx.shadowColor = '#fff5a0';
ctx.shadowBlur = 16;
ctx.fill();
ctx.shadowBlur = 0;
ctx.restore();
/* label */
ctx.save();
ctx.font = '10px sans-serif';
ctx.fillStyle = '#aaa';
ctx.textAlign = 'center';
ctx.fillText('Источник', x, cy + 20);
ctx.restore();
}
_drawSlitBarrier(x, cy, simH, sepPx, gapPx, slitCount) {
const ctx = this._ctx;
const half = sepPx / 2;
const hw = 6;
ctx.save();
ctx.fillStyle = '#3a3a5a';
/* top block */
ctx.fillRect(x - hw, 0, hw * 2, cy - half - gapPx / 2);
/* middle block (between slits) */
if (slitCount === 2) {
ctx.fillRect(x - hw, cy - half + gapPx / 2, hw * 2, sepPx - gapPx);
}
/* bottom block */
ctx.fillRect(x - hw, cy + half + gapPx / 2, hw * 2, simH - (cy + half + gapPx / 2));
/* slit highlight lines */
ctx.strokeStyle = 'rgba(255,255,200,0.5)';
ctx.lineWidth = 1;
[cy - half - gapPx / 2, cy - half + gapPx / 2, cy + half - gapPx / 2, cy + half + gapPx / 2].forEach(yy => {
ctx.beginPath(); ctx.moveTo(x - hw - 2, yy); ctx.lineTo(x + hw + 2, yy); ctx.stroke();
});
ctx.restore();
}
_drawSingleSlitBarrier(x, cy, simH, gapPx) {
const ctx = this._ctx;
const hw = 6;
ctx.save();
ctx.fillStyle = '#3a3a5a';
ctx.fillRect(x - hw, 0, hw * 2, cy - gapPx / 2);
ctx.fillRect(x - hw, cy + gapPx / 2, hw * 2, simH - cy - gapPx / 2);
ctx.strokeStyle = 'rgba(255,255,200,0.5)';
ctx.lineWidth = 1;
[cy - gapPx / 2, cy + gapPx / 2].forEach(yy => {
ctx.beginPath(); ctx.moveTo(x - hw - 2, yy); ctx.lineTo(x + hw + 2, yy); ctx.stroke();
});
ctx.restore();
}
_drawGratingBarrier(x, cy, simH, N) {
const ctx = this._ctx;
const hw = 6;
const totalH = simH * 0.7;
const startY = cy - totalH / 2;
ctx.save();
ctx.fillStyle = '#3a3a5a';
ctx.fillRect(x - hw, 0, hw * 2, simH);
/* punch slit holes */
const slitH = Math.max(2, Math.floor(totalH / (N * 2.5)));
const step = totalH / N;
ctx.fillStyle = '#0a0a16';
for (let i = 0; i < N; i++) {
const sy = startY + i * step + (step - slitH) / 2;
ctx.fillRect(x - hw, sy, hw * 2, slitH);
}
ctx.restore();
}
_drawWavefronts(slitX, s1y, slitX2, s2y, maxR, λ_nm, wl, simH) {
const ctx = this._ctx;
const baseColor = wl ? 'rgba(255,255,200,' : null;
const nArcs = 7;
const srcPts = s1y === s2y ? [{ x: slitX, y: s1y }] : [{ x: slitX, y: s1y }, { x: slitX2, y: s2y }];
srcPts.forEach(src => {
ctx.save();
for (let i = 1; i <= nArcs; i++) {
const r = (i / nArcs) * maxR * 0.92;
const a = wl ? (0.4 - i * 0.04) : (0.45 - i * 0.05);
ctx.strokeStyle = wl ? `rgba(255,255,180,${Math.max(0.04, a)})` : this._wlColor(λ_nm, Math.max(0.04, a));
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(src.x, src.y, r, -Math.PI / 2, Math.PI / 2);
ctx.stroke();
}
ctx.restore();
});
}
_drawScreen(screenX, simH, pts, λ_nm, wl) {
const ctx = this._ctx;
const bw = 16; // bar width
/* glow pass for bright fringes */
if (window.LabFX && window.LabFX.glow) {
/* simple manual glow: shadow blur */
}
ctx.save();
/* background of screen area */
ctx.fillStyle = '#08080f';
ctx.fillRect(screenX - 1, 0, bw + 2, simH);
pts.forEach(({ y_px, I, color }) => {
const yy = Math.round(y_px + simH / 2);
const col = color || (wl ? `rgba(255,255,200,${I.toFixed(3)})` : this._wlColor(λ_nm, I));
ctx.fillStyle = col;
ctx.fillRect(screenX, yy, bw, 2);
/* glow for bright pixels */
if (I > 0.55) {
ctx.save();
ctx.shadowColor = color || this._wlColor(λ_nm, 1);
ctx.shadowBlur = 8 + I * 10;
ctx.fillStyle = col;
ctx.fillRect(screenX + 1, yy, bw - 2, 2);
ctx.restore();
}
});
ctx.restore();
}
_drawGraph(x0, y0, W, H, pts, λ_nm, wl) {
const ctx = this._ctx;
const padL = 38, padR = 20, padT = 8, padB = 18;
const gx = x0 + padL, gy = y0 + padT;
const gw = W - padL - padR, gh = H - padT - padB;
/* graph background */
ctx.save();
ctx.fillStyle = '#080810';
ctx.fillRect(x0, y0, W, H);
ctx.fillStyle = '#0d0d1e';
ctx.fillRect(gx, gy, gw, gh);
/* axes */
ctx.strokeStyle = '#2a2a50';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(gx, gy); ctx.lineTo(gx, gy + gh); ctx.lineTo(gx + gw, gy + gh); ctx.stroke();
/* y-axis label */
ctx.save();
ctx.translate(x0 + 12, gy + gh / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillStyle = '#666';
ctx.font = '9px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('I(y)', 0, 0);
ctx.restore();
/* intensity curve */
const maxI = pts.reduce((m, p) => Math.max(m, p.I), 0) || 1;
ctx.beginPath();
let first = true;
pts.forEach(({ y_px, I }) => {
const px = gx + (y_px / (this._H / 2) + 1) / 2 * gw;
const py = gy + gh - (I / maxI) * gh * 0.92;
if (first) { ctx.moveTo(px, py); first = false; } else ctx.lineTo(px, py);
});
ctx.strokeStyle = wl ? '#ffe066' : this._wlColor(λ_nm, 0.9);
ctx.lineWidth = 1.5;
ctx.stroke();
/* axis label */
ctx.fillStyle = '#555';
ctx.font = '9px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('y', gx + gw / 2, gy + gh + padB - 4);
ctx.textAlign = 'right';
ctx.fillText('1', gx - 3, gy + gh * 0.08);
ctx.restore();
}
_drawHUDLine(ctx, W, H, text) {
ctx.save();
ctx.fillStyle = 'rgba(10,10,22,0.7)';
ctx.fillRect(4, H - 22, W - 8, 18);
ctx.fillStyle = '#9ad8ff';
ctx.font = '11px monospace';
ctx.textAlign = 'left';
ctx.fillText(text, 10, H - 7);
ctx.restore();
}
_drawLabel(ctx, x, y, text) {
ctx.save();
ctx.fillStyle = '#666';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(text, x, y);
ctx.restore();
}
/* color helpers */
_wlColor(nm, alpha) {
const [r, g, b] = this._wlRGB(nm);
return `rgba(${r},${g},${b},${(alpha || 1).toFixed(3)})`;
}
_wlRGB(nm) {
const c = wavelengthToRGB(nm);
/* wavelengthToRGB returns 'rgb(r,g,b)' — parse it */
const m = c.match(/(\d+),\s*(\d+),\s*(\d+)/);
if (m) return [+m[1], +m[2], +m[3]];
return [200, 200, 200];
}
_updateHUD() {
/* update stats bar */
const λ_nm = window._obWavelength || 550;
const λ = λ_nm * 1e-9;
const el = id => document.getElementById(id);
if (this._sub === 'young') {
const d = this._d_young * 1e-6, L = this._L_young;
const dy = (λ * L / d * 1e3).toFixed(3);
if (el('diffbar-info')) el('diffbar-info').textContent = `Δy = ${dy} мм`;
} else if (this._sub === 'single') {
const a = this._a_single * 1e-6;
const ang = (2 * λ / a * 180 / Math.PI).toFixed(2);
if (el('diffbar-info')) el('diffbar-info').textContent = `2λ/a = ${ang}°`;
} else if (this._sub === 'grating') {
const R = this._N_grating * 1;
if (el('diffbar-info')) el('diffbar-info').textContent = `R = Nn = ${R}`;
}
if (el('diffbar-sub')) el('diffbar-sub').textContent = {
young: 'Юнг',
single: 'Однощелевая',
grating: 'Решётка'
}[this._sub] || '';
if (el('diffbar-wl')) el('diffbar-wl').textContent = (window._obWhiteLight ? 'Белый' : λ_nm + ' нм');
}
}
/* ── DiffractionSim singleton + UI wiring ── */
var diffrSim = null;
function diffrSwitchSub(sub) {
if (window.LabFX) LabFX.sound.play('chime', { volume: 0.4 });
/* button styling */
['young', 'single', 'grating'].forEach(s => {
const btn = document.getElementById('diffr-sub-' + s);
if (btn) btn.classList.toggle('active', s === sub);
});
/* param panel visibility */
['young', 'single', 'grating'].forEach(s => {
const pnl = document.getElementById('ob-diffr-' + s + '-params');
if (pnl) pnl.style.display = s === sub ? '' : 'none';
});
if (diffrSim) {
diffrSim.setSub(sub);
diffrSim._updateHUD();
}
}
function diffrParam(name, val) {
/* update value label */
const labelId = 'diffr-' + name.replace('_', '-') + '-val';
const lbl = document.getElementById(labelId);
if (lbl) {
const v = parseFloat(val);
/* N_grating → integer; L/d_grating/a_grating → 1 decimal; others → 0 */
const decimals = name.includes('N_') ? -1 : (name === 'L_young' || name === 'd_grating' || name === 'a_grating') ? 1 : 0;
lbl.textContent = decimals < 0 ? Math.round(v) : v.toFixed(decimals);
}
if (diffrSim) diffrSim.setParam(name, val);
}
function diffrReset() {
if (!diffrSim) return;
diffrSim._d_young = 40;
diffrSim._L_young = 1.0;
diffrSim._a_single = 80;
diffrSim._N_grating = 10;
diffrSim._d_grating = 2.0;
diffrSim._a_grating = 0.5;
/* reset sliders */
['d-young', 'L-young', 'a-single', 'N-grating', 'd-grating', 'a-grating'].forEach(k => {
const sl = document.getElementById('sl-diffr-' + k);
if (sl) {
const defaults = {
'd-young': 40, 'L-young': 10, 'a-single': 80,
'N-grating': 10, 'd-grating': 20, 'a-grating': 5
};
if (defaults[k] !== undefined) sl.value = defaults[k];
}
const lbl = document.getElementById('diffr-' + k + '-val');
if (lbl) {
const dv = { 'd-young': 40, 'L-young': '1.0', 'a-single': 80, 'N-grating': 10, 'd-grating': '2.0', 'a-grating': '0.5' };
if (dv[k] !== undefined) lbl.textContent = dv[k];
}
});
diffrSim.draw();
diffrSim._updateHUD();
}