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:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 — Визуал
|
||||
|
||||
|
||||
Reference in New Issue
Block a user