fix(biochem 3D): корректная глубина + объёмные связи-цилиндры

Два дефекта, из-за которых 3D читался как плоская диаграмма:
- painter-сортировка была по возрастанию z (ближние первыми) — дальние
  атомы рисовались поверх ближних. Теперь единый список примитивов
  (атомы + половинки связей) сортируется по убыванию z (дальние первыми).
- связи были тонкими плоскими линиями. Теперь — затенённые «цилиндры»:
  толстый штрих с поперечным градиентом (центр светлее, края темнее),
  двухцветные (каждая половина под цвет своего атома) — фирменный вид
  ball-and-stick. Ширина зависит от перспективы (ближе — толще).
- усилена перспектива (fov 900→700), добавлен тёмный ободок сфер для объёма.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-30 12:58:39 +03:00
parent 3b6481b1df
commit 410eb8a862
12 changed files with 559 additions and 27 deletions
+69 -25
View File
@@ -396,62 +396,106 @@
* 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 = 900, sc = cam.scale || 1;
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;
const proj = atoms3d.map(a => {
// проекция: 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);
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)
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) {
// связи рисуем в порядке глубины вместе с атомами — упрощённо рисуем все связи,
// затем атомы поверх (сортированные). Для корректной глубины интерполируем 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); }
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 p of proj) {
const { a, sx, sy, persp } = p;
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) * 16 + 5;
const r = Math.max(3, baseR * persp * sc * (vdw ? 1.0 : 0.9));
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 [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)})`);
// глянцевый блик смещён к свету (верх-лево)
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, e.color);
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`;