diff --git a/frontend/css/lab.css b/frontend/css/lab.css index aa47919..e63e634 100644 --- a/frontend/css/lab.css +++ b/frontend/css/lab.css @@ -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; diff --git a/frontend/js/labs/stereo.js b/frontend/js/labs/stereo.js index 0b33f77..868fab9 100644 --- a/frontend/js/labs/stereo.js +++ b/frontend/js/labs/stereo.js @@ -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 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 => + '