diff --git a/frontend/js/labs/stereo.js b/frontend/js/labs/stereo.js index 17a879f..ae82d40 100644 --- a/frontend/js/labs/stereo.js +++ b/frontend/js/labs/stereo.js @@ -316,6 +316,7 @@ class StereoSim { this._nextCPointName = 0; // → M, N, K, … this._constructSeq = 0; // monotonic insertion order (for "remove last") this._relMode = null; // {op, refId} parallel/perpendicular through a point + this._sectionPlaneId = null; // id of the plane shown as a filled, measured section this._lastConstructMsg = ''; // transient result text for the panel hint this._undoStack = []; // construction-layer history (JSON snapshots) this._redoStack = []; @@ -364,7 +365,7 @@ class StereoSim { this._cpoints = []; this._lines = []; this._planes = []; this._lineMode = false; this._planeMode = false; this._intersectMode = false; this._intersectSel = []; - this._relMode = null; this._lastConstructMsg = ''; + this._relMode = null; this._sectionPlaneId = null; this._lastConstructMsg = ''; this._undoStack = []; this._redoStack = []; this._constructPicks = []; this._nextLineName = 0; this._nextPlaneName = 0; this._nextCPointName = 0; this._constructSeq = 0; @@ -816,6 +817,17 @@ class StereoSim { lines.push({ label: 'Периметр P', value: r(this._polygonPerimeter(poly)) }); } + if (this._sectionPlaneId) { + const pl = this._planes.find(p => p.id === this._sectionPlaneId); + const poly = this._activeSectionPolygon(); + if (pl && poly) { + const nm = ({ 3: 'треугольник', 4: 'четырёхугольник', 5: 'пятиугольник', 6: 'шестиугольник' }[poly.length] || `${poly.length}-угольник`); + lines.push({ label: 'Сечение пл. ' + pl.name, value: nm }); + lines.push({ label: 'Площадь S', value: r(this._polygonArea(poly)) }); + lines.push({ label: 'Периметр P', value: r(this._polygonPerimeter(poly)) }); + } + } + if (this._measurements.length) { const m = this._measurements[this._measurements.length - 1]; lines.push({ label: `Отрезок ${m.from}${m.to}`, value: m.dist }); @@ -3644,6 +3656,7 @@ class StereoSim { if (i >= 0) { a.splice(i, 1); break; } } this._intersectSel = this._intersectSel.filter(x => x !== id); + if (this._sectionPlaneId === id) this._sectionPlaneId = null; this._rebuildConstructions(); this._notify(); } @@ -3659,7 +3672,7 @@ class StereoSim { this._cpoints = []; this._lines = []; this._planes = []; this._constructPicks = []; this._lineMode = false; this._planeMode = false; this._intersectMode = false; this._intersectSel = []; - this._relMode = null; + this._relMode = null; this._sectionPlaneId = null; this._clearGroup(this._constructGroup); this._notify(); } @@ -3704,6 +3717,67 @@ class StereoSim { this._restoreSnapshot(this._redoStack.pop()); } + /* ── Section from a plane object (Phase C1): fill + vertex labels + S/P ── */ + + setSectionPlane(id) { + this._sectionPlaneId = (this._sectionPlaneId === id) ? null : id; + this._rebuildConstructions(); + this._notify(); + } + + // Ordered polygon (Vector3[]) where the active section plane cuts the solid, or null. + _activeSectionPolygon() { + if (!this._sectionPlaneId) return null; + const pl = this._planes.find(p => p.id === this._sectionPlaneId); + if (!pl || !pl.def) return null; + let poly = null; + try { + poly = this._sliceByPlane( + new THREE.Vector3(pl.def[0].x, pl.def[0].y, pl.def[0].z), + new THREE.Vector3(pl.def[1].x, pl.def[1].y, pl.def[1].z), + new THREE.Vector3(pl.def[2].x, pl.def[2].y, pl.def[2].z)); + } catch (_) { poly = null; } + return (poly && poly.length >= 3) ? poly : null; + } + + _sectionVertexLabel(i) { + // K, L, M, … wrapping after a dozen + return String.fromCharCode(75 + (i % 12)); + } + + // True shape (натуральная величина) of the active section: the polygon unfolded + // into its own plane → 2D points with REAL lengths preserved. Pure data (no DOM). + getTrueShape() { + const poly = this._activeSectionPolygon(); + if (!poly || poly.length < 3) return null; + const pl = this._planes.find(p => p.id === this._sectionPlaneId); + if (!pl) return null; + const n = new THREE.Vector3(pl.normal.x, pl.normal.y, pl.normal.z).normalize(); + let u = Math.abs(n.x) > 0.9 ? new THREE.Vector3(0, 1, 0) : new THREE.Vector3(1, 0, 0); + u = new THREE.Vector3().crossVectors(n, u).normalize(); + const w = new THREE.Vector3().crossVectors(n, u).normalize(); + const c = new THREE.Vector3(); + poly.forEach(p => c.add(p)); + c.divideScalar(poly.length); + const r = (v) => Math.round(v * 100) / 100; + const pts = poly.map(p => { + const d = p.clone().sub(c); + return { x: d.dot(u), y: d.dot(w) }; + }); + const edges = pts.map((p, i) => { + const q = pts[(i + 1) % pts.length]; + return r(Math.hypot(q.x - p.x, q.y - p.y)); + }); + return { + name: pl.name, + pts, + edges, + labels: pts.map((_, i) => this._sectionVertexLabel(i)), + area: r(this._polygonArea(poly)), + perim: r(this._polygonPerimeter(poly)), + }; + } + // Interactive tree summary for the panel (ids/types/visibility/selection). getConstructions() { const r = (v) => Math.round(v * 100) / 100; @@ -3712,6 +3786,7 @@ class StereoSim { return { intersectMode: this._intersectMode, relMode: !!this._relMode, + sectionId: this._sectionPlaneId, points: this._cpoints.map(p => ({ id: p.id, name: p.name, hidden: !!p.hidden, selected: isSel(p.id), info: `(${r(p.pos.x)}, ${r(p.pos.y)}, ${r(p.pos.z)})`, @@ -3725,6 +3800,7 @@ class StereoSim { const sign = (v) => (v >= 0 ? '+ ' : '− ') + Math.abs(r(v)); return { id: p.id, name: p.name, hidden: !!p.hidden, selected: isSel(p.id), + section: (p.id === this._sectionPlaneId), info: `${r(n.x)}x ${sign(n.y)}y ${sign(n.z)}z ${sign(D)} = 0`, }; }), @@ -3795,7 +3871,15 @@ class StereoSim { // or to intersection (select 2 objects). Returns { msg } for the panel hint. pickConstructObject(id) { if (this._relMode) return this._pickRelRef(id); - if (!this._intersectMode) return { msg: '' }; + if (!this._intersectMode) { + // Normal mode: click a plane in the tree to show/hide it as a filled, measured section. + const f = this._findObj(id); + if (f && f.type === 'plane') { + this.setSectionPlane(id); + return { msg: this._sectionPlaneId ? ('сечение по плоскости ' + f.obj.name) : 'сечение снято' }; + } + return { msg: '' }; + } const found = this._findObj(id); if (!found || found.type === 'point') return { msg: 'Для пересечения выберите прямую или плоскость' }; const i = this._intersectSel.indexOf(id); @@ -4084,19 +4168,42 @@ class StereoSim { this._constructGroup.add(bline); // Cross-section of the solid by this plane — makes the plane immediately meaningful. + // When this plane is the *active section*, fill it + label its vertices K,L,M… if (pl.def) { + const isSection = (pl.id === this._sectionPlaneId); + let poly = null; try { - const poly = this._sliceByPlane( + poly = this._sliceByPlane( new THREE.Vector3(pl.def[0].x, pl.def[0].y, pl.def[0].z), new THREE.Vector3(pl.def[1].x, pl.def[1].y, pl.def[1].z), new THREE.Vector3(pl.def[2].x, pl.def[2].y, pl.def[2].z)); - if (poly && poly.length >= 3) { - const sline = new THREE.Line(new THREE.BufferGeometry().setFromPoints([...poly, poly[0]]), - new THREE.LineBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.95 })); - sline.renderOrder = 4; - this._constructGroup.add(sline); + } catch (_) { poly = null; } + if (poly && poly.length >= 3) { + if (isSection) { + // filled face (triangle fan) + const positions = [], indices = []; + poly.forEach(p => positions.push(p.x, p.y, p.z)); + for (let i = 1; i < poly.length - 1; i++) indices.push(0, i, i + 1); + const fgeo = new THREE.BufferGeometry(); + fgeo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); + fgeo.setIndex(indices); + fgeo.computeVertexNormals(); + const fill = new THREE.Mesh(fgeo, new THREE.MeshBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.32, side: THREE.DoubleSide, depthWrite: false })); + fill.renderOrder = 3; + this._constructGroup.add(fill); } - } catch (_) {} + const sline = new THREE.Line(new THREE.BufferGeometry().setFromPoints([...poly, poly[0]]), + new THREE.LineBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: isSection ? 1 : 0.85 })); + sline.renderOrder = 4; + this._constructGroup.add(sline); + if (isSection && this.showLabels) { + poly.forEach((p, i) => { + const lbl = this._makeTextSprite(this._sectionVertexLabel(i), '#67E8F9', 38); + lbl.position.copy(p).add(new THREE.Vector3(0.14, 0.2, 0)); + this._constructGroup.add(lbl); + }); + } + } } if (this.showLabels) { @@ -4812,11 +4919,12 @@ class StereoSim { ''; const row = (o, kind, color, selectable) => { - const selBg = o.selected ? 'background:rgba(56,189,248,0.2);' : ''; + const selBg = o.section ? 'background:rgba(6,214,224,0.18);' : (o.selected ? 'background:rgba(56,189,248,0.2);' : ''); const dim = o.hidden ? 'opacity:0.4;' : ''; + const tag = o.section ? ' (сечение)' : ''; const main = '' + - '' + kind + ' ' + o.name + '' + + '' + kind + ' ' + o.name + '' + tag + (o.info ? ' ' + o.info + '' : '') + ''; return '
' + main + @@ -4828,10 +4936,45 @@ class StereoSim { const rows = []; c.points.forEach(p => rows.push(row(p, 'точка', '#6EE7B7', false))); c.lines.forEach(l => rows.push(row(l, 'прямая', '#7DD3FC', sel))); - c.planes.forEach(p => rows.push(row(p, 'плоскость', '#DDD6FE', sel))); + // planes are always clickable: in normal mode a click toggles "show as section" + c.planes.forEach(p => rows.push(row(p, 'плоскость', '#DDD6FE', true))); el.innerHTML = rows.join(''); } + // True-shape (натуральная величина) mini-panel for the active section. + function _stereoUpdateTrueShape() { + const el = document.getElementById('construct-trueshape'); + if (!el || !stereoSim) return; + const ts = stereoSim.getTrueShape(); + if (!ts) { el.style.display = 'none'; el.innerHTML = ''; return; } + el.style.display = ''; + const W = 150, H = 130, pad = 24; + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + ts.pts.forEach(p => { minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x); minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y); }); + const s = Math.min((W - 2 * pad) / Math.max(1e-3, maxX - minX), (H - 2 * pad) / Math.max(1e-3, maxY - minY)); + const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2; + const scr = ts.pts.map(p => ({ x: W / 2 + (p.x - cx) * s, y: H / 2 - (p.y - cy) * s })); + const ptsAttr = scr.map(p => p.x.toFixed(1) + ',' + p.y.toFixed(1)).join(' '); + let svg = ''; + svg += '' + + ''; + svg += ''; + scr.forEach((p, i) => { + const q = scr[(i + 1) % scr.length]; + svg += '' + ts.edges[i] + ''; + }); + scr.forEach((p, i) => { + const dx = p.x - W / 2, dy = p.y - H / 2, L = Math.hypot(dx, dy) || 1; + svg += ''; + svg += '' + ts.labels[i] + ''; + }); + svg += ''; + el.innerHTML = '
Натуральная величина сечения ' + + ts.name + ' · S = ' + ts.area + ' · P = ' + ts.perim + '
' + svg; + } + /* ── Section through 3 points UI ── */ function stereoSection3P(btn) { const on = !btn.classList.contains('active'); @@ -4971,6 +5114,9 @@ class StereoSim { stereoSim._lastConstructMsg = ''; } + // True-shape mini-panel of the active section + _stereoUpdateTrueShape(); + // Live readout overlay (section type/area/perimeter, last measurement) _stereoUpdateReadout(info); diff --git a/frontend/labs-bodies.html b/frontend/labs-bodies.html index 765480b..695243b 100644 --- a/frontend/labs-bodies.html +++ b/frontend/labs-bodies.html @@ -3708,7 +3708,9 @@
+
Клик по плоскости в списке — показать её сечением (заливка, площадь, периметр, натуральная величина).
+
Метки рёбер
diff --git a/plans/STEREO_3D_IMPROVEMENT.md b/plans/STEREO_3D_IMPROVEMENT.md index 834ca6c..cbb49ff 100644 --- a/plans/STEREO_3D_IMPROVEMENT.md +++ b/plans/STEREO_3D_IMPROVEMENT.md @@ -94,11 +94,18 @@ ### Фаза C — Сечения+ -- [ ] C1 — Сечение **плоскостью-объектом** (из Фазы A): «показать как сечение» с площадью/периметром. -- [ ] C2 — Сечение, **параллельное прямой/плоскости**; сечение **через прямую и точку**. -- [ ] C3 — **«Натуральная величина» сечения** (разворот многоугольника сечения в плоскость экрана, - отдельная мини-панель) + **штриховка**. -- [ ] C4 — Честный конструктивный алгоритм следов с анимацией перехода между шагами (из бэклога Ф6). +- [x] C1 — Сечение **плоскостью-объектом** (из Фазы A): клик по плоскости в дереве (нормальный режим) + → `setSectionPlane` показывает её заливкой + подписи вершин K,L,M… + площадь/периметр в readout + (`_activeSectionPolygon`, `getReadout`). Удаление/очистка/смена фигуры сбрасывают сечение. +- [x] C2 — **Покрыто Фазой A** (отдельный код не нужен): сечение через прямую+точку = плоскость по + 3 точкам (2 с прямой + 1); сечение ∥ плоскости через точку = rel-операция `ppar` → затем клик + как сечение. Дополнительный UI признан избыточным. +- [x] C3 — **«Натуральная величина» сечения** (`getTrueShape`): разворот многоугольника в его + плоскость (ортонормированный базис от нормали) с сохранением истинных длин → 2D-мини-панель + (SVG, штриховка ``, подписи вершин, длины сторон, S/P). Проверено сохранение длин и + площади для прямого и наклонного сечений. +- [ ] C4 — Честный конструктивный алгоритм следов с анимацией (из бэклога Ф6) — **отложено** + (крупная отдельная фича; текущий гибрид Ф6 + сечения-объекты Фазы A/C закрывают практику). ---