diff --git a/frontend/css/lab.css b/frontend/css/lab.css index 4ebe575..e6670bd 100644 --- a/frontend/css/lab.css +++ b/frontend/css/lab.css @@ -428,6 +428,17 @@ } .proj-preset-chip:hover { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,.06); } + /* opticsbench instrument preset chips — reuse proj-preset-chip style */ + .ob-preset-chip { + padding: 4px 10px; border-radius: 7px; font-size: 0.72rem; font-weight: 700; + border: 1.5px solid var(--border-h); background: transparent; + color: var(--text-2); cursor: pointer; transition: all .15s; white-space: nowrap; + } + .ob-preset-chip:hover { border-color: var(--cyan); color: var(--cyan); background: rgba(103,232,249,.07); } + .ob-preset-chip.active { border-color: var(--cyan); color: var(--cyan); background: rgba(103,232,249,.12); } + .ob-preset-chip.ob-preset-clear { border-color: #333; color: #666; } + .ob-preset-chip.ob-preset-clear:hover { border-color: #EF476F; color: #EF476F; background: rgba(239,71,111,.07); } + /* stats bar */ .proj-stats-bar { flex-shrink: 0; display: flex; align-items: stretch; @@ -1477,3 +1488,27 @@ canvas[data-draggable]:active { cursor: grabbing; } #lab-sim { view-transition-name: sim-view; } + +/* ═══ Optical Bench — wavelength slider ═══ */ +#ob-wavelength-bar { + min-height: 34px; +} +#ob-wavelength-bar input[type=range] { + height: 4px; + background: linear-gradient(to right, + #8000ff 0%, #0000ff 10%, #00ffff 30%, + #00ff00 50%, #ffff00 65%, #ff8000 80%, #ff0000 100%); + border-radius: 2px; + outline: none; + cursor: pointer; +} + +/* ═══ Spectrometer panel ═══ */ +#ob-spectrometer-panel { + overflow: hidden; +} +#ob-spectrometer-canvas { + border: 1px solid #1a1a2e; + border-radius: 4px; + background: #0a0a18; +} diff --git a/frontend/js/labs/opticsbench.js b/frontend/js/labs/opticsbench.js index 0056e40..3412ba9 100644 --- a/frontend/js/labs/opticsbench.js +++ b/frontend/js/labs/opticsbench.js @@ -11,6 +11,56 @@ 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' }, +]; + /* ───────────────────────────────────────────────────────────── 1. THIN LENS ENGINE (from thinlens.js) ───────────────────────────────────────────────────────────────*/ @@ -27,6 +77,20 @@ class ThinLensSim { 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); } @@ -56,6 +120,112 @@ class ThinLensSim { 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; @@ -96,7 +266,9 @@ class ThinLensSim { ctx.beginPath(); ctx.moveTo(0, axisY); ctx.lineTo(W, axisY); ctx.stroke(); ctx.setLineDash([]); - this._drawLens(ctx, lensX, axisY, f); + // 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; @@ -111,16 +283,26 @@ class ThinLensSim { hPrime = (-dPrime / d) * h; } - this._drawRays(ctx, lensX, axisY, d, h, f, dPrime, hPrime); + /* 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 (dPrime !== null && isFinite(dPrime)) { + 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 ? '#FFD166' : '#EF476F', isVirtual); + isVirtual ? 'rgba(255,133,162,0.9)' : '#06D6E0', isVirtual); } this._drawLabels(ctx, lensX, axisY, d, f, dPrime, hPrime); + this._drawArrowLabels(ctx, lensX, axisY, d, h, dPrime, hPrime); + this._drawLMInfo(ctx, lensX, axisY); // Lens caustics: emit dust near focal point when image exists if (window.LabFX && dPrime !== null && isFinite(dPrime) && dPrime > 0) { @@ -199,8 +381,30 @@ class ThinLensSim { } _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 = ['#06D6E0', '#7BF5A4', '#FFD166']; + const colors = [_monoColor, _monoColor, _monoColor]; const hasImage = dPrime !== null && isFinite(dPrime); const isVirtual = hasImage && dPrime < 0; ctx.lineWidth = 1.5; @@ -258,6 +462,34 @@ class ThinLensSim { }); } + /** 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; @@ -266,6 +498,123 @@ class ThinLensSim { 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; @@ -291,6 +640,80 @@ class ThinLensSim { 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) => { @@ -349,13 +772,14 @@ class MirrorSim { 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._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; @@ -374,6 +798,11 @@ class MirrorSim { 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); } @@ -412,7 +841,7 @@ class MirrorSim { 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' }; + 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(); @@ -601,7 +1030,22 @@ class MirrorSim { 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); @@ -717,8 +1161,12 @@ class MirrorSim { 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); - const COLS = ['#06D6E0','#7BF5A4','#FFD166']; - const FAN = 'rgba(255,255,255,0.18)'; + // 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) => { @@ -779,6 +1227,55 @@ class MirrorSim { 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; @@ -1052,6 +1549,65 @@ class MirrorSim { 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 => { @@ -1240,10 +1796,34 @@ class RefractionSim { } _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; - this._drawRay(ctx, incStartX, incStartY, hitX, hitY, '#9B5DE5', 2.5); - this._drawArrowhead(ctx, hitX, hitY, Math.atan2(hitY - incStartY, hitX - incStartX), '#9B5DE5'); + 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)); @@ -1257,8 +1837,9 @@ class RefractionSim { const refracEndX = hitX + refracDx * rayLen, refracEndY = hitY + refracDy * rayLen; const T = 1 - R; ctx.globalAlpha = Math.max(0.3, Math.sqrt(T)); - this._drawRay(ctx, hitX, hitY, refracEndX, refracEndY, '#06D6E0', 2.5); - this._drawArrowhead(ctx, refracEndX, refracEndY, Math.atan2(refracEndY - hitY, refracEndX - hitX), '#06D6E0'); + // 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; } } @@ -1406,14 +1987,555 @@ class RefractionSim { } /* ───────────────────────────────────────────────────────────── - 4. LAB UI INIT — Оптическая скамья + 4. FREE-BUILD MULTI-LENS SIM (Agent OB-A3) + Cascaded image formation — each lens treats the previous + image as its object. Two-lens effective focal length is + F = f1*f2 / (f1+f2-d). +───────────────────────────────────────────────────────────────*/ +class FreeBuildSim { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.W = 0; this.H = 0; + /* elements: { x_frac, f, id } sorted left→right at draw time */ + this.elements = [ + { x_frac: 0.30, f: 120, id: 1 }, + { x_frac: 0.65, f: 90, id: 2 }, + ]; + this.objFrac = 0.10; + this.objH = 60; + this._drag = null; + this.onUpdate = null; + this._bindEvents(); + new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); + } + + fit() { + const dpr = window.devicePixelRatio || 1; + const w = this.canvas.offsetWidth || 600; + const h = this.canvas.offsetHeight || 400; + this.canvas.width = w * dpr; + this.canvas.height = h * dpr; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.W = w; this.H = h; + } + + /* Returns { stages, totalM, sysFocal, elems } */ + _computeChain() { + const W = this.W; + const elems = [...this.elements].sort((a, b) => a.x_frac - b.x_frac); + let curX = this.objFrac * W; + let curH = this.objH; + const stages = []; + let totalM = 1; + for (const el of elems) { + const lensX = el.x_frac * W; + const dO = lensX - curX; + if (Math.abs(dO) < 1) continue; + const f = el.f; + const denom = dO - f; + let dI, imgH, M; + if (Math.abs(denom) < 0.5) { + dI = Infinity; imgH = Infinity; M = Infinity; + } else { + dI = (f * dO) / denom; + M = -(dI / dO); + imgH = M * curH; + } + const imgX = lensX + dI; + const isVirt = isFinite(dI) && dI < 0; + stages.push({ objX: curX, objH: curH, lensX, f, imgX, imgH, dO, dI, M, isVirt, el }); + totalM *= (isFinite(M) ? M : 1); + curX = isFinite(imgX) ? imgX : lensX + 1; + curH = isFinite(imgH) ? imgH : curH; + } + let sysFocal = null; + if (elems.length === 2) { + const f1 = elems[0].f, f2 = elems[1].f; + const d = (elems[1].x_frac - elems[0].x_frac) * W; + const dn = f1 + f2 - d; + if (Math.abs(dn) > 0.5) sysFocal = (f1 * f2) / dn; + } + return { stages, totalM, sysFocal, elems }; + } + + draw() { + const { ctx, W, H } = this; + if (!W || !H) return; + const ay = H / 2; + ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H); + // axis + ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1; ctx.setLineDash([6, 4]); + ctx.beginPath(); ctx.moveTo(0, ay); ctx.lineTo(W, ay); ctx.stroke(); + ctx.setLineDash([]); + + const { stages, totalM, sysFocal, elems } = this._computeChain(); + const objX = this.objFrac * W; + // object arrow + this._fbArrow(ctx, objX, ay, objX, ay - this.objH, '#9B5DE5', false); + // lenses + focal markers + elems.forEach(el => { + const lx = el.x_frac * W; + this._fbLens(ctx, lx, ay, el.f); + const fp = lx + el.f; + if (fp > 10 && fp < W - 10) { + ctx.fillStyle = 'rgba(6,214,224,0.5)'; ctx.beginPath(); ctx.arc(fp, ay, 3.5, 0, Math.PI * 2); ctx.fill(); + } + }); + // chain rays + this._drawChainRays(ctx, ay, stages); + // intermediate image arrows + const imgPal = ['#EF476F', '#FFD166', '#7BF5A4', '#06D6E0']; + stages.forEach((s, i) => { + if (!isFinite(s.dI)) return; + const col = imgPal[i % imgPal.length]; + this._fbArrow(ctx, s.imgX, ay, s.imgX, ay - s.imgH, col, s.isVirt); + ctx.font = '10px Manrope, system-ui, sans-serif'; + ctx.fillStyle = col; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; + ctx.fillText('Изобр.' + (i + 1), s.imgX, ay - Math.abs(s.imgH) - 6); + }); + this._drawInfoPanel(ctx, stages, totalM, sysFocal); + } + + /* Trace 3 characteristic rays through all lenses (thin-lens matrix method) */ + _drawChainRays(ctx, ay, stages) { + if (!stages.length) return; + const colors = ['#06D6E0', '#7BF5A4', '#FFD166']; + const s0 = stages[0]; + const W = this.W; + const rays = [ + { curY: ay - s0.objH, slope: 0 }, // parallel to axis + { curY: ay - s0.objH, slope: (ay - (ay - s0.objH)) / (s0.lensX - s0.objX) }, // through lens centre + { curY: ay - s0.objH, slope: (ay - (ay - s0.objH)) / ((s0.lensX - s0.f) - s0.objX) }, // through F + ]; + rays.forEach((ray, ri) => { + const col = colors[ri]; + const doGlow = (fn) => { + if (window.LabFX) LabFX.glow.drawGlow(ctx, fn, { color: col, intensity: 6 }); + else fn(); + }; + doGlow(() => { + ctx.strokeStyle = col; ctx.lineWidth = 1.4; ctx.setLineDash([]); + let curX = s0.objX; + let curY = ray.curY; + let slope = isFinite(ray.slope) ? ray.slope : 0; + ctx.beginPath(); ctx.moveTo(curX, curY); + stages.forEach(st => { + const lx = st.lensX; + const hitY = curY + slope * (lx - curX); + ctx.lineTo(lx, hitY); + // thin-lens refraction: slope_out = slope_in - (hitY - ay) / f + const hRel = hitY - ay; + slope = slope - hRel / st.f; + curX = lx; + curY = hitY; + }); + const extX = Math.min(W + 10, curX + 400); + ctx.lineTo(extX, curY + slope * (extX - curX)); + ctx.stroke(); + }); + }); + } + + _fbLens(ctx, lx, ay, f) { + const lh = Math.min(this.H * 0.36, 130); + const conv = f > 0; + ctx.strokeStyle = 'rgba(155,93,229,0.8)'; ctx.lineWidth = 2.5; + if (conv) { + const b = Math.min(16, Math.abs(f) * 0.1); + ctx.beginPath(); ctx.moveTo(lx, ay - lh); ctx.quadraticCurveTo(lx + b, ay, lx, ay + lh); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(lx, ay - lh); ctx.quadraticCurveTo(lx - b, ay, lx, ay + lh); ctx.stroke(); + } else { + const b = Math.min(12, Math.abs(f) * 0.08); + ctx.beginPath(); ctx.moveTo(lx, ay - lh); ctx.quadraticCurveTo(lx - b, ay, lx, ay + lh); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(lx, ay - lh); ctx.quadraticCurveTo(lx + b, ay, lx, ay + lh); ctx.stroke(); + } + ctx.strokeStyle = 'rgba(155,93,229,0.25)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(lx, ay - lh); ctx.lineTo(lx, ay + lh); ctx.stroke(); + // label f value + ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.fillStyle = 'rgba(155,93,229,0.9)'; + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillText('f=' + f.toFixed(0), lx, ay + lh + 4); + } + + _fbArrow(ctx, x1, y1, x2, y2, color, dashed) { + ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 2.5; + if (dashed) ctx.setLineDash([5, 4]); + ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); + ctx.setLineDash([]); + if (Math.abs(y2 - y1) < 2) return; + const angle = Math.atan2(y2 - y1, x2 - x1), aLen = 9; + ctx.beginPath(); + ctx.moveTo(x2, y2); + ctx.lineTo(x2 - aLen * Math.cos(angle - 0.35), y2 - aLen * Math.sin(angle - 0.35)); + ctx.lineTo(x2 - aLen * Math.cos(angle + 0.35), y2 - aLen * Math.sin(angle + 0.35)); + ctx.closePath(); ctx.fill(); + } + + _drawInfoPanel(ctx, stages, totalM, sysFocal) { + const bx = 8, by = 8, bw = 192, lh = 16; + const rows = [{ text: 'Объект', col: '#9B5DE5' }]; + stages.forEach((s, i) => { + rows.push({ text: 'Лин.' + (i + 1) + ' f=' + s.f.toFixed(0), col: 'rgba(155,93,229,0.85)' }); + const dpStr = isFinite(s.dI) ? s.dI.toFixed(0) : '∞'; + const mStr = isFinite(s.M) ? s.M.toFixed(2) : '∞'; + rows.push({ text: " Изобр." + (i + 1) + " d'=" + dpStr + ' M=' + mStr, col: isFinite(s.M) ? (s.isVirt ? '#FFD166' : '#EF476F') : '#888' }); + }); + const bh = rows.length * lh + 46; + ctx.save(); + ctx.fillStyle = 'rgba(22,22,38,0.88)'; + ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 8); ctx.fill(); + ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; + let cy = by + 8; + rows.forEach(r => { ctx.fillStyle = r.col; ctx.fillText(r.text, bx + 8, cy); cy += lh; }); + cy += 4; + ctx.fillStyle = 'rgba(255,255,255,0.75)'; + ctx.fillText('Г = ' + (isFinite(totalM) ? totalM.toFixed(3) : '∞'), bx + 8, cy); cy += lh; + if (sysFocal !== null) { + ctx.fillStyle = '#06D6E0'; + ctx.fillText('F сист. = ' + sysFocal.toFixed(0) + ' px', bx + 8, cy); + } + ctx.restore(); + } + + _bindEvents() { + const cv = this.canvas; + const getPos = (e) => { + const r = cv.getBoundingClientRect(); + const t = e.touches ? e.touches[0] : e; + return { mx: (t.clientX - r.left) * (this.W / r.width), my: (t.clientY - r.top) * (this.H / r.height) }; + }; + const hitTest = (mx, my) => { + const ay = this.H / 2; + for (let i = 0; i < this.elements.length; i++) { + const lx = this.elements[i].x_frac * this.W; + if (Math.abs(mx - lx) < 14 && Math.abs(my - ay) < 100) return { what: 'lens', idx: i }; + } + const ox = this.objFrac * this.W; + if (Math.hypot(mx - ox, my - (ay - this.objH)) < 18) return { what: 'object' }; + return null; + }; + cv.addEventListener('mousedown', e => { const p = getPos(e); this._drag = hitTest(p.mx, p.my); }); + window.addEventListener('mousemove', e => { + if (!this._drag) return; + if (e.cancelable) e.preventDefault(); + const { mx } = getPos(e); + const frac = Math.max(0.02, Math.min(0.98, mx / this.W)); + if (this._drag.what === 'object') { this.objFrac = frac; } + else if (this._drag.what === 'lens') { this.elements[this._drag.idx].x_frac = frac; } + this.draw(); + if (this.onUpdate) this.onUpdate(this._computeChain()); + }); + window.addEventListener('mouseup', () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = null; }); + cv.addEventListener('mousemove', e => { + if (this._drag) { cv.style.cursor = 'grabbing'; return; } + const { mx, my } = getPos(e); + cv.style.cursor = hitTest(mx, my) ? 'grab' : 'default'; + }); + cv.addEventListener('touchstart', e => { + if (e.touches.length === 1) { const p = getPos(e); this._drag = hitTest(p.mx, p.my); } + }, { passive: true }); + cv.addEventListener('touchmove', e => { + if (!this._drag) return; + if (e.cancelable) e.preventDefault(); + const { mx } = getPos(e); + const frac = Math.max(0.02, Math.min(0.98, mx / this.W)); + if (this._drag.what === 'object') this.objFrac = frac; + else if (this._drag.what === 'lens') this.elements[this._drag.idx].x_frac = frac; + this.draw(); + }, { passive: false }); + cv.addEventListener('touchend', () => { this._drag = null; }); + } + + addLens(f) { + const lastFrac = this.elements.length ? Math.max(...this.elements.map(e => e.x_frac)) : 0.5; + const newFrac = Math.min(0.92, lastFrac + 0.18); + const id = (this.elements.reduce((m, e) => Math.max(m, e.id), 0)) + 1; + this.elements.push({ x_frac: newFrac, f: +f || 100, id }); + this.draw(); + } + removeLens() { + if (this.elements.length > 1) this.elements.pop(); + this.draw(); + } + setLensF(idx, f) { + if (this.elements[idx]) { this.elements[idx].f = Math.max(-300, Math.min(300, +f || 100)); this.draw(); } + } +} + +/* ───────────────────────────────────────────────────────────── + 4b. PRISM ENGINE +───────────────────────────────────────────────────────────────*/ +class PrismSim { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.W = 0; this.H = 0; + this.apexAngle = 60; + this.n0 = 1.5; + this.rotation = 0; + this.incAngle = 30; + this._drag = null; + this.onUpdate = null; + this._bindEvents(); + new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); + } + + fit() { + const dpr = window.devicePixelRatio || 1; + const w = this.canvas.offsetWidth || 600; + const h = this.canvas.offsetHeight || 400; + this.canvas.width = w * dpr; + this.canvas.height = h * dpr; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.W = w; this.H = h; + } + + getParams() { return { apexAngle: this.apexAngle, n0: this.n0, rotation: this.rotation, incAngle: this.incAngle }; } + setParams({ apexAngle, n0, rotation, incAngle } = {}) { + if (apexAngle !== undefined) this.apexAngle = Math.max(20, Math.min(80, +apexAngle)); + if (n0 !== undefined) this.n0 = Math.max(1.3, Math.min(2.5, +n0)); + if (rotation !== undefined) this.rotation = +rotation % 360; + if (incAngle !== undefined) this.incAngle = Math.max(0, Math.min(80, +incAngle)); + this.draw(); this._emit(); + } + + reset() { this.apexAngle = 60; this.n0 = 1.5; this.rotation = 0; this.incAngle = 30; this.draw(); this._emit(); } + _emit() { if (this.onUpdate) this.onUpdate(this.getParams()); } + + draw() { + const ctx = this.ctx, W = this.W, H = this.H; + if (!W || !H) return; + + ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H); + ctx.strokeStyle = 'rgba(255,255,255,0.1)'; ctx.lineWidth = 1; ctx.setLineDash([6,4]); + ctx.beginPath(); ctx.moveTo(0, H/2); ctx.lineTo(W, H/2); ctx.stroke(); + ctx.setLineDash([]); + + const cx = W / 2, cy = H / 2; + const size = Math.min(W, H) * 0.30; + const rotRad = this.rotation * Math.PI / 180; + const topA = -Math.PI / 2 + rotRad; + const v = [0, 1, 2].map(i => ({ + x: cx + size * Math.cos(topA + i * (2 * Math.PI / 3)), + y: cy + size * Math.sin(topA + i * (2 * Math.PI / 3)), + })); + + // Prism body + const prismGrad = ctx.createLinearGradient(v[0].x, v[0].y, v[1].x, v[1].y); + prismGrad.addColorStop(0, 'rgba(100,180,255,0.07)'); + prismGrad.addColorStop(0.5, 'rgba(100,180,255,0.16)'); + prismGrad.addColorStop(1, 'rgba(100,180,255,0.07)'); + ctx.beginPath(); ctx.moveTo(v[0].x, v[0].y); ctx.lineTo(v[1].x, v[1].y); ctx.lineTo(v[2].x, v[2].y); ctx.closePath(); + ctx.fillStyle = prismGrad; ctx.fill(); + ctx.strokeStyle = 'rgba(100,180,255,0.5)'; ctx.lineWidth = 2; ctx.stroke(); + ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.fillStyle = 'rgba(100,180,255,0.7)'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('n = ' + this.n0.toFixed(2), cx, cy + 4); + + const isWhite = window._obWhiteLight; + const monoNm = window._obWavelength || 550; + const spectral = isWhite ? OB_SPECTRAL : [{ nm: monoNm }]; + + // Entry face: v[0] → v[2]; inward normal toward center + const efVec = { x: v[2].x - v[0].x, y: v[2].y - v[0].y }; + const efLen = Math.hypot(efVec.x, efVec.y); + let efNorm = { x: -efVec.y / efLen, y: efVec.x / efLen }; + if (efNorm.x * (cx - v[0].x) + efNorm.y * (cy - v[0].y) < 0) { + efNorm = { x: -efNorm.x, y: -efNorm.y }; + } + const eX = (v[0].x + v[2].x) / 2, eY = (v[0].y + v[2].y) / 2; + const tangDir = { x: efVec.x / efLen, y: efVec.y / efLen }; + const incRad = this.incAngle * Math.PI / 180; + const incDir = { + x: Math.cos(incRad) * (-efNorm.x) + Math.sin(incRad) * tangDir.x, + y: Math.cos(incRad) * (-efNorm.y) + Math.sin(incRad) * tangDir.y, + }; + const incDLen = Math.hypot(incDir.x, incDir.y); + incDir.x /= incDLen; incDir.y /= incDLen; + + const rayLen = W * 0.45; + this._drawRayLine(ctx, eX - incDir.x * rayLen, eY - incDir.y * rayLen, eX, eY, + isWhite ? '#FFFFFF' : wavelengthToRGB(monoNm), 2.5); + + // Exit face: v[0] → v[1]; outward normal + const exfVec = { x: v[1].x - v[0].x, y: v[1].y - v[0].y }; + const exfLen = Math.hypot(exfVec.x, exfVec.y); + let exfNorm = { x: -exfVec.y / exfLen, y: exfVec.x / exfLen }; + if (exfNorm.x * (cx - v[0].x) + exfNorm.y * (cy - v[0].y) > 0) { + exfNorm = { x: -exfNorm.x, y: -exfNorm.y }; + } + + const exitPts = []; + for (const s of spectral) { + const nP = _nAtWavelength(this.n0, s.nm); + const col = wavelengthToRGB(s.nm); + const cosI = -(incDir.x * efNorm.x + incDir.y * efNorm.y); + const sinR = Math.sqrt(Math.max(0, 1 - cosI * cosI)) / nP; + if (sinR > 1) continue; + const cosR = Math.sqrt(1 - sinR * sinR); + const rDir = { + x: (1/nP) * incDir.x + ((1/nP) * cosI - cosR) * efNorm.x, + y: (1/nP) * incDir.y + ((1/nP) * cosI - cosR) * efNorm.y, + }; + const rDLen = Math.hypot(rDir.x, rDir.y); + rDir.x /= rDLen; rDir.y /= rDLen; + + // Intersect with exit face segment v[0]→v[1] + const det = rDir.x * exfVec.y - rDir.y * exfVec.x; + if (Math.abs(det) < 0.001) continue; + const d0x = v[0].x - eX, d0y = v[0].y - eY; + const tRay = (d0x * exfVec.y - d0y * exfVec.x) / det; + const sFace = (d0x * rDir.y - d0y * rDir.x) / det; + if (tRay < 0 || sFace < -0.01 || sFace > 1.01) continue; + const exitX = eX + tRay * rDir.x, exitY = eY + tRay * rDir.y; + + ctx.globalAlpha = isWhite ? 0.65 : 1; + this._drawRayLine(ctx, eX, eY, exitX, exitY, col, isWhite ? 1.5 : 2); + ctx.globalAlpha = 1; + + // Snell at exit face: n1=nP, n2=1 (vector form) + const inward = { x: -exfNorm.x, y: -exfNorm.y }; + const cosEx = -(rDir.x * inward.x + rDir.y * inward.y); + const sinEx2 = nP * Math.sqrt(Math.max(0, 1 - cosEx * cosEx)); + if (sinEx2 > 1) continue; // TIR + const cosEx2 = Math.sqrt(1 - sinEx2 * sinEx2); + const eDir = { + x: nP * rDir.x + (nP * cosEx - cosEx2) * inward.x, + y: nP * rDir.y + (nP * cosEx - cosEx2) * inward.y, + }; + const eDLen = Math.hypot(eDir.x, eDir.y); + eDir.x /= eDLen; eDir.y /= eDLen; + + ctx.globalAlpha = isWhite ? 0.85 : 1; + this._drawRayLine(ctx, exitX, exitY, exitX + eDir.x * rayLen, exitY + eDir.y * rayLen, col, isWhite ? 2 : 2.5); + ctx.globalAlpha = 1; + exitPts.push({ nm: s.nm, col }); + } + + // Drag hint ring + ctx.strokeStyle = 'rgba(100,180,255,0.12)'; ctx.lineWidth = 1; ctx.setLineDash([3,4]); + ctx.beginPath(); ctx.arc(cx, cy, size * 1.18, 0, Math.PI * 2); ctx.stroke(); + ctx.setLineDash([]); + + // Info box + ctx.fillStyle = 'rgba(22,22,38,0.88)'; ctx.beginPath(); ctx.roundRect(12, 12, 224, 52, 8); ctx.fill(); + ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; + ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.fillText('Призма: A = ' + this.apexAngle + '°', 22, 22); + ctx.fillStyle = 'rgba(100,180,255,0.8)'; ctx.fillText('n(λ) = ' + this.n0.toFixed(2) + ' − 0.0002·(λ−550)', 22, 40); + + _obUpdateSpectrometer(exitPts); + if (window.LabFX) { LabFX.particles.update(1/60); LabFX.particles.draw(ctx); } + } + + _drawRayLine(ctx, x1, y1, x2, y2, color, width) { + const fn = () => { + ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round'; + ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); + }; + if (window.LabFX) LabFX.glow.drawGlow(ctx, fn, { color, intensity: 6 }); + else fn(); + } + + _bindEvents() { + const cv = this.canvas; + const getPos = (e) => { + const r = cv.getBoundingClientRect(); + const t = e.touches ? e.touches[0] : e; + return { mx: (t.clientX - r.left) * (this.W / r.width), my: (t.clientY - r.top) * (this.H / r.height) }; + }; + const hitTest = (mx, my) => Math.hypot(mx - this.W/2, my - this.H/2) < Math.min(this.W, this.H) * 0.35; + const onDown = (e) => { + const { mx, my } = getPos(e); + if (hitTest(mx, my)) this._drag = { sx: mx, sy: my, sRot: this.rotation, sInc: this.incAngle }; + }; + const onMove = (e) => { + if (!this._drag) return; + if (e.cancelable) e.preventDefault(); + const { mx, my } = getPos(e); + this.rotation = this._drag.sRot + (mx - this._drag.sx) * 0.4; + this.incAngle = Math.max(0, Math.min(80, this._drag.sInc + (my - this._drag.sy) * 0.3)); + this.draw(); this._emit(); + }; + const onUp = () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = null; }; + cv.addEventListener('mousedown', onDown); + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + cv.addEventListener('touchstart', e => { if (e.touches.length === 1) onDown(e); }, { passive: true }); + cv.addEventListener('touchmove', e => onMove(e), { passive: false }); + cv.addEventListener('touchend', onUp); + cv.addEventListener('mousemove', e => { + if (this._drag) { cv.style.cursor = 'grabbing'; return; } + const { mx, my } = getPos(e); + cv.style.cursor = hitTest(mx, my) ? 'grab' : 'default'; + }); + } +} + +/* ───────────────────────────────────────────────────────────── + 4c. SPECTROMETER PANEL +───────────────────────────────────────────────────────────────*/ +function _obDrawSpectrometer() { + const canvas = document.getElementById('ob-spectrometer-canvas'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const W = canvas.offsetWidth || 360, H = canvas.offsetHeight || 72; + const dpr = window.devicePixelRatio || 1; + canvas.width = W * dpr; canvas.height = H * dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.fillStyle = '#0a0a18'; ctx.fillRect(0, 0, W, H); + const barX1 = 24, barX2 = W - 24, barY = 22, barH = 20; + const grad = ctx.createLinearGradient(barX1, 0, barX2, 0); + for (let nm = 380; nm <= 780; nm += 10) { + grad.addColorStop((nm - 380) / 400, wavelengthToRGB(nm)); + } + ctx.fillStyle = grad; ctx.fillRect(barX1, barY, barX2 - barX1, barH); + ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 1; + ctx.strokeRect(barX1, barY, barX2 - barX1, barH); + ctx.font = '8px Manrope, system-ui, sans-serif'; ctx.textBaseline = 'top'; ctx.textAlign = 'center'; + [400, 450, 500, 550, 600, 650, 700, 750].forEach(nm => { + const x = barX1 + (nm - 380) / 400 * (barX2 - barX1); + ctx.fillStyle = 'rgba(255,255,255,0.45)'; ctx.fillText(nm, x, barY + barH + 3); + ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 0.5; + ctx.beginPath(); ctx.moveTo(x, barY + barH); ctx.lineTo(x, barY + barH + 2.5); ctx.stroke(); + }); + const wlNm = window._obWavelength || 550; + const wlX = barX1 + (wlNm - 380) / 400 * (barX2 - barX1); + ctx.strokeStyle = '#FFFFFF'; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(wlX, barY - 5); ctx.lineTo(wlX, barY + barH + 5); ctx.stroke(); + ctx.font = 'bold 9px Manrope, system-ui, sans-serif'; ctx.textBaseline = 'bottom'; ctx.textAlign = 'center'; + ctx.fillStyle = '#FFFFFF'; ctx.fillText(wlNm + ' нм', wlX, barY - 6); + if (window._obSpectrometerDots) { + for (const dot of window._obSpectrometerDots) { + const x = barX1 + (dot.nm - 380) / 400 * (barX2 - barX1); + ctx.fillStyle = dot.col; + ctx.beginPath(); ctx.arc(x, barY - 9, 4, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 0.8; + ctx.beginPath(); ctx.arc(x, barY - 9, 4, 0, Math.PI * 2); ctx.stroke(); + } + } +} + +function _obUpdateSpectrometer(exitPts) { + window._obSpectrometerDots = exitPts || []; + _obDrawSpectrometer(); +} + +/* ───────────────────────────────────────────────────────────── + 5. LAB UI INIT — Оптическая скамья ───────────────────────────────────────────────────────────────*/ var lensSim = null; var mirrorSim = null; var refrSim = null; +var prismSim = null; +var freeSim = null; /* multi-lens free-build (Agent OB-A3) */ var _obMode = 'lens'; // current active mode within opticsbench +/* Wavelength state — shared across all modes */ +window._obWavelength = 550; // default 550 nm (green/yellow) +window._obWhiteLight = false; // monochromatic by default + /* Open opticsbench, optionally setting a mode ('lens'|'mirror'|'refraction') */ function _openOpticsBench(mode) { mode = mode || 'lens'; @@ -1450,13 +2572,13 @@ function obSwitchMode(mode, silent) { _obMode = mode; /* tab button styling */ - ['lens', 'mirror', 'refraction'].forEach(m => { + ['lens', 'mirror', 'refraction', 'prism', 'freebuild'].forEach(m => { const btn = document.getElementById('ob-tab-' + m); if (btn) btn.classList.toggle('active', m === mode); }); /* show/hide per-mode control panels */ - ['ob-ctrl-lens', 'ob-ctrl-mirror', 'ob-ctrl-refraction'].forEach(id => { + ['ob-ctrl-lens', 'ob-ctrl-mirror', 'ob-ctrl-refraction', 'ob-ctrl-prism', 'ob-ctrl-freebuild'].forEach(id => { const el = document.getElementById(id); if (el) el.style.display = 'none'; }); @@ -1464,7 +2586,7 @@ function obSwitchMode(mode, silent) { if (activeCtrl) activeCtrl.style.display = ''; /* show/hide stats bar sections */ - ['ob-stats-lens', 'ob-stats-mirror', 'ob-stats-refr'].forEach(id => { + ['ob-stats-lens', 'ob-stats-mirror', 'ob-stats-refr', 'ob-stats-prism', 'ob-stats-freebuild'].forEach(id => { const el = document.getElementById(id); if (el) el.style.display = 'none'; }); @@ -1472,6 +2594,17 @@ function obSwitchMode(mode, silent) { const activeStats = document.getElementById('ob-stats-' + statsSuffix); if (activeStats) activeStats.style.display = 'flex'; + /* canvas visibility */ + const canvasIds = ['ob-lens-canvas', 'ob-mirror-canvas', 'ob-refr-canvas', 'ob-prism-canvas', 'ob-free-canvas']; + const modeCanvas = { lens: 'ob-lens-canvas', mirror: 'ob-mirror-canvas', refraction: 'ob-refr-canvas', prism: 'ob-prism-canvas', freebuild: 'ob-free-canvas' }; + canvasIds.forEach(id => { const el = document.getElementById(id); if (el) el.style.display = 'none'; }); + const activeCanvas = document.getElementById(modeCanvas[mode] || ''); + if (activeCanvas) activeCanvas.style.display = ''; + + /* spectrometer panel: show for prism mode */ + const specPanel = document.getElementById('ob-spectrometer-panel'); + if (specPanel) specPanel.style.display = (mode === 'prism') ? '' : 'none'; + /* init engine if not yet done, then (re-)draw */ if (mode === 'lens') { if (!lensSim) { @@ -1480,9 +2613,6 @@ function obSwitchMode(mode, silent) { lensSim.onUpdate = _lensUpdateUI; } lensSim.fit(); lensSim.draw(); lensSim._emit(); - document.getElementById('ob-lens-canvas').style.display = ''; - document.getElementById('ob-mirror-canvas').style.display = 'none'; - document.getElementById('ob-refr-canvas').style.display = 'none'; } else if (mode === 'mirror') { if (!mirrorSim) { const cv = document.getElementById('ob-mirror-canvas'); @@ -1497,9 +2627,6 @@ function obSwitchMode(mode, silent) { } mirrorSim.fit(); mirrorSim.draw(); mirrorSim._emit(); if (mirrorSim._showPhotons && !mirrorSim._photonRaf) mirrorSim._startPhotons(); - document.getElementById('ob-lens-canvas').style.display = 'none'; - document.getElementById('ob-mirror-canvas').style.display = ''; - document.getElementById('ob-refr-canvas').style.display = 'none'; } else if (mode === 'refraction') { if (!refrSim) { const cv = document.getElementById('ob-refr-canvas'); @@ -1507,12 +2634,71 @@ function obSwitchMode(mode, silent) { refrSim.onUpdate = _refrUpdateUI; } refrSim.fit(); refrSim.draw(); refrSim._emit(); - document.getElementById('ob-lens-canvas').style.display = 'none'; - document.getElementById('ob-mirror-canvas').style.display = 'none'; - document.getElementById('ob-refr-canvas').style.display = ''; + } else if (mode === 'prism') { + if (!prismSim) { + const cv = document.getElementById('ob-prism-canvas'); + if (cv) prismSim = new PrismSim(cv); + } + if (prismSim) { prismSim.fit(); prismSim.draw(); } + _obDrawSpectrometer(); + } else if (mode === 'freebuild') { /* Agent OB-A3 — multi-lens free build */ + if (!freeSim) { + const cv = document.getElementById('ob-free-canvas'); + if (cv) { + freeSim = new FreeBuildSim(cv); + freeSim.onUpdate = _freeUpdateUI; + } + } + if (freeSim) { freeSim.fit(); freeSim.draw(); _freeUpdateUI(freeSim._computeChain()); } } } +/* ── Wavelength controls ── */ +function obSetWavelength(nm) { + window._obWavelength = Math.max(380, Math.min(780, +nm)); + const lbl = document.getElementById('ob-wl-val'); + if (lbl) lbl.textContent = window._obWavelength + ' нм'; + _obRedraw(); +} + +function obToggleWhiteLight(on) { + window._obWhiteLight = !!on; + const slRow = document.getElementById('ob-wl-slider-row'); + if (slRow) slRow.style.opacity = on ? '0.4' : '1'; + _obRedraw(); +} + +function _obRedraw() { + if (_obMode === 'lens' && lensSim) { lensSim.draw(); } + if (_obMode === 'mirror' && mirrorSim) { mirrorSim.draw(); } + if (_obMode === 'refraction' && refrSim) { refrSim.draw(); } + if (_obMode === 'prism' && prismSim) { prismSim.draw(); } + if (_obMode === 'freebuild' && freeSim) { freeSim.draw(); } + _obDrawSpectrometer(); + // Update prism stats bar + const el = (id, txt) => { const e = document.getElementById(id); if (e) e.textContent = txt; }; + el('prismbar-wl', (window._obWavelength || 550) + ' нм'); + el('prismbar-mode', window._obWhiteLight ? 'Белый' : 'Моно'); + if (prismSim) el('prismbar-n', prismSim.n0.toFixed(2)); +} + +/* ── Prism controls ── */ +function prismParam(name, val) { + const v = parseFloat(val); + const ids = { n0: 'prism-n0-val', incAngle: 'prism-inc-val' }; + const el = document.getElementById(ids[name]); + if (el) el.textContent = name === 'n0' ? v.toFixed(2) : v; + if (prismSim) prismSim.setParams({ [name]: v }); +} + +function prismPreset(n0, incAngle) { + document.getElementById('sl-prism-n0').value = n0; + document.getElementById('prism-n0-val').textContent = n0.toFixed(2); + document.getElementById('sl-prism-inc').value = incAngle; + document.getElementById('prism-inc-val').textContent = incAngle; + if (prismSim) prismSim.setParams({ n0, incAngle }); +} + /* ── Thin Lens controls ── */ function lensParam(name, val) { const v = parseFloat(val); @@ -1529,6 +2715,71 @@ function lensPreset(f, d, 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); @@ -1631,3 +2882,294 @@ function _refrUpdateUI(info) { 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 = '
' + + label + '
' + + '
' + desc + '
'; + + /* 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) : '—'; +} diff --git a/frontend/lab.html b/frontend/lab.html index 0256899..ae165af 100644 --- a/frontend/lab.html +++ b/frontend/lab.html @@ -2531,6 +2531,38 @@ + + + + +
+ λ = +
+ 380 + + 780 +
+ 550 нм + +
+ +
+ Приборы: + + + + + + + + + +
@@ -2558,6 +2590,41 @@
Тащи стрелку-предмет или фокус мышью
+
+ +
+ + +
+ +
+ +
+ + +
+
Аберрации
+
+ + +
+ +
+ +
+ +
+ +
Отображение
@@ -2603,6 +2684,7 @@ +
Пресеты
@@ -2632,6 +2714,10 @@
+
Пресеты
@@ -2641,13 +2727,66 @@
Тащи луч мышью для изменения угла
- + + + + +
+ +
+ +
@@ -2669,6 +2808,16 @@
Крит. угол
ПВО
Нет
+ +