/* ═══════════════════════════════════════════════════════════════════════
   geometry.js — Интерактивная планиметрия для LearnSpace
   Phase 1: точки, отрезки, прямые, лучи, окружности, многоугольники
   Phase 2: инструменты построения (середина, биссектрисы, параллельные,
            перпендикуляры, пересечения) + система производных объектов
   ═══════════════════════════════════════════════════════════════════════ */
'use strict';

/* ── Утилиты ─────────────────────────────────────────────────────────── */
function gDist(a, b)   { return Math.hypot(b.x - a.x, b.y - a.y); }
function gMid(a, b)    { return { x: (a.x+b.x)/2, y: (a.y+b.y)/2 }; }
function gDot(a, b)    { return a.x*b.x + a.y*b.y; }
function gNorm(v)      { const l = Math.hypot(v.x,v.y); return l<1e-12?{x:0,y:0}:{x:v.x/l,y:v.y/l}; }
function gSub(a, b)    { return { x: a.x-b.x, y: a.y-b.y }; }
function gAdd(a, b)    { return { x: a.x+b.x, y: a.y+b.y }; }
function gScale(v, s)  { return { x: v.x*s, y: v.y*s }; }

/** Проекция точки P на прямую через L1-L2 */
function gFoot(P, L1, L2) {
  const dx = L2.x-L1.x, dy = L2.y-L1.y;
  const l2 = dx*dx + dy*dy;
  if (l2 < 1e-12) return { x: L1.x, y: L1.y };
  const t = ((P.x-L1.x)*dx + (P.y-L1.y)*dy) / l2;
  return { x: L1.x + t*dx, y: L1.y + t*dy };
}

/** Расстояние от точки до прямой через L1-L2 */
function gDistToLine(P, L1, L2) {
  return gDist(P, gFoot(P, L1, L2));
}

/** Расстояние от точки до отрезка L1-L2 */
function gDistToSegment(P, L1, L2) {
  const dx = L2.x-L1.x, dy = L2.y-L1.y;
  const l2 = dx*dx + dy*dy;
  if (l2 < 1e-12) return gDist(P, L1);
  const t = Math.max(0, Math.min(1, ((P.x-L1.x)*dx+(P.y-L1.y)*dy)/l2));
  return gDist(P, { x: L1.x+t*dx, y: L1.y+t*dy });
}

/** Пересечение двух прямых (через A1-A2 и B1-B2) */
function gIntersectLines(A1, A2, B1, B2) {
  const dx1 = A2.x-A1.x, dy1 = A2.y-A1.y;
  const dx2 = B2.x-B1.x, dy2 = B2.y-B1.y;
  const det = dx1*dy2 - dy1*dx2;
  if (Math.abs(det) < 1e-10) return null;
  const t = ((B1.x-A1.x)*dy2 - (B1.y-A1.y)*dx2) / det;
  return { x: A1.x + t*dx1, y: A1.y + t*dy1 };
}

/** Описанная окружность треугольника */
function gCircumcircle(A, B, C) {
  const D = 2*(A.x*(B.y-C.y) + B.x*(C.y-A.y) + C.x*(A.y-B.y));
  if (Math.abs(D) < 1e-10) return null;
  const a2=A.x*A.x+A.y*A.y, b2=B.x*B.x+B.y*B.y, c2=C.x*C.x+C.y*C.y;
  const cx = (a2*(B.y-C.y)+b2*(C.y-A.y)+c2*(A.y-B.y))/D;
  const cy = (a2*(C.x-B.x)+b2*(A.x-C.x)+c2*(B.x-A.x))/D;
  return { cx, cy, r: gDist({x:cx,y:cy}, A) };
}

/** Вписанная окружность треугольника */
function gIncircle(A, B, C) {
  const a = gDist(B, C), b = gDist(A, C), c = gDist(A, B);
  const s = a + b + c;
  if (s < 1e-12) return null;
  const cx = (a*A.x + b*B.x + c*C.x) / s;
  const cy = (a*A.y + b*B.y + c*C.y) / s;
  const area = gPolygonArea([A, B, C]);
  const r = area / (s / 2);
  return { cx, cy, r };
}

/** Точки касания двух касательных из внешней точки P к окружности (O, r) */
function gTangentPoints(O, P, r) {
  const d = gDist(O, P);
  if (d <= r + 1e-9) return null; // P внутри или на окружности
  const t   = (r * r) / d;                  // |OM|, M = нога с O на хорду касания
  const h   = r * Math.sqrt(d * d - r * r) / d; // |T₁M| = |T₂M|
  const vx  = (P.x - O.x) / d, vy = (P.y - O.y) / d; // O→P
  const px  = -vy, py = vx;                 // перпендикуляр
  const M   = { x: O.x + vx * t, y: O.y + vy * t };
  return [
    { x: M.x + px * h, y: M.y + py * h },
    { x: M.x - px * h, y: M.y - py * h },
  ];
}

/** Угол ABC (в градусах, вершина B) */
function gAngleDeg(A, B, C) {
  const v1 = gSub(A, B), v2 = gSub(C, B);
  const cos = gDot(v1, v2) / (gDist(A,B) * gDist(C,B));
  return Math.acos(Math.max(-1, Math.min(1, cos))) * 180 / Math.PI;
}

/** Ортоцентр треугольника ABC */
function gOrthocenter(A, B, C) {
  const Ha = gFoot(A, B, C); // основание высоты из A на BC
  const Hb = gFoot(B, A, C); // основание высоты из B на AC
  return gIntersectLines(A, Ha, B, Hb);
}

/** Площадь многоугольника (формула Гаусса) */
function gPolygonArea(pts) {
  let area = 0;
  for (let i = 0, n = pts.length; i < n; i++) {
    const j = (i+1)%n;
    area += pts[i].x * pts[j].y - pts[j].x * pts[i].y;
  }
  return Math.abs(area) / 2;
}

/* ── Viewport (система координат) ───────────────────────────────────── */
class GeoViewport {
  constructor() {
    this.cx = 0;   // центр в мат. координатах
    this.cy = 0;
    this.scale = 60; // пикселей на единицу
    this.W = 800;
    this.H = 600;
  }

  /** Математические → пиксельные */
  toCanvas(mx, my) {
    return {
      x: this.W/2 + (mx - this.cx) * this.scale,
      y: this.H/2 - (my - this.cy) * this.scale,
    };
  }

  /** Пиксельные → математические */
  toMath(px, py) {
    return {
      x: (px - this.W/2) / this.scale + this.cx,
      y: -((py - this.H/2) / this.scale) + this.cy,
    };
  }

  zoom(factor, px, py) {
    const m = this.toMath(px, py);
    this.scale = Math.max(10, Math.min(400, this.scale * factor));
    // Зафиксировать точку под курсором
    this.cx = m.x - (px - this.W/2) / this.scale;
    this.cy = m.y + (py - this.H/2) / this.scale;
  }

  pan(dpx, dpy) {
    this.cx -= dpx / this.scale;
    this.cy += dpy / this.scale;
  }

  /** Расстояние в пикселях между двумя мат. точками */
  toCanvasDist(md) { return md * this.scale; }

  /** Пиксельное расстояние → мат. единицы */
  toMathDist(pd) { return pd / this.scale; }
}

/* ── GeoEngine (хранилище объектов) ─────────────────────────────────── */
class GeoEngine {
  constructor() {
    this._objects = new Map();
    this._counter = 0;
  }

  _newId() { return 'g' + (++this._counter); }

  add(obj) {
    if (!obj.id) obj.id = this._newId();
    obj.style = obj.style || {};
    this._objects.set(obj.id, obj);
    return obj;
  }

  remove(id) {
    // BFS-каскад: собрать все транзитивно зависимые объекты, затем удалить
    const toDelete = new Set([id]);
    let changed = true;
    while (changed) {
      changed = false;
      for (const [oid, obj] of this._objects) {
        if (toDelete.has(oid)) continue;
        for (const did of toDelete) {
          if (this._dependsOn(obj, did)) { toDelete.add(oid); changed = true; break; }
        }
      }
    }
    for (const oid of toDelete) this._objects.delete(oid);
  }

  _dependsOn(obj, id) {
    switch (obj.type) {
      case 'segment':
        // Виртуальный отрезок-сторона полигона зависит от самого полигона
        if (obj.virtual && obj.polyId === id) return true;
        return obj.p1Id === id || obj.p2Id === id;
      case 'line': case 'ray':
        return obj.p1Id === id || obj.p2Id === id;
      case 'polygon':
        return obj.pointIds.includes(id);
      case 'circle':
        if (obj.derived) return obj.ptA === id || obj.ptB === id || obj.ptC === id;
        return obj.centerId === id || obj.edgeId === id;
      case 'point':
        if (!obj.derived) return false;
        if (obj.constr === 'midpoint')      return obj.srcA === id || obj.srcB === id;
        if (obj.constr === 'intersect')     return obj.src1 === id || obj.src2 === id;
        if (obj.constr === 'centroid' || obj.constr === 'orthocenter')
          return obj.ptA === id || obj.ptB === id || obj.ptC === id;
        if (obj.constr === 'altitude_foot')   return obj.ptA === id || obj.ptB === id || obj.ptC === id;
        if (obj.constr === 'parallelogram_d') return obj.ptA === id || obj.ptB === id || obj.ptC === id;
        if (obj.constr === 'scale') return obj.srcO === id || obj.srcPt === id;
        if (obj.constr === 'foot' || obj.constr === 'reflect') {
          if (obj.srcPt === id || obj.srcLine === id) return true;
          // Если srcLine — обычная прямая, зависим и от её точек
          const sl = this._objects.get(obj.srcLine);
          return !!(sl && (sl.p1Id === id || sl.p2Id === id));
        }
        if (obj.constr === 'ngon_vertex') return obj.srcCenter === id || obj.srcVertex === id;
        if (obj.constr === 'translate')   return obj.srcA === id || obj.srcB === id || obj.srcPt === id;
        if (obj.constr === 'on_segment') {
          if (obj.srcSeg === id) return true;
          // зависим и от перемещения конечных точек отрезка
          const seg = this._objects.get(obj.srcSeg);
          return !!(seg && (seg.p1Id === id || seg.p2Id === id));
        }
        if (obj.constr === 'on_circle') {
          if (obj.srcCircle === id) return true;
          // если id — точка, задающая окружность, зависим транзитивно через саму окружность
          const circ = this._objects.get(obj.srcCircle);
          if (!circ) return false;
          if (circ.derived) return circ.ptA === id || circ.ptB === id || circ.ptC === id;
          return circ.centerId === id || circ.edgeId === id;
        }
        return false;
      case 'derived_line':
        switch (obj.constr) {
          case 'perpbisect':    return obj.srcA === id || obj.srcB === id;
          case 'anglebisect':   return obj.srcA === id || obj.srcVtx === id || obj.srcB === id;
          case 'parallel': case 'perpendicular':
            return obj.srcPt === id || obj.srcDirPt1 === id || obj.srcDirPt2 === id;
          case 'tangent': {
            if (obj.srcPt === id || obj.srcCircle === id) return true;
            // Зависим и от точек, определяющих окружность
            const circ = this._objects.get(obj.srcCircle);
            if (!circ) return false;
            if (circ.derived) return circ.ptA === id || circ.ptB === id || circ.ptC === id;
            return circ.centerId === id || circ.edgeId === id;
          }
        }
        return false;
      case 'measure_length':
        return obj.srcSeg === id;
      case 'measure_angle':
        return obj.srcA === id || obj.srcVtx === id || obj.srcB === id;
      case 'measure_area':
        return obj.srcPoly === id;
      case 'locus':
        return obj.srcMover === id || obj.srcTarget === id;
    }
    return false;
  }

  /* Перевычислить производный объект из его источников */
  recompute(id) {
    const obj = this._objects.get(id);
    if (!obj || !obj.derived) return;
    const _g = oid => this._objects.get(oid);

    if (obj.type === 'point') {
      if (obj.constr === 'midpoint') {
        const a = _g(obj.srcA), b = _g(obj.srcB);
        if (a && b) { obj.x = (a.x+b.x)/2; obj.y = (a.y+b.y)/2; }
      } else if (obj.constr === 'intersect') {
        // Вычислить пересечение двух прямых (линии/отрезки/лучи/derived_line)
        const pts1 = this._twoMathPts(obj.src1);
        const pts2 = this._twoMathPts(obj.src2);
        if (pts1 && pts2) {
          const pt = gIntersectLines(pts1[0], pts1[1], pts2[0], pts2[1]);
          if (pt) { obj.x = pt.x; obj.y = pt.y; obj.valid = true; }
          else obj.valid = false;
        }
      } else if (obj.constr === 'foot' || obj.constr === 'reflect') {
        const srcPt = _g(obj.srcPt);
        const sl    = _g(obj.srcLine);
        if (!srcPt || !sl) return;
        let L1, L2;
        if (sl.type === 'derived_line') {
          L1 = { x: sl.ptX, y: sl.ptY };
          L2 = { x: sl.ptX + sl.dirX, y: sl.ptY + sl.dirY };
        } else {
          L1 = _g(sl.p1Id); L2 = _g(sl.p2Id);
        }
        if (L1 && L2) {
          const f = gFoot({ x: srcPt.x, y: srcPt.y }, L1, L2);
          if (obj.constr === 'foot') {
            obj.x = f.x; obj.y = f.y;
          } else {
            // reflect: P' = 2*foot - P
            obj.x = 2*f.x - srcPt.x;
            obj.y = 2*f.y - srcPt.y;
          }
        }
      } else if (obj.constr === 'ngon_vertex') {
        const center = _g(obj.srcCenter), v0 = _g(obj.srcVertex);
        if (!center || !v0) return;
        const angle = 2 * Math.PI * obj.k / obj.n;
        const dx = v0.x - center.x, dy = v0.y - center.y;
        const cos_a = Math.cos(angle), sin_a = Math.sin(angle);
        obj.x = center.x + dx*cos_a - dy*sin_a;
        obj.y = center.y + dx*sin_a + dy*cos_a;
      } else if (obj.constr === 'translate') {
        const pA = _g(obj.srcA), pB = _g(obj.srcB), pP = _g(obj.srcPt);
        if (pA && pB && pP) {
          obj.x = pP.x + (pB.x - pA.x);
          obj.y = pP.y + (pB.y - pA.y);
        }
      } else if (obj.constr === 'centroid') {
        const pA = _g(obj.ptA), pB = _g(obj.ptB), pC = _g(obj.ptC);
        if (pA && pB && pC) {
          obj.x = (pA.x + pB.x + pC.x) / 3;
          obj.y = (pA.y + pB.y + pC.y) / 3;
        }
      } else if (obj.constr === 'orthocenter') {
        const pA = _g(obj.ptA), pB = _g(obj.ptB), pC = _g(obj.ptC);
        if (pA && pB && pC) {
          const h = gOrthocenter(pA, pB, pC);
          if (h) { obj.x = h.x; obj.y = h.y; obj.valid = true; }
          else   { obj.valid = false; }
        }
      } else if (obj.constr === 'altitude_foot') {
        const pA = _g(obj.ptA), pB = _g(obj.ptB), pC = _g(obj.ptC);
        if (pA && pB && pC) {
          const f = gFoot(pA, pB, pC);
          obj.x = f.x; obj.y = f.y;
        }
      } else if (obj.constr === 'parallelogram_d') {
        const pA = _g(obj.ptA), pB = _g(obj.ptB), pC = _g(obj.ptC);
        if (pA && pB && pC) {
          obj.x = pA.x + pC.x - pB.x;
          obj.y = pA.y + pC.y - pB.y;
        }
      } else if (obj.constr === 'scale') {
        const pO = _g(obj.srcO), pP = _g(obj.srcPt);
        if (pO && pP) {
          obj.x = pO.x + obj.k * (pP.x - pO.x);
          obj.y = pO.y + obj.k * (pP.y - pO.y);
        }
      } else if (obj.constr === 'on_segment') {
        const seg = _g(obj.srcSeg);
        if (seg) {
          const p1 = _g(seg.p1Id), p2 = _g(seg.p2Id);
          if (p1 && p2) {
            const t = Math.max(0, Math.min(1, obj._t));
            obj.x = p1.x + t * (p2.x - p1.x);
            obj.y = p1.y + t * (p2.y - p1.y);
          }
        }
      } else if (obj.constr === 'on_circle') {
        const circ = _g(obj.srcCircle);
        if (circ) {
          let cx, cy, r;
          if (circ.derived && circ.cx != null) {
            cx = circ.cx; cy = circ.cy; r = circ.r;
          } else {
            const mc = _g(circ.centerId), me = _g(circ.edgeId);
            if (mc && me) { cx = mc.x; cy = mc.y; r = gDist(mc, me); }
          }
          if (cx != null) {
            obj.x = cx + r * Math.cos(obj._theta);
            obj.y = cy + r * Math.sin(obj._theta);
          }
        }
      }
    } else if (obj.type === 'circle' && obj.derived) {
      const pA = _g(obj.ptA), pB = _g(obj.ptB), pC = _g(obj.ptC);
      if (!pA || !pB || !pC) return;
      const A = { x: pA.x, y: pA.y }, B = { x: pB.x, y: pB.y }, C = { x: pC.x, y: pC.y };
      if (obj.constr === 'circumcircle') {
        const cc = gCircumcircle(A, B, C);
        if (cc) { obj.cx = cc.cx; obj.cy = cc.cy; obj.r = cc.r; obj.valid = true; }
        else    { obj.valid = false; }
      } else if (obj.constr === 'incircle') {
        const ic = gIncircle(A, B, C);
        if (ic) { obj.cx = ic.cx; obj.cy = ic.cy; obj.r = ic.r; obj.valid = true; }
        else    { obj.valid = false; }
      }
    } else if (obj.type === 'derived_line') {
      if (obj.constr === 'perpbisect') {
        const a = _g(obj.srcA), b = _g(obj.srcB);
        if (!a || !b) return;
        obj.ptX = (a.x+b.x)/2; obj.ptY = (a.y+b.y)/2;
        const dx = b.x-a.x, dy = b.y-a.y, len = Math.hypot(dx,dy);
        if (len > 1e-12) { obj.dirX = -dy/len; obj.dirY = dx/len; }
      } else if (obj.constr === 'anglebisect') {
        const a = _g(obj.srcA), vtx = _g(obj.srcVtx), b = _g(obj.srcB);
        if (!a || !vtx || !b) return;
        const va = gNorm({x:a.x-vtx.x, y:a.y-vtx.y});
        const vb = gNorm({x:b.x-vtx.x, y:b.y-vtx.y});
        const bis = gNorm({x:va.x+vb.x, y:va.y+vb.y});
        obj.ptX = vtx.x; obj.ptY = vtx.y;
        obj.dirX = bis.x; obj.dirY = bis.y;
      } else if (obj.constr === 'parallel') {
        const srcPt = _g(obj.srcPt), d1 = _g(obj.srcDirPt1), d2 = _g(obj.srcDirPt2);
        if (!srcPt || !d1 || !d2) return;
        const dx = d2.x-d1.x, dy = d2.y-d1.y, len = Math.hypot(dx,dy);
        if (len < 1e-12) return;
        obj.ptX = srcPt.x; obj.ptY = srcPt.y;
        obj.dirX = dx/len; obj.dirY = dy/len;
      } else if (obj.constr === 'perpendicular') {
        const srcPt = _g(obj.srcPt), d1 = _g(obj.srcDirPt1), d2 = _g(obj.srcDirPt2);
        if (!srcPt || !d1 || !d2) return;
        const dx = d2.x-d1.x, dy = d2.y-d1.y, len = Math.hypot(dx,dy);
        if (len < 1e-12) return;
        obj.ptX = srcPt.x; obj.ptY = srcPt.y;
        obj.dirX = -dy/len; obj.dirY = dx/len;
      } else if (obj.constr === 'tangent') {
        const circ = _g(obj.srcCircle), pt = _g(obj.srcPt);
        if (!circ || !pt) return;
        let O, r;
        if (circ.derived && circ.cx != null) {
          O = { x: circ.cx, y: circ.cy }; r = circ.r;
        } else {
          const ctr = _g(circ.centerId), edg = _g(circ.edgeId);
          if (!ctr || !edg) return;
          O = { x: ctr.x, y: ctr.y }; r = gDist(O, { x: edg.x, y: edg.y });
        }
        const P = { x: pt.x, y: pt.y };
        const tpts = gTangentPoints(O, P, r);
        if (!tpts) { obj.valid = false; return; }
        const T = tpts[obj.which];
        const dx = T.x - P.x, dy = T.y - P.y, len = Math.hypot(dx, dy);
        if (len < 1e-12) { obj.valid = false; return; }
        obj.ptX = P.x; obj.ptY = P.y;
        obj.dirX = dx/len; obj.dirY = dy/len;
        obj.valid = true;
      }
    }
  }

