feat(labs): opticsbench round 1 — instruments + aberrations + dispersion + chain

9 готовых пресетов приборов (OB_PRESETS):
- Лупа, Микроскоп, Телескопы Кеплера/Галилея, Камера, Перископ, Проектор
- Световод (TIR), Согнутая ложка в воде
- HUD с подписью на 5 сек при загрузке + chime/whoosh sounds

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-26 12:16:39 +03:00
parent 02009a8c94
commit 2a8011d68e
3 changed files with 1759 additions and 33 deletions
+150 -1
View File
@@ -2531,6 +2531,38 @@
<button id="ob-tab-lens" onclick="obSwitchMode('lens')" class="ob-tab active" 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-mirror" onclick="obSwitchMode('mirror')" 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-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>
</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">
<span style="font-size:.72rem;color:#aaa;font-weight:600;white-space:nowrap;flex-shrink:0">&#955; =</span>
<div id="ob-wl-slider-row" style="display:flex;align-items:center;gap:6px;flex:1;max-width:280px">
<span style="font-size:.68rem;color:#8080ff">380</span>
<input type="range" id="sl-ob-wavelength" min="380" max="780" step="5" value="550"
oninput="obSetWavelength(this.value)"
style="flex:1;accent-color:var(--violet)">
<span style="font-size:.68rem;color:#ff6060">780</span>
</div>
<span id="ob-wl-val" style="font-size:.75rem;font-weight:700;min-width:52px;color:#FFFFFF">550 нм</span>
<label style="display:flex;align-items:center;gap:4px;font-size:.72rem;color:#ccc;cursor:pointer;flex-shrink:0;white-space:nowrap">
<input type="checkbox" id="ob-white-toggle" onchange="obToggleWhiteLight(this.checked)">
Белый свет
</label>
</div>
<!-- ── Готовые сцены / Instruments preset bar ── -->
<div id="ob-presets-bar" style="display:flex;align-items:center;gap:6px;padding:6px 10px;background:#0f0f1c;border-bottom:1px solid #1e1e32;flex-shrink:0;flex-wrap:wrap">
<span style="font-size:.72rem;color:#666;font-weight:600;white-space:nowrap;flex-shrink:0">Приборы:</span>
<button class="ob-preset-chip" data-preset="magnifier" onclick="obLoadPreset('magnifier')" title="Лупа: d &lt; f, мнимое увеличение">Лупа</button>
<button class="ob-preset-chip" data-preset="microscope" onclick="obLoadPreset('microscope')" title="Микроскоп: объектив f=10, d=14">Микроскоп</button>
<button class="ob-preset-chip" data-preset="keplerian" onclick="obLoadPreset('keplerian')" title="Телескоп Кеплера: fуб=200, fок=30">Тел. Кеплера</button>
<button class="ob-preset-chip" data-preset="galilean" onclick="obLoadPreset('galilean')" title="Телескоп Галилея: fок=-40">Тел. Галилея</button>
<button class="ob-preset-chip" data-preset="camera" onclick="obLoadPreset('camera')" title="Камера/Глаз: f=40, d=120">Камера</button>
<button class="ob-preset-chip" data-preset="periscope" onclick="obLoadPreset('periscope')" title="Перископ: плоское зеркало 45°">Перископ</button>
<button class="ob-preset-chip" data-preset="projector" onclick="obLoadPreset('projector')" title="Слайд-проектор: f=80, d=100">Проектор</button>
<button class="ob-preset-chip" data-preset="fiber" onclick="obLoadPreset('fiber')" title="Световод: ПВО, n=1.5 → n=1">Световод</button>
<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>
<!-- Body row: control panels + shared canvas -->
<div style="display:flex;flex:1;min-height:0;overflow:hidden">
@@ -2558,6 +2590,41 @@
<button class="preset-btn" onclick="lensPreset(100,60,50)">d &lt; f</button>
</div>
<div class="pp-hint">Тащи стрелку-предмет или фокус мышью</div>
<div style="margin-top:8px"></div>
<!-- Feature 1: animated ray buttons -->
<div style="display:flex;gap:4px;margin-bottom:8px">
<button onclick="if(lensSim)lensSim.buildRays()" style="flex:1;padding:5px 0;border-radius:6px;border:none;background:linear-gradient(135deg,#06D6E0,#9B5DE5);color:#fff;font-size:.72rem;font-weight:700;cursor:pointer">Построить лучи</button>
<button onclick="if(lensSim)lensSim.resetRays()" style="padding:5px 9px;border-radius:6px;border:1px solid #333;background:#1a1a2e;color:#888;font-size:.78rem;cursor:pointer" title="Сбросить лучи">&#8634;</button>
</div>
<!-- Feature 3: lens-maker toggle -->
<div style="margin-bottom:6px">
<label style="display:flex;align-items:center;gap:6px;font-size:.72rem;color:#ccc;cursor:pointer">
<input type="checkbox" id="ltog-lensmaker" onchange="lensToggleLM(this.checked)">
Подробный (R1/R2/n)
</label>
</div>
<!-- LM sliders (hidden by default) -->
<div id="ob-lm-sliders" style="display:none">
<div class="proj-slider-row" style="margin-bottom:6px">
<label style="font-size:.72rem;color:#ccc;width:60px">R1 = <span id="lm-r1-val" style="color:#FFD166;font-weight:700">200</span></label>
<input type="range" id="sl-lm-r1" min="-300" max="300" step="5" value="200" oninput="lensLMParam('R1',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:6px">
<label style="font-size:.72rem;color:#ccc;width:60px">R2 = <span id="lm-r2-val" style="color:#FFD166;font-weight:700">-200</span></label>
<input type="range" id="sl-lm-r2" min="-300" max="300" step="5" value="-200" oninput="lensLMParam('R2',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.72rem;color:#ccc;width:60px">n = <span id="lm-n-val" style="color:#9B5DE5;font-weight:700">1.50</span></label>
<input type="range" id="sl-lm-n" min="1.3" max="2.4" step="0.05" value="1.5" oninput="lensLMParam('n',this.value)" style="flex:1">
</div>
<div style="font-size:.68rem;color:#888;margin-bottom:6px">f = 1/((n-1)*(1/R1 - 1/R2))</div>
</div>
<div style="margin-top:0"></div>
<div class="gp-section-title" style="margin-bottom:6px">Аберрации</div>
<div style="display:flex;flex-direction:column;gap:4px;margin-bottom:8px">
<label style="display:flex;align-items:center;gap:4px;font-size:.72rem;color:#ccc;cursor:pointer"><input type="checkbox" id="ltog-spherical" onchange="lensAberration('spherical',this.checked)"> Сферическая</label>
<label style="display:flex;align-items:center;gap:4px;font-size:.72rem;color:#ccc;cursor:pointer"><input type="checkbox" id="ltog-chromatic" onchange="lensAberration('chromatic',this.checked)"> Хроматическая</label>
</div>
</div>
<!-- ── Mirror control panel ── -->
<div id="ob-ctrl-mirror" class="proj-panel" style="width:264px;gap:0;flex-shrink:0;display:none">
@@ -2594,6 +2661,20 @@
<button onclick="mirrorStepNext()" style="flex:1;padding:5px 0;border-radius:6px;border:1px solid #333;background:#1a1a2e;color:#7BF5A4;font-size:.73rem;cursor:pointer" title="Показать следующий луч">&#9312; Пошагово</button>
<button onclick="mirrorStepReset()" style="padding:5px 9px;border-radius:6px;border:1px solid #333;background:#1a1a2e;color:#888;font-size:.78rem;cursor:pointer" title="Показать все лучи">&#8634;</button>
</div>
<!-- Feature 2: R-slider + parabolic toggle -->
<div style="margin-bottom:6px">
<label style="display:flex;align-items:center;gap:6px;font-size:.72rem;color:#ccc;cursor:pointer">
<input type="checkbox" id="mtog-useR" onchange="mirrorToggleR(this.checked)">
Радиус R (непрерывный)
</label>
</div>
<div id="ob-mirror-R-row" class="proj-slider-row" style="margin-bottom:6px;display:none">
<label style="font-size:.72rem;color:#ccc;width:60px">R = <span id="mirror-R-val" style="color:var(--cyan);font-weight:700">240</span></label>
<input type="range" id="sl-mirror-R" min="-250" max="250" step="5" value="240" oninput="mirrorRParam(this.value)" style="flex:1">
</div>
<div style="display:flex;gap:4px;margin-bottom:8px">
<button id="mirror-parab-btn" onclick="mirrorToggleParabolic(this)" style="flex:1;padding:5px 0;border-radius:6px;border:1px solid #333;background:#1a1a2e;color:#888;font-size:.72rem;cursor:pointer">Сферическое</button>
</div>
<div class="gp-section-title" style="margin-bottom:6px">Отображение</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:3px 10px;margin-bottom:10px">
<label style="display:flex;align-items:center;gap:4px;font-size:.72rem;color:#ccc;cursor:pointer"><input type="checkbox" id="mtog-normals" checked onchange="mirrorToggle('normals',this.checked)"> Нормали</label>
@@ -2603,6 +2684,7 @@
<label style="display:flex;align-items:center;gap:4px;font-size:.72rem;color:#ccc;cursor:pointer"><input type="checkbox" id="mtog-grid" onchange="mirrorToggle('grid',this.checked)"> Сетка</label>
<label style="display:flex;align-items:center;gap:4px;font-size:.72rem;color:#ccc;cursor:pointer"><input type="checkbox" id="mtog-zones" checked onchange="mirrorToggle('zones',this.checked)"> Зоны</label>
<label style="display:flex;align-items:center;gap:4px;font-size:.72rem;color:#ccc;cursor:pointer;grid-column:span 2"><input type="checkbox" id="mtog-point" onchange="mirrorSetPointMode(this.checked)"> Точечный объект</label>
<label style="display:flex;align-items:center;gap:4px;font-size:.72rem;color:#FF6B6B;cursor:pointer;grid-column:span 2"><input type="checkbox" id="mtog-spherical" onchange="mirrorAberration(this.checked)"> Сферич. аберрация</label>
</div>
<button onclick="if(mirrorSim)mirrorSim.exportPng()" 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"><svg class="ic" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Экспорт PNG</button>
<div class="gp-section-title" style="margin-bottom:6px">Пресеты</div>
@@ -2632,6 +2714,10 @@
<input type="range" id="sl-refr-angle" min="0" max="89" step="1" value="30" oninput="refrParam('angle',this.value)" style="flex:1">
</div>
<div style="margin-top:8px"></div>
<label style="display:flex;align-items:center;gap:6px;font-size:.72rem;color:#ccc;cursor:pointer;margin-bottom:8px">
<input type="checkbox" id="refr-dispersion-toggle" onchange="refrDispersion(this.checked)">
Дисперсия (7 цветов)
</label>
<div class="gp-section-title" style="margin-bottom:6px">Пресеты</div>
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px">
<button class="preset-btn" onclick="refrPreset(1,1.5,30)">Воздух&#8594;Стекло</button>
@@ -2641,13 +2727,66 @@
</div>
<div class="pp-hint">Тащи луч мышью для изменения угла</div>
</div>
<!-- ── Shared canvas area (all 3 canvases stacked) ── -->
<!-- ── Prism control panel ── -->
<div id="ob-ctrl-prism" class="proj-panel" style="width:220px;gap:0;flex-shrink:0;display:none">
<div class="gp-section-title" style="margin-bottom:8px">Параметры призмы</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:55px">n = <span id="prism-n0-val" style="color:#64b4ff;font-weight:700">1.50</span></label>
<input type="range" id="sl-prism-n0" min="1.3" max="2.4" step="0.01" value="1.5" oninput="prismParam('n0',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:65px">&#952;&#1074;&#1093; = <span id="prism-inc-val" style="color:#FFD166;font-weight:700">30</span>&#176;</label>
<input type="range" id="sl-prism-inc" min="0" max="75" step="1" value="30" oninput="prismParam('incAngle',this.value)" style="flex:1">
</div>
<div class="pp-hint" style="margin-bottom:8px">Тащи призму мышью: ← → вращение, &#8597; угол луча</div>
<div class="gp-section-title" style="margin-bottom:6px">Пресеты</div>
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px">
<button class="preset-btn" onclick="prismPreset(1.5,30)">Стекло (n=1.5)</button>
<button class="preset-btn" onclick="prismPreset(1.7,40)">Флинт (n=1.7)</button>
<button class="preset-btn" onclick="prismPreset(2.42,45)">Алмаз (n=2.42)</button>
</div>
</div>
<!-- ── Free-build multi-lens control panel (Agent OB-A3) ── -->
<div id="ob-ctrl-freebuild" class="proj-panel" style="width:220px;gap:0;flex-shrink:0;display:none">
<div class="gp-section-title" style="margin-bottom:8px">Цепочка линз</div>
<div style="display:flex;gap:4px;margin-bottom:10px">
<button class="preset-btn" onclick="freeAddLens()" style="flex:1">+ Линза</button>
<button class="preset-btn" onclick="freeRemoveLens()" style="flex:1">&#8722; Линза</button>
</div>
<div class="proj-slider-row" style="margin-bottom:6px">
<label style="font-size:.78rem;color:#ccc;width:72px">Лин.1 f=<span id="free-lens0-fval" style="color:var(--cyan);font-weight:700">120</span></label>
<input type="range" id="sl-free-f0" min="-300" max="300" step="5" value="120" oninput="freeLensF(0,this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:6px">
<label style="font-size:.78rem;color:#ccc;width:72px">Лин.2 f=<span id="free-lens1-fval" style="color:var(--cyan);font-weight:700">90</span></label>
<input type="range" id="sl-free-f1" min="-300" max="300" step="5" value="90" oninput="freeLensF(1,this.value)" style="flex:1">
</div>
<div style="margin-top:8px"></div>
<div class="gp-section-title" style="margin-bottom:6px">Пресеты</div>
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px">
<button class="preset-btn" onclick="freePreset('microscope')">Микроскоп</button>
<button class="preset-btn" onclick="freePreset('telescope')">Телескоп</button>
<button class="preset-btn" onclick="freePreset('relay')">Рел. цепочка</button>
</div>
<div class="pp-hint">Тащи линзы или предмет по оси мышью</div>
</div>
<!-- ── Shared canvas area (all 5 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>
</div>
</div>
<!-- Spectrometer panel (shown only in prism mode) -->
<div id="ob-spectrometer-panel" style="display:none;flex-shrink:0;padding:6px 10px 4px;background:#0a0a16;border-top:1px solid #1a1a30">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
<span style="font-size:.72rem;color:#888;font-weight:600">Спектрометр</span>
<span style="font-size:.68rem;color:#555">380780 нм</span>
</div>
<canvas id="ob-spectrometer-canvas" style="width:100%;height:56px;display:block;border-radius:4px"></canvas>
</div>
<!-- Stats bar -->
<div class="proj-stats-bar" id="ob-statsbar" style="padding:0">
<div id="ob-stats-lens" style="display:flex;flex:1;gap:0">
@@ -2669,6 +2808,16 @@
<div class="pstat"><div class="pstat-label">Крит. угол</div><div class="pstat-val" id="refrbar-v3" style="color:#FFD166">&#8212;</div></div>
<div class="pstat"><div class="pstat-label">ПВО</div><div class="pstat-val" id="refrbar-v4" style="color:#EF476F">Нет</div></div>
</div>
<div id="ob-stats-freebuild" style="display:none;flex:1;gap:0">
<div class="pstat"><div class="pstat-label">&#915; общ.</div><div class="pstat-val" id="freebar-mag" style="color:#FFD166">&#8212;</div></div>
<div class="pstat"><div class="pstat-label">F сист.</div><div class="pstat-val" id="freebar-sys" style="color:var(--cyan)">&#8212;</div></div>
</div>
<div id="ob-stats-prism" style="display:none;flex:1;gap:0">
<div class="pstat"><div class="pstat-label">A</div><div class="pstat-val" style="color:#64b4ff">60&#176;</div></div>
<div class="pstat"><div class="pstat-label">n(550)</div><div class="pstat-val" id="prismbar-n" style="color:#64b4ff">1.50</div></div>
<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>
</div>