diff --git a/frontend/js/labs/opticsbench.js b/frontend/js/labs/opticsbench.js
index 125f869..5624d87 100644
--- a/frontend/js/labs/opticsbench.js
+++ b/frontend/js/labs/opticsbench.js
@@ -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: 2–3 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;
diff --git a/frontend/lab.html b/frontend/lab.html
index 567b3fb..b962132 100644
--- a/frontend/lab.html
+++ b/frontend/lab.html
@@ -4847,7 +4847,7 @@
-
+
@@ -4864,7 +4864,7 @@
-
+