feat(stereo3d): Фаза 2 — точные сечения кривых, унификация пикинга, HiDPI-метки
- _sliceCurvedByNormal(): аналитическое сечение шара (окружность) и цилиндра/конуса/усеч.конуса (гладкая кривая через точное y(θ)); старый сэмплинг оставлен fallback'ом для почти вертикальных плоскостей - _edgePickNDC(): корректный пикинг ребра по всей длине (было — по середине) - _makeTextSprite: DPR-aware, аспект по тексту, обводка, анизотропия - тип сечения кривых = окружность/эллипс; вершинные маркеры cap ≤12 точек - bump stereo.js?v=5 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+134
-42
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -4817,7 +4817,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=4"></script>
|
||||
<script src="/js/labs/stereo.js?v=5"></script>
|
||||
<script src="/js/notifications.js"></script>
|
||||
<script src="/js/search.js"></script>
|
||||
<script src="/js/mobile.js"></script>
|
||||
|
||||
@@ -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 — Педагогика сечений
|
||||
|
||||
|
||||
Reference in New Issue
Block a user