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:
+324
-2
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user