feat(labs): wave 2 — depth features across 6 sims

Электрические цепи (circuit):
- Индуктивность L как новый компонент (1–1000 мГн, шорт в DC, jωL в AC)
- RLC preset для демонстрации резонанса
- Осциллограф: U(t)/I(t) для выбранного компонента, 100 sample, dual-axis
- Heatmap мощности: радиальный градиент halo от blue→red пропорционально P=UI

Стереометрия 3D (stereo):
- Сечение через 3 произвольные точки: pick на гранях/рёбрах/вершинах
- Плоскость + полигон пересечения с авто-определением типа (3–6-угольник) и площадью
- Step-by-step режим: визуализация P1→линия→P2→линия→P3→плоскость→сечение
- Поддержка всех solids (включая cylinder/cone через sampling fallback)

Планиметрия (geometry):
- Задачник framework: CHALLENGES[] с setup/check функциями
- 5 стартовых задач: серединный перпендикуляр, биссектриса, описанная окружность, ГМТ, касательная
- Авто-checker: толерантности ±0.5° для углов, ±1–5% для расстояний
- UI: collapsible панель с статус-иконками, конфетти + «Молодец!» на success

Электромагнитные поля (emfield):
- Preset «Тороид»: 16+16 проводов в концентрических кольцах
- Поверхность Гаусса: draggable круг, считает Φ = q_enc/ε₀, подсвечивает охваченные заряды
- Motional EMF: draggable rod, arrow-keys управление, считает ε = ∫(v×B)·dl

Химическая песочница (chemsandbox):
- Live-overlay с уравнением реакции: молекулярное / полное ионное / сокращённое ионное
- Coverage: 49/49 молекулярных, 34/49 ионных, 36/49 сокращённых
- Auto-hide через 5 сек, fade-in animation, цветовая кодировка типов

