'use strict';

/* ═══════════════════════════════════════════════════════════
   StereoSim — 3D Stereometry (Three.js)
   Cube, Parallelepiped, Pyramid, Tetrahedron, Cylinder,
   Cone, Truncated Cone, Sphere, Prism + sections, unfold
   ═══════════════════════════════════════════════════════════ */

class StereoSim {
  constructor(container) {
    this.container = container;
    this._running = false;

    /* Three.js core */
    this.scene    = new THREE.Scene();
    this.camera   = new THREE.PerspectiveCamera(50, 1, 0.1, 500);
    this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    this.renderer.setClearColor(0x0D0D1A, 1);
    container.appendChild(this.renderer.domElement);

    /* lighting */
    this.scene.add(new THREE.AmbientLight(0xffffff, 0.55));
    const dir = new THREE.DirectionalLight(0xffffff, 0.75);
    dir.position.set(6, 10, 8);
    this.scene.add(dir);
    const fill = new THREE.DirectionalLight(0x9B5DE5, 0.2);
    fill.position.set(-5, 3, -4);
    this.scene.add(fill);

    /* orbit camera */
    this._drag = false;
    this._prevX = 0; this._prevY = 0;
    this._rotY = 0.6; this._rotX = 0.45;
    this._dist = 14;
    this._autoSpin = true;
    this._idleTime = 0;

    const el = this.renderer.domElement;
    el.style.cursor = 'grab';
    this._clickStart = null;
    el.addEventListener('pointerdown', e => {
      this._clickStart = { x: e.clientX, y: e.clientY };
      this._drag = true; this._prevX = e.clientX; this._prevY = e.clientY;
      this._autoSpin = false; this._idleTime = 0;
      if (!this._pointMode && !this._connectMode && !this._measureMode && !this._angleMode && !this._section3PMode) el.style.cursor = 'grabbing';
    });
    window.addEventListener('pointerup', e => {
      const wasDrag = this._clickStart &&
        (Math.abs(e.clientX - this._clickStart.x) > 4 || Math.abs(e.clientY - this._clickStart.y) > 4);
      this._drag = false;
      if (this._pointMode) { el.style.cursor = 'cell'; if (!wasDrag) this._onPointClick(e); }
      else if (this._connectMode) { el.style.cursor = 'pointer'; if (!wasDrag) this._onConnectClick(e); }
      else if (this._measureMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onMeasureClick(e); }
      else if (this._angleMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onAngleClick(e); }
      else if (this._markMode) { el.style.cursor = 'pointer'; if (!wasDrag) this._onMarkClick(e); }
      else if (this._deriveMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onDeriveClick(e); }
      else if (this._section3PMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onSection3PClick(e); }
      else el.style.cursor = 'grab';
    });
    window.addEventListener('pointermove', e => {
      this._onHoverMove(e);
      if (!this._drag) return;
      this._rotY += (e.clientX - this._prevX) * 0.007;
      this._rotX += (e.clientY - this._prevY) * 0.007;
      this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX));
      this._prevX = e.clientX; this._prevY = e.clientY;
      this._idleTime = 0;
    });
    el.addEventListener('wheel', e => {
      e.preventDefault();
      this._dist = Math.max(4, Math.min(40, this._dist + e.deltaY * 0.02));
    }, { passive: false });

    /* touch — orbit + pinch zoom */
    this._touchDist = 0;
    el.addEventListener('touchstart', e => {
      if (e.touches.length === 1) {
        this._drag = true; this._prevX = e.touches[0].clientX; this._prevY = e.touches[0].clientY;
        this._autoSpin = false; this._idleTime = 0;
      } else if (e.touches.length === 2) {
        this._drag = false;
        const dx = e.touches[0].clientX - e.touches[1].clientX;
        const dy = e.touches[0].clientY - e.touches[1].clientY;
        this._touchDist = Math.sqrt(dx * dx + dy * dy);
      }
    }, { passive: true });
    el.addEventListener('touchmove', e => {
      if (e.touches.length === 2) {
        // pinch zoom
        const dx = e.touches[0].clientX - e.touches[1].clientX;
        const dy = e.touches[0].clientY - e.touches[1].clientY;
        const newDist = Math.sqrt(dx * dx + dy * dy);
        if (this._touchDist > 0) {
          const scale = this._touchDist / newDist;
          this._dist = Math.max(4, Math.min(40, this._dist * scale));
        }
        this._touchDist = newDist;
        return;
      }
      if (!this._drag || e.touches.length !== 1) return;
      const t = e.touches[0];
      this._rotY += (t.clientX - this._prevX) * 0.007;
      this._rotX += (t.clientY - this._prevY) * 0.007;
      this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX));
      this._prevX = t.clientX; this._prevY = t.clientY;
    }, { passive: true });
    el.addEventListener('touchend', () => { this._drag = false; this._touchDist = 0; });

    /* resize */
    this._ro = new ResizeObserver(() => this.fit());
    this._ro.observe(container);

    /* groups */
    this._figGroup     = new THREE.Group();
    this._labelGroup   = new THREE.Group();
    this._sectionGroup = new THREE.Group();
    this._sphereGroup  = new THREE.Group();
    this._measureGroup     = new THREE.Group();
    this._measurePickGroup = new THREE.Group();
    this._gridGroup    = new THREE.Group();
    this._markGroup    = new THREE.Group();
    this._derivedGroup = new THREE.Group();
    this._section3PGroup = new THREE.Group();
    this.scene.add(this._gridGroup);
    this.scene.add(this._figGroup);
    this.scene.add(this._sectionGroup);
    this.scene.add(this._sphereGroup);
    this.scene.add(this._measureGroup);
    this.scene.add(this._measurePickGroup);
    this.scene.add(this._markGroup);
    this.scene.add(this._derivedGroup);
    this.scene.add(this._section3PGroup);
    this.scene.add(this._labelGroup);

    /* state */
    this.figureType = 'cube';
    this.params     = { a: 4, b: 3, c: 5, h: 5, r: 2, R: 3, n: 4 };
    this.showEdges    = true;
    this.showVertices = true;
    this.showLabels   = true;
    this.showAxes     = true;
    this.showGrid     = true;
    this.opacity      = 0.3;

    this.showSection   = false;
    this.sectionHeight = 0.5; // 0..1
    this.sectionType   = 'horizontal'; // horizontal | diagonal | custom
    this.sectionAngle  = 0.5; // 0..1 for diagonal tilt

    this._unfold         = false;
    this._unfoldProgress = 0;
    this._unfoldTarget   = 0;

    this.showInscribed   = false;
    this.showCircumscribed = false;
    this.showHeight      = false;
    this.showApothem      = false;
    this.showDiagonals   = false;
    this.showMidpoints   = false;

    this._measureMode = false;
    this._measurePicks = [];
    this._measurements = [];

    /* angle modes */
    this._angleMode = null;    // 'edge' | 'linePlane' | 'dihedral' | 'pointPlane'
    this._anglePicks = [];     // picked vertices/points
    this._angleGroup = new THREE.Group();
    this.scene.add(this._angleGroup);

    /* hover coordinate tooltip */
    this._tooltipEl = null;
    this._initTooltip();

    /* custom points & connections */
    this._pointMode    = false;   // place points on edges
    this._connectMode  = false;   // connect two points with a line
    this._customPoints = [];      // [{pos: Vector3, edgeIdx: number, t: number, label: string}]
    this._connections  = [];      // [{from: idx, to: idx}]
    this._connectPicks = [];      // temp picks for connecting
    this._pointGroup   = new THREE.Group();
    this.scene.add(this._pointGroup);
    this._nextPointId  = 1;

    this._vertices = []; // [{pos: Vector3, label: string}]
    this._edges    = []; // [{from: Vector3, to: Vector3}]
    this._faces    = []; // [[Vector3, ...]]

    /* edge marks (tick / parallel) — аналог _drawTickMark() из планиметрии */
    this._edgeMarks = {};  // { edgeIdx: { ticks: 0-3, parallel: 0-3 } }
    this._markMode  = null; // 'ticks' | 'parallel' | null

    /* derived 3D constructions — аналог midpoint/altitude_foot/centroid из планиметрии */
    this._derived3D  = [];   // [{type, ...args}]
    this._deriveMode = null; // 'midpoint'|'face_centroid'|'alt_foot'|'solid_centroid'|null
    this._derivePicks = [];

    /* edge length labels */
    this.showEdgeLengths = false;

    /* section by 3 arbitrary points */
    this._section3PMode   = false;  // interactive picking active
    this._section3PPicks  = [];     // Vector3[] — up to 3 picked points
    this._section3PStepBy = false;  // step-by-step visualisation toggle
    this._section3PStep   = 0;      // current step (0=idle, 1..6=sub-steps)
    this._section3PData   = null;   // computed result {normal,D,polygon,area,typeName}

    this.onUpdate = null;

    this._buildGrid();
    this._buildFigure();
    this.fit();
    this.play();
  }

  /* ════════════════ PUBLIC API ════════════════ */

  setFigure(type) {
    this.figureType = type;
    this._unfold = false; this._unfoldProgress = 0; this._unfoldTarget = 0;
    this.showSection = false;
    this.showInscribed = false; this.showCircumscribed = false;
    this.showHeight = false; this.showApothem = false;
    this.showDiagonals = false; this.showMidpoints = false;
    this._measurements = [];
    this._measurePicks = [];
    this._customPoints = [];
    this._connections = [];
    this._connectPicks = [];
    this._anglePicks = [];
    this._angleMode = null;
    this._nextPointId = 1;
    this._clearGroup(this._pointGroup);
    this._clearGroup(this._angleGroup);
    this._clearGroup(this._measureGroup);
    this._edgeMarks = {};
    this._markMode = null;
    this._clearGroup(this._markGroup);
    this._derived3D = [];
    this._deriveMode = null;
    this._derivePicks = [];
    this._clearGroup(this._derivedGroup);
    this._section3PPicks = [];
    this._section3PData  = null;
    this._section3PMode  = false;
    this._section3PStep  = 0;
    this._clearGroup(this._section3PGroup);
    this._buildFigure();
    this._notify();
  }

  setParam(key, val) {
    this.params[key] = val;
    this._buildFigure();
    this._notify();
  }

  setOpacity(v) {
    this.opacity = v;
    this._buildFigure();
  }

  toggleEdges(v)    { this.showEdges = v;    this._buildFigure(); }
  toggleVertices(v) { this.showVertices = v; this._buildFigure(); }
  toggleLabels(v)   { this.showLabels = v;   this._buildFigure(); }
  toggleAxes(v)     { this.showAxes = v; this._buildGrid(); }
  toggleGrid(v)     { this.showGrid = v; this._buildGrid(); }

  toggleSection(on) {
    this.showSection = on;
    this._updateSection();
  }
  setSectionHeight(v) {
    this.sectionHeight = v;
    this._updateSection();
    this._notify();
  }
  setSectionType(t) {
    this.sectionType = t;
    this._updateSection();
    this._notify();
  }

  toggleUnfold(on) {
    this._unfold = on;
    this._unfoldTarget = on ? 1 : 0;
  }

  toggleInscribed(on) {
    this.showInscribed = on;
    this._updateSpheres();
    this._notify();
  }
  toggleCircumscribed(on) {
    this.showCircumscribed = on;
    this._updateSpheres();
    this._notify();
  }

  toggleHeight(on) {
    this.showHeight = on;
    this._buildFigure();
    this._notify();
  }

  toggleApothem(on) {
    this.showApothem = on;
    this._buildFigure();
    this._notify();
  }

  toggleDiagonals(on) {
    this.showDiagonals = on;
    this._buildFigure();
    this._notify();
  }

  toggleMidpoints(on) {
    this.showMidpoints = on;
    this._buildFigure();
    this._notify();
  }

  toggleMeasure(on) {
    this._measureMode = on;
    this._pointMode = false;
    this._angleMode = null;
    this._measurePicks = [];
    this._clearGroup(this._measurePickGroup);
    if (!on) { this._measurements = []; this._rebuildMeasureGroup(); }
    this.renderer.domElement.style.cursor = on ? 'crosshair' : 'grab';
  }

  removeLastMeasurement() {
    if (!this._measurements.length) return;
    this._measurements.pop();
    this._rebuildMeasureGroup();
  }

  clearMeasurements() {
    this._measurements = [];
    this._measurePicks = [];
    this._rebuildMeasureGroup();
    this._clearGroup(this._measurePickGroup);
  }

  _rebuildMeasureGroup() {
    this._clearGroup(this._measureGroup);
    for (const m of this._measurements) {
      const g = new THREE.Group();
      // spheres at endpoints
      for (const pos of [m.posA, m.posB]) {
        const sGeo = new THREE.SphereGeometry(0.14, 12, 12);
        const sMat = new THREE.MeshBasicMaterial({ color: 0xFFD166 });
        const s    = new THREE.Mesh(sGeo, sMat);
        s.position.copy(pos);
        g.add(s);
      }
      // dashed line
      const lineGeo = new THREE.BufferGeometry().setFromPoints([m.posA, m.posB]);
      const lineMat = new THREE.LineDashedMaterial({ color: 0xFFD166, dashSize: 0.15, gapSize: 0.1, transparent: true, opacity: 0.9 });
      const line    = new THREE.Line(lineGeo, lineMat);
      line.computeLineDistances();
      g.add(line);
      // label
      const mid   = new THREE.Vector3().addVectors(m.posA, m.posB).multiplyScalar(0.5);
      const label = this._makeTextSprite(`${m.from}${m.to} = ${m.dist}`, '#FFD166', 40);
      label.position.copy(mid).add(new THREE.Vector3(0, 0.3, 0));
      label.scale.set(1.4, 0.5, 1);
      g.add(label);
      this._measureGroup.add(g);
    }
  }

  setAngleMode(mode) {
    // mode: 'edge' | 'linePlane' | 'dihedral' | null
    this._angleMode = mode;
    this._anglePicks = [];
    this._measureMode = false;
    this._pointMode = false;
    this._connectMode = false;
    if (!mode) { this._clearGroup(this._angleGroup); }
    this.renderer.domElement.style.cursor = mode ? 'crosshair' : 'grab';
  }

  setSectionAngle(v) {
    this.sectionAngle = v;
    if (this.showSection && this.sectionType === 'diagonal') this._updateSection();
    this._notify();
  }

  /* ── Point mode: place points on edges ── */
  togglePointMode(on) {
    this._pointMode = on;
    this._measureMode = false;
    this._connectPicks = [];
    this.renderer.domElement.style.cursor = on ? 'cell' : 'grab';
  }

  toggleConnectMode(on) {
    this._connectMode = on;
    this._pointMode = false;
    this._measureMode = false;
    this._connectPicks = [];
    this.renderer.domElement.style.cursor = on ? 'pointer' : 'grab';
  }

  clearCustomPoints() {
    this._customPoints = [];
    this._connections = [];
    this._connectPicks = [];
    this._nextPointId = 1;
    this._clearGroup(this._pointGroup);
    if (this.showSection && this.sectionType === 'custom') this._updateSection();
    this._notify();
  }

  removeLastPoint() {
    if (!this._customPoints.length) return;
    // Remove connections referencing last point
    const lastIdx = this._customPoints.length - 1;
    this._connections = this._connections.filter(c => c.from !== lastIdx && c.to !== lastIdx);
    this._customPoints.pop();
    this._nextPointId = Math.max(1, this._nextPointId - 1);
    this._rebuildPointVisuals();
    if (this.showSection && this.sectionType === 'custom') this._updateSection();
    this._notify();
  }

  getCustomPoints() { return this._customPoints; }
  getConnections()  { return this._connections; }

  /* ── Edge mark mode ── */
  setMarkMode(mode) {
    // mode: 'ticks' | 'parallel' | null
    this._markMode   = mode;
    this._deriveMode = null;
    this._derivePicks = [];
    this._measureMode = false;
    this._pointMode   = false;
    this._connectMode = false;
    this._angleMode   = null;
    this.renderer.domElement.style.cursor = mode ? 'pointer' : 'grab';
  }

  clearMarks() {
    this._edgeMarks = {};
    this._renderEdgeMarks();
  }

  /* ── Derived 3D constructions mode ── */
  setDeriveMode(mode) {
    // mode: 'midpoint' | 'face_centroid' | 'alt_foot' | 'solid_centroid' | null
    this._deriveMode  = mode;
    this._derivePicks = [];
    this._markMode    = null;
    this._measureMode = false;
    this._pointMode   = false;
    this._connectMode = false;
    this._angleMode   = null;
    this.renderer.domElement.style.cursor = mode ? 'crosshair' : 'grab';
    if (mode === 'solid_centroid') this._addSolidCentroid();
  }

  clearDerived() {
    this._derived3D   = [];
    this._derivePicks = [];
    this._clearGroup(this._derivedGroup);
  }

  removeLastDerived() {
    if (!this._derived3D.length) return;
    this._derived3D.pop();
    this._buildDerived3D();
  }

  /* ── Edge length labels ── */
  toggleEdgeLengths(on) {
    this.showEdgeLengths = on;
    this._buildFigure();
  }

  /* ── Section by 3 arbitrary points ── */
  toggleSection3P(on) {
    this._section3PMode = on;
    // turn off all other interactive modes
    this._pointMode   = false;
    this._connectMode = false;
    this._measureMode = false;
    this._angleMode   = null;
    this._markMode    = null;
    this._deriveMode  = null;
    this._connectPicks = [];
    this.renderer.domElement.style.cursor = on ? 'crosshair' : 'grab';
  }

  clearSection3P() {
    this._section3PPicks = [];
    this._section3PData  = null;
    this._section3PStep  = 0;
    this._clearGroup(this._section3PGroup);
    this._notify();
  }

  toggleSection3PStepBy(on) {
    this._section3PStepBy = on;
    // re-render if data already exists
    if (this._section3PData) this._drawSection3P();
  }

  getSection3PInfo() {
    return this._section3PData;
  }

  getFormulas() {
    const p = this.params;
    const PI = Math.PI;
    const r = (v) => Math.round(v * 100) / 100;
    switch (this.figureType) {
      case 'cube': {
        const a = p.a;
        const rIn = a / 2, rOut = r(a * Math.sqrt(3) / 2);
        return { V: r(a**3), S: r(6*a**2), S_side: r(4*a**2), d: r(a*Math.sqrt(3)), h: a,
          formulas: [`V = a³ = ${r(a**3)}`, `S = 6a² = ${r(6*a**2)}`, `d = a√3 = ${r(a*Math.sqrt(3))}`,
            `r_вп = a/2 = ${r(rIn)}`, `R_оп = a√3/2 = ${rOut}`] };
      }
      case 'parallelepiped': {
        const {a,b,c} = p;
        return { V: r(a*b*c), S: r(2*(a*b+b*c+a*c)), S_side: r(2*c*(a+b)), d: r(Math.sqrt(a**2+b**2+c**2)), h: c,
          formulas: [`V = abc = ${r(a*b*c)}`, `S = 2(ab+bc+ac) = ${r(2*(a*b+b*c+a*c))}`, `d = √(a²+b²+c²) = ${r(Math.sqrt(a**2+b**2+c**2))}`,
            `r_вп = ${r(Math.min(a,b,c)/2)}`, `R_оп = ${r(Math.sqrt(a**2+b**2+c**2)/2)}`] };
      }
      case 'pyramid': {
        const {a, h, n} = p;
        const sBase = n * a**2 / (4 * Math.tan(PI/n));
        const apothem = a / (2 * Math.tan(PI/n));
        const slantH = Math.sqrt(h**2 + apothem**2);
        const sLat = n * a * slantH / 2;
        const Vp = sBase * h / 3;
        const rIn = r(3 * Vp / (sBase + sLat));
        const Rb = a / (2 * Math.sin(PI / n));
        const rOut = r((Rb**2 + h**2) / (2 * h));
        return { V: r(Vp), S: r(sBase+sLat), S_side: r(sLat), d: null, h,
          formulas: [`V = ⅓·S_осн·h = ${r(Vp)}`, `S_осн = ${r(sBase)}`, `S_бок = ${r(sLat)}`, `S_полн = ${r(sBase+sLat)}`,
            `a_осн = ${r(apothem)}`, `a_бок = ${r(slantH)}`, `r_вп = ${rIn}`, `R_оп = ${rOut}`] };
      }
      case 'tetrahedron': {
        const a = p.a;
        const rIn = r(a / (2 * Math.sqrt(6))), rOut = r(a * Math.sqrt(6) / 4);
        return { V: r(a**3*Math.sqrt(2)/12), S: r(a**2*Math.sqrt(3)), S_side: r(a**2*Math.sqrt(3)*3/4), d: null, h: r(a*Math.sqrt(2/3)),
          formulas: [`V = a³√2/12 = ${r(a**3*Math.sqrt(2)/12)}`, `S = a²√3 = ${r(a**2*Math.sqrt(3))}`, `h = a√(2/3) = ${r(a*Math.sqrt(2/3))}`,
            `r_вп = ${rIn}`, `R_оп = ${rOut}`] };
      }
      case 'cylinder': {
        const {r: rad, h} = p;
        const rIn = r(Math.min(rad, h/2)), rOut = r(Math.sqrt(rad**2 + (h/2)**2));
        return { V: r(PI*rad**2*h), S: r(2*PI*rad*(rad+h)), S_side: r(2*PI*rad*h), d: r(2*rad), h,
          formulas: [`V = πr²h = ${r(PI*rad**2*h)}`, `S_бок = 2πrh = ${r(2*PI*rad*h)}`, `S_полн = 2πr(r+h) = ${r(2*PI*rad*(rad+h))}`,
            `r_вп = ${rIn}`, `R_оп = ${rOut}`] };
      }
      case 'cone': {
        const {r: rad, h} = p;
        const l = Math.sqrt(rad**2+h**2);
        const rIn = r(rad * h / (rad + l)), rOut = r((rad**2 + h**2) / (2 * h));
        return { V: r(PI*rad**2*h/3), S: r(PI*rad*(rad+l)), S_side: r(PI*rad*l), d: r(2*rad), h,
          formulas: [`V = ⅓πr²h = ${r(PI*rad**2*h/3)}`, `l = √(r²+h²) = ${r(l)}`, `S_бок = πrl = ${r(PI*rad*l)}`,
            `r_вп = ${rIn}`, `R_оп = ${rOut}`] };
      }
      case 'trunccone': {
        const {R: R1, r: r1, h} = p;
        const l = Math.sqrt((R1-r1)**2+h**2);
        const V = PI*h*(R1**2+R1*r1+r1**2)/3;
        return { V: r(V), S: r(PI*(R1**2+r1**2+(R1+r1)*l)), S_side: r(PI*(R1+r1)*l), d: null, h,
          formulas: [`V = ⅓πh(R²+Rr+r²) = ${r(V)}`, `l = ${r(l)}`, `S_бок = π(R+r)l = ${r(PI*(R1+r1)*l)}`] };
      }
      case 'sphere': {
        const rad = p.r;
        return { V: r(4*PI*rad**3/3), S: r(4*PI*rad**2), S_side: null, d: r(2*rad), h: r(2*rad),
          formulas: [`V = ⁴⁄₃πr³ = ${r(4*PI*rad**3/3)}`, `S = 4πr² = ${r(4*PI*rad**2)}`, `d = 2r = ${r(2*rad)}`] };
      }
      case 'prism': {
        const {a, h, n} = p;
        const sBase = n * a**2 / (4 * Math.tan(PI/n));
        const apothem = a / (2 * Math.tan(PI/n));
        const Rb = a / (2 * Math.sin(PI/n));
        const rIn = r(Math.min(apothem, h/2)), rOut = r(Math.sqrt(Rb**2 + (h/2)**2));
        return { V: r(sBase*h), S: r(2*sBase + n*a*h), S_side: r(n*a*h), d: null, h,
          formulas: [`V = S_осн·h = ${r(sBase*h)}`, `S_осн = ${r(sBase)}`, `S_бок = nah = ${r(n*a*h)}`,
            `r_вп = ${rIn}`, `R_оп = ${rOut}`] };
      }
      case 'truncpyramid': {
        const { a, b, h, n } = p;
        const sLow  = n * a**2 / (4 * Math.tan(PI/n));
        const sUp   = n * b**2 / (4 * Math.tan(PI/n));
        const apLow = a / (2 * Math.tan(PI/n));
        const apUp  = b / (2 * Math.tan(PI/n));
        const slant = Math.sqrt(h**2 + (apLow - apUp)**2);
        const sLat  = n * (a + b) * slant / 2;
        const V     = h * (sLow + sUp + Math.sqrt(sLow * sUp)) / 3;
        return { V: r(V), S: r(sLow + sUp + sLat), S_side: r(sLat), d: null, h,
          formulas: [
            `V = h(S₁+S₂+√(S₁·S₂))/3 = ${r(V)}`,
            `S₁ = ${r(sLow)}, S₂ = ${r(sUp)}`,
            `l (апоф.) = ${r(slant)}`,
            `S_бок = n(a+b)l/2 = ${r(sLat)}`,
            `S_полн = ${r(sLow + sUp + sLat)}`,
          ]};
      }
      case 'octahedron': {
        const a = p.a;
        const V_val = r(a**3 * Math.SQRT2 / 3);
        const S_val = r(2 * a**2 * Math.sqrt(3));
        return { V: V_val, S: S_val, S_side: null, d: null, h: r(a * Math.SQRT2),
          formulas: [
            `V = a³√2/3 = ${V_val}`,
            `S = 2a²√3 = ${S_val}`,
            `h = a√2 = ${r(a * Math.SQRT2)}`,
            `r_вп = a√6/6 = ${r(a * Math.sqrt(6) / 6)}`,
            `R_оп = a√2/2 = ${r(a * Math.SQRT2 / 2)}`,
          ]};
      }
      case 'icosahedron': {
        const a = p.a;
        const phi = (1 + Math.sqrt(5)) / 2;
        const V_val = r(5 * a**3 * (3 + Math.sqrt(5)) / 12);
        const S_val = r(5 * a**2 * Math.sqrt(3));
        const rIn  = r(a * phi**2 / (2 * Math.sqrt(3)));
        const rOut = r(a * Math.sqrt(10 + 2*Math.sqrt(5)) / 4);
        return { V: V_val, S: S_val, S_side: null, d: null, h: r(a * Math.sqrt(10 + 2*Math.sqrt(5)) / 2),
          formulas: [
            `V = 5a³(3+√5)/12 = ${V_val}`,
            `S = 5a²√3 = ${S_val}`,
            `r_вп ≈ ${rIn}`,
            `R_оп = a√(10+2√5)/4 ≈ ${rOut}`,
          ]};
      }
      case 'dodecahedron': {
        const a = p.a;
        const phi = (1 + Math.sqrt(5)) / 2;
        const V_val = r(a**3 * (15 + 7*Math.sqrt(5)) / 4);
        const S_val = r(3 * a**2 * Math.sqrt(25 + 10*Math.sqrt(5)));
        const rIn  = r(a / 2 * Math.sqrt((25 + 11*Math.sqrt(5)) / 10));
        const rOut = r(a * Math.sqrt(3) * phi / 2);
        return { V: V_val, S: S_val, S_side: null, d: null, h: r(a * Math.sqrt(3) * phi),
          formulas: [
            `V = a³(15+7√5)/4 = ${V_val}`,
            `S = 3a²√(25+10√5) = ${S_val}`,
            `r_вп ≈ ${rIn}`,
            `R_оп = a√3·φ/2 ≈ ${rOut}`,
          ]};
      }
      default: return { V: 0, S: 0, S_side: 0, d: 0, h: 0, formulas: [] };
    }
  }

  getSectionArea() {
    if (!this.showSection || !this._sectionPolygon || !this._sectionPolygon.length) return 0;
    return this._polygonArea(this._sectionPolygon);
  }

  info() {
    const f = this.getFormulas();
    return {
      type: this.figureType,
      V: f.V, S: f.S, S_side: f.S_side, d: f.d, h: f.h,
      sectionArea: this.showSection ? this.getSectionArea() : null,
      inscribedR: this.showInscribed ? this._inscribedRadius() : null,
      circumscribedR: this.showCircumscribed ? this._circumscribedRadius() : null,
      customPoints: this._customPoints.length,
      connections: this._connections.length,
    };
  }

  fit() {
    const w = this.container.clientWidth || 600;
    const h = this.container.clientHeight || 400;
    this.camera.aspect = w / h;
    this.camera.updateProjectionMatrix();
    this.renderer.setSize(w, h);
  }

  play()  { if (!this._running) { this._running = true; this._loop(); } }
  stop()  { this._running = false; }
  pause() { this._running = false; }

  /* ════════════════ GRID + AXES ════════════════ */

  _buildGrid() {
    this._clearGroup(this._gridGroup);
    if (this.showGrid) {
      const grid = new THREE.GridHelper(20, 20, 0x222244, 0x151530);
      grid.position.y = -0.01;
      this._gridGroup.add(grid);
    }
    if (this.showAxes) {
      const axes = new THREE.AxesHelper(6);
      axes.material.transparent = true;
      axes.material.opacity = 0.4;
      this._gridGroup.add(axes);
    }
  }

  /* ════════════════ FIGURE BUILDER ════════════════ */

  _buildFigure() {
    this._clearGroup(this._figGroup);
    this._clearGroup(this._labelGroup);
    this._vertices = [];
    this._edges = [];
    this._faces = [];

    const builders = {
      cube:           () => this._buildCube(),
      parallelepiped: () => this._buildParallelepiped(),
      pyramid:        () => this._buildPyramid(),
      tetrahedron:    () => this._buildTetrahedron(),
      cylinder:       () => this._buildCylinder(),
      cone:           () => this._buildCone(),
      trunccone:      () => this._buildTruncCone(),
      sphere:         () => this._buildSphere(),
      prism:          () => this._buildPrism(),
      truncpyramid:   () => this._buildTruncPyramid(),
      octahedron:     () => this._buildOctahedron(),
      icosahedron:    () => this._buildIcosahedron(),
      dodecahedron:   () => this._buildDodecahedron(),
    };
    (builders[this.figureType] || builders.cube)();

    this._updateSection();
    this._updateSpheres();
    this._drawHeightLine();
    this._drawApothemLine();
    this._drawDiagonals();
    this._drawMidpoints();
    this._renderEdgeMarks();
    this._buildDerived3D();
  }

  /* ── BOX helpers ── */
  _buildBox(sx, sy, sz) {
    const hx = sx/2, hy = sy/2, hz = sz/2;
    // 8 vertices
    const v = [
      new THREE.Vector3(-hx, 0,  hz), // A (0)
      new THREE.Vector3( hx, 0,  hz), // B (1)
      new THREE.Vector3( hx, 0, -hz), // C (2)
      new THREE.Vector3(-hx, 0, -hz), // D (3)
      new THREE.Vector3(-hx, sy, hz), // E (4)
      new THREE.Vector3( hx, sy, hz), // F (5)
      new THREE.Vector3( hx, sy,-hz), // G (6)
      new THREE.Vector3(-hx, sy,-hz), // H (7)
    ];
    const labels = ['A','B','C','D','E','F','G','H'];
    this._vertices = v.map((pos, i) => ({ pos, label: labels[i] }));

    const edgeIdx = [[0,1],[1,2],[2,3],[3,0],[4,5],[5,6],[6,7],[7,4],[0,4],[1,5],[2,6],[3,7]];
    this._edges = edgeIdx.map(([a,b]) => ({ from: v[a], to: v[b] }));

    this._faces = [
      [v[0],v[1],v[2],v[3]], // bottom
      [v[4],v[5],v[6],v[7]], // top
      [v[0],v[1],v[5],v[4]], // front
      [v[2],v[3],v[7],v[6]], // back
      [v[1],v[2],v[6],v[5]], // right
      [v[0],v[3],v[7],v[4]], // left
    ];

    // transparent mesh
    const geo = new THREE.BoxGeometry(sx, sy, sz);
    geo.translate(0, sy/2, 0);
    const mat = new THREE.MeshPhysicalMaterial({
      color: 0x9B5DE5, transparent: true, opacity: this.opacity,
      side: THREE.DoubleSide, metalness: 0.05, roughness: 0.4,
      clearcoat: 0.3, depthWrite: false,
    });
    this._figGroup.add(new THREE.Mesh(geo, mat));

    this._addEdges();
    this._addVerticesAndLabels();
  }

  _buildCube() { const a = this.params.a; this._buildBox(a, a, a); }
  _buildParallelepiped() { this._buildBox(this.params.a, this.params.c, this.params.b); }

  /* ── PYRAMID ── */
  _buildPyramid() {
    const { a, h, n } = this.params;
    const baseVerts = this._regularPolygon(n, a);
    const apex = new THREE.Vector3(0, h, 0);
    const labels = 'ABCDEFGH'.split('');

    this._vertices = baseVerts.map((pos, i) => ({ pos, label: labels[i] || `P${i}` }));
    this._vertices.push({ pos: apex, label: 'S' });

    // edges: base ring + apex connections
    for (let i = 0; i < n; i++) {
      this._edges.push({ from: baseVerts[i], to: baseVerts[(i+1)%n] });
      this._edges.push({ from: baseVerts[i], to: apex });
    }

    // faces: base + lateral triangles
    this._faces.push([...baseVerts]);
    for (let i = 0; i < n; i++) {
      this._faces.push([baseVerts[i], baseVerts[(i+1)%n], apex]);
    }

    this._addMeshFromFaces(0x06D6A0);
    this._addEdges();
    this._addVerticesAndLabels();
  }

  /* ── TETRAHEDRON ── */
  _buildTetrahedron() {
    const a = this.params.a;
    const h = a * Math.sqrt(2/3);
    const r = a / Math.sqrt(3);
    const v = [
      new THREE.Vector3(0, 0, r),
      new THREE.Vector3(a/2, 0, -r/2),
      new THREE.Vector3(-a/2, 0, -r/2),
      new THREE.Vector3(0, h, 0),
    ];
    const labels = ['A','B','C','D'];
    this._vertices = v.map((pos, i) => ({ pos, label: labels[i] }));

    const edgeIdx = [[0,1],[1,2],[2,0],[0,3],[1,3],[2,3]];
    this._edges = edgeIdx.map(([a,b]) => ({ from: v[a], to: v[b] }));

    this._faces = [
      [v[0],v[1],v[2]],
      [v[0],v[1],v[3]],
      [v[1],v[2],v[3]],
      [v[0],v[2],v[3]],
    ];

    this._addMeshFromFaces(0x06D6E0);
    this._addEdges();
    this._addVerticesAndLabels();
  }

  /* ── CYLINDER ── */
  _buildCylinder() {
    const { r, h } = this.params;
    const geo = new THREE.CylinderGeometry(r, r, h, 48, 1, false);
    geo.translate(0, h/2, 0);
    const mat = new THREE.MeshPhysicalMaterial({
      color: 0xF59E0B, transparent: true, opacity: this.opacity,
      side: THREE.DoubleSide, metalness: 0.05, roughness: 0.4, depthWrite: false,
    });
    this._figGroup.add(new THREE.Mesh(geo, mat));

    // wireframe rings
    const ringGeo = new THREE.RingGeometry(r - 0.02, r + 0.02, 48);
    const ringMat = new THREE.MeshBasicMaterial({ color: 0xFFD166, side: THREE.DoubleSide });
    const bottomRing = new THREE.Mesh(ringGeo, ringMat);
    bottomRing.rotation.x = -Math.PI/2;
    this._figGroup.add(bottomRing);
    const topRing = bottomRing.clone();
    topRing.position.y = h;
    this._figGroup.add(topRing);

    // edges: vertical generators + center axis
    const n = 8;
    for (let i = 0; i < n; i++) {
      const angle = (i / n) * Math.PI * 2;
      const x = r * Math.cos(angle), z = r * Math.sin(angle);
      this._edges.push({ from: new THREE.Vector3(x, 0, z), to: new THREE.Vector3(x, h, z) });
    }
    // center axis
    this._vertices.push({ pos: new THREE.Vector3(0, 0, 0), label: 'O₁' });
    this._vertices.push({ pos: new THREE.Vector3(0, h, 0), label: 'O₂' });
    this._edges.push({ from: new THREE.Vector3(0, 0, 0), to: new THREE.Vector3(0, h, 0) });
    // base/top circle arc segments (for point-picking)
    const arcN = 32;
    for (let i = 0; i < arcN; i++) {
      const a1 = (i / arcN) * Math.PI * 2, a2 = ((i + 1) / arcN) * Math.PI * 2;
      this._edges.push({ from: new THREE.Vector3(r * Math.cos(a1), 0, r * Math.sin(a1)), to: new THREE.Vector3(r * Math.cos(a2), 0, r * Math.sin(a2)) });
      this._edges.push({ from: new THREE.Vector3(r * Math.cos(a1), h, r * Math.sin(a1)), to: new THREE.Vector3(r * Math.cos(a2), h, r * Math.sin(a2)) });
    }

    this._addEdges(0.4);
    this._addVerticesAndLabels();
  }

  /* ── CONE ── */
  _buildCone() {
    const { r, h } = this.params;
    const geo = new THREE.CylinderGeometry(0, r, h, 48, 1, false);
    geo.translate(0, h/2, 0);
    const mat = new THREE.MeshPhysicalMaterial({
      color: 0xE0335E, transparent: true, opacity: this.opacity,
      side: THREE.DoubleSide, metalness: 0.05, roughness: 0.4, depthWrite: false,
    });
    this._figGroup.add(new THREE.Mesh(geo, mat));

    // bottom ring
    const ringGeo = new THREE.RingGeometry(r - 0.02, r + 0.02, 48);
    const ringMat = new THREE.MeshBasicMaterial({ color: 0xFF6B8A, side: THREE.DoubleSide });
    const ring = new THREE.Mesh(ringGeo, ringMat);
    ring.rotation.x = -Math.PI/2;
    this._figGroup.add(ring);

    const apex = new THREE.Vector3(0, h, 0);
    this._vertices.push({ pos: new THREE.Vector3(0, 0, 0), label: 'O' });
    this._vertices.push({ pos: apex, label: 'S' });
    // slant lines
    const n = 8;
    for (let i = 0; i < n; i++) {
      const angle = (i / n) * Math.PI * 2;
      const x = r * Math.cos(angle), z = r * Math.sin(angle);
      this._edges.push({ from: new THREE.Vector3(x, 0, z), to: apex });
    }
    this._edges.push({ from: new THREE.Vector3(0, 0, 0), to: apex });
    // base circle as arc segments (for point-picking)
    const arcN = 32;
    for (let i = 0; i < arcN; i++) {
      const a1 = (i / arcN) * Math.PI * 2, a2 = ((i + 1) / arcN) * Math.PI * 2;
      this._edges.push({
        from: new THREE.Vector3(r * Math.cos(a1), 0, r * Math.sin(a1)),
        to:   new THREE.Vector3(r * Math.cos(a2), 0, r * Math.sin(a2)),
      });
    }

    this._addEdges(0.4);
    this._addVerticesAndLabels();
  }

  /* ── TRUNCATED CONE ── */
  _buildTruncCone() {
    const { R, r, h } = this.params;
    const geo = new THREE.CylinderGeometry(r, R, h, 48, 1, false);
    geo.translate(0, h/2, 0);
    const mat = new THREE.MeshPhysicalMaterial({
      color: 0x60A5FA, transparent: true, opacity: this.opacity,
      side: THREE.DoubleSide, metalness: 0.05, roughness: 0.4, depthWrite: false,
    });
    this._figGroup.add(new THREE.Mesh(geo, mat));

    // rings
    for (const [rad, y] of [[R, 0], [r, h]]) {
      const rg = new THREE.RingGeometry(rad - 0.02, rad + 0.02, 48);
      const rm = new THREE.MeshBasicMaterial({ color: 0x93C5FD, side: THREE.DoubleSide });
      const mesh = new THREE.Mesh(rg, rm);
      mesh.rotation.x = -Math.PI/2; mesh.position.y = y;
      this._figGroup.add(mesh);
    }

    this._vertices.push({ pos: new THREE.Vector3(0, 0, 0), label: 'O₁' });
    this._vertices.push({ pos: new THREE.Vector3(0, h, 0), label: 'O₂' });
    this._edges.push({ from: new THREE.Vector3(0, 0, 0), to: new THREE.Vector3(0, h, 0) });

    const n = 8;
    for (let i = 0; i < n; i++) {
      const angle = (i / n) * Math.PI * 2;
      this._edges.push({
        from: new THREE.Vector3(R * Math.cos(angle), 0, R * Math.sin(angle)),
        to:   new THREE.Vector3(r * Math.cos(angle), h, r * Math.sin(angle)),
      });
    }
    // circle arc segments for point-picking on both bases
    const arcN = 32;
    for (let i = 0; i < arcN; i++) {
      const a1 = (i / arcN) * Math.PI * 2, a2 = ((i + 1) / arcN) * Math.PI * 2;
      this._edges.push({ from: new THREE.Vector3(R * Math.cos(a1), 0, R * Math.sin(a1)), to: new THREE.Vector3(R * Math.cos(a2), 0, R * Math.sin(a2)) });
      this._edges.push({ from: new THREE.Vector3(r * Math.cos(a1), h, r * Math.sin(a1)), to: new THREE.Vector3(r * Math.cos(a2), h, r * Math.sin(a2)) });
    }

    this._addEdges(0.4);
    this._addVerticesAndLabels();
  }

  /* ── SPHERE ── */
  _buildSphere() {
    const rad = this.params.r;
    const geo = new THREE.SphereGeometry(rad, 48, 32);
    geo.translate(0, rad, 0);
    const mat = new THREE.MeshPhysicalMaterial({
      color: 0x9B5DE5, transparent: true, opacity: this.opacity,
      side: THREE.DoubleSide, metalness: 0.1, roughness: 0.3,
      clearcoat: 0.5, depthWrite: false,
    });
    this._figGroup.add(new THREE.Mesh(geo, mat));

    // equator + meridian wireframes
    const createCircle = (radius, y, rotX) => {
      const pts = [];
      for (let i = 0; i <= 64; i++) {
        const a = (i/64)*Math.PI*2;
        pts.push(new THREE.Vector3(radius*Math.cos(a), 0, radius*Math.sin(a)));
      }
      const lineGeo = new THREE.BufferGeometry().setFromPoints(pts);
      const lineMat = new THREE.LineBasicMaterial({ color: 0xCCCCFF, transparent: true, opacity: 0.5 });
      const line = new THREE.Line(lineGeo, lineMat);
      line.position.y = y;
      if (rotX) line.rotation.x = rotX;
      return line;
    };
    this._figGroup.add(createCircle(rad, rad, 0));
    this._figGroup.add(createCircle(rad, rad, Math.PI/2));

    this._vertices.push({ pos: new THREE.Vector3(0, rad, 0), label: 'O' });
    this._vertices.push({ pos: new THREE.Vector3(0, 2*rad, 0), label: 'N' });
    this._vertices.push({ pos: new THREE.Vector3(0, 0, 0), label: 'S' });
    this._addVerticesAndLabels();
  }

  /* ── PRISM ── */
  _buildPrism() {
    const { a, h, n } = this.params;
    const baseVerts = this._regularPolygon(n, a);
    const topVerts  = baseVerts.map(v => new THREE.Vector3(v.x, h, v.z));
    const labels = 'ABCDEFGH'.split('');

    this._vertices = baseVerts.map((pos, i) => ({ pos, label: labels[i] || `P${i}` }));
    topVerts.forEach((pos, i) => this._vertices.push({ pos, label: (labels[i] || `P${i}`) + '₁' }));

    // edges
    for (let i = 0; i < n; i++) {
      this._edges.push({ from: baseVerts[i], to: baseVerts[(i+1)%n] }); // base
      this._edges.push({ from: topVerts[i], to: topVerts[(i+1)%n] });   // top
      this._edges.push({ from: baseVerts[i], to: topVerts[i] });         // vertical
    }

    // faces
    this._faces.push([...baseVerts]);
    this._faces.push([...topVerts]);
    for (let i = 0; i < n; i++) {
      const j = (i+1) % n;
      this._faces.push([baseVerts[i], baseVerts[j], topVerts[j], topVerts[i]]);
    }

    this._addMeshFromFaces(0x06D6A0);
    this._addEdges();
    this._addVerticesAndLabels();
  }

  /* ── TRUNCATED PYRAMID ── */
  _buildTruncPyramid() {
    const { a, b, h, n } = this.params;
    const baseVerts = this._regularPolygon(n, a);
    const topVerts  = this._regularPolygon(n, b).map(v => new THREE.Vector3(v.x, h, v.z));
    const labels = 'ABCDEFGH'.split('');

    this._vertices = baseVerts.map((pos, i) => ({ pos, label: labels[i] || `P${i}` }));
    topVerts.forEach((pos, i) => this._vertices.push({ pos, label: (labels[i] || `P${i}`) + '₁' }));

    for (let i = 0; i < n; i++) {
      this._edges.push({ from: baseVerts[i], to: baseVerts[(i+1)%n] });
      this._edges.push({ from: topVerts[i],  to: topVerts[(i+1)%n] });
      this._edges.push({ from: baseVerts[i], to: topVerts[i] });
    }

    this._faces.push([...baseVerts]);
    this._faces.push([...topVerts]);
    for (let i = 0; i < n; i++) {
      const j = (i+1) % n;
      this._faces.push([baseVerts[i], baseVerts[j], topVerts[j], topVerts[i]]);
    }

    this._addMeshFromFaces(0xF59E0B);
    this._addEdges();
    this._addVerticesAndLabels();
  }

  /* ── OCTAHEDRON ── */
  _buildOctahedron() {
    const a = this.params.a;
    const R = a / Math.SQRT2;  // equatorial radius (dist from center to eq. vertex)
    const v = [
      new THREE.Vector3(0,     0,      0),    // 0 S₁ (bottom)
      new THREE.Vector3( R,    R,      0),    // 1 A
      new THREE.Vector3( 0,    R,      R),    // 2 B
      new THREE.Vector3(-R,    R,      0),    // 3 C
      new THREE.Vector3( 0,    R,     -R),    // 4 D
      new THREE.Vector3( 0,    a * Math.SQRT2, 0), // 5 S₂ (top)
    ];
    const labels = ['S₁','A','B','C','D','S₂'];
    this._vertices = v.map((pos, i) => ({ pos, label: labels[i] }));

    const edgeIdx = [[0,1],[0,2],[0,3],[0,4],[5,1],[5,2],[5,3],[5,4],[1,2],[2,3],[3,4],[4,1]];
    this._edges = edgeIdx.map(([i, j]) => ({ from: v[i], to: v[j] }));

    this._faces = [
      [v[0],v[1],v[2]], [v[0],v[2],v[3]], [v[0],v[3],v[4]], [v[0],v[4],v[1]],
      [v[5],v[2],v[1]], [v[5],v[3],v[2]], [v[5],v[4],v[3]], [v[5],v[1],v[4]],
    ];

    this._addMeshFromFaces(0xF15BB5);
    this._addEdges();
    this._addVerticesAndLabels();
  }

  /* ── ICOSAHEDRON ── */
  _buildIcosahedron() {
    const a = this.params.a;
    const circR = a * Math.sqrt(10 + 2 * Math.sqrt(5)) / 4;
    const geo = new THREE.IcosahedronGeometry(circR, 0);

    const posArr = geo.getAttribute('position').array;
    let minY = Infinity;
    for (let i = 1; i < posArr.length; i += 3) minY = Math.min(minY, posArr[i]);
    geo.translate(0, -minY, 0);

    const mat = new THREE.MeshPhysicalMaterial({
      color: 0x06D6E0, transparent: true, opacity: this.opacity,
      side: THREE.DoubleSide, metalness: 0.05, roughness: 0.4, clearcoat: 0.2, depthWrite: false,
    });
    this._figGroup.add(new THREE.Mesh(geo, mat));

    const verts = this._extractUniqueVerts(geo);
    const labels = 'ABCDEFGHIJKL'.split('');
    this._vertices = verts.map((pos, i) => ({ pos, label: labels[i] || `V${i+1}` }));

    for (let i = 0; i < verts.length; i++)
      for (let j = i + 1; j < verts.length; j++)
        if (verts[i].distanceTo(verts[j]) <= a * 1.06)
          this._edges.push({ from: verts[i], to: verts[j] });

    this._addEdges();
    this._addVerticesAndLabels();
  }

  /* ── DODECAHEDRON ── */
  _buildDodecahedron() {
    const a = this.params.a;
    const phi = (1 + Math.sqrt(5)) / 2;
    const circR = a * Math.sqrt(3) * phi / 2;
    const geo = new THREE.DodecahedronGeometry(circR, 0);

    const posArr = geo.getAttribute('position').array;
    let minY = Infinity;
    for (let i = 1; i < posArr.length; i += 3) minY = Math.min(minY, posArr[i]);
    geo.translate(0, -minY, 0);

    const mat = new THREE.MeshPhysicalMaterial({
      color: 0x9B5DE5, transparent: true, opacity: this.opacity,
      side: THREE.DoubleSide, metalness: 0.05, roughness: 0.4, clearcoat: 0.2, depthWrite: false,
    });
    this._figGroup.add(new THREE.Mesh(geo, mat));

    const edgeLen = a * 2 / phi;  // dodecahedron edge length relative to circumradius
    const verts = this._extractUniqueVerts(geo);
    const labels = 'ABCDEFGHIJKLMNOPQRST'.split('');
    this._vertices = verts.map((pos, i) => ({ pos, label: labels[i] || `V${i+1}` }));

    for (let i = 0; i < verts.length; i++)
      for (let j = i + 1; j < verts.length; j++)
        if (verts[i].distanceTo(verts[j]) <= a * 1.06)
          this._edges.push({ from: verts[i], to: verts[j] });

    this._addEdges();
    this._addVerticesAndLabels();
  }

  /* ════════════════ GEOMETRY HELPERS ════════════════ */

  _regularPolygon(n, sideLength) {
    const r = sideLength / (2 * Math.sin(Math.PI / n));
    const pts = [];
    for (let i = 0; i < n; i++) {
      const angle = (i / n) * Math.PI * 2 - Math.PI / 2;
      pts.push(new THREE.Vector3(r * Math.cos(angle), 0, r * Math.sin(angle)));
    }
    return pts;
  }

  /* Extract deduplicated vertex list from a THREE.BufferGeometry */
  _extractUniqueVerts(geo) {
    const arr = geo.getAttribute('position').array;
    const map = new Map();
    const verts = [];
    for (let i = 0; i < arr.length; i += 3) {
      const key = `${(arr[i]*1000)|0},${(arr[i+1]*1000)|0},${(arr[i+2]*1000)|0}`;
      if (!map.has(key)) {
        map.set(key, verts.length);
        verts.push(new THREE.Vector3(arr[i], arr[i+1], arr[i+2]));
      }
    }
    return verts;
  }

  _addMeshFromFaces(color) {
    // Build a single mesh from faces
    const positions = [];
    const indices = [];
    let vi = 0;

    for (const face of this._faces) {
      const base = vi;
      for (const v of face) {
        positions.push(v.x, v.y, v.z);
        vi++;
      }
      // triangulate fan
      for (let i = 1; i < face.length - 1; i++) {
        indices.push(base, base + i, base + i + 1);
      }
    }

    const geo = new THREE.BufferGeometry();
    geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
    geo.setIndex(indices);
    geo.computeVertexNormals();

    const mat = new THREE.MeshPhysicalMaterial({
      color, transparent: true, opacity: this.opacity,
      side: THREE.DoubleSide, metalness: 0.05, roughness: 0.4,
      clearcoat: 0.2, depthWrite: false,
    });
    this._figGroup.add(new THREE.Mesh(geo, mat));
  }

  _addEdges(opac = 0.8) {
    if (!this.showEdges) return;
    for (const e of this._edges) {
      const pts = [e.from, e.to];
      const geo = new THREE.BufferGeometry().setFromPoints(pts);
      const mat = new THREE.LineBasicMaterial({ color: 0xFFFFFF, transparent: true, opacity: opac, linewidth: 2 });
      this._figGroup.add(new THREE.Line(geo, mat));

      if (this.showEdgeLengths) {
        const len = e.from.distanceTo(e.to);
        const mid = new THREE.Vector3().addVectors(e.from, e.to).multiplyScalar(0.5);
        const lbl = this._makeTextSprite(len.toFixed(2), '#A8E063', 44);
        lbl.position.copy(mid).add(new THREE.Vector3(0.1, 0.12, 0.1));
        lbl.scale.set(0.9, 0.4, 1);
        this._labelGroup.add(lbl);
      }
    }
  }

  _addVerticesAndLabels() {
    for (const v of this._vertices) {
      if (this.showVertices) {
        const sGeo = new THREE.SphereGeometry(0.08, 12, 12);
        const sMat = new THREE.MeshBasicMaterial({ color: 0xFFFFFF });
        const sphere = new THREE.Mesh(sGeo, sMat);
        sphere.position.copy(v.pos);
        this._figGroup.add(sphere);
      }

      if (this.showLabels) {
        const sprite = this._makeTextSprite(v.label);
        sprite.position.copy(v.pos).add(new THREE.Vector3(0.15, 0.25, 0));
        this._labelGroup.add(sprite);
      }
    }
  }

  _makeTextSprite(text, color = '#ffffff', size = 64) {
    const canvas = document.createElement('canvas');
    canvas.width = 128; canvas.height = 64;
    const ctx = canvas.getContext('2d');
    ctx.font = `bold ${size}px Manrope, sans-serif`;
    ctx.fillStyle = color;
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(text, 64, 32);

    const tex = new THREE.CanvasTexture(canvas);
    tex.minFilter = THREE.LinearFilter;
    const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false });
    const sprite = new THREE.Sprite(mat);
    sprite.scale.set(0.8, 0.4, 1);
    return sprite;
  }

  /* ════════════════ SECTION PLANE ════════════════ */

  _updateSection() {
    this._clearGroup(this._sectionGroup);
    this._sectionPolygon = null;
    if (!this.showSection) return;

    const figH = this._figureHeight();
    const t = this.sectionType;

    if (t === 'horizontal') {
      const y = figH * this.sectionHeight;
      this._sectionPolygon = this._sliceAtY(y);
      if (this._sectionPolygon.length >= 3) {
        this._drawSectionPolygon(this._sectionPolygon, y);
        // translucent plane
        const planeGeo = new THREE.PlaneGeometry(16, 16);
        const planeMat = new THREE.MeshBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.08, side: THREE.DoubleSide });
        const planeMesh = new THREE.Mesh(planeGeo, planeMat);
        planeMesh.rotation.x = -Math.PI/2;
        planeMesh.position.y = y;
        this._sectionGroup.add(planeMesh);
      }
    } else if (t === 'diagonal') {
      this._sectionPolygon = this._sliceDiagonal();
      if (this._sectionPolygon.length >= 3) {
        this._drawSectionPolygon3D(this._sectionPolygon);
        this._drawSectionInfo(this._sectionPolygon);
      }
    } else if (t === 'custom') {
      // Need ≥3 custom points to define a plane
      if (this._customPoints.length >= 3) {
        const pts3 = this._customPoints.slice(0, 3).map(p => p.pos);
        this._sectionPolygon = this._sliceByPlane(pts3[0], pts3[1], pts3[2]);
        if (this._sectionPolygon.length >= 3) {
          this._drawSectionPolygon3D(this._sectionPolygon);
          this._drawSectionInfo(this._sectionPolygon);
        }
      }
    }
  }

  _figureHeight() {
    const p = this.params;
    switch (this.figureType) {
      case 'cube': return p.a;
      case 'parallelepiped': return p.c;
      case 'pyramid': case 'prism': case 'truncpyramid': return p.h;
      case 'tetrahedron': return p.a * Math.sqrt(2/3);
      case 'cylinder': case 'cone': case 'trunccone': return p.h;
      case 'sphere': return p.r * 2;
      case 'octahedron':   return p.a * Math.SQRT2;
      case 'icosahedron':  return p.a * Math.sqrt(10 + 2*Math.sqrt(5)) / 2;
      case 'dodecahedron': { const phi = (1+Math.sqrt(5))/2; return p.a * Math.sqrt(3) * phi; }
      default: return p.h || p.a || 4;
    }
  }

  _sliceAtY(y) {
    // Find intersections of all edges with horizontal plane y
    const points = [];
    for (const e of this._edges) {
      const y1 = e.from.y, y2 = e.to.y;
      if ((y1 <= y && y2 >= y) || (y2 <= y && y1 >= y)) {
        if (Math.abs(y2 - y1) < 1e-9) continue;
        const t = (y - y1) / (y2 - y1);
        if (t < -0.001 || t > 1.001) continue;
        const pt = new THREE.Vector3().lerpVectors(e.from, e.to, t);
        points.push(pt);
      }
    }
    // For cylinders/cones/spheres with no explicit edges, generate circle
    if (points.length < 3) {
      const circleR = this._radiusAtHeight(y);
      if (circleR > 0.01) {
        const n = 48;
        for (let i = 0; i < n; i++) {
          const a = (i/n)*Math.PI*2;
          points.push(new THREE.Vector3(circleR*Math.cos(a), y, circleR*Math.sin(a)));
        }
      }
    }
    // Sort by angle from centroid
    return this._sortByAngle(points);
  }

  _radiusAtHeight(y) {
    const p = this.params;
    const fh = this._figureHeight();
    const t = Math.max(0, Math.min(1, y / fh));
    switch (this.figureType) {
      case 'cylinder': return p.r;
      case 'cone': return p.r * (1 - t);
      case 'trunccone': return p.R + (p.r - p.R) * t;
      case 'sphere': {
        const dy = y - p.r; // center at (0, r, 0)
        const r2 = p.r**2 - dy**2;
        return r2 > 0 ? Math.sqrt(r2) : 0;
      }
      default: return 0;
    }
  }

  _sliceDiagonal() {
    const fh = this._figureHeight();
    const y = fh * this.sectionHeight;
    // Tilt angle: sectionAngle 0=horizontal, 1=nearly vertical (~80°)
    const tiltRad = this.sectionAngle * Math.PI * 0.44; // 0..~80°
    // Normal rotated from vertical (0,1,0) toward X axis
    const normal = new THREE.Vector3(Math.sin(tiltRad), Math.cos(tiltRad), 0).normalize();
    const pointOnPlane = new THREE.Vector3(0, y, 0);
    return this._sliceByNormal(normal, pointOnPlane);
  }

  _sliceByPlane(p1, p2, p3) {
    // Plane through 3 points
    const v1 = new THREE.Vector3().subVectors(p2, p1);
    const v2 = new THREE.Vector3().subVectors(p3, p1);
    const normal = new THREE.Vector3().crossVectors(v1, v2).normalize();
    if (normal.length() < 1e-9) return [];
    return this._sliceByNormal(normal, p1);
  }

  _sliceByNormal(normal, pointOnPlane) {
    const d = -normal.dot(pointOnPlane);
    const points = [];

    // Intersect with all edges
    for (const e of this._edges) {
      const d1 = normal.dot(e.from) + d;
      const d2 = normal.dot(e.to) + d;
      if ((d1 <= 0 && d2 >= 0) || (d2 <= 0 && d1 >= 0)) {
        if (Math.abs(d2 - d1) < 1e-9) continue;
        const t = -d1 / (d2 - d1);
        if (t < -0.001 || t > 1.001) continue;
        points.push(new THREE.Vector3().lerpVectors(e.from, e.to, Math.max(0, Math.min(1, t))));
      }
    }

    // For cylinders/cones/spheres — sample the intersection curve
    if (points.length < 3 && ['cylinder','cone','trunccone','sphere'].includes(this.figureType)) {
      const fh = this._figureHeight();
      const samples = 64;
      for (let i = 0; i < samples; i++) {
        const angle = (i / samples) * Math.PI * 2;
        // Walk along height, find where the plane intersects at this angle
        for (let step = 0; step <= 100; step++) {
          const y = (step / 100) * fh;
          const r = this._radiusAtHeight(y);
          if (r < 0.01) continue;
          const pt = new THREE.Vector3(r * Math.cos(angle), y, r * Math.sin(angle));
          const dist = normal.dot(pt) + d;
          if (Math.abs(dist) < fh * 0.012) {
            points.push(pt);
            break;
          }
        }
      }
    }

    // Sort into convex polygon order (project onto plane, sort by angle)
    return this._sortByAngle3D(points, normal);
  }

  _sortByAngle(points) {
    if (points.length < 3) return points;
    const cx = points.reduce((s,p) => s+p.x, 0) / points.length;
    const cz = points.reduce((s,p) => s+p.z, 0) / points.length;
    points.sort((a,b) => Math.atan2(a.z-cz, a.x-cx) - Math.atan2(b.z-cz, b.x-cx));
    return points;
  }

  _sortByAngle3D(points, normal) {
    if (points.length < 3) return points;
    // Remove near-duplicates
    const unique = [points[0]];
    for (let i = 1; i < points.length; i++) {
      let dup = false;
      for (const u of unique) {
        if (points[i].distanceTo(u) < 0.01) { dup = true; break; }
      }
      if (!dup) unique.push(points[i]);
    }
    if (unique.length < 3) return unique;

    // Centroid
    const c = new THREE.Vector3();
    unique.forEach(p => c.add(p));
    c.divideScalar(unique.length);

    // Build local 2D basis on the plane
    const u = new THREE.Vector3().subVectors(unique[0], c).normalize();
    const v = new THREE.Vector3().crossVectors(normal, u).normalize();

    // Project and sort by angle
    unique.sort((a, b) => {
      const da = new THREE.Vector3().subVectors(a, c);
      const db = new THREE.Vector3().subVectors(b, c);
      return Math.atan2(da.dot(v), da.dot(u)) - Math.atan2(db.dot(v), db.dot(u));
    });
    return unique;
  }

  _drawSectionInfo(pts) {
    if (pts.length < 3) return;
    const area = this._polygonArea(pts);
    let perimeter = 0;
    for (let i = 0; i < pts.length; i++) {
      perimeter += pts[i].distanceTo(pts[(i + 1) % pts.length]);
    }
    // Label at centroid
    const c = new THREE.Vector3();
    pts.forEach(p => c.add(p));
    c.divideScalar(pts.length);
    const label = this._makeTextSprite(`S=${area.toFixed(1)} P=${perimeter.toFixed(1)}`, '#06D6E0', 36);
    label.position.copy(c).add(new THREE.Vector3(0, 0.4, 0));
    label.scale.set(1.6, 0.5, 1);
    this._sectionGroup.add(label);

    // Vertex markers on section polygon
    for (const p of pts) {
      const sGeo = new THREE.SphereGeometry(0.06, 8, 8);
      const sMat = new THREE.MeshBasicMaterial({ color: 0x06D6E0 });
      const sm = new THREE.Mesh(sGeo, sMat);
      sm.position.copy(p);
      this._sectionGroup.add(sm);
    }
  }

  _drawSectionPolygon(pts, y) {
    if (pts.length < 3) return;
    // Fill polygon
    const shape = new THREE.Shape();
    shape.moveTo(pts[0].x, pts[0].z);
    for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i].x, pts[i].z);
    shape.closePath();
    const geo = new THREE.ShapeGeometry(shape);
    const mat = new THREE.MeshBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.35, side: THREE.DoubleSide });
    const mesh = new THREE.Mesh(geo, mat);
    mesh.rotation.x = -Math.PI/2;
    mesh.position.y = y + 0.005;
    this._sectionGroup.add(mesh);

    // outline
    const linePts = [...pts, pts[0]];
    const lineGeo = new THREE.BufferGeometry().setFromPoints(linePts);
    const lineMat = new THREE.LineBasicMaterial({ color: 0x06D6E0, linewidth: 2 });
    this._sectionGroup.add(new THREE.Line(lineGeo, lineMat));
  }

  _drawSectionPolygon3D(pts) {
    if (pts.length < 3) return;
    const positions = [];
    const indices = [];
    pts.forEach(p => positions.push(p.x, p.y, p.z));
    for (let i = 1; i < pts.length - 1; i++) indices.push(0, i, i+1);
    const geo = new THREE.BufferGeometry();
    geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
    geo.setIndex(indices);
    geo.computeVertexNormals();
    const mat = new THREE.MeshBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.35, side: THREE.DoubleSide });
    this._sectionGroup.add(new THREE.Mesh(geo, mat));

    const linePts = [...pts, pts[0]];
    const lineGeo = new THREE.BufferGeometry().setFromPoints(linePts);
    this._sectionGroup.add(new THREE.Line(lineGeo, new THREE.LineBasicMaterial({ color: 0x06D6E0 })));
  }

  _polygonArea(pts) {
    // Shoelace in 3D — project onto best-fit plane
    if (pts.length < 3) return 0;
    let area = 0;
    const n = pts.length;
    const cx = pts.reduce((s,p)=>s+p.x,0)/n;
    const cy = pts.reduce((s,p)=>s+p.y,0)/n;
    const cz = pts.reduce((s,p)=>s+p.z,0)/n;
    // Cross-product sum
    const cross = new THREE.Vector3(0,0,0);
    for (let i = 0; i < n; i++) {
      const a = new THREE.Vector3().subVectors(pts[i], new THREE.Vector3(cx,cy,cz));
      const b = new THREE.Vector3().subVectors(pts[(i+1)%n], new THREE.Vector3(cx,cy,cz));
      cross.add(new THREE.Vector3().crossVectors(a, b));
    }
    return Math.round(cross.length() / 2 * 100) / 100;
  }

  /* ════════════════ INSCRIBED / CIRCUMSCRIBED ════════════════ */

  _inscribedRadius() {
    const p = this.params;
    const PI = Math.PI;
    switch (this.figureType) {
      case 'cube': return p.a / 2;
      case 'parallelepiped': return Math.min(p.a, p.b, p.c) / 2;
      case 'tetrahedron': return p.a / (2 * Math.sqrt(6));
      case 'sphere': return p.r;
      case 'cylinder': return Math.min(p.r, p.h / 2);
      case 'cone': {
        // r_in = r * h / (r + sqrt(r²+h²))
        const l = Math.sqrt(p.r ** 2 + p.h ** 2);
        return p.r * p.h / (p.r + l);
      }
      case 'trunccone': {
        // inscribed sphere radius = h * (R - r) / (2 * sqrt((R-r)² + h²))... approximate
        // exact for truncated cone: r_in = h / (1 + sqrt(1 + ((R-r)/h)²)) * (not standard)
        // simpler: r_in = h * min(R, r) / sqrt((R-r)² + h²) — approximate
        // Actually: for truncated cone, inscribed sphere touches both bases and lateral surface
        // r_in = h * (R + r - sqrt((R-r)² + h²)) / (2 * (R - r)) when R ≠ r
        if (Math.abs(p.R - p.r) < 0.001) return Math.min(p.R, p.h / 2); // cylinder-like
        const l = Math.sqrt((p.R - p.r) ** 2 + p.h ** 2);
        return p.h * (p.R + p.r - l) / (2 * Math.abs(p.R - p.r));
      }
      case 'pyramid': {
        // r_in = 3V / S_total
        const { a, h, n } = p;
        const sBase = n * a ** 2 / (4 * Math.tan(PI / n));
        const apothem = a / (2 * Math.tan(PI / n));
        const slantH = Math.sqrt(h ** 2 + apothem ** 2);
        const sLat = n * a * slantH / 2;
        const V = sBase * h / 3;
        return 3 * V / (sBase + sLat);
      }
      case 'prism': {
        const { a, h, n } = p;
        const apothem = a / (2 * Math.tan(PI / n));
        return Math.min(apothem, h / 2);
      }
      case 'octahedron':   return p.a * Math.sqrt(6) / 6;
      case 'icosahedron':  { const phi = (1+Math.sqrt(5))/2; return p.a * phi**2 / (2*Math.sqrt(3)); }
      case 'dodecahedron': return p.a / 2 * Math.sqrt((25 + 11*Math.sqrt(5)) / 10);
      default: return null;
    }
  }

  _circumscribedRadius() {
    const p = this.params;
    const PI = Math.PI;
    switch (this.figureType) {
      case 'cube': return p.a * Math.sqrt(3) / 2;
      case 'parallelepiped': return Math.sqrt(p.a ** 2 + p.b ** 2 + p.c ** 2) / 2;
      case 'tetrahedron': return p.a * Math.sqrt(6) / 4;
      case 'sphere': return p.r;
      case 'cylinder': return Math.sqrt(p.r ** 2 + (p.h / 2) ** 2);
      case 'cone': {
        // circumscribed sphere around cone: passes through apex and base circle
        // R = (r² + h²) / (2h)
        return (p.r ** 2 + p.h ** 2) / (2 * p.h);
      }
      case 'trunccone': {
        // R = sqrt(R² + (h/2 + (R²-r²)/(2h))²) — approximate
        const hc = p.h / 2 + (p.R ** 2 - p.r ** 2) / (2 * p.h);
        return Math.sqrt(p.R ** 2 + hc ** 2);
      }
      case 'pyramid': {
        // R = sqrt(R_base² + h²_offset) where R_base = circumradius of base polygon
        // center of circumscribed sphere at height y: y = h - R, R² = R_base² + (h - y)²
        // solving: R = (R_base² + h²) / (2h)
        const { a, h, n } = p;
        const Rb = a / (2 * Math.sin(PI / n));
        return (Rb ** 2 + h ** 2) / (2 * h);
      }
      case 'prism': {
        const { a, h, n } = p;
        const Rb = a / (2 * Math.sin(PI / n));
        return Math.sqrt(Rb ** 2 + (h / 2) ** 2);
      }
      case 'octahedron':   return p.a * Math.SQRT2 / 2;
      case 'icosahedron':  return p.a * Math.sqrt(10 + 2*Math.sqrt(5)) / 4;
      case 'dodecahedron': { const phi = (1+Math.sqrt(5))/2; return p.a * Math.sqrt(3) * phi / 2; }
      default: return null;
    }
  }

  _inscribedCenter() {
    const p = this.params;
    const PI = Math.PI;
    switch (this.figureType) {
      case 'cube': return p.a / 2;
      case 'parallelepiped': return p.c / 2;
      case 'tetrahedron': return p.a / (2 * Math.sqrt(6)); // r_in from base
      case 'sphere': return p.r;
      case 'cylinder': return p.h / 2;
      case 'cone': {
        const l = Math.sqrt(p.r ** 2 + p.h ** 2);
        return p.r * p.h / (p.r + l); // r_in = height of center
      }
      case 'trunccone': return p.h / 2;
      case 'pyramid': {
        // inscribed sphere center at height r_in from base
        const { a, h, n } = p;
        const sBase = n * a ** 2 / (4 * Math.tan(PI / n));
        const apothem = a / (2 * Math.tan(PI / n));
        const slantH = Math.sqrt(h ** 2 + apothem ** 2);
        const sLat = n * a * slantH / 2;
        const V = sBase * h / 3;
        return 3 * V / (sBase + sLat); // r_in
      }
      case 'prism': return p.h / 2;
      case 'octahedron':   return p.a * Math.SQRT2 / 2;   // center of octahedron
      case 'icosahedron':  return p.a * Math.sqrt(10 + 2*Math.sqrt(5)) / 4;  // circumR = half-height approx
      case 'dodecahedron': { const phi = (1+Math.sqrt(5))/2; return p.a * Math.sqrt(3) * phi / 2; }
      default: return this._figureHeight() / 2;
    }
  }

  _circumscribedCenter() {
    const p = this.params;
    const PI = Math.PI;
    switch (this.figureType) {
      case 'cube': return p.a / 2;
      case 'parallelepiped': return p.c / 2;
      case 'tetrahedron': {
        // center at h - R from base = h - a*sqrt(6)/4
        const h = p.a * Math.sqrt(2 / 3);
        return h - p.a * Math.sqrt(6) / 4;
      }
      case 'sphere': return p.r;
      case 'cylinder': return p.h / 2;
      case 'cone': {
        // center at y = R_circ from apex? No. center at y = h - R + h_offset
        // R = (r²+h²)/(2h), center at y = h - R = h - (r²+h²)/(2h) = (h²-r²)/(2h)
        // but if r > h, center goes below base — clamp
        const R = (p.r ** 2 + p.h ** 2) / (2 * p.h);
        return p.h - R;
      }
      case 'trunccone': {
        const hc = p.h / 2 + (p.R ** 2 - p.r ** 2) / (2 * p.h);
        return hc; // height of center from base
      }
      case 'pyramid': {
        const { a, h, n } = p;
        const Rb = a / (2 * Math.sin(PI / n));
        const R = (Rb ** 2 + h ** 2) / (2 * h);
        return h - R; // from base
      }
      case 'prism': return p.h / 2;
      case 'octahedron':   return p.a * Math.SQRT2 / 2;
      case 'icosahedron':  return p.a * Math.sqrt(10 + 2*Math.sqrt(5)) / 4;
      case 'dodecahedron': { const phi = (1+Math.sqrt(5))/2; return p.a * Math.sqrt(3) * phi / 2; }
      default: return this._figureHeight() / 2;
    }
  }

  _updateSpheres() {
    this._clearGroup(this._sphereGroup);

    if (this.showInscribed) {
      const r = this._inscribedRadius();
      const cy = this._inscribedCenter();
      if (r && r > 0) {
        const geo = new THREE.SphereGeometry(r, 32, 24);
        const mat = new THREE.MeshPhysicalMaterial({
          color: 0x06D6E0, transparent: true, opacity: 0.12,
          side: THREE.DoubleSide, depthWrite: false,
        });
        const mesh = new THREE.Mesh(geo, mat);
        mesh.position.y = cy;
        this._sphereGroup.add(mesh);
        const wf = new THREE.LineSegments(
          new THREE.WireframeGeometry(geo),
          new THREE.LineBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.2 })
        );
        wf.position.y = cy;
        this._sphereGroup.add(wf);
        // radius label
        const lbl = this._makeTextSprite(`r=${r.toFixed(2)}`, '#06D6E0', 36);
        lbl.position.set(r * 0.5, cy + r * 0.3, 0);
        lbl.scale.set(1.0, 0.4, 1);
        this._sphereGroup.add(lbl);
        // radius line
        const lineGeo = new THREE.BufferGeometry().setFromPoints([
          new THREE.Vector3(0, cy, 0), new THREE.Vector3(r, cy, 0)
        ]);
        const lineMat = new THREE.LineDashedMaterial({ color: 0x06D6E0, dashSize: 0.1, gapSize: 0.06, transparent: true, opacity: 0.7 });
        const line = new THREE.Line(lineGeo, lineMat);
        line.computeLineDistances();
        this._sphereGroup.add(line);
      }
    }

    if (this.showCircumscribed) {
      const R = this._circumscribedRadius();
      const cy = this._circumscribedCenter();
      if (R && R > 0) {
        const geo = new THREE.SphereGeometry(R, 32, 24);
        const mat = new THREE.MeshPhysicalMaterial({
          color: 0xF59E0B, transparent: true, opacity: 0.08,
          side: THREE.DoubleSide, depthWrite: false,
        });
        const mesh = new THREE.Mesh(geo, mat);
        mesh.position.y = cy;
        this._sphereGroup.add(mesh);
        const wf = new THREE.LineSegments(
          new THREE.WireframeGeometry(geo),
          new THREE.LineBasicMaterial({ color: 0xF59E0B, transparent: true, opacity: 0.15 })
        );
        wf.position.y = cy;
        this._sphereGroup.add(wf);
        // radius label
        const lbl = this._makeTextSprite(`R=${R.toFixed(2)}`, '#F59E0B', 36);
        lbl.position.set(R * 0.5, cy + R * 0.3, 0);
        lbl.scale.set(1.0, 0.4, 1);
        this._sphereGroup.add(lbl);
        // radius line
        const lineGeo = new THREE.BufferGeometry().setFromPoints([
          new THREE.Vector3(0, cy, 0), new THREE.Vector3(R, cy, 0)
        ]);
        const lineMat = new THREE.LineDashedMaterial({ color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06, transparent: true, opacity: 0.7 });
        const line = new THREE.Line(lineGeo, lineMat);
        line.computeLineDistances();
        this._sphereGroup.add(line);
      }
    }
  }

  /* ════════════════ SECTION THROUGH 3 POINTS ════════════════ */

  _onSection3PClick(e) {
    if (!this._section3PMode) return;
    if (this._section3PPicks.length >= 3) return; // already have 3 — need reset first

    const { mx, my } = this._screenCoords(e);

    // Pick nearest point: prefer vertex snap, then edge snap
    let bestDist = 0.09;
    let bestPos  = null;

    for (const v of this._vertices) {
      const proj = v.pos.clone().project(this.camera);
      const d = Math.sqrt((mx - proj.x) ** 2 + (my - proj.y) ** 2);
      if (d < bestDist) { bestDist = d; bestPos = v.pos.clone(); }
    }

    // Also check custom points if placed
    for (const cp of this._customPoints) {
      const proj = cp.pos.clone().project(this.camera);
      const d = Math.sqrt((mx - proj.x) ** 2 + (my - proj.y) ** 2);
      if (d < bestDist) { bestDist = d; bestPos = cp.pos.clone(); }
    }

    // Edge snap (pick point on edge)
    for (const edge of this._edges) {
      const p1 = edge.from.clone().project(this.camera);
      const p2 = edge.to.clone().project(this.camera);
      const dx = p2.x - p1.x, dy = p2.y - p1.y;
      const lenSq = dx * dx + dy * dy;
      if (lenSq < 1e-9) continue;
      let t = ((mx - p1.x) * dx + (my - p1.y) * dy) / lenSq;
      t = Math.max(0, Math.min(1, t));
      const px = p1.x + t * dx, py = p1.y + t * dy;
      const d = Math.sqrt((mx - px) ** 2 + (my - py) ** 2);
      if (d < bestDist) {
        bestDist = d;
        bestPos  = new THREE.Vector3().lerpVectors(edge.from, edge.to, t);
      }
    }

    if (!bestPos) return;

    // Avoid duplicate picks (too close)
    for (const p of this._section3PPicks) {
      if (p.distanceTo(bestPos) < 0.08) return;
    }

    this._section3PPicks.push(bestPos);
    this._drawSection3P();
    if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.5, volume: 0.3 });

    if (this._section3PPicks.length === 3) {
      this._computeSection3P();
      this._drawSection3P();
      this._notify();
      if (window.LabFX) LabFX.sound.play('chime', { pitch: 1.1 });
    }
  }

  _computeSection3P() {
    const pts = this._section3PPicks;
    if (pts.length < 3) { this._section3PData = null; return; }

    const [P1, P2, P3] = pts;
    const v1 = new THREE.Vector3().subVectors(P2, P1);
    const v2 = new THREE.Vector3().subVectors(P3, P1);
    const normal = new THREE.Vector3().crossVectors(v1, v2);
    if (normal.length() < 1e-9) { this._section3PData = null; return; }
    normal.normalize();
    const D = -normal.dot(P1);

    // Intersect the plane with all edges of the solid
    const polygon = this._sliceByNormal(normal, P1);

    if (polygon.length < 3) { this._section3PData = null; return; }

    const area = this._polygonArea(polygon);
    const n = polygon.length;
    const typeNames = { 3: 'треугольник', 4: 'четырёхугольник', 5: 'пятиугольник', 6: 'шестиугольник' };
    const typeName = typeNames[n] || `${n}-угольник`;

    this._section3PData = { normal, D, polygon, area, typeName, P1, P2, P3 };
  }

  _drawSection3P() {
    this._clearGroup(this._section3PGroup);
    const picks = this._section3PPicks;
    const data  = this._section3PData;

    // Draw picked points as spheres (yellow accent)
    const PICK_COLOR  = 0xFFD166;
    const PLANE_COLOR = 0xEF476F;
    const SECT_COLOR  = 0x7BF5A4;

    picks.forEach((p, i) => {
      const sGeo = new THREE.SphereGeometry(0.13, 10, 10);
      const sMat = new THREE.MeshBasicMaterial({ color: PICK_COLOR });
      const s    = new THREE.Mesh(sGeo, sMat);
      s.position.copy(p);
      this._section3PGroup.add(s);

      // Number label
      const lbl = this._makeTextSprite(String(i + 1), '#FFD166', 42);
      lbl.position.copy(p).add(new THREE.Vector3(0.25, 0.25, 0));
      lbl.scale.set(0.7, 0.28, 1);
      this._section3PGroup.add(lbl);
    });

    // Draw line from P1 to P2 after 2nd pick
    if (picks.length >= 2) {
      const lg1 = new THREE.BufferGeometry().setFromPoints([picks[0], picks[1]]);
      this._section3PGroup.add(new THREE.Line(lg1, new THREE.LineBasicMaterial({ color: PICK_COLOR, opacity: 0.7, transparent: true })));
    }
    if (picks.length >= 3) {
      const lg2 = new THREE.BufferGeometry().setFromPoints([picks[1], picks[2]]);
      this._section3PGroup.add(new THREE.Line(lg2, new THREE.LineBasicMaterial({ color: PICK_COLOR, opacity: 0.7, transparent: true })));
      const lg3 = new THREE.BufferGeometry().setFromPoints([picks[2], picks[0]]);
      this._section3PGroup.add(new THREE.Line(lg3, new THREE.LineBasicMaterial({ color: PICK_COLOR, opacity: 0.5, transparent: true })));
    }

    if (!data || picks.length < 3) return;

    // Semi-transparent plane quad (large enough to show context)
    const { normal, D, polygon } = data;
    // Build a visible plane chip — use bounding box of polygon centroid + spread
    const c = new THREE.Vector3();
    polygon.forEach(p => c.add(p));
    c.divideScalar(polygon.length);
    // Local basis on plane
    const u = new THREE.Vector3().subVectors(polygon[0], c).normalize();
    const v = new THREE.Vector3().crossVectors(normal, u).normalize();
    const spread = Math.max(...polygon.map(p => c.distanceTo(p))) * 1.5;
    const planeVerts = [
      c.clone().addScaledVector(u, -spread).addScaledVector(v, -spread),
      c.clone().addScaledVector(u,  spread).addScaledVector(v, -spread),
      c.clone().addScaledVector(u,  spread).addScaledVector(v,  spread),
      c.clone().addScaledVector(u, -spread).addScaledVector(v,  spread),
    ];
    const planePositions = [];
    [[0,1,2],[0,2,3]].forEach(tri => tri.forEach(i => {
      const pv = planeVerts[i];
      planePositions.push(pv.x, pv.y, pv.z);
    }));
    const planeGeo = new THREE.BufferGeometry();
    planeGeo.setAttribute('position', new THREE.Float32BufferAttribute(planePositions, 3));
    const planeMat = new THREE.MeshBasicMaterial({ color: PLANE_COLOR, transparent: true, opacity: 0.08, side: THREE.DoubleSide });
    this._section3PGroup.add(new THREE.Mesh(planeGeo, planeMat));

    // Cross-section polygon fill
    const sectPositions = [];
    const sectIndices   = [];
    polygon.forEach(p => sectPositions.push(p.x, p.y, p.z));
    for (let i = 1; i < polygon.length - 1; i++) sectIndices.push(0, i, i + 1);
    const sectGeo = new THREE.BufferGeometry();
    sectGeo.setAttribute('position', new THREE.Float32BufferAttribute(sectPositions, 3));
    sectGeo.setIndex(sectIndices);
    sectGeo.computeVertexNormals();
    const sectMat = new THREE.MeshBasicMaterial({ color: SECT_COLOR, transparent: true, opacity: 0.45, side: THREE.DoubleSide });
    this._section3PGroup.add(new THREE.Mesh(sectGeo, sectMat));

    // Polygon outline (slightly offset along normal for visibility)
    const outlinePts = [...polygon, polygon[0]].map(p =>
      p.clone().addScaledVector(normal, 0.012)
    );
    const outlineGeo = new THREE.BufferGeometry().setFromPoints(outlinePts);
    const outlineMat = new THREE.LineBasicMaterial({ color: SECT_COLOR, linewidth: 2 });
    this._section3PGroup.add(new THREE.Line(outlineGeo, outlineMat));

    // Vertex markers on section polygon
    polygon.forEach(p => {
      const sg = new THREE.SphereGeometry(0.07, 8, 8);
      const sm = new THREE.MeshBasicMaterial({ color: SECT_COLOR });
      const s  = new THREE.Mesh(sg, sm);
      s.position.copy(p);
      this._section3PGroup.add(s);
    });

    // Step-by-step highlight (если включён пошаговый режим)
    if (this._section3PStepBy && this._section3PStep > 0) {
      this._drawSection3PStep(data);
    }
  }

  _drawSection3PStep(data) {
    // Extra step-by-step highlight objects added to _section3PGroup
    const step  = this._section3PStep;
    const picks = this._section3PPicks;
    const HILITE = 0xFFFFA0;

    const flash = (pos) => {
      const sg = new THREE.SphereGeometry(0.22, 10, 10);
      const sm = new THREE.MeshBasicMaterial({ color: HILITE, transparent: true, opacity: 0.7 });
      const s  = new THREE.Mesh(sg, sm);
      s.position.copy(pos);
      this._section3PGroup.add(s);
    };
    const flashLine = (a, b) => {
      const lg = new THREE.BufferGeometry().setFromPoints([a, b]);
      this._section3PGroup.add(new THREE.Line(lg, new THREE.LineBasicMaterial({ color: HILITE, linewidth: 3 })));
    };

    if (step >= 1) flash(picks[0]);
    if (step >= 2) { flash(picks[1]); flashLine(picks[0], picks[1]); }
    if (step >= 3) { flash(picks[2]); flashLine(picks[1], picks[2]); flashLine(picks[2], picks[0]); }
    // steps 4-6 handled by full plane + section already drawn above
  }

  /* ════════════════ MEASUREMENT MODE ════════════════ */

  _onMeasureClick(e) {
    if (!this._measureMode) return;
    const { mx, my } = this._screenCoords(e);

    // Find closest vertex OR custom point in screen space
    let bestDist = 0.08; // threshold in NDC
    let bestPick = null;

    // Check vertices
    for (const v of this._vertices) {
      const projected = v.pos.clone().project(this.camera);
      const d = Math.sqrt((projected.x - mx) ** 2 + (projected.y - my) ** 2);
      if (d < bestDist) { bestDist = d; bestPick = { pos: v.pos, label: v.label }; }
    }

    // Check custom points
    for (const cp of this._customPoints) {
      const projected = cp.pos.clone().project(this.camera);
      const d = Math.sqrt((projected.x - mx) ** 2 + (projected.y - my) ** 2);
      if (d < bestDist) { bestDist = d; bestPick = { pos: cp.pos, label: cp.label }; }
    }

    if (!bestPick) return;

    this._measurePicks.push(bestPick);

    // Highlight first pick in temporary group
    const sGeo = new THREE.SphereGeometry(0.14, 12, 12);
    const sMat = new THREE.MeshBasicMaterial({ color: 0xFFD166 });
    const s = new THREE.Mesh(sGeo, sMat);
    s.position.copy(bestPick.pos);
    this._measurePickGroup.add(s);

    if (this._measurePicks.length === 2) {
      const [a, b] = this._measurePicks;
      const dist = a.pos.distanceTo(b.pos);
      this._measurements.push({
        from: a.label, to: b.label,
        dist: Math.round(dist * 100) / 100,
        posA: a.pos.clone(), posB: b.pos.clone(),
      });
      this._measurePicks = [];
      this._clearGroup(this._measurePickGroup);
      this._rebuildMeasureGroup();
    }
  }

  /* ════════════════ POINT MODE — place points on edges ════════════════ */

  _screenCoords(e) {
    const rect = this.renderer.domElement.getBoundingClientRect();
    return {
      mx: ((e.clientX - rect.left) / rect.width) * 2 - 1,
      my: -((e.clientY - rect.top) / rect.height) * 2 + 1,
    };
  }

  _onPointClick(e) {
    const { mx, my } = this._screenCoords(e);

    // Find the nearest edge in screen space
    let bestDist = 0.08; // threshold in NDC
    let bestEdge = -1;
    let bestT = 0;
    let bestPos = null;

    for (let ei = 0; ei < this._edges.length; ei++) {
      const edge = this._edges[ei];
      const p1 = edge.from.clone().project(this.camera);
      const p2 = edge.to.clone().project(this.camera);

      // Point-to-segment distance in 2D
      const dx = p2.x - p1.x, dy = p2.y - p1.y;
      const lenSq = dx * dx + dy * dy;
      if (lenSq < 1e-9) continue;
      let t = ((mx - p1.x) * dx + (my - p1.y) * dy) / lenSq;
      t = Math.max(0.02, Math.min(0.98, t)); // clamp away from endpoints
      const px = p1.x + t * dx, py = p1.y + t * dy;
      const dist = Math.sqrt((mx - px) ** 2 + (my - py) ** 2);

      if (dist < bestDist) {
        bestDist = dist;
        bestEdge = ei;
        bestT = t;
        bestPos = new THREE.Vector3().lerpVectors(edge.from, edge.to, t);
      }
    }

    // Also check: click near a vertex <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> snap to vertex
    for (const v of this._vertices) {
      const proj = v.pos.clone().project(this.camera);
      const dist = Math.sqrt((mx - proj.x) ** 2 + (my - proj.y) ** 2);
      if (dist < 0.06) {
        bestPos = v.pos.clone();
        bestEdge = -2; // special: vertex
        bestT = 0;
        bestDist = dist;
      }
    }

    if (!bestPos) return;

    const label = String(this._nextPointId++);
    this._customPoints.push({
      pos: bestPos,
      edgeIdx: bestEdge,
      t: bestT,
      label,
    });

    this._rebuildPointVisuals();
    if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.5, volume: 0.3 });

    // If custom section mode and ≥3 points, update section
    if (this.showSection && this.sectionType === 'custom') this._updateSection();
    this._notify();
  }

  _onConnectClick(e) {
    const { mx, my } = this._screenCoords(e);

    // Find nearest custom point OR vertex
    let bestDist = 0.08;
    let bestIdx = -1;

    // Check custom points
    for (let i = 0; i < this._customPoints.length; i++) {
      const proj = this._customPoints[i].pos.clone().project(this.camera);
      const dist = Math.sqrt((mx - proj.x) ** 2 + (my - proj.y) ** 2);
      if (dist < bestDist) { bestDist = dist; bestIdx = i; }
    }

    // Also check vertices (mapped as negative indices: -1 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> vertex 0, -2 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> vertex 1, etc.)
    for (let i = 0; i < this._vertices.length; i++) {
      const proj = this._vertices[i].pos.clone().project(this.camera);
      const dist = Math.sqrt((mx - proj.x) ** 2 + (my - proj.y) ** 2);
      if (dist < bestDist) { bestDist = dist; bestIdx = -(i + 100); } // encode vertex
    }

    if (bestIdx === -1 && bestIdx !== -(99 + this._vertices.length)) return; // nothing found
    // Actually check: any valid pick
    if (bestDist >= 0.08) return;

    this._connectPicks.push(bestIdx);

    // Highlight pick
    const pos = bestIdx >= 0 ? this._customPoints[bestIdx].pos : this._vertices[-(bestIdx + 100)].pos;
    const sGeo = new THREE.SphereGeometry(0.12, 10, 10);
    const sMat = new THREE.MeshBasicMaterial({ color: 0xF59E0B });
    const s = new THREE.Mesh(sGeo, sMat);
    s.position.copy(pos);
    this._pointGroup.add(s);

    if (this._connectPicks.length === 2) {
      const [idxA, idxB] = this._connectPicks;
      if (idxA !== idxB) {
        this._connections.push({ from: idxA, to: idxB });
      }
      this._connectPicks = [];
      this._rebuildPointVisuals();
      this._notify();
    }
  }

  _getPointPos(idx) {
    if (idx >= 0) return this._customPoints[idx]?.pos;
    return this._vertices[-(idx + 100)]?.pos;
  }

  _getPointLabel(idx) {
    if (idx >= 0) return this._customPoints[idx]?.label || '?';
    return this._vertices[-(idx + 100)]?.label || '?';
  }

  _rebuildPointVisuals() {
    this._clearGroup(this._pointGroup);

    // Draw custom points
    for (const pt of this._customPoints) {
      // Sphere marker
      const sGeo = new THREE.SphereGeometry(0.1, 10, 10);
      const sMat = new THREE.MeshBasicMaterial({ color: 0xFFD166 });
      const s = new THREE.Mesh(sGeo, sMat);
      s.position.copy(pt.pos);
      this._pointGroup.add(s);

      // Label
      const label = this._makeTextSprite(pt.label, '#FFD166', 40);
      label.position.copy(pt.pos).add(new THREE.Vector3(0.2, 0.2, 0));
      label.scale.set(0.6, 0.3, 1);
      this._pointGroup.add(label);
    }

    // Draw connections
    for (const conn of this._connections) {
      const posA = this._getPointPos(conn.from);
      const posB = this._getPointPos(conn.to);
      if (!posA || !posB) continue;

      const lineGeo = new THREE.BufferGeometry().setFromPoints([posA, posB]);
      const lineMat = new THREE.LineDashedMaterial({
        color: 0xF59E0B, dashSize: 0.12, gapSize: 0.06,
        transparent: true, opacity: 0.9,
      });
      const line = new THREE.Line(lineGeo, lineMat);
      line.computeLineDistances();
      this._pointGroup.add(line);

      // Distance label
      const dist = posA.distanceTo(posB);
      const mid = new THREE.Vector3().addVectors(posA, posB).multiplyScalar(0.5);
      const lbl = this._makeTextSprite(dist.toFixed(2), '#F59E0B', 36);
      lbl.position.copy(mid).add(new THREE.Vector3(0, 0.25, 0));
      lbl.scale.set(0.8, 0.4, 1);
      this._pointGroup.add(lbl);
    }
  }

  /* ════════════════ HEIGHT LINE ════════════════ */

  _drawHeightLine() {
    if (!this.showHeight) return;
    const p = this.params;
    const fh = this._figureHeight();
    let from, to, label;

    switch (this.figureType) {
      case 'cube':
      case 'parallelepiped':
      case 'prism':
      case 'cylinder': {
        // vertical height: center of bottom base <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> center of top base
        from = new THREE.Vector3(0, 0, 0);
        to   = new THREE.Vector3(0, fh, 0);
        label = `h = ${fh.toFixed(2)}`;
        break;
      }
      case 'pyramid': {
        // apex to base center
        from = new THREE.Vector3(0, 0, 0);
        to   = new THREE.Vector3(0, p.h, 0);
        label = `h = ${p.h.toFixed(2)}`;
        break;
      }
      case 'tetrahedron': {
        const h = p.a * Math.sqrt(2 / 3);
        from = new THREE.Vector3(0, 0, 0);
        to   = new THREE.Vector3(0, h, 0);
        label = `h = ${h.toFixed(2)}`;
        break;
      }
      case 'cone': {
        from = new THREE.Vector3(0, 0, 0);
        to   = new THREE.Vector3(0, p.h, 0);
        label = `h = ${p.h.toFixed(2)}`;
        break;
      }
      case 'trunccone': {
        from = new THREE.Vector3(0, 0, 0);
        to   = new THREE.Vector3(0, p.h, 0);
        label = `h = ${p.h.toFixed(2)}`;
        break;
      }
      case 'sphere': {
        from = new THREE.Vector3(0, 0, 0);
        to   = new THREE.Vector3(0, 2 * p.r, 0);
        label = `d = ${(2 * p.r).toFixed(2)}`;
        break;
      }
      default: return;
    }

    // dashed line
    const lineGeo = new THREE.BufferGeometry().setFromPoints([from, to]);
    const lineMat = new THREE.LineDashedMaterial({
      color: 0xF9A8D4, dashSize: 0.15, gapSize: 0.08,
      transparent: true, opacity: 0.85,
    });
    const line = new THREE.Line(lineGeo, lineMat);
    line.computeLineDistances();
    this._figGroup.add(line);

    // small dots at endpoints
    for (const pt of [from, to]) {
      const sGeo = new THREE.SphereGeometry(0.06, 8, 8);
      const sMat = new THREE.MeshBasicMaterial({ color: 0xF9A8D4 });
      const s = new THREE.Mesh(sGeo, sMat);
      s.position.copy(pt);
      this._figGroup.add(s);
    }

    // label at midpoint
    const mid = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5);
    const lbl = this._makeTextSprite(label, '#F9A8D4', 36);
    lbl.position.copy(mid).add(new THREE.Vector3(0.4, 0, 0));
    lbl.scale.set(1.2, 0.4, 1);
    this._labelGroup.add(lbl);

    // right-angle marker at base (for pyramid/cone/tetrahedron)
    if (['pyramid', 'tetrahedron', 'cone'].includes(this.figureType)) {
      this._drawRightAngleMarker(from, new THREE.Vector3(0, 1, 0), new THREE.Vector3(1, 0, 0), 0.3);
    }
  }

  _drawRightAngleMarker(origin, dir1, dir2, size) {
    const p1 = origin.clone().add(dir1.clone().normalize().multiplyScalar(size));
    const p2 = origin.clone().add(dir2.clone().normalize().multiplyScalar(size));
    const p3 = p1.clone().add(dir2.clone().normalize().multiplyScalar(size));
    const pts = [p1, p3, p2];
    const geo = new THREE.BufferGeometry().setFromPoints(pts);
    const mat = new THREE.LineBasicMaterial({ color: 0xF9A8D4, transparent: true, opacity: 0.6 });
    this._figGroup.add(new THREE.Line(geo, mat));
  }

  /* ════════════════ APOTHEM LINE ════════════════ */

  _drawApothemLine() {
    if (!this.showApothem) return;
    const p = this.params;
    const PI = Math.PI;

    if (this.figureType === 'pyramid') {
      const { a, h, n } = p;
      const apothem = a / (2 * Math.tan(PI / n)); // base apothem
      const slantH = Math.sqrt(h ** 2 + apothem ** 2); // slant apothem

      // Base apothem: center of base to midpoint of first base edge
      const midEdge = this._getBaseMidpoint(n, a, 0);
      this._drawDashedSegment(
        new THREE.Vector3(0, 0, 0), midEdge,
        `a_осн = ${apothem.toFixed(2)}`, '#7BF5A4'
      );

      // Slant apothem: apex to midpoint of first base edge
      this._drawDashedSegment(
        new THREE.Vector3(0, h, 0), midEdge,
        `a_бок = ${slantH.toFixed(2)}`, '#60a5fa'
      );

      // Right angle at midEdge (between base apothem and base edge direction)
      const edgeDir = this._getBaseEdgeDir(n, a, 0);
      const toCenter = new THREE.Vector3().subVectors(new THREE.Vector3(0, 0, 0), midEdge).normalize();
      this._drawRightAngleMarker(midEdge, toCenter, edgeDir, 0.25);

    } else if (this.figureType === 'prism') {
      const { a, h, n } = p;
      const apothem = a / (2 * Math.tan(PI / n));

      // Base apothem
      const midEdge = this._getBaseMidpoint(n, a, 0);
      this._drawDashedSegment(
        new THREE.Vector3(0, 0, 0), midEdge,
        `a = ${apothem.toFixed(2)}`, '#7BF5A4'
      );

      // Right angle marker
      const edgeDir = this._getBaseEdgeDir(n, a, 0);
      const toCenter = new THREE.Vector3().subVectors(new THREE.Vector3(0, 0, 0), midEdge).normalize();
      this._drawRightAngleMarker(midEdge, toCenter, edgeDir, 0.25);

    } else if (this.figureType === 'cone') {
      // Slant height (образующая)
      const l = Math.sqrt(p.r ** 2 + p.h ** 2);
      this._drawDashedSegment(
        new THREE.Vector3(0, p.h, 0),
        new THREE.Vector3(p.r, 0, 0),
        `l = ${l.toFixed(2)}`, '#60a5fa'
      );

    } else if (this.figureType === 'trunccone') {
      const l = Math.sqrt((p.R - p.r) ** 2 + p.h ** 2);
      this._drawDashedSegment(
        new THREE.Vector3(p.r, p.h, 0),
        new THREE.Vector3(p.R, 0, 0),
        `l = ${l.toFixed(2)}`, '#60a5fa'
      );
    }
  }

  _getBaseMidpoint(n, a, edgeIndex) {
    const r = a / (2 * Math.sin(Math.PI / n));
    const a1 = (edgeIndex / n) * Math.PI * 2 - Math.PI / 2;
    const a2 = ((edgeIndex + 1) / n) * Math.PI * 2 - Math.PI / 2;
    return new THREE.Vector3(
      (r * Math.cos(a1) + r * Math.cos(a2)) / 2,
      0,
      (r * Math.sin(a1) + r * Math.sin(a2)) / 2
    );
  }

  _getBaseEdgeDir(n, a, edgeIndex) {
    const r = a / (2 * Math.sin(Math.PI / n));
    const a1 = (edgeIndex / n) * Math.PI * 2 - Math.PI / 2;
    const a2 = ((edgeIndex + 1) / n) * Math.PI * 2 - Math.PI / 2;
    const p1 = new THREE.Vector3(r * Math.cos(a1), 0, r * Math.sin(a1));
    const p2 = new THREE.Vector3(r * Math.cos(a2), 0, r * Math.sin(a2));
    return new THREE.Vector3().subVectors(p2, p1).normalize();
  }

  _drawDashedSegment(from, to, label, color) {
    const lineGeo = new THREE.BufferGeometry().setFromPoints([from, to]);
    const lineMat = new THREE.LineDashedMaterial({
      color: new THREE.Color(color), dashSize: 0.12, gapSize: 0.06,
      transparent: true, opacity: 0.85,
    });
    const line = new THREE.Line(lineGeo, lineMat);
    line.computeLineDistances();
    this._figGroup.add(line);

    // dots
    for (const pt of [from, to]) {
      const sGeo = new THREE.SphereGeometry(0.05, 8, 8);
      const sMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(color) });
      const s = new THREE.Mesh(sGeo, sMat);
      s.position.copy(pt);
      this._figGroup.add(s);
    }

    // label
    const mid = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5);
    const lbl = this._makeTextSprite(label, color, 34);
    lbl.position.copy(mid).add(new THREE.Vector3(0.3, 0.2, 0));
    lbl.scale.set(1.2, 0.4, 1);
    this._labelGroup.add(lbl);
  }

  /* ════════════════ ANGLE MEASUREMENT MODES ════════════════ */

  _pickNearestPoint(e) {
    const { mx, my } = this._screenCoords(e);
    let bestDist = 0.08;
    let bestPick = null;

    for (const v of this._vertices) {
      const projected = v.pos.clone().project(this.camera);
      const d = Math.sqrt((projected.x - mx) ** 2 + (projected.y - my) ** 2);
      if (d < bestDist) { bestDist = d; bestPick = { pos: v.pos.clone(), label: v.label }; }
    }
    for (const cp of this._customPoints) {
      const projected = cp.pos.clone().project(this.camera);
      const d = Math.sqrt((projected.x - mx) ** 2 + (projected.y - my) ** 2);
      if (d < bestDist) { bestDist = d; bestPick = { pos: cp.pos.clone(), label: cp.label }; }
    }
    return bestPick;
  }

  _pickNearestFace(e) {
    // Pick the face whose projected centroid is closest to click
    const { mx, my } = this._screenCoords(e);
    let bestDist = 0.15;
    let bestFace = null;
    let bestIdx = -1;

    for (let fi = 0; fi < this._faces.length; fi++) {
      const face = this._faces[fi];
      const c = new THREE.Vector3();
      face.forEach(v => c.add(v));
      c.divideScalar(face.length);
      const proj = c.clone().project(this.camera);
      const d = Math.sqrt((proj.x - mx) ** 2 + (proj.y - my) ** 2);
      if (d < bestDist) { bestDist = d; bestFace = face; bestIdx = fi; }
    }
    return bestFace;
  }

  _pickNearestEdge(e) {
    const { mx, my } = this._screenCoords(e);
    let bestDist = 0.06;
    let bestEdge = null;

    for (const edge of this._edges) {
      const p1 = edge.from.clone().project(this.camera);
      const p2 = edge.to.clone().project(this.camera);
      const mid = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
      const d = Math.sqrt((mid.x - mx) ** 2 + (mid.y - my) ** 2);
      if (d < bestDist) { bestDist = d; bestEdge = edge; }
    }
    return bestEdge;
  }

  _highlightPick(pos, color = 0xFFD166) {
    const sGeo = new THREE.SphereGeometry(0.12, 10, 10);
    const sMat = new THREE.MeshBasicMaterial({ color });
    const s = new THREE.Mesh(sGeo, sMat);
    s.position.copy(pos);
    this._angleGroup.add(s);
  }

  _onAngleClick(e) {
    if (!this._angleMode) return;

    if (this._angleMode === 'edge') {
      this._onEdgeAngleClick(e);
    } else if (this._angleMode === 'linePlane') {
      this._onLinePlaneAngleClick(e);
    } else if (this._angleMode === 'dihedral') {
      this._onDihedralAngleClick(e);
    } else if (this._angleMode === 'pointPlane') {
      this._onPointPlaneClick(e);
    } else if (this._angleMode === 'skewLines') {
      this._onSkewLinesClick(e);
    }
  }

  /* ── Skew lines: pick P1, P2 (line 1) then P3, P4 (line 2) ── */
  _onSkewLinesClick(e) {
    const pick = this._pickNearestPoint(e);
    if (!pick) return;

    const step = this._anglePicks.length;
    this._anglePicks.push(pick);

    const colors = [0xFFD166, 0xFFD166, 0x06D6E0, 0x06D6E0];
    this._highlightPick(pick.pos, colors[step] || 0xffffff);

    // After 2 picks: draw line 1
    if (this._anglePicks.length === 2) {
      const [P1, P2] = this._anglePicks;
      this._drawAngleLine(P1.pos, P2.pos, '#FFD166');
    }

    if (this._anglePicks.length < 4) return;

    const [P1, P2, P3, P4] = this._anglePicks;
    const d1 = new THREE.Vector3().subVectors(P2.pos, P1.pos);
    const d2 = new THREE.Vector3().subVectors(P4.pos, P3.pos);

    // Draw line 2
    this._drawAngleLine(P3.pos, P4.pos, '#06D6E0');

    // Angle between lines (acute)
    const cosA = Math.abs(d1.dot(d2)) / (d1.length() * d2.length());
    const angleDeg = Math.acos(Math.min(1, cosA)) * 180 / Math.PI;

    // Common perpendicular (distance between skew lines)
    const normal = new THREE.Vector3().crossVectors(d1, d2);
    let dist = 0;
    if (normal.length() > 1e-9) {
      // Lines are skew → project onto normal
      dist = Math.abs(new THREE.Vector3().subVectors(P3.pos, P1.pos).dot(normal.clone().normalize()));

      // Find foot points of common perpendicular
      const w  = new THREE.Vector3().subVectors(P1.pos, P3.pos);
      const b1 = d1.dot(d1), b2 = d2.dot(d2), d12 = d1.dot(d2);
      const det = b1 * b2 - d12 * d12;
      if (Math.abs(det) > 1e-9) {
        const t1 = (d12 * d2.dot(w) - b2 * d1.dot(w)) / det;
        const t2 = (b1 * d2.dot(w) - d12 * d1.dot(w)) / det;
        const Q1 = P1.pos.clone().addScaledVector(d1, t1);
        const Q2 = P3.pos.clone().addScaledVector(d2, t2);
        this._drawAngleLine(Q1, Q2, '#F9A8D4', true);
        this._highlightPick(Q1, 0xF9A8D4);
        this._highlightPick(Q2, 0xF9A8D4);
      }
    } else {
      // Parallel lines
      const cross = new THREE.Vector3().crossVectors(d1, new THREE.Vector3().subVectors(P3.pos, P1.pos));
      dist = cross.length() / d1.length();
    }

    // Label near midpoint between the two lines
    const mid = new THREE.Vector3().addVectors(P1.pos, P3.pos).multiplyScalar(0.5);
    mid.y += 0.5;
    const lbl = this._makeTextSprite(
      `∠ = ${angleDeg.toFixed(1)}°   d = ${dist.toFixed(2)}`, '#F9A8D4', 36
    );
    lbl.position.copy(mid);
    lbl.scale.set(2.8, 0.55, 1);
    this._angleGroup.add(lbl);

    this._anglePicks = [];
  }

  /* ── Edge angle: pick 3 points (B is vertex, angle ∠ABC) ── */
  _onEdgeAngleClick(e) {
    const pick = this._pickNearestPoint(e);
    if (!pick) return;

    this._anglePicks.push(pick);
    this._highlightPick(pick.pos, this._anglePicks.length === 2 ? 0xEF476F : 0xFFD166);

    if (this._anglePicks.length === 3) {
      const [A, B, C] = this._anglePicks;
      const BA = new THREE.Vector3().subVectors(A.pos, B.pos);
      const BC = new THREE.Vector3().subVectors(C.pos, B.pos);
      const cosAngle = BA.dot(BC) / (BA.length() * BC.length());
      const angle = Math.acos(Math.max(-1, Math.min(1, cosAngle))) * 180 / Math.PI;

      // Draw the angle arc
      this._drawAngleArc(B.pos, BA, BC, angle, 0.6, '#EF476F');

      // Label
      const bisect = new THREE.Vector3().addVectors(
        BA.clone().normalize(), BC.clone().normalize()
      ).normalize().multiplyScalar(0.8);
      const lblPos = B.pos.clone().add(bisect);
      const lbl = this._makeTextSprite(
        `∠${A.label}${B.label}${C.label} = ${angle.toFixed(1)}°`, '#EF476F', 36
      );
      lbl.position.copy(lblPos);
      lbl.scale.set(2.0, 0.5, 1);
      this._angleGroup.add(lbl);

      // Lines
      this._drawAngleLine(B.pos, A.pos, '#EF476F');
      this._drawAngleLine(B.pos, C.pos, '#EF476F');

      this._anglePicks = [];
    }
  }

  /* ── Line-Plane angle: pick 2 points (line), then 1 face ── */
  _onLinePlaneAngleClick(e) {
    if (this._anglePicks.length < 2) {
      // Picking line endpoints
      const pick = this._pickNearestPoint(e);
      if (!pick) return;
      this._anglePicks.push(pick);
      this._highlightPick(pick.pos);

      if (this._anglePicks.length === 2) {
        // Draw the line
        this._drawAngleLine(this._anglePicks[0].pos, this._anglePicks[1].pos, '#60a5fa');
      }
    } else {
      // Pick a face
      const face = this._pickNearestFace(e);
      if (!face || face.length < 3) return;

      const [A, B] = this._anglePicks;
      const lineDir = new THREE.Vector3().subVectors(B.pos, A.pos).normalize();

      // Face normal
      const v1 = new THREE.Vector3().subVectors(face[1], face[0]);
      const v2 = new THREE.Vector3().subVectors(face[2], face[0]);
      const normal = new THREE.Vector3().crossVectors(v1, v2).normalize();

      // Angle between line and plane = 90° - angle between line and normal
      const sinAngle = Math.abs(lineDir.dot(normal));
      const angle = Math.asin(Math.max(0, Math.min(1, sinAngle))) * 180 / Math.PI;

      // Highlight face
      const positions = [];
      const indices = [];
      face.forEach(v => positions.push(v.x, v.y, v.z));
      for (let i = 1; i < face.length - 1; i++) indices.push(0, i, i + 1);
      const geo = new THREE.BufferGeometry();
      geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
      geo.setIndex(indices);
      const mat = new THREE.MeshBasicMaterial({ color: 0x60a5fa, transparent: true, opacity: 0.2, side: THREE.DoubleSide });
      this._angleGroup.add(new THREE.Mesh(geo, mat));

      // Label at face centroid
      const c = new THREE.Vector3();
      face.forEach(v => c.add(v));
      c.divideScalar(face.length);
      const lbl = this._makeTextSprite(
        `∠(${A.label}${B.label}, пл) = ${angle.toFixed(1)}°`, '#60a5fa', 36
      );
      lbl.position.copy(c).add(new THREE.Vector3(0, 0.5, 0));
      lbl.scale.set(2.4, 0.5, 1);
      this._angleGroup.add(lbl);

      // Draw projection of line onto plane
      const proj = lineDir.clone().sub(normal.clone().multiplyScalar(lineDir.dot(normal))).normalize();
      const projEnd = A.pos.clone().add(proj.multiplyScalar(A.pos.distanceTo(B.pos)));
      this._drawAngleLine(A.pos, projEnd, '#60a5fa', true);

      this._anglePicks = [];
    }
  }

  /* ── Dihedral angle: pick 2 points of shared edge, then auto-find adjacent faces ── */
  _onDihedralAngleClick(e) {
    const pick = this._pickNearestPoint(e);
    if (!pick) return;

    this._anglePicks.push(pick);
    this._highlightPick(pick.pos, 0xc4b5fd);

    if (this._anglePicks.length === 2) {
      const [P1, P2] = this._anglePicks;
      const edgeDir = new THREE.Vector3().subVectors(P2.pos, P1.pos);

      // Find two faces sharing this edge (both contain P1 and P2)
      const adjFaces = [];
      const eps = 0.1;
      for (const face of this._faces) {
        let hasP1 = false, hasP2 = false;
        for (const v of face) {
          if (v.distanceTo(P1.pos) < eps) hasP1 = true;
          if (v.distanceTo(P2.pos) < eps) hasP2 = true;
        }
        if (hasP1 && hasP2) adjFaces.push(face);
      }

      if (adjFaces.length >= 2) {
        // Compute normals of both faces
        const normal1 = this._faceNormal(adjFaces[0]);
        const normal2 = this._faceNormal(adjFaces[1]);

        // Dihedral angle = π - angle between outward normals
        const cosAngle = normal1.dot(normal2);
        const rawAngle = Math.acos(Math.max(-1, Math.min(1, cosAngle))) * 180 / Math.PI;
        // Dihedral is the interior angle
        const dihedralAngle = 180 - rawAngle;

        // Highlight both faces
        for (const face of [adjFaces[0], adjFaces[1]]) {
          const positions = [];
          const indices = [];
          face.forEach(v => positions.push(v.x, v.y, v.z));
          for (let i = 1; i < face.length - 1; i++) indices.push(0, i, i + 1);
          const geo = new THREE.BufferGeometry();
          geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
          geo.setIndex(indices);
          const mat = new THREE.MeshBasicMaterial({ color: 0xc4b5fd, transparent: true, opacity: 0.2, side: THREE.DoubleSide });
          this._angleGroup.add(new THREE.Mesh(geo, mat));
        }

        // Label at edge midpoint
        const mid = new THREE.Vector3().addVectors(P1.pos, P2.pos).multiplyScalar(0.5);
        const lbl = this._makeTextSprite(
          `∠дв(${P1.label}${P2.label}) = ${dihedralAngle.toFixed(1)}°`, '#c4b5fd', 36
        );
        lbl.position.copy(mid).add(new THREE.Vector3(0, 0.5, 0.3));
        lbl.scale.set(2.4, 0.5, 1);
        this._angleGroup.add(lbl);

        // Draw the edge
        this._drawAngleLine(P1.pos, P2.pos, '#c4b5fd');
      } else {
        // Not enough adjacent faces — show error sprite
        const mid = new THREE.Vector3().addVectors(P1.pos, P2.pos).multiplyScalar(0.5);
        const lbl = this._makeTextSprite('Нет общего ребра', '#ff6b6b', 36);
        lbl.position.copy(mid).add(new THREE.Vector3(0, 0.5, 0));
        lbl.scale.set(2.0, 0.5, 1);
        this._angleGroup.add(lbl);
      }

      this._anglePicks = [];
    }
  }

  _faceNormal(face) {
    if (face.length < 3) return new THREE.Vector3(0, 1, 0);
    const v1 = new THREE.Vector3().subVectors(face[1], face[0]);
    const v2 = new THREE.Vector3().subVectors(face[2], face[0]);
    return new THREE.Vector3().crossVectors(v1, v2).normalize();
  }

  _drawAngleArc(center, dir1, dir2, angleDeg, radius, color) {
    const n1 = dir1.clone().normalize();
    const n2 = dir2.clone().normalize();
    const angleRad = angleDeg * Math.PI / 180;
    const steps = Math.max(8, Math.round(angleDeg / 5));
    const pts = [];

    // Build rotation from n1 toward n2
    const axis = new THREE.Vector3().crossVectors(n1, n2).normalize();
    if (axis.length() < 0.001) return; // parallel vectors

    for (let i = 0; i <= steps; i++) {
      const t = i / steps;
      const a = t * angleRad;
      const rotated = n1.clone().applyAxisAngle(axis, a).multiplyScalar(radius);
      pts.push(center.clone().add(rotated));
    }

    const geo = new THREE.BufferGeometry().setFromPoints(pts);
    const mat = new THREE.LineBasicMaterial({ color: new THREE.Color(color), transparent: true, opacity: 0.8 });
    this._angleGroup.add(new THREE.Line(geo, mat));
  }

  _drawAngleLine(from, to, color, dashed = false) {
    const geo = new THREE.BufferGeometry().setFromPoints([from, to]);
    let mat;
    if (dashed) {
      mat = new THREE.LineDashedMaterial({
        color: new THREE.Color(color), dashSize: 0.12, gapSize: 0.06,
        transparent: true, opacity: 0.7,
      });
    } else {
      mat = new THREE.LineBasicMaterial({ color: new THREE.Color(color), transparent: true, opacity: 0.7 });
    }
    const line = new THREE.Line(geo, mat);
    if (dashed) line.computeLineDistances();
    this._angleGroup.add(line);
  }

  /* ════════════════ DIAGONALS ════════════════ */

  _drawDiagonals() {
    if (!this.showDiagonals) return;
    const t = this.figureType;

    if (t === 'cube' || t === 'parallelepiped') {
      // 8 vertices: 0-3 bottom, 4-7 top (from _buildBox)
      const v = this._vertices.map(vt => vt.pos);
      if (v.length < 8) return;

      // Space diagonals (4)
      const spaceDiags = [[0,6],[1,7],[2,4],[3,5]];
      for (const [a, b] of spaceDiags) {
        this._drawDashedSegment(v[a], v[b], '', '#fbbf24');
      }

      // Face diagonals (12) — 2 per face
      const faceDiags = [
        [0,2],[1,3], // bottom
        [4,6],[5,7], // top
        [0,5],[1,4], // front
        [2,7],[3,6], // back
        [1,6],[2,5], // right
        [0,7],[3,4], // left
      ];
      for (const [a, b] of faceDiags) {
        const lineGeo = new THREE.BufferGeometry().setFromPoints([v[a], v[b]]);
        const lineMat = new THREE.LineDashedMaterial({
          color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06,
          transparent: true, opacity: 0.35,
        });
        const line = new THREE.Line(lineGeo, lineMat);
        line.computeLineDistances();
        this._figGroup.add(line);
      }

      // Space diagonal label — only one, the longest
      const d = v[0].distanceTo(v[6]);
      const mid = new THREE.Vector3().addVectors(v[0], v[6]).multiplyScalar(0.5);
      const lbl = this._makeTextSprite(`d = ${d.toFixed(2)}`, '#fbbf24', 34);
      lbl.position.copy(mid).add(new THREE.Vector3(0.3, 0.2, 0));
      lbl.scale.set(1.2, 0.4, 1);
      this._labelGroup.add(lbl);

    } else if (t === 'prism') {
      const n = this.params.n;
      const v = this._vertices.map(vt => vt.pos);
      if (v.length < n * 2) return;

      // Base diagonals (bottom)
      for (let i = 0; i < n; i++) {
        for (let j = i + 2; j < n; j++) {
          if (j === (i + n - 1) % n + i) continue; // skip adjacent
          if (i === 0 && j === n - 1) continue;
          const lineGeo = new THREE.BufferGeometry().setFromPoints([v[i], v[j]]);
          const lineMat = new THREE.LineDashedMaterial({
            color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06,
            transparent: true, opacity: 0.35,
          });
          const line = new THREE.Line(lineGeo, lineMat);
          line.computeLineDistances();
          this._figGroup.add(line);
        }
      }
      // Top diagonals
      for (let i = n; i < 2 * n; i++) {
        for (let j = i + 2; j < 2 * n; j++) {
          if (i === n && j === 2 * n - 1) continue;
          const lineGeo = new THREE.BufferGeometry().setFromPoints([v[i], v[j]]);
          const lineMat = new THREE.LineDashedMaterial({
            color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06,
            transparent: true, opacity: 0.35,
          });
          const line = new THREE.Line(lineGeo, lineMat);
          line.computeLineDistances();
          this._figGroup.add(line);
        }
      }
      // Space diagonals: connect bottom[i] to top[(i+k)%n] for k≠0
      for (let i = 0; i < n; i++) {
        for (let k = 1; k < n; k++) {
          const j = n + (i + k) % n;
          this._drawDashedSegment(v[i], v[j], '', '#fbbf24');
        }
      }

    } else if (t === 'pyramid') {
      // Base diagonals only
      const n = this.params.n;
      const v = this._vertices.map(vt => vt.pos);
      for (let i = 0; i < n; i++) {
        for (let j = i + 2; j < n; j++) {
          if (i === 0 && j === n - 1) continue;
          const lineGeo = new THREE.BufferGeometry().setFromPoints([v[i], v[j]]);
          const lineMat = new THREE.LineDashedMaterial({
            color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06,
            transparent: true, opacity: 0.4,
          });
          const line = new THREE.Line(lineGeo, lineMat);
          line.computeLineDistances();
          this._figGroup.add(line);
        }
      }
    }
  }

  /* ════════════════ MIDPOINTS ════════════════ */

  _drawMidpoints() {
    if (!this.showMidpoints) return;

    for (let i = 0; i < this._edges.length; i++) {
      const e = this._edges[i];
      const mid = new THREE.Vector3().addVectors(e.from, e.to).multiplyScalar(0.5);

      // small cyan sphere
      const sGeo = new THREE.SphereGeometry(0.06, 8, 8);
      const sMat = new THREE.MeshBasicMaterial({ color: 0x06D6E0 });
      const s = new THREE.Mesh(sGeo, sMat);
      s.position.copy(mid);
      this._figGroup.add(s);

      // label "M" + index
      const lbl = this._makeTextSprite(`M${i + 1}`, '#06D6E0', 28);
      lbl.position.copy(mid).add(new THREE.Vector3(0.15, 0.15, 0));
      lbl.scale.set(0.5, 0.25, 1);
      this._labelGroup.add(lbl);
    }
  }

  /* ════════════════ POINT-TO-PLANE DISTANCE ════════════════ */

  _onPointPlaneClick(e) {
    if (this._anglePicks.length < 1) {
      // First pick: a vertex/point
      const pick = this._pickNearestPoint(e);
      if (!pick) return;
      this._anglePicks.push(pick);
      this._highlightPick(pick.pos, 0xF9A8D4);
    } else {
      // Second pick: a face
      const face = this._pickNearestFace(e);
      if (!face || face.length < 3) return;

      const pt = this._anglePicks[0];
      const normal = this._faceNormal(face);
      const planePoint = face[0];

      // Distance = |dot(pt - planePoint, normal)|
      const diff = new THREE.Vector3().subVectors(pt.pos, planePoint);
      const dist = Math.abs(diff.dot(normal));

      // Projection of point onto plane
      const proj = pt.pos.clone().sub(normal.clone().multiplyScalar(diff.dot(normal)));

      // Draw perpendicular line
      this._drawAngleLine(pt.pos, proj, '#F9A8D4', true);

      // Highlight foot of perpendicular
      this._highlightPick(proj, 0xF9A8D4);

      // Right angle marker
      const edgeOnPlane = new THREE.Vector3().subVectors(face[1], face[0]).normalize();
      this._drawRightAngleMarkerAt(proj, normal, edgeOnPlane, 0.25, '#F9A8D4');

      // Highlight face
      const positions = [];
      const indices = [];
      face.forEach(v => positions.push(v.x, v.y, v.z));
      for (let i = 1; i < face.length - 1; i++) indices.push(0, i, i + 1);
      const geo = new THREE.BufferGeometry();
      geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
      geo.setIndex(indices);
      const mat = new THREE.MeshBasicMaterial({ color: 0xF9A8D4, transparent: true, opacity: 0.15, side: THREE.DoubleSide });
      this._angleGroup.add(new THREE.Mesh(geo, mat));

      // Label
      const mid = new THREE.Vector3().addVectors(pt.pos, proj).multiplyScalar(0.5);
      const lbl = this._makeTextSprite(`d(${pt.label}, пл) = ${dist.toFixed(2)}`, '#F9A8D4', 36);
      lbl.position.copy(mid).add(new THREE.Vector3(0.4, 0.2, 0));
      lbl.scale.set(2.2, 0.5, 1);
      this._angleGroup.add(lbl);

      this._anglePicks = [];
    }
  }

  _drawRightAngleMarkerAt(origin, normalDir, tangentDir, size, color) {
    const d1 = normalDir.clone().normalize().multiplyScalar(size);
    const d2 = tangentDir.clone().normalize().multiplyScalar(size);
    const p1 = origin.clone().add(d1);
    const p2 = origin.clone().add(d2);
    const p3 = p1.clone().add(d2);
    const pts = [p1, p3, p2];
    const geo = new THREE.BufferGeometry().setFromPoints(pts);
    const mat = new THREE.LineBasicMaterial({ color: new THREE.Color(color), transparent: true, opacity: 0.6 });
    this._angleGroup.add(new THREE.Line(geo, mat));
  }

  /* ════════════════ COORDINATE TOOLTIP ════════════════ */

  _initTooltip() {
    this._tooltipEl = document.createElement('div');
    Object.assign(this._tooltipEl.style, {
      position: 'absolute', pointerEvents: 'none',
      background: 'rgba(13,13,26,0.85)', color: '#ccc',
      fontSize: '11px', fontFamily: 'Manrope, monospace',
      padding: '3px 7px', borderRadius: '4px',
      border: '1px solid rgba(155,93,229,0.3)',
      display: 'none', zIndex: '50', whiteSpace: 'nowrap',
    });
    this.container.style.position = 'relative';
    this.container.appendChild(this._tooltipEl);
  }

  _onHoverMove(e) {
    const rect = this.renderer.domElement.getBoundingClientRect();
    if (e.clientX < rect.left || e.clientX > rect.right ||
        e.clientY < rect.top || e.clientY > rect.bottom) {
      if (this._tooltipEl) this._tooltipEl.style.display = 'none';
      return;
    }

    const mx = ((e.clientX - rect.left) / rect.width) * 2 - 1;
    const my = -((e.clientY - rect.top) / rect.height) * 2 + 1;

    let bestDist = 0.06;
    let bestV = null;

    for (const v of this._vertices) {
      const proj = v.pos.clone().project(this.camera);
      const d = Math.sqrt((proj.x - mx) ** 2 + (proj.y - my) ** 2);
      if (d < bestDist) { bestDist = d; bestV = v; }
    }
    for (const cp of this._customPoints) {
      const proj = cp.pos.clone().project(this.camera);
      const d = Math.sqrt((proj.x - mx) ** 2 + (proj.y - my) ** 2);
      if (d < bestDist) { bestDist = d; bestV = cp; }
    }

    if (bestV && this._tooltipEl) {
      const p = bestV.pos;
      this._tooltipEl.textContent = `${bestV.label} (${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)})`;
      this._tooltipEl.style.display = 'block';
      this._tooltipEl.style.left = (e.clientX - rect.left + 14) + 'px';
      this._tooltipEl.style.top  = (e.clientY - rect.top - 10) + 'px';
    } else if (this._tooltipEl) {
      this._tooltipEl.style.display = 'none';
    }
  }

  /* ════════════════ UNFOLD ANIMATION ════════════════ */
  // Simplified unfold: flatten figure by reducing Y coordinates
  _applyUnfold(progress) {
    // This is a visual-only effect — squash Y toward 0
    if (progress < 0.01) return;
    this._figGroup.children.forEach(child => {
      if (child.geometry) {
        // Already built, don't modify geometry — just scale Y
      }
    });
    this._figGroup.scale.y = 1 - progress * 0.85;
    this._figGroup.position.y = progress * 0.5;
  }

  /* ════════════════ UTILS ════════════════ */

  _clearGroup(group) {
    while (group.children.length) {
      const c = group.children[0];
      if (c.geometry) c.geometry.dispose();
      if (c.material) {
        if (c.material.map) c.material.map.dispose();
        c.material.dispose();
      }
      group.remove(c);
    }
  }

  /* ════════════════ EDGE MARKS ════════════════ */

  _pickNearestEdgeIdx(e) {
    const { mx, my } = this._screenCoords(e);
    let bestDist = 0.10;
    let bestIdx  = -1;
    for (let i = 0; i < this._edges.length; i++) {
      const edge = this._edges[i];
      const p1   = edge.from.clone().project(this.camera);
      const p2   = edge.to.clone().project(this.camera);
      // distance from click to edge midpoint in NDC
      const mx2  = (p1.x + p2.x) / 2;
      const my2  = (p1.y + p2.y) / 2;
      const d    = Math.sqrt((mx2 - mx) ** 2 + (my2 - my) ** 2);
      if (d < bestDist) { bestDist = d; bestIdx = i; }
    }
    return bestIdx;
  }

  _onMarkClick(e) {
    const idx = this._pickNearestEdgeIdx(e);
    if (idx < 0) return;
    if (!this._edgeMarks[idx]) this._edgeMarks[idx] = { ticks: 0, parallel: 0 };
    const m = this._edgeMarks[idx];
    if (this._markMode === 'ticks')    m.ticks    = (m.ticks    + 1) % 4;  // 0→1→2→3→0
    if (this._markMode === 'parallel') m.parallel = (m.parallel + 1) % 4;
    this._renderEdgeMarks();
  }

  _renderEdgeMarks() {
    this._clearGroup(this._markGroup);
    for (const [idxStr, mark] of Object.entries(this._edgeMarks)) {
      const idx = parseInt(idxStr);
      if (idx < 0 || idx >= this._edges.length) continue;
      const { from, to } = this._edges[idx];
      if (mark.ticks    > 0) this._drawEdgeTick3D(from, to, mark.ticks, '#FFD166');
      if (mark.parallel > 0) this._drawEdgeParallel3D(from, to, mark.parallel, '#06D6E0');
    }
  }

  _drawEdgeTick3D(from, to, count, color) {
    // Perpendicular ticks crossing the edge near its midpoint
    const dir  = new THREE.Vector3().subVectors(to, from).normalize();
    const up   = Math.abs(dir.y) < 0.9 ? new THREE.Vector3(0, 1, 0) : new THREE.Vector3(1, 0, 0);
    const perp = new THREE.Vector3().crossVectors(dir, up).normalize();
    const mid  = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5);
    const step = 0.22;
    const half = (count - 1) / 2;
    const mat  = new THREE.LineBasicMaterial({ color: new THREE.Color(color), linewidth: 2 });

    for (let i = 0; i < count; i++) {
      const offset = (i - half) * step;
      const center = mid.clone().add(dir.clone().multiplyScalar(offset));
      const p1 = center.clone().sub(perp.clone().multiplyScalar(0.18));
      const p2 = center.clone().add(perp.clone().multiplyScalar(0.18));
      const geo = new THREE.BufferGeometry().setFromPoints([p1, p2]);
      this._markGroup.add(new THREE.Line(geo, mat.clone()));
    }
  }

  _drawEdgeParallel3D(from, to, count, color) {
    // Chevron (arrow-head) marks indicating parallel edges
    const dir  = new THREE.Vector3().subVectors(to, from).normalize();
    const up   = Math.abs(dir.y) < 0.9 ? new THREE.Vector3(0, 1, 0) : new THREE.Vector3(1, 0, 0);
    const perp = new THREE.Vector3().crossVectors(dir, up).normalize();
    const mid  = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5);
    const step = 0.22;
    const half = (count - 1) / 2;
    const mat  = new THREE.LineBasicMaterial({ color: new THREE.Color(color), linewidth: 2 });

    for (let i = 0; i < count; i++) {
      const offset = (i - half) * step;
      const center = mid.clone().add(dir.clone().multiplyScalar(offset));
      // chevron: two lines meeting at a tip (along perp), base spread along dir
      const tip   = center.clone().add(perp.clone().multiplyScalar( 0.18));
      const base1 = center.clone().add(perp.clone().multiplyScalar(-0.10)).sub(dir.clone().multiplyScalar(0.14));
      const base2 = center.clone().add(perp.clone().multiplyScalar(-0.10)).add(dir.clone().multiplyScalar(0.14));
      const geo = new THREE.BufferGeometry().setFromPoints([base1, tip, base2]);
      this._markGroup.add(new THREE.Line(geo, mat.clone()));
    }
  }

  /* ════════════════ DERIVED 3D CONSTRUCTIONS ════════════════ */

  _addSolidCentroid() {
    if (!this._vertices.length) return;
    const c = new THREE.Vector3();
    this._vertices.forEach(v => c.add(v.pos));
    c.divideScalar(this._vertices.length);
    const n = this._derived3D.filter(d => d.type === 'point').length;
    this._derived3D.push({ type: 'point', pos: c.clone(), label: n ? 'G' + (n + 1) : 'G', color: '#9B5DE5' });
    this._buildDerived3D();
    this._deriveMode = null;
    this.renderer.domElement.style.cursor = 'grab';
  }

  _onDeriveClick(e) {
    if (this._deriveMode === 'midpoint') {
      const idx = this._pickNearestEdgeIdx(e);
      if (idx < 0) return;
      const { from, to } = this._edges[idx];
      const mid = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5);
      const n = this._derived3D.filter(d => d.type === 'point').length;
      this._derived3D.push({ type: 'point', pos: mid.clone(), label: n ? 'M' + (n + 1) : 'M', color: '#FFD166' });
      this._buildDerived3D();

    } else if (this._deriveMode === 'face_centroid') {
      const face = this._pickNearestFace(e);
      if (!face) return;
      const c = new THREE.Vector3();
      face.forEach(v => c.add(v));
      c.divideScalar(face.length);
      const n = this._derived3D.filter(d => d.type === 'point').length;
      this._derived3D.push({ type: 'point', pos: c.clone(), label: n ? 'O' + (n + 1) : 'O', color: '#A8E063' });
      this._buildDerived3D();

    } else if (this._deriveMode === 'alt_foot') {
      const pick = this._pickNearestPoint(e);
      if (!pick) return;
      this._derivePicks.push(pick);
      // Highlight first pick
      if (this._derivePicks.length === 1) {
        const sGeo = new THREE.SphereGeometry(0.14, 10, 10);
        const sMat = new THREE.MeshBasicMaterial({ color: 0xF15BB5 });
        const s = new THREE.Mesh(sGeo, sMat);
        s.position.copy(pick.pos);
        this._derivedGroup.add(s);
      } else if (this._derivePicks.length === 2) {
        // Vertex + base point: find foot of perpendicular from V onto the edge containing E
        const V = this._derivePicks[0].pos;
        const E = this._derivePicks[1].pos;
        const eps = 0.12;
        let foot = E.clone();
        for (const edge of this._edges) {
          if (edge.from.distanceTo(E) < eps || edge.to.distanceTo(E) < eps) {
            const d  = new THREE.Vector3().subVectors(edge.to, edge.from);
            const t  = new THREE.Vector3().subVectors(V, edge.from).dot(d) / d.lengthSq();
            foot = edge.from.clone().add(d.clone().multiplyScalar(Math.max(0, Math.min(1, t))));
            break;
          }
        }
        const n = this._derived3D.filter(d => d.type === 'point').length;
        this._derived3D.push({ type: 'point', pos: foot.clone(), label: n ? 'H' + (n + 1) : 'H', color: '#FF9F43' });
        this._derived3D.push({ type: 'line', from: V.clone(), to: foot.clone(), color: '#FF9F43', dashed: true });
        this._buildDerived3D();
        this._derivePicks = [];
      }
    }
  }

  _buildDerived3D() {
    this._clearGroup(this._derivedGroup);
    for (const d of this._derived3D) {
      if (d.type === 'point') {
        const sGeo = new THREE.SphereGeometry(0.12, 12, 12);
        const sMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(d.color || '#FFD166') });
        const s    = new THREE.Mesh(sGeo, sMat);
        s.position.copy(d.pos);
        this._derivedGroup.add(s);
        if (d.label) {
          const sprite = this._makeTextSprite(d.label, d.color || '#FFD166', 52);
          sprite.position.copy(d.pos).add(new THREE.Vector3(0.18, 0.25, 0));
          sprite.scale.set(1.0, 0.45, 1);
          this._derivedGroup.add(sprite);
        }
      } else if (d.type === 'line') {
        const pts = [d.from, d.to];
        const geo = new THREE.BufferGeometry().setFromPoints(pts);
        let mat;
        if (d.dashed) {
          mat = new THREE.LineDashedMaterial({
            color: new THREE.Color(d.color || '#FFD166'),
            dashSize: 0.1, gapSize: 0.07, transparent: true, opacity: 0.85,
          });
        } else {
          mat = new THREE.LineBasicMaterial({ color: new THREE.Color(d.color || '#FFD166'), transparent: true, opacity: 0.85 });
        }
        const line = new THREE.Line(geo, mat);
        if (d.dashed) line.computeLineDistances();
        this._derivedGroup.add(line);
      }
    }
  }

  _notify() {
    if (this.onUpdate) this.onUpdate(this.info());
  }

  /* ════════════════ ANIMATION LOOP ════════════════ */

  _loop() {
    if (!this._running) return;
    requestAnimationFrame(() => this._loop());

    // Auto-spin after idle
    this._idleTime++;
    if (this._idleTime > 300 && !this._drag) this._autoSpin = true;
    if (this._autoSpin) this._rotY += 0.002;

    // Unfold animation
    if (this._unfold && this._unfoldProgress < this._unfoldTarget) {
      this._unfoldProgress = Math.min(1, this._unfoldProgress + 0.015);
      this._applyUnfold(this._unfoldProgress);
    } else if (!this._unfold && this._unfoldProgress > 0) {
      this._unfoldProgress = Math.max(0, this._unfoldProgress - 0.015);
      this._applyUnfold(this._unfoldProgress);
      if (this._unfoldProgress <= 0) {
        this._figGroup.scale.y = 1;
        this._figGroup.position.y = 0;
      }
    }

    // Camera orbit
    this.camera.position.set(
      this._dist * Math.sin(this._rotY) * Math.cos(this._rotX),
      this._dist * Math.sin(this._rotX),
      this._dist * Math.cos(this._rotY) * Math.cos(this._rotX)
    );
    this.camera.lookAt(0, this._figureHeight() / 2, 0);

    this.renderer.render(this.scene, this.camera);
  }
}

