feat(opticsbench): конструктор оптических систем — Фаза 1 (общий трассировщик)
Режим «Цепочка линз» → «Конструктор» на базе нового класса BenchSim: - общий 2D-трассировщик: линза, зеркало (плоск./вогн./выпукл.), диафрагма, экран; источники предмет/точка/параллель; лимит отражений - фокус линзы в x+f и терминация зеркала проверены численно - динамический инспектор: палитра элементов, список схемы, свойства выбранного, удаление; слайдеры перерисовывают только холст (не ломают drag) - pointer-слушатели на canvas (capture, dispose), выбор/перетаскивание - пресеты: микроскоп/телескоп/проектор/зеркальная; сохранение состояния в снимок (_obGetState/_obApplyState); bump opticsbench.js?v=2 - призма — пока грубый placeholder (Снеллиус/дисперсия в Фазе 2) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2532,6 +2532,365 @@ class FreeBuildSim {
|
||||
}
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
4a-BIS. OPTICAL BENCH CONSTRUCTOR — general 2D ray tracer
|
||||
Mixed elements (lens, mirror, aperture, screen, prism) + sources.
|
||||
───────────────────────────────────────────────────────────────*/
|
||||
class BenchSim {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d');
|
||||
this.W = 0; this.H = 0;
|
||||
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 };
|
||||
// elements along the bench, positioned by x-fraction; centred on the axis
|
||||
this.elements = [
|
||||
this._mk('lens', { xf: 0.40, f: 130, ap: 95 }),
|
||||
this._mk('screen', { xf: 0.86 }),
|
||||
];
|
||||
this.selectedId = null;
|
||||
this._bindEvents();
|
||||
this._ro = new ResizeObserver(() => { this.fit(); this.draw(); });
|
||||
this._ro.observe(canvas.parentElement || canvas);
|
||||
}
|
||||
|
||||
_mk(type, p) {
|
||||
const id = this._nextId++;
|
||||
const base = { id, type, xf: p.xf != null ? p.xf : 0.5 };
|
||||
if (type === 'lens') return { ...base, f: p.f != null ? p.f : 130, ap: p.ap || 95 };
|
||||
if (type === 'mirror') return { ...base, kind: p.kind || 'concave', R: p.R != null ? p.R : 320, ap: p.ap || 95 };
|
||||
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 };
|
||||
return base;
|
||||
}
|
||||
|
||||
fit() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = this.canvas.offsetWidth || 600;
|
||||
const h = this.canvas.offsetHeight || 400;
|
||||
this.canvas.width = w * dpr;
|
||||
this.canvas.height = h * dpr;
|
||||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
this.W = w; this.H = h;
|
||||
}
|
||||
|
||||
/* ── element API (used by the inspector) ── */
|
||||
addElement(type) {
|
||||
const xf = Math.min(0.92, (this.elements.length ? Math.max(...this.elements.map(e => e.xf)) : 0.4) + 0.14);
|
||||
const el = this._mk(type, { xf });
|
||||
this.elements.push(el);
|
||||
this.selectedId = el.id;
|
||||
this._changed();
|
||||
return el;
|
||||
}
|
||||
removeElement(id) {
|
||||
this.elements = this.elements.filter(e => e.id !== id);
|
||||
if (this.selectedId === id) this.selectedId = null;
|
||||
this._changed();
|
||||
}
|
||||
selectElement(id) { this.selectedId = id; this._changed(); }
|
||||
updateElement(id, key, val) {
|
||||
const el = this.elements.find(e => e.id === id);
|
||||
if (!el) return;
|
||||
el[key] = (key === 'kind') ? val : +val;
|
||||
this._redraw(); // canvas only — never rebuild the inspector mid-slider-drag
|
||||
}
|
||||
setSource(key, val) {
|
||||
this.source[key] = (key === 'kind') ? val : +val;
|
||||
this._redraw();
|
||||
}
|
||||
getSelected() { return this.elements.find(e => e.id === this.selectedId) || null; }
|
||||
_redraw() { this.draw(); }
|
||||
_changed() { this.draw(); if (this.onUpdate) this.onUpdate(); } // draw + rebuild inspector
|
||||
|
||||
/* ── geometry helpers ── */
|
||||
_ex(el) { return el.xf * this.W; }
|
||||
_ay() { return this.H / 2; }
|
||||
|
||||
/* Emit the initial rays from the source. */
|
||||
_emitRays() {
|
||||
const ay = this._ay();
|
||||
const sx = this.source.xf * this.W;
|
||||
const rays = [];
|
||||
const push = (x, y, ang) => rays.push({ x, y, dx: Math.cos(ang), dy: Math.sin(ang), pts: [{ x, y }], alive: true, bounces: 0 });
|
||||
if (this.source.kind === 'parallel') {
|
||||
const n = 9, hh = 90;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const y = ay - hh + (2 * hh) * (i / (n - 1));
|
||||
push(sx, y, 0);
|
||||
}
|
||||
} 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)));
|
||||
} else { // object arrow: fan from tip and base
|
||||
const n = this.source.rays, A = this.source.spread;
|
||||
[ay - this.source.h, ay].forEach(y0 => {
|
||||
for (let i = 0; i < n; i++) push(sx, y0, -A + 2 * A * (i / (n - 1)));
|
||||
});
|
||||
}
|
||||
return rays;
|
||||
}
|
||||
|
||||
/* Trace one ray through the system, filling ray.pts. */
|
||||
_traceRay(ray) {
|
||||
const eps = 0.5, maxSteps = 40;
|
||||
const elems = this.elements;
|
||||
for (let step = 0; step < maxSteps && ray.alive; step++) {
|
||||
// find nearest element plane ahead
|
||||
let best = null;
|
||||
for (const el of elems) {
|
||||
const ex = this._ex(el);
|
||||
if (Math.abs(ray.dx) < 1e-6) continue;
|
||||
const t = (ex - ray.x) / ray.dx;
|
||||
if (t > eps && (!best || t < best.t)) best = { t, el, ex };
|
||||
}
|
||||
// boundary intersection
|
||||
const tBound = this._boundT(ray);
|
||||
if (!best || tBound < best.t) {
|
||||
const hx = ray.x + ray.dx * tBound, hy = ray.y + ray.dy * tBound;
|
||||
ray.pts.push({ x: hx, y: hy }); ray.alive = false; break;
|
||||
}
|
||||
// advance to element
|
||||
const hx = ray.x + ray.dx * best.t, hy = ray.y + ray.dy * best.t;
|
||||
ray.x = hx; ray.y = hy;
|
||||
const interacted = this._interact(ray, best.el, hy - this._ay());
|
||||
ray.pts.push({ x: ray.x, y: ray.y });
|
||||
if (!interacted) { ray.x += ray.dx * eps; ray.y += ray.dy * eps; } // missed → step past
|
||||
if (ray.bounces > 16) ray.alive = false;
|
||||
}
|
||||
return ray;
|
||||
}
|
||||
|
||||
_boundT(ray) {
|
||||
const ts = [];
|
||||
if (ray.dx > 1e-9) ts.push((this.W - ray.x) / ray.dx);
|
||||
else if (ray.dx < -1e-9) ts.push((0 - ray.x) / ray.dx);
|
||||
if (ray.dy > 1e-9) ts.push((this.H - ray.y) / ray.dy);
|
||||
else if (ray.dy < -1e-9) ts.push((0 - ray.y) / ray.dy);
|
||||
return ts.length ? Math.min(...ts.filter(t => t > 0)) : 1e6;
|
||||
}
|
||||
|
||||
/* Apply an element. Returns true if it interacted (false = ray missed it). */
|
||||
_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
|
||||
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 (Math.abs(yRel) > el.ap) return false;
|
||||
ray.dx = -ray.dx; // reflect about the vertical plane
|
||||
ray.bounces++;
|
||||
if (el.kind !== 'plane') {
|
||||
const fM = (el.kind === 'concave' ? 1 : -1) * el.R / 2;
|
||||
const sgn = Math.sign(ray.dx) || 1;
|
||||
const w = ray.dy / Math.abs(ray.dx);
|
||||
norm(sgn, w - yRel / fM);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (el.type === 'aperture') {
|
||||
if (Math.abs(yRel) > el.gap) ray.alive = false; // blocked by the stop
|
||||
return true;
|
||||
}
|
||||
if (el.type === 'screen') {
|
||||
ray.hitY = ray.y; ray.alive = false; // absorbed, hit recorded
|
||||
return true;
|
||||
}
|
||||
if (el.type === 'prism') {
|
||||
return this._prismInteract(ray, el, yRel);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Placeholder until Phase 2 (Snell + dispersion); acts as a weak deflector.
|
||||
_prismInteract(ray, el, yRel) {
|
||||
if (Math.abs(yRel) > el.size) return false;
|
||||
const sgn = Math.sign(ray.dx) || 1;
|
||||
const dev = (el.apex / 60) * (el.n - 1) * 0.5; // crude downward deviation toward the base
|
||||
const x = sgn, y = ray.dy / Math.abs(ray.dx) + dev;
|
||||
const l = Math.hypot(x, y) || 1;
|
||||
ray.dx = x / l; ray.dy = y / l;
|
||||
return true;
|
||||
}
|
||||
|
||||
draw() {
|
||||
const { ctx, W, H } = this;
|
||||
if (!W || !H) return;
|
||||
const ay = this._ay();
|
||||
ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H);
|
||||
// optical axis
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1; ctx.setLineDash([6, 4]);
|
||||
ctx.beginPath(); ctx.moveTo(0, ay); ctx.lineTo(W, ay); ctx.stroke(); ctx.setLineDash([]);
|
||||
|
||||
// trace rays
|
||||
const rays = this._emitRays();
|
||||
const rayColor = (typeof _obRayColor === 'function') ? _obRayColor(window._obWavelength || 540) : '#06D6E0';
|
||||
ctx.lineWidth = 1.1;
|
||||
for (const ray of rays) {
|
||||
this._traceRay(ray);
|
||||
ctx.strokeStyle = rayColor; ctx.globalAlpha = 0.8;
|
||||
ctx.beginPath();
|
||||
ray.pts.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y));
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// source + elements
|
||||
this._drawSource(ctx, ay);
|
||||
for (const el of this.elements) this._drawElement(ctx, el, ay);
|
||||
|
||||
if (typeof _drawOBFXLayer === 'function') {
|
||||
_drawOBFXLayer(ctx, 'freebuild', { srcX: this.source.xf * W, srcY: ay - (this.source.h || 0) });
|
||||
}
|
||||
}
|
||||
|
||||
_drawSource(ctx, ay) {
|
||||
const sx = this.source.xf * this.W;
|
||||
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 sel = this.selectedId === '__src';
|
||||
ctx.fillStyle = sel ? '#fff' : 'rgba(155,93,229,0.9)';
|
||||
ctx.font = '10px Manrope, system-ui, sans-serif'; ctx.textAlign = 'center';
|
||||
ctx.fillText('источник', sx, ay + 16);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
_drawElement(ctx, el, ay) {
|
||||
const x = this._ex(el);
|
||||
const sel = el.id === this.selectedId;
|
||||
ctx.save();
|
||||
ctx.lineWidth = sel ? 3 : 2;
|
||||
if (el.type === 'lens') {
|
||||
const conv = el.f >= 0;
|
||||
ctx.strokeStyle = sel ? '#fff' : '#06D6E0';
|
||||
ctx.beginPath(); ctx.moveTo(x, ay - el.ap); ctx.lineTo(x, ay + el.ap); ctx.stroke();
|
||||
// arrow tips to denote converging/diverging
|
||||
const tip = conv ? 7 : -7;
|
||||
[[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();
|
||||
});
|
||||
this._elLabel(ctx, x, ay + el.ap + 14, (conv ? 'линза +' : 'линза −') + Math.abs(el.f).toFixed(0));
|
||||
} else if (el.type === 'mirror') {
|
||||
ctx.strokeStyle = sel ? '#fff' : '#A8E063';
|
||||
ctx.beginPath();
|
||||
if (el.kind === 'plane') { ctx.moveTo(x, ay - el.ap); ctx.lineTo(x, ay + el.ap); }
|
||||
else {
|
||||
const bow = (el.kind === 'concave' ? -1 : 1) * 14;
|
||||
ctx.moveTo(x, ay - el.ap);
|
||||
ctx.quadraticCurveTo(x + bow, ay, x, ay + el.ap);
|
||||
}
|
||||
ctx.stroke();
|
||||
// hatch backside
|
||||
ctx.strokeStyle = 'rgba(168,224,99,0.4)'; ctx.lineWidth = 1;
|
||||
for (let yy = -el.ap; yy < el.ap; yy += 12) { ctx.beginPath(); ctx.moveTo(x, ay + yy); ctx.lineTo(x + 6, ay + yy + 6); ctx.stroke(); }
|
||||
this._elLabel(ctx, x, ay + el.ap + 14, 'зеркало ' + ({ plane: 'плоск', concave: 'вогн', convex: 'выпукл' }[el.kind]));
|
||||
} else if (el.type === 'aperture') {
|
||||
ctx.strokeStyle = sel ? '#fff' : '#EF476F'; ctx.lineWidth = sel ? 5 : 4;
|
||||
ctx.beginPath(); ctx.moveTo(x, ay - 110); ctx.lineTo(x, ay - el.gap); ctx.moveTo(x, ay + el.gap); ctx.lineTo(x, ay + 110); ctx.stroke();
|
||||
this._elLabel(ctx, x, ay + 124, 'диафрагма');
|
||||
} else if (el.type === 'screen') {
|
||||
ctx.strokeStyle = sel ? '#fff' : 'rgba(255,255,255,0.7)'; ctx.lineWidth = sel ? 5 : 4;
|
||||
ctx.beginPath(); ctx.moveTo(x, ay - 110); ctx.lineTo(x, ay + 110); ctx.stroke();
|
||||
this._elLabel(ctx, x, ay + 124, 'экран');
|
||||
} else if (el.type === 'prism') {
|
||||
ctx.strokeStyle = sel ? '#fff' : '#FFD166'; ctx.fillStyle = 'rgba(255,209,102,0.12)';
|
||||
ctx.beginPath(); ctx.moveTo(x, ay - el.size); ctx.lineTo(x + el.size * 0.7, ay + el.size); ctx.lineTo(x - el.size * 0.7, ay + el.size); ctx.closePath();
|
||||
ctx.fill(); ctx.stroke();
|
||||
this._elLabel(ctx, x, ay + el.size + 14, 'призма');
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
_elLabel(ctx, x, y, text) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.6)'; ctx.font = '10px Manrope, system-ui, sans-serif';
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText(text, x, y);
|
||||
}
|
||||
|
||||
_arrow(ctx, x0, y0, x1, y1, color) {
|
||||
ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 2.5;
|
||||
ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke();
|
||||
const a = Math.atan2(y1 - y0, x1 - x0);
|
||||
ctx.beginPath(); ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x1 - 9 * Math.cos(a - 0.4), y1 - 9 * Math.sin(a - 0.4));
|
||||
ctx.lineTo(x1 - 9 * Math.cos(a + 0.4), y1 - 9 * Math.sin(a + 0.4));
|
||||
ctx.closePath(); ctx.fill();
|
||||
}
|
||||
|
||||
/* ── interaction: drag + select ── */
|
||||
_bindEvents() {
|
||||
const cv = this.canvas;
|
||||
this._listeners = [];
|
||||
const on = (t, ty, fn, o) => { t.addEventListener(ty, fn, o); this._listeners.push([t, ty, fn, o]); };
|
||||
const pos = (e) => {
|
||||
const r = cv.getBoundingClientRect();
|
||||
const t = e.touches ? e.touches[0] : e;
|
||||
return { mx: (t.clientX - r.left) * (this.W / r.width), my: (t.clientY - r.top) * (this.H / r.height) };
|
||||
};
|
||||
const hit = (mx, my) => {
|
||||
const ay = this._ay();
|
||||
for (const el of this.elements) {
|
||||
if (Math.abs(mx - this._ex(el)) < 14 && Math.abs(my - ay) < 120) return { kind: 'el', id: el.id };
|
||||
}
|
||||
const sx = this.source.xf * this.W;
|
||||
if (Math.abs(mx - sx) < 16 && Math.abs(my - ay) < 120) return { kind: 'src' };
|
||||
return null;
|
||||
};
|
||||
on(cv, 'pointerdown', e => {
|
||||
const { mx, my } = pos(e);
|
||||
const h = hit(mx, my);
|
||||
this._drag = h;
|
||||
if (h) {
|
||||
this.selectedId = h.kind === 'src' ? '__src' : h.id;
|
||||
try { cv.setPointerCapture(e.pointerId); } catch (_) {}
|
||||
this._changed();
|
||||
} else { this.selectedId = null; this._changed(); }
|
||||
});
|
||||
on(cv, 'pointermove', e => {
|
||||
if (!this._drag) { const { mx, my } = pos(e); cv.style.cursor = hit(mx, my) ? 'grab' : 'default'; return; }
|
||||
const { mx } = pos(e);
|
||||
const xf = Math.max(0.02, Math.min(0.98, mx / this.W));
|
||||
if (this._drag.kind === 'src') this.source.xf = xf;
|
||||
else { const el = this.elements.find(x => x.id === this._drag.id); if (el) el.xf = xf; }
|
||||
this._redraw(); // position drag → redraw canvas, keep inspector intact
|
||||
});
|
||||
on(cv, 'pointerup', e => { this._drag = null; try { cv.releasePointerCapture(e.pointerId); } catch (_) {} });
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this._ro) { this._ro.disconnect(); this._ro = null; }
|
||||
if (this._listeners) { for (const [t, ty, fn, o] of this._listeners) t.removeEventListener(ty, fn, o); this._listeners = []; }
|
||||
}
|
||||
|
||||
/* ── state (for snapshot / embed) ── */
|
||||
getState() { return { source: { ...this.source }, elements: this.elements.map(e => ({ ...e })) }; }
|
||||
setState(st) {
|
||||
if (!st) return;
|
||||
if (st.source) this.source = { ...this.source, ...st.source };
|
||||
if (Array.isArray(st.elements)) {
|
||||
this.elements = st.elements.map(e => ({ ...e }));
|
||||
this._nextId = this.elements.reduce((m, e) => Math.max(m, e.id || 0), 0) + 1;
|
||||
}
|
||||
this._changed();
|
||||
}
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
4b. PRISM ENGINE
|
||||
───────────────────────────────────────────────────────────────*/
|
||||
@@ -3192,7 +3551,8 @@ var lensSim = null;
|
||||
var mirrorSim = null;
|
||||
var refrSim = null;
|
||||
var prismSim = null;
|
||||
var freeSim = null; /* multi-lens free-build (Agent OB-A3) */
|
||||
var freeSim = null; /* multi-lens free-build (legacy, superseded by benchSim) */
|
||||
var benchSim = null; /* optical bench constructor (general ray tracer) */
|
||||
var ifSim = null; /* interference/polarization (Agent C) */
|
||||
var _obMode = 'lens'; // current active mode within opticsbench
|
||||
|
||||
@@ -3222,6 +3582,7 @@ function _obGetState() {
|
||||
if (_obMode === 'lens') return { mode: 'lens', ...(lensSim ? lensSim.getParams() : {}) };
|
||||
if (_obMode === 'mirror') return { mode: 'mirror', ...(mirrorSim ? mirrorSim.getParams() : {}) };
|
||||
if (_obMode === 'refraction') return { mode: 'refraction', ...(refrSim ? refrSim.getParams() : {}) };
|
||||
if (_obMode === 'freebuild') return { mode: 'freebuild', bench: benchSim ? benchSim.getState() : null };
|
||||
if (_obMode === 'waves') return { mode: 'waves' };
|
||||
return { mode: _obMode };
|
||||
}
|
||||
@@ -3234,6 +3595,7 @@ function _obApplyState(st) {
|
||||
if (m === 'lens' && lensSim) lensSim.setParams(params);
|
||||
if (m === 'mirror' && mirrorSim) mirrorSim.setParams(params);
|
||||
if (m === 'refraction' && refrSim) refrSim.setParams(params);
|
||||
if (m === 'freebuild' && benchSim && st.bench) { benchSim.setState(st.bench); _benchUpdateUI(); }
|
||||
}
|
||||
|
||||
/* Switch between modes — mirrors emSwitchMode pattern */
|
||||
@@ -3317,15 +3679,15 @@ function obSwitchMode(mode, silent) {
|
||||
}
|
||||
if (prismSim) { prismSim.fit(); prismSim.draw(); }
|
||||
_obDrawSpectrometer();
|
||||
} else if (mode === 'freebuild') { /* Agent OB-A3 — multi-lens free build */
|
||||
if (!freeSim) {
|
||||
} else if (mode === 'freebuild') { /* Optical bench constructor (BenchSim) */
|
||||
if (!benchSim) {
|
||||
const cv = document.getElementById('ob-free-canvas');
|
||||
if (cv) {
|
||||
freeSim = new FreeBuildSim(cv);
|
||||
freeSim.onUpdate = _freeUpdateUI;
|
||||
benchSim = new BenchSim(cv);
|
||||
benchSim.onUpdate = _benchUpdateUI;
|
||||
}
|
||||
}
|
||||
if (freeSim) { freeSim.fit(); freeSim.draw(); _freeUpdateUI(freeSim._computeChain()); }
|
||||
if (benchSim) { benchSim.fit(); benchSim.draw(); _benchUpdateUI(); }
|
||||
} else if (mode === 'waves') { /* Agent B1 — diffraction & interference */
|
||||
if (!diffrSim) {
|
||||
const cv = document.getElementById('ob-waves-canvas');
|
||||
@@ -3952,6 +4314,111 @@ function _freeUpdateUI(chain) {
|
||||
if (el2) el2.textContent = chain.sysFocal !== null ? chain.sysFocal.toFixed(0) : '—';
|
||||
}
|
||||
|
||||
/* ── 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 'Призма';
|
||||
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>';
|
||||
}
|
||||
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 _benchPropsHTML() {
|
||||
if (!benchSim) return '';
|
||||
const sel = benchSim.selectedId;
|
||||
if (sel === '__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));
|
||||
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));
|
||||
} 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));
|
||||
} else if (e.type === 'aperture') {
|
||||
h += _benchRow('Зазор', _benchSlider(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));
|
||||
} 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');
|
||||
if (listEl) {
|
||||
listEl.innerHTML = benchSim.elements.map(e =>
|
||||
'<button class="preset-btn' + (e.id === benchSim.selectedId ? ' active' : '') +
|
||||
'" style="font-size:.68rem" onclick="benchSelect(' + e.id + ')">' + _benchElName(e) + '</button>'
|
||||
).join('') || '<div class="pp-hint">Пусто</div>';
|
||||
}
|
||||
const propsEl = document.getElementById('bench-props');
|
||||
if (propsEl) propsEl.innerHTML = _benchPropsHTML();
|
||||
}
|
||||
function benchAdd(type) { if (benchSim) { benchSim.addElement(type); _benchUpdateUI(); } }
|
||||
function benchRemove(id) { if (benchSim) { benchSim.removeElement(id); _benchUpdateUI(); } }
|
||||
function benchSelect(id) { if (benchSim) { benchSim.selectElement(id); _benchUpdateUI(); } }
|
||||
function benchUpdate(id, k, v) { if (benchSim) benchSim.updateElement(id, k, v); }
|
||||
function benchSourceKind(k) { if (benchSim) { benchSim.setSource('kind', k); _benchUpdateUI(); } }
|
||||
function benchSourceParam(k, v){ if (benchSim) benchSim.setSource(k, v); }
|
||||
function benchClear() {
|
||||
if (!benchSim) return;
|
||||
benchSim.elements = []; benchSim.selectedId = null; benchSim._changed(); _benchUpdateUI();
|
||||
}
|
||||
function benchPreset(name) {
|
||||
if (!benchSim) return;
|
||||
const P = {
|
||||
microscope: { source: { kind: 'object', xf: 0.06, h: 40, spread: 0.32, rays: 9 },
|
||||
elements: [{ type: 'lens', xf: 0.30, f: 45, ap: 90 }, { type: 'lens', xf: 0.66, f: 90, ap: 95 }, { type: 'screen', xf: 0.92 }] },
|
||||
telescope: { source: { kind: 'parallel', xf: 0.05, h: 0, spread: 0.2, rays: 9 },
|
||||
elements: [{ type: 'lens', xf: 0.28, f: 200, ap: 95 }, { type: 'lens', xf: 0.74, f: 60, ap: 80 }] },
|
||||
projector: { source: { kind: 'object', xf: 0.10, h: 80, spread: 0.34, rays: 9 },
|
||||
elements: [{ type: 'lens', xf: 0.40, f: 120, ap: 100 }, { type: 'screen', xf: 0.92 }] },
|
||||
folded: { source: { kind: 'object', xf: 0.08, h: 60, spread: 0.3, rays: 9 },
|
||||
elements: [{ type: 'lens', xf: 0.34, f: 150, ap: 90 }, { type: 'mirror', xf: 0.82, kind: 'concave', R: 320, ap: 100 }, { type: 'screen', xf: 0.50 }] },
|
||||
};
|
||||
const p = P[name]; if (!p) return;
|
||||
let id = 1;
|
||||
benchSim.source = { ...p.source };
|
||||
benchSim.elements = p.elements.map(e => ({ id: id++, ...e }));
|
||||
benchSim._nextId = id;
|
||||
benchSim.selectedId = null;
|
||||
benchSim._changed();
|
||||
_benchUpdateUI();
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
6. DIFFRACTION SIM — Волновая оптика (Юнг / Однощелевая / Решётка)
|
||||
───────────────────────────────────────────────────────────────*/
|
||||
|
||||
+21
-22
@@ -2934,7 +2934,7 @@
|
||||
<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-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>
|
||||
<button id="ob-tab-interf" onclick="obSwitchMode('interf')" 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-waves" onclick="obSwitchMode('waves')" 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>
|
||||
@@ -3186,28 +3186,27 @@
|
||||
</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">− Линза</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 id="ob-ctrl-freebuild" class="proj-panel" style="width:230px;gap:0;flex-shrink:0;display:none;overflow-y:auto">
|
||||
<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>
|
||||
<button class="preset-btn" style="font-size:.68rem" onclick="benchAdd('lens')">+ Линза</button>
|
||||
<button class="preset-btn" style="font-size:.68rem" onclick="benchAdd('mirror')">+ Зеркало</button>
|
||||
<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>
|
||||
</div>
|
||||
<div class="pp-hint">Тащи линзы или предмет по оси мышью</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>
|
||||
<div id="bench-props" style="margin-bottom:8px"></div>
|
||||
<div class="gp-section-title" style="margin-bottom:6px">Системы</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:6px">
|
||||
<button class="preset-btn" style="font-size:.68rem" onclick="benchPreset('microscope')">Микроскоп</button>
|
||||
<button class="preset-btn" style="font-size:.68rem" onclick="benchPreset('telescope')">Телескоп</button>
|
||||
<button class="preset-btn" style="font-size:.68rem" onclick="benchPreset('projector')">Проектор</button>
|
||||
<button class="preset-btn" style="font-size:.68rem" onclick="benchPreset('folded')">Зеркальная</button>
|
||||
</div>
|
||||
<button class="preset-btn" style="width:100%;margin-bottom:6px" onclick="benchClear()">Очистить</button>
|
||||
<div class="pp-hint">Тащи элементы и источник по оси. Клик — выбрать и настроить.</div>
|
||||
</div>
|
||||
<!-- ── Interference control panel (Agent C) ── -->
|
||||
<div id="ob-ctrl-interf" class="proj-panel" style="width:240px;gap:0;flex-shrink:0;display:none">
|
||||
@@ -4842,7 +4841,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"></script>
|
||||
<script src="/js/labs/opticsbench.js?v=2"></script>
|
||||
<script src="/js/labs/isoprocess.js"></script>
|
||||
<script src="/js/labs/titration.js"></script>
|
||||
<script src="/js/labs/probability.js"></script>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Конструктор оптических систем (в составе оптической скамьи)
|
||||
|
||||
Цель: превратить режим «Цепочка линз» (`FreeBuildSim`, только линзы) в полноценный **конструктор оптических систем** на базе общего 2D-трассировщика лучей с разнотипными элементами.
|
||||
|
||||
Файлы: `frontend/js/labs/opticsbench.js` (новый класс `BenchSim`), панель `#ob-ctrl-freebuild` и canvas `ob-free-canvas` в `frontend/lab.html`, вкладка `ob-tab-freebuild` (переименовать «Конструктор»).
|
||||
|
||||
Опирается на: реестр состояния `_registerSimState('opticsbench', …)`, OB_FX-слой, инфраструктуру вкладок `obSwitchMode`.
|
||||
|
||||
Статус: [ ] todo · [~] в работе · [x] готово
|
||||
|
||||
## Архитектура — общий 2D-трассировщик
|
||||
|
||||
- Сцена: горизонтальная ось, элементы на позициях `xf` (0..1 по ширине), центр на оси.
|
||||
- Луч `{x,y, dx,dy(ед.), wl, alive, bounces}`; трассировка: найти ближайший элемент по ходу → применить взаимодействие → продолжить (до выхода за холст / поглощения / лимита отражений).
|
||||
- Источники: предмет-стрелка, точечный, параллельный пучок.
|
||||
- Элементы (interact-функции):
|
||||
- **Линза** (f, апертура): параксиальный «кик» θ' = θ − y/f.
|
||||
- **Зеркало** (плоское/вогнутое/выпуклое, R): разворот хода + кик θ' для сферического (f=R/2).
|
||||
- **Диафрагма/щель** (зазор): поглощает вне зазора.
|
||||
- **Экран/детектор**: поглощает, фиксирует точку (пятно/профиль).
|
||||
- **Призма** (угол при вершине, n): преломление по Снеллиусу на двух гранях + дисперсия по λ.
|
||||
|
||||
## Фазы
|
||||
|
||||
### Фаза 1 — Ядро трассировщика + линза/зеркало/диафрагма/экран + источники — [x]
|
||||
- [x] 1.1 Класс `BenchSim`: модель элементов, источник, выбор/перетаскивание (pointer-слушатели на canvas + capture, dispose).
|
||||
- [x] 1.2 Трассировщик: линза (параксиальный кик, фокус в `x+f` — проверено численно), зеркало плоск./сфер. (разворот хода + кик, лимит отражений — без зацикливания), диафрагма (поглощение вне зазора), экран (фиксация точки); источники предмет/точка/параллель.
|
||||
- [x] 1.3 Рендер: лучи по λ (`_obRayColor`), элементы с подписями, выделение, OB_FX-слой.
|
||||
- [x] 1.4 Динамический инспектор: палитра «+ элемент» (линза/зеркало/диафрагма/экран/призма), список схемы, свойства выбранного, удаление. Слайдеры перерисовывают только холст (не ломают drag).
|
||||
- [x] 1.5 Встроено в режим `freebuild` (вкладка «Конструктор»), пресеты систем (микроскоп/телескоп/проектор/зеркальная), сохранение состояния в снимок.
|
||||
- Призма пока — грубый дефлектор-placeholder (настоящий Снеллиус в 2.2).
|
||||
|
||||
### Фаза 2 — Сферические зеркала + призма + дисперсия — [ ]
|
||||
- [ ] 2.1 Вогнутое/выпуклое зеркало (кик f=R/2, разворот хода, лимит отражений).
|
||||
- [ ] 2.2 Призма: Снеллиус на 2 гранях, дисперсия по длине волны, белый свет.
|
||||
|
||||
### Фаза 3 — Сохранение состояния + полировка — [ ]
|
||||
- [ ] 3.1 Расширить `_obGetState/_obApplyState` на конструктор (снимок/embed).
|
||||
- [ ] 3.2 Пресеты систем (микроскоп, телескоп, глаз, проектор), экспорт PNG.
|
||||
- [ ] 3.3 Полировка: апертурное отсечение, подписи, тач, a11y.
|
||||
|
||||
---
|
||||
История: создан 2026-05-30.
|
||||
Reference in New Issue
Block a user