From 81d4c15442a3dcac46c701a21355685b91d2f0e1 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sat, 30 May 2026 13:33:46 +0300 Subject: [PATCH] =?UTF-8?q?feat(opticsbench):=20=D1=83=D1=87=D0=B5=D0=B1?= =?UTF-8?q?=D0=BD=D0=BE=D0=B5=20=D0=BF=D0=BE=D1=81=D1=82=D1=80=D0=BE=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=85=D0=B0=D1=80=D0=B0=D0=BA=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D0=B8=D1=81=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8?= =?UTF-8?q?=D1=85=20=D0=BB=D1=83=D1=87=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Для «Предмет» + «Характ. лучи» (один предмет, одна линза): - подписи лучей 1/2/3 у предмета - точка изображения = пересечение финальных отрезков лучей 1 и 2 - стрелка-изображение (основание на оси → вершина в точке изображения) - мнимое изображение: пунктирные продления расходящихся лучей назад к мнимой точке (слева от линзы); подпись «изображение»/«мнимое изобр.» - проверено численно: предмет за 2F → реальное справа, внутри F → мнимое слева - bump opticsbench.js?v=10 Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/js/labs/opticsbench.js | 72 +++++++++++++++++++++++++++++---- frontend/lab.html | 4 +- 2 files changed, 67 insertions(+), 9 deletions(-) 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 @@ - +