  /* Возвращает два математических точки на объекте-линии (line/segment/ray/derived_line) */
  _twoMathPts(id) {
    const obj = this._objects.get(id);
    if (!obj) return null;
    if (obj.type === 'derived_line') {
      return [{ x:obj.ptX, y:obj.ptY }, { x:obj.ptX+obj.dirX, y:obj.ptY+obj.dirY }];
    }
    const p1 = this._objects.get(obj.p1Id), p2 = this._objects.get(obj.p2Id);
    if (!p1 || !p2) return null;
    return [{ x:p1.x, y:p1.y }, { x:p2.x, y:p2.y }];
  }

  /* Перевычислить все производные объекты, зависящие от changedId */
  propagateDeps(changedId) {
    for (const obj of this._objects.values()) {
      if (this._dependsOn(obj, changedId)) {
        this.recompute(obj.id);
        // Каскадная цепочка: производная точка может быть источником для других
        if (obj.derived) this.propagateDeps(obj.id);
      }
    }
  }

  /* Прямые зависимые объекты (один уровень) */
  getDependents(id) {
    const result = [];
    for (const obj of this._objects.values()) {
      if (obj.id !== id && this._dependsOn(obj, id)) result.push(obj);
    }
    return result;
  }

  get(id)   { return this._objects.get(id); }
  has(id)   { return this._objects.has(id); }
  all()     { return [...this._objects.values()]; }
  byType(t) { return this.all().filter(o => o.type === t); }
  points()  { return this.byType('point'); }

  movePoint(id, x, y) {
    const obj = this._objects.get(id);
    if (obj && obj.type === 'point' && !obj.derived && !obj.locked) {
      obj.x = x; obj.y = y;
      this.propagateDeps(id);
    }
  }

  clear() {
    this._objects.clear();
    this._counter = 0;
  }

  serialize() {
    return JSON.stringify([...this._objects.values()]);
  }

  deserialize(json) {
    this._objects.clear();
    const arr = JSON.parse(json);
    for (const obj of arr) this._objects.set(obj.id, obj);
    this._counter = Math.max(...arr.map(o => parseInt(o.id.slice(1))||0), 0);
  }
}

/* ══════════════════════════════════════════════════════════════════════
   GeoSim — главный класс интерактивной планиметрии
   ══════════════════════════════════════════════════════════════════════ */
class GeoSim {
  constructor(canvas) {
    this.canvas = canvas;
    this.ctx    = canvas.getContext('2d');
    this.vp     = new GeoViewport();
    this.eng    = new GeoEngine();

    /* ── Состояние инструментов ── */
    this.tool            = 'select';
    this._pending        = [];    // промежуточные клики многошаговых инструментов
    this._preview        = null;  // предпросмотр (курсор при рисовании)
    this._pendingLineRef = null;  // первый кликнутый объект для parallel/perp/intersect/reflect/foot
    this._pendingCircRef = null;  // первый кликнутый объект-окружность для tangent
    this._pendingScaleO  = null;  // центр подобия для инструмента scale
    this._pendingMover   = null;  // мовер-точка для инструмента locus
    this._scaleK         = 2;     // коэффициент подобия

    /* ── Состояние drag/pan ── */
    this._drag    = null; // { id, offX, offY }  — перетаскиваем точку
    this._panning = false;
    this._panLast = null;

    /* ── Snap ── */
    this._snapPt  = null; // { x, y } в мат. координатах
    this._snapId  = null; // ID точки-снапа

    /* ── Undo/Redo ── */
    this._undoStack = [];
    this._redoStack = [];

    /* ── Опции ── */
    this.showGrid     = true;
    this.showAxes     = true;
    this.showLabels   = true;
    this.showLengths  = false;
    this.showAngles   = false;
    this.readOnly     = false;

    /* ── Выбранный объект ── */
    this._selected  = null;
    this._hovered   = null;

    /* ── Callbacks ── */
    this.onUpdate        = null; // cb(stats)
    this.onHintChange    = null; // cb(tool, phase) — уведомить UI о смене подсказки
    this.onDeleteRequest = null; // cb(obj, deps, softFn, cascadeFn) — подтвердить удаление
    this.onLocusError    = null; // cb(msg) — ошибка при построении ГМТ

    this._labelCounter = 0;
    this._ngonSides    = 6;  // для инструмента правильного многоугольника
    this._bindEvents();
  }

  setNgonSides(n) {
    this._ngonSides = Math.max(3, Math.min(20, n));
  }

  setScaleK(k) {
    this._scaleK = +k || 2;
  }

  /* ── Инициализация ─────────────────────────────────────────── */
  fit() {
    const c = this.canvas;
    c.width  = c.offsetWidth  || c.parentElement?.offsetWidth  || 800;
    c.height = c.offsetHeight || c.parentElement?.offsetHeight || 600;
    this.vp.W = c.width;
    this.vp.H = c.height;
    this.render();
  }

  setTool(name) {
    this.tool            = name;
    this._pending        = [];
    this._preview        = null;
    this._selected       = null;
    this._pendingLineRef = null;
    this._pendingCircRef = null;
    this._pendingScaleO  = null;
    this._pendingMover   = null;
    this.canvas.style.cursor = name === 'select' ? 'default' : 'crosshair';
    this.render();
  }

  setReadOnly(v) {
    this.readOnly = v;
    if (v) { this.setTool('select'); this.canvas.style.cursor = 'default'; }
  }

  /* ── Следующая буква-метка ─────────────────────────────────── */
  _nextLabel() {
    const used = new Set(this.eng.points().map(p => p.label));
    for (let i = 0; i < 26; i++) {
      const l = String.fromCharCode(65+i);
      if (!used.has(l)) return l;
    }
    return String(++this._labelCounter);
  }

  /* ══ РЕНДЕР ══════════════════════════════════════════════════ */
  render() {
    const ctx = this.ctx;
    const { W, H } = this.vp;
    if (!W || !H) return;

    ctx.clearRect(0, 0, W, H);
    this._drawBg(ctx, W, H);
    if (this.showGrid) this._drawGrid(ctx, W, H);

    // Заливки многоугольников
    for (const obj of this.eng.byType('polygon')) this._drawPolyFill(ctx, obj);
    // Производные прямые (под основными объектами)
    for (const obj of this.eng.byType('derived_line')) this._drawDerivedLine(ctx, obj);
    // Прямые (рисуем до краёв)
    for (const obj of this.eng.byType('line'))    this._drawLine(ctx, obj);
    // Лучи
    for (const obj of this.eng.byType('ray'))     this._drawRay(ctx, obj);
    // Отрезки (виртуальные стороны полигонов не рисуем — они нарисованы как polygon stroke)
    for (const obj of this.eng.byType('segment')) {
      if (!obj.virtual) this._drawSegment(ctx, obj);
    }
    // Стороны многоугольников
    for (const obj of this.eng.byType('polygon')) this._drawPolyStroke(ctx, obj);
    // Окружности
    for (const obj of this.eng.byType('circle'))  this._drawCircle(ctx, obj);
    // Измерения
    if (this.showLengths) this._drawLengths(ctx);
    this._drawAngleMeasures(ctx); // всегда — для arcmark и прямых углов; showAngles управляет авто-подписями
    // Точки поверх всего (включая производные)
    for (const obj of this.eng.points())          this._drawPoint(ctx, obj);
    // Локусы (ГМТ)
    for (const obj of this.eng.byType('locus'))   this._drawLocus(ctx, obj);
    // Измерительные чипы поверх всего
    this._drawMeasurements(ctx);
    // Предпросмотр строящегося объекта
    this._drawPreview(ctx);
    // Подсветка первого объекта при инструментах построения
    if (this._pendingLineRef) this._drawLineRefHighlight(ctx, this._pendingLineRef);
    if (this._pendingCircRef) this._drawLineRefHighlight(ctx, this._pendingCircRef);
    // Индикатор снапа
    if (this._snapPt) this._drawSnapIndicator(ctx);
    // LabFX particles
    if (window.LabFX) LabFX.particles.draw(ctx);
  }

  _drawBg(ctx, W, H) {
    const bg = ctx.createLinearGradient(0, 0, W, H);
    bg.addColorStop(0, '#0a0718');
    bg.addColorStop(1, '#0e0d22');
    ctx.fillStyle = bg;
    ctx.fillRect(0, 0, W, H);
  }

  _drawGrid(ctx, W, H) {
    const vp = this.vp;
    // Определяем шаг сетки в мат. единицах (красивые числа)
    const rawStep = 80 / vp.scale; // примерно 80px между линиями
    const exp = Math.floor(Math.log10(rawStep));
    const frac = rawStep / Math.pow(10, exp);
    let step = frac < 1.5 ? 1 : frac < 3.5 ? 2 : frac < 7.5 ? 5 : 10;
    step *= Math.pow(10, exp);
    step = Math.max(0.001, step);

    const pxStep = step * vp.scale;

    // Математические границы видимой области
    const mLeft  = vp.toMath(0, 0).x;
    const mRight = vp.toMath(W, 0).x;
    const mBot   = vp.toMath(0, H).y;
    const mTop   = vp.toMath(0, 0).y;

    const x0 = Math.floor(mLeft / step) * step;
    const y0 = Math.floor(mBot  / step) * step;

    // Подсетка (× 0.2)
    if (pxStep > 30) {
      ctx.strokeStyle = 'rgba(255,255,255,0.025)';
      ctx.lineWidth = 0.5;
      const sub = step / 5;
      for (let mx = Math.floor(mLeft/sub)*sub; mx <= mRight+sub; mx += sub) {
        const px = vp.toCanvas(mx, 0).x;
        ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke();
      }
      for (let my = Math.floor(mBot/sub)*sub; my <= mTop+sub; my += sub) {
        const py = vp.toCanvas(0, my).y;
        ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke();
      }
    }

    // Основная сетка
    ctx.strokeStyle = 'rgba(255,255,255,0.07)';
    ctx.lineWidth = 1;
    for (let mx = x0; mx <= mRight + step; mx += step) {
      const px = vp.toCanvas(mx, 0).x;
      ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke();
    }
    for (let my = y0; my <= mTop + step; my += step) {
      const py = vp.toCanvas(0, my).y;
      ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke();
    }

    if (!this.showAxes) return;

    // Оси
    ctx.strokeStyle = 'rgba(255,255,255,0.18)';
    ctx.lineWidth = 1.5;
    const ox = vp.toCanvas(0, 0).x, oy = vp.toCanvas(0, 0).y;
    if (ox >= 0 && ox <= W) {
      ctx.beginPath(); ctx.moveTo(ox, 0); ctx.lineTo(ox, H); ctx.stroke();
    }
    if (oy >= 0 && oy <= H) {
      ctx.beginPath(); ctx.moveTo(0, oy); ctx.lineTo(W, oy); ctx.stroke();
    }

    // Подписи осей
    ctx.fillStyle = 'rgba(255,255,255,0.25)';
    ctx.font = '11px Manrope,sans-serif';
    ctx.textAlign = 'center';

    // Подписи по X
    for (let mx = x0; mx <= mRight + step; mx += step) {
      if (Math.abs(mx) < step*0.01) continue;
      const px = vp.toCanvas(mx, 0).x;
      const py = Math.min(H-10, Math.max(14, oy + 14));
      ctx.fillText(String(Math.round(mx*1000)/1000), px, py);
    }
    // Подписи по Y
    ctx.textAlign = 'right';
    for (let my = y0; my <= mTop + step; my += step) {
      if (Math.abs(my) < step*0.01) continue;
      const py = vp.toCanvas(0, my).y;
      const px = Math.max(24, Math.min(W-4, ox - 6));
      ctx.fillText(String(Math.round(my*1000)/1000), px, py + 4);
    }

    // Стрелки на концах осей
    ctx.fillStyle = 'rgba(255,255,255,0.25)';
    if (ox >= 0 && ox <= W) {
      ctx.beginPath(); ctx.moveTo(ox,4); ctx.lineTo(ox-4,14); ctx.lineTo(ox+4,14); ctx.closePath(); ctx.fill();
    }
    if (oy >= 0 && oy <= H) {
      ctx.beginPath(); ctx.moveTo(W-4,oy); ctx.lineTo(W-14,oy-4); ctx.lineTo(W-14,oy+4); ctx.closePath(); ctx.fill();
    }
    ctx.fillStyle = 'rgba(255,255,255,0.3)';
    ctx.font = 'italic 13px serif';
    ctx.textAlign = 'left';
    if (oy > 4 && oy < H) ctx.fillText('y', ox+6, 14);
    if (ox > 0 && ox < W) ctx.fillText('x', W-14, oy-6);
  }

  /* ── Отрисовка объектов ──────────────────────────────────────── */
  _p(id) {
    const o = this.eng.get(id);
    return o ? this.vp.toCanvas(o.x, o.y) : null;
  }

  _mpt(id) {
    const o = this.eng.get(id);
    return o ? { x: o.x, y: o.y } : null;
  }

  _lineColor(obj) {
    return obj.style?.color || this._catColor(obj._cat || 'default');
  }

  _catColor(cat) {
    const MAP = {
      segment: '#9B5DE5', line: '#06D6E0', ray: '#F15BB5',
      circle: '#FFB347', polygon: '#22d55e', default: '#9B5DE5',
    };
    return MAP[cat] || MAP.default;
  }

  _isSelected(obj) { return this._selected?.id === obj.id; }
  _isHovered(obj)  { return this._hovered?.id  === obj.id; }

