feat(biochem): ядро biochem-core.js + настоящая 3D-геометрия (VSEPR)

Фаза 0 (фундамент) + Фаза 1 (3D) плана BIOCHEM_UPGRADE:
- Новый общий модуль frontend/js/biochem-core.js (window.BIO): реестр
  элементов (CPK, масса, валентность, электроотрицательность, ковалентный/
  ван-дер-ваальсов радиусы), hillFormula/molarMass/parseFormula/dbe,
  нормализация связей (bF/bT/bO — чинит расхождение полей f/from, o/order),
  render2D, vsepr (генератор 3D по ОЭПВО), render3D (ball-and-stick с
  глубиной и затенением), safe (обёртка API с тостом), RING_TEMPLATES.
- biochem.html: подключён core; фейковый 3D (плоская проекция a.z||0)
  заменён на честную VSEPR-геометрию через BIO.render3D; в панель свойств
  добавлены форма молекулы, гибридизация и валентный угол; фикс бага
  порядка связи в getBondSum.

VSEPR проверен: вода — угловая, метан — тетраэдр 109.5°, CO2 — линейная
180°, NH3 — пирамидальная; sp/sp2/sp3 верно.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-30 12:42:44 +03:00
parent 1c7d8e9d95
commit 5dc9164ee3
2 changed files with 566 additions and 99 deletions
+33 -99
View File
@@ -507,6 +507,7 @@
</div>
<script src="/js/api.js"></script>
<script src="/js/biochem-core.js"></script>
<script src="/js/sidebar.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script>
@@ -666,7 +667,7 @@ function hillFormula() {
return parts.join('');
}
function getBondSum(id) {
return bonds.reduce((s,b) => s + (b.from===id||b.to===id ? b.o || b.order : 0), 0);
return bonds.reduce((s,b) => s + (b.from===id||b.to===id ? (b.order||b.o||1) : 0), 0);
}
function getIssues() {
return atoms.filter(a => {
@@ -712,6 +713,15 @@ function calcMolStats() {
}
chips.push(_chip('Полярность', polarity.label, polarity.cls));
chips.push(_chip('Атомов', atoms.length, ''));
// Geometry (VSEPR) — for the central atom
if (window.BIO && bonds.length) {
const geom = BIO.vsepr(atoms, bonds);
if (geom.shape) chips.push(_chip('Геометрия', geom.shape, 'violet'));
if (geom.hybridization) chips.push(_chip('Гибридизация', geom.hybridization, 'cyan'));
if (geom.angle != null) chips.push(_chip('Угол связи', geom.angle + '°', ''));
}
if (molClass) chips.push(
`<div class="bp-stat-chip" style="grid-column:1/-1"><span class="bp-stat-lbl">Класс</span>` +
`<span class="bp-stat-val violet">${molClass}</span></div>`
@@ -1283,6 +1293,7 @@ function updateInfo() {
} else { info.style.display = 'none'; }
calcMolStats();
if (_is3D) _build3D();
}
// ── Build element palette ──
@@ -1667,9 +1678,20 @@ let _3dVelY = 0;
let _3dSpin = true; // auto-spin flag
let _3dSpinTmo = null; // resume-spin timeout
// VDW radii (van der Waals, canvas units)
const VDW_R = { H:38, C:56, N:52, O:50, S:72, P:70, F:40, Cl:72, Br:85,
Na:80, Ca:86, K:92, Mg:72, Fe:70 };
// Real 3D geometry (VSEPR), rebuilt whenever the molecule changes in 3D mode
let _atoms3d = [];
function _build3D() {
if (window.BIO) _atoms3d = BIO.vsepr(atoms, bonds).atoms3d;
else _atoms3d = atoms.map(a => ({ id:a.id, s:a.s, x:a.x, y:a.y, z:0 }));
}
// Fit scale to the 3D molecule extent so it fills the view nicely
function _fit3D() {
if (!_atoms3d.length) { scale = 1; return; }
let ext = 1;
for (const a of _atoms3d) ext = Math.max(ext, Math.hypot(a.x, a.y, a.z));
const view = Math.min(canvas.width, canvas.height) * 0.40;
scale = Math.max(0.3, Math.min(4, view / (ext * 1.6 + 30)));
}
function toggle3D() {
_is3D = !_is3D;
@@ -1679,7 +1701,8 @@ function toggle3D() {
if (!_is3D && _isVDW) { _isVDW = false; vdwBtn.classList.remove('mode-3d-active'); }
if (_is3D) {
canvas.style.cursor = 'grab';
centerView();
_build3D();
_fit3D();
_start3D();
} else {
_stop3D();
@@ -1719,102 +1742,13 @@ function _stop3D() {
if (_3dAnimId) { cancelAnimationFrame(_3dAnimId); _3dAnimId = null; }
}
function _proj3D(x, y, z) {
const cy = Math.cos(_3dRotY), sy = Math.sin(_3dRotY);
const x1 = x * cy + z * sy;
const z1 = -x * sy + z * cy;
const cx = Math.cos(_3dRotX), sx2 = Math.sin(_3dRotX);
const y2 = y * cx - z1 * sx2;
const z2 = y * sx2 + z1 * cx;
const fov = 800;
const sc = fov / (fov + z2);
return { sx: x1 * sc, sy: y2 * sc, sz: z2, sc };
}
function _hexRgb(hex) {
hex = 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 render3D() {
const W = canvas.width, H = canvas.height;
ctx.clearRect(0,0,W,H);
ctx.fillStyle = '#07070f';
ctx.fillRect(0,0,W,H);
if (!atoms.length) return;
// molecule center (world coords)
let cx=0, cy=0;
for (const a of atoms) { cx+=a.x; cy+=a.y; }
cx/=atoms.length; cy/=atoms.length;
const s3 = scale * 1.15;
// project all atoms
const proj = atoms.map(a => {
const p = _proj3D((a.x-cx)*s3, (a.y-cy)*s3, (a.z||0)*s3);
return { a, sx: p.sx+W/2, sy: p.sy+H/2, sz: p.sz, sc: p.sc };
});
proj.sort((a,b) => b.sz - a.sz); // back<svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>front
const pm = {};
for (const p of proj) pm[p.a.id] = p;
// bonds (hidden in VDW mode)
if (!_isVDW) {
for (const b of bonds) {
const p1 = pm[b.from], p2 = pm[b.to];
if (!p1||!p2) continue;
const avgSc = (p1.sc+p2.sc)/2;
ctx.strokeStyle = `rgba(180,180,200,${0.35+avgSc*0.55})`;
ctx.lineWidth = Math.max(1.2, 3.5*avgSc);
ctx.lineCap = 'round';
const order = b.order||1;
if (order===1) {
ctx.beginPath(); ctx.moveTo(p1.sx,p1.sy); ctx.lineTo(p2.sx,p2.sy); ctx.stroke();
} else {
const dx=p2.sx-p1.sx, dy=p2.sy-p1.sy, len=Math.hypot(dx,dy)||1;
const ox=-dy/len*3.5*avgSc, oy=dx/len*3.5*avgSc;
for (let o=-(order-1); o<=(order-1); o+=2) {
ctx.beginPath();
ctx.moveTo(p1.sx+ox*o, p1.sy+oy*o);
ctx.lineTo(p2.sx+ox*o, p2.sy+oy*o);
ctx.stroke();
}
}
}
}
// atoms (back<svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>front already)
for (const p of proj) {
const { a, sx, sy, sc } = p;
const el = ELEMENTS[a.s]||{color:'#888',text:'#fff',radius:20};
const r = _isVDW
? Math.max(4, (VDW_R[a.s] || 56) * sc * 0.85)
: Math.max(3, el.radius * sc * 1.25);
const [r0,g0,b0] = _hexRgb(el.color);
// sphere gradient: highlight top-left, dark bottom-right
const grd = ctx.createRadialGradient(sx-r*0.32,sy-r*0.38,r*0.06, sx,sy,r);
grd.addColorStop(0, `rgb(${Math.min(255,r0+110)},${Math.min(255,g0+110)},${Math.min(255,b0+110)})`);
grd.addColorStop(0.42, el.color);
grd.addColorStop(1, `rgb(${Math.round(r0*0.2)},${Math.round(g0*0.2)},${Math.round(b0*0.2)})`);
ctx.beginPath();
ctx.arc(sx, sy, r, 0, Math.PI*2);
ctx.fillStyle = grd;
ctx.fill();
// label (hide H when tiny, hidden in VDW mode)
if (!_isVDW && (a.s !== 'H' || r > 13)) {
ctx.fillStyle = el.text||'#fff';
ctx.font = `bold ${Math.max(8,Math.round(r*0.72))}px Manrope,sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.shadowBlur = 0;
ctx.fillText(a.s, sx, sy);
}
}
if (!_atoms3d.length && atoms.length) _build3D();
// 3D coords are in canvas units (~real geometry); scale to fit the view
BIO.render3D(ctx, _atoms3d, bonds, {
rotX: _3dRotX, rotY: _3dRotY, scale: scale * 1.6, W, H,
}, { vdw: _isVDW, bg: '#07070f' });
}
// ── Init ──
+533
View File
@@ -0,0 +1,533 @@
/*
* 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;
}
/* ── 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 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(e.color, 90));
grd.addColorStop(0.5, e.color);
grd.addColorStop(1, _darken(e.color, 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 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 = 900, sc = cam.scale || 1;
if (opts.bg !== null) { ctx.fillStyle = opts.bg || '#07070f'; ctx.fillRect(0, 0, W, H); }
if (!atoms3d || !atoms3d.length) return;
const proj = atoms3d.map(a => {
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);
return { a, sx: x1 * persp + W / 2, sy: y2 * persp + H / 2, sz: z2, persp };
});
const pm = {}; for (const p of proj) pm[p.a.id] = p;
proj.sort((p, q) => p.sz - q.sz); // дальние раньше (painter)
const vdw = !!opts.vdw;
if (!vdw) {
// связи рисуем в порядке глубины вместе с атомами — упрощённо рисуем все связи,
// затем атомы поверх (сортированные). Для корректной глубины интерполируем z.
for (const b of bonds || []) {
const p1 = pm[bF(b)], p2 = pm[bT(b)];
if (!p1 || !p2) continue;
const avg = (p1.persp + p2.persp) / 2;
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;
ctx.strokeStyle = `rgba(190,195,210,${0.30 + avg * 0.55})`;
ctx.lineWidth = Math.max(1.4, 4 * avg);
ctx.lineCap = 'round';
const seg = (k) => { ctx.beginPath(); ctx.moveTo(p1.sx + ox*k, p1.sy + oy*k); ctx.lineTo(p2.sx + ox*k, p2.sy + oy*k); ctx.stroke(); };
if (o === 1) seg(0);
else { const off = 3.2 * avg; for (let i = -(o-1); i <= (o-1); i += 2) seg(off * i); }
}
}
for (const p of proj) {
const { a, sx, sy, persp } = p;
const e = el(a.s);
const baseR = vdw ? (e.vdw / 100) * 16 : (e.cov / 100) * 16 + 5;
const r = Math.max(3, baseR * persp * sc * (vdw ? 1.0 : 0.9));
const [r0, g0, b0] = _hexRgb(e.color);
const grd = ctx.createRadialGradient(sx - r*0.32, sy - r*0.38, r*0.06, sx, sy, r);
grd.addColorStop(0, `rgb(${Math.min(255,r0+115)},${Math.min(255,g0+115)},${Math.min(255,b0+115)})`);
grd.addColorStop(0.42, e.color);
grd.addColorStop(1, `rgb(${Math.round(r0*0.2)},${Math.round(g0*0.2)},${Math.round(b0*0.2)})`);
ctx.beginPath(); ctx.arc(sx, sy, r, 0, Math.PI * 2);
ctx.fillStyle = grd; ctx.fill();
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);
}
}
}
/* ── Цветовые утилиты ─────────────────────────────────────────────────── */
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)})`;
}
/* ── 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],
],
},
};
/* ── Экспорт ──────────────────────────────────────────────────────────── */
global.BIO = {
ELEMENTS, el,
bF, bT, bO,
counts, hillFormula, molarMass, parseFormula, dbe,
render2D, vsepr, render3D,
safe, RING_TEMPLATES,
_hexRgb, _lighten, _darken,
};
})(window);