Files
Learn_System/frontend/js/stereo3d.js
Maxim Dolgolyov b3ea35049f feat(stereo3d): drag-to-rotate для 3D-сцен Геометрии 10
STEREO3D.attachDragRotate(target, scene, onChange?) — мутирует scene.rotX/rotY на mouse/touch drag, по умолчанию пересобирает innerHTML контейнера через scene.render(). Применено к аннотированному кубу §1 (viz1-cube) в geometry_10_r1.html. Остальные сцены не затронуты.
2026-05-29 21:45:33 +03:00

788 lines
32 KiB
JavaScript
Raw Permalink 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;
/* ============================================================ */
/* Drag-to-rotate */
/* ============================================================ */
/*
* STEREO3D.attachDragRotate(target, scene, onChange?)
*
* target — SVGElement или контейнер-HTMLElement, на который повешен
* результат scene.render().
* scene — экземпляр STEREO3D.Scene (мы мутируем scene.rotX / scene.rotY).
* onChange — необязательный callback после изменения углов.
* Если не передан и target не SVG — по умолчанию
* пересобираем содержимое контейнера через scene.render().
*
* Возвращает функцию detach() — для удаления слушателей.
*/
S.attachDragRotate = function attachDragRotate(target, scene, onChange){
if (!target || !scene) return function(){};
// По умолчанию для контейнера-HTMLElement делаем re-render через innerHTML.
const isSvg = (target.tagName && String(target.tagName).toLowerCase() === 'svg');
const defaultOnChange = isSvg ? null : function(){ target.innerHTML = scene.render(); };
const cb = onChange || defaultOnChange;
let dragging = false, lastX = 0, lastY = 0;
try { target.style.touchAction = 'none'; } catch(_){}
try { target.style.cursor = 'grab'; } catch(_){}
function onDown(e){
dragging = true;
try { target.style.cursor = 'grabbing'; } catch(_){}
const p = (e.touches && e.touches[0]) ? e.touches[0] : e;
lastX = p.clientX; lastY = p.clientY;
if (e.preventDefault) e.preventDefault();
}
function onMove(e){
if (!dragging) return;
const p = (e.touches && e.touches[0]) ? e.touches[0] : e;
const dx = p.clientX - lastX, dy = p.clientY - lastY;
scene.rotY = (scene.rotY || 0) + dx * 0.012;
scene.rotX = (scene.rotX || 0) + dy * 0.012;
if (scene.rotX > 1.4) scene.rotX = 1.4;
if (scene.rotX < -1.4) scene.rotX = -1.4;
lastX = p.clientX; lastY = p.clientY;
if (cb) cb(scene);
if (e.preventDefault) e.preventDefault();
}
function onUp(){
if (!dragging) return;
dragging = false;
try { target.style.cursor = 'grab'; } catch(_){}
}
target.addEventListener('mousedown', onDown, { passive: false });
target.addEventListener('touchstart', onDown, { passive: false });
window.addEventListener('mousemove', onMove, { passive: false });
window.addEventListener('touchmove', onMove, { passive: false });
window.addEventListener('mouseup', onUp);
window.addEventListener('touchend', onUp);
window.addEventListener('touchcancel',onUp);
return function detach(){
target.removeEventListener('mousedown', onDown);
target.removeEventListener('touchstart', onDown);
window.removeEventListener('mousemove', onMove);
window.removeEventListener('touchmove', onMove);
window.removeEventListener('mouseup', onUp);
window.removeEventListener('touchend', onUp);
window.removeEventListener('touchcancel',onUp);
};
};
})();