/* 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);
  };
};

})();
