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
+324 -2
View File
@@ -43,7 +43,7 @@ class StereoSim {
this._clickStart = { x: e.clientX, y: e.clientY };
this._drag = true; this._prevX = e.clientX; this._prevY = e.clientY;
this._autoSpin = false; this._idleTime = 0;
if (!this._pointMode && !this._connectMode && !this._measureMode && !this._angleMode) el.style.cursor = 'grabbing';
if (!this._pointMode && !this._connectMode && !this._measureMode && !this._angleMode && !this._section3PMode) el.style.cursor = 'grabbing';
});
window.addEventListener('pointerup', e => {
const wasDrag = this._clickStart &&
@@ -55,6 +55,7 @@ class StereoSim {
else if (this._angleMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onAngleClick(e); }
else if (this._markMode) { el.style.cursor = 'pointer'; if (!wasDrag) this._onMarkClick(e); }
else if (this._deriveMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onDeriveClick(e); }
else if (this._section3PMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onSection3PClick(e); }
else el.style.cursor = 'grab';
});
window.addEventListener('pointermove', e => {
@@ -120,6 +121,7 @@ class StereoSim {
this._gridGroup = new THREE.Group();
this._markGroup = new THREE.Group();
this._derivedGroup = new THREE.Group();
this._section3PGroup = new THREE.Group();
this.scene.add(this._gridGroup);
this.scene.add(this._figGroup);
this.scene.add(this._sectionGroup);
@@ -128,6 +130,7 @@ class StereoSim {
this.scene.add(this._measurePickGroup);
this.scene.add(this._markGroup);
this.scene.add(this._derivedGroup);
this.scene.add(this._section3PGroup);
this.scene.add(this._labelGroup);
/* state */
@@ -196,6 +199,13 @@ class StereoSim {
/* edge length labels */
this.showEdgeLengths = false;
/* section by 3 arbitrary points */
this._section3PMode = false; // interactive picking active
this._section3PPicks = []; // Vector3[] — up to 3 picked points
this._section3PStepBy = false; // step-by-step visualisation toggle
this._section3PStep = 0; // current step (0=idle, 1..6=sub-steps)
this._section3PData = null; // computed result {normal,D,polygon,area,typeName}
this.onUpdate = null;
this._buildGrid();
@@ -231,6 +241,11 @@ class StereoSim {
this._deriveMode = null;
this._derivePicks = [];
this._clearGroup(this._derivedGroup);
this._section3PPicks = [];
this._section3PData = null;
this._section3PMode = false;
this._section3PStep = 0;
this._clearGroup(this._section3PGroup);
this._buildFigure();
this._notify();
}
@@ -466,6 +481,38 @@ class StereoSim {
this._buildFigure();
}
/* ── Section by 3 arbitrary points ── */
toggleSection3P(on) {
this._section3PMode = on;
// turn off all other interactive modes
this._pointMode = false;
this._connectMode = false;
this._measureMode = false;
this._angleMode = null;
this._markMode = null;
this._deriveMode = null;
this._connectPicks = [];
this.renderer.domElement.style.cursor = on ? 'crosshair' : 'grab';
}
clearSection3P() {
this._section3PPicks = [];
this._section3PData = null;
this._section3PStep = 0;
this._clearGroup(this._section3PGroup);
this._notify();
}
toggleSection3PStepBy(on) {
this._section3PStepBy = on;
// re-render if data already exists
if (this._section3PData) this._drawSection3P();
}
getSection3PInfo() {
return this._section3PData;
}
getFormulas() {
const p = this.params;
const PI = Math.PI;
@@ -1734,6 +1781,213 @@ class StereoSim {
}
}
/* ════════════════ SECTION THROUGH 3 POINTS ════════════════ */
_onSection3PClick(e) {
if (!this._section3PMode) return;
if (this._section3PPicks.length >= 3) return; // already have 3 — need reset first
const { mx, my } = this._screenCoords(e);
// Pick nearest point: prefer vertex snap, then edge snap
let bestDist = 0.09;
let bestPos = null;
for (const v of this._vertices) {
const proj = v.pos.clone().project(this.camera);
const d = Math.sqrt((mx - proj.x) ** 2 + (my - proj.y) ** 2);
if (d < bestDist) { bestDist = d; bestPos = v.pos.clone(); }
}
// Also check custom points if placed
for (const cp of this._customPoints) {
const proj = cp.pos.clone().project(this.camera);
const d = Math.sqrt((mx - proj.x) ** 2 + (my - proj.y) ** 2);
if (d < bestDist) { bestDist = d; bestPos = cp.pos.clone(); }
}
// Edge snap (pick point on edge)
for (const edge of this._edges) {
const p1 = edge.from.clone().project(this.camera);
const p2 = edge.to.clone().project(this.camera);
const dx = p2.x - p1.x, dy = p2.y - p1.y;
const lenSq = dx * dx + dy * dy;
if (lenSq < 1e-9) continue;
let t = ((mx - p1.x) * dx + (my - p1.y) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
const px = p1.x + t * dx, py = p1.y + t * dy;
const d = Math.sqrt((mx - px) ** 2 + (my - py) ** 2);
if (d < bestDist) {
bestDist = d;
bestPos = new THREE.Vector3().lerpVectors(edge.from, edge.to, t);
}
}
if (!bestPos) return;
// Avoid duplicate picks (too close)
for (const p of this._section3PPicks) {
if (p.distanceTo(bestPos) < 0.08) return;
}
this._section3PPicks.push(bestPos);
this._drawSection3P();
if (this._section3PPicks.length === 3) {
this._computeSection3P();
this._drawSection3P();
this._notify();
}
}
_computeSection3P() {
const pts = this._section3PPicks;
if (pts.length < 3) { this._section3PData = null; return; }
const [P1, P2, P3] = pts;
const v1 = new THREE.Vector3().subVectors(P2, P1);
const v2 = new THREE.Vector3().subVectors(P3, P1);
const normal = new THREE.Vector3().crossVectors(v1, v2);
if (normal.length() < 1e-9) { this._section3PData = null; return; }
normal.normalize();
const D = -normal.dot(P1);
// Intersect the plane with all edges of the solid
const polygon = this._sliceByNormal(normal, P1);
if (polygon.length < 3) { this._section3PData = null; return; }
const area = this._polygonArea(polygon);
const n = polygon.length;
const typeNames = { 3: 'треугольник', 4: 'четырёхугольник', 5: 'пятиугольник', 6: 'шестиугольник' };
const typeName = typeNames[n] || `${n}-угольник`;
this._section3PData = { normal, D, polygon, area, typeName, P1, P2, P3 };
}
_drawSection3P() {
this._clearGroup(this._section3PGroup);
const picks = this._section3PPicks;
const data = this._section3PData;
// Draw picked points as spheres (yellow accent)
const PICK_COLOR = 0xFFD166;
const PLANE_COLOR = 0xEF476F;
const SECT_COLOR = 0x7BF5A4;
picks.forEach((p, i) => {
const sGeo = new THREE.SphereGeometry(0.13, 10, 10);
const sMat = new THREE.MeshBasicMaterial({ color: PICK_COLOR });
const s = new THREE.Mesh(sGeo, sMat);
s.position.copy(p);
this._section3PGroup.add(s);
// Number label
const lbl = this._makeTextSprite(String(i + 1), '#FFD166', 42);
lbl.position.copy(p).add(new THREE.Vector3(0.25, 0.25, 0));
lbl.scale.set(0.7, 0.28, 1);
this._section3PGroup.add(lbl);
});
// Draw line from P1 to P2 after 2nd pick
if (picks.length >= 2) {
const lg1 = new THREE.BufferGeometry().setFromPoints([picks[0], picks[1]]);
this._section3PGroup.add(new THREE.Line(lg1, new THREE.LineBasicMaterial({ color: PICK_COLOR, opacity: 0.7, transparent: true })));
}
if (picks.length >= 3) {
const lg2 = new THREE.BufferGeometry().setFromPoints([picks[1], picks[2]]);
this._section3PGroup.add(new THREE.Line(lg2, new THREE.LineBasicMaterial({ color: PICK_COLOR, opacity: 0.7, transparent: true })));
const lg3 = new THREE.BufferGeometry().setFromPoints([picks[2], picks[0]]);
this._section3PGroup.add(new THREE.Line(lg3, new THREE.LineBasicMaterial({ color: PICK_COLOR, opacity: 0.5, transparent: true })));
}
if (!data || picks.length < 3) return;
// Semi-transparent plane quad (large enough to show context)
const { normal, D, polygon } = data;
// Build a visible plane chip — use bounding box of polygon centroid + spread
const c = new THREE.Vector3();
polygon.forEach(p => c.add(p));
c.divideScalar(polygon.length);
// Local basis on plane
const u = new THREE.Vector3().subVectors(polygon[0], c).normalize();
const v = new THREE.Vector3().crossVectors(normal, u).normalize();
const spread = Math.max(...polygon.map(p => c.distanceTo(p))) * 1.5;
const planeVerts = [
c.clone().addScaledVector(u, -spread).addScaledVector(v, -spread),
c.clone().addScaledVector(u, spread).addScaledVector(v, -spread),
c.clone().addScaledVector(u, spread).addScaledVector(v, spread),
c.clone().addScaledVector(u, -spread).addScaledVector(v, spread),
];
const planePositions = [];
[[0,1,2],[0,2,3]].forEach(tri => tri.forEach(i => {
const pv = planeVerts[i];
planePositions.push(pv.x, pv.y, pv.z);
}));
const planeGeo = new THREE.BufferGeometry();
planeGeo.setAttribute('position', new THREE.Float32BufferAttribute(planePositions, 3));
const planeMat = new THREE.MeshBasicMaterial({ color: PLANE_COLOR, transparent: true, opacity: 0.08, side: THREE.DoubleSide });
this._section3PGroup.add(new THREE.Mesh(planeGeo, planeMat));
// Cross-section polygon fill
const sectPositions = [];
const sectIndices = [];
polygon.forEach(p => sectPositions.push(p.x, p.y, p.z));
for (let i = 1; i < polygon.length - 1; i++) sectIndices.push(0, i, i + 1);
const sectGeo = new THREE.BufferGeometry();
sectGeo.setAttribute('position', new THREE.Float32BufferAttribute(sectPositions, 3));
sectGeo.setIndex(sectIndices);
sectGeo.computeVertexNormals();
const sectMat = new THREE.MeshBasicMaterial({ color: SECT_COLOR, transparent: true, opacity: 0.45, side: THREE.DoubleSide });
this._section3PGroup.add(new THREE.Mesh(sectGeo, sectMat));
// Polygon outline (slightly offset along normal for visibility)
const outlinePts = [...polygon, polygon[0]].map(p =>
p.clone().addScaledVector(normal, 0.012)
);
const outlineGeo = new THREE.BufferGeometry().setFromPoints(outlinePts);
const outlineMat = new THREE.LineBasicMaterial({ color: SECT_COLOR, linewidth: 2 });
this._section3PGroup.add(new THREE.Line(outlineGeo, outlineMat));
// Vertex markers on section polygon
polygon.forEach(p => {
const sg = new THREE.SphereGeometry(0.07, 8, 8);
const sm = new THREE.MeshBasicMaterial({ color: SECT_COLOR });
const s = new THREE.Mesh(sg, sm);
s.position.copy(p);
this._section3PGroup.add(s);
});
// Step-by-step highlight (если включён пошаговый режим)
if (this._section3PStepBy && this._section3PStep > 0) {
this._drawSection3PStep(data);
}
}
_drawSection3PStep(data) {
// Extra step-by-step highlight objects added to _section3PGroup
const step = this._section3PStep;
const picks = this._section3PPicks;
const HILITE = 0xFFFFA0;
const flash = (pos) => {
const sg = new THREE.SphereGeometry(0.22, 10, 10);
const sm = new THREE.MeshBasicMaterial({ color: HILITE, transparent: true, opacity: 0.7 });
const s = new THREE.Mesh(sg, sm);
s.position.copy(pos);
this._section3PGroup.add(s);
};
const flashLine = (a, b) => {
const lg = new THREE.BufferGeometry().setFromPoints([a, b]);
this._section3PGroup.add(new THREE.Line(lg, new THREE.LineBasicMaterial({ color: HILITE, linewidth: 3 })));
};
if (step >= 1) flash(picks[0]);
if (step >= 2) { flash(picks[1]); flashLine(picks[0], picks[1]); }
if (step >= 3) { flash(picks[2]); flashLine(picks[1], picks[2]); flashLine(picks[2], picks[0]); }
// steps 4-6 handled by full plane + section already drawn above
}
/* ════════════════ MEASUREMENT MODE ════════════════ */
_onMeasureClick(e) {
@@ -3190,7 +3444,8 @@ class StereoSim {
['stereo-measure-btn','stereo-point-btn','stereo-connect-btn',
'stereo-angle-edge-btn','stereo-angle-lp-btn','stereo-angle-dih-btn','stereo-angle-pp-btn','stereo-angle-skew-btn',
'stereo-mark-tick-btn','stereo-mark-par-btn',
'stereo-derive-mid-btn','stereo-derive-fc-btn','stereo-derive-alt-btn','stereo-derive-cen-btn'].forEach(id => {
'stereo-derive-mid-btn','stereo-derive-fc-btn','stereo-derive-alt-btn','stereo-derive-cen-btn',
'stereo-sect3p-btn'].forEach(id => {
document.getElementById(id)?.classList.remove('active');
});
if (stereoSim) {
@@ -3200,6 +3455,7 @@ class StereoSim {
stereoSim.setAngleMode(null);
stereoSim.setMarkMode(null);
stereoSim.setDeriveMode(null);
stereoSim.toggleSection3P(false);
}
const hint = document.getElementById('angle-hint');
if (hint) hint.textContent = '';
@@ -3328,6 +3584,69 @@ class StereoSim {
_stereoUpdatePointsInfo();
}
/* ── Section through 3 points UI ── */
function stereoSection3P(btn) {
const on = !btn.classList.contains('active');
_stereoDeactivateTools();
btn.classList.toggle('active', on);
if (stereoSim) stereoSim.toggleSection3P(on);
const hint = document.getElementById('sect3p-hint');
if (hint) hint.textContent = on ? 'Кликните 3 точки на рёбрах или вершинах' : '';
if (on) _stereoUpdateSection3PPanel();
}
function stereoSection3PClear() {
if (stereoSim) stereoSim.clearSection3P();
_stereoUpdateSection3PPanel();
}
function stereoSection3PStepBy(toggle) {
const on = !toggle.classList.contains('on');
toggle.classList.toggle('on', on);
if (stereoSim) stereoSim.toggleSection3PStepBy(on);
}
function stereoSection3PNextStep() {
if (!stereoSim) return;
const max = stereoSim._section3PData ? 6 : stereoSim._section3PPicks.length;
stereoSim._section3PStep = Math.min(stereoSim._section3PStep + 1, max);
stereoSim._drawSection3P();
}
function stereoSection3PPrevStep() {
if (!stereoSim) return;
stereoSim._section3PStep = Math.max(0, stereoSim._section3PStep - 1);
stereoSim._drawSection3P();
}
function _stereoUpdateSection3PPanel() {
const panel = document.getElementById('sect3p-info');
if (!panel) return;
if (!stereoSim) { panel.innerHTML = ''; return; }
const data = stereoSim.getSection3PInfo();
const picks = stereoSim._section3PPicks;
if (!data && picks.length === 0) { panel.innerHTML = ''; return; }
const r = v => Math.round(v * 100) / 100;
const fmtV = v => `(${r(v.x)}, ${r(v.y)}, ${r(v.z)})`;
const lines = [];
picks.forEach((p, i) => lines.push(`<div style="color:#FFD166">P${i+1} = ${fmtV(p)}</div>`));
if (data) {
const { normal: n, D, typeName, area } = data;
const A = r(n.x), B = r(n.y), C = r(n.z), Dv = r(D);
const eq = `${A}x + ${B}y + ${C}z ${Dv >= 0 ? '+' : ''}${Dv} = 0`;
lines.push(`<div style="color:#EF476F;margin-top:4px">Плоскость: ${eq}</div>`);
lines.push(`<div style="color:#7BF5A4">Сечение: <b>${typeName}</b></div>`);
if (area > 0) lines.push(`<div style="color:#7BF5A4">S = ${r(area)}</div>`);
} else if (picks.length < 3) {
lines.push(`<div style="color:rgba(255,255,255,0.35)">Выбрано точек: ${picks.length}/3</div>`);
}
panel.innerHTML = lines.join('');
}
function stereoInscribed(btn) {
const on = !btn.classList.contains('active');
btn.classList.toggle('active', on);
@@ -3380,6 +3699,9 @@ class StereoSim {
// Points info
_stereoUpdatePointsInfo(info);
// Section-3P panel
_stereoUpdateSection3PPanel();
}
function _stereoUpdatePointsInfo(info) {