feat(opticsbench): учебное построение характеристических лучей

Для «Предмет» + «Характ. лучи» (один предмет, одна линза):
- подписи лучей 1/2/3 у предмета
- точка изображения = пересечение финальных отрезков лучей 1 и 2
- стрелка-изображение (основание на оси → вершина в точке изображения)
- мнимое изображение: пунктирные продления расходящихся лучей назад к
  мнимой точке (слева от линзы); подпись «изображение»/«мнимое изобр.»
- проверено численно: предмет за 2F → реальное справа, внутри F → мнимое слева
- bump opticsbench.js?v=10

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-30 13:33:46 +03:00
parent 4b7939aba8
commit 81d4c15442
2 changed files with 67 additions and 9 deletions
+65 -7
View File
@@ -2621,8 +2621,8 @@ class BenchSim {
const rays = [];
// white light → one sub-ray per spectral sample (they coincide until a prism disperses them)
const wls = window._obWhiteLight ? OB_SPECTRAL.map(s => s.nm) : [window._obWavelength || 540];
const push = (x, y, ang) => {
for (const wl of wls) rays.push({ x, y, dx: Math.cos(ang), dy: Math.sin(ang), wl, pts: [{ x, y }], alive: true, bounces: 0 });
const push = (x, y, ang, role) => {
for (const wl of wls) rays.push({ x, y, dx: Math.cos(ang), dy: Math.sin(ang), wl, role: role || null, pts: [{ x, y }], alive: true, bounces: 0 });
};
const aim = (this.source.ang || 0) * Math.PI / 180;
if (this.source.kind === 'single') {
@@ -2652,11 +2652,11 @@ class BenchSim {
// textbook construction: 23 characteristic rays from the tip + axial ray from the base
const lensX = firstLens.xf * this.W, f = firstLens.f;
const aimAt = (tx, ty) => Math.atan2(ty - tipY, tx - sx);
push(sx, tipY, 0); // 1) parallel to axis → through far focus F'
push(sx, tipY, aimAt(lensX, axis)); // 2) through the optical centre → straight
const Fx = lensX - f; // front focal point
if (f > 0 && Fx > sx + 5) push(sx, tipY, aimAt(Fx, axis)); // 3) through F → emerges parallel
push(sx, baseY, 0); // base lies on the axis
push(sx, tipY, 0, 'char1'); // 1) parallel to axis → through far focus F'
push(sx, tipY, aimAt(lensX, axis), 'char2'); // 2) through the optical centre → straight
const Fx = lensX - f; // front focal point
if (f > 0 && Fx > sx + 5) push(sx, tipY, aimAt(Fx, axis), 'char3'); // 3) through F → emerges parallel
push(sx, baseY, 0, 'base'); // base lies on the axis
} else {
// physical bundle: a fan from tip and base
const n = Math.max(2, this.source.rays | 0), A = this.source.spread;
@@ -2810,6 +2810,11 @@ class BenchSim {
// image formed on screens (where rays land)
this._drawScreenHits(ctx, rays);
// textbook construction overlay (labels, image arrow, dashed extensions)
if (this.source.kind === 'object' && (this.source.rayMode || 'char') === 'char') {
this._drawCharConstruction(ctx, rays, ay);
}
if (typeof _drawOBFXLayer === 'function') {
// FX anchored at the actual source point (only the object arrow has a raised tip)
const fxY = this._sy() - (this.source.kind === 'object' ? this.source.h : 0);
@@ -2837,6 +2842,59 @@ class BenchSim {
ctx.restore();
}
_lineIntersect(a, b) {
const den = a.dx * b.dy - a.dy * b.dx;
if (Math.abs(den) < 1e-9) return null; // parallel → no finite intersection
const t = ((b.x - a.x) * b.dy - (b.y - a.y) * b.dx) / den;
return { x: a.x + t * a.dx, y: a.y + t * a.dy };
}
// Textbook overlay for single-lens characteristic construction:
// labels 1/2/3, the image arrow, and dashed back-extensions for a virtual image.
_drawCharConstruction(ctx, rays, axis) {
const lenses = this.elements.filter(e => e.type === 'lens');
if (lenses.length !== 1) return; // construction is only clean for one lens
const lensX = this._ex(lenses[0]);
ctx.save();
// ── ray labels 1/2/3 near the object ──
ctx.font = 'bold 11px Manrope, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const labelRay = (role, txt) => {
const r = rays.find(x => x.role === role); if (!r || r.pts.length < 2) return;
const p0 = r.pts[0], p1 = r.pts[1];
const d = Math.hypot(p1.x - p0.x, p1.y - p0.y) || 1;
const k = Math.min(40, d * 0.45);
const lx = p0.x + (p1.x - p0.x) / d * k, ly = p0.y + (p1.y - p0.y) / d * k;
ctx.fillStyle = 'rgba(13,13,26,0.7)'; ctx.beginPath(); ctx.arc(lx, ly, 8, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#7BF5A4'; ctx.fillText(txt, lx, ly);
};
labelRay('char1', '1'); labelRay('char2', '2'); labelRay('char3', '3');
// ── image point = intersection of the final segments of rays 1 and 2 ──
const finalLine = (role) => {
const r = rays.find(x => x.role === role); if (!r || r.pts.length < 2) return null;
const b = r.pts[r.pts.length - 1], a = r.pts[r.pts.length - 2];
return { x: a.x, y: a.y, dx: b.x - a.x, dy: b.y - a.y };
};
const L1 = finalLine('char1'), L2 = finalLine('char2');
const P = (L1 && L2) ? this._lineIntersect(L1, L2) : null;
if (P && isFinite(P.x) && isFinite(P.y)) {
const real = P.x > lensX + 2; // real image forms to the right of the lens
const col = real ? '#EF476F' : '#FFD166';
if (!real) {
// virtual image: extend the diverging rays backward (dashed) to the apparent source P
ctx.strokeStyle = col; ctx.setLineDash([5, 4]); ctx.lineWidth = 1;
[L1, L2].forEach(L => { if (!L) return; ctx.beginPath(); ctx.moveTo(L.x, L.y); ctx.lineTo(P.x, P.y); ctx.stroke(); });
ctx.setLineDash([]);
}
this._arrow(ctx, P.x, axis, P.x, P.y, col); // image arrow: base (axis) → tip (P)
ctx.fillStyle = col; ctx.beginPath(); ctx.arc(P.x, P.y, 3.5, 0, Math.PI * 2); ctx.fill();
ctx.font = '10px Manrope, system-ui, sans-serif';
ctx.fillText(real ? 'изображение' : 'мнимое изобр.', P.x, P.y + (P.y < axis ? -14 : 16));
}
ctx.restore();
}
_drawSource(ctx, _ayIgnored) {
const ay = this._sy(); // draw at the source vertical position
const sx = this.source.xf * this.W;
+2 -2
View File
@@ -4847,7 +4847,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=9"></script>
<script src="/js/labs/opticsbench.js?v=10"></script>
<script src="/js/labs/isoprocess.js"></script>
<script src="/js/labs/titration.js"></script>
<script src="/js/labs/probability.js"></script>
@@ -4864,7 +4864,7 @@
<script src="/js/labs/_periodic_data.js" defer></script>
<script src="/js/labs/periodic.js" defer></script>
<script src="/js/labs/qualanalysis.js" defer></script>
<script src="/js/labs/_pilots.js" defer></script>
<script src="/js/labs/_register-all.js" defer></script>
<script>
/* Sync sound toggle button icon with localStorage state on load */
(function() {