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`;
+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();
+61
View File
@@ -0,0 +1,61 @@
# Feature Context: Контент-движок лаборатории
## Current State
- Лаборатория работает на захардкоженной регистрации (см. PLAN.md Summary).
- Ветка `feature/lab-content-engine` создана от `master`.
## Architecture map (как было ДО рефактора)
- `frontend/lab.html` — sim-тела `<div id="sim-xxx">` (inline HTML, ~3000 строк) + 58 `<script>` тегов (4800-4861) + three.js.
- `frontend/js/labs/lab-glue.js`:
- `_catFilter`, `_disabledSimIds`, `_simModuleDisabled` (вкл/выкл из админки)
- `filterSims()`, `renderSims()` (карточки каталога)
- preview-хелперы `_grid/_axes/_svg` + ~60 констант `P_*`
- массив `SIMS` (821-866), `window.SIMS`/`window.LAB_SIMS`
- `frontend/js/labs/lab-init.js`:
- объявления переменных симуляций (gSim, pSim, …)
- `ALL_SIM_BODIES` / `ALL_CTRL_BARS` (33-48)
- `_pauseAllSims()` (54-91), `openSim(id)` if-цепочка (93-160), `closeSim()` (212-258)
- `_simShow()`, `_addTouchSupport()` (touch-bridge + ResizeObserver)
- объект `THEORY` + `loadTheory()` + `_theoryToggle()`
- функции `_openXxx()` (603-756) — единый шаблон: `_simShow('sim-xxx')` + ленивое `new XxxSim(...)` + показ `ctrl-xxx`
- `frontend/js/admin/sections/sims.js` — админ-секция (пока только вкл/выкл, `_disabledSimIds`).
## Загрузочный порядок (КРИТИЧНО)
В lab.html: движки `_fx_*`, `_phys_visuals`, `_graph_panel`, `_chem_visuals` грузятся ПЕРЕД симуляциями.
`lab-init.js` (4826) грузится ПЕРЕД `lab-glue.js` (4827). `renderSims()` вызывается в конце lab-glue.
Некоторые sim-файлы (graph.js) грузятся РАНЬШЕ lab-glue.js → preview `P_*` ещё не определены на момент исполнения их тел.
=> В манифестах `preview` поддерживает функцию (ленивое вычисление в renderSims), не только строку.
## Контракт LabRegistry (Фаза 0)
```
LabRegistry.register(manifest) // manifest.id уникален; повторная регистрация перезаписывает
LabRegistry.get(id) // по base-id (без ':arg')
LabRegistry.has(id)
LabRegistry.all() // в порядке регистрации
LabRegistry.setActive(sim) / stopActive() / destroyActive() // менеджер жизненного цикла
```
manifest: `{ id, cat, title, desc, preview(string|fn), theory?, bodyId?, mount?(host), open(ctx), stop?(), destroy?(), subject?, grade?, topics? }`
## Адаптер (Фаза 0): реестр в приоритете, иначе legacy
- `renderSims()` — порядок берём из исходного `SIMS`; для id, который есть в реестре, используем манифест (resolve preview), иначе legacy-запись; в конце добавляем registry-only записи, которых нет в SIMS.
- `openSim(id)``base = id.split(':')[0]`; если `LabRegistry.has(base)``stopActive()`; `get(base).open({arg})`; `setActive`; иначе старый if-путь.
- `loadTheory(id)` — если `get(base).theory` есть → рендерим из него; иначе `THEORY[base]`.
- `closeSim()`/`_pauseAllSims()` — дополнительно `LabRegistry.stopActive()` / `destroyActive()`.
## Temporary Workarounds
- (пока нет)
## Cross-Phase Dependencies
- Фаза 1 опирается на ядро реестра из Фазы 0.
- Фаза 3 (ленивая загрузка) опирается на манифесты с зависимостями движков (Фаза 1/2).
- Фаза 4 (БД) мёржит код-манифесты Фазы 1 с оверрайдами.
- Фаза 5 использует поля subject/grade/topics из манифестов.
## Deep-links (сохранить!)
`openSim('stereo:figure')`, `?stereofig=`, обратная совместимость `magnetic/coulomb→emfield`, `thinlens/mirrors/refraction→opticsbench`.
## Проектные правила (НЕ нарушать)
- Иконки: только inline SVG `.ic`, НЕ эмоджи.
- Поиск по коду: ast-index, НЕ Grep tool.
- БД: встроенный `node:sqlite` DatabaseSync, НЕ better-sqlite3.
- Git: коммитить только изменённые файлы.
+53
View File
@@ -0,0 +1,53 @@
# Feature: Контент-движок лаборатории (симуляции как данные)
**Branch:** `feature/lab-content-engine`
**Base branch:** `master`
**Created:** 2026-05-30
**Status:** 🟡 In Progress
**Strategy:** Big Bang
**Mode:** Automated
**Execution:** Direct
## Summary
Превратить захардкоженную в 6 местах регистрацию ~49 симуляций лаборатории в единый
декларативный манифест + реестр (`LabRegistry`). Каждая симуляция сама себя регистрирует
объектом `{id, cat, title, desc, preview, theory, bodyId/mount, open, stop, destroy, subject, grade, topics}`.
Ядро (renderSims/openSim/closeSim/loadTheory) работает с реестром, а не с массивами и
if-цепочками. Далее — ленивая загрузка кода, БД-бэкенд с админкой и курикулумная привязка.
## Build & Test Commands
- **Build:** — (фронт без сборки, статика через Express)
- **Test:** `cd backend && npm test` (актуально для Фаз 4-5; Фазы 0-3 — статическая проверка + ревью по диффу)
- **Lint:** `cd backend && npm run lint:routes` (актуально для Фаз 4-5)
## Phases
- [ ] Phase 0: Ядро реестра + адаптер + 3 пилота [domain: frontend] → [subplan](./phase-0-registry-core.md)
- [ ] Phase 1: Миграция всех симуляций на манифесты [domain: frontend] → [subplan](./phase-1-migrate-all.md)
- [ ] Phase 2: Тела симуляций как шаблоны + ленивый mount [domain: frontend] → [subplan](./phase-2-lazy-mount.md)
- [ ] Phase 3: Ленивая загрузка кода симуляций [domain: frontend] → [subplan](./phase-3-lazy-load.md)
- [ ] Phase 4: Реестр в БД + API + админка [domain: fullstack] → [subplan](./phase-4-db-admin.md)
- [ ] Phase 5: Курикулумная привязка [domain: fullstack] → [subplan](./phase-5-curriculum.md)
## Phase Progress Log
| Phase | Domain | Status | Review | Build | Committed |
|-------|--------|--------|--------|-------|-----------|
| Phase 0: Ядро реестра | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 1: Миграция всех | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 2: Ленивый mount | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 3: Ленивая загрузка | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 4: БД + админка | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 5: Курикулум | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
## Final Review
- [ ] Comprehensive code review
- [ ] Full build passes
- [ ] Full test suite passes
- [ ] Merged to `master`
## Notes (Big Bang temporary breakage map)
- Фаза 1 может временно ломать каталог/открытие симуляций пока миграция не завершена — устраняется внутри Фазы 1.
- Фаза 2 временно меняет структуру lab.html (вынос тел) — устраняется внутри Фазы 2.
- Полная работоспособность лаборатории гарантируется после ФИНАЛЬНОЙ фазы.
@@ -0,0 +1,46 @@
# Phase 0: Ядро реестра + адаптер + 3 пилота
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
## Objective
Создать `LabRegistry` (реестр + менеджер активной симуляции). Подключить адаптер: ядро
лаборатории сначала смотрит в реестр, иначе — старый путь. Мигрировать 3 пилота
(graph, quadratic, pendulum) и доказать паритет. Полностью обратимо.
## Tasks
- [ ] Создать `frontend/js/labs/_registry.js``window.LabRegistry` (register/get/has/all + setActive/stopActive/destroyActive). Без эмоджи.
- [ ] Подключить `_registry.js` в lab.html ПЕРВЫМ среди labs-скриптов (до graph.js).
- [ ] Адаптер `renderSims()` (lab-glue.js): порядок из SIMS, registry-override + resolve preview (string|fn), append registry-only.
- [ ] Адаптер `openSim()` (lab-init.js): base-id, registry-first → stopActive/open/setActive; deep-link `:arg` сохранить.
- [ ] Адаптер `loadTheory()` (lab-init.js): registry.theory в приоритете, иначе THEORY[base].
- [ ] Адаптер `closeSim()`/`_pauseAllSims()`: добавить `LabRegistry.stopActive()`/`destroyActive()`.
- [ ] Зарегистрировать 3 пилота в конце lab-init.js (после _openXxx): graph, quadratic, pendulum — с preview-fn, theory-объектом, open=_openXxx, stop/destroy.
- [ ] Удалить graph/quadratic/pendulum из legacy `THEORY` и `SIMS` (проверка, что адаптер их подхватывает из реестра).
## Files to Modify/Create
- `frontend/js/labs/_registry.js` — новый: ядро реестра.
- `frontend/lab.html` — добавить `<script src="/js/labs/_registry.js">` первым (в обоих местах, если дублируется список).
- `frontend/js/labs/lab-glue.js` — renderSims адаптер; убрать 3 пилота из SIMS.
- `frontend/js/labs/lab-init.js` — openSim/loadTheory/closeSim/_pauseAllSims адаптеры; регистрация 3 пилотов; убрать 3 пилота из THEORY.
## Acceptance Criteria
- Каталог отображает все симуляции в прежнем порядке; 3 пилота открываются и работают идентично.
- Остальные 46 симуляций открываются по-старому (legacy путь не сломан).
- Deep-links и обратная совместимость id работают.
- Нет дублей карточек (пилот не показан дважды).
- Нет эмоджи; иконки `.ic`.
## Notes
- Порядок загрузки: см. CONTEXT.md. preview как функция спасает от undefined P_*.
- `_disabledSimIds` фильтрация должна продолжать работать для registry-записей.
## Review Checklist
- [ ] Адаптер не ломает legacy симуляции
- [ ] Паритет 3 пилотов (open/stop/close/theory/preview)
- [ ] Соблюдены конвенции проекта (no emoji, .ic)
- [ ] Нет дублирования карточек
## Handoff to Next Phase
<!-- заполнить после фазы -->
@@ -0,0 +1,40 @@
# Phase 1: Миграция всех симуляций на манифесты
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
## Objective
Перевести все ~49 симуляций на сам/регистрацию через `LabRegistry`. Перенести данные
(catalogue meta, preview, theory) и поведение (open/stop/destroy) в манифесты. Удалить
legacy-структуры. Сохранить глобальные имена через shim.
## Tasks
- [ ] Для каждой симуляции зарегистрировать манифест (метаданные из SIMS, preview из P_*, theory из THEORY, open/stop/destroy из _openXxx + _pauseAllSims/closeSim веток).
- [ ] Удалить массив `SIMS` (lab-glue.js) и объект `THEORY` (lab-init.js).
- [ ] Удалить if-цепочку `openSim`, `_pauseAllSims`, switch в `closeSim`, `ALL_SIM_BODIES`/`ALL_CTRL_BARS`.
- [ ] lab-init.js усохнуть до generic-логики (openSim/closeSim через реестр).
- [ ] Shim глобальных имён (gSim, pSim, …) — их дёргают deep-link/поиск/инлайн-обработчики.
- [ ] Сохранить обратную совместимость id (magnetic/coulomb→emfield, thinlens/mirrors/refraction→opticsbench, stereo:fig, hydrostatics:arg, molphys:arg, chemistry:arg, dynamics:arg, emfield:mode, opticsbench:mode).
## Files to Modify/Create
- Все `frontend/js/labs/*.js` симуляции — добавить `LabRegistry.register(...)`.
- `frontend/js/labs/lab-glue.js`, `frontend/js/labs/lab-init.js` — удалить legacy.
## Acceptance Criteria
- Все симуляции открываются/работают как раньше (паритет).
- Удалены все 6 точек дублирования из CONTEXT.md.
- Deep-links и алиасы работают.
## Notes
- Мигрировать пачками (по категориям) с проверкой паритета после каждой пачки (Big Bang допускает временную поломку между пачками).
- Превью с зависимостями (random в P_ELECTROLYSIS) перенести как есть.
## Review Checklist
- [ ] Ни одна симуляция не потеряна
- [ ] Глобальные shim'ы на месте
- [ ] Алиасы/deep-links работают
- [ ] Legacy полностью удалён
## Handoff to Next Phase
<!-- заполнить после фазы -->
@@ -0,0 +1,37 @@
# Phase 2: Тела симуляций как шаблоны + ленивый mount
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
## Objective
Вынести inline-HTML тел симуляций (`<div id="sim-xxx">`, ~3000 строк) из lab.html в
манифесты: `mount(host)` создаёт DOM лениво при первом открытии. lab.html худеет.
## Tasks
- [ ] Добавить в манифест поле `mount(host)` (или `bodyHtml`) — строит/возвращает тело симуляции.
- [ ] Ядро: при первом open — если тело не смонтировано, вызвать mount() в контейнер `#lab-sim`.
- [ ] Перенести разметку каждого `sim-xxx` тела + его `ctrl-xxx` бара из lab.html в соответствующий модуль.
- [ ] Удалить вынесенные блоки из lab.html.
- [ ] Сохранить id элементов (canvas ids, ctrl ids) — на них завязаны Sim-классы.
## Files to Modify/Create
- Все `frontend/js/labs/*.js` — добавить mount/bodyHtml.
- `frontend/lab.html` — удалить inline тела.
## Acceptance Criteria
- Все симуляции монтируются и работают.
- lab.html значительно меньше (~3000 строк вынесено).
- Повторное открытие не дублирует DOM.
## Notes
- Theory-panel и общий sim-topbar остаются в lab.html.
- KaTeX/lucide ре-инициализация после mount при необходимости.
## Review Checklist
- [ ] Нет дублей DOM при повторном open
- [ ] id элементов сохранены
- [ ] Все тела перенесены
## Handoff to Next Phase
<!-- заполнить после фазы -->
@@ -0,0 +1,39 @@
# Phase 3: Ленивая загрузка кода симуляций
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
## Objective
Грузить тяжёлый код симуляции и движков по клику, а не 58 скриптов + three.js сразу.
Лёгкий манифест каталога загружается сразу.
## Tasks
- [ ] Вынести лёгкие метаданные каталога (id/cat/title/desc/preview) в отдельный `sims.manifest.js`, грузимый сразу.
- [ ] Тяжёлый код симуляции (Sim-класс + open/mount) грузить динамически при openSim (инъекция script или import()).
- [ ] Объявить зависимости движков в манифесте (`deps: ['_fx_core','_phys_visuals',...]`); загрузчик резолвит и грузит до кода симуляции, кешируя загруженное.
- [ ] three.js грузить только для 3D-симуляций (stereo).
- [ ] Лоадер с дедупликацией (один и тот же файл не грузится дважды).
## Files to Modify/Create
- `frontend/js/labs/_loader.js` — новый: динамический загрузчик + резолв зависимостей.
- `frontend/js/labs/sims.manifest.js` — новый: лёгкий каталог.
- `frontend/lab.html` — убрать массовые `<script>`, оставить ядро (api, registry, loader, manifest).
## Acceptance Criteria
- Первый рендер каталога без загрузки кода симуляций.
- Открытие симуляции догружает её код+движки и работает.
- three.js грузится только для 3D.
- Заметное падение объёма стартовой загрузки lab.html.
## Notes
- Учесть defer-скрипты (solutions/organic/periodic/qualanalysis).
- Кеш загруженных модулей в Map.
## Review Checklist
- [ ] Нет двойной загрузки
- [ ] Зависимости движков соблюдены
- [ ] Старт лаборатории легче
## Handoff to Next Phase
<!-- заполнить после фазы -->
@@ -0,0 +1,42 @@
# Phase 4: Реестр в БД + API + админка
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
## Objective
Хранить оверрайды каталога в БД, мёржить с код-манифестами, управлять каталогом из
админки (вкл/выкл, порядок, теги, рекомендуемые).
## Tasks
- [ ] Миграция БД: таблица `lab_sims` (id PK, title, cat, subject, grade, desc, enabled, sort, topic_id, textbook_ref, flags JSON, updated_at). Через `node:sqlite` DatabaseSync.
- [ ] Backend route `GET /api/lab/sims` — отдаёт мёрж: код-манифест (база) + БД-оверрайды.
- [ ] Backend admin routes: upsert/enable/disable/reorder/tag (под RBAC admin).
- [ ] Frontend: каталог берёт enabled/order/теги из `/api/lab/sims` (с фолбэком на код-манифест офлайн).
- [ ] Расширить `frontend/js/admin/sections/sims.js`: список, вкл/выкл, drag-reorder, теги, «рекомендуемые».
- [ ] Сохранить совместимость с `_disabledSimIds`.
## Files to Modify/Create
- `backend/src/db/migrations/0XX_lab_sims.sql` — новая миграция.
- `backend/src/routes/lab.js` (или расширить существующий) — API.
- `backend/src/server.js` — подключить роут (если новый файл).
- `frontend/js/admin/sections/sims.js` — расширить админку.
- `frontend/js/labs/_loader.js`/manifest — учитывать БД-данные.
## Acceptance Criteria
- `npm test` зелёный; `npm run lint:routes` без ошибок (auth на роутах).
- Админ может вкл/выкл/переупорядочить/тегировать симуляцию, изменения видны в каталоге.
- Офлайн/без БД — фолбэк на код-манифест.
## Notes
- RBAC: мутации только admin. Чтение каталога — для роли с доступом к лаборатории.
- Не дублировать данные: код-манифест = источник базовых полей; БД = оверрайды/доп.
## Review Checklist
- [ ] Миграция идемпотентна
- [ ] Роуты под auth (lint:routes)
- [ ] Мёрж корректен, фолбэк работает
- [ ] Тесты проходят
## Handoff to Next Phase
<!-- заполнить после фазы -->
@@ -0,0 +1,43 @@
# Phase 5: Курикулумная привязка
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
## Objective
Связать симуляции с учебной программой: § учебника, узел knowledge-map, тема банка
вопросов. Двусторонняя навигация.
## Tasks
- [ ] Схема связей: использовать поля манифеста (subject/grade/topics) + таблицу связей `lab_sim_links` (sim_id, kind[textbook|topic|kmap|question], ref_id).
- [ ] API: `GET /api/lab/sims/:id/related` — связанные § / темы / задачи.
- [ ] Frontend учебник/теория: кнопка «Открыть в лаборатории» в § (deep-link openSim).
- [ ] Frontend knowledge-map: узел темы → ссылка на симуляцию.
- [ ] Страница симуляции: блок «Связанная теория и задачи».
- [ ] Админка: редактирование связей симуляции.
## Files to Modify/Create
- `backend/src/db/migrations/0XX_lab_sim_links.sql`
- `backend/src/routes/lab.js` — related endpoint.
- `frontend/textbooks.html` / theory / учебник-рендер — кнопки в §.
- `frontend/knowledge-map.html` — ссылки с узлов.
- `frontend/lab.html` — блок связей на странице sim.
- `frontend/js/admin/sections/sims.js` — редактор связей.
## Acceptance Criteria
- Из § учебника можно открыть нужную симуляцию.
- На странице симуляции видны связанные теория/задачи.
- Узлы knowledge-map ведут на симуляции.
- `npm test` зелёный, роуты под auth.
## Notes
- Привязки опциональны: отсутствие связей не ломает страницы.
- Переиспользовать существующие topic_id банка вопросов и структуру учебников.
## Review Checklist
- [ ] Навигация в обе стороны работает
- [ ] Пустые связи не ломают UI
- [ ] Роуты под auth, тесты проходят
## Handoff to Next Phase
<!-- финальная фаза -->