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 ──