feat(labs): opticsbench round 2 — wave optics + interference + visual depth

Новый режим «Волны» (DiffractionSim, ~400 строк):
- Опыт Юнга: I = I₀·cos²(πd·sinθ/λ), полосы Δy = λL/d, концентрические волновые фронты
- Однощелевая дифракция: (sin α/α)², центральный максимум 2λ/a, минимумы
- Дифракционная решётка: (sin Nψ/N sin ψ)², главные порядки 0,±1,±2,±3, white-light спектр

Новый режим «Интерференция» (InterferenceSim):
- Кольца Ньютона: top-down + cross-section, r_n = √(nλR) тёмные / √((n+½)λR) светлые
- Тонкоплёночная интерференция: integrate I=cos²(π·OPD/λ) по спектру → цвет плёнки
  пресеты: мыльная плёнка / масло на воде / антибликовое покрытие
- Поляризация: P1+P2, закон Малюса I=I₀·cos²θ, анимированные E-векторы, гашение при 90°
  + связь с Брюстером из refraction mode

Визуальные эффекты (5 toggle'ов в <details>):
- «Волновые фронты»: перпендикулярные tick-marks вдоль лучей, λ_screen∝1/n в среде
- «Туман»: LabFX smoke partikles по всему canvas — лучи видны через дым
- «Lens flare»: 6-spike starburst + ghost-reflections + chromatic ring (additive composite)
- «Конструкция Гюйгенса»: расходящиеся wavelets на границе для refraction/reflection
- «Каустики»: 20-ray trace через линзу с aberration-shifted f_eff → настоящая caustic curve
- localStorage persist + zero cost when off

THEORY entry расширен 3 секциями (Юнг + однощель + решётка).

Каталог теперь: 7 вкладок в оптической скамье (lens / mirror / refraction / freebuild / prism / waves / interf).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-26 12:33:10 +03:00
parent 2a8011d68e
commit add17b1bb4
4 changed files with 1602 additions and 11 deletions
+44
View File
@@ -1512,3 +1512,47 @@ canvas[data-draggable]:active { cursor: grabbing; }
border-radius: 4px;
background: #0a0a18;
}
/* ═══ OB_FX Effects panel ═══ */
.ob-fx-panel {
flex-shrink: 0;
background: #0d0d1e;
border-bottom: 1px solid #1e1e32;
}
.ob-fx-summary {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
font-size: .72rem;
font-weight: 700;
color: #888;
cursor: pointer;
list-style: none;
user-select: none;
}
.ob-fx-summary::-webkit-details-marker { display: none; }
.ob-fx-summary:hover { color: var(--cyan); }
.ob-fx-summary .ic { fill: currentColor; opacity: 0.7; flex-shrink: 0; }
.ob-fx-row {
display: flex;
flex-wrap: wrap;
gap: 6px 14px;
padding: 6px 12px 8px;
}
.ob-fx-label {
display: flex;
align-items: center;
gap: 5px;
font-size: .72rem;
color: #bbb;
cursor: pointer;
white-space: nowrap;
}
.ob-fx-label:hover { color: #fff; }
.ob-fx-label input[type=checkbox] {
accent-color: var(--cyan);
width: 13px;
height: 13px;
cursor: pointer;
}
+3
View File
@@ -510,6 +510,9 @@
{ head: 'Закон Снеллиуса', formula: 'n_1 \\sin\\theta_1 = n_2 \\sin\\theta_2', text: 'Угол преломления зависит от соотношения показателей преломления двух сред.' },
{ head: 'Полное внутреннее отражение', formula: '\\theta_c = \\arcsin\\frac{n_2}{n_1}', text: 'При n₁ > n₂ и θ₁ > θc — свет полностью отражается.' },
{ head: 'Показатель преломления', formula: 'n = \\frac{c}{v}', text: 'Воздух ≈ 1.00, вода = 1.33, стекло ≈ 1.5, алмаз = 2.42.' },
{ head: 'Волновая оптика — Юнг', formula: 'I(y) = I_0 \\cos^2\\!\\left(\\frac{\\pi d \\sin\\theta}{\\lambda}\\right)', vars: [['d','расстояние между щелями'],['\\lambda','длина волны']], text: 'Расстояние между полосами: Δy = λL/d.' },
{ head: 'Однощелевая дифракция', formula: 'I(\\theta) = I_0 \\left(\\frac{\\sin\\alpha}{\\alpha}\\right)^2,\\quad \\alpha = \\frac{\\pi a \\sin\\theta}{\\lambda}', text: 'Угловая ширина центрального максимума: 2λ/a. Минимумы при a·sinθ = nλ.' },
{ head: 'Дифракционная решётка', formula: 'd \\sin\\theta = n\\lambda', vars: [['d','период решётки'],['n','порядок'],['\\lambda','длина волны']], text: 'Разрешающая способность R = Nn, где N — число щелей, n — порядок максимума.' },
]
},
thinlens: {
File diff suppressed because it is too large Load Diff
+162 -1
View File
@@ -2533,6 +2533,8 @@
<button id="ob-tab-refraction" onclick="obSwitchMode('refraction')" class="ob-tab" style="flex:1;padding:8px 0;border:none;background:transparent;color:#ccc;font-size:.78rem;font-weight:600;cursor:pointer;border-bottom:2px solid transparent;transition:all .15s">Преломление</button>
<button id="ob-tab-freebuild" onclick="obSwitchMode('freebuild')" class="ob-tab" style="flex:1;padding:8px 0;border:none;background:transparent;color:#ccc;font-size:.78rem;font-weight:600;cursor:pointer;border-bottom:2px solid transparent;transition:all .15s">Цепочка линз</button>
<button id="ob-tab-prism" onclick="obSwitchMode('prism')" class="ob-tab" style="flex:1;padding:8px 0;border:none;background:transparent;color:#ccc;font-size:.78rem;font-weight:600;cursor:pointer;border-bottom:2px solid transparent;transition:all .15s">Призма</button>
<button id="ob-tab-interf" onclick="obSwitchMode('interf')" class="ob-tab" style="flex:1;padding:8px 0;border:none;background:transparent;color:#ccc;font-size:.78rem;font-weight:600;cursor:pointer;border-bottom:2px solid transparent;transition:all .15s">Интерференция</button>
<button id="ob-tab-waves" onclick="obSwitchMode('waves')" class="ob-tab" style="flex:1;padding:8px 0;border:none;background:transparent;color:#ccc;font-size:.78rem;font-weight:600;cursor:pointer;border-bottom:2px solid transparent;transition:all .15s">Волны</button>
</div>
<!-- ── Wavelength slider bar (global) ── -->
<div id="ob-wavelength-bar" style="display:flex;align-items:center;gap:8px;padding:5px 10px;background:#0c0c1a;border-bottom:1px solid #1a1a30;flex-shrink:0">
@@ -2564,6 +2566,35 @@
<button class="ob-preset-chip" data-preset="spoon" onclick="obLoadPreset('spoon')" title="Ложка в воде: преломление на границе">Ложка в воде</button>
<button class="ob-preset-chip ob-preset-clear" onclick="obClearPreset()" title="Вернуть начальные настройки" style="margin-left:auto">Очистить</button>
</div>
<!-- ── Эффекты panel (collapsible) ── -->
<details id="ob-fx-panel" class="ob-fx-panel">
<summary class="ob-fx-summary">
<svg class="ic" viewBox="0 0 16 16" width="12" height="12"><path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 2a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm0 4.5c1.7 0 3.2.85 4.1 2.15a5 5 0 0 1-8.2 0C4.8 8.35 6.3 7.5 8 7.5z"/></svg>
Эффекты
</summary>
<div class="ob-fx-row">
<label class="ob-fx-label">
<input type="checkbox" id="obfx-wavefronts" onchange="obFXToggle('wavefronts',this.checked)">
Волновые фронты вдоль лучей
</label>
<label class="ob-fx-label">
<input type="checkbox" id="obfx-mist" onchange="obFXToggle('mist',this.checked)">
Туман
</label>
<label class="ob-fx-label">
<input type="checkbox" id="obfx-flare" onchange="obFXToggle('flare',this.checked)">
Lens flare
</label>
<label class="ob-fx-label">
<input type="checkbox" id="obfx-huygens" onchange="obFXToggle('huygens',this.checked)">
Конструкция Гюйгенса
</label>
<label class="ob-fx-label">
<input type="checkbox" id="obfx-caustics" onchange="obFXToggle('caustics',this.checked)">
Каустики
</label>
</div>
</details>
<!-- Body row: control panels + shared canvas -->
<div style="display:flex;flex:1;min-height:0;overflow:hidden">
<!-- ── Lens control panel ── -->
@@ -2770,13 +2801,134 @@
</div>
<div class="pp-hint">Тащи линзы или предмет по оси мышью</div>
</div>
<!-- ── Shared canvas area (all 5 canvases stacked) ── -->
<!-- ── Interference control panel (Agent C) ── -->
<div id="ob-ctrl-interf" class="proj-panel" style="width:240px;gap:0;flex-shrink:0;display:none">
<!-- Sub-mode buttons -->
<div class="gp-section-title" style="margin-bottom:6px">Эксперимент</div>
<div style="display:flex;gap:3px;margin-bottom:10px;flex-wrap:wrap">
<button id="if-sub-newton" class="preset-btn active" onclick="ifSwitchSub('newton')" style="font-size:.7rem;flex:1">Кольца Ньютона</button>
<button id="if-sub-thinfilm" class="preset-btn" onclick="ifSwitchSub('thinfilm')" style="font-size:.7rem;flex:1">Тонкая плёнка</button>
<button id="if-sub-polarization" class="preset-btn" onclick="ifSwitchSub('polarization')" style="font-size:.7rem;flex:1">Поляризация</button>
</div>
<!-- Newton rings controls -->
<div id="if-ctrl-newton">
<div class="gp-section-title" style="margin-bottom:6px">Кольца Ньютона</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:65px">R = <span id="if-newton-r-val" style="color:var(--cyan);font-weight:700">200</span> мм</label>
<input type="range" id="sl-if-newton-r" min="50" max="500" step="10" value="200" oninput="ifNewtParam('R',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:65px">n = <span id="if-newton-n-val" style="color:#FFD166;font-weight:700">12</span></label>
<input type="range" id="sl-if-newton-n" min="4" max="20" step="1" value="12" oninput="ifNewtParam('nmax',this.value)" style="flex:1">
</div>
<div class="pp-hint">r_n(dark) = sqrt(n*lambda*R)</div>
</div>
<!-- Thin film controls -->
<div id="if-ctrl-thinfilm" style="display:none">
<div class="gp-section-title" style="margin-bottom:6px">Тонкая плёнка</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:60px">t = <span id="if-tf-t-val" style="color:var(--cyan);font-weight:700">400</span></label>
<input type="range" id="sl-if-tf-t" min="50" max="2000" step="10" value="400" oninput="ifThinFilmParam('t',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:60px">n = <span id="if-tf-n-val" style="color:#FFD166;font-weight:700">1.33</span></label>
<input type="range" id="sl-if-tf-n" min="1.0" max="2.5" step="0.01" value="1.33" oninput="ifThinFilmParam('n',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:60px">&#952; = <span id="if-tf-th-val" style="color:#EF476F;font-weight:700">0</span>&#176;</label>
<input type="range" id="sl-if-tf-th" min="0" max="60" step="1" value="0" oninput="ifThinFilmParam('theta',this.value)" style="flex:1">
</div>
<div class="gp-section-title" style="margin-bottom:4px">Пресет</div>
<div style="display:flex;flex-wrap:wrap;gap:3px;margin-bottom:6px">
<button class="preset-btn" onclick="ifThinFilmPreset('soap')" style="font-size:.68rem">Мыльная n=1.33</button>
<button class="preset-btn" onclick="ifThinFilmPreset('oil')" style="font-size:.68rem">Масло n=1.50</button>
<button class="preset-btn" onclick="ifThinFilmPreset('coating')" style="font-size:.68rem">Покрытие n=1.38</button>
</div>
<div class="pp-hint">2nt&#183;cos&#952;r = (m+0.5)&#955; — максимум</div>
</div>
<!-- Polarization controls -->
<div id="if-ctrl-polarization" style="display:none">
<div class="gp-section-title" style="margin-bottom:6px">Поляризация (Малюс)</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:60px">&#952; = <span id="if-pol-th-val" style="color:var(--cyan);font-weight:700">45</span>&#176;</label>
<input type="range" id="sl-if-pol-th" min="0" max="90" step="1" value="45" oninput="ifPolParam('theta',this.value)" style="flex:1">
</div>
<div style="margin-bottom:8px">
<label style="font-size:.72rem;color:#ccc;display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="radio" name="if-pol-src" value="unpolarized" checked onchange="ifPolSrc(this.value)" style="accent-color:var(--violet)">
Неполяризованный
</label>
<label style="font-size:.72rem;color:#ccc;display:flex;align-items:center;gap:6px;cursor:pointer;margin-top:4px">
<input type="radio" name="if-pol-src" value="polarized" onchange="ifPolSrc(this.value)" style="accent-color:var(--violet)">
Поляризованный
</label>
</div>
<div class="pp-hint">I = I&#8320;&#183;cos&#178;(&#952;)</div>
</div>
</div>
<!-- ── Waves (diffraction) control panel (Agent B1) ── -->
<div id="ob-ctrl-waves" class="proj-panel" style="width:230px;gap:0;flex-shrink:0;display:none">
<div class="gp-section-title" style="margin-bottom:8px">Опыт</div>
<div style="display:flex;gap:3px;margin-bottom:10px">
<button id="diffr-sub-young" onclick="diffrSwitchSub('young')" class="preset-btn active" style="flex:1;font-size:.7rem">Юнг</button>
<button id="diffr-sub-single" onclick="diffrSwitchSub('single')" class="preset-btn" style="flex:1;font-size:.7rem">Однощелевая</button>
<button id="diffr-sub-grating" onclick="diffrSwitchSub('grating')" class="preset-btn" style="flex:1;font-size:.7rem">Решётка</button>
</div>
<!-- Young sliders -->
<div id="ob-diffr-young-params">
<div class="gp-section-title" style="margin-bottom:6px">Параметры (Юнг)</div>
<div class="proj-slider-row" style="margin-bottom:6px">
<label style="font-size:.75rem;color:#ccc;width:72px">d = <span id="diffr-d-young-val" style="color:var(--cyan);font-weight:700">40</span> мкм</label>
<input type="range" id="sl-diffr-d-young" min="10" max="100" step="1" value="40"
oninput="diffrParam('d_young',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.75rem;color:#ccc;width:72px">L = <span id="diffr-L-young-val" style="color:var(--violet);font-weight:700">1.0</span> м</label>
<input type="range" id="sl-diffr-L-young" min="5" max="20" step="1" value="10"
oninput="diffrParam('L_young',this.value/10)" style="flex:1">
</div>
</div>
<!-- Single-slit sliders -->
<div id="ob-diffr-single-params" style="display:none">
<div class="gp-section-title" style="margin-bottom:6px">Параметры (Однощелевая)</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.75rem;color:#ccc;width:72px">a = <span id="diffr-a-single-val" style="color:#FFD166;font-weight:700">80</span> мкм</label>
<input type="range" id="sl-diffr-a-single" min="10" max="200" step="5" value="80"
oninput="diffrParam('a_single',this.value)" style="flex:1">
</div>
</div>
<!-- Grating sliders -->
<div id="ob-diffr-grating-params" style="display:none">
<div class="gp-section-title" style="margin-bottom:6px">Параметры (Решётка)</div>
<div class="proj-slider-row" style="margin-bottom:6px">
<label style="font-size:.75rem;color:#ccc;width:72px">N = <span id="diffr-N-grating-val" style="color:#7BF5A4;font-weight:700">10</span></label>
<input type="range" id="sl-diffr-N-grating" min="2" max="100" step="1" value="10"
oninput="diffrParam('N_grating',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:6px">
<label style="font-size:.75rem;color:#ccc;width:72px">d = <span id="diffr-d-grating-val" style="color:var(--cyan);font-weight:700">2.0</span> мкм</label>
<input type="range" id="sl-diffr-d-grating" min="5" max="50" step="1" value="20"
oninput="diffrParam('d_grating',this.value/10)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.75rem;color:#ccc;width:72px">a = <span id="diffr-a-grating-val" style="color:#FFD166;font-weight:700">0.5</span> мкм</label>
<input type="range" id="sl-diffr-a-grating" min="1" max="30" step="1" value="5"
oninput="diffrParam('a_grating',this.value/10)" style="flex:1">
</div>
</div>
<div style="margin-top:4px"></div>
<button onclick="diffrReset()" style="width:100%;padding:5px 0;border-radius:6px;border:1px solid #333;background:#1a1a2e;color:#888;font-size:.72rem;cursor:pointer;margin-bottom:8px">Сброс</button>
<div class="pp-hint">&#955; берётся из глобального ползунка</div>
</div>
<!-- ── Shared canvas area (all 6 canvases stacked) ── -->
<div class="proj-canvas-outer" style="position:relative;flex:1;min-width:0">
<canvas id="ob-lens-canvas" style="position:absolute;top:0;left:0;width:100%;height:100%"></canvas>
<canvas id="ob-mirror-canvas" style="position:absolute;top:0;left:0;width:100%;height:100%;display:none"></canvas>
<canvas id="ob-refr-canvas" style="position:absolute;top:0;left:0;width:100%;height:100%;display:none"></canvas>
<canvas id="ob-prism-canvas" style="position:absolute;top:0;left:0;width:100%;height:100%;display:none"></canvas>
<canvas id="ob-free-canvas" style="position:absolute;top:0;left:0;width:100%;height:100%;display:none"></canvas>
<canvas id="ob-waves-canvas" style="position:absolute;top:0;left:0;width:100%;height:100%;display:none"></canvas>
<canvas id="ob-interf-canvas" style="position:absolute;top:0;left:0;width:100%;height:100%;display:none"></canvas>
</div>
</div>
<!-- Spectrometer panel (shown only in prism mode) -->
@@ -2818,6 +2970,15 @@
<div class="pstat"><div class="pstat-label">&#955;</div><div class="pstat-val" id="prismbar-wl" style="color:#FFFFFF">550 нм</div></div>
<div class="pstat"><div class="pstat-label">Режим</div><div class="pstat-val" id="prismbar-mode" style="color:#aaa">Моно</div></div>
</div>
<div id="ob-stats-interf" style="display:none;flex:1;gap:0">
<div class="pstat"><div class="pstat-label">Режим</div><div class="pstat-val" id="ifbar-sub" style="color:var(--cyan)">Кольца</div></div>
<div class="pstat"><div class="pstat-label">&#955;</div><div class="pstat-val" id="ifbar-wl" style="color:#FFFFFF">550 нм</div></div>
</div>
<div id="ob-stats-waves" style="display:none;flex:1;gap:0">
<div class="pstat"><div class="pstat-label">Опыт</div><div class="pstat-val" id="diffbar-sub" style="color:var(--cyan)">Юнг</div></div>
<div class="pstat"><div class="pstat-label">&#955;</div><div class="pstat-val" id="diffbar-wl" style="color:#FFFFFF">550 нм</div></div>
<div class="pstat" style="flex:2"><div class="pstat-label">Результат</div><div class="pstat-val" id="diffbar-info" style="color:#FFD166;font-size:.72rem">&#8212;</div></div>
</div>
</div>
</div>