feat(stereo3d): Фаза 3 — readout-панель, точки на гранях, подписи вершин сечения

- live-readout overlay: тип сечения, площадь, периметр, последнее измерение
  (через info().readout; _notify добавлен в section/measure-пути)
- _raycastFace(): в режиме точек клик по грани ставит точку на поверхности
- подписи вершин сечения буквами K,L,M… (наклонное/произвольное/3-точки, ≤12 вершин)
- bump stereo.js?v=6

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-30 11:29:25 +03:00
parent 799f651777
commit dbb6a6fa11
4 changed files with 117 additions and 12 deletions
+17
View File
@@ -299,6 +299,23 @@
.st-view-btn { width: 26px; height: 26px; }
}
/* live section / measurement readout (bottom-left of viewport) */
.st-readout {
position: absolute; left: 10px; bottom: 10px; z-index: 5;
min-width: 150px; max-width: 240px;
padding: 8px 10px; border-radius: 10px;
background: rgba(13,13,26,.72); backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,.10);
font-size: .72rem; color: rgba(255,255,255,.78);
pointer-events: none;
}
.st-ro-row { display: flex; justify-content: space-between; gap: 10px; line-height: 1.6; }
.st-ro-k { color: rgba(255,255,255,.55); }
.st-ro-v { color: #06D6E0; font-weight: 600; font-variant-numeric: tabular-nums; }
@media (max-width: 640px) {
.st-readout { left: 6px; bottom: 6px; font-size: .66rem; min-width: 120px; padding: 6px 8px; }
}
.st-tool-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 3px; margin-bottom: 4px; }
.st-tool-btn {
display: flex; align-items: center; gap: 5px;
+91 -7
View File
@@ -401,6 +401,7 @@ class StereoSim {
if (!this._measurements.length) return;
this._measurements.pop();
this._rebuildMeasureGroup();
this._notify();
}
clearMeasurements() {
@@ -408,6 +409,7 @@ class StereoSim {
this._measurePicks = [];
this._rebuildMeasureGroup();
this._clearGroup(this._measurePickGroup);
this._notify();
}
_rebuildMeasureGroup() {
@@ -724,6 +726,41 @@ class StereoSim {
return this._polygonArea(this._sectionPolygon);
}
_polygonPerimeter(pts) {
let p = 0;
for (let i = 0; i < pts.length; i++) p += pts[i].distanceTo(pts[(i + 1) % pts.length]);
return p;
}
// Live, human-readable lines for the viewport readout panel.
getReadout() {
const lines = [];
const r = (v) => Math.round(v * 100) / 100;
const curved = ['cylinder', 'cone', 'trunccone', 'sphere'].includes(this.figureType);
if (this._section3PData) {
const d = this._section3PData;
lines.push({ label: 'Сечение (3 точки)', value: d.typeName });
lines.push({ label: 'Площадь S', value: r(d.area) });
lines.push({ label: 'Периметр P', value: r(this._polygonPerimeter(d.polygon)) });
} else if (this.showSection && this._sectionPolygon && this._sectionPolygon.length >= 3) {
const poly = this._sectionPolygon;
const polyName = (curved && poly.length > 8)
? (this.figureType === 'sphere' || this.sectionType === 'horizontal' ? 'окружность' : 'эллипс')
: ({ 3: 'треугольник', 4: 'четырёхугольник', 5: 'пятиугольник', 6: 'шестиугольник' }[poly.length] || `${poly.length}-угольник`);
const kind = { horizontal: 'горизонтальное', diagonal: 'наклонное', custom: 'произвольное' }[this.sectionType] || '';
lines.push({ label: 'Сечение' + (kind ? ` (${kind})` : ''), value: polyName });
lines.push({ label: 'Площадь S', value: r(this.getSectionArea()) });
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 });
}
return lines;
}
info() {
const f = this.getFormulas();
return {
@@ -734,6 +771,7 @@ class StereoSim {
circumscribedR: this.showCircumscribed ? this._circumscribedRadius() : null,
customPoints: this._customPoints.length,
connections: this._connections.length,
readout: this.getReadout(),
};
}
@@ -1511,6 +1549,7 @@ class StereoSim {
}
}
}
this._notify();
}
_figureHeight() {
@@ -1744,16 +1783,22 @@ class StereoSim {
label.scale.set(1.6, 0.5, 1);
this._sectionGroup.add(label);
// Vertex markers only for genuine polygons; smooth conic sections (many
// sampled points) would otherwise render a ring of dozens of spheres.
// Vertex markers + letter labels for genuine polygons; smooth conic
// sections (many sampled points) would otherwise render dozens of spheres.
if (pts.length <= 12) {
for (const p of pts) {
const LETTERS = 'KLMNPQRSTUV';
pts.forEach((p, i) => {
const sGeo = new THREE.SphereGeometry(0.06, 8, 8);
const sMat = new THREE.MeshBasicMaterial({ color: 0x06D6E0 });
const sm = new THREE.Mesh(sGeo, sMat);
sm.position.copy(p);
this._sectionGroup.add(sm);
}
// letter label pushed slightly outward from the section centroid
const lbl = this._makeTextSprite(LETTERS[i] || `${i + 1}`, '#06D6E0', 34);
const out = new THREE.Vector3().subVectors(p, c).normalize().multiplyScalar(0.32);
lbl.position.copy(p).add(out).add(new THREE.Vector3(0, 0.18, 0));
this._sectionGroup.add(lbl);
});
}
}
@@ -2221,14 +2266,20 @@ class StereoSim {
const outlineMat = new THREE.LineBasicMaterial({ color: SECT_COLOR, linewidth: 2 });
this._section3PGroup.add(new THREE.Line(outlineGeo, outlineMat));
// Vertex markers — only for true polygons; smooth conic sections skip them.
// Vertex markers + letter labels — only for true polygons; smooth conic
// sections skip them.
if (polygon.length <= 12) {
polygon.forEach(p => {
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);
});
}
@@ -2308,6 +2359,7 @@ class StereoSim {
this._measurePicks = [];
this._clearGroup(this._measurePickGroup);
this._rebuildMeasureGroup();
this._notify();
}
}
@@ -2334,6 +2386,17 @@ class StereoSim {
return { dist: Math.hypot(mx - px, my - py), t };
}
// Raycast against the figure's solid mesh → a point on a face interior.
_raycastFace(mx, my) {
if (!this._raycaster) this._raycaster = new THREE.Raycaster();
this._raycaster.setFromCamera({ x: mx, y: my }, this.camera);
const hits = this._raycaster.intersectObjects(this._figGroup.children, true);
for (const h of hits) {
if (h.object && h.object.type === 'Mesh') return h.point.clone();
}
return null;
}
_onPointClick(e) {
const { mx, my } = this._screenCoords(e);
@@ -2365,7 +2428,7 @@ class StereoSim {
}
}
// Also check: click near a vertex <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> snap to vertex
// Also check: click near a vertex snap to vertex
for (const v of this._vertices) {
const proj = v.pos.clone().project(this.camera);
const dist = Math.sqrt((mx - proj.x) ** 2 + (my - proj.y) ** 2);
@@ -2377,6 +2440,12 @@ class StereoSim {
}
}
// Fall back to a point on a face interior (raycast) when not near edge/vertex.
if (!bestPos) {
const fp = this._raycastFace(mx, my);
if (fp) { bestPos = fp; bestEdge = -3; bestT = 0; }
}
if (!bestPos) return;
const label = String(this._nextPointId++);
@@ -4053,6 +4122,21 @@ class StereoSim {
// Section-3P panel
_stereoUpdateSection3PPanel();
// Live readout overlay (section type/area/perimeter, last measurement)
_stereoUpdateReadout(info);
}
function _stereoUpdateReadout(info) {
const el = document.getElementById('stereo-readout');
if (!el) return;
const lines = (info && info.readout) || [];
if (!lines.length) { el.style.display = 'none'; el.innerHTML = ''; return; }
el.style.display = '';
el.innerHTML = lines.map(l =>
'<div class="st-ro-row"><span class="st-ro-k">' + l.label + '</span>' +
'<span class="st-ro-v">' + l.value + '</span></div>'
).join('');
}
function _stereoUpdatePointsInfo(info) {
+3 -1
View File
@@ -4144,6 +4144,8 @@
</button>
</div>
</div>
<!-- ── live section/measure readout ── -->
<div class="st-readout" id="stereo-readout" style="display:none" aria-live="polite"></div>
</div>
</div>
@@ -4817,7 +4819,7 @@
<script src="/js/labs/flask.js"></script>
<script src="/js/labs/redox.js"></script>
<script src="/js/labs/ionexchange.js"></script>
<script src="/js/labs/stereo.js?v=5"></script>
<script src="/js/labs/stereo.js?v=6"></script>
<script src="/js/notifications.js"></script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
+6 -4
View File
@@ -28,11 +28,13 @@
Бэклог: zoom-to-cursor (перенесён из 1.1); SDF-шрифт и пул текстур (текущая резкость достаточна).
## Фаза 3 — Педагогика сечений
## Фаза 3 — Педагогика сечений — ГОТОВО (частично, см. бэклог)
- [ ] 3.1 Построение сечения «по следам» с пошаговой анимацией и подписью вершин.
- [ ] 3.2 Точки сечения в произвольной точке грани.
- [ ] 3.3 Постоянная панель readout: длины/углы/площадь сечения с пояснением.
- [x] 3.1 Подписи вершин сечения буквами (K, L, M…) в наклонном/произвольном сечении и сечении-по-3-точкам (для настоящих многоугольников ≤12 вершин). Пошаговый режим сечения-по-3-точкам уже был. _(Полное «построение по следам» — в бэклоге, крупная отдельная фича.)_
- [x] 3.2 Точки на произвольной точке грани: `_raycastFace()` — в режиме «точки» клик по грани (не у ребра/вершины) ставит точку на поверхности. Через произвольное сечение (3 кастомные точки) это даёт плоскость через внутренние точки граней.
- [x] 3.3 Live-readout overlay (`#stereo-readout`, низ-слева): тип сечения, площадь S, периметр P, последнее измерение. Обновляется через `info().readout` при любом изменении сечения/измерения.
Бэклог: полное «построение сечения по следам» (продление рёбер, след на грани); readout углов (двугранный/линия-плоскость) — сейчас угол только в 3D-метке.
## Фаза 4 — Визуал