  _drawPoint(ctx, obj) {
    const { x: px, y: py } = this.vp.toCanvas(obj.x, obj.y);
    const sel = this._isSelected(obj);
    const hov = this._isHovered(obj);
    // Производные точки рисуем иначе (меньше, другой цвет, пунктирный ободок)
    const isDerived = !!obj.derived;
    const col = obj.style?.color || (isDerived ? '#22d55e' : '#fff');
    const r   = isDerived ? 4 : (obj.style?.size || 5);

    if (sel || hov) {
      ctx.save();
      ctx.shadowColor = col; ctx.shadowBlur = 16;
      ctx.strokeStyle = col; ctx.lineWidth = 1.5;
      if (isDerived) ctx.setLineDash([3,3]);
      ctx.beginPath(); ctx.arc(px, py, r+5, 0, Math.PI*2); ctx.stroke();
      ctx.restore();
    }

    ctx.save();
    ctx.shadowColor = col; ctx.shadowBlur = 8;
    if (isDerived) {
      // Производные точки: только контур + центр
      ctx.globalAlpha = 0.85;
      ctx.strokeStyle = col; ctx.lineWidth = 1.5;
      ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI*2); ctx.stroke();
      ctx.fillStyle = col; ctx.globalAlpha = 0.5;
      ctx.beginPath(); ctx.arc(px, py, r*0.5, 0, Math.PI*2); ctx.fill();
    } else {
      ctx.fillStyle = col;
      ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI*2); ctx.fill();
      ctx.shadowBlur = 0;
      ctx.fillStyle = 'rgba(255,255,255,0.6)';
      ctx.beginPath(); ctx.arc(px, py, r*0.38, 0, Math.PI*2); ctx.fill();
    }
    ctx.restore();

    if (this.showLabels && obj.label) {
      ctx.save();
      ctx.font = isDerived ? '12px Manrope,sans-serif' : 'bold 14px Manrope,sans-serif';
      ctx.fillStyle = isDerived ? col : '#fff';
      ctx.globalAlpha = isDerived ? 0.85 : 1;
      ctx.shadowColor = 'rgba(0,0,0,0.8)';
      ctx.shadowBlur = 4;
      ctx.fillText(obj.label, px+9, py-9);
      ctx.restore();
    }
  }

  _drawSegment(ctx, obj) {
    const p1 = this._p(obj.p1Id), p2 = this._p(obj.p2Id);
    if (!p1 || !p2) return;
    const col = obj.style?.color || '#9B5DE5';
    const sel = this._isSelected(obj);
    ctx.save();
    ctx.strokeStyle = col;
    ctx.lineWidth = obj.style?.width || (sel ? 2.5 : 2);
    ctx.shadowColor = col; ctx.shadowBlur = sel ? 10 : 4;
    if (obj.style?.dash) ctx.setLineDash(obj.style.dash);
    ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke();
    ctx.restore();
    if (obj.tickMark)     this._drawTickMark(ctx, p1, p2, obj.tickMark, col);
    if (obj.parallelMark) this._drawParallelMark(ctx, p1, p2, obj.parallelMark, col);
    if (this.showLabels && obj.label) this._drawObjLabel(ctx, obj.label, {x:(p1.x+p2.x)/2, y:(p1.y+p2.y)/2}, col);
  }

  /* Метки равных сторон (штрихи поперёк отрезка) */
  _drawTickMark(ctx, p1, p2, count, col) {
    const dx = p2.x - p1.x, dy = p2.y - p1.y;
    const len = Math.hypot(dx, dy);
    if (len < 1e-9 || !count) return;
    const ux = dx / len, uy = dy / len;   // вдоль отрезка
    const nx = -uy, ny = ux;               // перпендикуляр
    const TICK = 7, SPACING = 5;
    const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2;
    ctx.save();
    ctx.strokeStyle = col; ctx.lineWidth = 2; ctx.globalAlpha = 0.9;
    ctx.shadowColor = col; ctx.shadowBlur = 3;
    for (let k = 0; k < count; k++) {
      const off = (k - (count - 1) / 2) * SPACING;
      const cx = mx + ux * off, cy = my + uy * off;
      ctx.beginPath();
      ctx.moveTo(cx + nx * TICK, cy + ny * TICK);
      ctx.lineTo(cx - nx * TICK, cy - ny * TICK);
      ctx.stroke();
    }
    ctx.restore();
  }

  /* Метки параллельных линий (шевроны >) */
  _drawParallelMark(ctx, p1, p2, count, col) {
    const dx = p2.x - p1.x, dy = p2.y - p1.y;
    const len = Math.hypot(dx, dy);
    if (len < 1e-9 || !count) return;
    const ux = dx / len, uy = dy / len;
    const nx = -uy, ny = ux;
    const W = 5, H = 5, SPACING = 8; // полуширина, полувысота, отступ между шевронами
    const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2;
    ctx.save();
    ctx.strokeStyle = col; ctx.lineWidth = 2; ctx.globalAlpha = 0.9;
    ctx.shadowColor = col; ctx.shadowBlur = 3;
    for (let k = 0; k < count; k++) {
      const off = (k - (count - 1) / 2) * SPACING;
      const cx = mx + ux * off, cy = my + uy * off;
      // Шеврон: два отрезка от "хвоста" к "острию"
      ctx.beginPath();
      ctx.moveTo(cx - ux*W + nx*H, cy - uy*W + ny*H);
      ctx.lineTo(cx + ux*W,         cy + uy*W);
      ctx.lineTo(cx - ux*W - nx*H, cy - uy*W - ny*H);
      ctx.stroke();
    }
    ctx.restore();
  }

  _drawLine(ctx, obj) {
    const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id);
    if (!m1 || !m2) return;
    const { W, H, vp } = { W: this.vp.W, H: this.vp.H, vp: this.vp };
    // Расширить до границ экрана
    const [p1c, p2c] = this._extendToEdges(m1, m2);
    if (!p1c || !p2c) return;
    const col = obj.style?.color || '#06D6E0';
    ctx.save();
    ctx.strokeStyle = col;
    ctx.lineWidth = obj.style?.width || 1.5;
    ctx.globalAlpha = 0.8;
    ctx.shadowColor = col; ctx.shadowBlur = 3;
    ctx.setLineDash([6, 6]);
    ctx.beginPath(); ctx.moveTo(p1c.x, p1c.y); ctx.lineTo(p2c.x, p2c.y); ctx.stroke();
    ctx.restore();
  }

  _drawRay(ctx, obj) {
    const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id);
    if (!m1 || !m2) return;
    const endC = this._extendOneWay(m1, m2);
    const p1c = this.vp.toCanvas(m1.x, m1.y);
    if (!endC) return;
    const col = obj.style?.color || '#F15BB5';
    ctx.save();
    ctx.strokeStyle = col;
    ctx.lineWidth = obj.style?.width || 1.5;
    ctx.globalAlpha = 0.8;
    ctx.shadowColor = col; ctx.shadowBlur = 3;
    ctx.beginPath(); ctx.moveTo(p1c.x, p1c.y); ctx.lineTo(endC.x, endC.y); ctx.stroke();
    ctx.restore();
  }

  /* ── Производная прямая (dashed, lighter) ── */
  _drawDerivedLine(ctx, obj) {
    if (!obj.ptX && obj.ptX !== 0) return;
    const m1 = { x: obj.ptX, y: obj.ptY };
    const m2 = { x: obj.ptX + obj.dirX, y: obj.ptY + obj.dirY };
    const [p1c, p2c] = this._extendToEdges(m1, m2);
    const col = obj.style?.color || '#4CC9F0';
    ctx.save();
    ctx.strokeStyle = col;
    ctx.lineWidth = 1.5;
    ctx.globalAlpha = 0.7;
    ctx.setLineDash([8, 5]);
    ctx.shadowColor = col; ctx.shadowBlur = 4;
    ctx.beginPath(); ctx.moveTo(p1c.x, p1c.y); ctx.lineTo(p2c.x, p2c.y); ctx.stroke();
    ctx.restore();
    // Подпись типа
    if (this.showLabels && obj.label) {
      const mid = { x:(p1c.x+p2c.x)/2, y:(p1c.y+p2c.y)/2 };
      ctx.save(); ctx.font = '11px Manrope,sans-serif';
      ctx.fillStyle = col; ctx.globalAlpha = 0.8;
      ctx.fillText(obj.label, mid.x + 6, mid.y - 6);
      ctx.restore();
    }
  }

  /* Подсветить объект (первый клик в parallel/perpendicular/intersect/tangent/...) */
  _drawLineRefHighlight(ctx, obj) {
    if (!obj) return;
    ctx.save();
    ctx.strokeStyle = '#FFE066'; ctx.lineWidth = 3; ctx.globalAlpha = 0.55;
    ctx.setLineDash([6, 4]);
    if (obj.type === 'circle') {
      // Подсветка окружности (для инструмента tangent)
      let cx, cy, r;
      if (obj.derived && obj.cx != null) {
        const c = this.vp.toCanvas(obj.cx, obj.cy);
        cx = c.x; cy = c.y; r = this.vp.toCanvasDist(obj.r);
      } else {
        const c = this._p(obj.centerId), e = this._p(obj.edgeId);
        if (!c || !e) { ctx.restore(); return; }
        cx = c.x; cy = c.y; r = gDist(c, e);
      }
      ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2); ctx.stroke();
    } else {
      let p1c, p2c;
      if (obj.type === 'derived_line') {
        const m1 = { x:obj.ptX, y:obj.ptY }, m2 = { x:obj.ptX+obj.dirX, y:obj.ptY+obj.dirY };
        [p1c, p2c] = this._extendToEdges(m1, m2);
      } else {
        const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id);
        if (!m1 || !m2) { ctx.restore(); return; }
        if (obj.type === 'segment') { p1c = this.vp.toCanvas(m1.x,m1.y); p2c = this.vp.toCanvas(m2.x,m2.y); }
        else [p1c, p2c] = this._extendToEdges(m1, m2);
      }
      ctx.beginPath(); ctx.moveTo(p1c.x, p1c.y); ctx.lineTo(p2c.x, p2c.y); ctx.stroke();
    }
    ctx.restore();
  }

  /* Найти окружность под курсором (для инструмента tangent) */
  _hitTestCircle(px, py) {
    const HIT = 12, m = this.vp.toMath(px, py);
    for (const obj of this.eng.byType('circle')) {
      let O, r;
      if (obj.derived && obj.cx != null) {
        O = { x: obj.cx, y: obj.cy }; r = obj.r;
      } else {
        const mc = this._mpt(obj.centerId), me = this._mpt(obj.edgeId);
        if (!mc || !me) continue;
        O = mc; r = gDist(mc, me);
      }
      if (Math.abs(gDist(m, O) - r) * this.vp.scale < HIT) return obj;
    }
    return null;
  }

  /* Вернуть две мат. точки на объекте (line/segment/ray/derived_line) — для хит-теста и пересечений */
  _twoPointsOnObj(obj) {
    if (!obj) return null;
    if (obj.type === 'derived_line') {
      return [{ x:obj.ptX, y:obj.ptY }, { x:obj.ptX+obj.dirX, y:obj.ptY+obj.dirY }];
    }
    const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id);
    return (m1 && m2) ? [m1, m2] : null;
  }

  /* Найти линейный объект под курсором (для инструментов построения) */
  _hitTestLine(px, py) {
    const HIT = 12, m = this.vp.toMath(px, py);
    const types = ['line','segment','ray'];
    for (const t of types) {
      for (const obj of this.eng.byType(t)) {
        if (obj.virtual) continue; // виртуальные отрезки-стороны проверяем ниже через полигон
        const pts = this._twoPointsOnObj(obj);
        if (!pts) continue;
        const d = t === 'segment' ? gDistToSegment(m,pts[0],pts[1]) : gDistToLine(m,pts[0],pts[1]);
        if (d * this.vp.scale < HIT) return obj;
      }
    }
    for (const obj of this.eng.byType('derived_line')) {
      const pts = this._twoPointsOnObj(obj);
      if (!pts) continue;
      if (gDistToLine(m, pts[0], pts[1]) * this.vp.scale < HIT) return obj;
    }
    // Стороны полигонов — ищем и при попадании создаём виртуальный отрезок
    for (const poly of this.eng.byType('polygon')) {
      const ids = poly.pointIds;
      for (let i = 0; i < ids.length; i++) {
        const j = (i + 1) % ids.length;
        const A = this._mpt(ids[i]), B = this._mpt(ids[j]);
        if (!A || !B) continue;
        if (gDistToSegment(m, A, B) * this.vp.scale < HIT) {
          return this._ensurePolySide(poly.id, ids[i], ids[j]);
        }
      }
    }
    return null;
  }

  /* Найти вершину полигона под курсором: {poly, idx} или null */
  _findVertexOfPoly(px, py, nSides = 0) {
    const SNAP_PX = 16;
    for (const poly of this.eng.byType('polygon')) {
      if (nSides > 0 && poly.pointIds.length !== nSides) continue;
      for (let i = 0; i < poly.pointIds.length; i++) {
        const p = this._p(poly.pointIds[i]);
        if (p && Math.hypot(p.x - px, p.y - py) < SNAP_PX) return { poly, idx: i };
      }
    }
    return null;
  }

  /* Найти треугольник под курсором (вершина, сторона или внутренность) */
  _findTriangleNear(px, py) {
    for (const poly of this.eng.byType('polygon')) {
      if (poly.pointIds.length !== 3) continue;
      const pts = poly.pointIds.map(id => this._p(id)).filter(Boolean);
      if (pts.length !== 3) continue;
      // Вершины
      for (const p of pts) { if (Math.hypot(p.x - px, p.y - py) < 20) return poly; }
      // Внутренность (cross-product тест)
      const sign = (P, A, B) => (B.x-A.x)*(P.y-A.y) - (B.y-A.y)*(P.x-A.x);
      const P = {x:px, y:py};
      const s0 = sign(P,pts[0],pts[1]), s1 = sign(P,pts[1],pts[2]), s2 = sign(P,pts[2],pts[0]);
      if ((s0>=0&&s1>=0&&s2>=0) || (s0<=0&&s1<=0&&s2<=0)) return poly;
    }
    return null;
  }

  /* Найти или создать виртуальный отрезок для стороны полигона */
  _ensurePolySide(polyId, p1Id, p2Id) {
    for (const obj of this.eng.byType('segment')) {
      if (obj.virtual && obj.polyId === polyId && obj.p1Id === p1Id && obj.p2Id === p2Id) return obj;
    }
    return this.eng.add({ type: 'segment', virtual: true, polyId, p1Id, p2Id });
  }

  _drawPolyFill(ctx, obj) {
    const pts = obj.pointIds.map(id => this._p(id)).filter(Boolean);
    if (pts.length < 3) return;
    const col = obj.style?.fillColor || 'rgba(155,93,229,0.12)';
    ctx.save();
    ctx.fillStyle = col;
    ctx.beginPath();
    ctx.moveTo(pts[0].x, pts[0].y);
    for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
    ctx.closePath(); ctx.fill();
    ctx.restore();
  }

  _drawPolyStroke(ctx, obj) {
    const pts = obj.pointIds.map(id => this._p(id)).filter(Boolean);
    if (pts.length < 2) return;
    const col = obj.style?.color || '#22d55e';
    const sel = this._isSelected(obj);
    ctx.save();
    ctx.strokeStyle = col;
    ctx.lineWidth = obj.style?.width || (sel ? 2.5 : 2);
    ctx.shadowColor = col; ctx.shadowBlur = sel ? 10 : 4;
    ctx.beginPath();
    ctx.moveTo(pts[0].x, pts[0].y);
    for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
    ctx.closePath(); ctx.stroke();
    ctx.restore();
    // Метки равных сторон (штрихи) и параллельных сторон (шевроны)
    for (let i = 0; i < pts.length; i++) {
      const j = (i+1) % pts.length;
      if (obj.sideMarks?.[i])    this._drawTickMark(ctx, pts[i], pts[j], obj.sideMarks[i], col);
      if (obj.parallelSideMarks?.[i]) this._drawParallelMark(ctx, pts[i], pts[j], obj.parallelSideMarks[i], col);
    }
  }

  _drawCircle(ctx, obj) {
    let cx, cy, r;
    const isDerived = obj.derived && (obj.constr === 'circumcircle' || obj.constr === 'incircle');
    if (isDerived) {
      if (!obj.valid) return;
      const c = this.vp.toCanvas(obj.cx, obj.cy);
      cx = c.x; cy = c.y;
      r  = this.vp.toCanvasDist(obj.r);
    } else {
      const c = this._p(obj.centerId), e = this._p(obj.edgeId);
      if (!c || !e) return;
      cx = c.x; cy = c.y;
      r  = gDist(c, e);
    }
    const col = obj.style?.color || '#FFB347';
    const sel = this._isSelected(obj);
    ctx.save();
    ctx.strokeStyle = col;
    ctx.lineWidth = obj.style?.width || (sel ? 2.5 : 2);
    ctx.shadowColor = col; ctx.shadowBlur = sel ? 10 : 4;
    ctx.globalAlpha = isDerived ? 0.75 : 0.9;
    if (isDerived) ctx.setLineDash([7, 4]);
    ctx.fillStyle = obj.style?.fillColor || `rgba(255,179,71,0.04)`;
    ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2);
    ctx.fill(); ctx.stroke();
    ctx.restore();
    if (this.showLabels && obj.label)
      this._drawObjLabel(ctx, obj.label, { x: cx, y: cy - r - 8 }, col);
  }

  _drawObjLabel(ctx, label, pos, col) {
    ctx.save();
    ctx.font = '12px Manrope,sans-serif';
    ctx.fillStyle = col || '#fff';
    ctx.textAlign = 'center';
    ctx.shadowColor = 'rgba(0,0,0,0.8)'; ctx.shadowBlur = 4;
    ctx.fillText(label, pos.x, pos.y);
    ctx.restore();
  }

  _drawLengths(ctx) {
    for (const seg of this.eng.byType('segment')) {
      const m1 = this._mpt(seg.p1Id), m2 = this._mpt(seg.p2Id);
      if (!m1 || !m2) continue;
      const len = gDist(m1, m2);
      const mid = this.vp.toCanvas((m1.x+m2.x)/2, (m1.y+m2.y)/2);
      const dx = m2.x-m1.x, dy = m2.y-m1.y;
      const nx = -dy / Math.hypot(dx,dy) * 14;
      const ny =  dx / Math.hypot(dx,dy) * 14; // отступ перпендикулярно
      ctx.save();
      ctx.font = '11px Manrope,sans-serif';
      ctx.fillStyle = seg.style?.color || '#9B5DE5';
      ctx.textAlign = 'center';
      ctx.shadowColor = 'rgba(0,0,0,0.9)'; ctx.shadowBlur = 4;
      ctx.fillText(len.toFixed(2), mid.x + nx, mid.y - ny + 4);
      ctx.restore();
    }
    for (const poly of this.eng.byType('polygon')) {
      const ids = poly.pointIds;
      for (let i = 0; i < ids.length; i++) {
        const m1 = this._mpt(ids[i]), m2 = this._mpt(ids[(i+1)%ids.length]);
        if (!m1 || !m2) continue;
        const len = gDist(m1, m2);
        const mid = this.vp.toCanvas((m1.x+m2.x)/2, (m1.y+m2.y)/2);
        ctx.save();
        ctx.font = '11px Manrope,sans-serif';
        ctx.fillStyle = poly.style?.color || '#22d55e';
        ctx.textAlign = 'center';
        ctx.shadowColor = 'rgba(0,0,0,0.9)'; ctx.shadowBlur = 4;
        ctx.fillText(len.toFixed(2), mid.x, mid.y - 8);
        ctx.restore();
      }
    }
  }

  _drawAngleMeasures(ctx) {
    const ARC_R = 22;  // радиус дуги угла, пикселей
    const SQ_SZ = 11;  // сторона квадрата прямого угла, пикселей
    const LBL_D = 14;  // отступ подписи от дуги/квадрата, пикселей

    for (const poly of this.eng.byType('polygon')) {
      const ids = poly.pointIds;
      const n   = ids.length;
      for (let i = 0; i < n; i++) {
        const A = this._mpt(ids[(i-1+n)%n]);
        const B = this._mpt(ids[i]);
        const C = this._mpt(ids[(i+1)%n]);
        if (!A || !B || !C) continue;

        const angle = gAngleDeg(A, B, C);
        const Apx = this.vp.toCanvas(A.x, A.y);
        const Bpx = this.vp.toCanvas(B.x, B.y);
        const Cpx = this.vp.toCanvas(C.x, C.y);
        const col = poly.style?.color || '#FFE066';

        // Единичные векторы B→A и B→C в пиксельных координатах
        const dAx = Apx.x - Bpx.x, dAy = Apx.y - Bpx.y;
        const dCx = Cpx.x - Bpx.x, dCy = Cpx.y - Bpx.y;
        const lenA = Math.hypot(dAx, dAy), lenC = Math.hypot(dCx, dCy);
        if (lenA < 1e-4 || lenC < 1e-4) continue;
        const uAx = dAx/lenA, uAy = dAy/lenA;
        const uCx = dCx/lenC, uCy = dCy/lenC;

        // Биссектриса угла в пиксельных координатах
        const bisX = uAx + uCx, bisY = uAy + uCy;
        const bisLen = Math.hypot(bisX, bisY);

        ctx.save();
        ctx.strokeStyle = col;
        ctx.lineWidth = 1.5;

        const explicitMark = poly.angleMarks?.[i] || 0; // 0=авто, 1-3=явный
        if (explicitMark > 0 && bisLen > 1e-6) {
          // Явные метки: всегда рисуем 1-3 концентрические дуги
          const midAngle   = Math.atan2(bisY, bisX);
          const halfSpread = Math.acos(Math.max(-1, Math.min(1, uAx*uCx + uAy*uCy))) / 2;
          ctx.globalAlpha = 0.85;
          ctx.lineWidth = 2;
          for (let k = 0; k < explicitMark; k++) {
            ctx.beginPath();
            ctx.arc(Bpx.x, Bpx.y, ARC_R + k * 8, midAngle - halfSpread, midAngle + halfSpread, false);
            ctx.stroke();
          }
        } else if (this.showAngles) {
          // Авто-режим: только если включено
          if (Math.abs(angle - 90) < 2) {
            ctx.globalAlpha = 0.8;
            ctx.beginPath();
            ctx.moveTo(Bpx.x + uAx*SQ_SZ,              Bpx.y + uAy*SQ_SZ);
            ctx.lineTo(Bpx.x + uAx*SQ_SZ + uCx*SQ_SZ,  Bpx.y + uAy*SQ_SZ + uCy*SQ_SZ);
            ctx.lineTo(Bpx.x + uCx*SQ_SZ,              Bpx.y + uCy*SQ_SZ);
            ctx.stroke();
          } else if (bisLen > 1e-6) {
            const midAngle   = Math.atan2(bisY, bisX);
            const halfSpread = Math.acos(Math.max(-1, Math.min(1, uAx*uCx + uAy*uCy))) / 2;
            ctx.globalAlpha = 0.7;
            ctx.beginPath();
            ctx.arc(Bpx.x, Bpx.y, ARC_R, midAngle - halfSpread, midAngle + halfSpread, false);
            ctx.stroke();
          }
          // Подпись угла
          if (bisLen > 1e-6) {
            const ldist = (Math.abs(angle - 90) < 2 ? SQ_SZ : ARC_R) + LBL_D;
            const bx = bisX / bisLen, by = bisY / bisLen;
            ctx.font = '10px Manrope,sans-serif';
            ctx.fillStyle = col;
            ctx.globalAlpha = 0.9;
            ctx.textAlign = 'center';
            ctx.shadowColor = 'rgba(0,0,0,0.9)'; ctx.shadowBlur = 3;
            ctx.fillText(angle.toFixed(1) + '°', Bpx.x + bx*ldist, Bpx.y + by*ldist + 3);
          }
        }

        ctx.restore();
      }
    }

    // Прямые углы для foot и altitude_foot конструкций
    for (const obj of this.eng.points()) {
      if (!obj.derived || (obj.constr !== 'foot' && obj.constr !== 'altitude_foot')) continue;
      const F   = this.vp.toCanvas(obj.x, obj.y);
      let P, L1m, L2m;
      if (obj.constr === 'altitude_foot') {
        P   = this.eng.get(obj.ptA);
        L1m = this._mpt(obj.ptB); L2m = this._mpt(obj.ptC);
      } else {
        P   = this.eng.get(obj.srcPt);
        const sl = this.eng.get(obj.srcLine);
        if (!sl) continue;
        if (sl.type === 'derived_line') {
          L1m = { x: sl.ptX, y: sl.ptY };
          L2m = { x: sl.ptX + sl.dirX, y: sl.ptY + sl.dirY };
        } else {
          L1m = this._mpt(sl.p1Id); L2m = this._mpt(sl.p2Id);
        }
      }
      if (!P || !L1m || !L2m) continue;
      const L1  = this.vp.toCanvas(L1m.x, L1m.y);
      const L2  = this.vp.toCanvas(L2m.x, L2m.y);
      const Ppx = this.vp.toCanvas(P.x, P.y);
      // Единичный вектор вдоль линии
      const ldx = L2.x - L1.x, ldy = L2.y - L1.y;
      const llen = Math.hypot(ldx, ldy);
      if (llen < 1e-9) continue;
      const uLx = ldx / llen, uLy = ldy / llen;
      // Единичный вектор F → P (направление перпендикуляра)
      const fpx = Ppx.x - F.x, fpy = Ppx.y - F.y;
      const fpLen = Math.hypot(fpx, fpy);
      if (fpLen < 2) continue; // точка совпадает с основанием — пропустить
      const uPx = fpx / fpLen, uPy = fpy / fpLen;
      // Квадрат прямого угла
      ctx.save();
      ctx.strokeStyle = '#4ADE80';
      ctx.lineWidth = 1.5;
      ctx.globalAlpha = 0.8;
      ctx.beginPath();
      ctx.moveTo(F.x + uLx*SQ_SZ,              F.y + uLy*SQ_SZ);
      ctx.lineTo(F.x + uLx*SQ_SZ + uPx*SQ_SZ, F.y + uLy*SQ_SZ + uPy*SQ_SZ);
      ctx.lineTo(F.x + uPx*SQ_SZ,              F.y + uPy*SQ_SZ);
      ctx.stroke();
      ctx.restore();
    }
  }

  /* ── Измерительные чипы (measure_length / measure_angle / measure_area) ── */
  _drawMeasurements(ctx) {
    const CHIP_PAD_X = 8, CHIP_PAD_Y = 4, CHIP_R = 6;
    ctx.save();
    ctx.font = '11px Manrope,sans-serif';
    for (const obj of this.eng.all()) {
      if (obj.type !== 'measure_length' && obj.type !== 'measure_angle' && obj.type !== 'measure_area') continue;
      const text = this._measureText(obj);
      if (text === null) continue;

      const labelPx = this._measureLabelPos(obj);
      if (!labelPx) continue;

      const w = ctx.measureText(text).width + CHIP_PAD_X * 2;
      const h = 18;
      const x = labelPx.x - w / 2;
      const y = labelPx.y - h / 2;

      const isSelected = this._isSelected(obj);
      const col = obj.type === 'measure_length' ? '#9B5DE5'
                : obj.type === 'measure_angle'  ? '#F15BB5'
                : '#22d55e';

      ctx.globalAlpha = 0.92;
      ctx.fillStyle = 'rgba(10,7,24,0.82)';
      ctx.beginPath();
      if (ctx.roundRect) ctx.roundRect(x, y, w, h, CHIP_R);
      else ctx.rect(x, y, w, h);
      ctx.fill();

      ctx.strokeStyle = isSelected ? '#fff' : col;
      ctx.lineWidth = isSelected ? 1.8 : 1.2;
      ctx.globalAlpha = isSelected ? 0.9 : 0.7;
      ctx.stroke();

      ctx.fillStyle = col;
      ctx.globalAlpha = 0.95;
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      ctx.shadowColor = 'rgba(0,0,0,0.8)'; ctx.shadowBlur = 3;
      ctx.fillText(text, labelPx.x, labelPx.y + 0.5);
      ctx.shadowBlur = 0;
      ctx.textBaseline = 'alphabetic';
    }
    ctx.restore();
  }

  _measureText(obj) {
    if (obj.type === 'measure_length') {
      const seg = this.eng.get(obj.srcSeg);
      if (!seg) return null;
      const m1 = this._mpt(seg.p1Id), m2 = this._mpt(seg.p2Id);
      if (!m1 || !m2) return null;
      const p1 = this.eng.get(seg.p1Id), p2 = this.eng.get(seg.p2Id);
      const lab1 = (p1 && p1.label) || '';
      const lab2 = (p2 && p2.label) || '';
      const name = lab1 && lab2 ? lab1 + lab2 : 'seg';
      return name + ' = ' + gDist(m1, m2).toFixed(2);
    }
    if (obj.type === 'measure_angle') {
      const pA = this.eng.get(obj.srcA), pV = this.eng.get(obj.srcVtx), pB = this.eng.get(obj.srcB);
      if (!pA || !pV || !pB) return null;
      const ang = gAngleDeg(pA, pV, pB);
      const lA = (pA.label) || '', lV = (pV.label) || '', lB = (pB.label) || '';
      const name = (lA && lV && lB) ? lA + lV + lB : 'ang';
      return '∠' + name + ' = ' + ang.toFixed(1) + '°';
    }
    if (obj.type === 'measure_area') {
      const poly = this.eng.get(obj.srcPoly);
      if (!poly) return null;
      const pts = poly.pointIds.map(id => this._mpt(id)).filter(Boolean);
      if (pts.length < 3) return null;
      return 'S = ' + gPolygonArea(pts).toFixed(2);
    }
    return null;
  }

  /* Базовая позиция чипа в пикселях (без пользовательского offset) */
  _measureLabelBasePos(obj) {
    if (obj.type === 'measure_length') {
      const seg = this.eng.get(obj.srcSeg);
      if (!seg) return null;
      const m1 = this._mpt(seg.p1Id), m2 = this._mpt(seg.p2Id);
      if (!m1 || !m2) return null;
      const mid = this.vp.toCanvas((m1.x + m2.x) / 2, (m1.y + m2.y) / 2);
      return { x: mid.x, y: mid.y - 18 };
    }
    if (obj.type === 'measure_angle') {
      const pV = this.eng.get(obj.srcVtx);
      if (!pV) return null;
      const vPx = this.vp.toCanvas(pV.x, pV.y);
      return { x: vPx.x, y: vPx.y - 28 };
    }
    if (obj.type === 'measure_area') {
      const poly = this.eng.get(obj.srcPoly);
      if (!poly) return null;
      const pts = poly.pointIds.map(id => this._mpt(id)).filter(Boolean);
      if (pts.length < 3) return null;
      const sumX = pts.reduce((s, p) => s + p.x, 0) / pts.length;
      const sumY = pts.reduce((s, p) => s + p.y, 0) / pts.length;
      const c = this.vp.toCanvas(sumX, sumY);
      return { x: c.x, y: c.y };
    }
    return null;
  }

  /* Позиция чипа в пикселях (базовая + пользовательский offset) */
  _measureLabelPos(obj) {
    const base = this._measureLabelBasePos(obj);
    if (!base) return null;
    return { x: base.x + (obj.offX || 0), y: base.y + (obj.offY || 0) };
  }

  /* ── Локус (ГМТ) ──────────────────────────────────────────── */
  _drawLocus(ctx, obj) {
    const pts = obj.samples;
    if (!pts || pts.length < 2) return;
    const locusColor = obj.style && obj.style.color ? obj.style.color : '#F59E0B';
    const drawPolyline = () => {
      ctx.save();
      ctx.strokeStyle = locusColor;
      ctx.lineWidth   = 2;
      ctx.globalAlpha = 0.65;
      ctx.setLineDash([]);
      ctx.beginPath();
      const first = this.vp.toCanvas(pts[0].x, pts[0].y);
      ctx.moveTo(first.x, first.y);
      for (let i = 1; i < pts.length; i++) {
        const p = this.vp.toCanvas(pts[i].x, pts[i].y);
        ctx.lineTo(p.x, p.y);
      }
      ctx.stroke();
      ctx.restore();
    };
    if (window.LabFX) {
      LabFX.glow.drawGlow(ctx, drawPolyline, { color: '#F59E0B', intensity: 8 });
    } else {
      drawPolyline();
    }
  }

  /* ── Предпросмотр (строящийся объект) ─────────────────────── */
  _drawPreview(ctx) {
    if (this._pending.length === 0 || !this._preview) return;
    const lastM = this._pending[this._pending.length-1];
    const curM  = this._preview;
    const p1c = this.vp.toCanvas(lastM.x, lastM.y);
    const p2c = this.vp.toCanvas(curM.x, curM.y);

    ctx.save();
    ctx.globalAlpha = 0.55;
    ctx.strokeStyle = '#fff';
    ctx.lineWidth = 1.5;
    ctx.setLineDash([4, 4]);

    if (this.tool === 'circle') {
      // Предпросмотр окружности
      const r = gDist(p1c, p2c);
      ctx.beginPath(); ctx.arc(p1c.x, p1c.y, r, 0, Math.PI*2); ctx.stroke();
    } else if (this.tool === 'line') {
      // Расширить до краёв
      const ext = this._extendToEdges(lastM, curM);
      if (ext) {
        ctx.beginPath(); ctx.moveTo(ext[0].x, ext[0].y); ctx.lineTo(ext[1].x, ext[1].y); ctx.stroke();
      }
    } else if (this.tool === 'ray') {
      const end = this._extendOneWay(lastM, curM);
      if (end) {
        ctx.beginPath(); ctx.moveTo(p1c.x, p1c.y); ctx.lineTo(end.x, end.y); ctx.stroke();
      }
    } else {
      ctx.beginPath(); ctx.moveTo(p1c.x, p1c.y); ctx.lineTo(p2c.x, p2c.y); ctx.stroke();
    }

    // Для полигона — показать цепочку
    if ((this.tool === 'polygon' || this.tool === 'triangle' || this.tool === 'quad') && this._pending.length > 1) {
      ctx.setLineDash([]);
      ctx.strokeStyle = '#22d55e';
      ctx.globalAlpha = 0.4;
      ctx.beginPath();
      const p0 = this.vp.toCanvas(this._pending[0].x, this._pending[0].y);
      ctx.moveTo(p0.x, p0.y);
      for (let i = 1; i < this._pending.length; i++) {
        const pp = this.vp.toCanvas(this._pending[i].x, this._pending[i].y);
        ctx.lineTo(pp.x, pp.y);
      }
      ctx.lineTo(p2c.x, p2c.y);
      ctx.stroke();
    }

    ctx.restore();

    // Фантомная точка в позиции курсора
    ctx.save();
    ctx.globalAlpha = 0.5;
    ctx.fillStyle = '#fff';
    ctx.beginPath(); ctx.arc(p2c.x, p2c.y, 4, 0, Math.PI*2); ctx.fill();
    ctx.restore();
  }

  _drawSnapIndicator(ctx) {
    const p = this.vp.toCanvas(this._snapPt.x, this._snapPt.y);
    ctx.save();
    ctx.strokeStyle = '#FFE066';
    ctx.lineWidth = 1.5;
    ctx.shadowColor = '#FFE066'; ctx.shadowBlur = 8;
    // Крестик
    const s = 8;
    ctx.beginPath();
    ctx.moveTo(p.x-s, p.y); ctx.lineTo(p.x+s, p.y);
    ctx.moveTo(p.x, p.y-s); ctx.lineTo(p.x, p.y+s);
    ctx.stroke();
    // Кольцо
    ctx.beginPath(); ctx.arc(p.x, p.y, s+2, 0, Math.PI*2); ctx.stroke();
    ctx.restore();
  }

  /* ── Геом. вспомогательные ───────────────────────────────────── */
  /** Расширить прямую через m1-m2 до границ экрана */
  _extendToEdges(m1, m2) {
    const vp = this.vp;
    const W = vp.W, H = vp.H;
    const dx = m2.x-m1.x, dy = m2.y-m1.y;
    if (Math.abs(dx)<1e-12 && Math.abs(dy)<1e-12) return null;
    const big = 1e6;
    const A = { x: m1.x - dx*big, y: m1.y - dy*big };
    const B = { x: m1.x + dx*big, y: m1.y + dy*big };
    const Apx = vp.toCanvas(A.x, A.y);
    const Bpx = vp.toCanvas(B.x, B.y);
    // Обрезаем по viewport
    const clipped = this._clipSegment(Apx.x, Apx.y, Bpx.x, Bpx.y, 0, 0, W, H);
    return clipped;
  }

  _extendOneWay(m1, m2) {
    const vp = this.vp;
    const dx = m2.x-m1.x, dy = m2.y-m1.y;
    if (Math.abs(dx)<1e-12 && Math.abs(dy)<1e-12) return null;
    const big = 1e6;
    const B = { x: m1.x + dx*big, y: m1.y + dy*big };
    return vp.toCanvas(B.x, B.y);
  }

  /** Cohen-Sutherland line clipping */
  _clipSegment(x0,y0,x1,y1,xmin,ymin,xmax,ymax) {
    const code = (x,y) =>
      (x<xmin?1:x>xmax?2:0) | (y<ymin?4:y>ymax?8:0);
    let c0=code(x0,y0), c1=code(x1,y1);
    while (true) {
      if (!(c0|c1)) return [{x:x0,y:y0},{x:x1,y:y1}];
      if (c0&c1)    return null;
      const c = c0||c1;
      let x,y;
      if (c&8)      { x=x0+(x1-x0)*(ymax-y0)/(y1-y0); y=ymax; }
      else if (c&4) { x=x0+(x1-x0)*(ymin-y0)/(y1-y0); y=ymin; }
      else if (c&2) { y=y0+(y1-y0)*(xmax-x0)/(x1-x0); x=xmax; }
      else          { y=y0+(y1-y0)*(xmin-x0)/(x1-x0); x=xmin; }
      if (c===c0) { x0=x;y0=y;c0=code(x,y); }
      else        { x1=x;y1=y;c1=code(x,y); }
    }
  }

  /* ══ SNAP ════════════════════════════════════════════════════ */
  _computeSnap(mx, my) {
    const SNAP_DIST_PX = 14; // радиус снапа в пикселях
    const snapDist = this.vp.toMathDist(SNAP_DIST_PX);

    this._snapPt = null;
    this._snapId = null;

    // 1. Снап к существующим точкам
    let best = Infinity, bestId = null;
    for (const pt of this.eng.points()) {
      const d = Math.hypot(pt.x - mx, pt.y - my);
      if (d < snapDist && d < best) { best = d; bestId = pt.id; }
    }
    if (bestId) {
      const pt = this.eng.get(bestId);
      this._snapPt = { x: pt.x, y: pt.y };
      this._snapId = bestId;
      return { x: pt.x, y: pt.y };
    }

    // 2. Снап к сетке
    if (this.showGrid) {
      const step = this._gridStep();
      const sx = Math.round(mx / step) * step;
      const sy = Math.round(my / step) * step;
      const dpx = this.vp.toCanvasDist(Math.hypot(sx-mx, sy-my));
      if (dpx < SNAP_DIST_PX * 0.7) {
        this._snapPt = { x: sx, y: sy };
        return { x: sx, y: sy };
      }
    }

    return { x: mx, y: my };
  }

  _gridStep() {
    const rawStep = 80 / this.vp.scale;
    const exp = Math.floor(Math.log10(rawStep));
    const frac = rawStep / Math.pow(10, exp);
    let step = frac < 1.5 ? 1 : frac < 3.5 ? 2 : frac < 7.5 ? 5 : 10;
    return step * Math.pow(10, exp);
  }

  /* ══ СОБЫТИЯ ══════════════════════════════════════════════════ */
  _bindEvents() {
    const c = this.canvas;
    c.addEventListener('pointerdown',  e => this._onDown(e));
    c.addEventListener('pointermove',  e => this._onMove(e));
    c.addEventListener('pointerup',    e => this._onUp(e));
    c.addEventListener('pointerleave', e => this._onLeave(e));
    c.addEventListener('wheel',        e => this._onWheel(e), { passive: false });
    c.addEventListener('contextmenu',  e => e.preventDefault());
  }

  _evPos(e) {
    const r = this.canvas.getBoundingClientRect();
    return { px: e.clientX - r.left, py: e.clientY - r.top };
  }

  _onDown(e) {
    e.preventDefault();
    if (this.readOnly) return;
    const { px, py } = this._evPos(e);

    // ПКМ → отмена текущего построения
    if (e.button === 2) {
      this._pending = []; this._preview = null; this._pendingLineRef = null; this._pendingMover = null;
      this.render(); return;
    }

    // Пан (средняя кнопка или Alt+ЛКМ)
    if (e.button === 1 || e.altKey) {
      this._panning = true; this._panLast = { px, py };
      this.canvas.style.cursor = 'grabbing';
      return;
    }

    const m = this.vp.toMath(px, py);
    const snapped = this._computeSnap(m.x, m.y);

    if (this.tool === 'select') {
      this._handleSelectDown(snapped, px, py);
      return;
    }

    this._handleToolClick(snapped, px, py);
  }

  _handleSelectDown(m, px, py) {
    // Найти точку под курсором
    const SNAP_PX = 12;
    let found = null;
    for (const pt of this.eng.points()) {
      const pp = this.vp.toCanvas(pt.x, pt.y);
      if (Math.hypot(pp.x-px, pp.y-py) < SNAP_PX) { found = pt; break; }
    }

    const isConstrained = found && found.derived &&
      (found.constr === 'on_segment' || found.constr === 'on_circle');
    if (found && !found.locked && (!found.derived || isConstrained)) {
      this._drag = { id: found.id, constrained: isConstrained };
      this._selected = found;
      this.canvas.style.cursor = 'grabbing';
    } else {
      // Проверить, не кликнули ли на чип измерения (для drag чипа)
      const hitObj = this._hitTest(px, py);
      this._selected = hitObj;
      if (hitObj && (hitObj.type === 'measure_length' || hitObj.type === 'measure_angle' || hitObj.type === 'measure_area')) {
        // drag чипа — запоминаем offset курсора относительно позиции чипа
        const lp = this._measureLabelPos(hitObj);
        this._drag = { id: hitObj.id, chipDrag: true, offX: px - (lp ? lp.x : px), offY: py - (lp ? lp.y : py) };
        this.canvas.style.cursor = 'grabbing';
      } else {
        this._drag = null;
      }
    }
    this.render();
  }

  /** Hit-test для не-точечных объектов */
  _hitTest(px, py) {
    const HIT = 8; // pixels
    const m = this.vp.toMath(px, py);

    // Измерительные чипы
    this.ctx.font = '11px Manrope,sans-serif';
    for (const obj of this.eng.all()) {
      if (obj.type !== 'measure_length' && obj.type !== 'measure_angle' && obj.type !== 'measure_area') continue;
      const text = this._measureText(obj);
      if (!text) continue;
      const labelPx = this._measureLabelPos(obj);
      if (!labelPx) continue;
      const w = this.ctx.measureText(text).width + 16;
      const h = 18;
      if (px >= labelPx.x - w/2 - 2 && px <= labelPx.x + w/2 + 2 &&
          py >= labelPx.y - h/2 - 2 && py <= labelPx.y + h/2 + 2) {
        return obj;
      }
    }
    // Локусы
    for (const obj of this.eng.byType('locus')) {
      const pts = obj.samples;
      if (!pts || pts.length < 2) continue;
      for (let i = 1; i < pts.length; i++) {
        const a = this.vp.toCanvas(pts[i-1].x, pts[i-1].y);
        const b = this.vp.toCanvas(pts[i].x, pts[i].y);
        const seg2D = { x: b.x-a.x, y: b.y-a.y };
        const lenSq = seg2D.x*seg2D.x + seg2D.y*seg2D.y;
        if (lenSq < 1e-9) continue;
        const t = Math.max(0, Math.min(1, ((px-a.x)*seg2D.x + (py-a.y)*seg2D.y) / lenSq));
        const dx = px - (a.x + t*seg2D.x), dy = py - (a.y + t*seg2D.y);
        if (dx*dx + dy*dy < HIT*HIT) return obj;
      }
    }

    // Полигоны (проверяем стороны)
    for (const obj of this.eng.byType('polygon')) {
      const ids = obj.pointIds;
      for (let i = 0; i < ids.length; i++) {
        const m1 = this._mpt(ids[i]), m2 = this._mpt(ids[(i+1)%ids.length]);
        if (!m1||!m2) continue;
        if (gDistToSegment(m, m1, m2) * this.vp.scale < HIT) return obj;
      }
    }
    // Отрезки (виртуальные стороны полигонов не выбираемы напрямую)
    for (const obj of this.eng.byType('segment')) {
      if (obj.virtual) continue;
      const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id);
      if (!m1||!m2) continue;
      if (gDistToSegment(m, m1, m2) * this.vp.scale < HIT) return obj;
    }
    // Окружности
    for (const obj of this.eng.byType('circle')) {
      const mc = this._mpt(obj.centerId), me = this._mpt(obj.edgeId);
      if (!mc||!me) continue;
      const r = gDist(mc, me);
      const d = Math.abs(gDist(m, mc) - r);
      if (d * this.vp.scale < HIT) return obj;
    }
    // Прямые
    for (const obj of this.eng.byType('line')) {
      const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id);
      if (!m1||!m2) continue;
      if (gDistToLine(m, m1, m2) * this.vp.scale < HIT) return obj;
    }
    // Лучи
    for (const obj of this.eng.byType('ray')) {
      const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id);
      if (!m1||!m2) continue;
      if (gDistToLine(m, m1, m2) * this.vp.scale < HIT) return obj;
    }
    // Производные прямые
    for (const obj of this.eng.byType('derived_line')) {
      const pts = this._twoPointsOnObj(obj);
      if (!pts) continue;
      if (gDistToLine(m, pts[0], pts[1]) * this.vp.scale < HIT) return obj;
    }
    return null;
  }

  _handleToolClick(snapped, px, py) {
    switch (this.tool) {
      case 'point':
        this._pushUndo();
        this._addPoint(snapped);
        break;

      case 'segment':
      case 'line':
      case 'ray': {
        this._pending.push(snapped);
        if (this._pending.length === 2) {
          this._pushUndo();
          const [p1, p2] = this._pending;
          const pt1 = this._ensurePoint(p1);
          const pt2 = this._ensurePoint(p2);
          if (this.tool === 'segment') {
            this.eng.add({ type:'segment', p1Id:pt1.id, p2Id:pt2.id, style:{color:'#9B5DE5'} });
          } else if (this.tool === 'line') {
            this.eng.add({ type:'line', p1Id:pt1.id, p2Id:pt2.id, style:{color:'#06D6E0'} });
          } else {
            this.eng.add({ type:'ray', p1Id:pt1.id, p2Id:pt2.id, style:{color:'#F15BB5'} });
          }
          this._pending = [];
          this._preview = null;
          if (this.onUpdate) this.onUpdate(this.getStats());
          if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.4, volume: 0.3 });
        }
        break;
      }

      case 'circle':
        this._pending.push(snapped);
        if (this._pending.length === 2) {
          this._pushUndo();
          const [c, e] = this._pending;
          const pc = this._ensurePoint(c);
          const pe = this._ensurePoint(e);
          this.eng.add({ type:'circle', centerId:pc.id, edgeId:pe.id, style:{color:'#FFB347'} });
          this._pending = [];
          this._preview = null;
          if (this.onUpdate) this.onUpdate(this.getStats());
          if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.4, volume: 0.3 });
        }
        break;

      case 'triangle':
        this._pending.push(snapped);
        if (this._pending.length === 3) {
          this._pushUndo();
          const pts = this._pending.map(p => this._ensurePoint(p));
          this.eng.add({ type:'polygon', pointIds:pts.map(p=>p.id),
            style:{color:'#22d55e', fillColor:'rgba(34,213,94,0.08)'} });
          this._pending = [];
          this._preview = null;
          if (this.onUpdate) this.onUpdate(this.getStats());
        }
        break;

      case 'quad':
        this._pending.push(snapped);
        if (this._pending.length === 4) {
          this._pushUndo();
          const pts = this._pending.map(p => this._ensurePoint(p));
          this.eng.add({ type:'polygon', pointIds:pts.map(p=>p.id),
            style:{color:'#22d55e', fillColor:'rgba(34,213,94,0.08)'} });
          this._pending = [];
          this._preview = null;
          if (this.onUpdate) this.onUpdate(this.getStats());
        }
        break;

      case 'polygon':
        // Незамкнутый многоугольник — двойной клик или клик на первую точку замыкает
        if (this._pending.length > 0 && this._snapId === this._pending[0]._id) {
          // Замкнуть
          this._finishPolygon();
        } else {
          this._pending.push({ ...snapped, _id: this._snapId });
        }
        break;

      /* ══ Phase 2: Инструменты построения ══ */

      case 'midpoint': {
        this._pending.push(snapped);
        if (this._pending.length === 2) {
          this._pushUndo();
          const pt1 = this._ensurePoint(this._pending[0]);
          const pt2 = this._ensurePoint(this._pending[1]);
          const lbl = 'M' + (this.eng.byType('point').filter(p=>p.constr==='midpoint').length+1||'');
          this.eng.add({
            type:'point', derived:true, constr:'midpoint',
            srcA:pt1.id, srcB:pt2.id,
            x:(pt1.x+pt2.x)/2, y:(pt1.y+pt2.y)/2,
            label:lbl, style:{color:'#22d55e', size:4}
          });
          this._pending = []; this._preview = null;
          if (this.onUpdate) this.onUpdate(this.getStats());
        }
        break;
      }

      case 'perpbisect': {
        this._pending.push(snapped);
        if (this._pending.length === 2) {
          this._pushUndo();
          const pt1 = this._ensurePoint(this._pending[0]);
          const pt2 = this._ensurePoint(this._pending[1]);
          const dx = pt2.x-pt1.x, dy = pt2.y-pt1.y, len = Math.hypot(dx,dy);
          if (len > 1e-12) {
            const cnt = this.eng.byType('derived_line').filter(d=>d.constr==='perpbisect').length;
            this.eng.add({
              type:'derived_line', derived:true, constr:'perpbisect',
              srcA:pt1.id, srcB:pt2.id,
              ptX:(pt1.x+pt2.x)/2, ptY:(pt1.y+pt2.y)/2,
              dirX:-dy/len, dirY:dx/len,
              label: cnt ? 'l'+(cnt+1) : 'l₁',
              style:{color:'#A78BFA'}
            });
          }
          this._pending = []; this._preview = null;
          if (this.onUpdate) this.onUpdate(this.getStats());
        }
        break;
      }

      case 'anglebisect': {
        this._pending.push(snapped);
        if (this._pending.length === 3) {
          this._pushUndo();
          const ptA   = this._ensurePoint(this._pending[0]);
          const ptVtx = this._ensurePoint(this._pending[1]);
          const ptB   = this._ensurePoint(this._pending[2]);
          const va = gNorm({x:ptA.x-ptVtx.x, y:ptA.y-ptVtx.y});
          const vb = gNorm({x:ptB.x-ptVtx.x, y:ptB.y-ptVtx.y});
          const bis = gNorm({x:va.x+vb.x, y:va.y+vb.y});
          if (Math.hypot(bis.x,bis.y) > 1e-12) {
            const cnt = this.eng.byType('derived_line').filter(d=>d.constr==='anglebisect').length;
            this.eng.add({
              type:'derived_line', derived:true, constr:'anglebisect',
              srcA:ptA.id, srcVtx:ptVtx.id, srcB:ptB.id,
              ptX:ptVtx.x, ptY:ptVtx.y,
              dirX:bis.x, dirY:bis.y,
              label: cnt ? 'b'+(cnt+1) : 'b₁',
              style:{color:'#FB923C'}
            });
          }
          this._pending = []; this._preview = null;
          if (this.onUpdate) this.onUpdate(this.getStats());
        }
        break;
      }

      case 'parallel':
      case 'perpendicular': {
        if (!this._pendingLineRef) {
          // Первый клик: ищем линейный объект
          const hit = this._hitTestLine(px, py);
          if (hit) {
            this._pendingLineRef = hit;
            if (this.onHintChange) this.onHintChange(this.tool, 2);
          }
        } else {
          // Второй клик: точка через которую проводим прямую
          this._pushUndo();
          const throughPt = this._ensurePoint(snapped);
          const hit = this._pendingLineRef;
          let d1, d2;
          if (hit.type === 'derived_line') {
            d1 = { x:hit.ptX, y:hit.ptY };
            d2 = { x:hit.ptX+hit.dirX, y:hit.ptY+hit.dirY };
          } else {
            d1 = this._mpt(hit.p1Id); d2 = this._mpt(hit.p2Id);
          }
          if (d1 && d2) {
            const dx = d2.x-d1.x, dy = d2.y-d1.y, len = Math.hypot(dx,dy);
            if (len > 1e-12) {
              let dirX, dirY;
              const srcDirPt1 = hit.p1Id || null, srcDirPt2 = hit.p2Id || null;
              if (this.tool === 'parallel') {
                dirX = dx/len; dirY = dy/len;
              } else {
                dirX = -dy/len; dirY = dx/len;
              }
              const cnt = this.eng.byType('derived_line').filter(d=>d.constr===this.tool).length;
              this.eng.add({
                type:'derived_line', derived:true, constr:this.tool,
                srcPt:throughPt.id,
                srcDirPt1: srcDirPt1, srcDirPt2: srcDirPt2,
                ptX:throughPt.x, ptY:throughPt.y, dirX, dirY,
                label: (this.tool==='parallel' ? 'p' : '⊥') + (cnt+1||''),
                style:{color: this.tool==='parallel' ? '#4CC9F0' : '#FF9F43'}
              });
            }
          }
          this._pendingLineRef = null; this._pending = []; this._preview = null;
          if (this.onUpdate) this.onUpdate(this.getStats());
        }
        break;
      }

      case 'intersect': {
        const hit = this._hitTestLine(px, py);
        if (!hit) break;
        if (!this._pendingLineRef) {
          this._pendingLineRef = hit;
          if (this.onHintChange) this.onHintChange('intersect', 2);
        } else if (hit !== this._pendingLineRef) {
          this._pushUndo();
          const pts1 = this._twoPointsOnObj(this._pendingLineRef);
          const pts2 = this._twoPointsOnObj(hit);
          if (pts1 && pts2) {
            const iPt = gIntersectLines(pts1[0], pts1[1], pts2[0], pts2[1]);
            if (iPt) {
              const lbl = this._nextLabel();
              this.eng.add({
                type:'point', derived:true, constr:'intersect',
                src1:this._pendingLineRef.id, src2:hit.id,
                x:iPt.x, y:iPt.y, valid:true,
                label:lbl, style:{color:'#F15BB5', size:5}
              });
            }
          }
          this._pendingLineRef = null; this._pending = [];
          if (this.onUpdate) this.onUpdate(this.getStats());
        }
        break;
      }

      /* ══ Phase 3: foot, circumcircle, incircle ══ */

      case 'foot': {
        if (!this._pendingLineRef) {
          // Первый клик: выбрать прямую
          const hit = this._hitTestLine(px, py);
          if (hit) {
            this._pendingLineRef = hit;
            if (this.onHintChange) this.onHintChange('foot', 2);
          }
        } else {
          // Второй клик: выбрать точку, с которой опускаем перпендикуляр
          this._pushUndo();
          const srcPt = this._ensurePoint(snapped);
          const sl = this._pendingLineRef;
          let L1, L2;
          if (sl.type === 'derived_line') {
            L1 = { x: sl.ptX, y: sl.ptY };
            L2 = { x: sl.ptX + sl.dirX, y: sl.ptY + sl.dirY };
          } else {
            L1 = this._mpt(sl.p1Id); L2 = this._mpt(sl.p2Id);
          }
          if (L1 && L2) {
            const f   = gFoot({ x: srcPt.x, y: srcPt.y }, L1, L2);
            const lbl = this._nextLabel();
            this.eng.add({
              type: 'point', derived: true, constr: 'foot',
              srcLine: sl.id, srcPt: srcPt.id,
              x: f.x, y: f.y,
              label: lbl, style: { color: '#4ADE80', size: 4 }
            });
          }
          this._pendingLineRef = null; this._pending = []; this._preview = null;
          if (this.onUpdate) this.onUpdate(this.getStats());
        }
        break;
      }

      case 'circumcircle':
      case 'incircle': {
        this._pending.push(snapped);
        if (this._pending.length === 3) {
          this._pushUndo();
          const pts = this._pending.map(p => this._ensurePoint(p));
          const A = { x: pts[0].x, y: pts[0].y };
          const B = { x: pts[1].x, y: pts[1].y };
          const C = { x: pts[2].x, y: pts[2].y };
          const cc = this.tool === 'circumcircle'
            ? gCircumcircle(A, B, C)
            : gIncircle(A, B, C);
          if (cc) {
            const sameConstr = this.eng.byType('circle').filter(c => c.constr === this.tool).length;
            const isCircum   = this.tool === 'circumcircle';
            this.eng.add({
              type: 'circle', derived: true, constr: this.tool,
              ptA: pts[0].id, ptB: pts[1].id, ptC: pts[2].id,
              cx: cc.cx, cy: cc.cy, r: cc.r, valid: true,
              label: isCircum
                ? (sameConstr ? 'C' + (sameConstr+1) : 'C₁')
                : (sameConstr ? 'I' + (sameConstr+1) : 'I₁'),
              style: { color: isCircum ? '#38BDF8' : '#34D399' }
            });
          }
          this._pending = []; this._preview = null;
          if (this.onUpdate) this.onUpdate(this.getStats());
        }
        break;
      }

      /* ══ Phase 4: reflect, ngon ══ */

      case 'reflect': {
        if (!this._pendingLineRef) {
          // Первый клик: выбрать ось симметрии (прямую/отрезок)
          const hit = this._hitTestLine(px, py);
          if (hit) {
            this._pendingLineRef = hit;
            if (this.onHintChange) this.onHintChange('reflect', 2);
          }
        } else {
          // Второй клик: точка, которую отражаем
          this._pushUndo();
          const srcPt = this._ensurePoint(snapped);
          const sl = this._pendingLineRef;
          let L1, L2;
          if (sl.type === 'derived_line') {
            L1 = { x: sl.ptX, y: sl.ptY };
            L2 = { x: sl.ptX + sl.dirX, y: sl.ptY + sl.dirY };
          } else {
            L1 = this._mpt(sl.p1Id); L2 = this._mpt(sl.p2Id);
          }
          if (L1 && L2) {
            const f   = gFoot({ x: srcPt.x, y: srcPt.y }, L1, L2);
            const lbl = this._nextLabel();
            this.eng.add({
              type: 'point', derived: true, constr: 'reflect',
              srcLine: sl.id, srcPt: srcPt.id,
              x: 2*f.x - srcPt.x,
              y: 2*f.y - srcPt.y,
              label: lbl, style: { color: '#F472B6', size: 4 }
            });
          }
          this._pendingLineRef = null; this._pending = []; this._preview = null;
          if (this.onUpdate) this.onUpdate(this.getStats());
        }
        break;
      }

      case 'ngon': {
        this._pending.push(snapped);
        if (this._pending.length === 2) {
          this._pushUndo();
          const n      = this._ngonSides;
          const center = this._ensurePoint(this._pending[0]);
          const v0     = this._ensurePoint(this._pending[1]);
          const col    = '#D4B96B';
          const ptIds  = [v0.id];

          for (let k = 1; k < n; k++) {
            const angle = 2 * Math.PI * k / n;
            const dx = v0.x - center.x, dy = v0.y - center.y;
            const cos_a = Math.cos(angle), sin_a = Math.sin(angle);
            const vk = this.eng.add({
              type: 'point', derived: true, constr: 'ngon_vertex',
              srcCenter: center.id, srcVertex: v0.id, k, n,
              x: center.x + dx*cos_a - dy*sin_a,
              y: center.y + dx*sin_a + dy*cos_a,
              label: '', style: { color: col, size: 4 }
            });
            ptIds.push(vk.id);
          }

          this.eng.add({
            type: 'polygon', pointIds: ptIds,
            style: { color: col, fillColor: 'rgba(212,185,107,0.08)' }
          });

          this._pending = []; this._preview = null;
          if (this.onUpdate) this.onUpdate(this.getStats());
        }
        break;
      }

      /* ══ Phase 5: tangent, translate ══ */

      case 'tangent': {
        if (!this._pendingCircRef) {
          // Первый клик: выбрать окружность
          const hit = this._hitTestCircle(px, py);
          if (hit) {
            this._pendingCircRef = hit;
            if (this.onHintChange) this.onHintChange('tangent', 2);
          }
        } else {
          // Второй клик: внешняя точка → создать 2 касательные
          this._pushUndo();
          const extPt = this._ensurePoint(snapped);
          const circ  = this._pendingCircRef;
          let O, r;
          if (circ.derived && circ.cx != null) {
            O = { x: circ.cx, y: circ.cy }; r = circ.r;
          } else {
            const mc = this._mpt(circ.centerId), me = this._mpt(circ.edgeId);
            O = mc; r = mc && me ? gDist(mc, me) : 0;
          }
          if (O && r > 0) {
            const tpts = gTangentPoints(O, { x: extPt.x, y: extPt.y }, r);
            if (tpts) {
              const cnt = this.eng.byType('derived_line').filter(d => d.constr === 'tangent').length;
              for (let w = 0; w < 2; w++) {
                const T = tpts[w];
                const dx = T.x - extPt.x, dy = T.y - extPt.y;
                const len = Math.hypot(dx, dy);
                if (len < 1e-12) continue;
                const lbl = cnt + w === 0 ? 't₁' : 't' + (cnt + w + 1);
                this.eng.add({
                  type: 'derived_line', derived: true, constr: 'tangent',
                  srcCircle: circ.id, srcPt: extPt.id, which: w,
                  ptX: extPt.x, ptY: extPt.y,
                  dirX: dx/len, dirY: dy/len,
                  valid: true,
                  label: lbl, style: { color: '#FCD34D' }
                });
              }
            }
          }
          this._pendingCircRef = null; this._pending = []; this._preview = null;
          if (this.onUpdate) this.onUpdate(this.getStats());
        }
        break;
      }

      case 'translate': {
        this._pending.push(snapped);
        if (this._pending.length === 1) {
          if (this.onHintChange) this.onHintChange('translate', 2);
        } else if (this._pending.length === 2) {
          if (this.onHintChange) this.onHintChange('translate', 3);
        } else if (this._pending.length === 3) {
          this._pushUndo();
          const ptA = this._ensurePoint(this._pending[0]);
          const ptB = this._ensurePoint(this._pending[1]);
          const ptP = this._ensurePoint(this._pending[2]);
          const lbl = this._nextLabel();
          this.eng.add({
            type: 'point', derived: true, constr: 'translate',
            srcA: ptA.id, srcB: ptB.id, srcPt: ptP.id,
            x: ptP.x + (ptB.x - ptA.x),
            y: ptP.y + (ptB.y - ptA.y),
            label: lbl, style: { color: '#60A5FA', size: 4 }
          });
          this._pending = []; this._preview = null;
          if (this.onUpdate) this.onUpdate(this.getStats());
        }
        break;
      }

      /* ══ Phase 8: tick marks, arc marks ══ */

      case 'tick': {
        // Клик на отрезок или сторону полигона → циклически меняет метку (0→1→2→3→0)
        const line = this._hitTestLine(px, py);
        if (line) {
          this._pushUndo();
          if (line.virtual && line.polyId) {
            const poly = this.eng.get(line.polyId);
            if (poly) {
              if (!poly.sideMarks) poly.sideMarks = new Array(poly.pointIds.length).fill(0);
              const si = poly.pointIds.indexOf(line.p1Id);
              if (si >= 0) poly.sideMarks[si] = ((poly.sideMarks[si] || 0) + 1) % 4;
            }
          } else {
            const seg = this.eng.get(line.id);
            if (seg) seg.tickMark = ((seg.tickMark || 0) + 1) % 4;
          }
          if (this.onUpdate) this.onUpdate(this.getStats());
        }
        break;
      }

      case 'arcmark': {
        // Клик на вершину полигона → циклически меняет метку дуги (0→1→2→3→0)
        const SNAP_PX = 14;
        let foundPoly = null, foundIdx = -1;
        outer8:
        for (const poly of this.eng.byType('polygon')) {
          for (let i = 0; i < poly.pointIds.length; i++) {
            const p = this._p(poly.pointIds[i]);
            if (p && Math.hypot(p.x - px, p.y - py) < SNAP_PX) {
              foundPoly = poly; foundIdx = i; break outer8;
            }
          }
        }
        if (foundPoly && foundIdx >= 0) {
          this._pushUndo();
          if (!foundPoly.angleMarks) foundPoly.angleMarks = new Array(foundPoly.pointIds.length).fill(0);
          foundPoly.angleMarks[foundIdx] = (foundPoly.angleMarks[foundIdx] + 1) % 4;
          if (this.onUpdate) this.onUpdate(this.getStats());
        }
        break;
      }

      /* ══ Phase 7: altitude, median, centroid, orthocenter ══ */

      case 'altitude': {
        // 1 клик на вершину треугольника → высота из этой вершины на противоположную сторону
        const hitAlt = this._findVertexOfPoly(px, py, 3);
        if (hitAlt) {
          this._pushUndo();
          const { poly: polyAlt, idx: iA } = hitAlt;
          const ids = polyAlt.pointIds;
          const iB = (iA+1)%3, iC = (iA+2)%3;
          const ptA = this.eng.get(ids[iA]), ptB = this.eng.get(ids[iB]), ptC = this.eng.get(ids[iC]);
          const A = {x:ptA.x,y:ptA.y}, B = {x:ptB.x,y:ptB.y}, C = {x:ptC.x,y:ptC.y};
          const f = gFoot(A, B, C);
          const nF = this.eng.byType('point').filter(p=>p.constr==='altitude_foot').length;
          const foot = this.eng.add({ type:'point', derived:true, constr:'altitude_foot',
            ptA:ids[iA], ptB:ids[iB], ptC:ids[iC], x:f.x, y:f.y,
            label:'H'+(nF ? String.fromCharCode(0x2080+nF+1) : '\u2081'),
            style:{color:'#4ADE80', size:4} });
          this.eng.add({ type:'segment', p1Id:ids[iA], p2Id:foot.id,
            style:{color:'#4ADE80', width:1.5} });
          if (this.onUpdate) this.onUpdate(this.getStats());
        }
        break;
      }

      case 'median': {
        // 1 клик на вершину треугольника → медиана из этой вершины к середине противоположной стороны
        const hitMed = this._findVertexOfPoly(px, py, 3);
        if (hitMed) {
          this._pushUndo();
          const { poly: polyMed, idx: iA } = hitMed;
          const ids = polyMed.pointIds;
          const iB = (iA+1)%3, iC = (iA+2)%3;
          const ptB = this.eng.get(ids[iB]), ptC = this.eng.get(ids[iC]);
          const nMid = this.eng.byType('point').filter(p=>p.constr==='midpoint').length;
          const mid = this.eng.add({ type:'point', derived:true, constr:'midpoint',
            srcA:ids[iB], srcB:ids[iC],
            x:(ptB.x+ptC.x)/2, y:(ptB.y+ptC.y)/2,
            label:'M'+(nMid+1), style:{color:'#22d55e', size:4} });
          this.eng.add({ type:'segment', p1Id:ids[iA], p2Id:mid.id,
            style:{color:'#22d55e', width:1.5} });
          if (this.onUpdate) this.onUpdate(this.getStats());
        }
        break;
      }

      case 'centroid': {
        // 1 клик на/внутри треугольника → 3 медианы + центроид G
        const polyCen = this._findTriangleNear(px, py);
        if (polyCen) {
          this._pushUndo();
          const [idA, idB, idC] = polyCen.pointIds;
          const pA = this.eng.get(idA), pB = this.eng.get(idB), pC = this.eng.get(idC);
          const nG  = this.eng.byType('point').filter(p=>p.constr==='centroid').length;
          const col = '#A78BFA';
          const mBC = this.eng.add({ type:'point', derived:true, constr:'midpoint', srcA:idB, srcB:idC, x:(pB.x+pC.x)/2, y:(pB.y+pC.y)/2, label:'M₁', style:{color:col,size:3} });
          const mAC = this.eng.add({ type:'point', derived:true, constr:'midpoint', srcA:idA, srcB:idC, x:(pA.x+pC.x)/2, y:(pA.y+pC.y)/2, label:'M₂', style:{color:col,size:3} });
          const mAB = this.eng.add({ type:'point', derived:true, constr:'midpoint', srcA:idA, srcB:idB, x:(pA.x+pB.x)/2, y:(pA.y+pB.y)/2, label:'M₃', style:{color:col,size:3} });
          this.eng.add({ type:'segment', p1Id:idA, p2Id:mBC.id, style:{color:col, width:1.5} });
          this.eng.add({ type:'segment', p1Id:idB, p2Id:mAC.id, style:{color:col, width:1.5} });
          this.eng.add({ type:'segment', p1Id:idC, p2Id:mAB.id, style:{color:col, width:1.5} });
          this.eng.add({ type:'point', derived:true, constr:'centroid',
            ptA:idA, ptB:idB, ptC:idC,
            x:(pA.x+pB.x+pC.x)/3, y:(pA.y+pB.y+pC.y)/3,
            label: nG ? 'G'+(nG+1) : 'G', style:{color:col, size:6} });
          if (this.onUpdate) this.onUpdate(this.getStats());
        }
        break;
      }

      case 'orthocenter': {
        // 1 клик на/внутри треугольника → 3 высоты + ортоцентр H
        const polyOrt = this._findTriangleNear(px, py);
        if (polyOrt) {
          this._pushUndo();
          const [idA, idB, idC] = polyOrt.pointIds;
          const pA = this.eng.get(idA), pB = this.eng.get(idB), pC = this.eng.get(idC);
          const nH  = this.eng.byType('point').filter(p=>p.constr==='orthocenter').length;
          const col = '#F97316';
          const A = {x:pA.x,y:pA.y}, B = {x:pB.x,y:pB.y}, C = {x:pC.x,y:pC.y};
          const Ha = this.eng.add({ type:'point', derived:true, constr:'altitude_foot', ptA:idA, ptB:idB, ptC:idC, ...gFoot(A,B,C), label:'H_a', style:{color:col,size:3} });
          const Hb = this.eng.add({ type:'point', derived:true, constr:'altitude_foot', ptA:idB, ptB:idA, ptC:idC, ...gFoot(B,A,C), label:'H_b', style:{color:col,size:3} });
          const Hc = this.eng.add({ type:'point', derived:true, constr:'altitude_foot', ptA:idC, ptB:idA, ptC:idB, ...gFoot(C,A,B), label:'H_c', style:{color:col,size:3} });
          this.eng.add({ type:'segment', p1Id:idA, p2Id:Ha.id, style:{color:col, width:1.5} });
          this.eng.add({ type:'segment', p1Id:idB, p2Id:Hb.id, style:{color:col, width:1.5} });
          this.eng.add({ type:'segment', p1Id:idC, p2Id:Hc.id, style:{color:col, width:1.5} });
          const orth = gOrthocenter(A, B, C);
          if (orth) {
            this.eng.add({ type:'point', derived:true, constr:'orthocenter',
              ptA:idA, ptB:idB, ptC:idC,
              x:orth.x, y:orth.y, valid:true,
              label: nH ? 'H'+(nH+1) : 'H', style:{color:col, size:6} });
          }
          if (this.onUpdate) this.onUpdate(this.getStats());
        }
        break;
      }

      /* ══ Phase 8.3: Метка параллельности ══ */

      case 'parallelmark': {
        // Клик на отрезок или сторону полигона → циклически меняет parallelMark (0→1→2→0)
        const line = this._hitTestLine(px, py);
        if (line) {
          this._pushUndo();
          if (line.virtual && line.polyId) {
            const poly = this.eng.get(line.polyId);
            if (poly) {
              if (!poly.parallelSideMarks) poly.parallelSideMarks = new Array(poly.pointIds.length).fill(0);
              const si = poly.pointIds.indexOf(line.p1Id);
              if (si >= 0) poly.parallelSideMarks[si] = ((poly.parallelSideMarks[si] || 0) + 1) % 3;
            }
          } else {
            const seg = this.eng.get(line.id);
            if (seg) seg.parallelMark = ((seg.parallelMark || 0) + 1) % 3;
          }
          if (this.onUpdate) this.onUpdate(this.getStats());
        }
        break;
      }

      /* ══ Phase 9.1: Средняя линия треугольника ══ */

      case 'midline': {
        // 3 клика: A, B, C → середины AB и AC, отрезок M₁M₂ параллельный BC
        this._pending.push(snapped);
        if (this._pending.length === 1) {
          if (this.onHintChange) this.onHintChange('midline', 2);
        } else if (this._pending.length === 2) {
          if (this.onHintChange) this.onHintChange('midline', 3);
        } else if (this._pending.length === 3) {
          this._pushUndo();
          const pA = this._ensurePoint(this._pending[0]);
          const pB = this._ensurePoint(this._pending[1]);
          const pC = this._ensurePoint(this._pending[2]);
          const n  = this.eng.byType('point').filter(p => p.constr === 'midpoint').length;
          const col = '#06D6E0';
          const mAB = this.eng.add({ type:'point', derived:true, constr:'midpoint',
            srcA:pA.id, srcB:pB.id, x:(pA.x+pB.x)/2, y:(pA.y+pB.y)/2,
            label: n ? `M${n+1}` : 'M₁', style:{color:col, size:3} });
          const mAC = this.eng.add({ type:'point', derived:true, constr:'midpoint',
            srcA:pA.id, srcB:pC.id, x:(pA.x+pC.x)/2, y:(pA.y+pC.y)/2,
            label: n ? `M${n+2}` : 'M₂', style:{color:col, size:3} });
          this.eng.add({ type:'segment', p1Id:mAB.id, p2Id:mAC.id,
            style:{color:col, width:2} });
          this._pending = []; this._preview = null;
          if (this.onUpdate) this.onUpdate(this.getStats());
        }
        break;
      }

      /* ══ Phase 9.2: Параллелограмм ══ */

      case 'parallelogram': {
        // 3 клика: A, B, C → D = A + C - B, полигон ABCD
        this._pending.push(snapped);
        if (this._pending.length === 1) {
          if (this.onHintChange) this.onHintChange('parallelogram', 2);
        } else if (this._pending.length === 2) {
          if (this.onHintChange) this.onHintChange('parallelogram', 3);
        } else if (this._pending.length === 3) {
          this._pushUndo();
          const pA = this._ensurePoint(this._pending[0]);
          const pB = this._ensurePoint(this._pending[1]);
          const pC = this._ensurePoint(this._pending[2]);
          const col = '#F97316';
          const dx = pA.x + pC.x - pB.x, dy = pA.y + pC.y - pB.y;
          const pD = this.eng.add({ type:'point', derived:true, constr:'parallelogram_d',
            ptA:pA.id, ptB:pB.id, ptC:pC.id, x:dx, y:dy,
            label:this._nextLabel(), style:{color:col, size:5} });
          this.eng.add({ type:'polygon', pointIds:[pA.id, pB.id, pC.id, pD.id],
            style:{color:col, fillColor:'rgba(249,115,22,0.08)'} });
          this._pending = []; this._preview = null;
          if (this.onUpdate) this.onUpdate(this.getStats());
        }
        break;
      }

      /* ══ Phase 9.3: Диагонали полигона ══ */

      case 'diagonal': {
        // Клик на полигон → добавляет все диагонали (соединяет несмежные вершины)
        const SNAP_PX = 18;
        let hitPoly = null;
        outer9:
        for (const poly of this.eng.byType('polygon')) {
          const pts = poly.pointIds.map(id => this._p(id)).filter(Boolean);
          // Проверяем попадание внутрь полигона (упрощённо — bbox)
          if (pts.length < 4) continue;
          const xs = pts.map(p => p.x), ys = pts.map(p => p.y);
          const bx = Math.min(...xs), bX = Math.max(...xs);
          const by = Math.min(...ys), bY = Math.max(...ys);
          if (px >= bx - SNAP_PX && px <= bX + SNAP_PX && py >= by - SNAP_PX && py <= bY + SNAP_PX) {
            hitPoly = poly; break outer9;
          }
        }
        if (hitPoly) {
          this._pushUndo();
          const ids = hitPoly.pointIds;
          const n   = ids.length;
          for (let i = 0; i < n - 2; i++) {
            for (let j = i + 2; j < n; j++) {
              if (i === 0 && j === n - 1) continue; // стороны, не диагонали
              this.eng.add({ type:'segment', p1Id:ids[i], p2Id:ids[j],
                style:{color:'#9CA3AF', width:1.5} });
            }
          }
          if (this.onUpdate) this.onUpdate(this.getStats());
        }
        break;
      }

      /* ══ Phase 10.1: Теорема Фалеса ══ */

      case 'thales': {
        // 3 клика: O (центр), A (точка на луче 1), B (точка на луче 2)
        // Строит A' = O + k*(A-O), B' = O + k*(B-O), AB ∥ A'B'
        this._pending.push(snapped);
        if (this._pending.length === 1) {
          if (this.onHintChange) this.onHintChange('thales', 2);
        } else if (this._pending.length === 2) {
          if (this.onHintChange) this.onHintChange('thales', 3);
        } else if (this._pending.length === 3) {
          this._pushUndo();
          const pO = this._ensurePoint(this._pending[0]);
          const pA = this._ensurePoint(this._pending[1]);
          const pB = this._ensurePoint(this._pending[2]);
          const k  = this._scaleK;
          const col = '#06D6E0';
          // A' и B' — производные точки (constr:'scale')
          const pA2 = this.eng.add({ type:'point', derived:true, constr:'scale',
            srcO:pO.id, srcPt:pA.id, k,
            x: pO.x + k*(pA.x-pO.x), y: pO.y + k*(pA.y-pO.y),
            label:"A'", style:{color:col, size:4} });
          const pB2 = this.eng.add({ type:'point', derived:true, constr:'scale',
            srcO:pO.id, srcPt:pB.id, k,
            x: pO.x + k*(pB.x-pO.x), y: pO.y + k*(pB.y-pO.y),
            label:"B'", style:{color:col, size:4} });
          // Лучи O→A'→... (через A и A')
          this.eng.add({ type:'segment', p1Id:pO.id, p2Id:pA2.id,
            style:{color:'#9CA3AF', width:1, dash:[5,4]} });
          this.eng.add({ type:'segment', p1Id:pO.id, p2Id:pB2.id,
            style:{color:'#9CA3AF', width:1, dash:[5,4]} });
          // Отрезок AB (ближняя параллель)
          this.eng.add({ type:'segment', p1Id:pA.id, p2Id:pB.id,
            style:{color:'#FFE066', width:2} });
          // Отрезок A'B' (дальняя параллель)
          this.eng.add({ type:'segment', p1Id:pA2.id, p2Id:pB2.id,
            style:{color:col, width:2} });
          this._pending = []; this._preview = null;
          if (this.onUpdate) this.onUpdate(this.getStats());
        }
        break;
      }

      /* ══ Phase 10.2: Подобие (масштаб) ══ */

      case 'scale': {
        // Шаг 1: клик → центр подобия O
        // Шаг 2: клик → точка P → строит P' = O + k*(P - O)
        if (!this._pendingScaleO) {
          this._pendingScaleO = this._ensurePoint(snapped);
          if (this.onHintChange) this.onHintChange('scale', 2);
        } else {
          this._pushUndo();
          const pO = this._pendingScaleO;
          const pP = this._ensurePoint(snapped);
          const k  = this._scaleK;
          const nx = pO.x + k * (pP.x - pO.x);
          const ny = pO.y + k * (pP.y - pO.y);
          this.eng.add({ type:'point', derived:true, constr:'scale',
            srcO:pO.id, srcPt:pP.id, k,
            x:nx, y:ny,
            label:this._nextLabel(),
            style:{color:'#F15BB5', size:5} });
          if (this.onUpdate) this.onUpdate(this.getStats());
          // Продолжаем с тем же O — можно строить следующие точки
          if (this.onHintChange) this.onHintChange('scale', 2);
        }
        break;
      }

      /* ══ Точка на отрезке — для ГМТ ══ */

      case 'point_on_segment': {
        const hitSeg = this._hitTestLine(px, py);
        if (!hitSeg || hitSeg.type !== 'segment' || hitSeg.virtual) break;
        this._pushUndo();
        const p1 = this.eng.get(hitSeg.p1Id), p2 = this.eng.get(hitSeg.p2Id);
        if (!p1 || !p2) break;
        const dx = p2.x - p1.x, dy = p2.y - p1.y;
        const l2 = dx*dx + dy*dy;
        const t = l2 < 1e-12 ? 0.5
          : Math.max(0, Math.min(1, ((snapped.x-p1.x)*dx + (snapped.y-p1.y)*dy) / l2));
        const lbl = 'P' + (this.eng.points().filter(p => p.constr === 'on_segment' || p.constr === 'on_circle').length + 1);
        this.eng.add({
          type: 'point', derived: true, constr: 'on_segment',
          srcSeg: hitSeg.id, _t: t,
          x: p1.x + t*dx, y: p1.y + t*dy,
          label: lbl, style: { color: '#06D6E0', size: 4 }
        });
        if (this.onUpdate) this.onUpdate(this.getStats());
        break;
      }

      /* ══ Точка на окружности — для ГМТ ══ */

      case 'point_on_circle': {
        const hitCirc = this._hitTestCircle(px, py);
        if (!hitCirc) break;
        this._pushUndo();
        let cx, cy, r;
        if (hitCirc.derived && hitCirc.cx != null) {
          cx = hitCirc.cx; cy = hitCirc.cy; r = hitCirc.r;
        } else {
          const mc = this.eng.get(hitCirc.centerId), me = this.eng.get(hitCirc.edgeId);
          if (!mc || !me) break;
          cx = mc.x; cy = mc.y; r = gDist(mc, me);
        }
        const theta = Math.atan2(snapped.y - cy, snapped.x - cx);
        const lbl = 'P' + (this.eng.points().filter(p => p.constr === 'on_segment' || p.constr === 'on_circle').length + 1);
        this.eng.add({
          type: 'point', derived: true, constr: 'on_circle',
          srcCircle: hitCirc.id, _theta: theta,
          x: cx + r * Math.cos(theta),
          y: cy + r * Math.sin(theta),
          label: lbl, style: { color: '#06D6E0', size: 4 }
        });
        if (this.onUpdate) this.onUpdate(this.getStats());
        break;
      }

      /* ══ Измерение длины — клик на отрезок ══ */

      case 'measure_length': {
        const seg = this._hitTestLine(px, py);
        if (seg && (seg.type === 'segment') && !seg.virtual) {
          this._pushUndo();
          this.eng.add({ type:'measure_length', srcSeg: seg.id, offX:0, offY:0 });
          if (this.onUpdate) this.onUpdate(this.getStats());
        }
        break;
      }

      /* ══ Измерение угла — 3 клика: сторона A, вершина, сторона B ══ */

      case 'measure_angle': {
        this._pending.push(snapped);
        if (this._pending.length === 1) {
          if (this.onHintChange) this.onHintChange('measure_angle', 2);
        } else if (this._pending.length === 2) {
          if (this.onHintChange) this.onHintChange('measure_angle', 3);
        } else if (this._pending.length === 3) {
          this._pushUndo();
          const ptA   = this._ensurePoint(this._pending[0]);
          const ptVtx = this._ensurePoint(this._pending[1]);
          const ptB   = this._ensurePoint(this._pending[2]);
          this.eng.add({ type:'measure_angle', srcA:ptA.id, srcVtx:ptVtx.id, srcB:ptB.id, offX:0, offY:0 });
          this._pending = []; this._preview = null;
          if (this.onUpdate) this.onUpdate(this.getStats());
        }
        break;
      }

      /* ══ Измерение площади — клик на полигон ══ */

      case 'measure_area': {
        const poly = this._hitTest(px, py);
        if (poly && poly.type === 'polygon') {
          this._pushUndo();
          this.eng.add({ type:'measure_area', srcPoly: poly.id, offX:0, offY:0 });
          if (this.onUpdate) this.onUpdate(this.getStats());
        }
        break;
      }

      /* ══ ГМТ (локус) — шаг 1: мовер-точка, шаг 2: целевая точка ══ */

      case 'locus': {
        if (!this._pendingMover) {
          // Первый клик: выбрать точку-мовер (должна быть constrained)
          const SNAP_PX = 12;
          let hitPt = null;
          for (const pt of this.eng.points()) {
            const pp = this.vp.toCanvas(pt.x, pt.y);
            if (Math.hypot(pp.x - px, pp.y - py) < SNAP_PX) { hitPt = pt; break; }
          }
          if (!hitPt) break;
          // Мовер должен быть constrained (точка на отрезке или на окружности по параметру)
          if (!hitPt.constr || (hitPt.constr !== 'on_segment' && hitPt.constr !== 'on_circle')) {
            if (this.onLocusError) this.onLocusError('Выбери точку, ограниченную на отрезке или окружности (тип: on_segment / on_circle)');
            break;
          }
          this._pendingMover = hitPt;
          if (this.onHintChange) this.onHintChange('locus', 2);
        } else {
          // Второй клик: выбрать целевую точку
          const SNAP_PX = 12;
          let hitPt = null;
          for (const pt of this.eng.points()) {
            const pp = this.vp.toCanvas(pt.x, pt.y);
            if (Math.hypot(pp.x - px, pp.y - py) < SNAP_PX) { hitPt = pt; break; }
          }
          if (!hitPt || hitPt === this._pendingMover) break;
          // Проверим, что целевая зависит от мовера
          if (!this._isDownstreamOf(hitPt.id, this._pendingMover.id)) {
            if (this.onLocusError) this.onLocusError('Целевая точка не зависит от выбранного мовера');
            this._pendingMover = null;
            break;
          }
          this._pushUndo();
          const samples = this._sweepLocus(this._pendingMover, hitPt);
          const cnt = this.eng.byType('locus').length;
          this.eng.add({
            type: 'locus',
            srcMover: this._pendingMover.id,
            srcTarget: hitPt.id,
            samples,
            style: { color: '#F59E0B' },
            label: cnt ? 'L' + (cnt + 1) : 'L₁'
          });
          this._pendingMover = null; this._pending = []; this._preview = null;
          if (this.onUpdate) this.onUpdate(this.getStats());
        }
        break;
      }
    }
    this.render();
  }

  /* Проверяет, зависит ли targetId от moverId (BFS по графу зависимостей) */
  _isDownstreamOf(targetId, moverId) {
    const visited = new Set();
    const queue = [moverId];
    while (queue.length) {
      const curr = queue.shift();
      if (curr === targetId) return true;
      if (visited.has(curr)) continue;
      visited.add(curr);
      for (const obj of this.eng.all()) {
        if (!visited.has(obj.id) && this.eng._dependsOn(obj, curr)) {
          queue.push(obj.id);
        }
      }
    }
    return false;
  }

  /* Прогоняет мовер по его диапазону и записывает позиции цели */
  _sweepLocus(moverPt, targetPt) {
    const N = 200;
    const samples = [];
    // Сохранить текущее состояние мовера
    const savedX = moverPt.x, savedY = moverPt.y;
    const savedT = moverPt._t;

    if (moverPt.constr === 'on_segment') {
      const seg = this.eng.get(moverPt.srcSeg);
      if (!seg) return samples;
      for (let i = 0; i <= N; i++) {
        const t = i / N;
        const p1 = this.eng.get(seg.p1Id), p2 = this.eng.get(seg.p2Id);
        if (!p1 || !p2) continue;
        moverPt.x = p1.x + t * (p2.x - p1.x);
        moverPt.y = p1.y + t * (p2.y - p1.y);
        moverPt._t = t;
        this.eng.propagateDeps(moverPt.id);
        samples.push({ x: targetPt.x, y: targetPt.y });
      }
    } else if (moverPt.constr === 'on_circle') {
      const circ = this.eng.get(moverPt.srcCircle);
      if (!circ) return samples;
      for (let i = 0; i <= N; i++) {
        const theta = 2 * Math.PI * i / N;
        let cx, cy, r;
        if (circ.derived && circ.cx != null) {
          cx = circ.cx; cy = circ.cy; r = circ.r;
        } else {
          const mc = this.eng.get(circ.centerId), me = this.eng.get(circ.edgeId);
          if (!mc || !me) continue;
          cx = mc.x; cy = mc.y; r = gDist(mc, me);
        }
        moverPt.x = cx + r * Math.cos(theta);
        moverPt.y = cy + r * Math.sin(theta);
        this.eng.propagateDeps(moverPt.id);
        samples.push({ x: targetPt.x, y: targetPt.y });
      }
    }

    // Восстановить состояние мовера
    moverPt.x = savedX; moverPt.y = savedY;
    if (savedT !== undefined) moverPt._t = savedT; else delete moverPt._t;
    this.eng.propagateDeps(moverPt.id);
    return samples;
  }

  _finishPolygon() {
    if (this._pending.length < 3) { this._pending = []; this._preview = null; this.render(); return; }
    this._pushUndo();
    const pts = this._pending.map(p => this._ensurePoint(p));
    this.eng.add({ type:'polygon', pointIds:pts.map(p=>p.id),
      style:{color:'#22d55e', fillColor:'rgba(34,213,94,0.08)'} });
    this._pending = [];
    this._preview = null;
    if (this.onUpdate) this.onUpdate(this.getStats());
    this.render();
  }

  _addPoint(m) {
    const pt = this.eng.add({ type:'point', x:m.x, y:m.y, label:this._nextLabel(),
      style:{color:'#9B5DE5', size:5} });
    if (this.onUpdate) this.onUpdate(this.getStats());
    if (window.LabFX) {
      LabFX.sound.play('tick', { pitch: 1.4, volume: 0.3 });
      const cp = this.vp.toCanvas(m.x, m.y);
      LabFX.particles.emit({ ctx: this.ctx, x: cp.x, y: cp.y, count: 4, color: '#9B5DE5', shape: 'dust', life: 400, speed: 35, spread: Math.PI * 2, gravity: 0, glow: true });
      if (this._fxFrames !== undefined) this._fxFrames = 60;
    }
    return pt;
  }

  /** Найти или создать точку в мат. координатах */
  _ensurePoint(m) {
    if (m._id && this.eng.has(m._id)) return this.eng.get(m._id);
    // Ищем существующую точку в этой позиции (snap)
    for (const pt of this.eng.points()) {
      if (Math.abs(pt.x - m.x) < 1e-9 && Math.abs(pt.y - m.y) < 1e-9) return pt;
    }
    return this._addPoint(m);
  }

  /** Переместить точку on_segment или on_circle — проецируем мышь на хост-геометрию */
  _moveConstrainedPoint(id, mx, my) {
    const obj = this.eng.get(id);
    if (!obj) return;
    if (obj.constr === 'on_segment') {
      const seg = this.eng.get(obj.srcSeg);
      if (!seg) return;
      const p1 = this.eng.get(seg.p1Id), p2 = this.eng.get(seg.p2Id);
      if (!p1 || !p2) return;
      const dx = p2.x - p1.x, dy = p2.y - p1.y;
      const l2 = dx*dx + dy*dy;
      if (l2 < 1e-12) return;
      const t = Math.max(0, Math.min(1, ((mx-p1.x)*dx + (my-p1.y)*dy) / l2));
      obj._t = t;
      obj.x  = p1.x + t*dx;
      obj.y  = p1.y + t*dy;
    } else if (obj.constr === 'on_circle') {
      const circ = this.eng.get(obj.srcCircle);
      if (!circ) return;
      let cx, cy, r;
      if (circ.derived && circ.cx != null) {
        cx = circ.cx; cy = circ.cy; r = circ.r;
      } else {
        const mc = this.eng.get(circ.centerId), me = this.eng.get(circ.edgeId);
        if (!mc || !me) return;
        cx = mc.x; cy = mc.y; r = gDist(mc, me);
      }
      const theta = Math.atan2(my - cy, mx - cx);
      obj._theta = theta;
      obj.x = cx + r * Math.cos(theta);
      obj.y = cy + r * Math.sin(theta);
    }
    this.eng.propagateDeps(id);
  }

  _onMove(e) {
    const { px, py } = this._evPos(e);

    if (this._panning) {
      this.vp.pan(px - this._panLast.px, py - this._panLast.py);
      this._panLast = { px, py };
      this.render(); return;
    }

    const m = this.vp.toMath(px, py);

    if (this._drag) {
      if (this._drag.chipDrag) {
        // Перетаскивание чипа измерения
        const obj = this.eng.get(this._drag.id);
        if (obj) {
          const basePos = this._measureLabelBasePos(obj);
          if (basePos) {
            obj.offX = (px - this._drag.offX) - basePos.x;
            obj.offY = (py - this._drag.offY) - basePos.y;
          }
        }
        this.render(); return;
      }
      if (this._drag.constrained) {
        this._moveConstrainedPoint(this._drag.id, m.x, m.y);
        this.render(); return;
      }
      const snapped = this._computeSnap(m.x, m.y);
      this.eng.movePoint(this._drag.id, snapped.x, snapped.y);
      this.render(); return;
    }

    // Обновить snap для предпросмотра
    const snapped = this._computeSnap(m.x, m.y);
    this._preview = snapped;

    // Hover
    if (this.tool === 'select') {
      let h = null;
      for (const pt of this.eng.points()) {
        const pp = this.vp.toCanvas(pt.x, pt.y);
        if (Math.hypot(pp.x-px, pp.y-py) < 12) { h = pt; break; }
      }
      if (!h) h = this._hitTest(px, py);
      this._hovered = h;
      this.canvas.style.cursor = h ? 'pointer' : 'default';
    }

    this.render();
  }

  _onUp(e) {
    if (this._panning) {
      this._panning = false;
      this.canvas.style.cursor = this.tool === 'select' ? 'default' : 'crosshair';
    }
    if (this._drag) {
      this._drag = null;
      this.canvas.style.cursor = 'default';
      if (this.onUpdate) this.onUpdate(this.getStats());
    }
  }

  _onLeave(e) {
    this._preview = null;
    this._panning = false;
    this._snapPt = null;
    this.render();
  }

  _onWheel(e) {
    e.preventDefault();
    const { px, py } = this._evPos(e);
    const factor = e.deltaY < 0 ? 1.12 : 1/1.12;
    this.vp.zoom(factor, px, py);
    this.render();
  }

  /* ══ UNDO/REDO ════════════════════════════════════════════════ */
  _pushUndo() {
    this._undoStack.push(this.eng.serialize());
    this._redoStack = [];
    if (this._undoStack.length > 80) this._undoStack.shift();
  }

  undo() {
    if (!this._undoStack.length) return;
    this._redoStack.push(this.eng.serialize());
    this.eng.deserialize(this._undoStack.pop());
    this._selected = null; this._pending = []; this._preview = null;
    this.render();
    if (this.onUpdate) this.onUpdate(this.getStats());
  }

  redo() {
    if (!this._redoStack.length) return;
    this._undoStack.push(this.eng.serialize());
    this.eng.deserialize(this._redoStack.pop());
    this._selected = null;
    this.render();
    if (this.onUpdate) this.onUpdate(this.getStats());
  }

  /* ── Удалить выбранный объект ── */
  deleteSelected() {
    if (!this._selected) return;
    const obj  = this._selected;
    const deps = this.eng.getDependents(obj.id).filter(d => !d.virtual);
    if (deps.length > 0 && this.onDeleteRequest) {
      // Есть зависимые — делегировать подтверждение наружу
      this.onDeleteRequest(obj, deps,
        () => this._doDeleteSoft(obj.id),
        () => this._doDeleteCascade(obj.id)
      );
    } else {
      this._doDeleteCascade(obj.id);
    }
  }

  _doDeleteCascade(id) {
    this._pushUndo();
    this.eng.remove(id);
    this._selected = null;
    this.render();
    if (this.onUpdate) this.onUpdate(this.getStats());
  }

  /* Мягкое удаление: derived-точки становятся свободными, остальное каскадируется */
  _doDeleteSoft(id) {
    this._pushUndo();
    for (const dep of this.eng.getDependents(id)) {
      if (dep.type === 'point' && dep.derived) {
        // Фиксируем текущие координаты и делаем точку свободной
        dep.derived = null; dep.constr = null;
        dep.srcLine = dep.srcPt = dep.srcA = dep.srcB =
        dep.srcCenter = dep.srcVertex = dep.srcDirPt1 = dep.srcDirPt2 =
        dep.ptA = dep.ptB = dep.ptC = undefined;
      }
    }
    this.eng.remove(id);
    this._selected = null;
    this.render();
    if (this.onUpdate) this.onUpdate(this.getStats());
  }

  /* ── Очистить всё ── */
  reset() {
    this._pushUndo();
    this.eng.clear();
    this._pending = []; this._preview = null; this._selected = null;
    this.render();
    if (this.onUpdate) this.onUpdate(this.getStats());
  }

  /* ── Центрировать вид ── */
  resetView() {
    this.vp.cx = 0; this.vp.cy = 0; this.vp.scale = 60;
    this.render();
  }

  /* ══ СТАТИСТИКА ══════════════════════════════════════════════ */
  getStats() {
    const allPts = this.eng.points();
    const pts  = allPts.filter(p => !p.derived).length;
    const derivedPts = allPts.filter(p => !!p.derived).length;
    const segs = this.eng.byType('segment').filter(s=>!s.virtual).length + this.eng.byType('polygon').reduce((s,p)=>s+p.pointIds.length,0);
    const circs= this.eng.byType('circle').length;
    const polys= this.eng.byType('polygon').length;
    const derivedCircles = this.eng.byType('circle').filter(c => c.derived).length;
    const constructions  = this.eng.byType('derived_line').length + derivedPts + derivedCircles;

    // Статистика для выбранного объекта
    let sel = null;
    if (this._selected) {
      const obj = this._selected;
      if (obj.type === 'segment') {
        const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id);
        if (m1&&m2) sel = { type:'segment', len: gDist(m1,m2).toFixed(3), mid: gMid(m1,m2) };
      } else if (obj.type === 'circle') {
        let r = null;
        if (obj.derived && (obj.constr === 'circumcircle' || obj.constr === 'incircle')) {
          if (obj.valid) r = obj.r;
        } else {
          const mc = this._mpt(obj.centerId), me = this._mpt(obj.edgeId);
          if (mc && me) r = gDist(mc, me);
        }
        if (r != null) sel = { type:'circle', r:r.toFixed(3), perimeter:(2*Math.PI*r).toFixed(3), area:(Math.PI*r*r).toFixed(3) };
      } else if (obj.type === 'polygon') {
        const pts2 = obj.pointIds.map(id=>this._mpt(id)).filter(Boolean);
        if (pts2.length >= 3) {
          const perimeter = pts2.reduce((s,p,i)=>s+gDist(p,pts2[(i+1)%pts2.length]),0);
          const area = gPolygonArea(pts2);
          const angles = pts2.map((_,i)=>gAngleDeg(pts2[(i-1+pts2.length)%pts2.length],pts2[i],pts2[(i+1)%pts2.length]));
          sel = { type:'polygon', n:pts2.length, perimeter:perimeter.toFixed(3), area:area.toFixed(3), angles };
        }
      } else if (obj.type === 'point') {
        sel = { type:'point', x:obj.x.toFixed(3), y:obj.y.toFixed(3), label:obj.label };
      }
    }

    return { pts, segs, circs, polys, constructions, selected: sel };
  }

  /* ══ ЭКСПОРТ/ИМПОРТ СОСТОЯНИЯ (для classroom sim sync) ════════ */
  exportState() {
    return {
      objects: this.eng.serialize(),
      viewport: { cx: this.vp.cx, cy: this.vp.cy, scale: this.vp.scale },
      showGrid:   this.showGrid,
      showAxes:   this.showAxes,
      showLabels: this.showLabels,
      showLengths:this.showLengths,
      showAngles: this.showAngles,
    };
  }

  importState(st) {
    if (!st) return;
    try {
      if (st.objects)  this.eng.deserialize(st.objects);
      if (st.viewport) {
        this.vp.cx = st.viewport.cx;
        this.vp.cy = st.viewport.cy;
        this.vp.scale = st.viewport.scale;
      }
      if (st.showGrid    !== undefined) this.showGrid     = st.showGrid;
      if (st.showAxes    !== undefined) this.showAxes     = st.showAxes;
      if (st.showLabels  !== undefined) this.showLabels   = st.showLabels;
      if (st.showLengths !== undefined) this.showLengths  = st.showLengths;
      if (st.showAngles  !== undefined) this.showAngles   = st.showAngles;
      this._selected = null; this._pending = []; this._preview = null;
      this.render();
    } catch(err) { console.warn('GeoSim.importState error:', err); }
  }

  /* ── Экспорт PNG ── */
  exportPNG() {
    this.canvas.toBlob(blob => {
      const a = document.createElement('a');
      a.href = URL.createObjectURL(blob);
      a.download = 'geometry.png';
      a.click();
    }, 'image/png');
  }
}

