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.