feat(opticsbench): конструктор Фаза 4 — новые источники/элементы + улучшения

Источники: одиночный луч и лазер (узкий пучок) + угол прицеливания
(point/single/laser/parallel наклоняются по ang).
Новые элементы:
- граница сред: Снеллиус на вертикальной плоскости + полное внутр. отражение
  (проверено: 30°→19.47°, ПВО при 50°)
- стеклянная пластина: параллельный сдвиг (преломление вход/выход)
Улучшения:
- отсечение апертурой (лучи вне линзы/зеркала поглощаются — виньетирование)
- метки F и 2F у собирающей линзы
- числовые значения у слайдеров инспектора (без пересборки панели)
bump opticsbench.js?v=5

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-30 12:49:53 +03:00
parent 1674df0ddc
commit eb5593333c
3 changed files with 131 additions and 52 deletions
+120 -50
View File
@@ -2544,8 +2544,8 @@ class BenchSim {
this.onUpdate = null;
this._drag = null;
this._nextId = 1;
// source: object arrow by default
this.source = { kind: 'object', xf: 0.07, h: 70, spread: 0.32, rays: 9 };
// source: object arrow by default. `ang` (deg) aims point/single/laser/parallel.
this.source = { kind: 'object', xf: 0.07, h: 70, spread: 0.32, rays: 9, ang: 0 };
// elements along the bench, positioned by x-fraction; centred on the axis
this.elements = [
this._mk('lens', { xf: 0.40, f: 130, ap: 95 }),
@@ -2565,6 +2565,8 @@ class BenchSim {
if (type === 'aperture') return { ...base, gap: p.gap != null ? p.gap : 40 };
if (type === 'screen') return { ...base };
if (type === 'prism') return { ...base, apex: p.apex != null ? p.apex : 50, n: p.n != null ? p.n : 1.52, size: p.size || 90 };
if (type === 'interface') return { ...base, n1: p.n1 != null ? p.n1 : 1, n2: p.n2 != null ? p.n2 : 1.5, ap: p.ap || 110 };
if (type === 'slab') return { ...base, n: p.n != null ? p.n : 1.5, t: p.t != null ? p.t : 60, ap: p.ap || 95 };
return base;
}
@@ -2621,15 +2623,26 @@ class BenchSim {
const push = (x, y, ang) => {
for (const wl of wls) rays.push({ x, y, dx: Math.cos(ang), dy: Math.sin(ang), wl, pts: [{ x, y }], alive: true, bounces: 0 });
};
if (this.source.kind === 'parallel') {
const n = 9, hh = 90;
const aim = (this.source.ang || 0) * Math.PI / 180;
if (this.source.kind === 'single') {
push(sx, ay, aim); // one aimable ray
} else if (this.source.kind === 'laser') {
const n = 3, hh = 7; // narrow collimated beam
const px = -Math.sin(aim), py = Math.cos(aim); // perpendicular to aim
for (let i = 0; i < n; i++) {
const y = ay - hh + (2 * hh) * (i / (n - 1));
push(sx, y, 0);
const o = (i - (n - 1) / 2) * hh;
push(sx + px * o, ay + py * o, aim);
}
} else if (this.source.kind === 'parallel') {
const n = 9, hh = 90;
const px = -Math.sin(aim), py = Math.cos(aim);
for (let i = 0; i < n; i++) {
const o = -hh + (2 * hh) * (i / (n - 1));
push(sx + px * o, ay + py * o, aim);
}
} else if (this.source.kind === 'point') {
const n = this.source.rays, A = this.source.spread;
for (let i = 0; i < n; i++) push(sx, ay, -A + 2 * A * (i / (n - 1)));
for (let i = 0; i < n; i++) push(sx, ay, aim - A + 2 * A * (i / (n - 1)));
} else { // object arrow: fan from tip and base
const n = this.source.rays, A = this.source.spread;
[ay - this.source.h, ay].forEach(y0 => {
@@ -2682,14 +2695,38 @@ class BenchSim {
_interact(ray, el, yRel) {
const norm = (x, y) => { const l = Math.hypot(x, y) || 1; ray.dx = x / l; ray.dy = y / l; };
if (el.type === 'lens') {
if (Math.abs(yRel) > el.ap) return false; // outside aperture → pass
if (Math.abs(yRel) > el.ap) { ray.alive = false; return true; } // mount blocks outside aperture
const sgn = Math.sign(ray.dx) || 1;
const w = ray.dy / Math.abs(ray.dx);
norm(sgn, w - yRel / el.f);
return true;
}
if (el.type === 'mirror') {
if (el.type === 'interface') {
if (Math.abs(yRel) > el.ap) return false;
// Snell at a vertical plane (normal = x). Tangential (y) component scales by n_i/n_t.
const goingRight = ray.dx > 0;
const ni = goingRight ? el.n1 : el.n2;
const nt = goingRight ? el.n2 : el.n1;
const dyT = (ni / nt) * ray.dy; // sinθ_t (tangential preserved)
if (Math.abs(dyT) >= 1) { ray.dx = -ray.dx; ray.bounces++; return true; } // total internal reflection
const sgn = Math.sign(ray.dx) || 1;
ray.dy = dyT; ray.dx = sgn * Math.sqrt(Math.max(0, 1 - dyT * dyT));
return true;
}
if (el.type === 'slab') {
if (Math.abs(yRel) > el.ap) return false;
// parallel plate: ray exits parallel but laterally shifted (refract in, travel t, refract out)
const sinI = ray.dy; // |d|=1 → y-comp is sinθ from axis
const sinT = sinI / el.n;
const tanT = sinT / Math.sqrt(Math.max(1e-6, 1 - sinT * sinT));
const sgn = Math.sign(ray.dx) || 1;
ray.pts.push({ x: ray.x, y: ray.y }); // entry on the front face
ray.x += sgn * el.t; // emerge on the far face
ray.y += tanT * el.t; // inside-the-glass vertical travel
return true; // direction unchanged (parallel faces); tracer pushes exit
}
if (el.type === 'mirror') {
if (Math.abs(yRel) > el.ap) { ray.alive = false; return true; }
ray.dx = -ray.dx; // reflect about the vertical plane
ray.bounces++;
if (el.kind !== 'plane') {
@@ -2784,15 +2821,22 @@ class BenchSim {
_drawSource(ctx, ay) {
const sx = this.source.xf * this.W;
const aim = (this.source.ang || 0) * Math.PI / 180;
ctx.save();
if (this.source.kind === 'object') {
this._arrow(ctx, sx, ay, sx, ay - this.source.h, '#9B5DE5');
} else {
ctx.fillStyle = this.source.kind === 'point' ? '#FFD166' : '#9B5DE5';
ctx.beginPath(); ctx.arc(sx, ay, 5, 0, Math.PI * 2); ctx.fill();
if (this.source.kind === 'parallel') {
ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(sx, ay - 90); ctx.lineTo(sx, ay + 90); ctx.stroke();
const isLaser = this.source.kind === 'laser', isSingle = this.source.kind === 'single';
ctx.fillStyle = (this.source.kind === 'point' || isSingle) ? '#FFD166' : '#9B5DE5';
ctx.beginPath(); ctx.arc(sx, ay, isLaser ? 4 : 5, 0, Math.PI * 2); ctx.fill();
if (this.source.kind === 'parallel' || isLaser) {
const px = -Math.sin(aim), py = Math.cos(aim), hh = isLaser ? 10 : 90;
ctx.strokeStyle = isLaser ? '#FF5B5B' : '#9B5DE5'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(sx - px * hh, ay - py * hh); ctx.lineTo(sx + px * hh, ay + py * hh); ctx.stroke();
}
// aim arrow for single / laser / point
if (isSingle || isLaser || this.source.kind === 'point') {
this._arrow(ctx, sx, ay, sx + 22 * Math.cos(aim), ay + 22 * Math.sin(aim), isLaser ? '#FF5B5B' : '#FFD166');
}
}
const sel = this.selectedId === '__src';
@@ -2816,7 +2860,26 @@ class BenchSim {
[[ay - el.ap, 1], [ay + el.ap, -1]].forEach(([yy, s]) => {
ctx.beginPath(); ctx.moveTo(x, yy); ctx.lineTo(x - tip, yy + s * 7); ctx.moveTo(x, yy); ctx.lineTo(x + tip, yy + s * 7); ctx.stroke();
});
// focal markers F and 2F on both sides of the lens
if (conv) {
ctx.fillStyle = 'rgba(6,214,224,0.6)';
[el.f, -el.f, 2 * el.f, -2 * el.f].forEach((d, i) => {
const fx = x + d; if (fx < 6 || fx > this.W - 6) return;
ctx.beginPath(); ctx.arc(fx, ay, 3, 0, Math.PI * 2); ctx.fill();
this._elLabel(ctx, fx, ay - 16, i < 2 ? 'F' : '2F');
});
}
this._elLabel(ctx, x, ay + el.ap + 14, (conv ? 'линза +' : 'линза ') + Math.abs(el.f).toFixed(0));
} else if (el.type === 'interface') {
ctx.fillStyle = 'rgba(96,165,250,0.10)'; ctx.fillRect(x, ay - el.ap, this.W - x, 2 * el.ap);
ctx.strokeStyle = sel ? '#fff' : '#60a5fa';
ctx.beginPath(); ctx.moveTo(x, ay - el.ap); ctx.lineTo(x, ay + el.ap); ctx.stroke();
this._elLabel(ctx, x, ay + el.ap + 14, 'граница ' + el.n1.toFixed(2) + ' | ' + el.n2.toFixed(2));
} else if (el.type === 'slab') {
ctx.fillStyle = 'rgba(123,245,164,0.10)'; ctx.fillRect(x, ay - el.ap, el.t, 2 * el.ap);
ctx.strokeStyle = sel ? '#fff' : '#7BF5A4';
ctx.strokeRect(x, ay - el.ap, el.t, 2 * el.ap);
this._elLabel(ctx, x + el.t / 2, ay + el.ap + 14, 'пластина n=' + el.n.toFixed(2));
} else if (el.type === 'mirror') {
ctx.strokeStyle = sel ? '#fff' : '#A8E063';
ctx.beginPath();
@@ -4350,65 +4413,72 @@ function _freeUpdateUI(chain) {
/* ── Optical bench constructor (BenchSim) UI ── */
function _benchElName(e) {
if (e.type === 'lens') return (e.f >= 0 ? 'Линза +' : 'Линза ') + Math.abs(e.f).toFixed(0);
if (e.type === 'mirror') return 'Зеркало ' + ({ plane: 'плоск', concave: 'вогн', convex: 'выпукл' }[e.kind] || '');
if (e.type === 'aperture') return 'Диафрагма';
if (e.type === 'screen') return 'Экран';
if (e.type === 'prism') return 'Призма';
if (e.type === 'lens') return (e.f >= 0 ? 'Линза +' : 'Линза ') + Math.abs(e.f).toFixed(0);
if (e.type === 'mirror') return 'Зеркало ' + ({ plane: 'плоск', concave: 'вогн', convex: 'выпукл' }[e.kind] || '');
if (e.type === 'aperture') return 'Диафрагма';
if (e.type === 'screen') return 'Экран';
if (e.type === 'prism') return 'Призма';
if (e.type === 'interface') return 'Граница сред';
if (e.type === 'slab') return 'Пластина';
return e.type;
}
function _benchRow(label, html) {
return '<div class="proj-slider-row" style="margin-bottom:6px"><label style="font-size:.74rem;color:#ccc;width:78px">' + label + '</label>' + html + '</div>';
// Labelled slider with a live numeric value (no panel rebuild → drag stays smooth).
function _benchCtl(label, id, key, min, max, step, val, isSource) {
const vid = 'bv_' + (isSource ? 's' : id) + '_' + key;
const call = isSource ? "benchSourceParam('" + key + "',this.value)" : "benchUpdate(" + id + ",'" + key + "',this.value)";
return '<div class="proj-slider-row" style="margin-bottom:6px">' +
'<label style="font-size:.72rem;color:#ccc;width:104px">' + label + ' <b id="' + vid + '" style="color:var(--cyan)">' + val + '</b></label>' +
'<input type="range" min="' + min + '" max="' + max + '" step="' + step + '" value="' + val +
'" oninput="' + call + ';var b=document.getElementById(\'' + vid + '\');if(b)b.textContent=this.value" style="flex:1"></div>';
}
function _benchSlider(id, key, min, max, step, val) {
return '<input type="range" min="' + min + '" max="' + max + '" step="' + step + '" value="' + val +
'" oninput="benchUpdate(' + id + ',\'' + key + '\',this.value)" style="flex:1">';
function _benchBtnRow(opts, isActive, onClick) {
return '<div style="display:flex;flex-wrap:wrap;gap:3px;margin-bottom:6px">' +
opts.map(o => { const [k, lbl] = o.split(':');
return '<button class="preset-btn' + (isActive(k) ? ' active' : '') + '" style="flex:1;font-size:.66rem" onclick="' + onClick(k) + '">' + lbl + '</button>';
}).join('') + '</div>';
}
function _benchPropsHTML() {
if (!benchSim) return '';
const sel = benchSim.selectedId;
if (sel === '__src') {
if (benchSim.selectedId === '__src') {
const s = benchSim.source;
let h = '<div class="gp-section-title" style="margin:4px 0 6px">Источник</div>';
h += '<div style="display:flex;gap:3px;margin-bottom:6px">' +
['object:Предмет', 'point:Точка', 'parallel:Параллель'].map(o => {
const [k, lbl] = o.split(':');
return '<button class="preset-btn' + (s.kind === k ? ' active' : '') + '" style="flex:1;font-size:.68rem" onclick="benchSourceKind(\'' + k + '\')">' + lbl + '</button>';
}).join('') + '</div>';
if (s.kind === 'object') h += _benchRow('Высота', _benchSourceSlider('h', 20, 120, 2, s.h));
if (s.kind !== 'parallel') h += _benchRow('Раствор', _benchSourceSlider('spread', 0.1, 0.6, 0.02, s.spread));
h += _benchBtnRow(['object:Предмет', 'point:Точка', 'parallel:Параллель', 'single:Луч', 'laser:Лазер'],
k => s.kind === k, k => "benchSourceKind('" + k + "')");
if (s.kind === 'object') h += _benchCtl('Высота', 0, 'h', 20, 120, 2, s.h, true);
if (s.kind === 'point') h += _benchCtl('Раствор', 0, 'spread', 0.1, 0.6, 0.02, s.spread, true);
if (s.kind !== 'object' && s.kind !== 'parallel') {} // no spread for single/laser
if (s.kind !== 'object') h += _benchCtl('Угол°', 0, 'ang', -60, 60, 1, s.ang || 0, true);
return h;
}
const e = benchSim.getSelected();
if (!e) return '<div class="pp-hint">Выберите элемент или источник (клик по схеме)</div>';
let h = '<div class="gp-section-title" style="margin:4px 0 6px">' + _benchElName(e) + '</div>';
if (e.type === 'lens') {
h += _benchRow('f, px', _benchSlider(e.id, 'f', -300, 300, 5, e.f));
h += _benchRow('Апертура', _benchSlider(e.id, 'ap', 30, 130, 5, e.ap));
h += _benchCtl('f, px', e.id, 'f', -300, 300, 5, e.f);
h += _benchCtl('Апертура', e.id, 'ap', 30, 130, 5, e.ap);
} else if (e.type === 'mirror') {
h += '<div style="display:flex;gap:3px;margin-bottom:6px">' +
['plane:Плоск', 'concave:Вогн', 'convex:Выпукл'].map(o => {
const [k, lbl] = o.split(':');
return '<button class="preset-btn' + (e.kind === k ? ' active' : '') + '" style="flex:1;font-size:.68rem" onclick="benchUpdate(' + e.id + ',\'kind\',\'' + k + '\');_benchUpdateUI()">' + lbl + '</button>';
}).join('') + '</div>';
if (e.kind !== 'plane') h += _benchRow('R, px', _benchSlider(e.id, 'R', 100, 600, 10, e.R));
h += _benchRow('Апертура', _benchSlider(e.id, 'ap', 30, 130, 5, e.ap));
h += _benchBtnRow(['plane:Плоск', 'concave:Вогн', 'convex:Выпукл'],
k => e.kind === k, k => "benchUpdate(" + e.id + ",'kind','" + k + "');_benchUpdateUI()");
if (e.kind !== 'plane') h += _benchCtl('R, px', e.id, 'R', 100, 600, 10, e.R);
h += _benchCtl('Апертура', e.id, 'ap', 30, 130, 5, e.ap);
} else if (e.type === 'aperture') {
h += _benchRow('Зазор', _benchSlider(e.id, 'gap', 5, 110, 2, e.gap));
h += _benchCtl('Зазор', e.id, 'gap', 5, 110, 2, e.gap);
} else if (e.type === 'prism') {
h += _benchRow('Угол', _benchSlider(e.id, 'apex', 20, 70, 1, e.apex));
h += _benchRow('n', _benchSlider(e.id, 'n', 1.3, 1.9, 0.01, e.n));
h += _benchRow('Размер', _benchSlider(e.id, 'size', 50, 130, 5, e.size));
h += _benchCtl('Угол', e.id, 'apex', 20, 70, 1, e.apex);
h += _benchCtl('n', e.id, 'n', 1.3, 1.9, 0.01, e.n);
h += _benchCtl('Размер', e.id, 'size', 50, 130, 5, e.size);
} else if (e.type === 'interface') {
h += _benchCtl('n слева', e.id, 'n1', 1.0, 2.4, 0.01, e.n1);
h += _benchCtl('n справа', e.id, 'n2', 1.0, 2.4, 0.01, e.n2);
} else if (e.type === 'slab') {
h += _benchCtl('n', e.id, 'n', 1.1, 2.0, 0.01, e.n);
h += _benchCtl('Толщина', e.id, 't', 20, 140, 5, e.t);
} else if (e.type === 'screen') {
h += '<div class="pp-hint">Экран ловит изображение.</div>';
}
h += '<button class="preset-btn" style="width:100%;margin-top:6px;color:#EF476F" onclick="benchRemove(' + e.id + ')">Удалить элемент</button>';
return h;
}
function _benchSourceSlider(key, min, max, step, val) {
return '<input type="range" min="' + min + '" max="' + max + '" step="' + step + '" value="' + val +
'" oninput="benchSourceParam(\'' + key + '\',this.value)" style="flex:1">';
}
function _benchUpdateUI() {
if (!benchSim) return;
const listEl = document.getElementById('bench-list');
+3 -1
View File
@@ -3194,6 +3194,8 @@
<button class="preset-btn" style="font-size:.68rem" onclick="benchAdd('aperture')">+ Диафрагма</button>
<button class="preset-btn" style="font-size:.68rem" onclick="benchAdd('screen')">+ Экран</button>
<button class="preset-btn" style="font-size:.68rem" onclick="benchAdd('prism')">+ Призма</button>
<button class="preset-btn" style="font-size:.68rem" onclick="benchAdd('interface')">+ Граница</button>
<button class="preset-btn" style="font-size:.68rem" onclick="benchAdd('slab')">+ Пластина</button>
</div>
<div class="gp-section-title" style="margin-bottom:6px">Схема</div>
<div id="bench-list" style="display:flex;flex-wrap:wrap;gap:3px;margin-bottom:8px"></div>
@@ -4844,7 +4846,7 @@
<script src="/js/labs/graphtransform.js"></script>
<script src="/js/labs/pendulum.js"></script>
<script src="/js/labs/equilibrium.js"></script>
<script src="/js/labs/opticsbench.js?v=4"></script>
<script src="/js/labs/opticsbench.js?v=5"></script>
<script src="/js/labs/isoprocess.js"></script>
<script src="/js/labs/titration.js"></script>
<script src="/js/labs/probability.js"></script>
+8 -1
View File
@@ -39,7 +39,14 @@
- [x] 3.2 Пресеты систем (микроскоп/телескоп/проектор/зеркальная) — Фаза 1; экспорт PNG (`benchExportPng`, кнопка «Снимок PNG»).
- [x] 3.3 Экран ловит изображение: светящиеся пятна (additive `lighter`) в точках попадания лучей, по λ — видно формирование изображения и спектр.
Бэклог: точная двухгранная призма (Снеллиус на гранях вместо тонкопризменного), апертурное отсечение лучей вне линзы (сейчас проходят прямо), профиль интенсивности на экране, поворот элементов, удаление legacy `FreeBuildSim`.
### Фаза 4 — Контент и улучшения — [x]
- [x] Источники: **одиночный луч** и **лазер** (узкий пучок) + **угол прицеливания** (point/single/laser/parallel наклоняются на `ang`).
- [x] Новые элементы: **граница сред** (Снеллиус на вертикальной плоскости + ПВО — проверено: 30°→19.47°, ПВО при 50°) и **стеклянная пластина** (параллельный сдвиг, преломление на входе/выходе).
- [x] Отсечение апертурой: лучи вне апертуры линзы/зеркала поглощаются (видно виньетирование, размер апертуры значим).
- [x] Метки **F и 2F** у собирающей линзы.
- [x] Числовые значения у слайдеров инспектора (живое обновление без пересборки панели).
Бэклог: точная двухгранная призма (Снеллиус на гранях вместо тонкопризменного); профиль интенсивности на экране; поворот/наклон элементов и 2D-перетаскивание (yf); делитель пучка (форк луча); удаление legacy `FreeBuildSim`.
---
История: создан 2026-05-30.