Волны и звук (waves):
- Doppler: source+observer drag, expanding wavefronts, f_obs формула, Mach cone при v>c
- Beats: f1+f2, sum waveform с envelope, индикация f_beat=|f1-f2|
- Spectrum (DFT): N=256 samples pure JS, bar-chart с пиками и labels, «Добавить гармонику»

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-23 12:48:14 +03:00
parent 7f75c96acd
commit 8f30a8cef6
8 changed files with 2367 additions and 36 deletions
+461
View File
@@ -3240,3 +3240,464 @@ class GeoSim {
/* ── trig circle ── */
/* ══════════════════════════════════════════════════════════════════════
ЗАДАЧНИК — challenge framework
══════════════════════════════════════════════════════════════════════ */
/**
* Helper: get two math-coordinate points on any line-like object.
* Works for 'segment', 'line', 'ray', 'derived_line'.
*/
function _challTwoPts(eng, obj) {
if (!obj) return null;
if (obj.type === 'derived_line') {
return [{ x: obj.ptX, y: obj.ptY },
{ x: obj.ptX + obj.dirX, y: obj.ptY + obj.dirY }];
}
const p1 = eng.get(obj.p1Id), p2 = eng.get(obj.p2Id);
if (!p1 || !p2) return null;
return [{ x: p1.x, y: p1.y }, { x: p2.x, y: p2.y }];
}
/**
* Find all "line-like" objects (line, ray, segment, derived_line).
*/
function _challLines(eng) {
return eng.all().filter(o =>
o.type === 'line' || o.type === 'ray' ||
o.type === 'segment' || o.type === 'derived_line'
);
}
/**
* Normalise direction: always returns { dx, dy } with dy >= 0
* (or dx > 0 when dy == 0), for comparing line directions.
*/
function _challNormDir(dx, dy) {
const len = Math.hypot(dx, dy);
if (len < 1e-12) return { dx: 0, dy: 0 };
let nx = dx / len, ny = dy / len;
if (ny < 0 || (Math.abs(ny) < 1e-9 && nx < 0)) { nx = -nx; ny = -ny; }
return { dx: nx, dy: ny };
}
const CHALLENGES = [
/* ── C1: Серединный перпендикуляр ──────────────────────────────── */
{
id: 'C1',
title: 'Серединный перпендикуляр к AB',
desc: 'Постройте серединный перпендикуляр к отрезку AB. ' +
'Используйте инструмент «⊥ биссект.» или постройте вручную ' +
'(прямую через середину AB, перпендикулярную AB).',
hint: 'Воспользуйтесь инструментом «⊥ биссект.» — кликните точки A и B.',
setup(eng) {
eng.clear();
const A = eng.add({ type:'point', x:-2, y:0, label:'A' });
const B = eng.add({ type:'point', x: 2, y:0, label:'B' });
eng.add({ type:'segment', p1Id:A.id, p2Id:B.id });
},
check(eng) {
// Find A and B by label
const pts = eng.points();
const A = pts.find(p => p.label === 'A');
const B = pts.find(p => p.label === 'B');
if (!A || !B) return { passed: false, hint: 'Не найдены точки A и B.' };
const mid = { x: (A.x + B.x) / 2, y: (A.y + B.y) / 2 };
const segDx = B.x - A.x, segDy = B.y - A.y;
const segLen = Math.hypot(segDx, segDy);
if (segLen < 1e-9) return { passed: false };
// Perpendicular direction to AB
const perpDx = -segDy / segLen, perpDy = segDx / segLen;
for (const obj of _challLines(eng)) {
const pts2 = _challTwoPts(eng, obj);
if (!pts2) continue;
const [P1, P2] = pts2;
const dx = P2.x - P1.x, dy = P2.y - P1.y;
const len2 = Math.hypot(dx, dy);
if (len2 < 1e-9) continue;
// Check direction is perpendicular to AB (dot product with AB dir ≈ 0)
const dot = (dx / len2) * segDx / segLen + (dy / len2) * segDy / segLen;
if (Math.abs(dot) > 0.02) continue; // not perpendicular
// Check passes through midpoint
const distToMid = gDistToLine(mid, P1, P2);
if (distToMid < 0.05) return { passed: true };
}
return { passed: false,
hint: 'Нужна прямая, проходящая через середину AB и перпендикулярная AB.' };
}
},
/* ── C2: Биссектриса угла ───────────────────────────────────────── */
{
id: 'C2',
title: 'Биссектриса угла',
desc: 'Постройте биссектрису угла с вершиной V. ' +
'Используйте инструмент «∠ биссект.» (три клика: A, вершина V, B).',
hint: 'Инструмент «∠ биссект.»: кликните точку A, затем V (вершину), затем B.',
setup(eng) {
eng.clear();
const V = eng.add({ type:'point', x:0, y:0, label:'V' });
const A = eng.add({ type:'point', x:-3, y:0, label:'A' });
const B = eng.add({ type:'point', x:0, y:3, label:'B' });
eng.add({ type:'ray', p1Id:V.id, p2Id:A.id });
eng.add({ type:'ray', p1Id:V.id, p2Id:B.id });
},
check(eng) {
const pts = eng.points();
const V = pts.find(p => p.label === 'V');
const A = pts.find(p => p.label === 'A');
const B = pts.find(p => p.label === 'B');
if (!V || !A || !B) return { passed: false, hint: 'Не найдены точки V, A, B.' };
// Expected bisector direction
const va = gNorm({ x: A.x - V.x, y: A.y - V.y });
const vb = gNorm({ x: B.x - V.x, y: B.y - V.y });
const bisDir = gNorm({ x: va.x + vb.x, y: va.y + vb.y });
if (Math.hypot(bisDir.x, bisDir.y) < 1e-9)
return { passed: false, hint: 'Угол вырожден.' };
// Half-angle for tolerance: ±0.5°
const halfAngleDeg = gAngleDeg(A, V, B) / 2;
const TOL_DEG = 0.5;
for (const obj of _challLines(eng)) {
const pts2 = _challTwoPts(eng, obj);
if (!pts2) continue;
const [P1, P2] = pts2;
// Must pass through V
const distV = gDistToLine({ x: V.x, y: V.y }, P1, P2);
if (distV > 0.08) continue;
// Direction must match bisector
const dx = P2.x - P1.x, dy = P2.y - P1.y;
const len = Math.hypot(dx, dy);
if (len < 1e-9) continue;
const crossAbs = Math.abs((dx / len) * bisDir.y - (dy / len) * bisDir.x);
// sin(angle between lines) = crossAbs; for ±0.5° sin(0.5°) ≈ 0.0087
if (crossAbs < Math.sin(TOL_DEG * Math.PI / 180)) return { passed: true };
}
return { passed: false,
hint: 'Нужен луч/прямая из V, делящая угол AVB пополам.' };
}
},
/* ── C3: Описанная окружность вокруг треугольника ───────────────── */
{
id: 'C3',
title: 'Описанная окружность треугольника',
desc: 'Постройте окружность, проходящую через все три вершины треугольника ABC. ' +
'Используйте инструмент «Описанная» (circumcircle).',
hint: 'Инструмент «Описанная»: кликните три вершины A, B, C — окружность строится автоматически.',
setup(eng) {
eng.clear();
const A = eng.add({ type:'point', x:-2, y:-1.5, label:'A' });
const B = eng.add({ type:'point', x: 2, y:-1.5, label:'B' });
const C = eng.add({ type:'point', x: 0, y: 2, label:'C' });
eng.add({ type:'polygon', pointIds:[A.id, B.id, C.id] });
},
check(eng) {
const pts = eng.points();
const A = pts.find(p => p.label === 'A');
const B = pts.find(p => p.label === 'B');
const C = pts.find(p => p.label === 'C');
if (!A || !B || !C) return { passed: false, hint: 'Не найдены вершины A, B, C.' };
// Look for any circle passing through A, B, C within 1%
for (const circ of eng.byType('circle')) {
let cx, cy, r;
if (circ.derived && circ.cx != null) {
cx = circ.cx; cy = circ.cy; r = circ.r;
} else {
const ctr = eng.get(circ.centerId);
const edg = eng.get(circ.edgeId);
if (!ctr || !edg) continue;
cx = ctr.x; cy = ctr.y;
r = gDist({ x: cx, y: cy }, { x: edg.x, y: edg.y });
}
if (r < 1e-9) continue;
const O = { x: cx, y: cy };
const rA = gDist(O, A), rB = gDist(O, B), rC = gDist(O, C);
const tol = r * 0.05; // 5% — generous for hand-built circumcircles
if (Math.abs(rA - r) < tol && Math.abs(rB - r) < tol && Math.abs(rC - r) < tol)
return { passed: true };
}
return { passed: false,
hint: 'Постройте окружность, равноудалённую от A, B и C.' };
}
},
/* ── C4: ГМТ — множество точек, равноудалённых от A и B ────────── */
{
id: 'C4',
title: 'ГМТ: равноудалённые от A и B',
desc: 'Постройте геометрическое место точек, равноудалённых от точек A и B. ' +
'Подсказка: это серединный перпендикуляр AB. ' +
'Используйте инструмент «ГМТ» (locus): ' +
'создайте скользящую точку на окружности или отрезке, ' +
'затем постройте из неё точку-цель, равноудалённую от A и B.',
hint: 'Самый простой способ: серединный перпендикуляр к AB — это и есть ГМТ. ' +
'Используйте инструмент «⊥ биссект.» или locus.',
setup(eng) {
eng.clear();
const A = eng.add({ type:'point', x:-2, y:0, label:'A' });
const B = eng.add({ type:'point', x: 2, y:0, label:'B' });
},
check(eng) {
// Accept: any locus object OR any line that is the perpendicular bisector of AB
const pts = eng.points();
const A = pts.find(p => p.label === 'A');
const B = pts.find(p => p.label === 'B');
if (!A || !B) return { passed: false };
// Accept a locus object (heuristic: it exists)
if (eng.byType('locus').length > 0) return { passed: true };
// Accept a perpendicular bisector line/derived_line through midpoint
const mid = { x: (A.x + B.x) / 2, y: (A.y + B.y) / 2 };
const segLen = gDist(A, B);
if (segLen < 1e-9) return { passed: false };
const segDx = (B.x - A.x) / segLen, segDy = (B.y - A.y) / segLen;
for (const obj of _challLines(eng)) {
const pts2 = _challTwoPts(eng, obj);
if (!pts2) continue;
const [P1, P2] = pts2;
const dx = P2.x - P1.x, dy = P2.y - P1.y;
const len = Math.hypot(dx, dy);
if (len < 1e-9) continue;
const dot = Math.abs((dx / len) * segDx + (dy / len) * segDy);
if (dot > 0.02) continue; // not perpendicular to AB
if (gDistToLine(mid, P1, P2) < 0.08) return { passed: true };
}
return { passed: false,
hint: 'Постройте серединный перпендикуляр к AB или используйте инструмент ГМТ.' };
}
},
/* ── C5: Касательная к окружности ──────────────────────────────── */
{
id: 'C5',
title: 'Касательная к окружности',
desc: 'Постройте касательную к окружности из внешней точки P. ' +
'Используйте инструмент «Касательные» (tangent): кликните на окружность, ' +
'затем на внешнюю точку P.',
hint: 'Инструмент «Касательные»: сначала кликните на окружность, потом на точку P.',
setup(eng) {
eng.clear();
const center = eng.add({ type:'point', x: 0, y: 0, label:'O' });
const edge = eng.add({ type:'point', x: 2, y: 0, label:'R' });
eng.add({ type:'circle', centerId: center.id, edgeId: edge.id });
eng.add({ type:'point', x: 5, y: 0, label:'P' });
},
check(eng) {
// Find the setup circle and P
const pts = eng.points();
const O = pts.find(p => p.label === 'O');
const P = pts.find(p => p.label === 'P');
if (!O || !P) return { passed: false };
// Find circle with center O
let circR = null;
for (const circ of eng.byType('circle')) {
const ctr = eng.get(circ.centerId);
if (ctr && Math.abs(ctr.x - O.x) < 0.01 && Math.abs(ctr.y - O.y) < 0.01) {
const edg = eng.get(circ.edgeId);
if (edg) { circR = gDist({ x: O.x, y: O.y }, { x: edg.x, y: edg.y }); break; }
}
}
if (!circR) return { passed: false, hint: 'Исходная окружность не найдена.' };
const Opt = { x: O.x, y: O.y };
const TOL = circR * 0.05; // 5% of radius
// Look for any line/ray through P where distance from O to line ≈ radius
for (const obj of _challLines(eng)) {
const pts2 = _challTwoPts(eng, obj);
if (!pts2) continue;
const [P1, P2] = pts2;
// Must pass through P (within tolerance)
const distP = gDistToLine({ x: P.x, y: P.y }, P1, P2);
if (distP > 0.15) continue;
// Distance from center O to the line ≈ radius
const distO = gDistToLine(Opt, P1, P2);
if (Math.abs(distO - circR) < TOL) return { passed: true };
}
return { passed: false,
hint: 'Постройте прямую через P, касающуюся окружности с центром O.' };
}
},
];
/* ── Challenge state ─────────────────────────────────────────────── */
let _challState = CHALLENGES.map(() => 'locked');
_challState[0] = 'current'; // first challenge is unlocked
let _challAttempts = CHALLENGES.map(() => 0); // fail attempt counter
let _challPanelOpen = false;
function geoToggleChallengePanel() {
_challPanelOpen = !_challPanelOpen;
const panel = document.getElementById('geo-challenge-panel');
if (panel) panel.classList.toggle('open', _challPanelOpen);
if (_challPanelOpen) _geoChallRenderList();
}
function _geoChallRenderList() {
const list = document.getElementById('geo-chall-list');
if (!list) return;
list.innerHTML = '';
const doneCount = _challState.filter(s => s === 'done').length;
const countEl = document.getElementById('geo-chall-count');
if (countEl) countEl.textContent = doneCount + '/' + CHALLENGES.length;
CHALLENGES.forEach((ch, idx) => {
const state = _challState[idx];
const item = document.createElement('div');
item.className = 'geo-chall-item chall-' + state;
item.dataset.idx = idx;
// Status icon
let statusContent = (idx + 1).toString();
if (state === 'done') {
statusContent = '<svg viewBox="0 0 24 24" fill="none" stroke="#4ADE80" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="width:11px;height:11px"><polyline points="20 6 9 17 4 12"/></svg>';
} else if (state === 'locked') {
statusContent = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:10px;height:10px"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
}
item.innerHTML = `
<div class="geo-chall-head">
<div class="geo-chall-status">${statusContent}</div>
<div class="geo-chall-name">${ch.title}</div>
</div>
<div class="geo-chall-body">
<div class="geo-chall-desc">${ch.desc}</div>
<div class="geo-chall-actions">
<button class="geo-chall-btn geo-chall-btn-check" onclick="geoChallCheck(${idx})">Проверить</button>
<button class="geo-chall-btn geo-chall-btn-reset" onclick="geoChallSetup(${idx})" title="Начать заново">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="width:12px;height:12px"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-3.95"/></svg>
</button>
</div>
<div class="geo-chall-hint${_challAttempts[idx] >= 2 ? ' visible' : ''}" id="geo-chall-hint-${idx}">${ch.hint}</div>
<div class="geo-chall-feedback" id="geo-chall-fb-${idx}"></div>
</div>`;
// Allow expanding done items too
item.querySelector('.geo-chall-head').addEventListener('click', () => {
item.classList.toggle('geo-chall-expanded');
});
list.appendChild(item);
});
}
function geoChallSetup(idx) {
if (!geomSim) return;
const ch = CHALLENGES[idx];
if (!ch) return;
ch.setup(geomSim.eng);
// Recompute all derived objects after setup
for (const obj of geomSim.eng.all()) {
if (obj.derived) geomSim.eng.recompute(obj.id);
}
geomSim.fit();
geomSim.render();
_geoUpdateStats();
const fb = document.getElementById('geo-chall-fb-' + idx);
if (fb) { fb.textContent = ''; fb.className = 'geo-chall-feedback'; }
}
function geoChallCheck(idx) {
if (!geomSim) return;
const ch = CHALLENGES[idx];
if (!ch || _challState[idx] === 'locked') return;
const result = ch.check(geomSim.eng);
const fb = document.getElementById('geo-chall-fb-' + idx);
if (result.passed) {
_challState[idx] = 'done';
// Unlock next
if (idx + 1 < CHALLENGES.length && _challState[idx + 1] === 'locked') {
_challState[idx + 1] = 'current';
}
if (fb) { fb.textContent = 'Верно!'; fb.className = 'geo-chall-feedback ok'; }
_geoChallSuccessBurst();
_geoChallRenderList();
} else {
_challAttempts[idx]++;
const msg = result.hint
? result.hint
: 'Не совсем. Попробуй ещё раз.';
if (fb) { fb.textContent = msg; fb.className = 'geo-chall-feedback err'; }
// Show hint after 2 fails
if (_challAttempts[idx] >= 2) {
const hintEl = document.getElementById('geo-chall-hint-' + idx);
if (hintEl) hintEl.classList.add('visible');
}
}
}
function _geoChallSuccessBurst() {
const outer = document.querySelector('.geo-canvas-outer');
if (!outer) return;
// "Молодец!" label
const label = document.createElement('div');
label.className = 'geo-chall-success-label';
label.textContent = 'Молодец!';
outer.appendChild(label);
setTimeout(() => label.remove(), 2400);
// Confetti particles on canvas
if (!geomSim) return;
const canvas = geomSim.canvas;
const ctx = geomSim.ctx;
const W = canvas.width, H = canvas.height;
const particles = [];
const colors = ['#4ADE80', '#34D399', '#A78BFA', '#60A5FA', '#FBBF24', '#F472B6'];
for (let i = 0; i < 60; i++) {
particles.push({
x: W / 2 + (Math.random() - 0.5) * W * 0.4,
y: H / 2 + (Math.random() - 0.5) * H * 0.3,
vx: (Math.random() - 0.5) * 5,
vy: (Math.random() - 0.6) * 6,
r: 3 + Math.random() * 4,
color: colors[Math.floor(Math.random() * colors.length)],
alpha: 1,
rot: Math.random() * Math.PI * 2,
rotV: (Math.random() - 0.5) * 0.3,
});
}
let frame = 0;
const maxFrames = 60;
function burst() {
if (frame >= maxFrames) { geomSim.render(); return; }
geomSim.render();
for (const p of particles) {
ctx.save();
ctx.globalAlpha = p.alpha;
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.fillStyle = p.color;
ctx.fillRect(-p.r / 2, -p.r / 2, p.r, p.r * 1.6);
ctx.restore();
p.x += p.vx;
p.y += p.vy;
p.vy += 0.18; // gravity
p.alpha -= 1 / maxFrames;
p.rot += p.rotV;
}
frame++;
requestAnimationFrame(burst);
}
requestAnimationFrame(burst);
}