/* ─── lab UI init ─────────────────────────────────── */
  var stereoSim = null;

  // which params are relevant per figure type
  const STEREO_PARAM_MAP = {
    cube:           ['a'],
    parallelepiped: ['a','b','c'],
    pyramid:        ['a','n','h'],
    tetrahedron:    ['a'],
    cylinder:       ['r','h'],
    cone:           ['r','h'],
    trunccone:      ['R','r','h'],
    sphere:         ['r'],
    prism:          ['a','n','h'],
    truncpyramid:   ['a','b','n','h'],
    octahedron:     ['a'],
    icosahedron:    ['a'],
    dodecahedron:   ['a'],
  };

  function _openStereo() {
    document.getElementById('sim-topbar-title').textContent = 'Стереометрия 3D';
    _simShow('sim-stereo');
    document.getElementById('stereo-stats').style.display = '';
    requestAnimationFrame(() => requestAnimationFrame(() => {
      if (!stereoSim) {
        stereoSim = new StereoSim(document.getElementById('stereo-container'));
        stereoSim.onUpdate = _stereoUpdateUI;
      } else {
        stereoSim.fit();
        stereoSim.play();
      }
      _stereoShowParams(stereoSim.figureType || 'cube');
      _stereoUpdateUI(stereoSim.info());
      _stereoUpdateFormulas();
    }));
  }

  function setStereoFigure(type, btn) {
    document.querySelectorAll('.stereo-fig-btn').forEach(b => b.classList.remove('active'));
    if (btn) btn.classList.add('active');
    if (stereoSim) {
      stereoSim.setFigure(type);
      _stereoShowParams(type);
      _stereoUpdateFormulas();
      if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.2, volume: 0.3 });
      // reset toggles and tool buttons
      document.getElementById('sect-toggle').classList.remove('active');
      document.getElementById('stereo-unfold-btn').classList.remove('active');
      document.getElementById('stereo-measure-btn').classList.remove('active');
      // reset element toggles
      ['stg-height','stg-apothem','stg-diagonals','stg-midpoints','stg-inscribed','stg-circumscribed','stg-edgelengths'].forEach(id => {
        document.getElementById(id)?.classList.remove('on');
      });
      _stereoDeactivateTools();
    }
  }

  function _stereoShowParams(type) {
    const show = STEREO_PARAM_MAP[type] || ['a'];
    ['a','b','c','h','r','R','n'].forEach(k => {
      document.getElementById('sp-' + k + '-row').style.display = show.includes(k) ? '' : 'none';
    });
  }

  function stereoParamChange(key, val) {
    val = +val;
    const label = document.getElementById('sp-' + key + '-val');
    if (label) label.textContent = val;
    if (stereoSim) {
      stereoSim.setParam(key, val);
      _stereoUpdateFormulas();
    }
  }

  function stereoOpacityChange(val) {
    val = +val;
    document.getElementById('sp-opacity-val').textContent = val.toFixed(2);
    if (stereoSim) stereoSim.setOpacity(val);
  }

  // legacy (used nowhere now but kept for safety)
  function stereoToggle(layer, btn) {
    const on = !btn.classList.contains('active');
    btn.classList.toggle('active', on);
    if (!stereoSim) return;
    if (layer === 'edges')    stereoSim.toggleEdges(on);
    if (layer === 'vertices') stereoSim.toggleVertices(on);
    if (layer === 'labels')   stereoSim.toggleLabels(on);
    if (layer === 'axes')     stereoSim.toggleAxes(on);
    if (layer === 'grid')     stereoSim.toggleGrid(on);
  }

  // new toggle-row style
  function stereoToggleSt(layer, toggle) {
    const on = !toggle.classList.contains('on');
    toggle.classList.toggle('on', on);
    if (!stereoSim) return;
    if (layer === 'edges')    stereoSim.toggleEdges(on);
    if (layer === 'vertices') stereoSim.toggleVertices(on);
    if (layer === 'labels')   stereoSim.toggleLabels(on);
    if (layer === 'axes')     stereoSim.toggleAxes(on);
    if (layer === 'grid')     stereoSim.toggleGrid(on);
  }

  function stereoToggleElem(layer, toggle) {
    const on = !toggle.classList.contains('on');
    toggle.classList.toggle('on', on);
    if (!stereoSim) return;
    if (layer === 'height')        stereoSim.toggleHeight(on);
    if (layer === 'apothem')       stereoSim.toggleApothem(on);
    if (layer === 'diagonals')     stereoSim.toggleDiagonals(on);
    if (layer === 'midpoints')     stereoSim.toggleMidpoints(on);
    if (layer === 'inscribed')     stereoSim.toggleInscribed(on);
    if (layer === 'circumscribed') stereoSim.toggleCircumscribed(on);
    if (layer === 'edgelengths')   stereoSim.toggleEdgeLengths(on);
  }

  // n-stepper for prism/pyramid
  function stereoNChange(delta) {
    if (!stereoSim) return;
    const cur = stereoSim.params.n || 4;
    const nv = Math.max(3, Math.min(12, cur + delta));
    document.getElementById('sp-n-val').textContent = nv;
    stereoSim.setParam('n', nv);
    _stereoUpdateFormulas();
  }

  function stereoSectionToggle(btn) {
    const on = !btn.classList.contains('active');
    btn.classList.toggle('active', on);
    if (stereoSim) stereoSim.toggleSection(on);
  }

  function stereoSectionType(t, btn) {
    document.querySelectorAll('.stereo-sect-type').forEach(b => b.classList.remove('active'));
    btn.classList.add('active');
    // Show/hide angle slider for diagonal
    document.getElementById('sp-angle-row').style.display = t === 'diagonal' ? '' : 'none';
    if (stereoSim) stereoSim.setSectionType(t);
  }

  function stereoSectionHeight(val) {
    document.getElementById('sp-sect-val').textContent = val + '%';
    if (stereoSim) stereoSim.setSectionHeight(+val / 100);
  }

  function stereoSectionAngle(val) {
    document.getElementById('sp-angle-val').textContent = val + '%';
    if (stereoSim) stereoSim.setSectionAngle(+val / 100);
  }

  function stereoUnfold(btn) {
    const on = !btn.classList.contains('active');
    btn.classList.toggle('active', on);
    if (stereoSim) stereoSim.toggleUnfold(on);
  }

  function _stereoDeactivateTools() {
    ['stereo-measure-btn','stereo-point-btn','stereo-connect-btn',
     'stereo-angle-edge-btn','stereo-angle-lp-btn','stereo-angle-dih-btn','stereo-angle-pp-btn','stereo-angle-skew-btn',
     'stereo-mark-tick-btn','stereo-mark-par-btn',
     'stereo-derive-mid-btn','stereo-derive-fc-btn','stereo-derive-alt-btn','stereo-derive-cen-btn',
     'stereo-sect3p-btn'].forEach(id => {
      document.getElementById(id)?.classList.remove('active');
    });
    if (stereoSim) {
      stereoSim.toggleMeasure(false);
      stereoSim.togglePointMode(false);
      stereoSim.toggleConnectMode(false);
      stereoSim.setAngleMode(null);
      stereoSim.setMarkMode(null);
      stereoSim.setDeriveMode(null);
      stereoSim.toggleSection3P(false);
    }
    const hint = document.getElementById('angle-hint');
    if (hint) hint.textContent = '';
  }

  function stereoMeasure(btn) {
    const on = !btn.classList.contains('active');
    _stereoDeactivateTools();
    btn.classList.toggle('active', on);
    if (stereoSim) stereoSim.toggleMeasure(on);
  }

  function stereoMeasureUndo() {
    if (stereoSim) stereoSim.removeLastMeasurement();
  }

  function stereoMeasureClear() {
    if (stereoSim) stereoSim.clearMeasurements();
  }

  function stereoToggleHeight(btn) {
    const on = !btn.classList.contains('active');
    btn.classList.toggle('active', on);
    if (stereoSim) stereoSim.toggleHeight(on);
  }

  function stereoToggleApothem(btn) {
    const on = !btn.classList.contains('active');
    btn.classList.toggle('active', on);
    if (stereoSim) stereoSim.toggleApothem(on);
  }

  function stereoToggleDiag(btn) {
    const on = !btn.classList.contains('active');
    btn.classList.toggle('active', on);
    if (stereoSim) stereoSim.toggleDiagonals(on);
  }

  function stereoToggleMid(btn) {
    const on = !btn.classList.contains('active');
    btn.classList.toggle('active', on);
    if (stereoSim) stereoSim.toggleMidpoints(on);
  }

  const ANGLE_HINTS = {
    edge: 'Кликните 3 точки: A, B (вершина угла), C',
    linePlane: 'Кликните 2 точки (прямая), затем — грань',
    dihedral: 'Кликните 2 точки общего ребра двух граней',
    pointPlane: 'Кликните точку, затем — грань',
    skewLines: 'P1, P2 (прямая 1) → P3, P4 (прямая 2): угол и расстояние',
  };

  function stereoAngleMode(mode, btn) {
    const on = !btn.classList.contains('active');
    _stereoDeactivateTools();
    btn.classList.toggle('active', on);
    if (stereoSim) stereoSim.setAngleMode(on ? mode : null);
    const hint = document.getElementById('angle-hint');
    if (hint) hint.textContent = on ? ANGLE_HINTS[mode] : '';
  }

  function stereoAngleClear() {
    _stereoDeactivateTools();
    if (stereoSim) {
      stereoSim.setAngleMode(null);
      stereoSim._clearGroup(stereoSim._angleGroup);
    }
  }

  /* ── Edge marks ── */
  function stereoMarkMode(mode, btn) {
    const on = !btn.classList.contains('active');
    _stereoDeactivateTools();
    btn.classList.toggle('active', on);
    if (stereoSim) stereoSim.setMarkMode(on ? mode : null);
  }

  function stereoMarkClear() {
    _stereoDeactivateTools();
    if (stereoSim) stereoSim.clearMarks();
  }

  function stereoToggleEdgeLengths(btn) {
    const on = !btn.classList.contains('active');
    btn.classList.toggle('active', on);
    if (stereoSim) stereoSim.toggleEdgeLengths(on);
  }

  /* ── Derived points ── */
  function stereoDerive(mode, btn) {
    const on = !btn.classList.contains('active');
    _stereoDeactivateTools();
    btn.classList.toggle('active', on);
    if (stereoSim) stereoSim.setDeriveMode(on ? mode : null);
  }

  function stereoDeriveUndo() {
    if (stereoSim) stereoSim.removeLastDerived();
  }

  function stereoDeriveClear() {
    _stereoDeactivateTools();
    if (stereoSim) stereoSim.clearDerived();
  }

  function stereoPointMode(btn) {
    const on = !btn.classList.contains('active');
    _stereoDeactivateTools();
    btn.classList.toggle('active', on);
    if (stereoSim) stereoSim.togglePointMode(on);
  }

  function stereoConnectMode(btn) {
    const on = !btn.classList.contains('active');
    _stereoDeactivateTools();
    btn.classList.toggle('active', on);
    if (stereoSim) stereoSim.toggleConnectMode(on);
  }

  function stereoUndoPoint() {
    if (stereoSim) stereoSim.removeLastPoint();
  }

  function stereoClearPoints() {
    if (stereoSim) stereoSim.clearCustomPoints();
    _stereoUpdatePointsInfo();
  }

  /* ── Section through 3 points UI ── */
  function stereoSection3P(btn) {
    const on = !btn.classList.contains('active');
    _stereoDeactivateTools();
    btn.classList.toggle('active', on);
    if (stereoSim) stereoSim.toggleSection3P(on);
    const hint = document.getElementById('sect3p-hint');
    if (hint) hint.textContent = on ? 'Кликните 3 точки на рёбрах или вершинах' : '';
    if (on) _stereoUpdateSection3PPanel();
  }

  function stereoSection3PClear() {
    if (stereoSim) stereoSim.clearSection3P();
    _stereoUpdateSection3PPanel();
  }

  function stereoSection3PStepBy(toggle) {
    const on = !toggle.classList.contains('on');
    toggle.classList.toggle('on', on);
    if (stereoSim) stereoSim.toggleSection3PStepBy(on);
  }

  function stereoSection3PNextStep() {
    if (!stereoSim) return;
    const max = stereoSim._section3PData ? 6 : stereoSim._section3PPicks.length;
    stereoSim._section3PStep = Math.min(stereoSim._section3PStep + 1, max);
    stereoSim._drawSection3P();
  }

  function stereoSection3PPrevStep() {
    if (!stereoSim) return;
    stereoSim._section3PStep = Math.max(0, stereoSim._section3PStep - 1);
    stereoSim._drawSection3P();
  }

  function _stereoUpdateSection3PPanel() {
    const panel = document.getElementById('sect3p-info');
    if (!panel) return;
    if (!stereoSim) { panel.innerHTML = ''; return; }
    const data  = stereoSim.getSection3PInfo();
    const picks = stereoSim._section3PPicks;
    if (!data && picks.length === 0) { panel.innerHTML = ''; return; }

    const r = v => Math.round(v * 100) / 100;
    const fmtV = v => `(${r(v.x)}, ${r(v.y)}, ${r(v.z)})`;
    const lines = [];

    picks.forEach((p, i) => lines.push(`<div style="color:#FFD166">P${i+1} = ${fmtV(p)}</div>`));

    if (data) {
      const { normal: n, D, typeName, area } = data;
      const A = r(n.x), B = r(n.y), C = r(n.z), Dv = r(D);
      const eq = `${A}x + ${B}y + ${C}z ${Dv >= 0 ? '+' : ''}${Dv} = 0`;
      lines.push(`<div style="color:#EF476F;margin-top:4px">Плоскость: ${eq}</div>`);
      lines.push(`<div style="color:#7BF5A4">Сечение: <b>${typeName}</b></div>`);
      if (area > 0) lines.push(`<div style="color:#7BF5A4">S = ${r(area)}</div>`);
    } else if (picks.length < 3) {
      lines.push(`<div style="color:rgba(255,255,255,0.35)">Выбрано точек: ${picks.length}/3</div>`);
    }

    panel.innerHTML = lines.join('');
  }

  function stereoInscribed(btn) {
    const on = !btn.classList.contains('active');
    btn.classList.toggle('active', on);
    if (stereoSim) stereoSim.toggleInscribed(on);
  }

  function stereoCircumscribed(btn) {
    const on = !btn.classList.contains('active');
    btn.classList.toggle('active', on);
    if (stereoSim) stereoSim.toggleCircumscribed(on);
  }

  function _stereoUpdateFormulas() {
    if (!stereoSim) return;
    const f = stereoSim.getFormulas();
    const el = document.getElementById('stereo-formulas');
    if (!f || !f.formulas) { el.innerHTML = ''; return; }
    const colors = ['#7BF5A4','#60a5fa','#c4b5fd','#fbbf24','#f9a8d4','#F59E0B','#EF476F'];
    el.innerHTML = f.formulas.map((s, i) =>
      '<div style="color:' + (colors[i % colors.length]) + '">' + s + '</div>'
    ).join('');
  }

  function _stereoUpdateUI(info) {
    if (!info) return;
    document.getElementById('stbar-vol').textContent  = info.V !== undefined ? info.V.toFixed(2) : '—';
    document.getElementById('stbar-area').textContent  = info.S !== undefined ? info.S.toFixed(2) : '—';
    document.getElementById('stbar-side').textContent  = info.S_side !== undefined ? info.S_side.toFixed(2) : '—';
    document.getElementById('stbar-h').textContent     = info.h !== undefined ? info.h.toFixed(2) : '—';
    document.getElementById('stbar-d').textContent     = info.d !== undefined && info.d > 0 ? info.d.toFixed(2) : '—';

    // Section area
    const sectEl = document.getElementById('sect-area-display');
    if (info.sectionArea && info.sectionArea > 0) {
      sectEl.style.display = '';
      sectEl.textContent = 'S сечения = ' + info.sectionArea.toFixed(2);
    } else {
      sectEl.style.display = 'none';
    }

    // Inscribed / Circumscribed radius info
    const rInfo = document.getElementById('sphere-radius-info');
    if (rInfo) {
      const parts = [];
      if (info.inscribedR != null) parts.push('r_вп = ' + info.inscribedR.toFixed(2));
      if (info.circumscribedR != null) parts.push('R_оп = ' + info.circumscribedR.toFixed(2));
      rInfo.textContent = parts.join('  ·  ');
      rInfo.style.display = parts.length ? '' : 'none';
    }

    // Points info
    _stereoUpdatePointsInfo(info);

    // Section-3P panel
    _stereoUpdateSection3PPanel();
  }

  function _stereoUpdatePointsInfo(info) {
    const el = document.getElementById('points-info');
    if (!el) return;
    if (!info) info = stereoSim?.info();
    if (!info) { el.textContent = ''; return; }
    let txt = '';
    if (info.customPoints > 0) txt += `Точек: ${info.customPoints}`;
    if (info.connections > 0) txt += ` · Линий: ${info.connections}`;
    el.textContent = txt;
  }

