/*
 * biochem-core.js — общее ядро модуля «Биохимия» (window.BIO)
 *
 * Единый источник правды для всех 5 страниц биохимии:
 *   - ELEMENTS: реестр элементов (цвет CPK, масса, валентность, электроотрицательность,
 *     ковалентный и ван-дер-ваальсов радиусы, число валентных электронов)
 *   - формулы/масса: hillFormula, molarMass, parseFormula
 *   - нормализация связей: bF/bT/bO (чинит расхождение полей f/from, t/to, o/order)
 *   - 2D-рендер: render2D (ball-and-stick для превью)
 *   - 3D-геометрия: vsepr (генератор настоящих 3D-координат по теории ОЭПВО/VSEPR)
 *   - 3D-рендер: render3D (ball-and-stick с глубиной и затенением)
 *   - safe: обёртка для API-вызовов с тостом ошибки
 *   - RING_TEMPLATES: шаблоны колец
 *
 * Зависимостей нет; LS.toast используется опционально (если доступен).
 */
(function (global) {
  'use strict';

  /* ── Реестр элементов ─────────────────────────────────────────────────
   * color  — CPK-цвет заливки
   * text   — цвет символа поверх заливки
   * radius — радиус кружка в 2D-редакторе (усл. ед.)
   * mass   — атомная масса (г/моль)
   * maxV   — типичная максимальная валентность (для проверки и оценки геометрии)
   * en     — электроотрицательность по Полингу (для полярности связей)
   * ve     — число валентных электронов (для оценки неподелённых пар → VSEPR)
   * cov    — ковалентный радиус, пм (длины связей в 3D)
   * vdw    — ван-дер-ваальсов радиус, пм (space-fill режим)
   * metal  — ионный/металлический центр (нет ковалентной геометрии)
   */
  const ELEMENTS = {
    H:  { name:'Водород',  color:'#D4D4D4', text:'#222', radius:18, mass:1.008,  maxV:1, en:2.20, ve:1, cov:31,  vdw:120 },
    C:  { name:'Углерод',  color:'#555555', text:'#fff', radius:20, mass:12.011, maxV:4, en:2.55, ve:4, cov:76,  vdw:170 },
    N:  { name:'Азот',     color:'#4060FF', text:'#fff', radius:20, mass:14.007, maxV:3, en:3.04, ve:5, cov:71,  vdw:155 },
    O:  { name:'Кислород', color:'#EE2020', text:'#fff', radius:20, mass:15.999, maxV:2, en:3.44, ve:6, cov:66,  vdw:152 },
    P:  { name:'Фосфор',   color:'#FF8000', text:'#fff', radius:22, mass:30.974, maxV:5, en:2.19, ve:5, cov:107, vdw:180 },
    S:  { name:'Сера',     color:'#C8B400', text:'#000', radius:22, mass:32.06,  maxV:6, en:2.58, ve:6, cov:105, vdw:180 },
    F:  { name:'Фтор',     color:'#33CC33', text:'#fff', radius:18, mass:18.998, maxV:1, en:3.98, ve:7, cov:57,  vdw:147 },
    Cl: { name:'Хлор',     color:'#00A860', text:'#fff', radius:22, mass:35.45,  maxV:1, en:3.16, ve:7, cov:102, vdw:175 },
    Br: { name:'Бром',     color:'#A52A2A', text:'#fff', radius:24, mass:79.904, maxV:1, en:2.96, ve:7, cov:120, vdw:185 },
    I:  { name:'Иод',      color:'#940094', text:'#fff', radius:26, mass:126.90, maxV:1, en:2.66, ve:7, cov:139, vdw:198 },
    Na: { name:'Натрий',   color:'#8040C0', text:'#fff', radius:22, mass:22.990, maxV:1, en:0.93, ve:1, cov:166, vdw:227, metal:true },
    K:  { name:'Калий',    color:'#8F40D4', text:'#fff', radius:24, mass:39.098, maxV:1, en:0.82, ve:1, cov:203, vdw:275, metal:true },
    Ca: { name:'Кальций',  color:'#707070', text:'#fff', radius:22, mass:40.078, maxV:2, en:1.00, ve:2, cov:176, vdw:231, metal:true },
    Mg: { name:'Магний',   color:'#1E8A1E', text:'#fff', radius:22, mass:24.305, maxV:2, en:1.31, ve:2, cov:141, vdw:173, metal:true },
    Fe: { name:'Железо',   color:'#B03010', text:'#fff', radius:22, mass:55.845, maxV:3, en:1.83, ve:8, cov:132, vdw:194, metal:true },
  };

  function el(sym) { return ELEMENTS[sym] || { name:sym, color:'#888', text:'#fff', radius:20, mass:0, maxV:4, en:2.5, ve:4, cov:75, vdw:170 }; }

  /* ── Нормализация связей ──────────────────────────────────────────────
   * В БД связи хранятся как {f,t,o}; в редакторе — как {from,to,order}.
   * Эти хелперы устраняют расхождение (бывший баг `b.o || b.order`).
   */
  function bF(b) { return b.from != null ? b.from : b.f; }
  function bT(b) { return b.to   != null ? b.to   : b.t; }
  function bO(b) { return (b.order != null ? b.order : b.o) || 1; }

  /* ── Формулы и масса ──────────────────────────────────────────────────── */
  function counts(atoms) {
    const c = {};
    for (const a of atoms) c[a.s] = (c[a.s] || 0) + 1;
    return c;
  }
  function hillFormula(atoms) {
    if (!atoms || !atoms.length) return '';
    const cnt = counts(atoms);
    const parts = [];
    if (cnt.C) { parts.push('C' + (cnt.C > 1 ? cnt.C : '')); delete cnt.C; }
    if (cnt.H) { parts.push('H' + (cnt.H > 1 ? cnt.H : '')); delete cnt.H; }
    for (const e of Object.keys(cnt).sort()) parts.push(e + (cnt[e] > 1 ? cnt[e] : ''));
    return parts.join('');
  }
  function molarMass(atoms) {
    let m = 0;
    for (const a of atoms) m += el(a.s).mass;
    return m;
  }
  // Разбор строковой формулы (с поддержкой скобок и цифр): "Ca(OH)2" → {Ca:1,O:2,H:2}
  function parseFormula(str) {
    const out = {};
    const re = /([A-Z][a-z]?)(\d*)|(\()|(\))(\d*)/g;
    const stack = [out];
    let m;
    while ((m = re.exec(str)) !== null) {
      if (m[1]) {
        const n = m[2] ? parseInt(m[2], 10) : 1;
        const top = stack[stack.length - 1];
        top[m[1]] = (top[m[1]] || 0) + n;
      } else if (m[3]) {
        stack.push({});
      } else if (m[4] !== undefined) {
        const grp = stack.pop();
        const mult = m[5] ? parseInt(m[5], 10) : 1;
        const top = stack[stack.length - 1];
        for (const k in grp) top[k] = (top[k] || 0) + grp[k] * mult;
      }
    }
    return out;
  }

  /* ── Степень ненасыщенности (DBE) ─────────────────────────────────────── */
  function dbe(atoms) {
    const c = counts(atoms);
    const C = c.C || 0, H = c.H || 0, N = c.N || 0, P = c.P || 0;
    const X = (c.Cl || 0) + (c.F || 0) + (c.Br || 0) + (c.I || 0);
    if (!C && !H) return null;
    return (2 * C + 2 + N + P - H - X) / 2;
  }

  /* ── Химический движок: заряды, диполь, полярность, группы ─────────────────
   * Частичные заряды — по разнице электроотрицательностей на связях
   * (модель Гusing EN): электроны смещаются к более электроотрицательному
   * атому, менее ЭО атом получает δ+, более ЭО — δ−.
   */
  const _CHARGE_K = 0.21;
  function partialCharges(atoms, bonds) {
    const byId = {}; atoms.forEach(a => byId[a.id] = a);
    const q = {}; atoms.forEach(a => q[a.id] = 0);
    for (const b of bonds || []) {
      const f = bF(b), t = bT(b), o = bO(b);
      const af = byId[f], at = byId[t];
      if (!af || !at) continue;
      const d = (el(at.s).en - el(af.s).en) * o * _CHARGE_K; // поток к более ЭО
      q[f] += d;   // менее ЭО → δ+
      q[t] -= d;   // более ЭО → δ−
    }
    return q;
  }

  /* Дипольный момент — векторная сумма q·r по 3D-координатам (из VSEPR).
   * Симметричные молекулы (CO₂, CH₄, CCl₄) дают ~0 → неполярны; это и есть
   * окупаемость настоящей 3D-геометрии. Возврат в условных «дебаях» (D-прокси).
   */
  function dipole(atoms, bonds, geom) {
    const g = geom || vsepr(atoms, bonds);
    const q = partialCharges(atoms, bonds);
    let vx = 0, vy = 0, vz = 0;
    for (const a of g.atoms3d) { const c = q[a.id] || 0; vx += c * a.x; vy += c * a.y; vz += c * a.z; }
    const BOND = 94; // ~длина C–C в усл. ед. (нормировка к «дебаям»)
    const magnitude = Math.hypot(vx, vy, vz) / BOND * 4.0;
    return { vector: [vx, vy, vz], magnitude, charges: q };
  }

  /* Классификация полярности на основе диполя и состава. */
  function polarity(atoms, bonds, geom) {
    const c = counts(atoms);
    if (c.Na || c.K || c.Ca || c.Mg || c.Fe) return { label: 'Ионная', cls: 'bad', dipole: null };
    if (atoms.length < 2) return { label: '—', cls: '', dipole: 0 };
    const dp = dipole(atoms, bonds, geom);
    const m = dp.magnitude;
    let label, cls;
    if (m < 0.18)      { label = 'Неполярная';       cls = 'good'; }
    else if (m < 0.55) { label = 'Слабо полярная';   cls = 'warn'; }
    else if (m < 1.5)  { label = 'Полярная';         cls = 'warn'; }
    else               { label = 'Сильно полярная';  cls = 'bad';  }
    return { label, cls, dipole: m, vector: dp.vector, charges: dp.charges };
  }

  /* Массовые доли элементов (%). */
  function massFractions(atoms) {
    const c = counts(atoms);
    const total = molarMass(atoms) || 1;
    const out = {};
    for (const s of Object.keys(c)) out[s] = (el(s).mass * c[s] / total) * 100;
    return out;
  }

  /* Детекция функциональных групп (паттерн-матчинг по графу). */
  function functionalGroups(atoms, bonds) {
    const byId = {}; atoms.forEach(a => byId[a.id] = a);
    const bondsOf = id => (bonds || []).filter(b => bF(b) === id || bT(b) === id);
    const othr = (b, id) => bF(b) === id ? bT(b) : bF(b);
    const sym = id => byId[id] && byId[id].s;
    const groups = [];
    const usedC = new Set();

    for (const a of atoms) {
      if (a.s !== 'C') continue;
      const my = bondsOf(a.id);
      const dblO = my.some(b => bO(b) === 2 && sym(othr(b, a.id)) === 'O');
      const sglO = my.filter(b => bO(b) === 1 && sym(othr(b, a.id)) === 'O');
      if (dblO && sglO.length) {
        const oId = othr(sglO[0], a.id);
        if (bondsOf(oId).some(b => sym(othr(b, oId)) === 'H')) { groups.push({ label: '−COOH', color: '#f87171' }); usedC.add(a.id); continue; }
        groups.push({ label: '−COO− (эфир)', color: '#fb923c' }); usedC.add(a.id); continue;
      }
      if (dblO && !usedC.has(a.id)) {
        const hN = my.some(b => sym(othr(b, a.id)) === 'H');
        const cN = my.filter(b => sym(othr(b, a.id)) === 'C').length;
        groups.push({ label: hN ? '−CHO' : (cN >= 2 ? 'C=O (кетон)' : 'C=O'), color: '#fb923c' });
        usedC.add(a.id);
      }
    }
    const ohN = atoms.filter(a => a.s === 'O' && bondsOf(a.id).some(b => bO(b) === 1 && sym(othr(b, a.id)) === 'H')).length;
    if (ohN) groups.push({ label: ohN > 1 ? `−OH ×${ohN}` : '−OH', color: '#60a5fa' });
    for (const a of atoms) {
      if (a.s !== 'N') continue;
      const hC = bondsOf(a.id).filter(b => sym(othr(b, a.id)) === 'H').length;
      if (hC >= 2) groups.push({ label: '−NH₂', color: '#34d399' });
      else if (hC === 1) groups.push({ label: '−NH', color: '#34d399' });
    }
    if (atoms.some(a => a.s === 'S' && bondsOf(a.id).some(b => sym(othr(b, a.id)) === 'H'))) groups.push({ label: '−SH', color: '#fbbf24' });
    const enes = (bonds || []).filter(b => bO(b) === 2 && sym(bF(b)) === 'C' && sym(bT(b)) === 'C');
    if (enes.length) groups.push({ label: enes.length > 1 ? `C=C ×${enes.length}` : 'C=C', color: '#a78bfa' });
    if ((bonds || []).some(b => bO(b) === 3 && sym(bF(b)) === 'C' && sym(bT(b)) === 'C')) groups.push({ label: 'C≡C', color: '#e879f9' });
    const cIds = new Set(atoms.filter(a => a.s === 'C').map(a => a.id));
    if ((bonds || []).filter(b => bO(b) === 2 && cIds.has(bF(b)) && cIds.has(bT(b))).length >= 3) groups.push({ label: 'Арен', color: '#06D6E0' });
    const halos = ['F', 'Cl', 'Br', 'I'];
    for (const h of halos) { const n = atoms.filter(a => a.s === h).length; if (n) groups.push({ label: n > 1 ? `−${h} ×${n}` : `−${h}`, color: '#4ade80' }); }
    for (const a of atoms) { if (a.s === 'P' && bondsOf(a.id).filter(b => sym(othr(b, a.id)) === 'O').length >= 2) { groups.push({ label: 'Фосфат', color: '#f97316' }); break; } }
    return groups;
  }

  /* Полный анализ молекулы — единая точка для всех страниц. */
  function analyze(atoms, bonds) {
    if (!atoms || !atoms.length) return null;
    const geom = vsepr(atoms, bonds);
    const pol = polarity(atoms, bonds, geom);
    return {
      formula: hillFormula(atoms),
      mass: molarMass(atoms),
      dbe: dbe(atoms),
      atomCount: atoms.length,
      geometry: { shape: geom.shape, hybridization: geom.hybridization, angle: geom.angle, centerSym: geom.centerSym },
      polarity: pol,
      charges: pol.charges || partialCharges(atoms, bonds),
      dipole: pol.dipole,
      groups: functionalGroups(atoms, bonds),
      massFractions: massFractions(atoms),
      atoms3d: geom.atoms3d,
      perAtom: geom.perAtom,
    };
  }

  /* ── Балансировка уравнений реакций ───────────────────────────────────────
   * Вход: reactants[], products[] — массивы строковых формул ("H2","O2",...).
   * Метод: матрица «элемент × вещество» (реагенты +, продукты −), поиск
   * целочисленного вектора в ядре через дробный метод Гаусса + НОК/НОД.
   * Выход: { coefficients:[...], reactants:[...], products:[...] } или null.
   */
  function _gcd(a, b) { a = Math.abs(a); b = Math.abs(b); while (b) { [a, b] = [b, a % b]; } return a || 1; }
  function _lcm(a, b) { return Math.abs(a * b) / _gcd(a, b); }
  // дроби как [num, den]
  function _fr(n, d) { d = d || 1; if (d < 0) { n = -n; d = -d; } const g = _gcd(n, d) || 1; return [n / g, d / g]; }
  function _frSub(a, b) { return _fr(a[0] * b[1] - b[0] * a[1], a[1] * b[1]); }
  function _frMul(a, b) { return _fr(a[0] * b[0], a[1] * b[1]); }
  function _frDiv(a, b) { return _fr(a[0] * b[1], a[1] * b[0]); }

  function balance(reactants, products) {
    const species = [...reactants, ...products];
    if (species.length < 2) return null;
    const nR = reactants.length;
    const elemSet = new Set();
    const comps = species.map(f => { const c = parseFormula(f); Object.keys(c).forEach(e => elemSet.add(e)); return c; });
    const elements = [...elemSet];
    const n = species.length;
    // матрица элементов (дроби)
    let M = elements.map(el => comps.map((c, i) => _fr((c[el] || 0) * (i < nR ? 1 : -1), 1)));
    // RREF
    const rows = M.length, cols = n;
    let pivotCols = [];
    let r = 0;
    for (let c = 0; c < cols && r < rows; c++) {
      let piv = -1;
      for (let i = r; i < rows; i++) if (M[i][c][0] !== 0) { piv = i; break; }
      if (piv < 0) continue;
      [M[r], M[piv]] = [M[piv], M[r]];
      const pv = M[r][c];
      for (let j = 0; j < cols; j++) M[r][j] = _frDiv(M[r][j], pv);
      for (let i = 0; i < rows; i++) {
        if (i === r || M[i][c][0] === 0) continue;
        const factor = M[i][c];
        for (let j = 0; j < cols; j++) M[i][j] = _frSub(M[i][j], _frMul(factor, M[r][j]));
      }
      pivotCols.push(c);
      r++;
    }
    // свободные столбцы (нет пивота) → ставим параметр 1
    const pivotSet = new Set(pivotCols);
    const free = [];
    for (let c = 0; c < cols; c++) if (!pivotSet.has(c)) free.push(c);
    if (free.length !== 1) return null; // нет однозначного баланса (или недоопределено)
    const freeCol = free[0];
    // x[freeCol] = 1; x[pivot] = -M[row][freeCol]
    const x = new Array(cols).fill(null);
    x[freeCol] = _fr(1, 1);
    for (let i = 0; i < pivotCols.length; i++) {
      const pc = pivotCols[i];
      x[pc] = _fr(-M[i][freeCol][0], M[i][freeCol][1]);
    }
    // к целым: умножить на НОК знаменателей
    let denLcm = 1;
    for (const v of x) denLcm = _lcm(denLcm, v[1]);
    let ints = x.map(v => v[0] * (denLcm / v[1]));
    // знак: сделать положительными
    if (ints.some(v => v < 0) && ints.every(v => v <= 0)) ints = ints.map(v => -v);
    if (ints.some(v => v < 0)) return null; // несбалансируемо в положительных
    // сократить на общий НОД
    let g = 0; for (const v of ints) g = _gcd(g, v);
    if (g > 1) ints = ints.map(v => v / g);
    if (ints.some(v => v <= 0)) return null;
    return { coefficients: ints, reactants: ints.slice(0, nR), products: ints.slice(nR) };
  }

  /* ── Парсер SMILES (учебное подмножество) ─────────────────────────────────
   * Поддержка: органические атомы в ВЕРХНЕМ регистре (B,C,N,O,P,S,F,Cl,Br,I,H),
   * связи -, =, #, ветви ( ), замыкание циклов цифрами и %nn. Неявные H
   * достраиваются по валентности. Возврат {atoms:[{id,s,x,y}], bonds:[{f,t,o}]}
   * или null. Ароматика в нижнем регистре (c,n,o…) НЕ поддержана — используйте
   * форму Кекуле (C1=CC=CC=C1). 2D-укладка — BFS с разводом углов.
   */
  function parseSmiles(str) {
    if (!str || typeof str !== 'string') return null;
    str = str.trim().replace(/\s+/g, '');
    if (!str) return null;
    const atoms = [], bonds = [];
    let id = 1;
    const twoLetter = { C: ['Cl'], B: ['Br'] };
    const known = new Set(['B','C','N','O','P','S','F','I','H','Cl','Br']);
    const stack = [];           // для ветвей: сохранённые «текущие» атомы
    const ring = {};            // digit -> {atom, order}
    let prev = null;            // предыдущий атом для связи
    let pendingOrder = 0;       // 0 = по умолчанию (1)
    let i = 0;
    const addAtom = s => { const a = { id: id++, s, x: 0, y: 0 }; atoms.push(a); return a; };
    const addBond = (f, t, o) => { if (f === t) return; bonds.push({ f, t, o: o || 1 }); };

    while (i < str.length) {
      const ch = str[i];
      if (ch === '(') { stack.push(prev); i++; continue; }
      if (ch === ')') { prev = stack.pop() ?? prev; i++; continue; }
      if (ch === '-') { pendingOrder = 1; i++; continue; }
      if (ch === '=') { pendingOrder = 2; i++; continue; }
      if (ch === '#') { pendingOrder = 3; i++; continue; }
      if (ch === '%') {
        const d = str.slice(i + 1, i + 3);
        i += 3;
        _ringClose(d);
        continue;
      }
      if (ch >= '0' && ch <= '9') { _ringClose(ch); i++; continue; }
      // атом: пробуем двухбуквенный
      let sym = null;
      const pair = str.slice(i, i + 2);
      if (twoLetter[ch] && twoLetter[ch].includes(pair)) { sym = pair; i += 2; }
      else if (known.has(ch)) { sym = ch; i += 1; }
      else return null; // неподдержанный символ (в т.ч. строчная ароматика, [..])
      const a = addAtom(sym);
      if (prev) addBond(prev.id, a.id, pendingOrder || 1);
      pendingOrder = 0;
      prev = a;
    }
    function _ringClose(d) {
      if (ring[d]) { addBond(ring[d].atom.id, prev.id, ring[d].order || pendingOrder || 1); delete ring[d]; pendingOrder = 0; }
      else { ring[d] = { atom: prev, order: pendingOrder || 0 }; pendingOrder = 0; }
    }
    if (!atoms.length) return null;

    // неявные H по валентности
    const sumOrder = {}; atoms.forEach(a => sumOrder[a.id] = 0);
    for (const b of bonds) { sumOrder[b.f] += b.o; sumOrder[b.t] += b.o; }
    const heavy = atoms.slice();
    for (const a of heavy) {
      if (a.s === 'H') continue;
      const maxV = el(a.s).maxV || 4;
      const need = maxV - (sumOrder[a.id] || 0);
      for (let k = 0; k < need; k++) { const h = addAtom('H'); addBond(a.id, h.id, 1); }
    }
    _layout2D(atoms, bonds);
    return { atoms, bonds };
  }

  // Простая 2D-укладка: BFS, развод связей по углам, длина ~55
  function _layout2D(atoms, bonds) {
    const byId = {}; atoms.forEach(a => byId[a.id] = a);
    const adj = {}; atoms.forEach(a => adj[a.id] = []);
    for (const b of bonds) { adj[b.f].push(b.t); adj[b.t].push(b.f); }
    const placed = new Set();
    const L = 55;
    let root = atoms[0];
    for (const a of atoms) if (adj[a.id].length > adj[root.id].length) root = a;
    root.x = 0; root.y = 0; placed.add(root.id);
    const q = [{ id: root.id, dir: 0 }];
    while (q.length) {
      const { id, dir } = q.shift();
      const cur = byId[id];
      const nb = adj[id].filter(n => !placed.has(n));
      const n = nb.length;
      // развод: вокруг направления «от родителя», сектор ~270°
      const spread = Math.PI * 1.5;
      nb.forEach((nid, k) => {
        const ang = dir + (n === 1 ? 0.6 : (-spread / 2 + spread * (k / Math.max(1, n - 1)))) ;
        const c = byId[nid];
        c.x = cur.x + Math.cos(ang) * L;
        c.y = cur.y + Math.sin(ang) * L;
        placed.add(nid);
        q.push({ id: nid, dir: ang });
      });
    }
  }

  /* ── Экспорт молекулы ───────────────────────────────────────────────────── */
  function toJSON(atoms, bonds, name) {
    return JSON.stringify({
      name: name || hillFormula(atoms),
      formula: hillFormula(atoms),
      atoms: atoms.map(a => ({ id: a.id, s: a.s, x: Math.round(a.x), y: Math.round(a.y) })),
      bonds: (bonds || []).map(b => ({ f: bF(b), t: bT(b), o: bO(b) })),
    }, null, 2);
  }
  function download(filename, content, mime) {
    const blob = new Blob([content], { type: mime || 'text/plain;charset=utf-8' });
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = filename;
    a.click();
    URL.revokeObjectURL(a.href);
  }

  /* ── 2D-рендер (ball-and-stick для превью) ────────────────────────────────
   * atoms: [{s,x,y}]  bonds: [{f,t,o}] | [{from,to,order}]
   * opts: { fit:true|false, padding, bg, lineColor, showSymbols, hideH, scale }
   * Если fit=true — масштабирует молекулу под размер canvas (для thumbnail).
   */
  function render2D(ctx, atoms, bonds, opts) {
    opts = opts || {};
    const W = ctx.canvas.width, H = ctx.canvas.height;
    if (opts.bg) { ctx.fillStyle = opts.bg; ctx.fillRect(0, 0, W, H); }
    else ctx.clearRect(0, 0, W, H);
    if (!atoms || !atoms.length) return;

    let sc = opts.scale || 1, ox = W / 2, oy = H / 2;
    let cx = 0, cy = 0;
    for (const a of atoms) { cx += a.x; cy += a.y; }
    cx /= atoms.length; cy /= atoms.length;

    if (opts.fit !== false) {
      let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
      for (const a of atoms) {
        const r = el(a.s).radius;
        minX = Math.min(minX, a.x - r); maxX = Math.max(maxX, a.x + r);
        minY = Math.min(minY, a.y - r); maxY = Math.max(maxY, a.y + r);
      }
      const pad = opts.padding != null ? opts.padding : 14;
      const bw = maxX - minX || 1, bh = maxY - minY || 1;
      sc = Math.min((W - pad * 2) / bw, (H - pad * 2) / bh);
      if (opts.maxScale) sc = Math.min(sc, opts.maxScale);
    }
    const P = (a) => ({ x: (a.x - cx) * sc + ox, y: (a.y - cy) * sc + oy });
    const byId = {}; for (const a of atoms) byId[a.id] = a;

    // Bonds
    ctx.lineCap = 'round';
    for (const b of bonds || []) {
      const a1 = byId[bF(b)], a2 = byId[bT(b)];
      if (!a1 || !a2) continue;
      const p1 = P(a1), p2 = P(a2);
      const dx = p2.x - p1.x, dy = p2.y - p1.y, len = Math.hypot(dx, dy) || 1;
      const px = -dy / len, py = dx / len;
      const o = bO(b);
      ctx.strokeStyle = opts.lineColor || '#7a8290';
      ctx.lineWidth = Math.max(1, 2.2 * sc);
      const off = 3 * sc;
      const seg = (k) => { ctx.beginPath(); ctx.moveTo(p1.x + px * k, p1.y + py * k); ctx.lineTo(p2.x + px * k, p2.y + py * k); ctx.stroke(); };
      if (o === 1) seg(0);
      else if (o === 2) { seg(-off); seg(off); }
      else { seg(0); seg(-off * 1.4); seg(off * 1.4); }
    }
    // Atoms
    const showSym = opts.showSymbols !== false;
    for (const a of atoms) {
      const e = el(a.s);
      if (opts.hideH && a.s === 'H') continue;
      const p = P(a);
      const r = Math.max(3, e.radius * sc * (opts.atomScale || 1));
      const fill = opts.charges ? chargeColor(opts.charges[a.id]) : e.color;
      const grd = ctx.createRadialGradient(p.x - r * 0.3, p.y - r * 0.35, r * 0.1, p.x, p.y, r);
      grd.addColorStop(0, _lighten(fill, 90));
      grd.addColorStop(0.5, fill);
      grd.addColorStop(1, _darken(fill, 0.55));
      ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
      ctx.fillStyle = grd; ctx.fill();
      if (showSym && r > 6 && (a.s !== 'H' || r > 9)) {
        ctx.fillStyle = e.text || '#fff';
        ctx.font = `bold ${Math.max(7, Math.round(r * 0.8))}px Manrope, sans-serif`;
        ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
        ctx.fillText(a.s, p.x, p.y);
      }
    }
  }

  /* ── VSEPR: генерация настоящей 3D-геометрии ──────────────────────────────
   * Вход: atoms [{id,s,x,y}], bonds [{f,t,o}|{from,to,order}]
   * Выход: {
   *   atoms3d: [{id,s,x,y,z}]   — 3D-координаты (усл. ед., ~как 2D-масштаб),
   *   perAtom: { id: {domains, shape, hybridization, lonePairs} },
   *   shape:   строка-описание формы для малых молекул (напр. «угловая»),
   *   angle:   характерный валентный угол центральной молекулы (°) | null
   * }
   *
   * Алгоритм: для каждого атома считаем число электронных доменов
   * (соседи + неподелённые пары) → идеальная геометрия → BFS-укладка в 3D,
   * ориентируя набор идеальных направлений так, чтобы связь к «родителю»
   * совпала с одним из направлений.
   */
  const TET = (function () {
    const k = 1 / Math.sqrt(3);
    return [[k, k, k], [k, -k, -k], [-k, k, -k], [-k, -k, k]];
  })();
  const TRIG = [[1, 0, 0], [-0.5, 0.8660254, 0], [-0.5, -0.8660254, 0]];
  const LIN = [[1, 0, 0], [-1, 0, 0]];
  const OCT = [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]];
  const TRIGBIPY = [[0, 0, 1], [0, 0, -1], [1, 0, 0], [-0.5, 0.8660254, 0], [-0.5, -0.8660254, 0]];

  function _idealDirs(domains) {
    if (domains <= 1) return [[1, 0, 0]];
    if (domains === 2) return LIN;
    if (domains === 3) return TRIG;
    if (domains === 4) return TET;
    if (domains === 5) return TRIGBIPY;
    return OCT;
  }
  function _shapeName(centerSym, neighbors, lonePairs) {
    const d = neighbors + lonePairs;
    if (neighbors === 1) return 'двухатомная';
    if (d === 2) return 'линейная';
    if (d === 3) return lonePairs === 0 ? 'тригональная' : 'угловая';
    if (d === 4) {
      if (lonePairs === 0) return 'тетраэдрическая';
      if (lonePairs === 1) return 'пирамидальная';
      return 'угловая';
    }
    if (d === 5) return 'тригонально-бипирамидальная';
    return 'октаэдрическая';
  }
  function _hyb(domains) {
    return domains <= 2 ? 'sp' : domains === 3 ? 'sp²' : domains === 4 ? 'sp³' : domains === 5 ? 'sp³d' : 'sp³d²';
  }
  function _idealAngle(domains, lonePairs) {
    if (domains === 2) return 180;
    if (domains === 3) return lonePairs ? 117 : 120;
    if (domains === 4) return lonePairs === 0 ? 109.5 : lonePairs === 1 ? 107 : 104.5;
    if (domains === 5) return 90;
    return 90;
  }

  // Вращение, переводящее единичный вектор a в единичный вектор b (Родригес)
  function _rotBetween(a, b) {
    const v = _cross(a, b);
    const c = _dot(a, b);
    const s = _len(v);
    if (s < 1e-9) {
      if (c > 0) return [[1,0,0],[0,1,0],[0,0,1]]; // совпадают
      // противоположны — поворот на 180° вокруг любой перпендикулярной оси
      const ax = Math.abs(a[0]) < 0.9 ? [1,0,0] : [0,1,0];
      const k = _norm(_cross(a, ax));
      return _rotAxis(k, Math.PI);
    }
    const k = [v[0]/s, v[1]/s, v[2]/s];
    return _rotAxis(k, Math.atan2(s, c));
  }
  function _rotAxis(k, ang) {
    const c = Math.cos(ang), s = Math.sin(ang), t = 1 - c;
    const [x, y, z] = k;
    return [
      [t*x*x + c,   t*x*y - s*z, t*x*z + s*y],
      [t*x*y + s*z, t*y*y + c,   t*y*z - s*x],
      [t*x*z - s*y, t*y*z + s*x, t*z*z + c],
    ];
  }
  function _apply(M, v) {
    return [
      M[0][0]*v[0] + M[0][1]*v[1] + M[0][2]*v[2],
      M[1][0]*v[0] + M[1][1]*v[1] + M[1][2]*v[2],
      M[2][0]*v[0] + M[2][1]*v[1] + M[2][2]*v[2],
    ];
  }
  function _cross(a, b) { return [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]]; }
  function _dot(a, b) { return a[0]*b[0]+a[1]*b[1]+a[2]*b[2]; }
  function _len(v) { return Math.hypot(v[0], v[1], v[2]); }
  function _norm(v) { const l = _len(v) || 1; return [v[0]/l, v[1]/l, v[2]/l]; }

  function vsepr(atoms, bonds) {
    const idList = atoms.map(a => a.id);
    const byId = {}; atoms.forEach(a => byId[a.id] = a);
    // adjacency: id -> [{id, order}]
    const adj = {}; idList.forEach(i => adj[i] = []);
    let bondSum = {}; idList.forEach(i => bondSum[i] = 0);
    for (const b of bonds || []) {
      const f = bF(b), t = bT(b), o = bO(b);
      if (adj[f] && adj[t]) {
        adj[f].push({ id: t, order: o });
        adj[t].push({ id: f, order: o });
        bondSum[f] += o; bondSum[t] += o;
      }
    }
    // per-atom geometry descriptor
    const perAtom = {};
    for (const a of atoms) {
      const e = el(a.s);
      const deg = adj[a.id].length;
      let lp = 0;
      if (!e.metal && a.s !== 'H') {
        lp = Math.max(0, Math.round((e.ve - bondSum[a.id]) / 2));
        // у H, галогенов-терминалов и т.п. домены = соседи (терминальные)
      }
      const domains = Math.max(deg, deg + lp, 1);
      perAtom[a.id] = {
        domains,
        lonePairs: lp,
        neighbors: deg,
        shape: _shapeName(a.s, deg, lp),
        hybridization: _hyb(domains),
        angle: _idealAngle(domains, lp),
      };
    }

    // bond length in canvas units between two atoms
    const blen = (s1, s2) => (el(s1).cov + el(s2).cov) * 0.62; // C-C ≈ 94

    // BFS embedding
    const pos = {};                 // id -> [x,y,z]
    const placedDirs = {};          // id -> array of used unit directions (from this atom)
    const visited = new Set();
    // root = atom with max degree (центральный)
    let root = idList[0];
    for (const i of idList) if (adj[i].length > adj[root].length) root = i;

    const queue = [];
    pos[root] = [0, 0, 0];
    placedDirs[root] = [];
    visited.add(root);
    queue.push(root);

    while (queue.length) {
      const cur = queue.shift();
      const e = byId[cur].s;
      const info = perAtom[cur];
      const ideal = _idealDirs(info.domains).map(_norm);

      // Сколько направлений уже занято связью к родителю
      const usedDirs = placedDirs[cur].slice();
      // Ориентируем идеальный набор так, чтобы ideal[0] совпал с первым занятым
      let dirs = ideal;
      if (usedDirs.length) {
        const R = _rotBetween(ideal[0], usedDirs[0]);
        dirs = ideal.map(d => _norm(_apply(R, d)));
      }
      // помечаем направления, ближайшие к уже занятым, как использованные
      const taken = new Array(dirs.length).fill(false);
      for (const u of usedDirs) {
        let best = -1, bestDot = -2;
        for (let i = 0; i < dirs.length; i++) {
          if (taken[i]) continue;
          const dd = _dot(dirs[i], u);
          if (dd > bestDot) { bestDot = dd; best = i; }
        }
        if (best >= 0) taken[best] = true;
      }
      // распределяем оставшиеся направления по непосещённым соседям
      const freeIdx = [];
      for (let i = 0; i < dirs.length; i++) if (!taken[i]) freeIdx.push(i);
      let fi = 0;
      for (const nb of adj[cur]) {
        if (visited.has(nb.id)) continue;
        const dir = dirs[freeIdx[fi++]] || _norm([Math.random()-0.5, Math.random()-0.5, Math.random()-0.5]);
        const L = blen(e, byId[nb.id].s);
        pos[nb.id] = [pos[cur][0] + dir[0]*L, pos[cur][1] + dir[1]*L, pos[cur][2] + dir[2]*L];
        placedDirs[nb.id] = [[-dir[0], -dir[1], -dir[2]]]; // обратное направление к родителю
        (placedDirs[cur] = placedDirs[cur] || []).push(dir);
        visited.add(nb.id);
        queue.push(nb.id);
      }
    }
    // атомы вне основного компонента (несвязанные) — раскидываем по сетке
    let stray = 0;
    for (const a of atoms) {
      if (!pos[a.id]) { pos[a.id] = [(stray % 4) * 90 - 135, Math.floor(stray / 4) * 90, 0]; stray++; }
    }

    // центрируем
    let cx = 0, cy = 0, cz = 0, n = atoms.length || 1;
    for (const a of atoms) { cx += pos[a.id][0]; cy += pos[a.id][1]; cz += pos[a.id][2]; }
    cx /= n; cy /= n; cz /= n;
    const atoms3d = atoms.map(a => ({
      id: a.id, s: a.s,
      x: pos[a.id][0] - cx, y: pos[a.id][1] - cy, z: pos[a.id][2] - cz,
    }));

    // характеристика молекулы в целом (по центральному тяжёлому атому)
    let centerId = root;
    for (const a of atoms) if (a.s !== 'H' && perAtom[a.id].neighbors > perAtom[centerId].neighbors) centerId = a.id;
    const ci = perAtom[centerId];
    return {
      atoms3d, perAtom,
      shape: ci ? ci.shape : null,
      angle: ci && ci.neighbors >= 2 ? ci.angle : null,
      hybridization: ci ? ci.hybridization : null,
      centerSym: byId[centerId] ? byId[centerId].s : null,
    };
  }

  /* ── 3D-рендер (ball-and-stick с глубиной) ────────────────────────────────
   * atoms3d: [{id,s,x,y,z}]  bonds: нормализуются
   * cam: { rotX, rotY, scale, W, H }
   * opts: { vdw:false, bg:'#07070f', showSymbols:true }
   */
  // Затенённый «цилиндр» связи: толстый штрих с поперечным градиентом (центр светлее, края темнее)
  function _stick(ctx, x1, y1, x2, y2, width, baseRgb, alpha) {
    const dx = x2 - x1, dy = y2 - y1, len = Math.hypot(dx, dy) || 1;
    const ox = -dy / len, oy = dx / len;
    const mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
    const [r, g, b] = baseRgb;
    const grd = ctx.createLinearGradient(mx - ox * width, my - oy * width, mx + ox * width, my + oy * width);
    const dark = `rgba(${Math.round(r*0.35)},${Math.round(g*0.35)},${Math.round(b*0.35)},${alpha})`;
    const lite = `rgba(${Math.min(255,r+70)},${Math.min(255,g+70)},${Math.min(255,b+70)},${alpha})`;
    grd.addColorStop(0, dark);
    grd.addColorStop(0.42, lite);
    grd.addColorStop(0.5, `rgba(${Math.min(255,r+110)},${Math.min(255,g+110)},${Math.min(255,b+110)},${alpha})`);
    grd.addColorStop(0.58, lite);
    grd.addColorStop(1, dark);
    ctx.strokeStyle = grd;
    ctx.lineWidth = width * 2;
    ctx.lineCap = 'round';
    ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
  }

  function render3D(ctx, atoms3d, bonds, cam, opts) {
    opts = opts || {};
    const W = cam.W, H = cam.H;
    const cxr = Math.cos(cam.rotX), sxr = Math.sin(cam.rotX);
    const cyr = Math.cos(cam.rotY), syr = Math.sin(cam.rotY);
    const fov = 700, sc = cam.scale || 1;

    if (opts.bg !== null) { ctx.fillStyle = opts.bg || '#07070f'; ctx.fillRect(0, 0, W, H); }
    if (!atoms3d || !atoms3d.length) return;

    // проекция: sz — глубина (больше = дальше от камеры)
    const pm = {};
    for (const a of atoms3d) {
      const x = a.x * sc, y = a.y * sc, z = a.z * sc;
      const x1 = x * cyr + z * syr;
      const z1 = -x * syr + z * cyr;
      const y2 = y * cxr - z1 * sxr;
      const z2 = y * sxr + z1 * cxr;
      const persp = fov / (fov + z2);
      pm[a.id] = { a, sx: x1 * persp + W / 2, sy: y2 * persp + H / 2, sz: z2, persp };
    }

    const vdw = !!opts.vdw;
    // единый список примитивов (атомы + половинки связей) для корректной сортировки по глубине
    const prims = [];

    if (!vdw) {
      for (const b of bonds || []) {
        const p1 = pm[bF(b)], p2 = pm[bT(b)];
        if (!p1 || !p2) continue;
        const o = bO(b);
        const dx = p2.sx - p1.sx, dy = p2.sy - p1.sy, len = Math.hypot(dx, dy) || 1;
        const ox = -dy / len, oy = dx / len;     // перпендикуляр для кратных связей
        const c1 = _hexRgb(el(p1.a.s).color), c2 = _hexRgb(el(p2.a.s).color);
        const mxs = (p1.sx + p2.sx) / 2, mys = (p1.sy + p2.sy) / 2;
        // ширина связи зависит от перспективы (ближе — толще)
        const wAvg = (p1.persp + p2.persp) / 2;
        const baseW = Math.max(1.6, 3.4 * wAvg);
        // смещения для двойных/тройных связей
        const offs = o === 1 ? [0] : o === 2 ? [-1, 1] : [-1.5, 0, 1.5];
        const ow = baseW * 1.7;
        for (const k of offs) {
          const sxo = ox * k * ow, syo = oy * k * ow;
          const w = o === 1 ? baseW : baseW * 0.62;
          // половина к атому 1
          prims.push({ t: 'stick', z: (p1.sz * 3 + p2.sz) / 4,
            x1: p1.sx + sxo, y1: p1.sy + syo, x2: mxs + sxo, y2: mys + syo, w, c: c1, persp: p1.persp });
          // половина к атому 2
          prims.push({ t: 'stick', z: (p2.sz * 3 + p1.sz) / 4,
            x1: mxs + sxo, y1: mys + syo, x2: p2.sx + sxo, y2: p2.sy + syo, w, c: c2, persp: p2.persp });
        }
      }
    }

    for (const id in pm) {
      const p = pm[id];
      prims.push({ t: 'atom', z: p.sz, p });
    }

    prims.sort((a, b) => b.z - a.z); // дальние раньше (painter): больший z рисуется первым

    for (const pr of prims) {
      if (pr.t === 'stick') {
        _stick(ctx, pr.x1, pr.y1, pr.x2, pr.y2, pr.w, pr.c, 0.55 + pr.persp * 0.4);
        continue;
      }
      const { a, sx, sy, persp } = pr.p;
      const e = el(a.s);
      const baseR = vdw ? (e.vdw / 100) * 16 : (e.cov / 100) * 11 + 6;
      const r = Math.max(3, baseR * persp * sc * (vdw ? 1.0 : 0.95));
      const fillHex = opts.charges ? chargeColor(opts.charges[a.id]) : e.color;
      const [r0, g0, b0] = _hexRgb(fillHex);
      // глянцевый блик смещён к свету (верх-лево)
      const grd = ctx.createRadialGradient(sx - r*0.35, sy - r*0.4, r*0.05, sx, sy, r * 1.05);
      grd.addColorStop(0, `rgb(${Math.min(255,r0+135)},${Math.min(255,g0+135)},${Math.min(255,b0+135)})`);
      grd.addColorStop(0.4, fillHex);
      grd.addColorStop(1, `rgb(${Math.round(r0*0.18)},${Math.round(g0*0.18)},${Math.round(b0*0.18)})`);
      // мягкая тень-ободок для объёма
      ctx.beginPath(); ctx.arc(sx, sy, r, 0, Math.PI * 2);
      ctx.fillStyle = grd; ctx.fill();
      ctx.lineWidth = 0.8; ctx.strokeStyle = `rgba(0,0,0,0.35)`; ctx.stroke();
      if (opts.showSymbols !== false && !vdw && (a.s !== 'H' || r > 12)) {
        ctx.fillStyle = e.text || '#fff';
        ctx.font = `bold ${Math.max(8, Math.round(r * 0.72))}px Manrope, sans-serif`;
        ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
        ctx.fillText(a.s, sx, sy);
      }
    }

    // стрелка дипольного момента (от центра к δ−), если передан вектор
    if (opts.dipoleVec) {
      const [dx, dy, dz] = opts.dipoleVec;
      const dl = Math.hypot(dx, dy, dz);
      if (dl > 1e-3) {
        const proj = (x, y, z) => {
          const x1 = x * cyr + z * syr, z1 = -x * syr + z * cyr;
          const y2 = y * cxr - z1 * sxr, z2 = y * sxr + z1 * cxr;
          const pp = fov / (fov + z2);
          return [x1 * pp + W / 2, y2 * pp + H / 2];
        };
        const L = 70; // длина стрелки в экранных ед.
        const ux = dx / dl, uy = dy / dl, uz = dz / dl;
        const [ax, ay] = proj(0, 0, 0);
        const [bx, by] = proj(ux * L / sc, uy * L / sc, uz * L / sc);
        ctx.strokeStyle = '#facc15'; ctx.fillStyle = '#facc15'; ctx.lineWidth = 2.5; ctx.lineCap = 'round';
        ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
        const ang = Math.atan2(by - ay, bx - ax), ah = 9;
        ctx.beginPath(); ctx.moveTo(bx, by);
        ctx.lineTo(bx - ah * Math.cos(ang - 0.4), by - ah * Math.sin(ang - 0.4));
        ctx.lineTo(bx - ah * Math.cos(ang + 0.4), by - ah * Math.sin(ang + 0.4));
        ctx.closePath(); ctx.fill();
      }
    }
  }

  /* ── Цветовые утилиты ─────────────────────────────────────────────────── */
  function _hexRgb(hex) {
    hex = String(hex).replace('#', '');
    if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
    const n = parseInt(hex, 16);
    return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
  }
  function _lighten(hex, amt) {
    const [r, g, b] = _hexRgb(hex);
    return `rgb(${Math.min(255, r + amt)},${Math.min(255, g + amt)},${Math.min(255, b + amt)})`;
  }
  function _darken(hex, f) {
    const [r, g, b] = _hexRgb(hex);
    return `rgb(${Math.round(r * f)},${Math.round(g * f)},${Math.round(b * f)})`;
  }
  // Цвет атома по частичному заряду: δ+ синий, δ− красный, 0 серый
  function chargeColor(q) {
    const grey = [138, 138, 138];
    const t = Math.max(-1, Math.min(1, (q || 0) / 0.5));
    const target = t > 0 ? [64, 96, 255] : [238, 32, 32]; // δ+ синий / δ− красный
    const k = Math.abs(t);
    const mix = grey.map((g, i) => Math.round(g + (target[i] - g) * k));
    return `#${mix.map(v => v.toString(16).padStart(2, '0')).join('')}`;
  }

  /* ── safe: обёртка для API с тостом ошибки ───────────────────────────────
   * await BIO.safe(LS.biochemGetMolecules(), 'Не удалось загрузить молекулы')
   */
  async function safe(promiseOrFn, errMsg) {
    try {
      return await (typeof promiseOrFn === 'function' ? promiseOrFn() : promiseOrFn);
    } catch (e) {
      const msg = errMsg || ('Ошибка: ' + (e && e.message ? e.message : e));
      if (global.LS && typeof global.LS.toast === 'function') global.LS.toast(msg, 'error');
      else console.error(msg, e);
      return null;
    }
  }

  /* ── Шаблоны колец (2D-координаты) ───────────────────────────────────── */
  const RING_TEMPLATES = {
    benzene: {
      atoms: [{s:'C',x:0,y:-55},{s:'C',x:47.6,y:-27.5},{s:'C',x:47.6,y:27.5},
              {s:'C',x:0,y:55},{s:'C',x:-47.6,y:27.5},{s:'C',x:-47.6,y:-27.5}],
      bonds: [[0,1,2],[1,2,1],[2,3,2],[3,4,1],[4,5,2],[5,0,1]],
    },
    cyclohexane: {
      atoms: [{s:'C',x:0,y:-55},{s:'C',x:47.6,y:-27.5},{s:'C',x:47.6,y:27.5},
              {s:'C',x:0,y:55},{s:'C',x:-47.6,y:27.5},{s:'C',x:-47.6,y:-27.5}],
      bonds: [[0,1,1],[1,2,1],[2,3,1],[3,4,1],[4,5,1],[5,0,1]],
    },
    cyclopentane: {
      atoms: [{s:'C',x:0,y:-50},{s:'C',x:47.6,y:-15.5},{s:'C',x:29.4,y:40.5},
              {s:'C',x:-29.4,y:40.5},{s:'C',x:-47.6,y:-15.5}],
      bonds: [[0,1,1],[1,2,1],[2,3,1],[3,4,1],[4,0,1]],
    },
    naphthalene: {
      atoms: [
        {s:'C',x:0,y:27.5},{s:'C',x:0,y:-27.5},
        {s:'C',x:-47.6,y:-55},{s:'C',x:-95.2,y:-27.5},{s:'C',x:-95.2,y:27.5},{s:'C',x:-47.6,y:55},
        {s:'C',x:47.6,y:-55},{s:'C',x:95.2,y:-27.5},{s:'C',x:95.2,y:27.5},{s:'C',x:47.6,y:55},
      ],
      bonds: [
        [0,1,1],[1,2,2],[2,3,1],[3,4,2],[4,5,1],[5,0,2],
        [1,6,2],[6,7,1],[7,8,2],[8,9,1],[9,0,2],
      ],
    },
  };

  /* ── Валентность: подробная проверка с подсказками (Фаза 2.4) ──────────
   * Возвращает массив проблем-«перевалентностей» с готовым человекочитаемым
   * текстом: [{ id, symbol, name, used, max, over, kind:'error', msg }].
   * Работает с обоими форматами связей (from/to/order и f/t/o) через bF/bT/bO.
   */
  function _bondWord(n) {
    const m10 = n % 10, m100 = n % 100;
    if (m10 === 1 && m100 !== 11) return 'связь';
    if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return 'связи';
    return 'связей';
  }
  function valency(atoms, bonds) {
    if (!atoms || !atoms.length) return [];
    const sum = {};
    for (const b of (bonds || [])) {
      const f = bF(b), t = bT(b), o = bO(b);
      sum[f] = (sum[f] || 0) + o;
      sum[t] = (sum[t] || 0) + o;
    }
    const out = [];
    for (const a of atoms) {
      const e = el(a.s);
      const used = sum[a.id] || 0;
      const max = e.maxV != null ? e.maxV : 4;
      if (used > max) {
        const over = used - max;
        out.push({
          id: a.id, symbol: a.s, name: e.name, used, max, over, kind: 'error',
          msg: e.name + ' (' + a.s + '): занято ' + used + ' ' + _bondWord(used) +
               ', максимум ' + max + ' — убери ' + over,
        });
      }
    }
    return out;
  }

  /* ── Экспорт (браузер: window.BIO; Node: module.exports) ──────────────── */
  var _api = {
    ELEMENTS, el,
    bF, bT, bO,
    counts, hillFormula, molarMass, parseFormula, dbe,
    partialCharges, dipole, polarity, massFractions, functionalGroups, analyze, valency,
    balance, parseSmiles, toJSON, download,
    render2D, vsepr, render3D, chargeColor,
    safe, RING_TEMPLATES,
    _hexRgb, _lighten, _darken,
  };
  global.BIO = _api;
  if (typeof module !== 'undefined' && module.exports) module.exports = _api;
})(typeof window !== 'undefined' ? window : globalThis);
