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:
+69
-25
@@ -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`;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
'use strict';
|
||||
/*
|
||||
* LabRegistry — единый реестр симуляций лаборатории (контент-движок).
|
||||
*
|
||||
* Цель: симуляции описываются декларативным манифестом и сами себя регистрируют,
|
||||
* вместо захардкоженных массивов (SIMS), if-цепочек (openSim) и объектов (THEORY).
|
||||
*
|
||||
* Манифест:
|
||||
* {
|
||||
* id: 'pendulum', // уникальный, без ':arg'
|
||||
* cat: 'phys', // math | phys | chem | bio | game
|
||||
* title: 'Маятник',
|
||||
* desc: 'Колебания, период…',
|
||||
* preview: string | function(), // SVG-разметка карточки (функция вычисляется лениво)
|
||||
* theory: { title, sections[] },// объект для панели теории (как в THEORY)
|
||||
* bodyId: 'sim-pendulum', // (опц.) id тела; mount() — для ленивого создания DOM (Фаза 2)
|
||||
* mount: function(host){}, // (опц.) ленивое монтирование тела
|
||||
* open: function(ctx){}, // ctx = { id, arg } — открыть/инициализировать
|
||||
* stop: function(){}, // (опц.) остановить анимации (не разрушая)
|
||||
* destroy: function(){}, // (опц.) полностью закрыть; по умолчанию == stop
|
||||
* subject, grade, topics // (опц.) курикулумные поля (Фаза 5)
|
||||
* }
|
||||
*
|
||||
* Загружается ПЕРВЫМ среди labs-скриптов, чтобы window.LabRegistry существовал
|
||||
* к моменту исполнения тел остальных модулей.
|
||||
*/
|
||||
(function () {
|
||||
var _list = []; // манифесты в порядке регистрации
|
||||
var _byId = {}; // id -> манифест
|
||||
var _active = null; // текущая открытая симуляция
|
||||
|
||||
function _baseId(id) {
|
||||
return id == null ? id : String(id).split(':')[0];
|
||||
}
|
||||
|
||||
function register(m) {
|
||||
if (!m || !m.id) return null;
|
||||
if (Object.prototype.hasOwnProperty.call(_byId, m.id)) {
|
||||
// перерегистрация: заменить на месте, сохранив позицию
|
||||
for (var i = 0; i < _list.length; i++) {
|
||||
if (_list[i].id === m.id) { _list[i] = m; break; }
|
||||
}
|
||||
} else {
|
||||
_list.push(m);
|
||||
}
|
||||
_byId[m.id] = m;
|
||||
return m;
|
||||
}
|
||||
|
||||
function get(id) {
|
||||
var b = _baseId(id);
|
||||
return Object.prototype.hasOwnProperty.call(_byId, b) ? _byId[b] : null;
|
||||
}
|
||||
|
||||
function has(id) { return !!get(id); }
|
||||
|
||||
function all() { return _list.slice(); }
|
||||
|
||||
function setActive(m) { _active = m || null; }
|
||||
|
||||
function stopActive() {
|
||||
if (_active && typeof _active.stop === 'function') {
|
||||
try { _active.stop(); } catch (e) { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
function destroyActive() {
|
||||
if (_active) {
|
||||
if (typeof _active.destroy === 'function') {
|
||||
try { _active.destroy(); } catch (e) { /* noop */ }
|
||||
} else if (typeof _active.stop === 'function') {
|
||||
try { _active.stop(); } catch (e) { /* noop */ }
|
||||
}
|
||||
}
|
||||
_active = null;
|
||||
}
|
||||
|
||||
function active() { return _active; }
|
||||
|
||||
// Разрешить preview (строка или функция) в готовую разметку.
|
||||
function resolvePreview(m) {
|
||||
if (!m) return '';
|
||||
var p = m.preview;
|
||||
if (typeof p === 'function') {
|
||||
try { return p() || ''; } catch (e) { return ''; }
|
||||
}
|
||||
return p || '';
|
||||
}
|
||||
|
||||
window.LabRegistry = {
|
||||
register: register,
|
||||
get: get,
|
||||
has: has,
|
||||
all: all,
|
||||
setActive: setActive,
|
||||
stopActive: stopActive,
|
||||
destroyActive: destroyActive,
|
||||
active: active,
|
||||
resolvePreview: resolvePreview
|
||||
};
|
||||
})();
|
||||
@@ -20,11 +20,25 @@
|
||||
}
|
||||
|
||||
function renderSims() {
|
||||
const base = _catFilter === 'all' ? SIMS : SIMS.filter(s => s.cat === _catFilter);
|
||||
// Контент-движок: мёрж код-реестра поверх legacy SIMS.
|
||||
// Порядок берём из SIMS; для мигрированных id используем манифест реестра;
|
||||
// registry-only записи добавляем в конец.
|
||||
const _reg = (window.LabRegistry ? window.LabRegistry.all() : []);
|
||||
const _regById = {};
|
||||
_reg.forEach(m => { _regById[m.id] = m; });
|
||||
const _seen = {};
|
||||
const _merged = [];
|
||||
SIMS.forEach(s => {
|
||||
_merged.push(s.id && _regById[s.id] ? _regById[s.id] : s);
|
||||
if (s.id) _seen[s.id] = 1;
|
||||
});
|
||||
_reg.forEach(m => { if (!_seen[m.id]) _merged.push(m); });
|
||||
|
||||
const base = _catFilter === 'all' ? _merged : _merged.filter(s => s.cat === _catFilter);
|
||||
const list = base.filter(s => !s.id || !_disabledSimIds.has(s.id));
|
||||
document.getElementById('sim-grid').innerHTML = list.map(s => `
|
||||
<div class="sim-card ${s.id ? '' : 'soon'}" ${s.id ? `onclick="openSim('${s.id}')"` : ''}>
|
||||
${s.preview}
|
||||
${window.LabRegistry ? window.LabRegistry.resolvePreview(s) : s.preview}
|
||||
<div class="sim-body">
|
||||
<div class="sim-cat ${s.cat}">${s.cat === 'math' ? '∑ Математика' : s.cat === 'chem' ? '<svg class="ic" viewBox="0 0 24 24"><path d="M9 3h6m-4.5 0v5.5l-4 7.5a1 1 0 0 0 .9 1.5h8.2a1 1 0 0 0 .9-1.5l-4-7.5V3"/></svg> Химия' : s.cat === 'bio' ? '<svg class="ic" viewBox="0 0 24 24"><path d="M2 15c6.667-6 13.333 0 20-6"/><path d="M9 22c1.798-2 2.518-4 2.807-6"/><path d="M15 2c-1.798 2-2.518 4-2.807 6"/><path d="m17 6-2.5-2.5M14 8 13 7M7 18l2.5 2.5M3.5 14.5l.5.5M20 9l.5.5M6.5 12.5l1 1M16.5 10.5l1 1M10 16l1.5 1.5"/></svg> Биология' : s.cat === 'game' ? '<svg class="ic" viewBox="0 0 24 24"><line x1="6" y1="12" x2="10" y2="12"/><line x1="8" y1="10" x2="8" y2="14"/><line x1="15" y1="13" x2="15.01" y2="13"/><line x1="18" y1="11" x2="18.01" y2="11"/><rect x="2" y="6" width="20" height="12" rx="2"/></svg> Игры' : LS.icon('zap',14) + ' Физика'}</div>
|
||||
<div class="sim-title">${s.title}</div>
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
// Pause all animation-loop sims (non-destructive). Called when switching
|
||||
// between sims so a previously opened sim doesn't keep rendering offscreen.
|
||||
function _pauseAllSims() {
|
||||
if (window.LabRegistry) window.LabRegistry.stopActive();
|
||||
if (pSim) pSim.pause();
|
||||
if (cSim) cSim.pause();
|
||||
if (gasSim) gasSim.stop();
|
||||
@@ -105,6 +106,16 @@
|
||||
// load theory for this sim
|
||||
loadTheory(id.includes(':') ? id.split(':')[0] : id);
|
||||
|
||||
// Контент-движок: мигрированные симуляции открываются через реестр.
|
||||
if (window.LabRegistry && window.LabRegistry.has(id)) {
|
||||
const _m = window.LabRegistry.get(id);
|
||||
const _arg = id.includes(':') ? id.split(':')[1] : undefined;
|
||||
window.LabRegistry.setActive(_m);
|
||||
try { _m.open({ id: id, arg: _arg }); } catch (e) { console.error('[LabRegistry] open failed:', id, e); }
|
||||
if (window.lucide) lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
if (id === 'graph') _openGraph();
|
||||
if (id === 'projectile') _openProjectile();
|
||||
if (id === 'collision') _openCollision();
|
||||
@@ -210,6 +221,7 @@
|
||||
}
|
||||
|
||||
function closeSim() {
|
||||
if (window.LabRegistry) window.LabRegistry.destroyActive();
|
||||
if (pSim) pSim.pause();
|
||||
if (cSim) cSim.pause();
|
||||
if (mSim && mSim.particleOn) mSim.toggleParticle();
|
||||
|
||||
Reference in New Issue
Block a user