353a6cb8a9
- _prismInteract: тонкопризменное отклонение δ=(n−1)·A к основанию + хроматическая дисперсия n(λ) через _nAtWavelength - белый свет: пучки по OB_SPECTRAL, каждый луч красится по длине волны (до призмы совпадают, после — расходятся в спектр); управление общим λ-баром - _obRedraw для freebuild переключён на benchSim (был freeSim) - сферические зеркала уже из Фазы 1; проверено численно (фиолет>красный) - bump opticsbench.js?v=3 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5062 lines
212 KiB
JavaScript
5062 lines
212 KiB
JavaScript
'use strict';
|
||
/* ══════════════════════════════════════════════════════════════
|
||
OpticsBenchSim — unified optical bench simulation
|
||
Merges: ThinLensSim (thinlens.js) + MirrorSim (mirror.js) + RefractionSim (refraction.js)
|
||
|
||
Modes:
|
||
'lens' — thin lens: 1/f = 1/d + 1/d', M = -d'/d
|
||
'mirror' — curved / flat mirrors, same formula
|
||
'refraction'— Snell's law: n₁sin θ₁ = n₂sin θ₂, TIR, dispersion
|
||
|
||
Physics preserved verbatim from original sims.
|
||
══════════════════════════════════════════════════════════════ */
|
||
|
||
/* ─────────────────────────────────────────────────────────────
|
||
0. WAVELENGTH UTILITIES
|
||
───────────────────────────────────────────────────────────────*/
|
||
|
||
/**
|
||
* Convert wavelength (nm, 380–780) to an sRGB CSS color string.
|
||
* Well-known approximation by Dan Bruton (adjusted for alpha at edges).
|
||
*/
|
||
function wavelengthToRGB(nm) {
|
||
let R = 0, G = 0, B = 0, a = 1;
|
||
if (nm >= 380 && nm < 440) { R = -(nm - 440) / (440 - 380); G = 0; B = 1; }
|
||
else if (nm < 490) { R = 0; G = (nm - 440) / (490 - 440); B = 1; }
|
||
else if (nm < 510) { R = 0; G = 1; B = -(nm - 510) / (510 - 490); }
|
||
else if (nm < 580) { R = (nm - 510) / (580 - 510); G = 1; B = 0; }
|
||
else if (nm < 645) { R = 1; G = -(nm - 645) / (645 - 580); B = 0; }
|
||
else if (nm < 781) { R = 1; G = 0; B = 0; }
|
||
if (nm >= 700) a = 0.3 + 0.7 * (780 - nm) / (780 - 700);
|
||
else if (nm < 420) a = 0.3 + 0.7 * (nm - 380) / (420 - 380);
|
||
return `rgba(${Math.round(R * 255 * a)},${Math.round(G * 255 * a)},${Math.round(B * 255 * a)},1)`;
|
||
}
|
||
|
||
/**
|
||
* Wavelength-dependent index of refraction — simple linear model.
|
||
* n(550nm) = n0 (glass baseline), with normal dispersion (blue > red).
|
||
* Formula: n(λ) = n0 - 0.0002*(λ - 550)
|
||
*/
|
||
function _nAtWavelength(n0, nm) {
|
||
return n0 - 0.0002 * (nm - 550);
|
||
}
|
||
|
||
/**
|
||
* Return the ray color for the current global wavelength setting.
|
||
* If white-light mode: returns null (caller must draw multi-spectral bundles).
|
||
* Otherwise returns a CSS color string.
|
||
*/
|
||
function _obRayColor(fallback) {
|
||
if (window._obWhiteLight) return null;
|
||
return wavelengthToRGB(window._obWavelength || 550);
|
||
}
|
||
|
||
/** Spectral samples used for white-light / prism dispersion */
|
||
const OB_SPECTRAL = [
|
||
{ nm: 405, label: 'violet' },
|
||
{ nm: 450, label: 'blue' },
|
||
{ nm: 510, label: 'green' },
|
||
{ nm: 550, label: 'yellow' },
|
||
{ nm: 610, label: 'orange' },
|
||
{ nm: 660, label: 'red' },
|
||
];
|
||
|
||
/* ─────────────────────────────────────────────────────────────
|
||
0b. OB_FX — VISUAL DEPTH TOGGLES (A-agent)
|
||
Five optional visual layers controlled by checkboxes.
|
||
State persisted in localStorage as 'ob_fx_state'.
|
||
───────────────────────────────────────────────────────────────*/
|
||
|
||
function _obFXLoad() {
|
||
try {
|
||
const raw = localStorage.getItem('ob_fx_state');
|
||
if (raw) Object.assign(window.OB_FX, JSON.parse(raw));
|
||
} catch (_e) { /* ignore */ }
|
||
}
|
||
|
||
function _obFXSave() {
|
||
try { localStorage.setItem('ob_fx_state', JSON.stringify(window.OB_FX)); } catch (_e) { /* ignore */ }
|
||
}
|
||
|
||
/** Called by each toggle checkbox */
|
||
function obFXToggle(key, val) {
|
||
window.OB_FX[key] = !!val;
|
||
_obFXSave();
|
||
_obRedraw();
|
||
}
|
||
|
||
window.OB_FX = { wavefronts: false, mist: false, flare: false, huygens: false, caustics: false };
|
||
_obFXLoad();
|
||
|
||
/* ── Mist: background smoke-particle emitter ── */
|
||
let _obMistRaf = 0, _obMistCtx = null, _obMistFrame = 0;
|
||
|
||
function _obMistTick() {
|
||
if (!window.OB_FX.mist) { _obMistRaf = 0; return; }
|
||
const ctx = _obMistCtx;
|
||
if (!ctx || !window.LabFX) { _obMistRaf = 0; return; }
|
||
_obMistFrame++;
|
||
if (_obMistFrame % 2 === 0) {
|
||
const W = ctx.canvas.width, H = ctx.canvas.height;
|
||
LabFX.particles.emit({
|
||
ctx, x: Math.random() * W, y: Math.random() * H,
|
||
count: 1, color: 'rgba(180,200,255,0.06)', speed: 3,
|
||
spread: Math.PI * 2, life: 3000, shape: 'smoke', size: 30, glow: false,
|
||
});
|
||
}
|
||
_obMistRaf = requestAnimationFrame(_obMistTick);
|
||
}
|
||
|
||
function _obStartMist(ctx) {
|
||
_obMistCtx = ctx;
|
||
if (!_obMistRaf && window.OB_FX.mist && window.LabFX) {
|
||
_obMistRaf = requestAnimationFrame(_obMistTick);
|
||
}
|
||
}
|
||
|
||
/* ── Shared wavefront phase clock ── */
|
||
let _obWFPhase = 0, _obWFLastT = 0;
|
||
|
||
/**
|
||
* Draw animated sinusoidal wavefront ticks along a ray segment.
|
||
* Ticks march along the ray to simulate a travelling wave.
|
||
* In-glass spacing compresses by 1/nMed (shows refraction at boundary).
|
||
*/
|
||
function _obDrawWavefrontTicks(ctx, x1, y1, x2, y2, nm, nMed) {
|
||
nMed = nMed || 1;
|
||
const dx = x2 - x1, dy = y2 - y1;
|
||
const len = Math.hypot(dx, dy);
|
||
if (len < 4) return;
|
||
const lambdaPx = Math.max(8, (nm * 0.45) / nMed);
|
||
const ux = dx / len, uy = dy / len;
|
||
const qx = -uy, qy = ux; // perpendicular unit vector
|
||
const tickLen = 5;
|
||
const col = wavelengthToRGB(nm || 550);
|
||
const phaseOff = (_obWFPhase * lambdaPx * 2) % lambdaPx;
|
||
ctx.save();
|
||
ctx.globalAlpha = 0.52;
|
||
ctx.strokeStyle = col; ctx.lineWidth = 1;
|
||
let t = phaseOff;
|
||
while (t < len) {
|
||
const cx = x1 + ux * t, cy = y1 + uy * t;
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx - qx * tickLen, cy - qy * tickLen);
|
||
ctx.lineTo(cx + qx * tickLen, cy + qy * tickLen);
|
||
ctx.stroke();
|
||
t += lambdaPx;
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
/**
|
||
* Draw lens flare: starburst + ghost reflections + chromatic ring.
|
||
* Uses additive blending for the glow effect.
|
||
*/
|
||
function _obDrawLensFlare(ctx, srcX, srcY, W, H) {
|
||
ctx.save();
|
||
ctx.globalCompositeOperation = 'lighter';
|
||
const spikes = 6, spikeLen = 40;
|
||
for (let i = 0; i < spikes; i++) {
|
||
const angle = (i / spikes) * Math.PI + _obWFPhase * 0.3;
|
||
const grad = ctx.createLinearGradient(srcX, srcY,
|
||
srcX + Math.cos(angle) * spikeLen, srcY + Math.sin(angle) * spikeLen);
|
||
grad.addColorStop(0, 'rgba(255,255,200,0.32)');
|
||
grad.addColorStop(1, 'rgba(255,255,200,0)');
|
||
ctx.strokeStyle = grad; ctx.lineWidth = 1.5;
|
||
ctx.beginPath(); ctx.moveTo(srcX, srcY);
|
||
ctx.lineTo(srcX + Math.cos(angle) * spikeLen, srcY + Math.sin(angle) * spikeLen);
|
||
ctx.stroke();
|
||
ctx.beginPath(); ctx.moveTo(srcX, srcY);
|
||
ctx.lineTo(srcX - Math.cos(angle) * spikeLen, srcY - Math.sin(angle) * spikeLen);
|
||
ctx.stroke();
|
||
}
|
||
const cg = ctx.createRadialGradient(srcX, srcY, 0, srcX, srcY, 14);
|
||
cg.addColorStop(0, 'rgba(255,255,240,0.55)');
|
||
cg.addColorStop(1, 'rgba(255,255,240,0)');
|
||
ctx.fillStyle = cg; ctx.beginPath(); ctx.arc(srcX, srcY, 14, 0, Math.PI * 2); ctx.fill();
|
||
const gx = W / 2 - srcX, gy = H / 2 - srcY;
|
||
[
|
||
{ t: 0.3, r: 18, col: 'rgba(100,80,255,0.12)' },
|
||
{ t: 0.6, r: 11, col: 'rgba(255,60,60,0.10)' },
|
||
{ t: 1.1, r: 7, col: 'rgba(60,220,255,0.09)' },
|
||
].forEach(function(g) {
|
||
const gX = srcX + gx * g.t, gY = srcY + gy * g.t;
|
||
const gg = ctx.createRadialGradient(gX, gY, 0, gX, gY, g.r);
|
||
gg.addColorStop(0, g.col); gg.addColorStop(1, 'rgba(0,0,0,0)');
|
||
ctx.fillStyle = gg; ctx.beginPath(); ctx.arc(gX, gY, g.r, 0, Math.PI * 2); ctx.fill();
|
||
});
|
||
ctx.globalAlpha = 0.07 + 0.04 * Math.sin(_obWFPhase);
|
||
ctx.strokeStyle = 'rgba(200,150,255,0.5)'; ctx.lineWidth = 2;
|
||
ctx.beginPath(); ctx.arc(srcX, srcY, 22 + 4 * Math.sin(_obWFPhase * 0.5), 0, Math.PI * 2); ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
/**
|
||
* Draw Huygens wavelets at a refraction/reflection boundary.
|
||
* 5 sample points along wavefront emit expanding semi-circular arcs.
|
||
*/
|
||
function _obDrawHuygens(ctx, bx, by, normalAngle, kind) {
|
||
const t = _obWFPhase % 1;
|
||
const maxR = 28, nSamples = 5;
|
||
ctx.save();
|
||
ctx.globalAlpha = 0.22;
|
||
for (let i = 0; i < nSamples; i++) {
|
||
const offset = (i - (nSamples - 1) / 2) * 12;
|
||
const wx = bx + Math.cos(normalAngle + Math.PI / 2) * offset;
|
||
const wy = by + Math.sin(normalAngle + Math.PI / 2) * offset;
|
||
const r = t * maxR;
|
||
const sa = kind === 'reflection'
|
||
? normalAngle - Math.PI / 2
|
||
: normalAngle + Math.PI / 2;
|
||
ctx.strokeStyle = 'rgba(180,220,255,0.7)'; ctx.lineWidth = 1.2;
|
||
ctx.beginPath();
|
||
ctx.arc(wx, wy, r, sa - Math.PI / 2, sa + Math.PI / 2);
|
||
ctx.stroke();
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
/**
|
||
* Draw caustic curve under a lens by tracing ~20 parallel rays from infinity.
|
||
* Each ray is slightly shifted by spherical aberration creating an extended caustic.
|
||
*/
|
||
function _obDrawCausticCurve(ctx, lx, ay, f, H) {
|
||
if (!isFinite(f) || f <= 0) return;
|
||
const lensH = Math.min(H * 0.38, 140);
|
||
const nRays = 20;
|
||
const pts = [];
|
||
for (let i = 0; i < nRays; i++) {
|
||
const yIn = -lensH + (2 * lensH * i) / (nRays - 1);
|
||
const abFac = 1 - 0.0008 * (yIn / lensH) * (yIn / lensH) * Math.min(Math.abs(f), 150);
|
||
const fEff = f * abFac;
|
||
const sl = -yIn / fEff;
|
||
if (Math.abs(sl) < 0.0001) continue;
|
||
const tCross = -yIn / sl;
|
||
if (tCross < 0 || tCross > 600) continue;
|
||
pts.push({ x: lx + tCross, y: ay });
|
||
}
|
||
if (pts.length < 2) return;
|
||
const pulse = 0.55 + 0.35 * Math.sin(_obWFPhase * Math.PI * 2);
|
||
ctx.save();
|
||
ctx.globalAlpha = pulse;
|
||
ctx.globalCompositeOperation = 'lighter';
|
||
ctx.strokeStyle = '#FFD166'; ctx.lineWidth = 2;
|
||
ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 6;
|
||
ctx.beginPath();
|
||
pts.forEach(function(p, idx) { idx === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y); });
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
/**
|
||
* Unified OB_FX layer — called at end of each sim draw() BEFORE particles.draw().
|
||
* @param {CanvasRenderingContext2D} ctx
|
||
* @param {string} simType 'lens'|'mirror'|'refraction'|'prism'|'freebuild'
|
||
* @param {object} [extras] {rays[], srcX, srcY, boundary, causticParams}
|
||
*/
|
||
function _drawOBFXLayer(ctx, simType, extras) {
|
||
const now = performance.now();
|
||
if (_obWFLastT) _obWFPhase += (now - _obWFLastT) / 1000 * 0.5;
|
||
_obWFLastT = now;
|
||
|
||
if (window.OB_FX.mist) _obStartMist(ctx);
|
||
if (!extras) return;
|
||
|
||
if (window.OB_FX.wavefronts && extras.rays) {
|
||
const nm = window._obWavelength || 550;
|
||
extras.rays.forEach(function(r) {
|
||
_obDrawWavefrontTicks(ctx, r.x1, r.y1, r.x2, r.y2, nm, r.n || 1);
|
||
});
|
||
}
|
||
if (window.OB_FX.flare && extras.srcX !== undefined) {
|
||
_obDrawLensFlare(ctx, extras.srcX, extras.srcY, ctx.canvas.width, ctx.canvas.height);
|
||
}
|
||
if (window.OB_FX.huygens && extras.boundary) {
|
||
const b = extras.boundary;
|
||
_obDrawHuygens(ctx, b.bx, b.by, b.normalAngle, b.kind);
|
||
}
|
||
if (window.OB_FX.caustics && extras.causticParams) {
|
||
const cp = extras.causticParams;
|
||
_obDrawCausticCurve(ctx, cp.lx, cp.ay, cp.f, ctx.canvas.height);
|
||
}
|
||
}
|
||
|
||
/* ─────────────────────────────────────────────────────────────
|
||
1. THIN LENS ENGINE (from thinlens.js)
|
||
───────────────────────────────────────────────────────────────*/
|
||
class ThinLensSim {
|
||
constructor(canvas) {
|
||
this.canvas = canvas;
|
||
this.ctx = canvas.getContext('2d');
|
||
this.W = 0; this.H = 0;
|
||
|
||
this.f = 100;
|
||
this.d = 200;
|
||
this.h = 50;
|
||
|
||
this._drag = null;
|
||
this.onUpdate = null;
|
||
|
||
/* aberration toggles (Agent OB-A3) */
|
||
this._aberrSpherical = false;
|
||
this._aberrChromatic = false;
|
||
|
||
/* ── Lens-maker formula params (Feature 3) ── */
|
||
this._lmSimple = true; // true = simple f-slider mode; false = R1/R2/n mode
|
||
this._lmR1 = 100; // front surface radius (mm)
|
||
this._lmR2 = -100; // back surface radius (mm)
|
||
this._lmN = 1.5; // refractive index
|
||
|
||
/* ── Animated ray construction (Feature 1) ── */
|
||
this._rayAnimT = [1, 1, 1]; // progress of each of the 3 rays [0..1]
|
||
this._rayTweens = []; // active tween handles
|
||
|
||
this._bindEvents();
|
||
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
|
||
}
|
||
|
||
fit() {
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const w = this.canvas.offsetWidth || 600;
|
||
const h = this.canvas.offsetHeight || 400;
|
||
this.canvas.width = w * dpr;
|
||
this.canvas.height = h * dpr;
|
||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
this.W = w; this.H = h;
|
||
}
|
||
|
||
getParams() { return { f: this.f, d: this.d, h: this.h }; }
|
||
setParams({ f, d, h } = {}) {
|
||
if (f !== undefined) this.f = Math.max(-200, Math.min(200, +f));
|
||
if (d !== undefined) this.d = Math.max(30, Math.min(400, +d));
|
||
if (h !== undefined) this.h = Math.max(20, Math.min(80, +h));
|
||
this.draw();
|
||
this._emit();
|
||
}
|
||
|
||
reset() {
|
||
this.f = 100; this.d = 200; this.h = 50;
|
||
this.draw();
|
||
this._emit();
|
||
}
|
||
|
||
/* toggle aberration modes (Agent OB-A3) */
|
||
setAberration(type, on) {
|
||
if (type === 'spherical') this._aberrSpherical = !!on;
|
||
if (type === 'chromatic') this._aberrChromatic = !!on;
|
||
this.draw();
|
||
this._emit();
|
||
}
|
||
|
||
|
||
/* ═══ Lens-maker formula (Feature 3) ════════════════════════════ */
|
||
_lmCalcF() {
|
||
const R1 = this._lmR1, R2 = this._lmR2, n = this._lmN;
|
||
const inv1 = Math.abs(R1) > 0.5 ? 1 / R1 : 0;
|
||
const inv2 = Math.abs(R2) > 0.5 ? 1 / R2 : 0;
|
||
const invF = (n - 1) * (inv1 - inv2);
|
||
if (Math.abs(invF) < 1e-6) return 9999;
|
||
return 1 / invF;
|
||
}
|
||
|
||
setLensMode(simple) {
|
||
this._lmSimple = !!simple;
|
||
if (!simple) {
|
||
const k = this.f * (this._lmN - 1);
|
||
if (Math.abs(k) > 1) { this._lmR1 = k * 2; this._lmR2 = -k * 2; }
|
||
}
|
||
this.draw(); this._emit();
|
||
}
|
||
|
||
setLMParam(name, val) {
|
||
const v = +val;
|
||
if (name === 'R1') this._lmR1 = Math.max(-300, Math.min(300, v));
|
||
else if (name === 'R2') this._lmR2 = Math.max(-300, Math.min(300, v));
|
||
else if (name === 'n') this._lmN = Math.max(1.3, Math.min(2.4, v));
|
||
if (!this._lmSimple) this.f = Math.max(-200, Math.min(200, this._lmCalcF()));
|
||
this.draw(); this._emit();
|
||
}
|
||
|
||
_lmShapeName() {
|
||
const R1 = this._lmR1, R2 = this._lmR2;
|
||
const flat1 = Math.abs(R1) > 280, flat2 = Math.abs(R2) > 280;
|
||
if (flat1 && flat2) return 'плоскопараллельная';
|
||
if (R1 > 0 && R2 < 0) return 'двояковыпуклая';
|
||
if (R1 < 0 && R2 > 0) return 'двояковогнутая';
|
||
if (flat1 || flat2) return 'плоско-выпуклая / вогнутая';
|
||
return 'мениск';
|
||
}
|
||
|
||
_drawLensLM(ctx, lx, ay) {
|
||
const lensH = Math.min(this.H * 0.38, 140);
|
||
const R1 = this._lmR1, R2 = this._lmR2;
|
||
const flat1 = Math.abs(R1) > 280, flat2 = Math.abs(R2) > 280;
|
||
const b1 = flat1 ? 0 : Math.max(-20, Math.min(20, lensH * lensH / (2 * R1)));
|
||
const b2 = flat2 ? 0 : Math.max(-20, Math.min(20, lensH * lensH / (2 * -R2)));
|
||
ctx.strokeStyle = 'rgba(155,93,229,0.85)'; ctx.lineWidth = 2.5;
|
||
ctx.beginPath(); ctx.moveTo(lx + b1, ay - lensH); ctx.quadraticCurveTo(lx + b1 * 2, ay, lx + b1, ay + lensH); ctx.stroke();
|
||
ctx.beginPath(); ctx.moveTo(lx + b2, ay - lensH); ctx.quadraticCurveTo(lx + b2 * 2, ay, lx + b2, ay + lensH); ctx.stroke();
|
||
ctx.strokeStyle = 'rgba(155,93,229,0.4)'; ctx.lineWidth = 1.2;
|
||
ctx.beginPath(); ctx.moveTo(lx + b1, ay - lensH); ctx.lineTo(lx + b2, ay - lensH); ctx.stroke();
|
||
ctx.beginPath(); ctx.moveTo(lx + b1, ay + lensH); ctx.lineTo(lx + b2, ay + lensH); ctx.stroke();
|
||
ctx.strokeStyle = 'rgba(155,93,229,0.2)'; ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.lineTo(lx, ay + lensH); ctx.stroke();
|
||
if (this.f > 0) { this._lensArrow(ctx, lx, ay - lensH, -1); this._lensArrow(ctx, lx, ay + lensH, 1); }
|
||
else { this._lensArrowDiv(ctx, lx, ay - lensH, -1); this._lensArrowDiv(ctx, lx, ay + lensH, 1); }
|
||
}
|
||
|
||
_drawLMInfo(ctx, lx, ay) {
|
||
if (this._lmSimple) return;
|
||
const f = this.f, D = Math.abs(f) > 0.5 ? (1000 / f).toFixed(2) : '---';
|
||
const n = this._lmN.toFixed(2);
|
||
const R1s = Math.abs(this._lmR1) > 280 ? 'inf' : this._lmR1.toFixed(0);
|
||
const R2s = Math.abs(this._lmR2) > 280 ? 'inf' : this._lmR2.toFixed(0);
|
||
const shape = this._lmShapeName();
|
||
const bx = lx + 20, by = 12, bw = 200, bh = 72;
|
||
ctx.fillStyle = 'rgba(13,13,26,0.88)';
|
||
ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 8); ctx.fill();
|
||
ctx.strokeStyle = 'rgba(155,93,229,0.3)'; ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 8); ctx.stroke();
|
||
ctx.font = '10px Manrope, system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
|
||
ctx.fillStyle = 'rgba(155,93,229,0.9)'; ctx.fillText('Lensmaker', bx + 8, by + 7);
|
||
ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.fillText('R1=' + R1s + ' R2=' + R2s + ' n=' + n, bx + 8, by + 22);
|
||
ctx.fillStyle = '#06D6E0'; ctx.fillText('f = ' + (isFinite(f) ? f.toFixed(1) : '---') + ' mm', bx + 8, by + 38);
|
||
ctx.fillStyle = '#FFD166'; ctx.fillText('D = ' + D + ' dptr', bx + 104, by + 38);
|
||
ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText(shape, bx + 8, by + 54);
|
||
}
|
||
|
||
/* ═══ Animated 3-ray construction (Feature 1) ═══════════════════ */
|
||
buildRays() {
|
||
this._rayTweens.forEach(h => h && h.cancel && h.cancel());
|
||
this._rayTweens = [];
|
||
this._rayAnimT = [0, 0, 0];
|
||
const animate = (idx, onDone) => {
|
||
if (!window.LabFX) { this._rayAnimT[idx] = 1; this.draw(); if (onDone) onDone(); return; }
|
||
const h = LabFX.motion.tween(0, 1, 500, 'easeOutCubic',
|
||
t => { this._rayAnimT[idx] = t; this.draw(); }, onDone);
|
||
this._rayTweens[idx] = h;
|
||
};
|
||
animate(0, () => animate(1, () => animate(2, () => { if (window.LabFX) LabFX.sound.play('chime'); })));
|
||
}
|
||
|
||
resetRays() {
|
||
this._rayTweens.forEach(h => h && h.cancel && h.cancel());
|
||
this._rayTweens = [];
|
||
this._rayAnimT = [1, 1, 1];
|
||
this.draw();
|
||
}
|
||
|
||
info() {
|
||
const { f, d, h } = this;
|
||
const denom = d - f;
|
||
const dPrime = Math.abs(denom) < 0.01 ? Infinity : (f * d) / denom;
|
||
const M = Math.abs(denom) < 0.01 ? Infinity : -dPrime / d;
|
||
const hPrime = M === Infinity ? Infinity : M * h;
|
||
const isVirtual = dPrime < 0;
|
||
return {
|
||
f: +f.toFixed(1),
|
||
d: +d.toFixed(1),
|
||
dPrime: dPrime === Infinity ? Infinity : +dPrime.toFixed(1),
|
||
M: M === Infinity ? Infinity : +M.toFixed(3),
|
||
imageType: isVirtual ? 'мнимое' : 'действительное',
|
||
h: +h.toFixed(1),
|
||
hPrime: hPrime === Infinity ? Infinity : +Math.abs(hPrime).toFixed(1),
|
||
};
|
||
}
|
||
|
||
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
|
||
|
||
_toCanvas(sx, sy) { return { cx: this.W / 2 + sx, cy: this.H / 2 - sy }; }
|
||
_fromCanvas(cx, cy) { return { sx: cx - this.W / 2, sy: this.H / 2 - cy }; }
|
||
|
||
draw() {
|
||
const ctx = this.ctx, W = this.W, H = this.H;
|
||
if (!W || !H) return;
|
||
|
||
const { f, d, h } = this;
|
||
const lensX = W / 2;
|
||
const axisY = H / 2;
|
||
|
||
ctx.fillStyle = '#0D0D1A';
|
||
ctx.fillRect(0, 0, W, H);
|
||
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
|
||
ctx.lineWidth = 1;
|
||
ctx.setLineDash([6, 4]);
|
||
ctx.beginPath(); ctx.moveTo(0, axisY); ctx.lineTo(W, axisY); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
|
||
// Lens silhouette: detailed (LM) or simple mode
|
||
if (!this._lmSimple) this._drawLensLM(ctx, lensX, axisY);
|
||
else this._drawLens(ctx, lensX, axisY, f);
|
||
this._drawFocalPoints(ctx, lensX, axisY, f);
|
||
|
||
const objX = lensX - d;
|
||
this._drawArrow(ctx, objX, axisY, objX, axisY - h, '#9B5DE5', false);
|
||
|
||
const denom = d - f;
|
||
let dPrime, hPrime;
|
||
if (Math.abs(denom) < 0.5) {
|
||
dPrime = null; hPrime = null;
|
||
} else {
|
||
dPrime = (f * d) / denom;
|
||
hPrime = (-dPrime / d) * h;
|
||
}
|
||
|
||
/* aberrations override standard rays (Agent OB-A3) */
|
||
if (this._aberrSpherical) {
|
||
this._drawSphericalAberration(ctx, lensX, axisY, d, h, f);
|
||
} else if (this._aberrChromatic) {
|
||
this._drawChromaticAberration(ctx, lensX, axisY, d, h, f);
|
||
} else {
|
||
this._drawRaysAnimated(ctx, lensX, axisY, d, h, f, dPrime, hPrime);
|
||
}
|
||
|
||
if (!this._aberrSpherical && !this._aberrChromatic && dPrime !== null && isFinite(dPrime)) {
|
||
const isVirtual = dPrime < 0;
|
||
const imgX = lensX + dPrime;
|
||
// Feature 1: real=cyan, virtual=pink/dashed
|
||
this._drawArrow(ctx, imgX, axisY, imgX, axisY - hPrime,
|
||
isVirtual ? 'rgba(255,133,162,0.9)' : '#06D6E0', isVirtual);
|
||
}
|
||
|
||
this._drawLabels(ctx, lensX, axisY, d, f, dPrime, hPrime);
|
||
this._drawArrowLabels(ctx, lensX, axisY, d, h, dPrime, hPrime);
|
||
this._drawLMInfo(ctx, lensX, axisY);
|
||
|
||
// Lens caustics: emit dust near focal point when image exists (only when OB_FX.caustics is off)
|
||
if (!window.OB_FX.caustics && window.LabFX && dPrime !== null && isFinite(dPrime) && dPrime > 0) {
|
||
const imgX = lensX + dPrime;
|
||
if (!this._causticFrame) this._causticFrame = 0;
|
||
this._causticFrame++;
|
||
if (this._causticFrame % 4 === 0) {
|
||
LabFX.particles.emit({ ctx, x: imgX + (Math.random() - 0.5) * 10, y: axisY + (Math.random() - 0.5) * 10, count: 3, color: '#FFD166', speed: 8, spread: Math.PI * 2, life: 500, shape: 'dust', glow: true });
|
||
}
|
||
}
|
||
// OB_FX visual depth layer
|
||
{
|
||
const objX = lensX - d;
|
||
const fxExtras = {
|
||
srcX: objX, srcY: axisY - h,
|
||
causticParams: { lx: lensX, ay: axisY, f },
|
||
rays: [
|
||
{ x1: objX, y1: axisY - h, x2: lensX, y2: axisY - h },
|
||
{ x1: lensX, y1: axisY - h, x2: lensX + (dPrime || 200), y2: dPrime ? axisY - ((-dPrime / d) * h) : axisY },
|
||
],
|
||
};
|
||
_drawOBFXLayer(ctx, 'lens', fxExtras);
|
||
}
|
||
if (window.LabFX) { LabFX.particles.update(1 / 60); LabFX.particles.draw(ctx); }
|
||
}
|
||
|
||
_drawLens(ctx, lx, ay, f) {
|
||
const lensH = Math.min(this.H * 0.38, 140);
|
||
const converging = f > 0;
|
||
ctx.strokeStyle = 'rgba(155,93,229,0.8)';
|
||
ctx.lineWidth = 2.5;
|
||
if (converging) {
|
||
const bulge = Math.min(18, Math.abs(f) * 0.12);
|
||
ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.quadraticCurveTo(lx + bulge, ay, lx, ay + lensH); ctx.stroke();
|
||
ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.quadraticCurveTo(lx - bulge, ay, lx, ay + lensH); ctx.stroke();
|
||
this._lensArrow(ctx, lx, ay - lensH, -1);
|
||
this._lensArrow(ctx, lx, ay + lensH, 1);
|
||
} else {
|
||
const bulge = Math.min(14, Math.abs(f) * 0.1);
|
||
ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.quadraticCurveTo(lx - bulge, ay, lx, ay + lensH); ctx.stroke();
|
||
ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.quadraticCurveTo(lx + bulge, ay, lx, ay + lensH); ctx.stroke();
|
||
this._lensArrowDiv(ctx, lx, ay - lensH, -1);
|
||
this._lensArrowDiv(ctx, lx, ay + lensH, 1);
|
||
}
|
||
ctx.strokeStyle = 'rgba(155,93,229,0.3)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.lineTo(lx, ay + lensH); ctx.stroke();
|
||
}
|
||
|
||
_lensArrow(ctx, x, y, dir) {
|
||
const sz = 7;
|
||
ctx.fillStyle = 'rgba(155,93,229,0.8)';
|
||
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x - sz, y + dir * sz * 1.2); ctx.lineTo(x + sz, y + dir * sz * 1.2); ctx.closePath(); ctx.fill();
|
||
}
|
||
|
||
_lensArrowDiv(ctx, x, y, dir) {
|
||
const sz = 6;
|
||
ctx.fillStyle = 'rgba(155,93,229,0.8)';
|
||
ctx.beginPath(); ctx.moveTo(x - sz, y); ctx.lineTo(x, y - dir * sz); ctx.lineTo(x + sz, y); ctx.closePath(); ctx.fill();
|
||
}
|
||
|
||
_drawFocalPoints(ctx, lx, ay, f) {
|
||
const pts = [{ sx: f, label: "F'" }, { sx: -f, label: 'F' }, { sx: 2 * f, label: "2F'" }, { sx: -2 * f, label: '2F' }];
|
||
for (const p of pts) {
|
||
const px = lx + p.sx;
|
||
if (px < 10 || px > this.W - 10) continue;
|
||
const isFocal = !p.label.startsWith('2');
|
||
const r = isFocal ? 5 : 3.5;
|
||
const col = isFocal ? '#06D6E0' : 'rgba(6,214,224,0.5)';
|
||
ctx.fillStyle = col;
|
||
ctx.beginPath(); ctx.arc(px, ay, r, 0, Math.PI * 2); ctx.fill();
|
||
ctx.font = '11px Manrope, system-ui, sans-serif';
|
||
ctx.fillStyle = col; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
||
ctx.fillText(p.label, px, ay + 10);
|
||
}
|
||
}
|
||
|
||
_drawArrow(ctx, x1, y1, x2, y2, color, dashed) {
|
||
ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 2.5;
|
||
if (dashed) ctx.setLineDash([6, 4]);
|
||
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
|
||
if (dashed) ctx.setLineDash([]);
|
||
const angle = Math.atan2(y2 - y1, x2 - x1), aLen = 10;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x2, y2);
|
||
ctx.lineTo(x2 - aLen * Math.cos(angle - 0.35), y2 - aLen * Math.sin(angle - 0.35));
|
||
ctx.lineTo(x2 - aLen * Math.cos(angle + 0.35), y2 - aLen * Math.sin(angle + 0.35));
|
||
ctx.closePath(); ctx.fill();
|
||
}
|
||
|
||
_drawRays(ctx, lx, ay, d, h, f, dPrime, hPrime) {
|
||
// Wavelength-aware coloring
|
||
const _wl = window._obWhiteLight;
|
||
const _wlNm = window._obWavelength || 550;
|
||
if (_wl) {
|
||
// White-light mode: draw 3 spectral bundles (R/G/B) with offset for chromatic spread
|
||
const bundles = [{ nm: 660, dOff: 0 }, { nm: 550, dOff: 0 }, { nm: 450, dOff: 0 }];
|
||
const n0 = 1.5; // default glass
|
||
bundles.forEach(b => {
|
||
const nc = _nAtWavelength(n0, b.nm);
|
||
// focal length shifts with n — thin lens: f ~ (n-1)*C → f(λ) = f * (n0-1)/(nc-1)
|
||
const fC = f * (n0 - 1) / (nc - 1);
|
||
const denomC = d - fC;
|
||
const dPC = Math.abs(denomC) < 0.5 ? null : (fC * d) / denomC;
|
||
const hPC = dPC !== null ? (-dPC / d) * h : null;
|
||
const col = wavelengthToRGB(b.nm);
|
||
ctx.save(); ctx.globalAlpha = 0.65;
|
||
this._drawRaysSingle(ctx, lx, ay, d, h, fC, dPC, hPC, col);
|
||
ctx.restore();
|
||
});
|
||
return;
|
||
}
|
||
const _monoColor = wavelengthToRGB(_wlNm);
|
||
const objX = lx - d, objY = ay - h;
|
||
const colors = [_monoColor, _monoColor, _monoColor];
|
||
const hasImage = dPrime !== null && isFinite(dPrime);
|
||
const isVirtual = hasImage && dPrime < 0;
|
||
ctx.lineWidth = 1.5;
|
||
const _doGlow = (color, fn) => {
|
||
if (window.LabFX) LabFX.glow.drawGlow(ctx, fn, { color, intensity: 8 });
|
||
else fn();
|
||
};
|
||
|
||
// Ray 1: parallel to axis
|
||
_doGlow(colors[0], () => {
|
||
ctx.strokeStyle = colors[0]; ctx.setLineDash([]);
|
||
ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, objY); ctx.stroke();
|
||
if (hasImage) {
|
||
const imgX = lx + dPrime, imgY = ay - hPrime;
|
||
if (!isVirtual) {
|
||
ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(imgX, imgY); ctx.stroke();
|
||
this._extendRay(ctx, lx, objY, imgX, imgY, colors[0]);
|
||
} else {
|
||
const outSlope = (objY - ay) / f;
|
||
ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(lx + 300, objY + outSlope * 300); ctx.stroke();
|
||
ctx.setLineDash([4, 4]);
|
||
ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(imgX, imgY); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Ray 2: through center
|
||
_doGlow(colors[1], () => {
|
||
ctx.strokeStyle = colors[1]; ctx.setLineDash([]);
|
||
const slope = (objY - ay) / (objX - lx);
|
||
const farX = lx + 350, farY = ay + slope * 350;
|
||
ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(farX, farY); ctx.stroke();
|
||
if (isVirtual) {
|
||
const backX = lx - 350, backY = ay - slope * 350;
|
||
ctx.setLineDash([4, 4]);
|
||
ctx.beginPath(); ctx.moveTo(lx, ay); ctx.lineTo(backX, backY); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
}
|
||
});
|
||
|
||
// Ray 3: through F
|
||
_doGlow(colors[2], () => {
|
||
ctx.strokeStyle = colors[2]; ctx.setLineDash([]);
|
||
const fx = lx - f, slope = (objY - ay) / (objX - fx);
|
||
const hitY = objY + slope * (lx - objX);
|
||
ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, hitY); ctx.stroke();
|
||
const endX = hasImage && !isVirtual ? Math.max(lx + dPrime + 60, lx + 300) : lx + 300;
|
||
ctx.beginPath(); ctx.moveTo(lx, hitY); ctx.lineTo(endX, hitY); ctx.stroke();
|
||
if (hasImage && isVirtual) {
|
||
ctx.setLineDash([4, 4]);
|
||
ctx.beginPath(); ctx.moveTo(lx, hitY); ctx.lineTo(lx + dPrime, ay - hPrime); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
}
|
||
});
|
||
}
|
||
|
||
/** Single-color version of _drawRays used internally for white-light chromatic bundles */
|
||
_drawRaysSingle(ctx, lx, ay, d, h, f, dPrime, hPrime, color) {
|
||
const objX = lx - d, objY = ay - h;
|
||
const hasImage = dPrime !== null && isFinite(dPrime);
|
||
const isVirtual = hasImage && dPrime < 0;
|
||
ctx.lineWidth = 1.5; ctx.strokeStyle = color; ctx.setLineDash([]);
|
||
// Ray 1: parallel to axis
|
||
ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, objY); ctx.stroke();
|
||
if (hasImage) {
|
||
const imgX = lx + dPrime, imgY = ay - hPrime;
|
||
if (!isVirtual) {
|
||
ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(imgX, imgY); ctx.stroke();
|
||
} else {
|
||
const outSlope = (objY - ay) / f;
|
||
ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(lx + 200, objY + outSlope * 200); ctx.stroke();
|
||
}
|
||
}
|
||
// Ray 2: through center
|
||
const slope2 = (objY - ay) / (objX - lx);
|
||
ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx + 250, ay + slope2 * 250); ctx.stroke();
|
||
// Ray 3: through F (brief version)
|
||
const fxOff = lx - f, slope3 = (objY - ay) / (objX - fxOff);
|
||
const hitY3 = objY + slope3 * (lx - objX);
|
||
ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, hitY3); ctx.stroke();
|
||
ctx.beginPath(); ctx.moveTo(lx, hitY3); ctx.lineTo(lx + 250, hitY3); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
}
|
||
|
||
_extendRay(ctx, x1, y1, x2, y2, color) {
|
||
const dx = x2 - x1, dy = y2 - y1, len = Math.hypot(dx, dy);
|
||
if (len < 1) return;
|
||
ctx.globalAlpha = 0.3; ctx.strokeStyle = color;
|
||
ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo(x2 + (dx / len) * 80, y2 + (dy / len) * 80); ctx.stroke();
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
|
||
/* ── Spherical aberration (Agent OB-A3) ──────────────────────
|
||
5 parallel rays at different heights; f_eff(h) = f - h²/(2f).
|
||
Marginal rays focus closer than paraxial for a converging lens.
|
||
─────────────────────────────────────────────────────────────── */
|
||
_drawSphericalAberration(ctx, lx, ay, d, h, f) {
|
||
if (Math.abs(f) < 1) return;
|
||
const lensH = Math.min(this.H * 0.36, 130);
|
||
const fracs = [-0.9, -0.5, 0, 0.5, 0.9];
|
||
const pal = ['#FF6B6B', '#FFD166', '#7BF5A4', '#06D6E0', '#B8A4FF'];
|
||
const objX = lx - d;
|
||
ctx.save();
|
||
ctx.lineWidth = 1.4;
|
||
const focusPts = [];
|
||
fracs.forEach((fr, i) => {
|
||
const rayH = fr * lensH;
|
||
const fEff = f > 0 ? f - (rayH * rayH) / (2 * f) : f + (rayH * rayH) / (2 * Math.abs(f));
|
||
const denom = d - fEff;
|
||
if (Math.abs(denom) < 0.5) return;
|
||
const dPrEff = (fEff * d) / denom;
|
||
ctx.strokeStyle = pal[i];
|
||
ctx.globalAlpha = 0.8;
|
||
ctx.setLineDash([]);
|
||
const startY = ay - rayH;
|
||
ctx.beginPath(); ctx.moveTo(objX, startY); ctx.lineTo(lx, startY); ctx.stroke();
|
||
if (f > 0 && isFinite(dPrEff) && dPrEff > 0) {
|
||
const focX = lx + dPrEff;
|
||
ctx.beginPath(); ctx.moveTo(lx, startY); ctx.lineTo(focX, ay);
|
||
ctx.lineTo(focX + 70, ay + (startY - ay) * 0.5); ctx.stroke();
|
||
focusPts.push({ fEff, label: i === 2 ? 'парак.' : 'краев.' });
|
||
} else {
|
||
ctx.beginPath(); ctx.moveTo(lx, startY);
|
||
ctx.lineTo(lx + 280, startY + (ay - startY) * 0.6); ctx.stroke();
|
||
}
|
||
});
|
||
ctx.globalAlpha = 1;
|
||
ctx.restore();
|
||
if (focusPts.length >= 2) {
|
||
const fMin = Math.min(...focusPts.map(p => p.fEff)).toFixed(0);
|
||
const fMax = Math.max(...focusPts.map(p => p.fEff)).toFixed(0);
|
||
ctx.save();
|
||
const bx = 12, by = 70;
|
||
ctx.fillStyle = 'rgba(22,22,38,0.88)';
|
||
ctx.beginPath(); ctx.roundRect(bx, by, 224, 44, 8); ctx.fill();
|
||
ctx.font = '11px Manrope, system-ui, sans-serif';
|
||
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
|
||
ctx.fillStyle = '#FF6B6B'; ctx.fillText('Сферическая аберрация', bx + 8, by + 6);
|
||
ctx.fillStyle = 'rgba(255,255,255,0.55)';
|
||
ctx.fillText('f парак.=' + fMax + ' f краев.=' + fMin, bx + 8, by + 24);
|
||
ctx.restore();
|
||
}
|
||
}
|
||
|
||
/* ── Chromatic aberration (Agent OB-A3) ──────────────────────
|
||
f_R = f×1.02, f_G = f, f_B = f×0.98
|
||
Three ray bundles drawn in R/G/B colours converging to different points.
|
||
─────────────────────────────────────────────────────────────── */
|
||
_drawChromaticAberration(ctx, lx, ay, d, h, f) {
|
||
if (Math.abs(f) < 1) return;
|
||
const channels = [
|
||
{ scale: 1.02, color: '#FF4444', label: 'R' },
|
||
{ scale: 1.00, color: '#7BF5A4', label: 'G' },
|
||
{ scale: 0.98, color: '#4488FF', label: 'B' },
|
||
];
|
||
const objX = lx - d;
|
||
ctx.save();
|
||
ctx.lineWidth = 1.6;
|
||
const focusPts = [];
|
||
channels.forEach(ch => {
|
||
const fc = f * ch.scale;
|
||
const denom = d - fc;
|
||
if (Math.abs(denom) < 0.5) return;
|
||
const dPrEff = (fc * d) / denom;
|
||
const hPrEff = (-dPrEff / d) * h;
|
||
const hasImg = isFinite(dPrEff) && dPrEff > 0;
|
||
ctx.strokeStyle = ch.color;
|
||
ctx.globalAlpha = 0.78;
|
||
ctx.setLineDash([]);
|
||
// Ray 1: parallel to axis
|
||
ctx.beginPath(); ctx.moveTo(objX, ay - h); ctx.lineTo(lx, ay - h);
|
||
if (hasImg) { ctx.lineTo(lx + dPrEff, ay - hPrEff); }
|
||
ctx.stroke();
|
||
// Ray 2: through centre
|
||
const slopeC = h / d;
|
||
ctx.beginPath(); ctx.moveTo(objX, ay - h); ctx.lineTo(lx, ay);
|
||
if (hasImg) { ctx.lineTo(lx + dPrEff, ay - hPrEff); }
|
||
else { ctx.lineTo(lx + 280, ay - slopeC * 280); }
|
||
ctx.stroke();
|
||
if (hasImg) focusPts.push({ x: lx + dPrEff, label: ch.label, color: ch.color, f: fc });
|
||
});
|
||
ctx.globalAlpha = 1;
|
||
ctx.setLineDash([]);
|
||
// focal markers
|
||
focusPts.forEach(fp => {
|
||
ctx.fillStyle = fp.color;
|
||
ctx.beginPath(); ctx.arc(fp.x, ay, 5, 0, Math.PI * 2); ctx.fill();
|
||
ctx.font = '10px Manrope, system-ui, sans-serif';
|
||
ctx.fillStyle = fp.color; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
|
||
ctx.fillText(fp.label + ' f=' + fp.f.toFixed(0), fp.x, ay - 10);
|
||
});
|
||
ctx.restore();
|
||
// info box
|
||
ctx.save();
|
||
const bx = 12, by = 70;
|
||
ctx.fillStyle = 'rgba(22,22,38,0.88)';
|
||
ctx.beginPath(); ctx.roundRect(bx, by, 224, 56, 8); ctx.fill();
|
||
ctx.font = '11px Manrope, system-ui, sans-serif';
|
||
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
|
||
ctx.fillStyle = '#4488FF'; ctx.fillText('Хроматическая аберрация', bx + 8, by + 6);
|
||
if (focusPts.length >= 2) {
|
||
const spread = Math.abs(focusPts[focusPts.length - 1].x - focusPts[0].x).toFixed(0);
|
||
ctx.fillStyle = 'rgba(255,255,255,0.55)';
|
||
ctx.fillText('Разброс фокусов: ' + spread + ' px', bx + 8, by + 22);
|
||
ctx.fillText('fR > fG > fB (красный фокус дальше)', bx + 8, by + 38);
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
_drawLabels(ctx, lx, ay, d, f, dPrime, hPrime) {
|
||
ctx.font = '12px Manrope, system-ui, sans-serif'; ctx.textBaseline = 'top';
|
||
const objX = lx - d;
|
||
ctx.fillStyle = '#9B5DE5'; ctx.textAlign = 'center';
|
||
ctx.fillText('d = ' + d.toFixed(0), (objX + lx) / 2, ay + 26);
|
||
ctx.fillStyle = '#06D6E0';
|
||
ctx.fillText('f = ' + f.toFixed(0), lx, ay + 42);
|
||
if (dPrime !== null && isFinite(dPrime)) {
|
||
const imgX = lx + dPrime;
|
||
ctx.fillStyle = dPrime > 0 ? '#EF476F' : '#FFD166'; ctx.textAlign = 'center';
|
||
ctx.fillText("d' = " + dPrime.toFixed(1), (lx + imgX) / 2, ay + 26);
|
||
}
|
||
const info = this.info();
|
||
const boxW = 200, boxH = 52, bx = 12, by = 12;
|
||
ctx.fillStyle = 'rgba(22,22,38,0.85)';
|
||
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill();
|
||
ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.7)';
|
||
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
|
||
ctx.fillText("1/f = 1/d + 1/d'", bx + 10, by + 10);
|
||
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
||
const mStr = info.M === Infinity ? '---' : info.M.toFixed(2);
|
||
const dpStr = info.dPrime === Infinity ? '---' : info.dPrime.toFixed(1);
|
||
ctx.fillText('M = ' + mStr + " d' = " + dpStr + ' ' + info.imageType, bx + 10, by + 30);
|
||
}
|
||
|
||
/* === _drawRaysAnimated: principal rays with per-ray progress === */
|
||
_drawRaysAnimated(ctx, lx, ay, d, h, f, dPrime, hPrime) {
|
||
const T = this._rayAnimT;
|
||
if (T[0] >= 1 && T[1] >= 1 && T[2] >= 1) { this._drawRays(ctx, lx, ay, d, h, f, dPrime, hPrime); return; }
|
||
const objX = lx - d, objY = ay - h;
|
||
const hasImage = dPrime !== null && isFinite(dPrime);
|
||
const isVirtual = hasImage && dPrime < 0;
|
||
// Wavelength-aware colors for animation
|
||
const _wlNm = window._obWavelength || 550;
|
||
const _wl = window._obWhiteLight;
|
||
const _mc = wavelengthToRGB(_wlNm);
|
||
const COLORS = _wl ? [wavelengthToRGB(660), wavelengthToRGB(550), wavelengthToRGB(450)] : [_mc, _mc, _mc];
|
||
ctx.lineWidth = 1.8;
|
||
const lerp = (a, b, t) => a + (b - a) * Math.min(1, Math.max(0, t));
|
||
const drawPts = (color, pts, t) => {
|
||
if (t <= 0 || pts.length < 2) return;
|
||
const totalLen = pts.reduce((s, p, i) => i === 0 ? 0 : s + Math.hypot(p[0]-pts[i-1][0], p[1]-pts[i-1][1]), 0);
|
||
const target = totalLen * t;
|
||
const draw = () => {
|
||
ctx.strokeStyle = color; ctx.setLineDash([]);
|
||
ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]);
|
||
let drawn = 0;
|
||
for (let i = 1; i < pts.length; i++) {
|
||
const segLen = Math.hypot(pts[i][0]-pts[i-1][0], pts[i][1]-pts[i-1][1]);
|
||
if (drawn + segLen <= target) { ctx.lineTo(pts[i][0], pts[i][1]); drawn += segLen; }
|
||
else { const fr = segLen > 0 ? (target - drawn) / segLen : 0; ctx.lineTo(lerp(pts[i-1][0], pts[i][0], fr), lerp(pts[i-1][1], pts[i][1], fr)); break; }
|
||
}
|
||
ctx.stroke();
|
||
};
|
||
if (window.LabFX) LabFX.glow.drawGlow(ctx, draw, { color, intensity: 10 });
|
||
else draw();
|
||
};
|
||
const FAR = lx + 360;
|
||
const imgX = hasImage ? lx + dPrime : null, imgY = hasImage ? ay - hPrime : null;
|
||
// Ray 1: parallel to axis -> through F'
|
||
if (T[0] > 0) {
|
||
let pts;
|
||
if (!hasImage) { pts = [[objX, objY], [lx, objY], [FAR, objY]]; }
|
||
else if (!isVirtual) { pts = [[objX, objY], [lx, objY], [imgX, imgY]]; }
|
||
else { const s = (objY - ay) / f; pts = [[objX, objY], [lx, objY], [FAR, objY + s*(FAR-lx)]]; }
|
||
drawPts(COLORS[0], pts, T[0]);
|
||
}
|
||
// Ray 2: through optical center (straight)
|
||
if (T[1] > 0) {
|
||
const s = (objY - ay) / (objX - lx);
|
||
drawPts(COLORS[1], [[objX, objY], [FAR, ay + s*(FAR-lx)]], T[1]);
|
||
}
|
||
// Ray 3: through front focus F -> parallel after lens
|
||
if (T[2] > 0) {
|
||
const fx = lx - f, s = (objY - ay) / (objX - fx);
|
||
const hitY = objY + s * (lx - objX);
|
||
const endX = hasImage && !isVirtual ? Math.max(imgX + 60, FAR) : FAR;
|
||
drawPts(COLORS[2], [[objX, objY], [lx, hitY], [endX, hitY]], T[2]);
|
||
}
|
||
}
|
||
|
||
/* === Arrow labels: h_o, h_i, magnification Gamma === */
|
||
_drawArrowLabels(ctx, lx, ay, d, h, dPrime, hPrime) {
|
||
const objX = lx - d;
|
||
ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.textBaseline = 'middle';
|
||
ctx.fillStyle = 'rgba(155,93,229,0.85)'; ctx.textAlign = 'right';
|
||
ctx.fillText('ho=' + h.toFixed(0), objX - 6, ay - h / 2);
|
||
if (dPrime !== null && isFinite(dPrime)) {
|
||
const imgX = lx + dPrime, isVirtual = dPrime < 0;
|
||
const M = -dPrime / d;
|
||
const Gstr = isFinite(M) ? (M >= 0 ? '+' : '') + M.toFixed(2) : '---';
|
||
const imgColor = isVirtual ? 'rgba(255,133,162,0.85)' : 'rgba(6,214,224,0.85)';
|
||
ctx.fillStyle = imgColor; ctx.textAlign = 'left';
|
||
ctx.fillText("hi=" + Math.abs(hPrime).toFixed(0), imgX + 6, ay - hPrime / 2);
|
||
ctx.fillStyle = '#FFD166'; ctx.textAlign = 'center';
|
||
ctx.fillText('G=' + Gstr, (lx + imgX) / 2, ay + 60);
|
||
}
|
||
}
|
||
|
||
_bindEvents() {
|
||
const cv = this.canvas;
|
||
const getPos = (e) => {
|
||
const r = cv.getBoundingClientRect();
|
||
const t = e.touches ? e.touches[0] : e;
|
||
return { mx: (t.clientX - r.left) * (this.W / r.width), my: (t.clientY - r.top) * (this.H / r.height) };
|
||
};
|
||
const hitTest = (mx, my) => {
|
||
const lx = this.W / 2, ay = this.H / 2;
|
||
if (Math.hypot(mx - (lx - this.d), my - (ay - this.h)) < 20) return 'object';
|
||
if (Math.hypot(mx - (lx - this.f), my - ay) < 16) return 'focus';
|
||
return null;
|
||
};
|
||
const onDown = (e) => { const { mx, my } = getPos(e); this._drag = hitTest(mx, my); };
|
||
const onMove = (e) => {
|
||
if (!this._drag) return;
|
||
if (e.cancelable) e.preventDefault();
|
||
const { mx } = getPos(e), lx = this.W / 2;
|
||
if (this._drag === 'object') this.d = Math.max(30, Math.min(400, lx - mx));
|
||
else if (this._drag === 'focus') this.f = Math.max(-200, Math.min(200, lx - mx));
|
||
this.draw(); this._emit();
|
||
};
|
||
const onUp = () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = null; };
|
||
cv.addEventListener('mousedown', onDown);
|
||
window.addEventListener('mousemove', onMove);
|
||
window.addEventListener('mouseup', onUp);
|
||
cv.addEventListener('touchstart', e => { if (e.touches.length === 1) onDown(e); }, { passive: true });
|
||
cv.addEventListener('touchmove', e => onMove(e), { passive: false });
|
||
cv.addEventListener('touchend', onUp);
|
||
cv.addEventListener('mousemove', e => {
|
||
if (this._drag) { cv.style.cursor = 'grabbing'; return; }
|
||
const { mx, my } = getPos(e);
|
||
cv.style.cursor = hitTest(mx, my) ? 'grab' : 'default';
|
||
});
|
||
}
|
||
}
|
||
|
||
/* ─────────────────────────────────────────────────────────────
|
||
2. MIRROR ENGINE (from mirror.js)
|
||
───────────────────────────────────────────────────────────────*/
|
||
class MirrorSim {
|
||
constructor(canvas) {
|
||
this.canvas = canvas;
|
||
this.ctx = canvas.getContext('2d');
|
||
this.W = 0; this.H = 0;
|
||
|
||
this.type = 'concave';
|
||
this.f = 120;
|
||
this.d = 240;
|
||
this.h = 60;
|
||
|
||
this._playing = false;
|
||
this._animT = 1.4;
|
||
this._animSpeed = 1;
|
||
this._raf = null;
|
||
|
||
this._step = -1;
|
||
|
||
this._showGrid = false;
|
||
this._showZones = true;
|
||
this._showNormals = true;
|
||
this._showDims = true;
|
||
this._showAngles = true;
|
||
this._showPhotons = true;
|
||
this._pointMode = false;
|
||
this._showSpherical = false; /* spherical aberration toggle (Agent OB-A3) */
|
||
|
||
this._photons = [];
|
||
this._photonRaf = null;
|
||
this._photonTimer = 0;
|
||
this._lastPhoTime = 0;
|
||
this._photonPaths = [];
|
||
|
||
this._prevType = 'concave';
|
||
this._transT = 1.0;
|
||
this._transRaf = null;
|
||
|
||
this._drag = null;
|
||
this._hoverX = -999;
|
||
this._hoverY = -999;
|
||
|
||
this.onUpdate = null;
|
||
this.onAnimate = null;
|
||
|
||
/* Feature 2: R slider + spherical aberration toggle */
|
||
this._R = 240; // radius of curvature (positive=concave, negative=convex)
|
||
this._useR = false; // true = R-slider mode; false = classic type+f mode
|
||
this._parabolic = false; // false = spherical mirror; true = perfect parabolic
|
||
|
||
this._bindEvents();
|
||
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
|
||
}
|
||
|
||
fit() {
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const w = this.canvas.offsetWidth || 600;
|
||
const h = this.canvas.offsetHeight || 400;
|
||
this.canvas.width = w * dpr;
|
||
this.canvas.height = h * dpr;
|
||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
this.W = w; this.H = h;
|
||
}
|
||
|
||
setType(type) {
|
||
if (type === this.type) return;
|
||
this._prevType = this.type;
|
||
this.type = type;
|
||
if (this._playing) this._stopAnim();
|
||
this._startTransition();
|
||
this.draw(); this._emit();
|
||
}
|
||
|
||
getParams() { return { f: this.f, d: this.d, h: this.h }; }
|
||
setParams({ f, d, h } = {}) {
|
||
if (f !== undefined) this.f = Math.max(30, Math.min(300, +f));
|
||
if (d !== undefined) this.d = Math.max(30, Math.min(490, +d));
|
||
if (h !== undefined) this.h = Math.max(20, Math.min(80, +h));
|
||
this.draw(); this._emit();
|
||
}
|
||
|
||
setAnimSpeed(s) { this._animSpeed = +s || 1; }
|
||
togglePlay() { this._playing ? this._stopAnim() : this._startAnim(); }
|
||
stepNext() { this._step = Math.min(3, this._step + 1); this.draw(); }
|
||
stepReset() { this._step = -1; this.draw(); }
|
||
setPointMode(on) { this._pointMode = !!on; this.draw(); this._emit(); }
|
||
|
||
setToggle(name, val) {
|
||
const map = { grid:'_showGrid', zones:'_showZones', normals:'_showNormals', dims:'_showDims', angles:'_showAngles', photons:'_showPhotons', spherical:'_showSpherical' };
|
||
if (map[name]) this[map[name]] = !!val;
|
||
if (name === 'photons') { val ? this._startPhotons() : this._stopPhotons(); }
|
||
this.draw();
|
||
}
|
||
|
||
exportPng() {
|
||
const a = document.createElement('a');
|
||
a.href = this.canvas.toDataURL('image/png');
|
||
a.download = 'mirror_' + this.type + '_d' + Math.round(this.d) + '.png';
|
||
a.click();
|
||
}
|
||
|
||
_fSigned() {
|
||
if (this.type === 'flat') return Infinity;
|
||
return this.type === 'convex' ? -this.f : this.f;
|
||
}
|
||
|
||
info() {
|
||
const { type, d, h } = this;
|
||
const f = this._fSigned();
|
||
let dPrime, M;
|
||
if (type === 'flat') { dPrime = -d; M = 1; }
|
||
else {
|
||
const den = d - f;
|
||
if (Math.abs(den) < 0.5) { dPrime = Infinity; M = Infinity; }
|
||
else { dPrime = f * d / den; M = -dPrime / d; }
|
||
}
|
||
const hPrime = M === Infinity ? Infinity : M * h;
|
||
const isReal = dPrime > 0 && dPrime !== Infinity;
|
||
const imageType = dPrime === Infinity ? '∞' : isReal ? 'действительное' : 'мнимое';
|
||
const orient = (M === Infinity || M === 1) ? 'прямое' : M < 0 ? 'перевёрнутое' : 'прямое';
|
||
const sizeStr = M === Infinity ? '' : Math.abs(M) > 1.05 ? 'увеличенное' : Math.abs(M) < 0.95 ? 'уменьшенное' : 'равное';
|
||
return {
|
||
f: type === 'flat' ? '∞' : (type === 'convex' ? -this.f : +this.f).toFixed(0),
|
||
d: +d.toFixed(1),
|
||
dPrime: dPrime === Infinity ? Infinity : +dPrime.toFixed(1),
|
||
M: M === Infinity ? Infinity : +M.toFixed(3),
|
||
imageType, orient, sizeStr,
|
||
hPrime: hPrime === Infinity ? Infinity : +Math.abs(hPrime).toFixed(1),
|
||
isReal,
|
||
};
|
||
}
|
||
|
||
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
|
||
|
||
_getBulge(type) {
|
||
if (type === 'flat') return 0;
|
||
if (type === 'concave') return -Math.min(30, this.f * 0.18);
|
||
return Math.min(24, this.f * 0.16);
|
||
}
|
||
|
||
_startTransition() {
|
||
this._transT = 0;
|
||
if (this._transRaf) cancelAnimationFrame(this._transRaf);
|
||
const step = () => {
|
||
this._transT = Math.min(1, this._transT + 0.07);
|
||
this.draw();
|
||
if (this._transT < 1) this._transRaf = requestAnimationFrame(step);
|
||
else this._transRaf = null;
|
||
};
|
||
this._transRaf = requestAnimationFrame(step);
|
||
}
|
||
|
||
_startAnim() { this._playing = true; this._animLoop(); }
|
||
_stopAnim() {
|
||
this._playing = false;
|
||
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
|
||
}
|
||
|
||
_animLoop() {
|
||
if (!this._playing) return;
|
||
this._animT += 0.013 * this._animSpeed;
|
||
const t = 0.5 - 0.5 * Math.cos(this._animT);
|
||
if (this.type === 'concave') this.d = Math.max(30, Math.min(490, this.f * (0.35 + 2.75 * t)));
|
||
else this.d = 40 + 400 * t;
|
||
if (this.onAnimate) this.onAnimate(this.d);
|
||
this.draw(); this._emit();
|
||
this._raf = requestAnimationFrame(() => this._animLoop());
|
||
}
|
||
|
||
_getRayPaths(mx, ay, f, dPrime, hPrime) {
|
||
const { d, h, type } = this;
|
||
const hasImage = dPrime !== null && isFinite(dPrime);
|
||
const isReal = hasImage && dPrime > 0;
|
||
const imgX = hasImage ? mx - dPrime : null;
|
||
const imgY = hasImage ? ay - (this._pointMode ? 0 : hPrime) : null;
|
||
const objX = mx - d;
|
||
const objY = ay - (this._pointMode ? 0 : h);
|
||
const COLORS = ['#06D6E0', '#7BF5A4', '#FFD166'];
|
||
if (type === 'flat') {
|
||
return [objY, ay, ay - h * 0.5].map((hy, i) => ({
|
||
pts: [[objX, objY], [mx, hy], ...(hasImage ? [[imgX, imgY]] : [])],
|
||
color: COLORS[i],
|
||
}));
|
||
}
|
||
const hit1Y = ay - (this._pointMode ? 0 : h);
|
||
const hit2Y = ay;
|
||
const denom3 = d - f;
|
||
const hit3Y = Math.abs(denom3) < 0.5 ? null : ay + (this._pointMode ? 0 : h) * f / denom3;
|
||
const rays = [];
|
||
const add = (hitY, color) => {
|
||
if (hitY === null || !isFinite(hitY) || hitY < -this.H || hitY > 2 * this.H) return;
|
||
const pts = [[objX, objY], [mx, hitY]];
|
||
if (hasImage) {
|
||
if (isReal) {
|
||
pts.push([imgX, imgY]);
|
||
const dx = imgX - mx, dy = imgY - hitY, l = Math.hypot(dx, dy);
|
||
if (l > 1) pts.push([imgX + dx / l * 60, imgY + dy / l * 60]);
|
||
} else {
|
||
const dx = imgX - mx, dy = imgY - hitY;
|
||
if (Math.abs(dx) > 1) {
|
||
const tL = (mx - 5) / dx;
|
||
let endX = 5, endY = hitY - dy * tL;
|
||
if (endY < 5 || endY > this.H - 5) {
|
||
endY = endY < 5 ? 5 : this.H - 5;
|
||
const tE = (hitY - endY) / dy;
|
||
endX = Math.max(5, mx - dx * tE);
|
||
}
|
||
pts.push([endX, endY]);
|
||
}
|
||
}
|
||
}
|
||
rays.push({ pts, color });
|
||
};
|
||
add(hit1Y, COLORS[0]); add(hit2Y, COLORS[1]); add(hit3Y, COLORS[2]);
|
||
return rays;
|
||
}
|
||
|
||
_startPhotons() {
|
||
if (this._photonRaf) return;
|
||
this._lastPhoTime = performance.now();
|
||
this._photonLoop();
|
||
}
|
||
|
||
_stopPhotons() {
|
||
if (this._photonRaf) { cancelAnimationFrame(this._photonRaf); this._photonRaf = null; }
|
||
this._photons = [];
|
||
this.draw();
|
||
}
|
||
|
||
_photonLoop() {
|
||
const now = performance.now();
|
||
const dt = Math.min((now - this._lastPhoTime) / 1000, 0.1);
|
||
this._lastPhoTime = now;
|
||
const spd = 200;
|
||
for (const p of this._photons) p.t = Math.min(1, p.t + dt * spd / p.len);
|
||
this._photons = this._photons.filter(p => p.t < 1);
|
||
this._photonTimer += dt;
|
||
if (this._photonTimer > 0.75 && this._photonPaths.length) {
|
||
this._photonTimer = 0;
|
||
for (const path of this._photonPaths) {
|
||
if (path.pts.length < 2) continue;
|
||
let len = 0;
|
||
for (let i = 1; i < path.pts.length; i++)
|
||
len += Math.hypot(path.pts[i][0] - path.pts[i-1][0], path.pts[i][1] - path.pts[i-1][1]);
|
||
if (len > 20) this._photons.push({ pts: path.pts, color: path.color, t: 0, len });
|
||
}
|
||
}
|
||
if (!this._playing) this.draw();
|
||
this._photonRaf = requestAnimationFrame(() => this._photonLoop());
|
||
}
|
||
|
||
draw() {
|
||
const { ctx, W, H } = this;
|
||
if (!W || !H) return;
|
||
const f = this._fSigned();
|
||
const mx = Math.round(W * 0.62);
|
||
const ay = H / 2;
|
||
let dPrime = null, hPrime = null;
|
||
if (this.type === 'flat') { dPrime = -this.d; hPrime = this._pointMode ? 0 : this.h; }
|
||
else {
|
||
const den = this.d - f;
|
||
if (Math.abs(den) >= 0.5) {
|
||
dPrime = f * this.d / den;
|
||
hPrime = this._pointMode ? 0 : (-dPrime / this.d) * this.h;
|
||
}
|
||
}
|
||
const step = this._step;
|
||
const showRay = i => step === -1 || i <= step;
|
||
const showFill = step === -1 || step >= 3;
|
||
this._photonPaths = this._getRayPaths(mx, ay, f, dPrime, hPrime);
|
||
ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H);
|
||
if (this._showGrid) this._drawGrid(ctx);
|
||
if (this._showZones) this._drawZones(ctx, mx);
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1; ctx.setLineDash([6, 4]);
|
||
ctx.beginPath(); ctx.moveTo(0, ay); ctx.lineTo(W, ay); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
this._drawFanRays(ctx, mx, ay, f, dPrime, hPrime, showRay, showFill);
|
||
/* spherical aberration overlay (Agent OB-A3) */
|
||
if (this._showSpherical && this.type !== 'flat' && isFinite(f))
|
||
this._drawMirrorSphericalAberration(ctx, mx, ay, f);
|
||
/* Feature 2: parabolic/spherical aberration fan */
|
||
if (this._useR && this.type !== 'flat' && isFinite(f))
|
||
this._drawAberrationFan(ctx, mx, ay, f);
|
||
this._drawMirror(ctx, mx, ay);
|
||
/* Feature 2: R and f labels on mirror */
|
||
if (this._useR && this.type !== 'flat' && isFinite(f)) {
|
||
ctx.save();
|
||
ctx.font = 'bold 11px Manrope, system-ui, sans-serif';
|
||
ctx.fillStyle = 'rgba(6,214,224,0.9)';
|
||
ctx.textAlign = 'right'; ctx.textBaseline = 'bottom';
|
||
ctx.fillText('R=' + this._R.toFixed(0) + ' f=' + f.toFixed(0), mx - 4, ay - 6);
|
||
ctx.restore();
|
||
}
|
||
if (this.type !== 'flat') {
|
||
this._drawFocalPoints(ctx, mx, ay, f);
|
||
this._drawCenterC(ctx, mx, ay, f);
|
||
}
|
||
if (this._showNormals && this.type !== 'flat' && (step === -1 || step >= 3))
|
||
this._drawNormals(ctx, mx, ay, f);
|
||
if (this._showAngles && this.type !== 'flat' && step === -1)
|
||
this._drawAngleArcs(ctx, mx, ay, f);
|
||
if (step === -1 || step >= 1) this._drawRayLabels(ctx, mx, ay, f, step);
|
||
const objX = mx - this.d;
|
||
if (this._pointMode) {
|
||
ctx.save(); ctx.shadowColor = '#9B5DE5'; ctx.shadowBlur = 10;
|
||
ctx.fillStyle = '#9B5DE5'; ctx.beginPath(); ctx.arc(objX, ay, 5, 0, Math.PI*2); ctx.fill(); ctx.restore();
|
||
} else { this._drawArrow(ctx, objX, ay, objX, ay - this.h, '#9B5DE5', false); }
|
||
if (dPrime !== null && isFinite(dPrime)) {
|
||
const imgX = mx - dPrime, imgY = ay - (this._pointMode ? 0 : hPrime);
|
||
if (this._pointMode) {
|
||
ctx.save(); ctx.fillStyle = dPrime > 0 ? '#EF476F' : '#FFD166';
|
||
if (dPrime < 0) { ctx.globalAlpha = 0.55; ctx.setLineDash([4,3]); }
|
||
ctx.beginPath(); ctx.arc(imgX, ay, 5, 0, Math.PI*2);
|
||
dPrime > 0 ? ctx.fill() : (() => { ctx.stroke(); })();
|
||
ctx.restore();
|
||
} else {
|
||
this._drawArrow(ctx, imgX, ay, imgX, imgY, dPrime > 0 ? '#EF476F' : '#FFD166', dPrime <= 0);
|
||
}
|
||
}
|
||
if (this._showDims && (step === -1 || step >= 3))
|
||
this._drawDimensions(ctx, mx, ay, f, dPrime, hPrime);
|
||
this._drawInfoBox(ctx, f, dPrime);
|
||
if ((step === -1 || step >= 3) && dPrime !== null) this._drawImageBadge(ctx, dPrime, hPrime);
|
||
this._drawCriticalMarker(ctx, f);
|
||
if (this._showDims) this._drawLegend(ctx);
|
||
if (this._showPhotons && this._photons.length) this._drawPhotons(ctx);
|
||
this._drawTooltip(ctx, mx, ay, f, dPrime, hPrime);
|
||
if (step >= 0) this._drawStepOverlay(ctx, step);
|
||
|
||
// Mirror caustics near focal point when real image exists (only when OB_FX.caustics is off)
|
||
if (!window.OB_FX.caustics && window.LabFX && dPrime !== null && isFinite(dPrime) && dPrime > 0) {
|
||
const focX = mx - dPrime, focY = ay - (this._pointMode ? 0 : hPrime);
|
||
if (!this._mCausticFrame) this._mCausticFrame = 0;
|
||
this._mCausticFrame++;
|
||
if (this._mCausticFrame % 4 === 0) {
|
||
LabFX.particles.emit({ ctx, x: focX + (Math.random()-0.5)*10, y: focY + (Math.random()-0.5)*10, count: 2, color: '#FFD166', speed: 6, spread: Math.PI*2, life: 500, shape: 'dust', glow: true });
|
||
}
|
||
}
|
||
// OB_FX visual depth layer
|
||
{
|
||
const objX = mx - this.d;
|
||
const fxExtras = {
|
||
srcX: objX, srcY: ay - this.h,
|
||
causticParams: f > 0 ? { lx: mx, ay, f } : null,
|
||
rays: [
|
||
{ x1: objX, y1: ay - this.h, x2: mx, y2: ay },
|
||
],
|
||
boundary: { bx: mx, by: ay, normalAngle: 0, kind: 'reflection' },
|
||
};
|
||
if (!fxExtras.causticParams) delete fxExtras.causticParams;
|
||
_drawOBFXLayer(ctx, 'mirror', fxExtras);
|
||
}
|
||
if (window.LabFX) { LabFX.particles.update(1/60); LabFX.particles.draw(ctx); }
|
||
}
|
||
|
||
_drawGrid(ctx) {
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.03)'; ctx.lineWidth = 1; ctx.beginPath();
|
||
for (let x = 0; x < this.W; x += 40) { ctx.moveTo(x,0); ctx.lineTo(x,this.H); }
|
||
for (let y = 0; y < this.H; y += 40) { ctx.moveTo(0,y); ctx.lineTo(this.W,y); }
|
||
ctx.stroke();
|
||
}
|
||
|
||
_drawZones(ctx, mx) {
|
||
const g1 = ctx.createLinearGradient(0,0,mx,0);
|
||
g1.addColorStop(0, 'rgba(6,214,224,0.0)'); g1.addColorStop(1, 'rgba(6,214,224,0.03)');
|
||
ctx.fillStyle = g1; ctx.fillRect(0, 0, mx, this.H);
|
||
const g2 = ctx.createLinearGradient(mx,0,this.W,0);
|
||
g2.addColorStop(0, 'rgba(239,71,111,0.04)'); g2.addColorStop(1, 'rgba(239,71,111,0.0)');
|
||
ctx.fillStyle = g2; ctx.fillRect(mx, 0, this.W-mx, this.H);
|
||
}
|
||
|
||
_drawMirror(ctx, mx, ay) {
|
||
const mH = Math.min(this.H * 0.4, 150);
|
||
ctx.save();
|
||
const ease = t => t < 0.5 ? 2*t*t : -1+(4-2*t)*t;
|
||
const bulge = this._getBulge(this._prevType) + (this._getBulge(this.type) - this._getBulge(this._prevType)) * ease(this._transT);
|
||
ctx.strokeStyle = 'rgba(6,214,224,0.92)'; ctx.lineWidth = 3;
|
||
ctx.shadowColor = 'rgba(6,214,224,0.45)'; ctx.shadowBlur = 8;
|
||
ctx.beginPath(); ctx.moveTo(mx, ay - mH); ctx.quadraticCurveTo(mx + bulge, ay, mx, ay + mH); ctx.stroke();
|
||
ctx.shadowBlur = 0;
|
||
ctx.strokeStyle = 'rgba(6,214,224,0.15)'; ctx.lineWidth = 1.5;
|
||
for (let i = 0; i <= 10; i++) {
|
||
const y = ay - mH + i * mH * 2 / 10;
|
||
ctx.beginPath(); ctx.moveTo(mx, y); ctx.lineTo(mx+14, y+10); ctx.stroke();
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
_drawFocalPoints(ctx, mx, ay, f) {
|
||
const pts = [{ px: mx-f, lbl:'F', r:5 }, { px: mx-2*f, lbl:'2F', r:3.5 }];
|
||
ctx.font = '11px Manrope, system-ui, sans-serif';
|
||
for (const p of pts) {
|
||
if (p.px < 4 || p.px > this.W-4) continue;
|
||
const col = f < 0 ? 'rgba(255,209,102,0.7)' : '#06D6E0';
|
||
ctx.fillStyle = col; ctx.beginPath(); ctx.arc(p.px, ay, p.r, 0, Math.PI*2); ctx.fill();
|
||
ctx.fillStyle = col; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText(p.lbl, p.px, ay+9);
|
||
}
|
||
}
|
||
|
||
_drawCenterC(ctx, mx, ay, f) {
|
||
if (!isFinite(f)) return;
|
||
const cx = mx - 2*f;
|
||
if (cx < 4 || cx > this.W-4) return;
|
||
const pulse = Math.abs(this.d - 2*Math.abs(f)) < Math.abs(f)*0.06;
|
||
ctx.save();
|
||
if (pulse) { ctx.shadowColor='rgba(255,152,0,0.9)'; ctx.shadowBlur=14; }
|
||
ctx.fillStyle = pulse ? '#FF9800' : 'rgba(255,152,0,0.5)';
|
||
ctx.beginPath(); ctx.arc(cx, ay, pulse ? 5 : 3.5, 0, Math.PI*2); ctx.fill();
|
||
ctx.shadowBlur = 0;
|
||
ctx.font = '11px Manrope, system-ui, sans-serif';
|
||
ctx.fillStyle = pulse ? '#FF9800' : 'rgba(255,152,0,0.6)';
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText('C', cx, ay+9);
|
||
ctx.restore();
|
||
}
|
||
|
||
_drawFanRays(ctx, mx, ay, f, dPrime, hPrime, showRay, showFill) {
|
||
const { d, h, type } = this;
|
||
const hasImg = dPrime !== null && isFinite(dPrime);
|
||
const isReal = hasImg && dPrime > 0;
|
||
const imgX = hasImg ? mx - dPrime : null;
|
||
const imgY = hasImg ? ay - (this._pointMode ? 0 : hPrime) : null;
|
||
const objX = mx - d, objY = ay - (this._pointMode ? 0 : h);
|
||
// Wavelength-aware color
|
||
const _wl = window._obWhiteLight;
|
||
const _wlNm = window._obWavelength || 550;
|
||
const _mc = wavelengthToRGB(_wlNm);
|
||
const COLS = _wl ? [wavelengthToRGB(660), wavelengthToRGB(550), wavelengthToRGB(450)] : [_mc, _mc, _mc];
|
||
const FAN = _wl ? 'rgba(255,255,255,0.12)' : `rgba(255,255,255,0.18)`;
|
||
if (type === 'flat') {
|
||
const hits = [objY, ay, ay - (this._pointMode ? 0 : h)*0.5];
|
||
hits.forEach((hy, i) => {
|
||
if (!showRay(i)) return;
|
||
this._flatRay(ctx, mx, ay, d, h, objX, objY, hy, COLS[i], imgX, imgY, hasImg);
|
||
});
|
||
return;
|
||
}
|
||
const hit1 = ay - (this._pointMode ? 0 : h);
|
||
const hit2 = ay;
|
||
const den3 = d - f;
|
||
const hit3 = Math.abs(den3) < 0.5 ? null : ay + (this._pointMode ? 0 : h)*f/den3;
|
||
if (showFill) {
|
||
const fills = [(hit1+hit2)/2];
|
||
if (hit3 !== null && isFinite(hit3)) fills.push((hit2+hit3)/2);
|
||
for (const hy of fills) this._oneRay(ctx, mx, objX, objY, hy, FAN, 0.6, hasImg, isReal, imgX, imgY);
|
||
}
|
||
if (showRay(0)) this._oneRay(ctx, mx, objX, objY, hit1, COLS[0], 1.0, hasImg, isReal, imgX, imgY);
|
||
if (showRay(1)) this._oneRay(ctx, mx, objX, objY, hit2, COLS[1], 1.0, hasImg, isReal, imgX, imgY);
|
||
if (showRay(2)) this._oneRay(ctx, mx, objX, objY, hit3, COLS[2], 1.0, hasImg, isReal, imgX, imgY);
|
||
}
|
||
|
||
_oneRay(ctx, mx, ox, oy, hitY, color, alpha, hasImg, isReal, imgX, imgY) {
|
||
if (hitY === null || !isFinite(hitY) || hitY < -this.H || hitY > 2*this.H) return;
|
||
ctx.save(); ctx.globalAlpha = alpha;
|
||
if (window.LabFX && alpha > 0.5) { ctx.shadowColor = color; ctx.shadowBlur = 8; }
|
||
ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([]);
|
||
ctx.beginPath(); ctx.moveTo(ox, oy); ctx.lineTo(mx, hitY); ctx.stroke();
|
||
if (!hasImg) { ctx.restore(); return; }
|
||
if (isReal) {
|
||
ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(imgX, imgY); ctx.stroke();
|
||
const dx = imgX-mx, dy = imgY-hitY, l = Math.hypot(dx,dy);
|
||
if (l > 1) { ctx.globalAlpha = alpha * 0.22; ctx.beginPath(); ctx.moveTo(imgX,imgY); ctx.lineTo(imgX+dx/l*60, imgY+dy/l*60); ctx.stroke(); }
|
||
} else {
|
||
const dx = imgX-mx, dy = imgY-hitY;
|
||
if (Math.abs(dx) < 1) { ctx.restore(); return; }
|
||
const tL = (mx-5)/dx;
|
||
let ex = 5, ey = hitY - dy*tL;
|
||
if (ey < 5 || ey > this.H-5) { ey = ey < 5 ? 5 : this.H-5; ex = Math.max(5, mx - dx*(hitY-ey)/dy); }
|
||
ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(ex, ey); ctx.stroke();
|
||
ctx.globalAlpha = alpha * 0.4; ctx.setLineDash([4,4]);
|
||
ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(imgX, imgY); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
_flatRay(ctx, mx, ay, d, h, ox, oy, hitY, color, imgX, imgY, hasImg) {
|
||
ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([]);
|
||
ctx.beginPath(); ctx.moveTo(ox, oy); ctx.lineTo(mx, hitY); ctx.stroke();
|
||
const slope = (hitY-oy)/(mx-ox);
|
||
const farX = Math.max(5, ox-50);
|
||
const farY = hitY - slope*(mx-farX);
|
||
ctx.globalAlpha = 0.3;
|
||
ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(farX, Math.max(5, Math.min(this.H-5, farY))); ctx.stroke();
|
||
ctx.globalAlpha = 1;
|
||
if (hasImg) { ctx.setLineDash([4,4]); ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(imgX, imgY); ctx.stroke(); ctx.setLineDash([]); }
|
||
ctx.restore();
|
||
}
|
||
|
||
/* ── Mirror spherical aberration (Agent OB-A3) ────────────────
|
||
For a spherical concave mirror, paraxial focal length = f.
|
||
Marginal rays: f_eff(h) = f - h²/(4f) (simplified).
|
||
Draw 5 incoming parallel rays at different heights; show they
|
||
focus at slightly different points along the axis.
|
||
─────────────────────────────────────────────────────────────── */
|
||
_drawMirrorSphericalAberration(ctx, mx, ay, f) {
|
||
const mH = Math.min(this.H * 0.38, 140);
|
||
const fracs = [-0.85, -0.5, 0, 0.5, 0.85];
|
||
const pal = ['#FF6B6B', '#FFD166', '#7BF5A4', '#06D6E0', '#B8A4FF'];
|
||
ctx.save();
|
||
ctx.lineWidth = 1.3;
|
||
const focusPts = [];
|
||
fracs.forEach((fr, i) => {
|
||
const rayH = fr * mH;
|
||
const fEff = f > 0 ? f - (rayH * rayH) / (4 * f) : f;
|
||
const focX = mx - fEff;
|
||
ctx.strokeStyle = pal[i];
|
||
ctx.globalAlpha = 0.7;
|
||
ctx.setLineDash([]);
|
||
const startY = ay - rayH;
|
||
// incoming parallel ray from left
|
||
ctx.beginPath(); ctx.moveTo(0, startY); ctx.lineTo(mx, startY); ctx.stroke();
|
||
// reflected ray toward (approximate) focus
|
||
if (focX > 5 && focX < mx) {
|
||
ctx.beginPath(); ctx.moveTo(mx, startY); ctx.lineTo(focX, ay);
|
||
ctx.lineTo(focX - 60, ay + (startY - ay) * 0.4); ctx.stroke();
|
||
focusPts.push({ fEff, x: focX });
|
||
}
|
||
});
|
||
ctx.globalAlpha = 1;
|
||
ctx.restore();
|
||
// info box
|
||
if (focusPts.length >= 2) {
|
||
const fMin = Math.min(...focusPts.map(p => p.fEff)).toFixed(0);
|
||
const fMax = Math.max(...focusPts.map(p => p.fEff)).toFixed(0);
|
||
ctx.save();
|
||
const bx = 12, by = 70;
|
||
ctx.fillStyle = 'rgba(22,22,38,0.88)';
|
||
ctx.beginPath(); ctx.roundRect(bx, by, 224, 44, 8); ctx.fill();
|
||
ctx.font = '11px Manrope, system-ui, sans-serif';
|
||
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
|
||
ctx.fillStyle = '#FF6B6B'; ctx.fillText('Сферическая аберрация (зеркало)', bx + 8, by + 6);
|
||
ctx.fillStyle = 'rgba(255,255,255,0.55)';
|
||
ctx.fillText('f парак.=' + fMax + ' f краев.=' + fMin, bx + 8, by + 24);
|
||
ctx.restore();
|
||
}
|
||
}
|
||
|
||
_drawNormals(ctx, mx, ay, f) {
|
||
if (!isFinite(f)) return;
|
||
const { d, h } = this;
|
||
const cX = mx - 2*f;
|
||
const hits = [ay-h, ay];
|
||
const d3 = d-f;
|
||
if (Math.abs(d3) >= 0.5) { const y3 = ay + h*f/d3; if (isFinite(y3)) hits.push(y3); }
|
||
ctx.save(); ctx.strokeStyle='rgba(255,255,255,0.14)'; ctx.lineWidth=1; ctx.setLineDash([4,4]);
|
||
for (const hy of hits) {
|
||
if (hy < -this.H || hy > 2*this.H) continue;
|
||
const nx=cX-mx, ny=ay-hy, nl=Math.hypot(nx,ny);
|
||
if (nl < 1) continue;
|
||
const ux=nx/nl*28, uy=ny/nl*28;
|
||
ctx.beginPath(); ctx.moveTo(mx-ux,hy-uy); ctx.lineTo(mx+ux,hy+uy); ctx.stroke();
|
||
}
|
||
ctx.setLineDash([]); ctx.restore();
|
||
}
|
||
|
||
_drawAngleArcs(ctx, mx, ay, f) {
|
||
if (!isFinite(f)) return;
|
||
const { d, h } = this;
|
||
const hitY = ay - h;
|
||
if (hitY < 5 || hitY > this.H-5) return;
|
||
const cX = mx - 2*f;
|
||
const nx = cX-mx, ny = ay-hitY, nl = Math.hypot(nx, ny);
|
||
if (nl < 1) return;
|
||
const normInward = Math.atan2(ny, nx);
|
||
const normOuter = normInward + Math.PI;
|
||
const incDir = Math.atan2(hitY-(ay-h), mx-(mx-d));
|
||
const incFrom = incDir + Math.PI;
|
||
const r = 14;
|
||
ctx.save(); ctx.lineWidth = 1;
|
||
ctx.strokeStyle = 'rgba(6,214,224,0.45)';
|
||
ctx.beginPath(); ctx.arc(mx, hitY, r, normOuter, incFrom, false); ctx.stroke();
|
||
ctx.fillStyle = 'rgba(6,214,224,0.7)'; ctx.font = '9px Manrope, system-ui, sans-serif';
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||
const mid = (normOuter+incFrom)/2;
|
||
ctx.fillText('θ', mx+Math.cos(mid)*(r+9), hitY+Math.sin(mid)*(r+9));
|
||
ctx.restore();
|
||
}
|
||
|
||
_drawRayLabels(ctx, mx, ay, f, step) {
|
||
if (this.type === 'flat' || !isFinite(f)) return;
|
||
const { d, h } = this;
|
||
const hits = [ay-h, ay, null];
|
||
const den3 = d-f;
|
||
if (Math.abs(den3) >= 0.5) { const y3 = ay+h*f/den3; if (isFinite(y3)) hits[2] = y3; }
|
||
const COLS = ['#06D6E0','#7BF5A4','#FFD166'];
|
||
const LBLS = ['①','②','③'];
|
||
ctx.font = 'bold 11px Manrope, system-ui, sans-serif'; ctx.textAlign = 'left';
|
||
hits.forEach((hy, i) => {
|
||
if (hy === null || !isFinite(hy) || hy < -50 || hy > this.H+50) return;
|
||
if (step !== -1 && i > step) return;
|
||
ctx.fillStyle = COLS[i]; ctx.textBaseline = 'middle'; ctx.fillText(LBLS[i], mx+8, hy);
|
||
});
|
||
}
|
||
|
||
_drawArrow(ctx, x1, y1, x2, y2, color, dashed) {
|
||
ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 2.5;
|
||
if (dashed) ctx.setLineDash([6,4]);
|
||
ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.stroke();
|
||
if (dashed) ctx.setLineDash([]);
|
||
const a = Math.atan2(y2-y1, x2-x1), s=10;
|
||
ctx.beginPath(); ctx.moveTo(x2,y2);
|
||
ctx.lineTo(x2-s*Math.cos(a-0.35), y2-s*Math.sin(a-0.35));
|
||
ctx.lineTo(x2-s*Math.cos(a+0.35), y2-s*Math.sin(a+0.35));
|
||
ctx.closePath(); ctx.fill();
|
||
}
|
||
|
||
_drawDimensions(ctx, mx, ay, f, dPrime, hPrime) {
|
||
const { d, h } = this;
|
||
const objX = mx - d;
|
||
const yBase = ay + Math.min(this.H*0.22, 60);
|
||
ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.lineWidth = 1;
|
||
const bracket = (x1, x2, y, lbl, col) => {
|
||
if (x1 === x2 || x1 < 4 || x2 > this.W-4) return;
|
||
ctx.strokeStyle = col; ctx.fillStyle = col; ctx.setLineDash([]);
|
||
ctx.beginPath();
|
||
ctx.moveTo(x1, y-5); ctx.lineTo(x1, y+5); ctx.moveTo(x1, y); ctx.lineTo(x2, y);
|
||
ctx.moveTo(x2, y-5); ctx.lineTo(x2, y+5); ctx.stroke();
|
||
ctx.textAlign='center'; ctx.textBaseline='top'; ctx.fillText(lbl, (x1+x2)/2, y+3);
|
||
};
|
||
bracket(objX, mx, yBase, 'd=' + d.toFixed(0), 'rgba(155,93,229,0.65)');
|
||
if (isFinite(f) && Math.abs(f) > 5) {
|
||
const fX = mx-f;
|
||
if (fX > 4 && fX < this.W-4)
|
||
bracket(Math.min(fX,mx), Math.max(fX,mx), yBase+20, 'f=' + Math.abs(f).toFixed(0), 'rgba(6,214,224,0.55)');
|
||
}
|
||
if (dPrime !== null && isFinite(dPrime)) {
|
||
const ix = mx-dPrime;
|
||
if (ix > 4 && ix < this.W-4)
|
||
bracket(Math.min(ix,mx), Math.max(ix,mx), yBase, "d'=" + Math.abs(dPrime).toFixed(0),
|
||
dPrime > 0 ? 'rgba(239,71,111,0.65)' : 'rgba(255,209,102,0.65)');
|
||
}
|
||
const xl = objX-18;
|
||
if (xl > 4 && h > 6 && !this._pointMode) {
|
||
ctx.strokeStyle='rgba(155,93,229,0.4)';
|
||
ctx.beginPath(); ctx.moveTo(xl,ay); ctx.lineTo(xl,ay-h); ctx.stroke();
|
||
ctx.fillStyle='rgba(155,93,229,0.7)'; ctx.textAlign='right'; ctx.textBaseline='middle';
|
||
ctx.fillText('h=' + h.toFixed(0), xl-3, ay-h/2);
|
||
}
|
||
if (dPrime !== null && isFinite(dPrime) && !this._pointMode && Math.abs(hPrime) > 6) {
|
||
const ix = mx-dPrime;
|
||
const xil = ix + (dPrime > 0 ? -18 : 18);
|
||
if (xil > 4 && xil < this.W-4) {
|
||
const col = dPrime > 0 ? 'rgba(239,71,111,' : 'rgba(255,209,102,';
|
||
ctx.strokeStyle = col+'0.4)'; ctx.beginPath(); ctx.moveTo(ix,ay); ctx.lineTo(ix,ay-hPrime); ctx.stroke();
|
||
ctx.fillStyle = col+'0.7)';
|
||
ctx.textAlign = dPrime > 0 ? 'right' : 'left'; ctx.textBaseline='middle';
|
||
ctx.fillText("h'=" + Math.abs(hPrime).toFixed(0), ix+(dPrime>0?-3:3), ay-hPrime/2);
|
||
}
|
||
}
|
||
}
|
||
|
||
_drawInfoBox(ctx, f, dPrime) {
|
||
const info = this.info();
|
||
const bx=12, by=12, bw=230, bh=76;
|
||
ctx.fillStyle='rgba(13,13,26,0.9)'; ctx.beginPath(); ctx.roundRect(bx,by,bw,bh,8); ctx.fill();
|
||
ctx.strokeStyle='rgba(255,255,255,0.06)'; ctx.lineWidth=1;
|
||
ctx.beginPath(); ctx.roundRect(bx,by,bw,bh,8); ctx.stroke();
|
||
ctx.font='11px Manrope, system-ui, sans-serif'; ctx.textAlign='left'; ctx.textBaseline='top';
|
||
ctx.fillStyle='rgba(255,255,255,0.42)';
|
||
ctx.fillText("1/f = 1/d + 1/d'", bx+10, by+8);
|
||
if (isFinite(f) && dPrime !== null && isFinite(dPrime)) {
|
||
ctx.fillStyle='rgba(6,214,224,0.88)'; ctx.fillText('1/' + Math.abs(+info.f), bx+10, by+28);
|
||
ctx.fillStyle='rgba(255,255,255,0.28)';ctx.fillText('=',bx+60,by+28);
|
||
ctx.fillStyle='rgba(155,93,229,0.88)'; ctx.fillText('1/' + info.d, bx+78, by+28);
|
||
ctx.fillStyle='rgba(255,255,255,0.28)';ctx.fillText('+',bx+120,by+28);
|
||
ctx.fillStyle= dPrime>0 ? 'rgba(239,71,111,0.88)' : 'rgba(255,209,102,0.88)';
|
||
ctx.fillText((dPrime>0?'':'−') + '1/' + Math.abs(+info.dPrime).toFixed(0), bx+136, by+28);
|
||
} else {
|
||
ctx.fillStyle='rgba(255,209,102,0.75)';
|
||
ctx.fillText('d = f → изображение на ∞', bx+10, by+28);
|
||
}
|
||
if (info.M !== Infinity) {
|
||
ctx.fillStyle='rgba(255,255,255,0.28)'; ctx.fillText('M = ' + info.M, bx+10, by+48);
|
||
if (isFinite(dPrime)) {
|
||
ctx.fillStyle = dPrime > 0 ? '#EF476F' : '#FFD166';
|
||
ctx.textAlign = 'right'; ctx.fillText(info.imageType + ' ' + info.orient, bx+bw-10, by+48);
|
||
ctx.textAlign = 'left';
|
||
}
|
||
}
|
||
}
|
||
|
||
_drawImageBadge(ctx, dPrime, hPrime) {
|
||
const info = this.info();
|
||
const bw=160, bh=58, bx=this.W-bw-12, by=12;
|
||
ctx.fillStyle='rgba(13,13,26,0.88)'; ctx.beginPath(); ctx.roundRect(bx,by,bw,bh,8); ctx.fill();
|
||
ctx.strokeStyle='rgba(255,255,255,0.06)'; ctx.lineWidth=1;
|
||
ctx.beginPath(); ctx.roundRect(bx,by,bw,bh,8); ctx.stroke();
|
||
const isInf = !isFinite(dPrime);
|
||
ctx.font='10px Manrope, system-ui, sans-serif'; ctx.textAlign='left'; ctx.textBaseline='top';
|
||
const tc = isInf ? '#FFD166' : dPrime>0 ? '#EF476F' : '#FFD166';
|
||
ctx.fillStyle='rgba(255,255,255,0.3)'; ctx.fillText('Тип:', bx+10, by+8);
|
||
ctx.fillStyle=tc; ctx.fillText(isInf?'∞':info.imageType, bx+44, by+8);
|
||
if (!isInf) {
|
||
ctx.fillStyle='rgba(255,255,255,0.3)'; ctx.fillText('Ориент.:', bx+10, by+26);
|
||
ctx.fillStyle = info.M<0 ? 'rgba(239,71,111,0.9)' : 'rgba(123,245,164,0.9)';
|
||
ctx.fillText(info.orient, bx+62, by+26);
|
||
if (info.sizeStr) {
|
||
const sc = Math.abs(+info.M)>1.05 ? '#9B5DE5' : Math.abs(+info.M)<0.95 ? '#06D6E0' : 'rgba(255,255,255,0.6)';
|
||
ctx.fillStyle='rgba(255,255,255,0.3)'; ctx.fillText('Размер:', bx+10, by+42);
|
||
ctx.fillStyle=sc; ctx.fillText(info.sizeStr + ' x' + Math.abs(+info.M).toFixed(2), bx+57, by+42);
|
||
}
|
||
}
|
||
}
|
||
|
||
_drawCriticalMarker(ctx, f) {
|
||
if (!isFinite(f) || f <= 0) return;
|
||
const eps = f*0.06;
|
||
let text = null;
|
||
if (Math.abs(this.d-f) < eps) text = 'd = f : лучи параллельны, изображения нет';
|
||
else if (Math.abs(this.d-2*f)<eps) text = 'd = 2f : изображение равное, M = −1';
|
||
if (!text) return;
|
||
ctx.save(); ctx.font='bold 11px Manrope, system-ui, sans-serif';
|
||
ctx.textAlign='center'; ctx.textBaseline='top';
|
||
const tw=ctx.measureText(text).width, bx=this.W/2-tw/2-10, by=4;
|
||
ctx.fillStyle='rgba(255,209,102,0.15)'; ctx.beginPath(); ctx.roundRect(bx,by,tw+20,22,6); ctx.fill();
|
||
ctx.strokeStyle='rgba(255,209,102,0.35)'; ctx.lineWidth=1;
|
||
ctx.beginPath(); ctx.roundRect(bx,by,tw+20,22,6); ctx.stroke();
|
||
ctx.fillStyle='#FFD166'; ctx.fillText(text, this.W/2, by+5);
|
||
ctx.restore();
|
||
}
|
||
|
||
_drawLegend(ctx) {
|
||
const items = [
|
||
{ c:'rgba(155,93,229,0.8)', t:'d > 0 — предмет перед зеркалом' },
|
||
{ c:'rgba(239,71,111,0.8)', t:"d' > 0 — действительное" },
|
||
{ c:'rgba(255,209,102,0.8)',t:"d' < 0 — мнимое" },
|
||
];
|
||
const bx=12, lh=14, by=this.H - items.length*lh - 16;
|
||
ctx.save(); ctx.font='9px Manrope, system-ui, sans-serif'; ctx.textBaseline='top';
|
||
items.forEach(({ c, t }, i) => {
|
||
const y = by+i*lh;
|
||
ctx.fillStyle=c; ctx.fillRect(bx, y+3, 8, 8);
|
||
ctx.fillStyle='rgba(255,255,255,0.32)'; ctx.textAlign='left'; ctx.fillText(t, bx+13, y);
|
||
});
|
||
ctx.restore();
|
||
}
|
||
|
||
_drawPhotons(ctx) {
|
||
for (const p of this._photons) {
|
||
const pos = this._photonPos(p.pts, p.t);
|
||
if (!pos) continue;
|
||
ctx.save(); ctx.shadowColor = p.color; ctx.shadowBlur = 8;
|
||
ctx.fillStyle = p.color; ctx.beginPath(); ctx.arc(pos[0], pos[1], 3, 0, Math.PI*2); ctx.fill();
|
||
ctx.restore();
|
||
}
|
||
}
|
||
|
||
_photonPos(pts, t) {
|
||
if (pts.length < 2) return null;
|
||
let total = 0;
|
||
const lens = [];
|
||
for (let i=1; i<pts.length; i++) {
|
||
const l = Math.hypot(pts[i][0]-pts[i-1][0], pts[i][1]-pts[i-1][1]);
|
||
lens.push(l); total += l;
|
||
}
|
||
let dist = t * total;
|
||
for (let i=0; i<lens.length; i++) {
|
||
if (dist <= lens[i]) { const k = dist/lens[i]; return [pts[i][0]+(pts[i+1][0]-pts[i][0])*k, pts[i][1]+(pts[i+1][1]-pts[i][1])*k]; }
|
||
dist -= lens[i];
|
||
}
|
||
return pts[pts.length-1];
|
||
}
|
||
|
||
_drawTooltip(ctx, mx, ay, f, dPrime, hPrime) {
|
||
const { _hoverX:hx, _hoverY:hy } = this;
|
||
if (hx < 0) return;
|
||
let tip = null;
|
||
const chk = (px, py, lbl, sub) => { if (!tip && Math.hypot(hx-px, hy-py) < 15) tip = { lbl, sub }; };
|
||
if (isFinite(f)) {
|
||
chk(mx-f, ay, 'Главный фокус F', 'f = ' + Math.abs(f).toFixed(0));
|
||
chk(mx-2*f, ay, 'Центр кривизны C', 'R = 2f = ' + (2*Math.abs(f)).toFixed(0));
|
||
}
|
||
chk(mx-this.d, ay-(this._pointMode?0:this.h), 'Предмет', 'd = ' + this.d.toFixed(0) + ', h = ' + this.h.toFixed(0));
|
||
if (dPrime !== null && isFinite(dPrime)) {
|
||
const ix=mx-dPrime, iy=ay-(this._pointMode?0:hPrime);
|
||
chk(ix, iy, 'Изображение', "d' = " + Math.abs(dPrime).toFixed(0) + ', M = ' + this.info().M);
|
||
}
|
||
if (!tip) return;
|
||
ctx.save();
|
||
ctx.font = 'bold 11px Manrope, system-ui, sans-serif';
|
||
const tw = Math.max(ctx.measureText(tip.lbl).width, ctx.measureText(tip.sub).width);
|
||
const bw=tw+20, bh=34;
|
||
let tx=hx+14, ty=hy-bh-6;
|
||
if (tx+bw > this.W-4) tx = hx-bw-14;
|
||
if (ty < 4) ty = hy+10;
|
||
ctx.fillStyle='rgba(13,13,26,0.95)'; ctx.strokeStyle='rgba(6,214,224,0.45)'; ctx.lineWidth=1;
|
||
ctx.beginPath(); ctx.roundRect(tx,ty,bw,bh,6); ctx.fill(); ctx.stroke();
|
||
ctx.fillStyle='#fff'; ctx.textAlign='left'; ctx.textBaseline='top'; ctx.fillText(tip.lbl, tx+10, ty+6);
|
||
ctx.font='10px Manrope, system-ui, sans-serif';
|
||
ctx.fillStyle='rgba(255,255,255,0.5)'; ctx.fillText(tip.sub, tx+10, ty+20);
|
||
ctx.restore();
|
||
}
|
||
|
||
_drawStepOverlay(ctx, step) {
|
||
const lbls = [
|
||
'① Луч параллельно оси → отражается через F',
|
||
'② Луч через вершину → отражается симметрично',
|
||
'③ Луч через F → отражается параллельно',
|
||
' Изображение — пересечение всех отражённых лучей',
|
||
];
|
||
const text = lbls[Math.min(step, lbls.length-1)];
|
||
ctx.save(); ctx.font = '11px Manrope, system-ui, sans-serif';
|
||
const tw = ctx.measureText(text).width;
|
||
const bx = this.W/2-tw/2-12, by = this.H-34;
|
||
ctx.fillStyle='rgba(13,13,26,0.9)'; ctx.beginPath(); ctx.roundRect(bx,by,tw+24,24,6); ctx.fill();
|
||
ctx.fillStyle='#7BF5A4'; ctx.textAlign='center'; ctx.textBaseline='middle';
|
||
ctx.fillText(text, this.W/2, by+12);
|
||
ctx.restore();
|
||
}
|
||
|
||
/* === Feature 2: R-slider mode for MirrorSim === */
|
||
setMirrorR(R) {
|
||
this._useR = true;
|
||
this._R = +R;
|
||
// Derive type and f from R
|
||
const absR = Math.abs(this._R);
|
||
if (absR < 5) { this.type = 'flat'; }
|
||
else if (this._R > 0) { this.type = 'concave'; this.f = absR / 2; }
|
||
else { this.type = 'convex'; this.f = absR / 2; }
|
||
this.draw(); this._emit();
|
||
}
|
||
|
||
setMirrorParabolic(on) {
|
||
this._parabolic = !!on;
|
||
this.draw();
|
||
}
|
||
|
||
/* Draw 5 parallel rays showing spherical vs parabolic aberration */
|
||
_drawAberrationFan(ctx, mx, ay, f) {
|
||
if (!isFinite(f) || Math.abs(f) < 5) return;
|
||
const mH = Math.min(this.H * 0.38, 140);
|
||
const heights = [-0.85, -0.45, 0, 0.45, 0.85];
|
||
const COLORS = ['#FF6B6B', '#FFD166', '#7BF5A4', '#06D6E0', '#B8A4FF'];
|
||
ctx.save(); ctx.lineWidth = 1.4;
|
||
heights.forEach((fr, i) => {
|
||
const rayH = fr * mH;
|
||
// For parabolic mirror: all parallel rays focus exactly at f
|
||
// For spherical: marginal rays (fr != 0) focus closer by h^2/(2R) approx
|
||
const fEff = this._parabolic ? f : f - (rayH * rayH) / (2 * Math.abs(f) * 2);
|
||
const startX = mx - this.d - 40;
|
||
const hitY = ay - rayH; // hits mirror at height rayH
|
||
// Incident ray: horizontal from left to mirror
|
||
ctx.strokeStyle = COLORS[i]; ctx.globalAlpha = 0.75;
|
||
ctx.setLineDash([]);
|
||
ctx.beginPath(); ctx.moveTo(startX, ay - rayH); ctx.lineTo(mx, hitY); ctx.stroke();
|
||
// Reflected ray: goes toward focal point fEff
|
||
const focX = mx - fEff;
|
||
if (focX > 0 && focX < this.W) {
|
||
ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(focX, ay);
|
||
// extend a bit past focus
|
||
const dx = focX - mx, dy = ay - hitY, len = Math.hypot(dx, dy);
|
||
if (len > 1) ctx.lineTo(focX + dx/len*50, ay + dy/len*50);
|
||
ctx.stroke();
|
||
}
|
||
});
|
||
ctx.globalAlpha = 1;
|
||
// label
|
||
const label = this._parabolic ? 'Параболическое (идеальный фокус)' : 'Сферическое (аберрация)';
|
||
const col = this._parabolic ? '#7BF5A4' : '#FF6B6B';
|
||
const bx = 12, by = this.H - 36;
|
||
ctx.fillStyle = 'rgba(13,13,26,0.85)';
|
||
ctx.beginPath(); ctx.roundRect(bx, by, 250, 24, 6); ctx.fill();
|
||
ctx.font = 'bold 11px Manrope, system-ui, sans-serif';
|
||
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
|
||
ctx.fillStyle = col;
|
||
ctx.fillText(label, bx + 8, by + 12);
|
||
ctx.restore();
|
||
}
|
||
|
||
_bindEvents() {
|
||
const cv = this.canvas;
|
||
const getPos = e => {
|
||
const r = cv.getBoundingClientRect();
|
||
const t = e.touches ? e.touches[0] : e;
|
||
return { px: (t.clientX-r.left)*(this.W/r.width), py: (t.clientY-r.top)*(this.H/r.height) };
|
||
};
|
||
const mX = () => Math.round(this.W*0.62);
|
||
const aY = () => this.H/2;
|
||
const hitTest = (px, py) => {
|
||
if (this._playing) return null;
|
||
const mx=mX(), ay=aY(), f=this._fSigned();
|
||
if (Math.hypot(px-(mx-this.d), py-(ay-(this._pointMode?0:this.h))) < 20) return 'object';
|
||
if (this.type !== 'flat' && isFinite(f) && Math.hypot(px-(mx-f), py-ay) < 16) return 'focus';
|
||
const info = this.info();
|
||
if (info.dPrime !== Infinity && isFinite(info.dPrime)) {
|
||
const ix=mx-info.dPrime, iy=ay-(this._pointMode?0:(info.hPrime||0));
|
||
if (Math.hypot(px-ix, py-iy) < 18) return 'image';
|
||
}
|
||
return null;
|
||
};
|
||
cv.addEventListener('mousedown', e => { const {px,py}=getPos(e); this._drag=hitTest(px,py); });
|
||
window.addEventListener('mousemove', e => {
|
||
const {px,py} = getPos(e);
|
||
this._hoverX = px; this._hoverY = py;
|
||
if (this._drag) {
|
||
if (e.cancelable) e.preventDefault();
|
||
const mx=mX(), f=this._fSigned();
|
||
if (this._drag === 'object') this.d = Math.max(30, Math.min(490, mx-px));
|
||
else if (this._drag === 'focus') this.f = Math.max(30, Math.min(300, Math.abs(mx-px)));
|
||
else if (this._drag === 'image' && isFinite(f) && this.type !== 'flat') {
|
||
const dp = mx-px; if (Math.abs(dp-f) > 5) this.d = Math.max(30, Math.min(490, f*dp/(dp-f)));
|
||
}
|
||
if (this.onAnimate) this.onAnimate(this.d);
|
||
this.draw(); this._emit();
|
||
} else if (!this._photonRaf && !this._playing) { this.draw(); }
|
||
});
|
||
window.addEventListener('mouseup', () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = null; });
|
||
cv.addEventListener('mousemove', e => {
|
||
if (this._drag) { cv.style.cursor='grabbing'; return; }
|
||
const {px,py}=getPos(e);
|
||
cv.style.cursor = (hitTest(px,py) && !this._playing) ? 'grab' : 'default';
|
||
});
|
||
cv.addEventListener('touchstart', e => {
|
||
if (e.touches.length===1) { const {px,py}=getPos(e); this._drag=hitTest(px,py); }
|
||
}, { passive: true });
|
||
cv.addEventListener('touchmove', e => {
|
||
if (!this._drag) return;
|
||
if (e.cancelable) e.preventDefault();
|
||
const {px}=getPos(e), mx=mX(), f=this._fSigned();
|
||
if (this._drag==='object') this.d=Math.max(30,Math.min(490,mx-px));
|
||
else if (this._drag==='focus') this.f=Math.max(30,Math.min(300,Math.abs(mx-px)));
|
||
else if (this._drag==='image' && isFinite(f) && this.type!=='flat') {
|
||
const dp=mx-px; if (Math.abs(dp-f)>5) this.d=Math.max(30,Math.min(490,f*dp/(dp-f)));
|
||
}
|
||
if (this.onAnimate) this.onAnimate(this.d);
|
||
this.draw(); this._emit();
|
||
}, { passive: false });
|
||
cv.addEventListener('touchend', () => { this._drag=null; });
|
||
}
|
||
}
|
||
|
||
/* ─────────────────────────────────────────────────────────────
|
||
3. REFRACTION ENGINE (from refraction.js)
|
||
───────────────────────────────────────────────────────────────*/
|
||
class RefractionSim {
|
||
constructor(canvas) {
|
||
this.canvas = canvas;
|
||
this.ctx = canvas.getContext('2d');
|
||
this.W = 0; this.H = 0;
|
||
|
||
this.n1 = 1.0;
|
||
this.n2 = 1.5;
|
||
this.angle = 30;
|
||
this.dispersion = false;
|
||
|
||
this._drag = false;
|
||
this.onUpdate = null;
|
||
|
||
this._bindEvents();
|
||
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
|
||
}
|
||
|
||
fit() {
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const w = this.canvas.offsetWidth || 600;
|
||
const h = this.canvas.offsetHeight || 400;
|
||
this.canvas.width = w * dpr;
|
||
this.canvas.height = h * dpr;
|
||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
this.W = w; this.H = h;
|
||
}
|
||
|
||
getParams() { return { n1: this.n1, n2: this.n2, angle: this.angle, dispersion: this.dispersion }; }
|
||
setParams({ n1, n2, angle, dispersion } = {}) {
|
||
if (n1 !== undefined) this.n1 = Math.max(1.0, Math.min(3.0, +n1));
|
||
if (n2 !== undefined) this.n2 = Math.max(1.0, Math.min(3.0, +n2));
|
||
if (angle !== undefined) this.angle = Math.max(0, Math.min(89, +angle));
|
||
if (dispersion !== undefined) this.dispersion = !!dispersion;
|
||
this.draw(); this._emit();
|
||
}
|
||
|
||
reset() { this.n1 = 1.0; this.n2 = 1.5; this.angle = 30; this.dispersion = false; this.draw(); this._emit(); }
|
||
|
||
info() {
|
||
const { n1, n2, angle } = this;
|
||
const theta1Rad = angle * Math.PI / 180;
|
||
const sinTheta2 = (n1 / n2) * Math.sin(theta1Rad);
|
||
const isTIR = Math.abs(sinTheta2) > 1;
|
||
const criticalAngle = n1 > n2 ? +(Math.asin(n2 / n1) * 180 / Math.PI).toFixed(1) : null;
|
||
let angle2;
|
||
if (isTIR) angle2 = 'ПВО';
|
||
else angle2 = +(Math.asin(sinTheta2) * 180 / Math.PI).toFixed(1);
|
||
return { n1: +n1.toFixed(2), n2: +n2.toFixed(2), angle1: +angle.toFixed(1), angle2, criticalAngle, isTIR };
|
||
}
|
||
|
||
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
|
||
|
||
draw() {
|
||
const ctx = this.ctx, W = this.W, H = this.H;
|
||
if (!W || !H) return;
|
||
const midY = H / 2, hitX = W / 2, hitY = midY;
|
||
const gradTop = ctx.createLinearGradient(0, 0, 0, midY);
|
||
gradTop.addColorStop(0, '#131328'); gradTop.addColorStop(1, '#1a1a3a');
|
||
ctx.fillStyle = gradTop; ctx.fillRect(0, 0, W, midY);
|
||
const gradBot = ctx.createLinearGradient(0, midY, 0, H);
|
||
gradBot.addColorStop(0, '#0e1a2e'); gradBot.addColorStop(1, '#0D0D1A');
|
||
ctx.fillStyle = gradBot; ctx.fillRect(0, midY, W, H - midY);
|
||
ctx.save();
|
||
ctx.shadowColor = 'rgba(155, 93, 229, 0.4)'; ctx.shadowBlur = 12;
|
||
ctx.strokeStyle = 'rgba(155, 93, 229, 0.5)'; ctx.lineWidth = 2;
|
||
ctx.beginPath(); ctx.moveTo(0, midY); ctx.lineTo(W, midY); ctx.stroke();
|
||
ctx.restore();
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1; ctx.setLineDash([6, 4]);
|
||
ctx.beginPath(); ctx.moveTo(hitX, 0); ctx.lineTo(hitX, H); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
const theta1Rad = this.angle * Math.PI / 180;
|
||
const sinTheta2 = (this.n1 / this.n2) * Math.sin(theta1Rad);
|
||
const isTIR = Math.abs(sinTheta2) > 1;
|
||
let R = 1;
|
||
if (!isTIR) {
|
||
const theta2Rad = Math.asin(sinTheta2);
|
||
const cosT1 = Math.cos(theta1Rad), cosT2 = Math.cos(theta2Rad);
|
||
const rs = (this.n1 * cosT1 - this.n2 * cosT2) / (this.n1 * cosT1 + this.n2 * cosT2);
|
||
R = rs * rs;
|
||
}
|
||
const rayLen = Math.max(W, H) * 0.6;
|
||
if (this.n1 > this.n2) {
|
||
const critRad = Math.asin(this.n2 / this.n1);
|
||
const critDx = Math.sin(critRad), critDy = Math.cos(critRad);
|
||
ctx.strokeStyle = 'rgba(255,209,102,0.25)'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]);
|
||
ctx.beginPath(); ctx.moveTo(hitX, hitY); ctx.lineTo(hitX - critDx * rayLen * 0.5, hitY - critDy * rayLen * 0.5); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
ctx.font = '10px Manrope, system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,209,102,0.5)';
|
||
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
|
||
ctx.fillText('θc=' + (critRad * 180 / Math.PI).toFixed(1) + '°', hitX - critDx * rayLen * 0.35 + 6, hitY - critDy * rayLen * 0.35);
|
||
}
|
||
if (this.dispersion && !isTIR) this._drawDispersion(ctx, hitX, hitY, midY, theta1Rad, rayLen);
|
||
else this._drawMainRays(ctx, hitX, hitY, midY, theta1Rad, sinTheta2, isTIR, R, rayLen);
|
||
this._drawAngleArcs(ctx, hitX, hitY, theta1Rad, sinTheta2, isTIR);
|
||
this._drawMediumLabels(ctx, W, H, midY);
|
||
this._drawInfoBox(ctx, isTIR, R);
|
||
const incDx = Math.sin(theta1Rad), incDy = Math.cos(theta1Rad);
|
||
const handleX = hitX - incDx * rayLen * 0.55, handleY = hitY - incDy * rayLen * 0.55;
|
||
const grad = ctx.createRadialGradient(handleX, handleY, 0, handleX, handleY, 10);
|
||
grad.addColorStop(0, 'rgba(155,93,229,0.4)'); grad.addColorStop(1, 'rgba(155,93,229,0)');
|
||
ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(handleX, handleY, 10, 0, Math.PI * 2); ctx.fill();
|
||
|
||
// TIR one-shot sound
|
||
if (window.LabFX) {
|
||
if (isTIR && !this._wasTIR) {
|
||
LabFX.sound.play('spark', { volume: 0.2 });
|
||
}
|
||
this._wasTIR = isTIR;
|
||
|
||
// Brewster angle: R ≈ 0 (reflected intensity near zero for s-pol)
|
||
const _isBrew = !isTIR && R < 0.005 && this.angle > 0;
|
||
if (_isBrew && !this._wasBrewster) {
|
||
LabFX.sound.play('chime', { pitch: 1.5, volume: 0.3 });
|
||
}
|
||
this._wasBrewster = _isBrew;
|
||
}
|
||
// OB_FX visual depth layer
|
||
{
|
||
const incDx2 = Math.sin(theta1Rad), incDy2 = Math.cos(theta1Rad);
|
||
const incStartX = hitX - incDx2 * rayLen, incStartY = hitY - incDy2 * rayLen;
|
||
// Normal at boundary points upward (angle = -PI/2 in canvas coords)
|
||
const normalAngle = -Math.PI / 2;
|
||
const kind = isTIR ? 'reflection' : 'refraction';
|
||
const fxExtras = {
|
||
srcX: incStartX, srcY: incStartY,
|
||
rays: [
|
||
{ x1: incStartX, y1: incStartY, x2: hitX, y2: hitY, n: this.n1 },
|
||
],
|
||
boundary: { bx: hitX, by: hitY, normalAngle, kind },
|
||
};
|
||
_drawOBFXLayer(ctx, 'refraction', fxExtras);
|
||
}
|
||
if (window.LabFX) {
|
||
LabFX.particles.update(1 / 60);
|
||
LabFX.particles.draw(ctx);
|
||
}
|
||
}
|
||
|
||
_drawMainRays(ctx, hitX, hitY, midY, theta1Rad, sinTheta2, isTIR, R, rayLen) {
|
||
// Use wavelength color if set, otherwise fall back to purple/red/cyan scheme
|
||
const _wl = window._obWhiteLight;
|
||
const _wlNm = window._obWavelength || 550;
|
||
const incColor = _wl ? '#FFFFFF' : wavelengthToRGB(_wlNm);
|
||
const incDx = Math.sin(theta1Rad), incDy = Math.cos(theta1Rad);
|
||
const incStartX = hitX - incDx * rayLen, incStartY = hitY - incDy * rayLen;
|
||
if (_wl) {
|
||
// White-light: draw spectral fan using physical n(λ) model
|
||
this._drawRay(ctx, incStartX, incStartY, hitX, hitY, '#FFFFFF', 2.5);
|
||
this._drawArrowhead(ctx, hitX, hitY, Math.atan2(hitY - incStartY, hitX - incStartX), '#FFFFFF');
|
||
for (const s of OB_SPECTRAL) {
|
||
const n2w = _nAtWavelength(this.n2, s.nm);
|
||
const sinT2w = (this.n1 / n2w) * Math.sin(theta1Rad);
|
||
if (Math.abs(sinT2w) > 1) continue;
|
||
const t2w = Math.asin(sinT2w);
|
||
const col = wavelengthToRGB(s.nm);
|
||
ctx.globalAlpha = 0.8;
|
||
this._drawRay(ctx, hitX, hitY, hitX + Math.sin(t2w) * rayLen, hitY + Math.cos(t2w) * rayLen, col, 1.8);
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
// Reflected ray (partial, semi-transparent)
|
||
ctx.globalAlpha = 0.3;
|
||
this._drawRay(ctx, hitX, hitY, hitX + incDx * rayLen * 0.7, hitY - incDy * rayLen * 0.7, '#FFFFFF', 1.5);
|
||
ctx.globalAlpha = 1;
|
||
return;
|
||
}
|
||
this._drawRay(ctx, incStartX, incStartY, hitX, hitY, incColor, 2.5);
|
||
this._drawArrowhead(ctx, hitX, hitY, Math.atan2(hitY - incStartY, hitX - incStartX), incColor);
|
||
const refDx = incDx, refDy = -incDy;
|
||
const refEndX = hitX + refDx * rayLen, refEndY = hitY + refDy * rayLen;
|
||
const refAlpha = isTIR ? 1.0 : Math.max(0.3, Math.sqrt(R));
|
||
ctx.globalAlpha = refAlpha;
|
||
this._drawRay(ctx, hitX, hitY, refEndX, refEndY, '#EF476F', 2.5);
|
||
this._drawArrowhead(ctx, refEndX, refEndY, Math.atan2(refEndY - hitY, refEndX - hitX), '#EF476F');
|
||
ctx.globalAlpha = 1;
|
||
if (!isTIR) {
|
||
const theta2Rad = Math.asin(sinTheta2);
|
||
const refracDx = Math.sin(theta2Rad), refracDy = Math.cos(theta2Rad);
|
||
const refracEndX = hitX + refracDx * rayLen, refracEndY = hitY + refracDy * rayLen;
|
||
const T = 1 - R;
|
||
ctx.globalAlpha = Math.max(0.3, Math.sqrt(T));
|
||
// Refracted ray: use wavelength color with slight shift for refracted
|
||
this._drawRay(ctx, hitX, hitY, refracEndX, refracEndY, incColor, 2.5);
|
||
this._drawArrowhead(ctx, refracEndX, refracEndY, Math.atan2(refracEndY - hitY, refracEndX - hitX), incColor);
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
}
|
||
|
||
_drawDispersion(ctx, hitX, hitY, midY, theta1Rad, rayLen) {
|
||
const spectral = [
|
||
{ color: '#FF0000', wave: 656 }, { color: '#FF7F00', wave: 589 }, { color: '#FFFF00', wave: 550 },
|
||
{ color: '#00FF00', wave: 510 }, { color: '#00FFFF', wave: 475 }, { color: '#0000FF', wave: 450 },
|
||
{ color: '#8B00FF', wave: 400 },
|
||
];
|
||
const incDx = Math.sin(theta1Rad), incDy = Math.cos(theta1Rad);
|
||
const incStartX = hitX - incDx * rayLen, incStartY = hitY - incDy * rayLen;
|
||
this._drawRay(ctx, incStartX, incStartY, hitX, hitY, '#FFFFFF', 2.5);
|
||
this._drawArrowhead(ctx, hitX, hitY, Math.atan2(hitY - incStartY, hitX - incStartX), '#FFFFFF');
|
||
const A = this.n2 - 4500 / (550 * 550), B = 4500;
|
||
for (const s of spectral) {
|
||
const n2w = A + B / (s.wave * s.wave);
|
||
const sinT2 = (this.n1 / n2w) * Math.sin(theta1Rad);
|
||
if (Math.abs(sinT2) > 1) continue;
|
||
const t2 = Math.asin(sinT2), dx = Math.sin(t2), dy = Math.cos(t2);
|
||
ctx.globalAlpha = 0.85;
|
||
this._drawRay(ctx, hitX, hitY, hitX + dx * rayLen, hitY + dy * rayLen, s.color, 1.5);
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
const refDx = incDx, refDy = -incDy;
|
||
ctx.globalAlpha = 0.35;
|
||
this._drawRay(ctx, hitX, hitY, hitX + refDx * rayLen * 0.7, hitY + refDy * rayLen * 0.7, '#FFFFFF', 1.5);
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
|
||
_drawRay(ctx, x1, y1, x2, y2, color, width) {
|
||
const drawFn = () => {
|
||
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round';
|
||
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
|
||
ctx.save(); ctx.shadowColor = color; ctx.shadowBlur = 8; ctx.globalAlpha = 0.3;
|
||
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
|
||
ctx.restore();
|
||
};
|
||
if (window.LabFX) LabFX.glow.drawGlow(ctx, drawFn, { color, intensity: 8 });
|
||
else drawFn();
|
||
}
|
||
|
||
_drawArrowhead(ctx, x, y, angle, color) {
|
||
const aLen = 10;
|
||
ctx.fillStyle = color;
|
||
ctx.beginPath(); ctx.moveTo(x, y);
|
||
ctx.lineTo(x - aLen * Math.cos(angle - 0.3), y - aLen * Math.sin(angle - 0.3));
|
||
ctx.lineTo(x - aLen * Math.cos(angle + 0.3), y - aLen * Math.sin(angle + 0.3));
|
||
ctx.closePath(); ctx.fill();
|
||
}
|
||
|
||
_drawAngleArcs(ctx, hitX, hitY, theta1Rad, sinTheta2, isTIR) {
|
||
const arcR = 50, font = '12px Manrope, system-ui, sans-serif';
|
||
if (this.angle > 1) {
|
||
ctx.strokeStyle = 'rgba(155,93,229,0.6)'; ctx.lineWidth = 1.5;
|
||
ctx.beginPath();
|
||
const normAngle = -Math.PI / 2, incAngle = -Math.PI / 2 - theta1Rad;
|
||
ctx.arc(hitX, hitY, arcR, Math.min(incAngle, normAngle), Math.max(incAngle, normAngle)); ctx.stroke();
|
||
ctx.font = font; ctx.fillStyle = '#9B5DE5'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||
const midA = normAngle - theta1Rad / 2;
|
||
ctx.fillText('θ₁=' + this.angle.toFixed(1) + '°', hitX + (arcR + 20) * Math.cos(midA), hitY + (arcR + 20) * Math.sin(midA));
|
||
}
|
||
if (!isTIR && Math.abs(sinTheta2) <= 1) {
|
||
const theta2Rad = Math.asin(sinTheta2);
|
||
if (theta2Rad > 0.02) {
|
||
ctx.strokeStyle = 'rgba(6,214,224,0.6)'; ctx.lineWidth = 1.5;
|
||
ctx.beginPath();
|
||
const normDown = Math.PI / 2, refAngle = Math.PI / 2 + theta2Rad;
|
||
ctx.arc(hitX, hitY, arcR * 0.8, Math.min(normDown, refAngle), Math.max(normDown, refAngle)); ctx.stroke();
|
||
const angle2Deg = theta2Rad * 180 / Math.PI;
|
||
ctx.font = font; ctx.fillStyle = '#06D6E0'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||
const midA2 = normDown + theta2Rad / 2;
|
||
ctx.fillText('θ₂=' + angle2Deg.toFixed(1) + '°', hitX + (arcR * 0.8 + 20) * Math.cos(midA2), hitY + (arcR * 0.8 + 20) * Math.sin(midA2));
|
||
}
|
||
}
|
||
}
|
||
|
||
_drawMediumLabels(ctx, W, H, midY) {
|
||
ctx.font = '13px Manrope, system-ui, sans-serif'; ctx.textBaseline = 'middle';
|
||
ctx.fillStyle = 'rgba(155,93,229,0.6)'; ctx.textAlign = 'left';
|
||
ctx.fillText('n₁ = ' + this.n1.toFixed(2), 16, midY - 30);
|
||
ctx.fillStyle = 'rgba(6,214,224,0.6)';
|
||
ctx.fillText('n₂ = ' + this.n2.toFixed(2), 16, midY + 30);
|
||
const theta1Rad = this.angle * Math.PI / 180;
|
||
const sinT2 = (this.n1 / this.n2) * Math.sin(theta1Rad);
|
||
if (Math.abs(sinT2) > 1) {
|
||
ctx.font = 'bold 14px Manrope, system-ui, sans-serif'; ctx.fillStyle = '#EF476F'; ctx.textAlign = 'center';
|
||
ctx.fillText('Полное внутреннее отражение (ПВО)', W / 2, midY + 60);
|
||
}
|
||
}
|
||
|
||
_drawInfoBox(ctx, isTIR, R) {
|
||
const boxW = 220, boxH = 72, bx = this.W - boxW - 12, by = 12;
|
||
ctx.fillStyle = 'rgba(22,22,38,0.85)'; ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill();
|
||
ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
|
||
ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.fillText('n₁·sin(θ₁) = n₂·sin(θ₂)', bx + 10, by + 10);
|
||
const info = this.info();
|
||
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
||
ctx.fillText('θ₁ = ' + info.angle1 + '° θ₂ = ' + (info.isTIR ? 'ПВО' : info.angle2 + '°'), bx + 10, by + 28);
|
||
const rPct = (R * 100).toFixed(1), tPct = ((1 - R) * 100).toFixed(1);
|
||
ctx.fillStyle = '#EF476F'; ctx.fillText('R = ' + rPct + '%', bx + 10, by + 46);
|
||
ctx.fillStyle = '#06D6E0'; ctx.fillText('T = ' + (isTIR ? '0' : tPct) + '%', bx + 90, by + 46);
|
||
if (info.criticalAngle !== null) { ctx.fillStyle = '#FFD166'; ctx.fillText('θc = ' + info.criticalAngle + '°', bx + 160, by + 46); }
|
||
}
|
||
|
||
_bindEvents() {
|
||
const cv = this.canvas;
|
||
const getPos = (e) => {
|
||
const r = cv.getBoundingClientRect();
|
||
const t = e.touches ? e.touches[0] : e;
|
||
return { mx: (t.clientX - r.left) * (this.W / r.width), my: (t.clientY - r.top) * (this.H / r.height) };
|
||
};
|
||
const hitTest = (mx, my) => {
|
||
const hitX = this.W / 2, hitY = this.H / 2;
|
||
if (my >= hitY) return false;
|
||
const dist = Math.hypot(mx - hitX, my - hitY);
|
||
return dist > 20 && dist < Math.max(this.W, this.H) * 0.6;
|
||
};
|
||
const angleFromMouse = (mx, my) => {
|
||
const hitX = this.W / 2, hitY = this.H / 2;
|
||
const dx = mx - hitX, dy = hitY - my;
|
||
return Math.max(0, Math.min(89, Math.atan2(Math.abs(dx), dy) * 180 / Math.PI));
|
||
};
|
||
const onDown = (e) => { const { mx, my } = getPos(e); if (hitTest(mx, my)) this._drag = true; };
|
||
const onMove = (e) => {
|
||
if (!this._drag) return;
|
||
if (e.cancelable) e.preventDefault();
|
||
const { mx, my } = getPos(e);
|
||
this.angle = angleFromMouse(mx, my);
|
||
this.draw(); this._emit();
|
||
};
|
||
const onUp = () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = false; };
|
||
cv.addEventListener('mousedown', onDown);
|
||
window.addEventListener('mousemove', onMove);
|
||
window.addEventListener('mouseup', onUp);
|
||
cv.addEventListener('touchstart', e => { if (e.touches.length === 1) onDown(e); }, { passive: true });
|
||
cv.addEventListener('touchmove', e => onMove(e), { passive: false });
|
||
cv.addEventListener('touchend', onUp);
|
||
cv.addEventListener('mousemove', e => {
|
||
if (this._drag) { cv.style.cursor = 'grabbing'; return; }
|
||
const { mx, my } = getPos(e);
|
||
cv.style.cursor = hitTest(mx, my) ? 'grab' : 'default';
|
||
});
|
||
}
|
||
}
|
||
|
||
/* ─────────────────────────────────────────────────────────────
|
||
4. FREE-BUILD MULTI-LENS SIM (Agent OB-A3)
|
||
Cascaded image formation — each lens treats the previous
|
||
image as its object. Two-lens effective focal length is
|
||
F = f1*f2 / (f1+f2-d).
|
||
───────────────────────────────────────────────────────────────*/
|
||
class FreeBuildSim {
|
||
constructor(canvas) {
|
||
this.canvas = canvas;
|
||
this.ctx = canvas.getContext('2d');
|
||
this.W = 0; this.H = 0;
|
||
/* elements: { x_frac, f, id } sorted left→right at draw time */
|
||
this.elements = [
|
||
{ x_frac: 0.30, f: 120, id: 1 },
|
||
{ x_frac: 0.65, f: 90, id: 2 },
|
||
];
|
||
this.objFrac = 0.10;
|
||
this.objH = 60;
|
||
this._drag = null;
|
||
this.onUpdate = null;
|
||
this._bindEvents();
|
||
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
|
||
}
|
||
|
||
fit() {
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const w = this.canvas.offsetWidth || 600;
|
||
const h = this.canvas.offsetHeight || 400;
|
||
this.canvas.width = w * dpr;
|
||
this.canvas.height = h * dpr;
|
||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
this.W = w; this.H = h;
|
||
}
|
||
|
||
/* Returns { stages, totalM, sysFocal, elems } */
|
||
_computeChain() {
|
||
const W = this.W;
|
||
const elems = [...this.elements].sort((a, b) => a.x_frac - b.x_frac);
|
||
let curX = this.objFrac * W;
|
||
let curH = this.objH;
|
||
const stages = [];
|
||
let totalM = 1;
|
||
for (const el of elems) {
|
||
const lensX = el.x_frac * W;
|
||
const dO = lensX - curX;
|
||
if (Math.abs(dO) < 1) continue;
|
||
const f = el.f;
|
||
const denom = dO - f;
|
||
let dI, imgH, M;
|
||
if (Math.abs(denom) < 0.5) {
|
||
dI = Infinity; imgH = Infinity; M = Infinity;
|
||
} else {
|
||
dI = (f * dO) / denom;
|
||
M = -(dI / dO);
|
||
imgH = M * curH;
|
||
}
|
||
const imgX = lensX + dI;
|
||
const isVirt = isFinite(dI) && dI < 0;
|
||
stages.push({ objX: curX, objH: curH, lensX, f, imgX, imgH, dO, dI, M, isVirt, el });
|
||
totalM *= (isFinite(M) ? M : 1);
|
||
curX = isFinite(imgX) ? imgX : lensX + 1;
|
||
curH = isFinite(imgH) ? imgH : curH;
|
||
}
|
||
let sysFocal = null;
|
||
if (elems.length === 2) {
|
||
const f1 = elems[0].f, f2 = elems[1].f;
|
||
const d = (elems[1].x_frac - elems[0].x_frac) * W;
|
||
const dn = f1 + f2 - d;
|
||
if (Math.abs(dn) > 0.5) sysFocal = (f1 * f2) / dn;
|
||
}
|
||
return { stages, totalM, sysFocal, elems };
|
||
}
|
||
|
||
draw() {
|
||
const { ctx, W, H } = this;
|
||
if (!W || !H) return;
|
||
const ay = H / 2;
|
||
ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H);
|
||
// axis
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1; ctx.setLineDash([6, 4]);
|
||
ctx.beginPath(); ctx.moveTo(0, ay); ctx.lineTo(W, ay); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
|
||
const { stages, totalM, sysFocal, elems } = this._computeChain();
|
||
const objX = this.objFrac * W;
|
||
// object arrow
|
||
this._fbArrow(ctx, objX, ay, objX, ay - this.objH, '#9B5DE5', false);
|
||
// lenses + focal markers
|
||
elems.forEach(el => {
|
||
const lx = el.x_frac * W;
|
||
this._fbLens(ctx, lx, ay, el.f);
|
||
const fp = lx + el.f;
|
||
if (fp > 10 && fp < W - 10) {
|
||
ctx.fillStyle = 'rgba(6,214,224,0.5)'; ctx.beginPath(); ctx.arc(fp, ay, 3.5, 0, Math.PI * 2); ctx.fill();
|
||
}
|
||
});
|
||
// chain rays
|
||
this._drawChainRays(ctx, ay, stages);
|
||
// intermediate image arrows
|
||
const imgPal = ['#EF476F', '#FFD166', '#7BF5A4', '#06D6E0'];
|
||
stages.forEach((s, i) => {
|
||
if (!isFinite(s.dI)) return;
|
||
const col = imgPal[i % imgPal.length];
|
||
this._fbArrow(ctx, s.imgX, ay, s.imgX, ay - s.imgH, col, s.isVirt);
|
||
ctx.font = '10px Manrope, system-ui, sans-serif';
|
||
ctx.fillStyle = col; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
|
||
ctx.fillText('Изобр.' + (i + 1), s.imgX, ay - Math.abs(s.imgH) - 6);
|
||
});
|
||
this._drawInfoPanel(ctx, stages, totalM, sysFocal);
|
||
// OB_FX visual depth layer
|
||
{
|
||
const objX = this.objFrac * this.W;
|
||
_drawOBFXLayer(ctx, 'freebuild', { srcX: objX, srcY: ay - this.objH });
|
||
}
|
||
}
|
||
|
||
/* Trace 3 characteristic rays through all lenses (thin-lens matrix method) */
|
||
_drawChainRays(ctx, ay, stages) {
|
||
if (!stages.length) return;
|
||
const colors = ['#06D6E0', '#7BF5A4', '#FFD166'];
|
||
const s0 = stages[0];
|
||
const W = this.W;
|
||
const rays = [
|
||
{ curY: ay - s0.objH, slope: 0 }, // parallel to axis
|
||
{ curY: ay - s0.objH, slope: (ay - (ay - s0.objH)) / (s0.lensX - s0.objX) }, // through lens centre
|
||
{ curY: ay - s0.objH, slope: (ay - (ay - s0.objH)) / ((s0.lensX - s0.f) - s0.objX) }, // through F
|
||
];
|
||
rays.forEach((ray, ri) => {
|
||
const col = colors[ri];
|
||
const doGlow = (fn) => {
|
||
if (window.LabFX) LabFX.glow.drawGlow(ctx, fn, { color: col, intensity: 6 });
|
||
else fn();
|
||
};
|
||
doGlow(() => {
|
||
ctx.strokeStyle = col; ctx.lineWidth = 1.4; ctx.setLineDash([]);
|
||
let curX = s0.objX;
|
||
let curY = ray.curY;
|
||
let slope = isFinite(ray.slope) ? ray.slope : 0;
|
||
ctx.beginPath(); ctx.moveTo(curX, curY);
|
||
stages.forEach(st => {
|
||
const lx = st.lensX;
|
||
const hitY = curY + slope * (lx - curX);
|
||
ctx.lineTo(lx, hitY);
|
||
// thin-lens refraction: slope_out = slope_in - (hitY - ay) / f
|
||
const hRel = hitY - ay;
|
||
slope = slope - hRel / st.f;
|
||
curX = lx;
|
||
curY = hitY;
|
||
});
|
||
const extX = Math.min(W + 10, curX + 400);
|
||
ctx.lineTo(extX, curY + slope * (extX - curX));
|
||
ctx.stroke();
|
||
});
|
||
});
|
||
}
|
||
|
||
_fbLens(ctx, lx, ay, f) {
|
||
const lh = Math.min(this.H * 0.36, 130);
|
||
const conv = f > 0;
|
||
ctx.strokeStyle = 'rgba(155,93,229,0.8)'; ctx.lineWidth = 2.5;
|
||
if (conv) {
|
||
const b = Math.min(16, Math.abs(f) * 0.1);
|
||
ctx.beginPath(); ctx.moveTo(lx, ay - lh); ctx.quadraticCurveTo(lx + b, ay, lx, ay + lh); ctx.stroke();
|
||
ctx.beginPath(); ctx.moveTo(lx, ay - lh); ctx.quadraticCurveTo(lx - b, ay, lx, ay + lh); ctx.stroke();
|
||
} else {
|
||
const b = Math.min(12, Math.abs(f) * 0.08);
|
||
ctx.beginPath(); ctx.moveTo(lx, ay - lh); ctx.quadraticCurveTo(lx - b, ay, lx, ay + lh); ctx.stroke();
|
||
ctx.beginPath(); ctx.moveTo(lx, ay - lh); ctx.quadraticCurveTo(lx + b, ay, lx, ay + lh); ctx.stroke();
|
||
}
|
||
ctx.strokeStyle = 'rgba(155,93,229,0.25)'; ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.moveTo(lx, ay - lh); ctx.lineTo(lx, ay + lh); ctx.stroke();
|
||
// label f value
|
||
ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.fillStyle = 'rgba(155,93,229,0.9)';
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
||
ctx.fillText('f=' + f.toFixed(0), lx, ay + lh + 4);
|
||
}
|
||
|
||
_fbArrow(ctx, x1, y1, x2, y2, color, dashed) {
|
||
ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 2.5;
|
||
if (dashed) ctx.setLineDash([5, 4]);
|
||
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
if (Math.abs(y2 - y1) < 2) return;
|
||
const angle = Math.atan2(y2 - y1, x2 - x1), aLen = 9;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x2, y2);
|
||
ctx.lineTo(x2 - aLen * Math.cos(angle - 0.35), y2 - aLen * Math.sin(angle - 0.35));
|
||
ctx.lineTo(x2 - aLen * Math.cos(angle + 0.35), y2 - aLen * Math.sin(angle + 0.35));
|
||
ctx.closePath(); ctx.fill();
|
||
}
|
||
|
||
_drawInfoPanel(ctx, stages, totalM, sysFocal) {
|
||
const bx = 8, by = 8, bw = 192, lh = 16;
|
||
const rows = [{ text: 'Объект', col: '#9B5DE5' }];
|
||
stages.forEach((s, i) => {
|
||
rows.push({ text: 'Лин.' + (i + 1) + ' f=' + s.f.toFixed(0), col: 'rgba(155,93,229,0.85)' });
|
||
const dpStr = isFinite(s.dI) ? s.dI.toFixed(0) : '∞';
|
||
const mStr = isFinite(s.M) ? s.M.toFixed(2) : '∞';
|
||
rows.push({ text: " Изобр." + (i + 1) + " d'=" + dpStr + ' M=' + mStr, col: isFinite(s.M) ? (s.isVirt ? '#FFD166' : '#EF476F') : '#888' });
|
||
});
|
||
const bh = rows.length * lh + 46;
|
||
ctx.save();
|
||
ctx.fillStyle = 'rgba(22,22,38,0.88)';
|
||
ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 8); ctx.fill();
|
||
ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
|
||
let cy = by + 8;
|
||
rows.forEach(r => { ctx.fillStyle = r.col; ctx.fillText(r.text, bx + 8, cy); cy += lh; });
|
||
cy += 4;
|
||
ctx.fillStyle = 'rgba(255,255,255,0.75)';
|
||
ctx.fillText('Г = ' + (isFinite(totalM) ? totalM.toFixed(3) : '∞'), bx + 8, cy); cy += lh;
|
||
if (sysFocal !== null) {
|
||
ctx.fillStyle = '#06D6E0';
|
||
ctx.fillText('F сист. = ' + sysFocal.toFixed(0) + ' px', bx + 8, cy);
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
_bindEvents() {
|
||
const cv = this.canvas;
|
||
const getPos = (e) => {
|
||
const r = cv.getBoundingClientRect();
|
||
const t = e.touches ? e.touches[0] : e;
|
||
return { mx: (t.clientX - r.left) * (this.W / r.width), my: (t.clientY - r.top) * (this.H / r.height) };
|
||
};
|
||
const hitTest = (mx, my) => {
|
||
const ay = this.H / 2;
|
||
for (let i = 0; i < this.elements.length; i++) {
|
||
const lx = this.elements[i].x_frac * this.W;
|
||
if (Math.abs(mx - lx) < 14 && Math.abs(my - ay) < 100) return { what: 'lens', idx: i };
|
||
}
|
||
const ox = this.objFrac * this.W;
|
||
if (Math.hypot(mx - ox, my - (ay - this.objH)) < 18) return { what: 'object' };
|
||
return null;
|
||
};
|
||
cv.addEventListener('mousedown', e => { const p = getPos(e); this._drag = hitTest(p.mx, p.my); });
|
||
window.addEventListener('mousemove', e => {
|
||
if (!this._drag) return;
|
||
if (e.cancelable) e.preventDefault();
|
||
const { mx } = getPos(e);
|
||
const frac = Math.max(0.02, Math.min(0.98, mx / this.W));
|
||
if (this._drag.what === 'object') { this.objFrac = frac; }
|
||
else if (this._drag.what === 'lens') { this.elements[this._drag.idx].x_frac = frac; }
|
||
this.draw();
|
||
if (this.onUpdate) this.onUpdate(this._computeChain());
|
||
});
|
||
window.addEventListener('mouseup', () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = null; });
|
||
cv.addEventListener('mousemove', e => {
|
||
if (this._drag) { cv.style.cursor = 'grabbing'; return; }
|
||
const { mx, my } = getPos(e);
|
||
cv.style.cursor = hitTest(mx, my) ? 'grab' : 'default';
|
||
});
|
||
cv.addEventListener('touchstart', e => {
|
||
if (e.touches.length === 1) { const p = getPos(e); this._drag = hitTest(p.mx, p.my); }
|
||
}, { passive: true });
|
||
cv.addEventListener('touchmove', e => {
|
||
if (!this._drag) return;
|
||
if (e.cancelable) e.preventDefault();
|
||
const { mx } = getPos(e);
|
||
const frac = Math.max(0.02, Math.min(0.98, mx / this.W));
|
||
if (this._drag.what === 'object') this.objFrac = frac;
|
||
else if (this._drag.what === 'lens') this.elements[this._drag.idx].x_frac = frac;
|
||
this.draw();
|
||
}, { passive: false });
|
||
cv.addEventListener('touchend', () => { this._drag = null; });
|
||
}
|
||
|
||
addLens(f) {
|
||
const lastFrac = this.elements.length ? Math.max(...this.elements.map(e => e.x_frac)) : 0.5;
|
||
const newFrac = Math.min(0.92, lastFrac + 0.18);
|
||
const id = (this.elements.reduce((m, e) => Math.max(m, e.id), 0)) + 1;
|
||
this.elements.push({ x_frac: newFrac, f: +f || 100, id });
|
||
this.draw();
|
||
}
|
||
removeLens() {
|
||
if (this.elements.length > 1) this.elements.pop();
|
||
this.draw();
|
||
}
|
||
setLensF(idx, f) {
|
||
if (this.elements[idx]) { this.elements[idx].f = Math.max(-300, Math.min(300, +f || 100)); this.draw(); }
|
||
}
|
||
}
|
||
|
||
/* ─────────────────────────────────────────────────────────────
|
||
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
|
||
this.source = { kind: 'object', xf: 0.07, h: 70, spread: 0.32, rays: 9 };
|
||
// 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 = null;
|
||
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 };
|
||
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') ? val : +val;
|
||
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; }
|
||
|
||
/* Emit the initial rays from the source. */
|
||
_emitRays() {
|
||
const ay = this._ay();
|
||
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) => {
|
||
for (const wl of wls) rays.push({ x, y, dx: Math.cos(ang), dy: Math.sin(ang), wl, pts: [{ x, y }], alive: true, bounces: 0 });
|
||
};
|
||
if (this.source.kind === 'parallel') {
|
||
const n = 9, hh = 90;
|
||
for (let i = 0; i < n; i++) {
|
||
const y = ay - hh + (2 * hh) * (i / (n - 1));
|
||
push(sx, y, 0);
|
||
}
|
||
} 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, -A + 2 * A * (i / (n - 1)));
|
||
} else { // object arrow: fan from tip and base
|
||
const n = this.source.rays, A = this.source.spread;
|
||
[ay - this.source.h, ay].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) return false; // outside aperture → pass
|
||
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 === 'mirror') {
|
||
if (Math.abs(yRel) > el.ap) return false;
|
||
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.alive = false; // absorbed, hit recorded
|
||
return true;
|
||
}
|
||
if (el.type === 'prism') {
|
||
return this._prismInteract(ray, el, yRel);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// Thin-prism deviation δ = (n−1)·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);
|
||
|
||
if (typeof _drawOBFXLayer === 'function') {
|
||
_drawOBFXLayer(ctx, 'freebuild', { srcX: this.source.xf * W, srcY: ay - (this.source.h || 0) });
|
||
}
|
||
}
|
||
|
||
_drawSource(ctx, ay) {
|
||
const sx = this.source.xf * this.W;
|
||
ctx.save();
|
||
if (this.source.kind === 'object') {
|
||
this._arrow(ctx, sx, ay, sx, ay - this.source.h, '#9B5DE5');
|
||
} else {
|
||
ctx.fillStyle = this.source.kind === 'point' ? '#FFD166' : '#9B5DE5';
|
||
ctx.beginPath(); ctx.arc(sx, ay, 5, 0, Math.PI * 2); ctx.fill();
|
||
if (this.source.kind === 'parallel') {
|
||
ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 2;
|
||
ctx.beginPath(); ctx.moveTo(sx, ay - 90); ctx.lineTo(sx, ay + 90); ctx.stroke();
|
||
}
|
||
}
|
||
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();
|
||
});
|
||
this._elLabel(ctx, x, ay + el.ap + 14, (conv ? 'линза +' : 'линза −') + Math.abs(el.f).toFixed(0));
|
||
} 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();
|
||
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 };
|
||
}
|
||
const sx = this.source.xf * this.W;
|
||
if (Math.abs(mx - sx) < 16 && Math.abs(my - ay) < 120) return { kind: 'src' };
|
||
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 } = pos(e);
|
||
const xf = Math.max(0.02, Math.min(0.98, mx / this.W));
|
||
if (this._drag.kind === 'src') this.source.xf = xf;
|
||
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 (_) {} });
|
||
}
|
||
|
||
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 'Призма';
|
||
return e.type;
|
||
}
|
||
function _benchRow(label, html) {
|
||
return '<div class="proj-slider-row" style="margin-bottom:6px"><label style="font-size:.74rem;color:#ccc;width:78px">' + label + '</label>' + html + '</div>';
|
||
}
|
||
function _benchSlider(id, key, min, max, step, val) {
|
||
return '<input type="range" min="' + min + '" max="' + max + '" step="' + step + '" value="' + val +
|
||
'" oninput="benchUpdate(' + id + ',\'' + key + '\',this.value)" style="flex:1">';
|
||
}
|
||
function _benchPropsHTML() {
|
||
if (!benchSim) return '';
|
||
const sel = benchSim.selectedId;
|
||
if (sel === '__src') {
|
||
const s = benchSim.source;
|
||
let h = '<div class="gp-section-title" style="margin:4px 0 6px">Источник</div>';
|
||
h += '<div style="display:flex;gap:3px;margin-bottom:6px">' +
|
||
['object:Предмет', 'point:Точка', 'parallel:Параллель'].map(o => {
|
||
const [k, lbl] = o.split(':');
|
||
return '<button class="preset-btn' + (s.kind === k ? ' active' : '') + '" style="flex:1;font-size:.68rem" onclick="benchSourceKind(\'' + k + '\')">' + lbl + '</button>';
|
||
}).join('') + '</div>';
|
||
if (s.kind === 'object') h += _benchRow('Высота', _benchSourceSlider('h', 20, 120, 2, s.h));
|
||
if (s.kind !== 'parallel') h += _benchRow('Раствор', _benchSourceSlider('spread', 0.1, 0.6, 0.02, s.spread));
|
||
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 += _benchRow('f, px', _benchSlider(e.id, 'f', -300, 300, 5, e.f));
|
||
h += _benchRow('Апертура', _benchSlider(e.id, 'ap', 30, 130, 5, e.ap));
|
||
} else if (e.type === 'mirror') {
|
||
h += '<div style="display:flex;gap:3px;margin-bottom:6px">' +
|
||
['plane:Плоск', 'concave:Вогн', 'convex:Выпукл'].map(o => {
|
||
const [k, lbl] = o.split(':');
|
||
return '<button class="preset-btn' + (e.kind === k ? ' active' : '') + '" style="flex:1;font-size:.68rem" onclick="benchUpdate(' + e.id + ',\'kind\',\'' + k + '\');_benchUpdateUI()">' + lbl + '</button>';
|
||
}).join('') + '</div>';
|
||
if (e.kind !== 'plane') h += _benchRow('R, px', _benchSlider(e.id, 'R', 100, 600, 10, e.R));
|
||
h += _benchRow('Апертура', _benchSlider(e.id, 'ap', 30, 130, 5, e.ap));
|
||
} else if (e.type === 'aperture') {
|
||
h += _benchRow('Зазор', _benchSlider(e.id, 'gap', 5, 110, 2, e.gap));
|
||
} else if (e.type === 'prism') {
|
||
h += _benchRow('Угол', _benchSlider(e.id, 'apex', 20, 70, 1, e.apex));
|
||
h += _benchRow('n', _benchSlider(e.id, 'n', 1.3, 1.9, 0.01, e.n));
|
||
h += _benchRow('Размер', _benchSlider(e.id, 'size', 50, 130, 5, e.size));
|
||
} 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 _benchSourceSlider(key, min, max, step, val) {
|
||
return '<input type="range" min="' + min + '" max="' + max + '" step="' + step + '" value="' + val +
|
||
'" oninput="benchSourceParam(\'' + key + '\',this.value)" style="flex:1">';
|
||
}
|
||
function _benchUpdateUI() {
|
||
if (!benchSim) return;
|
||
const listEl = document.getElementById('bench-list');
|
||
if (listEl) {
|
||
listEl.innerHTML = 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('') || '<div class="pp-hint">Пусто</div>';
|
||
}
|
||
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 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();
|
||
}
|