Files
Learn_System/frontend/js/stereo3d.js
T
Maxim Dolgolyov 0284611263 feat(geom10 W0): инфра — миграция БД, stereo3d.js, hub + 4 stub-раздела
- Миграция 027: textbooks hub geometry-10 + 4 ребёнка (r1 blue, r2 emerald, r3 rose, r4 amber)
- frontend/js/stereo3d.js: библиотека 3D-проекций (Scene, CABINET/ISOMETRIC, cube/box/prism/pyramid/tetrahedron/plane/arrow/angle, авто-видимость рёбер)
- geometry_10_hub.html: 4 карточки разделов, общий прогресс, превью 4 тел через stereo3d
- 4 stub-файла разделов (r1-r4) с list параграфов и плашкой 'в разработке'
- backend/scripts/gen_geom10_stubs.js: генератор stub-файлов
2026-05-29 14:37:07 +03:00

718 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* stereo3d.js — библиотека 3D-проекций для Геометрии 10 (стереометрия).
*
* Чистый SVG-движок: проекция 3D → 2D через cabinet / isometric.
* Не зависит от Three.js, Canvas, WebGL. Возвращает строки SVG.
*
* Публичный API: window.STEREO3D = { Scene, views, util }.
*
* Использование:
* const scene = new STEREO3D.Scene(380, 320, {view:'CABINET', scale:50});
* scene.addCube({center:[0,0,0], size:2, label:'ABCDA1B1C1D1'});
* scene.addLabel('M', [0, 0, 1.2]);
* const svgHtml = scene.render();
*
* Соглашение об осях: x → вправо, y → вглубь, z → вверх.
* В SVG y растёт вниз, мы инвертируем при проекции.
*/
(function(){
'use strict';
if (window.STEREO3D && window.STEREO3D.__installed) return;
const S = window.STEREO3D = window.STEREO3D || {};
S.__installed = true;
/* ============================================================ */
/* ВЕКТОРНАЯ АЛГЕБРА */
/* ============================================================ */
function v3(a,b,c){ return [a,b,c]; }
function add(a,b){ return [a[0]+b[0], a[1]+b[1], a[2]+b[2]]; }
function sub(a,b){ return [a[0]-b[0], a[1]-b[1], a[2]-b[2]]; }
function scale3(a,k){ return [a[0]*k, a[1]*k, a[2]*k]; }
function dot(a,b){ return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]; }
function cross(a,b){
return [
a[1]*b[2] - a[2]*b[1],
a[2]*b[0] - a[0]*b[2],
a[0]*b[1] - a[1]*b[0]
];
}
function len3(a){ return Math.hypot(a[0], a[1], a[2]); }
function norm3(a){ const L = len3(a)||1; return [a[0]/L, a[1]/L, a[2]/L]; }
function rotX(p, a){
const c = Math.cos(a), s = Math.sin(a);
return [p[0], p[1]*c - p[2]*s, p[1]*s + p[2]*c];
}
function rotY(p, a){
const c = Math.cos(a), s = Math.sin(a);
return [p[0]*c + p[2]*s, p[1], -p[0]*s + p[2]*c];
}
function rotZ(p, a){
const c = Math.cos(a), s = Math.sin(a);
return [p[0]*c - p[1]*s, p[0]*s + p[1]*c, p[2]];
}
S.util = { v3, add, sub, scale3, dot, cross, len3, norm3, rotX, rotY, rotZ };
/* ============================================================ */
/* ПРОЕКЦИИ */
/* ============================================================ */
const VIEWS = S.views = {
CABINET: {
project: function(p){
// x → вправо, y → вглубь под 30° со сжатием 0.5, z → вверх
const a = Math.PI / 6;
return [
p[0] + 0.5 * p[1] * Math.cos(a),
-p[2] + 0.5 * p[1] * Math.sin(a)
];
},
cameraDir: [0.5*Math.cos(Math.PI/6), 1, 0.5*Math.sin(Math.PI/6)]
},
ISOMETRIC: {
project: function(p){
// классическая изометрия: оси под 30° к горизонтали
const c = Math.cos(Math.PI/6), s = Math.sin(Math.PI/6);
return [
(p[0] - p[1]) * c,
-p[2] + (p[0] + p[1]) * s
];
},
cameraDir: [1, 1, 1]
}
};
/* ============================================================ */
/* МАТЕРИАЛЫ */
/* ============================================================ */
const MAT = {
edge: { stroke:'#1e293b', width:1.8 },
edgeHidden:{ stroke:'#94a3b8', width:1.2, dash:'4 3' },
edgeHi: { stroke:'#dc2626', width:2.8 },
edgePar: { stroke:'#10b981', width:2.4 },
edgePerp: { stroke:'#7c3aed', width:2.4 },
face: { fill:'#dbeafe', opacity:0.35, stroke:'#1e3a8a', strokeWidth:1.4 },
plane: { fill:'#3b82f6', opacity:0.18, stroke:'#1e3a8a', strokeWidth:1.2, dash:'6 4' },
vertex: { fill:'#1e293b', r:3.5 }
};
/* ============================================================ */
/* Scene */
/* ============================================================ */
class Scene {
constructor(W, H, opts){
opts = opts || {};
this.W = W;
this.H = H;
this.scale = opts.scale || 40;
this.center = opts.center || [W/2, H/2];
this.view = (typeof opts.view === 'string') ? (VIEWS[opts.view] || VIEWS.CABINET) : (opts.view || VIEWS.CABINET);
this.rotX = opts.rotX || 0;
this.rotY = opts.rotY || 0;
this.bg = opts.bg || '#fafafa';
this.border = (opts.border !== undefined) ? opts.border : '1px solid #e2e8f0';
this.radius = opts.radius || 10;
this.items = []; // отрисовка в порядке Z (заполняется в render)
this._id = opts.id || ('s3d-' + Math.random().toString(36).slice(2,7));
}
// Применить повороты + проекцию
project3(p){
let q = p;
if (this.rotX) q = rotX(q, this.rotX);
if (this.rotY) q = rotY(q, this.rotY);
const [px, py] = this.view.project(q);
return [this.center[0] + this.scale * px, this.center[1] + this.scale * py];
}
// То же, но возвращает все три величины (для сортировки по глубине)
project3Depth(p){
let q = p;
if (this.rotX) q = rotX(q, this.rotX);
if (this.rotY) q = rotY(q, this.rotY);
const [px, py] = this.view.project(q);
// глубина = проекция на направление камеры
const cam = this.view.cameraDir;
const depth = dot(q, cam);
return { x: this.center[0] + this.scale * px, y: this.center[1] + this.scale * py, depth };
}
/* === Добавление элементов === */
addEdge(p1, p2, opts){
this.items.push({ kind:'edge', p1, p2, opts: opts || {} });
return this;
}
addFace(points, opts){
this.items.push({ kind:'face', points, opts: opts || {} });
return this;
}
addVertex(p, label, opts){
this.items.push({ kind:'vertex', p, label, opts: opts || {} });
return this;
}
addLabel(label, p, opts){
this.items.push({ kind:'label', p, label, opts: opts || {} });
return this;
}
addArrow(p1, p2, opts){
this.items.push({ kind:'arrow', p1, p2, opts: opts || {} });
return this;
}
addPlane(point, normal, opts){
this.items.push({ kind:'plane', point, normal, opts: opts || {} });
return this;
}
addAngleMark(vertex, p1, p2, opts){
this.items.push({ kind:'angle', vertex, p1, p2, opts: opts || {} });
return this;
}
addRightAngleMark(vertex, p1, p2, opts){
this.items.push({ kind:'rightAngle', vertex, p1, p2, opts: opts || {} });
return this;
}
addDashedSegment(p1, p2, opts){
return this.addEdge(p1, p2, Object.assign({hidden:true}, opts||{}));
}
/* === Готовые тела === */
// Куб ABCDA1B1C1D1 — стандартное соглашение учебника
addCube(params){
params = params || {};
const c = params.center || [0,0,0];
const s = (params.size || 2) / 2;
const labels = params.labels !== false;
const labelMap = params.labelMap || ['A','B','C','D','A_1','B_1','C_1','D_1'];
// Нижняя грань (z = -s): A B C D против часовой при взгляде сверху
const A = add(c, [-s, -s, -s]);
const B = add(c, [+s, -s, -s]);
const C = add(c, [+s, +s, -s]);
const D = add(c, [-s, +s, -s]);
const A1 = add(c, [-s, -s, +s]);
const B1 = add(c, [+s, -s, +s]);
const C1 = add(c, [+s, +s, +s]);
const D1 = add(c, [-s, +s, +s]);
const verts = [A,B,C,D,A1,B1,C1,D1];
const faces = [
[0,1,2,3], // нижняя
[4,5,6,7], // верхняя
[0,1,5,4], // передняя
[1,2,6,5], // правая
[2,3,7,6], // задняя
[3,0,4,7] // левая
];
const edges = [
[0,1],[1,2],[2,3],[3,0], // низ
[4,5],[5,6],[6,7],[7,4], // верх
[0,4],[1,5],[2,6],[3,7] // вертикали
];
return this._addPolyhedron(verts, faces, edges,
labels ? labelMap.map((L,i)=>({label:L, point:verts[i]})) : [],
params);
}
// Параллелепипед с произвольными размерами
addBox(params){
params = params || {};
const c = params.center || [0,0,0];
const sz = params.size || [2,2,2];
const [ax, ay, az] = [sz[0]/2, sz[1]/2, sz[2]/2];
const labelMap = params.labelMap || ['A','B','C','D','A_1','B_1','C_1','D_1'];
const A = add(c, [-ax, -ay, -az]);
const B = add(c, [+ax, -ay, -az]);
const C = add(c, [+ax, +ay, -az]);
const D = add(c, [-ax, +ay, -az]);
const A1 = add(c, [-ax, -ay, +az]);
const B1 = add(c, [+ax, -ay, +az]);
const C1 = add(c, [+ax, +ay, +az]);
const D1 = add(c, [-ax, +ay, +az]);
const verts = [A,B,C,D,A1,B1,C1,D1];
const faces = [
[0,1,2,3],[4,5,6,7],[0,1,5,4],[1,2,6,5],[2,3,7,6],[3,0,4,7]
];
const edges = [
[0,1],[1,2],[2,3],[3,0],
[4,5],[5,6],[6,7],[7,4],
[0,4],[1,5],[2,6],[3,7]
];
return this._addPolyhedron(verts, faces, edges,
params.labels !== false ? labelMap.map((L,i)=>({label:L, point:verts[i]})) : [],
params);
}
// Тетраэдр (правильный или по 4 вершинам)
addTetrahedron(params){
params = params || {};
let verts;
if (params.vertices){
verts = params.vertices;
} else {
const c = params.center || [0,0,0];
const r = params.size || 1.6;
// правильный тетраэдр: вершины на сфере
verts = [
add(c, [ r, r, r]),
add(c, [ r, -r, -r]),
add(c, [-r, r, -r]),
add(c, [-r, -r, r])
];
}
const labelMap = params.labelMap || ['A','B','C','D'];
const faces = [[0,1,2],[0,1,3],[0,2,3],[1,2,3]];
const edges = [[0,1],[0,2],[0,3],[1,2],[1,3],[2,3]];
return this._addPolyhedron(verts, faces, edges,
params.labels !== false ? labelMap.map((L,i)=>({label:L, point:verts[i]})) : [],
params);
}
// n-угольная призма (база — правильный n-угольник в плоскости xy)
addPrism(params){
params = params || {};
const n = params.n || 4;
const r = params.baseRadius || 1.4;
const h = params.height || 2;
const c = params.center || [0,0,0];
const bottom = [];
const top = [];
for (let i = 0; i < n; i++){
const a = 2 * Math.PI * i / n - Math.PI / 2;
bottom.push(add(c, [r * Math.cos(a), r * Math.sin(a), -h/2]));
top.push(add(c, [r * Math.cos(a), r * Math.sin(a), +h/2]));
}
const verts = bottom.concat(top);
const faces = [];
const bottomIdx = []; for (let i = 0; i < n; i++) bottomIdx.push(i);
const topIdx = []; for (let i = 0; i < n; i++) topIdx.push(i + n);
faces.push(bottomIdx);
faces.push(topIdx.slice().reverse());
for (let i = 0; i < n; i++){
const j = (i + 1) % n;
faces.push([i, j, j+n, i+n]);
}
const edges = [];
for (let i = 0; i < n; i++){
const j = (i + 1) % n;
edges.push([i, j]);
edges.push([i+n, j+n]);
edges.push([i, i+n]);
}
return this._addPolyhedron(verts, faces, edges, [], params);
}
// n-угольная пирамида
addPyramid(params){
params = params || {};
const n = params.n || 4;
const r = params.baseRadius || 1.4;
const h = params.height || 2;
const c = params.center || [0,0,0];
const base = [];
for (let i = 0; i < n; i++){
const a = 2 * Math.PI * i / n - Math.PI / 2;
base.push(add(c, [r * Math.cos(a), r * Math.sin(a), -h/2]));
}
const apex = add(c, [0, 0, h/2]);
const verts = base.concat([apex]);
const apexIdx = n;
const faces = [];
faces.push(base.map((_,i)=>i));
for (let i = 0; i < n; i++){
const j = (i + 1) % n;
faces.push([i, j, apexIdx]);
}
const edges = [];
for (let i = 0; i < n; i++){
const j = (i + 1) % n;
edges.push([i, j]);
edges.push([i, apexIdx]);
}
return this._addPolyhedron(verts, faces, edges, [], params);
}
// Конус (приближение многогранником)
addCone(params){
params = params || {};
const n = params.segments || 24;
return this.addPyramid(Object.assign({n}, params));
}
// Цилиндр (приближение призмой)
addCylinder(params){
params = params || {};
const n = params.segments || 24;
return this.addPrism(Object.assign({n}, params));
}
// Сфера (упрощённо — окружность по силуэту)
addSphere(params){
params = params || {};
const c = params.center || [0,0,0];
const r = params.radius || 1.4;
this.items.push({ kind:'sphere', center:c, radius:r, opts:params });
return this;
}
// Общий конструктор многогранника
_addPolyhedron(verts, faces, edges, vertLabels, params){
params = params || {};
const color = params.color || '#dbeafe';
const opacity = (params.opacity !== undefined) ? params.opacity : 0.35;
const showFaces = params.showFaces !== false;
const showHidden = params.showHidden !== false;
this.items.push({
kind:'polyhedron',
verts, faces, edges, vertLabels,
color, opacity, showFaces, showHidden,
hiOptsByEdge: params.highlightEdges || null,
hiOptsByFace: params.highlightFaces || null,
labels: vertLabels,
labelStyle: params.labelStyle || {}
});
return this;
}
/* === Рендер === */
render(){
const W = this.W, H = this.H;
const open = '<svg viewBox="0 0 '+W+' '+H+'" preserveAspectRatio="xMidYMid meet" '
+ 'style="width:100%;height:auto;display:block;background:'+this.bg
+ ';border:'+this.border+';border-radius:'+this.radius+'px">';
const defs = this._defs();
// Собираем «плоские» элементы для отрисовки: faces (back), edges (hidden), faces (front), edges (visible), verts, labels.
const out = [];
// Сначала — обрабатываем все объекты в two passes:
// pass 1: задние грани + невидимые рёбра
// pass 2: передние грани + видимые рёбра
// pass 3: вершины
// pass 4: подписи + декораторы (углы, стрелки)
const flat = []; // {type, depth, svg}
for (const it of this.items){
if (it.kind === 'polyhedron'){
this._polyhedronToFlat(it, flat);
} else if (it.kind === 'edge'){
const a = this.project3Depth(it.p1);
const b = this.project3Depth(it.p2);
const mid = (a.depth + b.depth) / 2;
flat.push({ pass: it.opts.hidden ? 1 : 4, depth: mid, svg: this._edgeSvg(a, b, it.opts) });
} else if (it.kind === 'face'){
const pts = it.points.map(p => this.project3Depth(p));
const mid = pts.reduce((s,p)=>s+p.depth, 0)/pts.length;
flat.push({ pass: 2, depth: mid, svg: this._faceSvg(pts, it.opts) });
} else if (it.kind === 'vertex'){
const a = this.project3Depth(it.p);
flat.push({ pass: 5, depth: a.depth, svg: this._vertexSvg(a, it.label, it.opts) });
} else if (it.kind === 'label'){
const a = this.project3Depth(it.p);
flat.push({ pass: 6, depth: a.depth, svg: this._labelSvg(a, it.label, it.opts) });
} else if (it.kind === 'arrow'){
const a = this.project3Depth(it.p1);
const b = this.project3Depth(it.p2);
flat.push({ pass: 4, depth: (a.depth+b.depth)/2, svg: this._arrowSvg(a, b, it.opts) });
} else if (it.kind === 'plane'){
flat.push({ pass: 2, depth: dot(it.point, this.view.cameraDir), svg: this._planeSvg(it.point, it.normal, it.opts) });
} else if (it.kind === 'angle'){
const v = this.project3Depth(it.vertex);
flat.push({ pass: 6, depth: v.depth, svg: this._angleMarkSvg(it.vertex, it.p1, it.p2, it.opts) });
} else if (it.kind === 'rightAngle'){
const v = this.project3Depth(it.vertex);
flat.push({ pass: 6, depth: v.depth, svg: this._rightAngleMarkSvg(it.vertex, it.p1, it.p2, it.opts) });
} else if (it.kind === 'sphere'){
const c = this.project3Depth(it.center);
const r = this.scale * it.radius;
flat.push({ pass: 2, depth: c.depth, svg:
'<circle cx="'+c.x+'" cy="'+c.y+'" r="'+r+'" fill="'+(it.opts.color||'#dbeafe')+'" fill-opacity="0.35" stroke="#1e3a8a" stroke-width="1.4"/>'
+ '<ellipse cx="'+c.x+'" cy="'+c.y+'" rx="'+r+'" ry="'+(r*0.35)+'" fill="none" stroke="#1e3a8a" stroke-width="1" stroke-dasharray="4 3" opacity="0.6"/>'
});
}
}
// Сортируем по pass, потом по depth (меньшая глубина = дальше).
flat.sort((a,b)=> (a.pass - b.pass) || (a.depth - b.depth));
let body = '';
for (const f of flat) body += f.svg;
return open + defs + body + '</svg>';
}
_defs(){
return '<defs>'
+ '<marker id="'+this._id+'-arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">'
+ '<path d="M 0 0 L 10 5 L 0 10 z" fill="#1e293b"/>'
+ '</marker>'
+ '</defs>';
}
_polyhedronToFlat(it, flat){
const proj = it.verts.map(p => this.project3Depth(p));
// Для каждой грани вычисляем нормаль в 3D и определяем — обращена к камере или нет.
const cam = this.view.cameraDir;
const faceData = it.faces.map((idxs)=>{
const p0 = it.verts[idxs[0]], p1 = it.verts[idxs[1]], p2 = it.verts[idxs[2]];
// нормаль через cross product. Для выпуклых тел нужно учесть центр тела.
const n = norm3(cross(sub(p1, p0), sub(p2, p0)));
// центр грани
let cx=0, cy=0, cz=0;
for (const i of idxs){ cx+=it.verts[i][0]; cy+=it.verts[i][1]; cz+=it.verts[i][2]; }
const ctr = [cx/idxs.length, cy/idxs.length, cz/idxs.length];
// если нормаль смотрит ВНУТРЬ тела (к началу), инвертируем
// (примерное предположение что центр тела — среднее всех вершин)
let bx=0, by=0, bz=0;
for (const v of it.verts){ bx+=v[0]; by+=v[1]; bz+=v[2]; }
const body = [bx/it.verts.length, by/it.verts.length, bz/it.verts.length];
const outward = sub(ctr, body);
if (dot(n, outward) < 0){ n[0]=-n[0]; n[1]=-n[1]; n[2]=-n[2]; }
// повернуть нормаль теми же поворотами что и точки
let rn = n;
if (this.rotX) rn = rotX(rn, this.rotX);
if (this.rotY) rn = rotY(rn, this.rotY);
const visible = dot(rn, cam) > 0;
const meanDepth = idxs.reduce((s,i)=>s+proj[i].depth, 0)/idxs.length;
return { idxs, visible, meanDepth, n: rn };
});
// Какие рёбра видимые (хоть одна смежная грань видима)
const edgeFaceMap = new Map();
function edgeKey(a, b){ return Math.min(a,b)+'-'+Math.max(a,b); }
for (let fi = 0; fi < it.faces.length; fi++){
const fc = it.faces[fi];
for (let i = 0; i < fc.length; i++){
const k = edgeKey(fc[i], fc[(i+1)%fc.length]);
if (!edgeFaceMap.has(k)) edgeFaceMap.set(k, []);
edgeFaceMap.get(k).push(fi);
}
}
const edgeVisible = new Map();
for (const [k, fis] of edgeFaceMap){
const vis = fis.some(fi => faceData[fi].visible);
edgeVisible.set(k, vis);
}
// 1) задние грани (полупрозрачные) — если showFaces
if (it.showFaces){
for (const fd of faceData){
if (!fd.visible){
const pts = fd.idxs.map(i => proj[i]);
flat.push({ pass:2, depth:fd.meanDepth, svg: this._faceSvg(pts, { fill: it.color, opacity: it.opacity * 0.5, stroke:'none' }) });
}
}
}
// 2) невидимые рёбра — пунктир
if (it.showHidden){
for (const e of it.edges){
const k = edgeKey(e[0], e[1]);
if (!edgeVisible.get(k)){
const a = proj[e[0]], b = proj[e[1]];
flat.push({ pass:1, depth:(a.depth+b.depth)/2, svg: this._edgeSvg(a, b, { hidden:true }) });
}
}
}
// 3) передние грани (полупрозрачные) — поверх задних рёбер
if (it.showFaces){
for (const fd of faceData){
if (fd.visible){
const pts = fd.idxs.map(i => proj[i]);
flat.push({ pass:3, depth:fd.meanDepth, svg: this._faceSvg(pts, { fill: it.color, opacity: it.opacity, stroke:'none' }) });
}
}
}
// 4) видимые рёбра — сплошные
for (const e of it.edges){
const k = edgeKey(e[0], e[1]);
if (edgeVisible.get(k)){
const a = proj[e[0]], b = proj[e[1]];
let style = {};
if (it.hiOptsByEdge){
for (const h of it.hiOptsByEdge){
if ((h.edge[0] === e[0] && h.edge[1] === e[1]) || (h.edge[0] === e[1] && h.edge[1] === e[0])){
style = h; break;
}
}
}
flat.push({ pass:4, depth:(a.depth+b.depth)/2, svg: this._edgeSvg(a, b, style) });
}
}
// 5) подписи вершин
if (it.labels && it.labels.length){
for (let vi = 0; vi < it.labels.length; vi++){
const lab = it.labels[vi];
const proj1 = proj[vi];
flat.push({ pass:6, depth:proj1.depth, svg: this._vertexLabelSvg(proj1, lab.label, it.labelStyle) });
}
}
}
/* === SVG примитивы === */
_edgeSvg(a, b, opts){
opts = opts || {};
let stroke, width, dash;
if (opts.hidden){ stroke = opts.stroke || MAT.edgeHidden.stroke; width = opts.width || MAT.edgeHidden.width; dash = opts.dash || MAT.edgeHidden.dash; }
else if (opts.highlight === 'par'){ stroke = MAT.edgePar.stroke; width = MAT.edgePar.width; }
else if (opts.highlight === 'perp'){ stroke = MAT.edgePerp.stroke; width = MAT.edgePerp.width; }
else if (opts.highlight){ stroke = MAT.edgeHi.stroke; width = MAT.edgeHi.width; }
else { stroke = opts.stroke || MAT.edge.stroke; width = opts.width || MAT.edge.width; dash = opts.dash; }
let s = '<line x1="'+a.x.toFixed(2)+'" y1="'+a.y.toFixed(2)+'" x2="'+b.x.toFixed(2)+'" y2="'+b.y.toFixed(2)+'" stroke="'+stroke+'" stroke-width="'+width+'" stroke-linecap="round"';
if (dash) s += ' stroke-dasharray="'+dash+'"';
s += '/>';
return s;
}
_faceSvg(pts, opts){
opts = opts || {};
const d = pts.map(p => p.x.toFixed(2)+','+p.y.toFixed(2)).join(' ');
const fill = opts.fill || MAT.face.fill;
const op = (opts.opacity !== undefined) ? opts.opacity : MAT.face.opacity;
const stroke = opts.stroke || 'none';
const sw = opts.strokeWidth || MAT.face.strokeWidth;
return '<polygon points="'+d+'" fill="'+fill+'" fill-opacity="'+op+'" stroke="'+stroke+'" stroke-width="'+sw+'"/>';
}
_vertexSvg(p, label, opts){
opts = opts || {};
const r = opts.r || MAT.vertex.r;
const fill = opts.fill || MAT.vertex.fill;
let s = '<circle cx="'+p.x.toFixed(2)+'" cy="'+p.y.toFixed(2)+'" r="'+r+'" fill="'+fill+'"/>';
if (label) s += this._vertexLabelSvg(p, label, opts);
return s;
}
_vertexLabelSvg(p, label, opts){
opts = opts || {};
const dx = (opts.dx !== undefined) ? opts.dx : 8;
const dy = (opts.dy !== undefined) ? opts.dy : -6;
const fs = opts.fontSize || 14;
const fill = opts.color || '#1e293b';
// если в label есть _, форматируем подстрочный индекс
const html = formatLabel(label);
return '<text x="'+(p.x+dx).toFixed(2)+'" y="'+(p.y+dy).toFixed(2)+'" font-size="'+fs+'" font-family="Unbounded,Inter,sans-serif" font-weight="800" fill="'+fill+'">'+html+'</text>';
}
_labelSvg(p, label, opts){
opts = opts || {};
const dx = (opts.dx !== undefined) ? opts.dx : 0;
const dy = (opts.dy !== undefined) ? opts.dy : 0;
const fs = opts.fontSize || 13;
const fill = opts.color || '#475569';
const html = formatLabel(label);
const anchor = opts.anchor || 'middle';
return '<text x="'+(p.x+dx).toFixed(2)+'" y="'+(p.y+dy).toFixed(2)+'" font-size="'+fs+'" font-family="Inter,sans-serif" font-weight="600" fill="'+fill+'" text-anchor="'+anchor+'">'+html+'</text>';
}
_arrowSvg(a, b, opts){
opts = opts || {};
const stroke = opts.color || '#1e293b';
const w = opts.width || 2;
let s = '<line x1="'+a.x.toFixed(2)+'" y1="'+a.y.toFixed(2)+'" x2="'+b.x.toFixed(2)+'" y2="'+b.y.toFixed(2)+'" stroke="'+stroke+'" stroke-width="'+w+'" stroke-linecap="round" marker-end="url(#'+this._id+'-arr)"';
if (opts.dash) s += ' stroke-dasharray="'+opts.dash+'"';
s += '/>';
if (opts.label){
const mx = (a.x+b.x)/2, my = (a.y+b.y)/2;
s += '<text x="'+(mx+(opts.lx||0)).toFixed(2)+'" y="'+(my+(opts.ly||-6)).toFixed(2)+'" font-size="13" font-family="Unbounded,Inter,sans-serif" font-weight="800" fill="'+stroke+'">'+formatLabel(opts.label)+'</text>';
}
return s;
}
_planeSvg(point, normal, opts){
opts = opts || {};
const sz = opts.size || 3;
const fill = opts.fill || MAT.plane.fill;
const op = (opts.opacity !== undefined) ? opts.opacity : MAT.plane.opacity;
// строим два ортогональных вектора в плоскости нормали
const n = norm3(normal);
let u;
if (Math.abs(n[0]) < 0.9) u = norm3(cross(n, [1,0,0]));
else u = norm3(cross(n, [0,1,0]));
const w = norm3(cross(n, u));
const corners = [
add(point, add(scale3(u, +sz), scale3(w, +sz))),
add(point, add(scale3(u, +sz), scale3(w, -sz))),
add(point, add(scale3(u, -sz), scale3(w, -sz))),
add(point, add(scale3(u, -sz), scale3(w, +sz)))
].map(p => this.project3(p));
const d = corners.map(p => p[0].toFixed(2)+','+p[1].toFixed(2)).join(' ');
let s = '<polygon points="'+d+'" fill="'+fill+'" fill-opacity="'+op+'" stroke="'+(opts.stroke||MAT.plane.stroke)+'" stroke-width="'+(opts.strokeWidth||MAT.plane.strokeWidth)+'"';
if (opts.dash !== false) s += ' stroke-dasharray="'+(opts.dash||MAT.plane.dash)+'"';
s += '/>';
if (opts.label){
const c = this.project3(point);
s += '<text x="'+c[0].toFixed(2)+'" y="'+c[1].toFixed(2)+'" font-size="14" font-family="Unbounded,Inter,sans-serif" font-weight="800" fill="#1e3a8a">'+formatLabel(opts.label)+'</text>';
}
return s;
}
_angleMarkSvg(vertex, p1, p2, opts){
opts = opts || {};
const r3 = opts.r || 0.35;
const u = norm3(sub(p1, vertex));
const v = norm3(sub(p2, vertex));
// строим дугу в 3D через интерполяцию по углу
const steps = 12;
const dotUV = Math.max(-1, Math.min(1, dot(u, v)));
const ang = Math.acos(dotUV);
const w = norm3(sub(v, scale3(u, dotUV)));
const pts = [];
for (let i = 0; i <= steps; i++){
const t = ang * i / steps;
const dir = add(scale3(u, Math.cos(t)), scale3(w, Math.sin(t)));
pts.push(this.project3(add(vertex, scale3(dir, r3))));
}
const d = 'M ' + pts.map(p => p[0].toFixed(2)+','+p[1].toFixed(2)).join(' L ');
let s = '<path d="'+d+'" fill="none" stroke="'+(opts.color||'#d97706')+'" stroke-width="'+(opts.width||1.6)+'"/>';
if (opts.label){
const mid = pts[Math.floor(pts.length/2)];
s += '<text x="'+mid[0].toFixed(2)+'" y="'+mid[1].toFixed(2)+'" font-size="12" font-family="Unbounded,Inter,sans-serif" font-weight="700" fill="'+(opts.color||'#d97706')+'">'+formatLabel(opts.label)+'</text>';
}
return s;
}
_rightAngleMarkSvg(vertex, p1, p2, opts){
opts = opts || {};
const sz = opts.size || 0.2;
const u = norm3(sub(p1, vertex));
const v = norm3(sub(p2, vertex));
const a = add(vertex, scale3(u, sz));
const b = add(vertex, scale3(add(u, v), sz));
const c = add(vertex, scale3(v, sz));
const A = this.project3(a), B = this.project3(b), C = this.project3(c);
return '<polyline points="'+A[0].toFixed(2)+','+A[1].toFixed(2)+' '+B[0].toFixed(2)+','+B[1].toFixed(2)+' '+C[0].toFixed(2)+','+C[1].toFixed(2)+'" fill="none" stroke="'+(opts.color||'#7c3aed')+'" stroke-width="'+(opts.width||1.6)+'"/>';
}
}
/* === Форматирование подписей: A_1, B_1, alpha -> греческие, и т.д. === */
function formatLabel(s){
if (s == null) return '';
let out = String(s);
// греческие
out = out.replace(/\balpha\b/g, 'α')
.replace(/\bbeta\b/g, 'β')
.replace(/\bgamma\b/g, 'γ')
.replace(/\bphi\b/g, 'φ')
.replace(/\btheta\b/g, 'θ')
.replace(/\bpi\b/g, 'π');
// одиночные индексы _1, _2 — превращаем в <tspan>
out = out.replace(/_(\d)/g, '<tspan baseline-shift="sub" font-size="0.7em">$1</tspan>');
// _{abc} — тоже
out = out.replace(/_\{([^}]+)\}/g, '<tspan baseline-shift="sub" font-size="0.7em">$1</tspan>');
return out;
}
S.Scene = Scene;
S.formatLabel = formatLabel;
})();