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
+101
View File
@@ -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
};
})();
+16 -2
View File
@@ -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>
+12
View File
@@ -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();