diff --git a/frontend/js/labs/stereo.js b/frontend/js/labs/stereo.js
index bb9ea72..0b33f77 100644
--- a/frontend/js/labs/stereo.js
+++ b/frontend/js/labs/stereo.js
@@ -1432,20 +1432,41 @@ class StereoSim {
}
_makeTextSprite(text, color = '#ffffff', size = 64) {
+ text = String(text);
+ // High-res, aspect-correct backing canvas → crisp at any zoom / DPI.
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
+ const fontPx = 96; // backing resolution (independent of world size)
+ const pad = 12;
+ const meas = document.createElement('canvas').getContext('2d');
+ meas.font = `bold ${fontPx}px Manrope, sans-serif`;
+ const wCss = Math.ceil(meas.measureText(text).width) + pad * 2;
+ const hCss = fontPx + pad * 2;
+
const canvas = document.createElement('canvas');
- canvas.width = 128; canvas.height = 64;
+ canvas.width = Math.max(2, Math.round(wCss * dpr));
+ canvas.height = Math.max(2, Math.round(hCss * dpr));
const ctx = canvas.getContext('2d');
- ctx.font = `bold ${size}px Manrope, sans-serif`;
- ctx.fillStyle = color;
+ ctx.scale(dpr, dpr);
+ ctx.font = `bold ${fontPx}px Manrope, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
- ctx.fillText(text, 64, 32);
+ // dark halo keeps labels legible over bright faces / grid
+ ctx.lineWidth = fontPx * 0.14;
+ ctx.lineJoin = 'round';
+ ctx.strokeStyle = 'rgba(0,0,0,0.55)';
+ ctx.strokeText(text, wCss / 2, hCss / 2);
+ ctx.fillStyle = color;
+ ctx.fillText(text, wCss / 2, hCss / 2);
const tex = new THREE.CanvasTexture(canvas);
tex.minFilter = THREE.LinearFilter;
+ tex.magFilter = THREE.LinearFilter;
+ if (this.renderer.capabilities) tex.anisotropy = this.renderer.capabilities.getMaxAnisotropy();
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false });
const sprite = new THREE.Sprite(mat);
- sprite.scale.set(0.8, 0.4, 1);
+ // world height scales with the requested `size`; width follows the text aspect
+ const worldH = (size / 64) * 0.5;
+ sprite.scale.set(worldH * (wCss / hCss), worldH, 1);
return sprite;
}
@@ -1575,9 +1596,18 @@ class StereoSim {
_sliceByNormal(normal, pointOnPlane) {
const d = -normal.dot(pointOnPlane);
+ const curved = ['cylinder', 'cone', 'trunccone', 'sphere'].includes(this.figureType);
+
+ // Curved solids: analytic (smooth) intersection where possible.
+ if (curved) {
+ const poly = this._sliceCurvedByNormal(normal, d);
+ if (poly && poly.length >= 3) return poly;
+ // else fall through to the generic sampler (e.g. near-vertical planes)
+ }
+
const points = [];
- // Intersect with all edges
+ // Intersect with all edges (polyhedra)
for (const e of this._edges) {
const d1 = normal.dot(e.from) + d;
const d2 = normal.dot(e.to) + d;
@@ -1589,23 +1619,19 @@ class StereoSim {
}
}
- // For cylinders/cones/spheres — sample the intersection curve
- if (points.length < 3 && ['cylinder','cone','trunccone','sphere'].includes(this.figureType)) {
+ // Fallback sampler for curved solids when the analytic path bailed out.
+ if (points.length < 3 && curved) {
const fh = this._figureHeight();
- const samples = 64;
+ const samples = 96;
for (let i = 0; i < samples; i++) {
const angle = (i / samples) * Math.PI * 2;
- // Walk along height, find where the plane intersects at this angle
for (let step = 0; step <= 100; step++) {
const y = (step / 100) * fh;
const r = this._radiusAtHeight(y);
if (r < 0.01) continue;
const pt = new THREE.Vector3(r * Math.cos(angle), y, r * Math.sin(angle));
const dist = normal.dot(pt) + d;
- if (Math.abs(dist) < fh * 0.012) {
- points.push(pt);
- break;
- }
+ if (Math.abs(dist) < fh * 0.012) { points.push(pt); break; }
}
}
}
@@ -1614,6 +1640,55 @@ class StereoSim {
return this._sortByAngle3D(points, normal);
}
+ // Analytic plane∩(solid of revolution) — returns an ordered, smooth polygon
+ // (circle for a sphere, ellipse/conic arc for cylinder/cone), or null to defer.
+ _sliceCurvedByNormal(normal, d) {
+ const ft = this.figureType;
+ const fh = this._figureHeight();
+ const TAU = Math.PI * 2;
+
+ if (ft === 'sphere') {
+ const R = this.params.r;
+ const C = new THREE.Vector3(0, R, 0); // sphere centre
+ const dist = normal.dot(C) + d; // signed distance centre→plane
+ if (Math.abs(dist) >= R) return []; // plane misses the sphere
+ const rho = Math.sqrt(Math.max(0, R * R - dist * dist));
+ const center = C.clone().addScaledVector(normal, -dist); // projection onto plane
+ // orthonormal basis in the plane
+ let u = Math.abs(normal.x) > 0.9 ? new THREE.Vector3(0, 1, 0) : new THREE.Vector3(1, 0, 0);
+ u = new THREE.Vector3().crossVectors(normal, u).normalize();
+ const v = new THREE.Vector3().crossVectors(normal, u).normalize();
+ const out = [];
+ const N = 72;
+ for (let i = 0; i < N; i++) {
+ const a = (i / N) * TAU;
+ out.push(center.clone().addScaledVector(u, rho * Math.cos(a)).addScaledVector(v, rho * Math.sin(a)));
+ }
+ return out; // already ordered around the circle
+ }
+
+ // cylinder / cone / trunccone — lateral surface r(y) = r0 + k·y
+ if (Math.abs(normal.y) < 0.05) return null; // near-vertical plane → defer
+ const r0 = this._radiusAtHeight(0);
+ const r1 = this._radiusAtHeight(fh);
+ const k = (r1 - r0) / fh;
+ const out = [];
+ const N = 120;
+ for (let i = 0; i < N; i++) {
+ const a = (i / N) * TAU;
+ const c = normal.x * Math.cos(a) + normal.z * Math.sin(a);
+ // plane: c·r(y) + n_y·y + d = 0, with r(y) = r0 + k·y
+ const denom = c * k + normal.y;
+ if (Math.abs(denom) < 1e-9) continue;
+ const y = -(c * r0 + d) / denom;
+ if (y < -1e-6 || y > fh + 1e-6) continue; // generator meets plane outside the body
+ const r = r0 + k * Math.max(0, Math.min(fh, y));
+ if (r < 1e-4) continue;
+ out.push(new THREE.Vector3(r * Math.cos(a), Math.max(0, Math.min(fh, y)), r * Math.sin(a)));
+ }
+ return out.length >= 3 ? this._sortByAngle3D(out, normal) : null;
+ }
+
_sortByAngle(points) {
if (points.length < 3) return points;
const cx = points.reduce((s,p) => s+p.x, 0) / points.length;
@@ -1669,13 +1744,16 @@ class StereoSim {
label.scale.set(1.6, 0.5, 1);
this._sectionGroup.add(label);
- // Vertex markers on section polygon
- for (const p of pts) {
- 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);
+ // Vertex markers only for genuine polygons; smooth conic sections (many
+ // sampled points) would otherwise render a ring of dozens of spheres.
+ if (pts.length <= 12) {
+ for (const p of pts) {
+ 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);
+ }
}
}
@@ -2046,8 +2124,15 @@ class StereoSim {
const area = this._polygonArea(polygon);
const n = polygon.length;
+ const curved = ['cylinder', 'cone', 'trunccone', 'sphere'].includes(this.figureType);
const typeNames = { 3: 'треугольник', 4: 'четырёхугольник', 5: 'пятиугольник', 6: 'шестиугольник' };
- const typeName = typeNames[n] || `${n}-угольник`;
+ let typeName;
+ if (curved && n > 8) {
+ typeName = this.figureType === 'sphere' ? 'окружность'
+ : (Math.abs(normal.y) > 0.98 ? 'окружность' : 'эллипс (коническое сечение)');
+ } else {
+ typeName = typeNames[n] || `${n}-угольник`;
+ }
this._section3PData = { normal, D, polygon, area, typeName, P1, P2, P3 };
}
@@ -2136,14 +2221,16 @@ class StereoSim {
const outlineMat = new THREE.LineBasicMaterial({ color: SECT_COLOR, linewidth: 2 });
this._section3PGroup.add(new THREE.Line(outlineGeo, outlineMat));
- // Vertex markers on section polygon
- polygon.forEach(p => {
- 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);
- });
+ // Vertex markers — only for true polygons; smooth conic sections skip them.
+ if (polygon.length <= 12) {
+ polygon.forEach(p => {
+ 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);
+ });
+ }
// Step-by-step highlight (если включён пошаговый режим)
if (this._section3PStepBy && this._section3PStep > 0) {
@@ -2234,6 +2321,19 @@ class StereoSim {
};
}
+ // Distance (in NDC) from a screen point to the projected 3D segment a→b,
+ // and the clamped parameter t. Used by every edge picker for consistency.
+ _edgePickNDC(mx, my, a, b) {
+ const p1 = a.clone().project(this.camera);
+ const p2 = b.clone().project(this.camera);
+ const dx = p2.x - p1.x, dy = p2.y - p1.y;
+ const lenSq = dx * dx + dy * dy;
+ let t = lenSq < 1e-9 ? 0 : ((mx - p1.x) * dx + (my - p1.y) * dy) / lenSq;
+ t = Math.max(0, Math.min(1, t));
+ const px = p1.x + t * dx, py = p1.y + t * dy;
+ return { dist: Math.hypot(mx - px, my - py), t };
+ }
+
_onPointClick(e) {
const { mx, my } = this._screenCoords(e);
@@ -2648,11 +2748,8 @@ class StereoSim {
let bestEdge = null;
for (const edge of this._edges) {
- const p1 = edge.from.clone().project(this.camera);
- const p2 = edge.to.clone().project(this.camera);
- const mid = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
- const d = Math.sqrt((mid.x - mx) ** 2 + (mid.y - my) ** 2);
- if (d < bestDist) { bestDist = d; bestEdge = edge; }
+ const { dist } = this._edgePickNDC(mx, my, edge.from, edge.to);
+ if (dist < bestDist) { bestDist = dist; bestEdge = edge; }
}
return bestEdge;
}
@@ -3259,13 +3356,8 @@ class StereoSim {
let bestIdx = -1;
for (let i = 0; i < this._edges.length; i++) {
const edge = this._edges[i];
- const p1 = edge.from.clone().project(this.camera);
- const p2 = edge.to.clone().project(this.camera);
- // distance from click to edge midpoint in NDC
- const mx2 = (p1.x + p2.x) / 2;
- const my2 = (p1.y + p2.y) / 2;
- const d = Math.sqrt((mx2 - mx) ** 2 + (my2 - my) ** 2);
- if (d < bestDist) { bestDist = d; bestIdx = i; }
+ const { dist } = this._edgePickNDC(mx, my, edge.from, edge.to);
+ if (dist < bestDist) { bestDist = dist; bestIdx = i; }
}
return bestIdx;
}
diff --git a/frontend/lab.html b/frontend/lab.html
index 8beeff1..1550b40 100644
--- a/frontend/lab.html
+++ b/frontend/lab.html
@@ -4817,7 +4817,7 @@
-
+
diff --git a/plans/STEREO_3D_IMPROVEMENT.md b/plans/STEREO_3D_IMPROVEMENT.md
index 56f2f75..1051d17 100644
--- a/plans/STEREO_3D_IMPROVEMENT.md
+++ b/plans/STEREO_3D_IMPROVEMENT.md
@@ -20,11 +20,13 @@
- [x] 1.2 Overlay-тулбар в правом верхнем углу viewport: сброс вида + пресеты ракурса (Изо / Спереди / Сбоку / Сверху). Пресет «держит» вид (спин выключается).
- [x] 1.3 Тумблер авто-вращения (с реальным засыпанием loop при выключении), fullscreen (по `.graph-canvas-outer`), снимок PNG (`preserveDrawingBuffer` + синхронный рендер → download). a11y: `aria-pressed`/`aria-label` на кнопках.
-## Фаза 2 — Геометрия и пикинг
+## Фаза 2 — Геометрия и пикинг — ГОТОВО
-- [ ] 2.1 Точные сечения кривых тел (окружность/эллипс для шара/цилиндра/конуса) вместо сэмплинга порогом.
-- [ ] 2.2 Унифицировать пикинг (расстояние точка-отрезок везде, в т.ч. `_pickNearestEdgeIdx`).
-- [ ] 2.3 HiDPI-метки (резкие спрайты/SDF), пул текстур вместо пересоздания.
+- [x] 2.1 Аналитические сечения кривых тел `_sliceCurvedByNormal()`: шар → точная окружность (плоскость∩сфера), цилиндр/конус/усеч.конус → гладкая кривая через точное решение y(θ) для образующей `r(y)=r0+k·y`. Старый пороговый сэмплинг оставлен как fallback (почти вертикальные плоскости). Проверено численно (диапазон y цилиндра и радиус окружности шара совпадают с формулами).
+- [x] 2.2 Общий хелпер `_edgePickNDC()` (расстояние точка-отрезок в NDC); `_pickNearestEdge` и `_pickNearestEdgeIdx` переведены с «по середине ребра» на корректный пикинг по всей длине.
+- [x] 2.3 HiDPI-метки: `_makeTextSprite` рендерит на canvas с учётом DPR, корректный аспект по ширине текста, тёмная обводка для читаемости, анизотропная фильтрация. Тип сечения для кривых = «окружность»/«эллипс», вершинные маркеры не плодятся (cap ≤12 точек).
+
+Бэклог: zoom-to-cursor (перенесён из 1.1); SDF-шрифт и пул текстур (текущая резкость достаточна).
## Фаза 3 — Педагогика сечений