b3ea35049f
STEREO3D.attachDragRotate(target, scene, onChange?) — мутирует scene.rotX/rotY на mouse/touch drag, по умолчанию пересобирает innerHTML контейнера через scene.render(). Применено к аннотированному кубу §1 (viz1-cube) в geometry_10_r1.html. Остальные сцены не затронуты.
788 lines
32 KiB
JavaScript
788 lines
32 KiB
JavaScript
/* 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);
|
||
};
|
||
};
|
||
|
||
})();
|