diff --git a/frontend/js/labs/stereo.js b/frontend/js/labs/stereo.js
index 3550290..4bb6afe 100644
--- a/frontend/js/labs/stereo.js
+++ b/frontend/js/labs/stereo.js
@@ -287,6 +287,7 @@ class StereoSim {
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._stepCaption = ''; // caption for the current trace-method step
this.onUpdate = null;
@@ -589,8 +590,10 @@ class StereoSim {
toggleSection3PStepBy(on) {
this._section3PStepBy = on;
- // re-render if data already exists
- if (this._section3PData) this._drawSection3P();
+ // entering step mode: start at the first step so the build-up is visible
+ if (on && this._section3PStep === 0) this._section3PStep = 1;
+ if (!on) this._stepCaption = '';
+ if (this._section3PData || this._section3PPicks.length) this._drawSection3P();
}
getSection3PInfo() {
@@ -2246,12 +2249,14 @@ class StereoSim {
this._section3PGroup.add(lbl);
});
- // Draw line from P1 to P2 after 2nd pick
- if (picks.length >= 2) {
+ // Connector triangle between picks — shown live while picking and in normal
+ // mode; hidden during the step build-up (step 2 draws same-face sides itself).
+ const showPickLines = !this._section3PStepBy;
+ if (showPickLines && 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) {
+ if (showPickLines && 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]]);
@@ -2260,6 +2265,10 @@ class StereoSim {
if (!data || picks.length < 3) return;
+ // In step-by-step mode the finished section is hidden until step ≥ 5 so the
+ // trace construction builds up; otherwise (or at the end) show it in full.
+ const showFull = !this._section3PStepBy || this._section3PStep >= 5;
+
// 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
@@ -2286,71 +2295,178 @@ class StereoSim {
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));
+ if (showFull) {
+ // 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));
+ // 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 + letter labels — only for true polygons; smooth conic
- // sections skip them.
- if (polygon.length <= 12) {
- const LETTERS = 'KLMNPQRSTUV';
- polygon.forEach((p, i) => {
- 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);
- const lbl = this._makeTextSprite(LETTERS[i] || `${i + 1}`, '#7BF5A4', 32);
- const out = new THREE.Vector3().subVectors(p, c).normalize().multiplyScalar(0.3);
- lbl.position.copy(p).add(out).add(new THREE.Vector3(0, 0.16, 0));
- this._section3PGroup.add(lbl);
- });
+ // Vertex markers + letter labels — only for true polygons; smooth conic
+ // sections skip them.
+ if (polygon.length <= 12) {
+ const LETTERS = 'KLMNPQRSTUV';
+ polygon.forEach((p, i) => {
+ 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);
+ const lbl = this._makeTextSprite(LETTERS[i] || `${i + 1}`, '#7BF5A4', 32);
+ const out = new THREE.Vector3().subVectors(p, c).normalize().multiplyScalar(0.3);
+ lbl.position.copy(p).add(out).add(new THREE.Vector3(0, 0.16, 0));
+ this._section3PGroup.add(lbl);
+ });
+ }
}
- // Step-by-step highlight (если включён пошаговый режим)
+ // Step-by-step trace construction (метод следов)
if (this._section3PStepBy && this._section3PStep > 0) {
this._drawSection3PStep(data);
+ } else {
+ this._stepCaption = '';
}
}
+ // Solids that rest on a base plane (y=0) — the trace method applies to these.
+ _hasBase() {
+ return ['cube', 'parallelepiped', 'prism', 'pyramid', 'truncpyramid', 'tetrahedron']
+ .includes(this.figureType);
+ }
+
+ // Trace line of the cutting plane on the base plane y=0.
+ // Plane: n·X + D = 0 → at y=0: n.x·x + n.z·z + D = 0. Returns {p0, dir} or null
+ // when the cutting plane is (nearly) parallel to the base (trace at infinity).
+ _traceLine(data) {
+ const a = data.normal.x, b = data.normal.z, D = data.D;
+ if (Math.abs(a) < 1e-6 && Math.abs(b) < 1e-6) return null; // parallel to base
+ const dir = new THREE.Vector3(-b, 0, a).normalize();
+ let p0;
+ if (Math.abs(a) >= Math.abs(b)) p0 = new THREE.Vector3(-D / a, 0, 0);
+ else p0 = new THREE.Vector3(0, 0, -D / b);
+ return { p0, dir };
+ }
+
+ // Auxiliary points: extend each lateral side of the section to the base plane.
+ // Each extension meets the base on the trace line — the heart of the method.
+ _auxiliaryPoints(polygon) {
+ const out = [];
+ const n = polygon.length;
+ for (let i = 0; i < n; i++) {
+ const A = polygon[i], B = polygon[(i + 1) % n];
+ if (A.y < 0.05 && B.y < 0.05) continue; // edge already on the base
+ if (Math.abs(B.y - A.y) < 1e-3) continue; // horizontal → meets base at ∞
+ const t = -A.y / (B.y - A.y);
+ const H = new THREE.Vector3().lerpVectors(A, B, t);
+ if (Math.abs(H.x) > 40 || Math.abs(H.z) > 40) continue; // near-parallel, too far
+ // prefer extensions that reach the base outside the segment (the classic case)
+ const reach = (t < 0) ? -t : (t > 1 ? t - 1 : 0);
+ out.push({ A, B, H, reach });
+ }
+ out.sort((p, q) => p.reach - q.reach); // nearest extensions first
+ return out;
+ }
+
_drawSection3PStep(data) {
- // Extra step-by-step highlight objects added to _section3PGroup
const step = this._section3PStep;
const picks = this._section3PPicks;
- const HILITE = 0xFFFFA0;
+ const grp = this._section3PGroup;
+ const HILITE = 0xFFFFA0, TRACE = 0xEF476F, AUX = 0xFFD166;
- 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 dot = (pos, color, r = 0.12) => {
+ const s = new THREE.Mesh(new THREE.SphereGeometry(r, 10, 10),
+ new THREE.MeshBasicMaterial({ color }));
+ s.position.copy(pos); grp.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 })));
+ const solidLine = (a, b, color) => {
+ grp.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints([a, b]),
+ new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.95 })));
+ };
+ const dashLine = (a, b, color) => {
+ const l = new THREE.Line(new THREE.BufferGeometry().setFromPoints([a, b]),
+ new THREE.LineDashedMaterial({ color, dashSize: 0.18, gapSize: 0.1, transparent: true, opacity: 0.9 }));
+ l.computeLineDistances(); grp.add(l);
+ };
+ const tag = (pos, text, color, off = new THREE.Vector3(0.25, 0.25, 0)) => {
+ const s = this._makeTextSprite(text, '#' + new THREE.Color(color).getHexString(), 34);
+ s.position.copy(pos).add(off); grp.add(s);
};
- 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
+ const hasBase = this._hasBase();
+ const polygon = data.polygon;
+ const trace = hasBase ? this._traceLine(data) : null;
+ const aux = (hasBase && trace) ? this._auxiliaryPoints(polygon).slice(0, 2) : [];
+
+ // Step 1 — the three given points
+ if (step >= 1) picks.forEach((p, i) => { dot(p, HILITE); tag(p, 'P' + (i + 1), HILITE); });
+
+ // Step 2 — connect points lying in the same face → first sides of the section
+ if (step >= 2) {
+ for (let i = 0; i < picks.length; i++) {
+ for (let j = i + 1; j < picks.length; j++) {
+ if (this._sameFace(picks[i], picks[j])) solidLine(picks[i], picks[j], HILITE);
+ }
+ }
+ }
+
+ // Step 3 — build the trace of the cutting plane on the base
+ if (step >= 3 && trace) {
+ const L = 13;
+ const a = trace.p0.clone().addScaledVector(trace.dir, -L);
+ const b = trace.p0.clone().addScaledVector(trace.dir, L);
+ dashLine(a, b, TRACE);
+ tag(b, 'след', TRACE, new THREE.Vector3(0.3, 0.2, 0));
+ }
+
+ // Step 4 — extend the section's sides to the trace → auxiliary points
+ if (step >= 4) {
+ aux.forEach((q, i) => {
+ dashLine(q.A, q.H, AUX);
+ dot(q.H, AUX, 0.1);
+ tag(q.H, 'T' + (i + 1), AUX);
+ });
+ }
+
+ // Steps 5–6 — the finished section is drawn by _drawSection3P (showFull)
+ const CAPS = hasBase ? {
+ 1: 'Шаг 1. Отмечены 3 точки, задающие секущую плоскость.',
+ 2: 'Шаг 2. Соединяем точки в одной грани — первые стороны сечения.',
+ 3: 'Шаг 3. Строим след — линию пересечения плоскости с основанием.',
+ 4: 'Шаг 4. Продлеваем стороны сечения до следа — вспомогательные точки.',
+ 5: 'Шаг 5. Через след находим остальные вершины и замыкаем сечение.',
+ 6: `Шаг 6. Сечение построено: ${data.typeName}` + (data.area > 0 ? `, S = ${Math.round(data.area * 100) / 100}.` : '.'),
+ } : {
+ 1: 'Шаг 1. Отмечены 3 точки, задающие секущую плоскость.',
+ 2: 'Шаг 2. Соединяем точки, лежащие в одной грани.',
+ 3: 'Для этого тела метод следов не применяется — показываем готовое сечение.',
+ 4: 'Готовое сечение.', 5: 'Готовое сечение.', 6: 'Готовое сечение.',
+ };
+ this._stepCaption = CAPS[Math.min(step, 6)] || '';
+ }
+
+ // True if two points both lie in (the plane of) the same face of the solid.
+ _sameFace(p, q) {
+ for (const face of this._faces) {
+ if (face.length < 3) continue;
+ const nrm = this._faceNormal(face);
+ const d = nrm.dot(face[0]);
+ if (Math.abs(nrm.dot(p) - d) < 0.06 && Math.abs(nrm.dot(q) - d) < 0.06) return true;
+ }
+ return false;
}
/* ════════════════ MEASUREMENT MODE ════════════════ */
@@ -4068,23 +4184,33 @@ class StereoSim {
_stereoUpdateSection3PPanel();
}
+ function _stereoStepHint() {
+ const hint = document.getElementById('sect3p-hint');
+ if (hint && stereoSim) hint.textContent = stereoSim._stepCaption || '';
+ }
+
function stereoSection3PStepBy(toggle) {
const on = !toggle.classList.contains('on');
toggle.classList.toggle('on', on);
if (stereoSim) stereoSim.toggleSection3PStepBy(on);
+ _stereoStepHint();
}
function stereoSection3PNextStep() {
if (!stereoSim) return;
+ if (!stereoSim._section3PStepBy) return; // steps only meaningful in step mode
const max = stereoSim._section3PData ? 6 : stereoSim._section3PPicks.length;
stereoSim._section3PStep = Math.min(stereoSim._section3PStep + 1, max);
stereoSim._drawSection3P();
+ _stereoStepHint();
}
function stereoSection3PPrevStep() {
if (!stereoSim) return;
- stereoSim._section3PStep = Math.max(0, stereoSim._section3PStep - 1);
+ if (!stereoSim._section3PStepBy) return;
+ stereoSim._section3PStep = Math.max(1, stereoSim._section3PStep - 1);
stereoSim._drawSection3P();
+ _stereoStepHint();
}
function _stereoUpdateSection3PPanel() {
diff --git a/frontend/lab.html b/frontend/lab.html
index eb48d2e..81f454f 100644
--- a/frontend/lab.html
+++ b/frontend/lab.html
@@ -4819,7 +4819,7 @@
-
+
diff --git a/plans/STEREO_3D_IMPROVEMENT.md b/plans/STEREO_3D_IMPROVEMENT.md
index 967ec27..9cfd4c1 100644
--- a/plans/STEREO_3D_IMPROVEMENT.md
+++ b/plans/STEREO_3D_IMPROVEMENT.md
@@ -51,6 +51,17 @@
Бэклог Фазы 5: модульное дробление файла; deep-link конкретного сечения/инструмента (не только фигуры).
+## Фаза 6 — Построение сечения «по следам» (метод следов) — ГОТОВО (путь b)
+
+Реализован гибрид: финальный полигон считается надёжно (Фаза 2), а след и вспомогательные точки выводятся аналитически — без риска несходимости конструктивного алгоритма. Scope: тела с основанием (куб, параллелепипед, призма, пирамида, усеч. пирамида, тетраэдр).
+
+- [x] 6.1 `_hasBase()` + `_traceLine(data)` — след = π ∩ плоскость основания (y=0): `n.x·x + n.z·z + D = 0`; возвращает `{p0, dir}` или null (плоскость параллельна основанию). Проверено численно (точка следа на плоскости, остаток 0).
+- [x] 6.2 `_auxiliaryPoints(polygon)` — продление боковых сторон сечения до y=0; точка пересечения лежит ровно на следе (численно dist=0). Сортировка по «дальности продления», берём 2 ближайшие.
+- [x] 6.3 Настоящий пошаговый `_drawSection3PStep` (заменил бутафорию): 6 подписанных шагов — 3 точки → стороны в одной грани (`_sameFace`) → след → вспом. точки T₁,T₂ → вершины+замыкание → итог. В step-режиме финальное сечение скрыто до шага 5 (`showFull`), пиковые линии тоже скрыты. Подписи шагов в `#sect3p-hint` через `_stepCaption`. Для тел без основания — деградация к простым шагам с пояснением.
+- bump stereo.js?v=9
+
+Бэклог Фазы 6: «честный» конструктивный алгоритм (шаг по граням через след для поиска каждой новой вершины); анимация перехода между шагами; ветка для плоскости, параллельной основанию.
+
---
-История: создан 2026-05-30.
+История: создан 2026-05-30. Фаза 6 добавлена 2026-05-30.