From c802fe552a0a0ac3488836b628cbc2208699292c Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sat, 30 May 2026 11:19:40 +0300 Subject: [PATCH] =?UTF-8?q?feat(stereo3d):=20=D0=A4=D0=B0=D0=B7=D0=B0=202?= =?UTF-8?q?=20=E2=80=94=20=D1=82=D0=BE=D1=87=D0=BD=D1=8B=D0=B5=20=D1=81?= =?UTF-8?q?=D0=B5=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BA=D1=80=D0=B8=D0=B2?= =?UTF-8?q?=D1=8B=D1=85,=20=D1=83=D0=BD=D0=B8=D1=84=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D0=BF=D0=B8=D0=BA=D0=B8=D0=BD=D0=B3=D0=B0?= =?UTF-8?q?,=20HiDPI-=D0=BC=D0=B5=D1=82=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _sliceCurvedByNormal(): аналитическое сечение шара (окружность) и цилиндра/конуса/усеч.конуса (гладкая кривая через точное y(θ)); старый сэмплинг оставлен fallback'ом для почти вертикальных плоскостей - _edgePickNDC(): корректный пикинг ребра по всей длине (было — по середине) - _makeTextSprite: DPR-aware, аспект по тексту, обводка, анизотропия - тип сечения кривых = окружность/эллипс; вершинные маркеры cap ≤12 точек - bump stereo.js?v=5 Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/js/labs/stereo.js | 176 +++++++++++++++++++++++++-------- frontend/lab.html | 2 +- plans/STEREO_3D_IMPROVEMENT.md | 10 +- 3 files changed, 141 insertions(+), 47 deletions(-) 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 — Педагогика сечений