/* ─── lab UI init ─────────────────────────────────── */
  function geoSetTool(name, btnEl) {
    if (!geomSim) return;
    geomSim.setTool(name);
    document.querySelectorAll('.geo-tool-btn').forEach(b => b.classList.remove('active'));
    if (btnEl) btnEl.classList.add('active');
    _geoShowHint(name);
    if (window.LabFX) LabFX.sound.play('click', { pitch: 1.1 });
  }

  const _GEO_PHASE_HINTS = {
    parallel_2:      'Теперь кликни на точку — через неё проведём прямую',
    perpendicular_2: 'Теперь кликни на точку — через неё проведём перпендикуляр',
    intersect_2:     'Теперь кликни на вторую прямую',
    foot_2:          'Теперь кликни на точку — найдём основание перпендикуляра',
    reflect_2:       'Теперь кликни на точку — получишь её симметричное отражение',
    tangent_2:       'Теперь кликни на внешнюю точку — получишь две касательные',
    translate_2:     'Теперь кликни конец вектора B',
    translate_3:     'Теперь кликни точку P — она будет перенесена',
    midline_2:       'Кликни вершину B (конец первой стороны)',
    midline_3:       'Кликни вершину C (конец второй стороны) — построим среднюю линию',
    parallelogram_2: 'Кликни вершину B (смежная с A)',
    parallelogram_3: 'Кликни вершину C — построим параллелограмм ABCD',
    scale_2:         'Кликни точку P — построим P\' = O + k·(P − O)',
    thales_2:        'Кликни точку A (на первом луче)',
    thales_3:        'Кликни точку B (на втором луче) — построим A\'B\' ∥ AB',
    measure_angle_2: 'Кликни вершину угла',
    measure_angle_3: 'Кликни вторую точку на стороне угла — измерение готово',
    locus_2:              'Кликни целевую точку, зависящую от мовера — построим ГМТ',
    point_on_segment_1:   'Кликни на отрезок — точка прикрепится к нему и будет по нему скользить',
    point_on_circle_1:    'Кликни на окружность — точка прикрепится к ней и будет по ней скользить',
  };

  function _geoShowHint(name, phase) {
    const hint = document.getElementById('geo-hint');
    if (!hint) return;
    if (phase && phase > 1) {
      hint.textContent = _GEO_PHASE_HINTS[`${name}_${phase}`] || _GEO_HINTS[name] || '';
    } else {
      hint.textContent = _GEO_HINTS[name] || '';
    }
  }

  function geoNgonN(delta) {
    if (!geomSim) return;
    geomSim.setNgonSides(geomSim._ngonSides + delta);
    const el = document.getElementById('geo-ngon-n');
    if (el) el.textContent = geomSim._ngonSides;
  }

  function geoScaleK(delta) {
    if (!geomSim) return;
    const k = Math.round((geomSim._scaleK + delta) * 10) / 10;
    if (k < 0.1) return;
    geomSim.setScaleK(k);
    const el = document.getElementById('geo-scale-k');
    if (el) el.textContent = k;
  }

  function geoToggle(prop, rowEl) {
    if (!geomSim) return;
    geomSim[prop] = !geomSim[prop];
    const tog = rowEl.querySelector('.geo-toggle');
    if (tog) tog.classList.toggle('on', geomSim[prop]);
    geomSim.render();
  }

  function _geoUpdateStats() {
    if (!geomSim) return;
    const s = geomSim.getStats();
    document.getElementById('geo-st-pts').textContent         = s.pts;
    document.getElementById('geo-st-segs').textContent        = s.segs;
    document.getElementById('geo-st-circs').textContent       = s.circs;
    document.getElementById('geo-st-polys').textContent       = s.polys;
    const cEl = document.getElementById('geo-st-constr');
    if (cEl) cEl.textContent = s.constructions || 0;
  }

  /* Диалог подтверждения удаления объекта с зависимыми */
  let _geoDelSoftFn = null, _geoDelHardFn = null;
  function _geoShowDeleteConfirm(obj, deps, softFn, hardFn) {
    const panel = document.getElementById('geo-del-confirm');
    const msg   = document.getElementById('geo-del-msg');
    if (!panel || !msg) { hardFn(); return; }
    const names = { point:'точка', segment:'отрезок', line:'прямая', ray:'луч',
                    circle:'окружность', polygon:'многоугольник', derived_line:'построение',
                    measure_length:'измерение длины', measure_angle:'измерение угла',
                    measure_area:'измерение площади', locus:'ГМТ' };
    const n = names[obj.type] || 'объект';
    msg.textContent = `Удалить ${n}? Зависимых: ${deps.length}.`;
    _geoDelSoftFn = softFn;
    _geoDelHardFn = hardFn;
    panel.classList.add('visible');
  }
  function _geoHideDeleteConfirm() {
    document.getElementById('geo-del-confirm')?.classList.remove('visible');
    _geoDelSoftFn = _geoDelHardFn = null;
  }

  /* Показать inline-сообщение об ошибке ГМТ (временно заменяет hint-bar) */
  function _geoShowLocusError(msg) {
    const hint = document.getElementById('geo-hint');
    if (!hint) return;
    const prev = hint.textContent;
    hint.textContent = msg;
    hint.style.color = '#f87171';
    if (window.LabFX) LabFX.sound.play('fizz', { pitch: 0.5, volume: 0.2 });
    setTimeout(() => {
      hint.textContent = prev;
      hint.style.color = '';
    }, 2800);
  }

  // Кнопки диалога — подключаем после DOM ready
  document.addEventListener('DOMContentLoaded', () => {
    document.getElementById('geo-del-soft')?.addEventListener('click', () => {
      _geoDelSoftFn?.(); _geoHideDeleteConfirm(); _geoUpdateStats();
    });
    document.getElementById('geo-del-hard')?.addEventListener('click', () => {
      _geoDelHardFn?.(); _geoHideDeleteConfirm(); _geoUpdateStats();
    });
    document.getElementById('geo-del-cancel')?.addEventListener('click', _geoHideDeleteConfirm);
  });

  function _openGeometry() {
    document.getElementById('sim-topbar-title').textContent = 'Планиметрия';
    _simShow('sim-geometry');
    _simShow('ctrl-geometry');

    _registerSimState(
      'geometry',
      () => geomSim?.exportState(),
      st  => { if (geomSim && st) { geomSim.importState(st); _geoUpdateStats(); } }
    );
    if (_embedMode) _startStateEmit('geometry');

    requestAnimationFrame(() => requestAnimationFrame(() => {
      const canvas = document.getElementById('geo-canvas');
      if (!geomSim) {
        geomSim = new GeoSim(canvas);
        geomSim.onUpdate        = _geoUpdateStats;
        geomSim.onHintChange    = (tool, phase) => _geoShowHint(tool, phase);
        geomSim.onDeleteRequest = _geoShowDeleteConfirm;
        geomSim.onLocusError    = _geoShowLocusError;

        // keyboard shortcuts
        canvas.setAttribute('tabindex', '0');
        canvas.addEventListener('keydown', e => {
          if (!geomSim) return;
          if (e.key === 'Escape') { geoSetTool('select', document.getElementById('geo-btn-select')); }
          if ((e.ctrlKey||e.metaKey) && e.key === 'z') { e.preventDefault(); geomSim.undo(); _geoUpdateStats(); }
          if ((e.ctrlKey||e.metaKey) && (e.key === 'y' || (e.shiftKey && e.key==='z'))) { e.preventDefault(); geomSim.redo(); _geoUpdateStats(); }
          if (e.key === 'Delete' || e.key === 'Backspace') { geomSim.deleteSelected(); _geoUpdateStats(); }
          if (e.key === 'Enter') { geomSim._finishPolygon?.(); _geoUpdateStats(); }
        });
      }
      geomSim.fit();
      geomSim.render();
      _geoUpdateStats();

      // sync toggle UI to current state
      ['showGrid','showAxes','showLabels','showLengths','showAngles'].forEach(p => {
        const el = document.getElementById('geo-tog-' + p);
        if (el) el.classList.toggle('on', !!geomSim[p]);
      });

      // LabFX particle RAF loop
      if (!geomSim._fxRaf && window.LabFX) {
        let _fxLast = performance.now();
        geomSim._fxFrames = 0;
        const _fxLoop = (now) => {
          const dt = (now - _fxLast) / 1000; _fxLast = now;
          LabFX.particles.update(dt);
          if (geomSim._fxFrames > 0) { geomSim._fxFrames--; geomSim.render(); }
          geomSim._fxRaf = requestAnimationFrame(_fxLoop);
        };
        geomSim._fxRaf = requestAnimationFrame(_fxLoop);
      }
    }));
  }

  /* ── trig circle ── */

  /* ══════════════════════════════════════════════════════════════════════
     ЗАДАЧНИК — challenge framework
     ══════════════════════════════════════════════════════════════════════ */

  /**
   * Helper: get two math-coordinate points on any line-like object.
   * Works for 'segment', 'line', 'ray', 'derived_line'.
   */
  function _challTwoPts(eng, obj) {
    if (!obj) return null;
    if (obj.type === 'derived_line') {
      return [{ x: obj.ptX, y: obj.ptY },
              { x: obj.ptX + obj.dirX, y: obj.ptY + obj.dirY }];
    }
    const p1 = eng.get(obj.p1Id), p2 = eng.get(obj.p2Id);
    if (!p1 || !p2) return null;
    return [{ x: p1.x, y: p1.y }, { x: p2.x, y: p2.y }];
  }

  /**
   * Find all "line-like" objects (line, ray, segment, derived_line).
   */
  function _challLines(eng) {
    return eng.all().filter(o =>
      o.type === 'line' || o.type === 'ray' ||
      o.type === 'segment' || o.type === 'derived_line'
    );
  }

  /**
   * Normalise direction: always returns { dx, dy } with dy >= 0
   * (or dx > 0 when dy == 0), for comparing line directions.
   */
  function _challNormDir(dx, dy) {
    const len = Math.hypot(dx, dy);
    if (len < 1e-12) return { dx: 0, dy: 0 };
    let nx = dx / len, ny = dy / len;
    if (ny < 0 || (Math.abs(ny) < 1e-9 && nx < 0)) { nx = -nx; ny = -ny; }
    return { dx: nx, dy: ny };
  }

  const CHALLENGES = [
    /* ── C1: Серединный перпендикуляр ──────────────────────────────── */
    {
      id: 'C1',
      title: 'Серединный перпендикуляр к AB',
      desc: 'Постройте серединный перпендикуляр к отрезку AB. ' +
            'Используйте инструмент «⊥ биссект.» или постройте вручную ' +
            '(прямую через середину AB, перпендикулярную AB).',
      hint: 'Воспользуйтесь инструментом «⊥ биссект.» — кликните точки A и B.',
      setup(eng) {
        eng.clear();
        const A = eng.add({ type:'point', x:-2, y:0, label:'A' });
        const B = eng.add({ type:'point', x: 2, y:0, label:'B' });
        eng.add({ type:'segment', p1Id:A.id, p2Id:B.id });
      },
      check(eng) {
        // Find A and B by label
        const pts = eng.points();
        const A = pts.find(p => p.label === 'A');
        const B = pts.find(p => p.label === 'B');
        if (!A || !B) return { passed: false, hint: 'Не найдены точки A и B.' };

        const mid = { x: (A.x + B.x) / 2, y: (A.y + B.y) / 2 };
        const segDx = B.x - A.x, segDy = B.y - A.y;
        const segLen = Math.hypot(segDx, segDy);
        if (segLen < 1e-9) return { passed: false };

        // Perpendicular direction to AB
        const perpDx = -segDy / segLen, perpDy = segDx / segLen;

        for (const obj of _challLines(eng)) {
          const pts2 = _challTwoPts(eng, obj);
          if (!pts2) continue;
          const [P1, P2] = pts2;
          const dx = P2.x - P1.x, dy = P2.y - P1.y;
          const len2 = Math.hypot(dx, dy);
          if (len2 < 1e-9) continue;

          // Check direction is perpendicular to AB (dot product with AB dir ≈ 0)
          const dot = (dx / len2) * segDx / segLen + (dy / len2) * segDy / segLen;
          if (Math.abs(dot) > 0.02) continue; // not perpendicular

          // Check passes through midpoint
          const distToMid = gDistToLine(mid, P1, P2);
          if (distToMid < 0.05) return { passed: true };
        }
        return { passed: false,
                 hint: 'Нужна прямая, проходящая через середину AB и перпендикулярная AB.' };
      }
    },

    /* ── C2: Биссектриса угла ───────────────────────────────────────── */
    {
      id: 'C2',
      title: 'Биссектриса угла',
      desc: 'Постройте биссектрису угла с вершиной V. ' +
            'Используйте инструмент «∠ биссект.» (три клика: A, вершина V, B).',
      hint: 'Инструмент «∠ биссект.»: кликните точку A, затем V (вершину), затем B.',
      setup(eng) {
        eng.clear();
        const V = eng.add({ type:'point', x:0,  y:0,  label:'V' });
        const A = eng.add({ type:'point', x:-3, y:0,  label:'A' });
        const B = eng.add({ type:'point', x:0,  y:3,  label:'B' });
        eng.add({ type:'ray', p1Id:V.id, p2Id:A.id });
        eng.add({ type:'ray', p1Id:V.id, p2Id:B.id });
      },
      check(eng) {
        const pts = eng.points();
        const V = pts.find(p => p.label === 'V');
        const A = pts.find(p => p.label === 'A');
        const B = pts.find(p => p.label === 'B');
        if (!V || !A || !B) return { passed: false, hint: 'Не найдены точки V, A, B.' };

        // Expected bisector direction
        const va = gNorm({ x: A.x - V.x, y: A.y - V.y });
        const vb = gNorm({ x: B.x - V.x, y: B.y - V.y });
        const bisDir = gNorm({ x: va.x + vb.x, y: va.y + vb.y });
        if (Math.hypot(bisDir.x, bisDir.y) < 1e-9)
          return { passed: false, hint: 'Угол вырожден.' };

        // Half-angle for tolerance: ±0.5°
        const halfAngleDeg = gAngleDeg(A, V, B) / 2;
        const TOL_DEG = 0.5;

        for (const obj of _challLines(eng)) {
          const pts2 = _challTwoPts(eng, obj);
          if (!pts2) continue;
          const [P1, P2] = pts2;

          // Must pass through V
          const distV = gDistToLine({ x: V.x, y: V.y }, P1, P2);
          if (distV > 0.08) continue;

          // Direction must match bisector
          const dx = P2.x - P1.x, dy = P2.y - P1.y;
          const len = Math.hypot(dx, dy);
          if (len < 1e-9) continue;
          const crossAbs = Math.abs((dx / len) * bisDir.y - (dy / len) * bisDir.x);
          // sin(angle between lines) = crossAbs; for ±0.5° sin(0.5°) ≈ 0.0087
          if (crossAbs < Math.sin(TOL_DEG * Math.PI / 180)) return { passed: true };
        }
        return { passed: false,
                 hint: 'Нужен луч/прямая из V, делящая угол AVB пополам.' };
      }
    },

    /* ── C3: Описанная окружность вокруг треугольника ───────────────── */
    {
      id: 'C3',
      title: 'Описанная окружность треугольника',
      desc: 'Постройте окружность, проходящую через все три вершины треугольника ABC. ' +
            'Используйте инструмент «Описанная» (circumcircle).',
      hint: 'Инструмент «Описанная»: кликните три вершины A, B, C — окружность строится автоматически.',
      setup(eng) {
        eng.clear();
        const A = eng.add({ type:'point', x:-2, y:-1.5, label:'A' });
        const B = eng.add({ type:'point', x: 2, y:-1.5, label:'B' });
        const C = eng.add({ type:'point', x: 0, y: 2,   label:'C' });
        eng.add({ type:'polygon', pointIds:[A.id, B.id, C.id] });
      },
      check(eng) {
        const pts = eng.points();
        const A = pts.find(p => p.label === 'A');
        const B = pts.find(p => p.label === 'B');
        const C = pts.find(p => p.label === 'C');
        if (!A || !B || !C) return { passed: false, hint: 'Не найдены вершины A, B, C.' };

        // Look for any circle passing through A, B, C within 1%
        for (const circ of eng.byType('circle')) {
          let cx, cy, r;
          if (circ.derived && circ.cx != null) {
            cx = circ.cx; cy = circ.cy; r = circ.r;
          } else {
            const ctr = eng.get(circ.centerId);
            const edg = eng.get(circ.edgeId);
            if (!ctr || !edg) continue;
            cx = ctr.x; cy = ctr.y;
            r = gDist({ x: cx, y: cy }, { x: edg.x, y: edg.y });
          }
          if (r < 1e-9) continue;
          const O = { x: cx, y: cy };
          const rA = gDist(O, A), rB = gDist(O, B), rC = gDist(O, C);
          const tol = r * 0.05; // 5% — generous for hand-built circumcircles
          if (Math.abs(rA - r) < tol && Math.abs(rB - r) < tol && Math.abs(rC - r) < tol)
            return { passed: true };
        }
        return { passed: false,
                 hint: 'Постройте окружность, равноудалённую от A, B и C.' };
      }
    },

    /* ── C4: ГМТ — множество точек, равноудалённых от A и B ────────── */
    {
      id: 'C4',
      title: 'ГМТ: равноудалённые от A и B',
      desc: 'Постройте геометрическое место точек, равноудалённых от точек A и B. ' +
            'Подсказка: это серединный перпендикуляр AB. ' +
            'Используйте инструмент «ГМТ» (locus): ' +
            'создайте скользящую точку на окружности или отрезке, ' +
            'затем постройте из неё точку-цель, равноудалённую от A и B.',
      hint: 'Самый простой способ: серединный перпендикуляр к AB — это и есть ГМТ. ' +
            'Используйте инструмент «⊥ биссект.» или locus.',
      setup(eng) {
        eng.clear();
        const A = eng.add({ type:'point', x:-2, y:0, label:'A' });
        const B = eng.add({ type:'point', x: 2, y:0, label:'B' });
      },
      check(eng) {
        // Accept: any locus object OR any line that is the perpendicular bisector of AB
        const pts = eng.points();
        const A = pts.find(p => p.label === 'A');
        const B = pts.find(p => p.label === 'B');
        if (!A || !B) return { passed: false };

        // Accept a locus object (heuristic: it exists)
        if (eng.byType('locus').length > 0) return { passed: true };

        // Accept a perpendicular bisector line/derived_line through midpoint
        const mid = { x: (A.x + B.x) / 2, y: (A.y + B.y) / 2 };
        const segLen = gDist(A, B);
        if (segLen < 1e-9) return { passed: false };
        const segDx = (B.x - A.x) / segLen, segDy = (B.y - A.y) / segLen;

        for (const obj of _challLines(eng)) {
          const pts2 = _challTwoPts(eng, obj);
          if (!pts2) continue;
          const [P1, P2] = pts2;
          const dx = P2.x - P1.x, dy = P2.y - P1.y;
          const len = Math.hypot(dx, dy);
          if (len < 1e-9) continue;
          const dot = Math.abs((dx / len) * segDx + (dy / len) * segDy);
          if (dot > 0.02) continue; // not perpendicular to AB
          if (gDistToLine(mid, P1, P2) < 0.08) return { passed: true };
        }
        return { passed: false,
                 hint: 'Постройте серединный перпендикуляр к AB или используйте инструмент ГМТ.' };
      }
    },

    /* ── C5: Касательная к окружности ──────────────────────────────── */
    {
      id: 'C5',
      title: 'Касательная к окружности',
      desc: 'Постройте касательную к окружности из внешней точки P. ' +
            'Используйте инструмент «Касательные» (tangent): кликните на окружность, ' +
            'затем на внешнюю точку P.',
      hint: 'Инструмент «Касательные»: сначала кликните на окружность, потом на точку P.',
      setup(eng) {
        eng.clear();
        const center = eng.add({ type:'point', x: 0, y: 0, label:'O' });
        const edge   = eng.add({ type:'point', x: 2, y: 0, label:'R' });
        eng.add({ type:'circle', centerId: center.id, edgeId: edge.id });
        eng.add({ type:'point', x: 5, y: 0, label:'P' });
      },
      check(eng) {
        // Find the setup circle and P
        const pts = eng.points();
        const O = pts.find(p => p.label === 'O');
        const P = pts.find(p => p.label === 'P');
        if (!O || !P) return { passed: false };

        // Find circle with center O
        let circR = null;
        for (const circ of eng.byType('circle')) {
          const ctr = eng.get(circ.centerId);
          if (ctr && Math.abs(ctr.x - O.x) < 0.01 && Math.abs(ctr.y - O.y) < 0.01) {
            const edg = eng.get(circ.edgeId);
            if (edg) { circR = gDist({ x: O.x, y: O.y }, { x: edg.x, y: edg.y }); break; }
          }
        }
        if (!circR) return { passed: false, hint: 'Исходная окружность не найдена.' };

        const Opt = { x: O.x, y: O.y };
        const TOL = circR * 0.05; // 5% of radius

        // Look for any line/ray through P where distance from O to line ≈ radius
        for (const obj of _challLines(eng)) {
          const pts2 = _challTwoPts(eng, obj);
          if (!pts2) continue;
          const [P1, P2] = pts2;

          // Must pass through P (within tolerance)
          const distP = gDistToLine({ x: P.x, y: P.y }, P1, P2);
          if (distP > 0.15) continue;

          // Distance from center O to the line ≈ radius
          const distO = gDistToLine(Opt, P1, P2);
          if (Math.abs(distO - circR) < TOL) return { passed: true };
        }
        return { passed: false,
                 hint: 'Постройте прямую через P, касающуюся окружности с центром O.' };
      }
    },
  ];

  /* ── Challenge state ─────────────────────────────────────────────── */
  let _challState = CHALLENGES.map(() => 'locked');
  _challState[0] = 'current'; // first challenge is unlocked
  let _challAttempts = CHALLENGES.map(() => 0); // fail attempt counter
  let _challPanelOpen = false;

  function geoToggleChallengePanel() {
    _challPanelOpen = !_challPanelOpen;
    const panel = document.getElementById('geo-challenge-panel');
    if (panel) panel.classList.toggle('open', _challPanelOpen);
    if (_challPanelOpen) _geoChallRenderList();
  }

  function _geoChallRenderList() {
    const list = document.getElementById('geo-chall-list');
    if (!list) return;
    list.innerHTML = '';
    const doneCount = _challState.filter(s => s === 'done').length;
    const countEl = document.getElementById('geo-chall-count');
    if (countEl) countEl.textContent = doneCount + '/' + CHALLENGES.length;

    CHALLENGES.forEach((ch, idx) => {
      const state = _challState[idx];
      const item = document.createElement('div');
      item.className = 'geo-chall-item chall-' + state;
      item.dataset.idx = idx;

      // Status icon
      let statusContent = (idx + 1).toString();
      if (state === 'done') {
        statusContent = '<svg viewBox="0 0 24 24" fill="none" stroke="#4ADE80" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="width:11px;height:11px"><polyline points="20 6 9 17 4 12"/></svg>';
      } else if (state === 'locked') {
        statusContent = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:10px;height:10px"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
      }

      item.innerHTML = `
        <div class="geo-chall-head">
          <div class="geo-chall-status">${statusContent}</div>
          <div class="geo-chall-name">${ch.title}</div>
        </div>
        <div class="geo-chall-body">
          <div class="geo-chall-desc">${ch.desc}</div>
          <div class="geo-chall-actions">
            <button class="geo-chall-btn geo-chall-btn-check" onclick="geoChallCheck(${idx})">Проверить</button>
            <button class="geo-chall-btn geo-chall-btn-reset" onclick="geoChallSetup(${idx})" title="Начать заново">
              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="width:12px;height:12px"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-3.95"/></svg>
            </button>
          </div>
          <div class="geo-chall-hint${_challAttempts[idx] >= 2 ? ' visible' : ''}" id="geo-chall-hint-${idx}">${ch.hint}</div>
          <div class="geo-chall-feedback" id="geo-chall-fb-${idx}"></div>
        </div>`;

      // Allow expanding done items too
      item.querySelector('.geo-chall-head').addEventListener('click', () => {
        item.classList.toggle('geo-chall-expanded');
      });

      list.appendChild(item);
    });
  }

  function geoChallSetup(idx) {
    if (!geomSim) return;
    const ch = CHALLENGES[idx];
    if (!ch) return;
    ch.setup(geomSim.eng);
    // Recompute all derived objects after setup
    for (const obj of geomSim.eng.all()) {
      if (obj.derived) geomSim.eng.recompute(obj.id);
    }
    geomSim.fit();
    geomSim.render();
    _geoUpdateStats();
    const fb = document.getElementById('geo-chall-fb-' + idx);
    if (fb) { fb.textContent = ''; fb.className = 'geo-chall-feedback'; }
  }

  function geoChallCheck(idx) {
    if (!geomSim) return;
    const ch = CHALLENGES[idx];
    if (!ch || _challState[idx] === 'locked') return;

    const result = ch.check(geomSim.eng);
    const fb = document.getElementById('geo-chall-fb-' + idx);

    if (result.passed) {
      _challState[idx] = 'done';
      // Unlock next
      if (idx + 1 < CHALLENGES.length && _challState[idx + 1] === 'locked') {
        _challState[idx + 1] = 'current';
      }
      if (fb) { fb.textContent = 'Верно!'; fb.className = 'geo-chall-feedback ok'; }
      _geoChallSuccessBurst();
      _geoChallRenderList();
    } else {
      _challAttempts[idx]++;
      const msg = result.hint
        ? result.hint
        : 'Не совсем. Попробуй ещё раз.';
      if (fb) { fb.textContent = msg; fb.className = 'geo-chall-feedback err'; }
      // Show hint after 2 fails
      if (_challAttempts[idx] >= 2) {
        const hintEl = document.getElementById('geo-chall-hint-' + idx);
        if (hintEl) hintEl.classList.add('visible');
      }
    }
  }

  function _geoChallSuccessBurst() {
    const outer = document.querySelector('.geo-canvas-outer');
    if (!outer) return;

    // "Молодец!" label
    const label = document.createElement('div');
    label.className = 'geo-chall-success-label';
    label.textContent = 'Молодец!';
    outer.appendChild(label);
    setTimeout(() => label.remove(), 2400);

    if (!geomSim) return;

    if (window.LabFX) {
      // Migrate to LabFX particles
      const ctx = geomSim.ctx;
      const W = geomSim.vp.W, H = geomSim.vp.H;
      const confettiColors = ['#4ADE80', '#34D399', '#A78BFA', '#60A5FA', '#FBBF24', '#F472B6'];
      confettiColors.forEach(color => {
        LabFX.particles.emit({ ctx, x: W / 2, y: H / 2, count: 10, color, shape: 'spark', spread: Math.PI * 2, life: 1600, speed: 180, gravity: 200, glow: true });
      });
      LabFX.sound.play('chime');
      LabFX.haptic([15, 30, 15, 30, 15]);
      if (geomSim._fxFrames !== undefined) geomSim._fxFrames = 120;
    } else {
      // Fallback: original confetti
      const canvas = geomSim.canvas;
      const ctx = geomSim.ctx;
      const W = canvas.width, H = canvas.height;
      const particles = [];
      const colors = ['#4ADE80', '#34D399', '#A78BFA', '#60A5FA', '#FBBF24', '#F472B6'];
      for (let i = 0; i < 60; i++) {
        particles.push({
          x: W / 2 + (Math.random() - 0.5) * W * 0.4,
          y: H / 2 + (Math.random() - 0.5) * H * 0.3,
          vx: (Math.random() - 0.5) * 5,
          vy: (Math.random() - 0.6) * 6,
          r: 3 + Math.random() * 4,
          color: colors[Math.floor(Math.random() * colors.length)],
          alpha: 1,
          rot: Math.random() * Math.PI * 2,
          rotV: (Math.random() - 0.5) * 0.3,
        });
      }
      let frame = 0;
      const maxFrames = 60;
      function burst() {
        if (frame >= maxFrames) { geomSim.render(); return; }
        geomSim.render();
        for (const p of particles) {
          ctx.save();
          ctx.globalAlpha = p.alpha;
          ctx.translate(p.x, p.y);
          ctx.rotate(p.rot);
          ctx.fillStyle = p.color;
          ctx.fillRect(-p.r / 2, -p.r / 2, p.r, p.r * 1.6);
          ctx.restore();
          p.x  += p.vx;
          p.y  += p.vy;
          p.vy += 0.18;
          p.alpha -= 1 / maxFrames;
          p.rot   += p.rotV;
        }
        frame++;
        requestAnimationFrame(burst);
      }
      requestAnimationFrame(burst);
    }
  }

