diff --git a/frontend/biochem.html b/frontend/biochem.html
index 08778ad..a6dec9b 100644
--- a/frontend/biochem.html
+++ b/frontend/biochem.html
@@ -435,6 +435,18 @@
+
+
Текущее задание
@@ -1260,6 +1272,42 @@ async function saveCurrentMolecule() {
} catch(e) { LS.toast('Ошибка: '+e.message, 'error'); }
}
+// ── SMILES import ──
+function importSmiles() {
+ const inp = document.getElementById('smiles-in');
+ const smi = (inp.value || '').trim();
+ if (!smi) return;
+ const parsed = BIO.parseSmiles(smi);
+ if (!parsed || !parsed.atoms.length) {
+ LS.toast('Не удалось разобрать SMILES (поддержан верхний регистр: CCO, C1=CC=CC=C1)', 'error');
+ return;
+ }
+ pushHistory();
+ // переносим в редактор (bonds в формате {from,to,order})
+ const idMap = {};
+ atoms = parsed.atoms.map(a => { const nid = nextId++; idMap[a.id] = nid; return { id: nid, s: a.s, x: a.x, y: a.y }; });
+ bonds = parsed.bonds.map(b => ({ id: nextId++, from: idMap[b.f], to: idMap[b.t], order: b.o }));
+ inp.value = '';
+ if (_is3D) _build3D();
+ centerView(); updateInfo();
+ LS.toast(`Импортировано: ${BIO.hillFormula(atoms)}`, 'success');
+}
+
+// ── Export ──
+function exportPNG() {
+ if (!atoms.length) { LS.toast('Пустой холст', 'info'); return; }
+ const a = document.createElement('a');
+ a.href = canvas.toDataURL('image/png');
+ a.download = (hillFormula() || 'molecule') + (_is3D ? '-3d' : '') + '.png';
+ a.click();
+}
+function exportJSON() {
+ if (!atoms.length) { LS.toast('Пустой холст', 'info'); return; }
+ BIO.download((hillFormula() || 'molecule') + '.json',
+ BIO.toJSON(atoms, bonds.map(b => ({ f: b.from, t: b.to, o: b.order })), hillFormula()),
+ 'application/json');
+}
+
// ── Library ──
async function loadFromLibrary() {
if (!_libAll.length) _libAll = await LS.biochemGetMolecules();
diff --git a/frontend/js/biochem-core.js b/frontend/js/biochem-core.js
index 6a7e5c8..a622e0b 100644
--- a/frontend/js/biochem-core.js
+++ b/frontend/js/biochem-core.js
@@ -304,6 +304,121 @@
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 }
@@ -802,7 +917,7 @@
bF, bT, bO,
counts, hillFormula, molarMass, parseFormula, dbe,
partialCharges, dipole, polarity, massFractions, functionalGroups, analyze,
- balance,
+ balance, parseSmiles, toJSON, download,
render2D, vsepr, render3D, chargeColor,
safe, RING_TEMPLATES,
_hexRgb, _lighten, _darken,