# Feature Context: Конструктор симуляций (SimForge)

## Current State
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) ЗАВЕРШЁН — P5 «Прямое манипулирование + история» РЕАЛИЗОВАН**
  (рабочее дерево, не закоммичено; ветка `feature/sim-builder`). Файл: ТОЛЬКО `frontend/js/sim-builder.js`.
  `_sim_engine.js` НЕ тронут — `_toWorld`/`_toPx`/`_niceStep` уже публичны на инстансе движка, хука не
  потребовалось (в IMPROVEMENTS.md P5 предполагались правки движка — не понадобились).
  - **Прямое манипулирование** (`bindPreviewDrag` переписан): «ручки» через `handlesOf(obj)` для ВСЕХ
    позиционируемых типов — point/circle/label/readout/rect (одна ручка x,y), segment/vector (origin x1,y1 +
    end x2,y2 ИЛИ origin+dx/dy), polyline/path (по ручке на числовую вершину `points`). Хит-тест `pickHandle`
    (14px, через `_toPx`); режимы pointerdown: `handle`/`place` (единств. ручка — клик ставит)/`body`
    (несколько ручек — относительный сдвиг)/`none`. Поля-выражения `blocked` (не затираются). `refreshObjFields`
    расширен на x1/y1/x2/y2/dx/dy/points.
  - **Snap-к-сетке**: тумблер в тулбаре (`_snap`, `toggleSnap`, иконка `ICON.grid`, активность — инлайн
    `SNAP_ACTIVE_CSS`); округление к `_niceStep(34)` (минорный шаг сетки; fallback 0.5). Выравнивание к чужим
    координатам не делалось (бонус; snap достаточно — отмечено как частичное).
  - **Undo/Redo**: стек `JSON.stringify(this.st)` (глубина 50), `pushHistory` (до мутации, без дублей, сброс
    redo), `snapField` (один снапшот на сессию правки поля через focusin/`_fieldSnapTaken`). Структурные
    операции — снапшот сразу; drag — один на сессию (no-op откатывается). Кнопки undo/redo (SVG `.ic`) +
    Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y (`bindKeyboardShortcuts`, игнорит фокус в полях). `loadFromSim` обнуляет
    историю; `_restoreSnapshot` → renderPanels + scheduleRemount.
  - Верификация: `node --check` OK; эмодзи/eval — 0; vm-смоук 38/38 PASS (drag всех типов + body-move; snap;
    защита выражений; undo/redo drag+add; лимит стека; round-trip идемпотентен). buildSpec/валидация не тронуты.
    git status: тронут только sim-builder.js (`_sim_engine.js` в статусе — чужой коммит параллельной сессии
    «goal/game», мной НЕ редактировался).
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P4 «UI билдера + контролы стиля» РЕАЛИЗОВАН** (рабочее дерево, не
  закоммичено; ветка `feature/sim-builder`). Файлы: только `frontend/sim-builder.html` + `frontend/js/sim-builder.js`.
  `_sim_engine.js`/`js/api.js`/lab.* НЕ тронуты — билдер лишь генерит спеку, которую движок (P2/P3) уже умеет рисовать.
  - **Контролы стиля объекта** (блок «Стиль», `STYLE_FOR[type]`): слайдер непрозр.(`opacity` 0..1),
    select `lineStyle`(solid/dashed/dotted), `pointStyle`(только point), тумблер `glow`, тумблер градиент-
    заливки(circle/rect → `gradient:[c0,c1]`). Цвета — `colorCtl`: нативный `<input type=color>` + текст
    (источник истины, держит rgba/named) + очистка для fill/trailColor. Синхрон — `wireColorControls`,
    `toHexColor`→`#rrggbb`. Per-объект уже были width/color в OBJ_FIELDS — переведены на color-пикеры.
  - **Редактор кривых plot** (`plotEditor`/`curveEditor`): UI-модель `{var,range_a/b,samples,trace,legend,
    plotFill,plotMarker,curves:[{expr,color,label,width,lineStyle,opacity,fill,fillColor,marker}]}`. Список
    кривых (add/del, минимум 1), на кривую все P3-поля + fx-палитра, plot-уровневые fill/marker/легенда.
    `loadPlot` (spec→UI: curves[]→exprs[]→legacy expr; легаси plot-level width/lineStyle/opacity → в кривую),
    `normalizePlotForSpec`+`stripCurve` (UI→spec). Одиночная простая кривая → легаси `{expr,color}`, иначе
    `curves:[...]`. `legend:false` эмитится только при выкл.
  - **Список объектов/графиков**: z-order вверх/вниз (порядок массива = порядок отрисовки), видимость
    (`hidden:true` — чисто билдерский флаг, фильтруется в `buildSpec`, движок не знает), дублировать
    (deep-clone+новый `_uid`, `id+'_copy'`), удалить. Иконки — новые inline SVG `.ic` (up/down/copy/eye/eyeOff/clearX).
  - **Минимизация спеки + стабильный round-trip**: `stripObj.isDefaultStyle` выбрасывает дефолты
    (glow:false, lineStyle:'solid', pointStyle:'filled', opacity:1, trail/closed:false) и `hidden`. Save→load→
    save идемпотентен (loadFromSim восстанавливает дефолты из контролов).
  - **Дизайн/мобайл**: новые CSS-классы в ls.css-стиле (`.sbu-obj-style`/`.sbu-style-row`/`.sbu-color-*`/
    `.sbu-range`/`.sbu-curve(s)`/`.is-hidden`/`.sbu-grad-row`); заголовок объекта flex-wrap + 26px-кнопки;
    медиа ≤920px (раскладка) + новый ≤560px (поля/стили в один столбец). Пустые состояния дополнены.
  - **Безопасность**: выражения только через `SimExpr.compile`; цвета попадают лишь в спеку (canvas-стоки
    движка), DOM-style с польз.цветом не используется; eval/new Function — нет.
  - Верификация: `node --check` sim-builder.js + извлечённого инлайна html — OK; эмодзи нет (скан кодпойнтов
    обоих файлов — 0); eval/new Function — 0; headless vm-смоук (DOM/SimExpr-стаб) 27+12 PASS: стили объекта в
    спеке, round-trip объектов идемпотентен ×2, plot с 2 кривыми (label/marker/lineStyle/opacity/fill-цвет/
    range/samples) + round-trip ×2, легаси-одиночная кривая → легаси-форма + round-trip, hidden исключает из
    спеки, z-order=порядок массива, дефолты-стрип; +шаблонные легаси-plot save→load→save стабильны (2 PASS).
    Temp удалены. git status: тронуты только sim-builder.html и sim-builder.js.
  - **Следующее (P5):** прямое манипулирование на сцене (drag всех типов + snap-к-сетке + выравнивание) и
    undo/redo. Потребуются правки `_sim_engine.js` (хит-тесты/ручки) + `sim-builder.js` (стек снапшотов `this.st`).
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P3 «Графики/диаграммы» РЕАЛИЗОВАН** (рабочее дерево, не
  закоммичено; ветка `feature/sim-builder`). Файл: только `frontend/js/labs/_sim_engine.js`. Расширен
  `_drawPlot` + ветка `type==='plot'` в `_prepareObjects`. Оси/сетка/подписи уже из P1 — не дублировались.
  - **Несколько кривых**: нормализуются в `prep.curves[]`, приоритет источника `curves:[{...}]` →
    `exprs:['sin(x)','x^2']` → одиночный `expr` (легаси, обратная совместимость сохранена). Каждой кривой
    свой цвет (явный `color` или `DEFAULT_PALETTE[i%8]`). `prep.exprFn` = первой кривой (для trace-режима).
  - **Поля кривой** (`curves[i]`): `expr`, `color`, `label`(→легенда), `width`, `lineStyle`, `opacity`,
    `fill`(true→полупрозр. цвет / строка), `marker`(none|dot|ring). Не заданные наследуют plot-уровень.
    **Plot-уровневые `fill`/`marker`** — дефолт для всех кривых.
  - **Заливка под кривой** `_fillUnderCurve` (между кривой и y=0, посегментно — разрывы у не-finite не
    сливаются; baseY клиппится к canvas). **Маркеры** `_drawCurveMarkers` (переиспользует `_drawPoint`,
    прорежены ~28px). **Легенда** `_drawLegend` на canvas (тёмная плашка + свотч + светлый текст, верх-право,
    авто при `label`, `legend:false` отключает). Новые модульные хелперы `_markerStyle`/`_fillAlpha`.
  - **Безопасность**: цвета только в canvas-стоки (strokeStyle/fillStyle/fillText фикс-цвет легенды);
    DOM-style с пользовательским цветом не используется; eval нет. Каждая кривая в своём save/restore,
    легенда на внешнем уровне.
  - Верификация: `node --check` OK; headless vm-смоук (canvas-стаб со счётчиком save/restore + РЕАЛЬНЫЕ
    `_sim_expr`+`_sim_engine`) 10/10: легаси/exprs[]/curves+fill+marker+legend/наследование/не-finite
    (1/x,tan)/legend:false/trace±range/fillUnder+markers с null/регресс point-vector-circle-rect — все PASS;
    ctx сбалансирован (depth→0, нет underflow). Эмодзи нет (только пре-существующие → в комментариях); eval=0.
    Temp-смоук удалён. git status: тронут только `_sim_engine.js`.
  - **Следующее (P4):** UI билдера + контролы стиля (`sim-builder.html`/`sim-builder.js`) — дать новым полям
    plot контролы: список кривых (add/del, expr+color+label+width+lineStyle+opacity+fill+marker), plot-fill/
    marker, тумблер легенды; плюс per-объект color/opacity/width/dash, z-order, дублирование, мобайл.
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P2 «Качество графики объектов» РЕАЛИЗОВАН** (рабочее дерево, не
  закоммичено; ветка `feature/sim-builder`). Файл: только `frontend/js/labs/_sim_engine.js`. Один движок →
  эффект и в билдере, и в /lab, и на доске.
  - **Чтение стилей** расширено в `_prepareObjects`; применение — через два хелпера: `_applyStroke(ctx,o)`
    (ставит globalAlpha=opacity, lineWidth=width, lineJoin/Cap='round', setLineDash по lineStyle, glow→shadow)
    и `_fillStyleFor(ctx,o,x0,y0,x1,y1)` (линейный градиент `gradient:[c0,c1]` по bbox ИЛИ сплошной fillColor;
    всё — canvas-стоки, мусорный цвет игнорится). Каждая ветка `_drawObject` в своём `save/restore`.
  - **Новые поля стиля спеки** (контракт для P4-контролов): `opacity` 0..1, `lineStyle` solid|dashed|dotted,
    `fill`/`gradient:[c0,c1]`, `glow:true`/`shadow`, `pointStyle` filled|hollow|cross|ring, `trailFade`/
    `trailWidth`/`trailLen`. Полный список с дефолтами — в IMPROVEMENTS.md (Handoff P2→P3/P4).
  - **Стрелки векторов** (`_arrowHead`/`_arrowHeadLen`): заполненный барбед-треугольник (вырез у основания),
    длина `max(9,width*3.2)`px, тело линии укорочено на длину головы. **Точки** `_drawPoint` — 4 стиля
    (filled-деф. = кружок + тонкая белая обводка). **Трассы** `_drawTrail(ctx,pts,o)` — посегментное
    затухание (alpha 0.08→0.68 от хвоста к голове) либо одна линия без fade.
  - **Палитра по умолчанию** `DEFAULT_PALETTE` (8 холодно-ярких тонов, циклически по индексу) вместо единого
    `#06D6E0`; явный `color`/`fill` всегда сохраняется. `_drawPlot` теперь зовёт `_applyStroke` (dash/opacity/
    glow на кривых).
  - Верификация: `node --check` OK; headless-смоук (vm + DOM/canvas-стаб со счётчиками вызовов + РЕАЛЬНЫЕ
    `_sim_expr.js`+`_sim_engine.js`) 23/23: рендер 18-объектной спеки (все типы + все новые поля) ×4 кадра без
    throw; ctx не протекает (save/restore-баланс depth=0, globalAlpha→1, shadowBlur→0, lineDash→[] после кадра);
    setLineDash/createLinearGradient/fill/stroke/arc вызваны (dashed/dotted/gradient/fills); arrowHeadLen
    масштаб; все поля прочитаны; палитра применена + явный color сохранён; трасса накоплена; destroy чист.
    Эмодзи нет (скан кодпойнтов: только пре-существующие →/─/═/∞ в комментариях); eval=0; new Function — только
    в комментарии стр.15. git status: тронут только `_sim_engine.js`.
  - **Следующее (P3):** РЕАЛИЗОВАНО (см. блок P3 выше) — несколько кривых, заливка, маркеры, легенда.
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P1 «Рабочее поле» РЕАЛИЗОВАН** (рабочее дерево, не закоммичено;
  ветка `feature/sim-builder`, общая с параллельной сессией materials/quota). Файл: только
  `frontend/js/labs/_sim_engine.js` (sim-builder.html НЕ потребовался). Один движок → эффект и в билдере, и в /lab, и на доске.
  - **Fix смещения вправо:** `_build` больше не делит строку flex с фикс-панелью 260px. Теперь
    `root`(relative) → `stage`(absolute inset:0, canvas+labels на всю площадь) + плавающая `panel`
    (absolute left/top:10px, z-index:5, pointer-events:auto, сворачивается `_togglePanel`, есть только при params)
    + бар кнопок вида (right/bottom:10px). Сцена центрирована во всю ширину хоста; пустая спека не съезжает.
  - **Сетка:** minor(~34px)/major(×5), адаптивна к zoom (`_niceStep(targetPx)` завязан на `_scale`, шаги
    1/2/5·10^n), рисуется через всю видимую область (`_visibleWorld`), линии на .5px (резкость, без «ступенек»).
  - **Оси:** X/Y (прижимаются к краю canvas, если 0 вне видимой области) + числовые подписи делений
    (светлый текст + тень на тёмном фоне, `_axisNum`/`_stepDecimals`) + маркер origin (0,0).
  - **Zoom/Pan:** колесо → `_zoomAt(lx,ly,factor)` (мир-точка под курсором инвариантна, зум-кламп 0.1..50×);
    pan = drag пустого места (`_setupZoomPan`), приоритет ручек/тел через общий `_pickHandleAt` (pan стартует,
    только если хит-тест вернул null). Кнопки вида: `inst.fitView()` / `inst.resetView()` (оба → центрированный
    viewport, SVG `.ic` в углу сцены). `_viewLocked` сохраняет вид при ресайзе. DPR-резкость сохранена.
  - **destroy** снимает wheel+pan-листенеры и ResizeObserver. Верификация: `node --check` OK; headless-смоук
    (DOM/canvas-стаб + реальные `_sim_expr.js`+`_sim_engine.js`) 40/40 (центрирование пустой спеки, zoom-инвариант
    курсора+кламп, pan-сдвиг `_off`, приоритет ручек над pan, drag-ручка пишет param, подписи-оверлей следуют
    zoom/pan, fit/reset вида, ресайз сохраняет вид, рендер всех 10 типов объектов без throw, destroy чист);
    эмодзи нет (только `→` в комментариях, как в существующем коде), eval/Function нет.
  - **Следующее (P2):** качество графики объектов (`_drawObject`/`_drawTrail`/`_arrowHead`/`_drawPlot`/
    `_prepareObjects` в `_sim_engine.js`).
- **ВСЕ ФАЗЫ (0–7) РЕАЛИЗОВАНЫ** (в рабочем дереве, не закоммичено — коммит за оркестратором).
  Фича «Конструктор симуляций» функционально полна: рантайм+физика, БД+API, билдер, каталог в /lab,
  раздача/клон/шаблоны/привязка, доска онлайн-урока с синхроном классу.
- **Фаза 7 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено). Custom-sim на доске онлайн-урока через
  существующий iframe-конвейер. **Аддитивные** правки трёх файлов; рабочее дерево по ним было ЧИСТЫМ
  до начала (classroom.html не имел чужих незакоммиченных правок параллельной сессии — проверено git status).
  - `backend/src/controllers/classroom/sim.js` (+21/-2): `simOpen` принимает `simId='custom:<dbid>'`,
    валидирует доступ (владелец ИЛИ published ИЛИ admin; иначе 404/403). Встроенный id — прежний regex
    `^[a-z0-9_-]{1,40}$`. `simState/simMode/simAnnotate/simClose` НЕ тронуты (state-объект уже произвольный).
  - `frontend/classroom.html` (+31/-4): `_crLoadCustomSims()` (кэш `LS.customSimsList`), `crOpenSimPicker`
    async с предзагрузкой, `_crRenderSimGrid` мёржит свои+published custom (бейдж «Моя», id `custom:<dbid>`,
    XSS-escape). Существующий `crPickSim` передаёт id как есть; `onSimOpen` грузит iframe
    `/lab?embed=1&sim=custom:<id>` (encodeURIComponent безопасен, lab декодирует param).
  - `frontend/js/labs/lab-glue.js` (+48/-1): `_bridgeCustomSimState(real)` — подключает custom-sim к
    тому же мосту `sim_state`/`apply_sim_state`, что и встроенные. getState=`{params,running}` /
    applyState=`setParam`+play/pause поверх SimEngine-инстанса (`real.instance()`). Регистрируется под
    ключом `_autoSim` (`custom:<dbid>`, т.к. apply у ученика берёт `_simStateRegistry[_autoSim]`),
    запускает `_startStateEmit`. Вызов в `_registerLazy.open()` после `real.open(ctx)` (только embed).
  - **Синхрон:** параметры слайдеров + play/pause — полный (demo-режим). Время `t` (фаза анимации)
    покадрово НЕ синхронится (by design; ученик крутит свой rAF при running). Аннотации/режим — через
    существующий конвейер без изменений (id-agnostic). Закрытие/смена: `frame.src='about:blank'` сносит
    весь документ iframe (SimEngine+rAF+слушатели) — утечек нет.
  - **Доступ:** двойная проверка — `simOpen` на сервере (постановка на доску) + `GET /custom-sims/:id`
    при загрузке спеки в iframe. Чужой draft → 403 на обоих. На доску только своё или published.
  - Верификация: `node --check` sim.js / lab-glue.js / извлечённого инлайна classroom.html — OK;
    эмодзи нет (UTF-8-скан текст-элементов: 0 в js, 11 в classroom.html — все ПРЕ-существующие
    ×/⇒/реакции, не в моих строках); eval/new Function — 0 call-sites; `npm test` 240/248 pass
    (8 fail = тот же baseline: 3 auth.test + 5 page-тестов без jsdom; обе custom-sims-сьюты зелёные).
    git status: тронуты только мои 3 файла (+плановые .md); js/api.js НЕ нужен (методы есть с Ф3).
- **Фаза 6 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Файлы:
  `backend/src/controllers/customSimController.js` (+share/clone/related/addLink/removeLink, импорт
  `pushNotif`), `backend/src/routes/customSims.js` (+POST `/:id/share`, POST `/:id/clone`, GET
  `/:id/related`, POST `/:id/links`, DELETE `/:id/links/:linkId`), `js/api.js` (+`customSimShare/
  Clone/Related/AddLink/DelLink`), `frontend/js/labs/lab-glue.js` (аддитивно в IIFE LabCustom:
  кнопки share/clone/publish-toggle на карточках + делегат + `shareToClass/clone/setStatus`, ICON-блок),
  `frontend/js/sim-builder.js` (тулбар: «Шаблон»/«Раздать»/publish-toggle; методы `setStatus/
  openShareModal/openTemplateModal`; данные `TEMPLATES`×4; ICON.template/unpublish),
  `backend/tests/custom-sims-share.test.js` (new, 15 it, все зелёные).
  - **РЕШЕНИЕ копия-vs-доступ (зафиксировано):** published custom-sim видна ВСЕМ в каталоге /lab
    (`list`/`get` отдают published любому; custom-sim НЕ гейтится allowlist'ом content_access 'sim' —
    тот гейтит только legacy `lab_sims`). Поэтому «раздать классу» = (1) авто-публикация
    (status→published), (2) ДОЛГОВЕЧНОЕ адресное уведомление ученикам класса через `pushNotif`
    (notifications-таблица + SSE) со ссылкой `/lab?sim=custom:<id>`. БЕЗ копии (в отличие от «Моих
    материалов», где оригинал приватный и копия обязательна) и БЕЗ записи content_access.
  - **Привязка к программе:** переиспользован `lab_sim_links` с `sim_id='custom:<id>'` (sim_id TEXT —
    отдельная таблица не нужна). Связями СВОЕЙ симуляции управляет владелец/admin (не только admin как
    у lab_sims). Backend + GET `/related` готовы; UI-редактор связей + чипы в каталоге — остаток (handoff).
  - **Клон:** копия spec вызвавшему как draft (title += ' (копия)', version=1). Источник: своя любая
    ИЛИ чужая published (чужой draft → 403).
  - Верификация: `node --check` всех 6 изм. файлов OK; эмодзи нет (скан — только `→`/`∑` в комментариях,
    как в существующем коде); eval/Function нет; `npm run lint:routes` 0 unprotected (baseline 0);
    `npm test` 216/224 pass (8 fail = тот же baseline: 3 auth.test + 5 page-тестов без jsdom — не моя
    фаза; обе custom-sims-сьюты зелёные). git status: только мои файлы; classroom.html/lab.html не тронуты.
- **Фаза 5 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Только
  **аддитивные** правки двух файлов параллельной сессии (без рефактора их кода): рабочее дерево
  по ним было ЧИСТЫМ до начала. classroom.html / backend / `_sim_deps.js` НЕ тронуты.
  - **`frontend/js/labs/lab-init.js`** (+7 строк): в начало `openSim(id)` добавлен хук
    `if (window.LabCustom && LabCustom.resolveId) id = LabCustom.resolveId(id) || id;` —
    переводит deep-link/клик `custom:<dbid>` в реестровый id `customsim_<dbid>` (LabRegistry.get/has
    обрезают часть после `:`, поэтому в реестре двоеточие недопустимо). Для встроенных id — no-op.
  - **`frontend/js/labs/lab-glue.js`**: (а) `renderSims()` merge +`&& !m._custom` (custom не в
    основной сетке) и вызов `LabCustom.renderSection(_catFilter)`; (б) init-блок (non-embed и embed)
    зовёт `LabCustom.init()`, отложенное открытие `?sim=custom:*` до загрузки списка; (в) новый
    IIFE **`window.LabCustom`** в конце файла.
  - **Поток**: `LS.customSimsList()` (мета без spec) → `_registerLazy` кладёт в LabRegistry
    манифест-заглушку `customsim_<dbid>` (`_custom:true`) с ленивым `open()`. Секция «Мои симуляции»
    `#custom-sim-section` (создаётся динамически в `#lab-home`, без правок lab.html/CSS) рендерит
    карточки из `_meta`. Открытие: `resolveId` → дисп. реестра → `open()` заглушки →
    `ensureSpec(dbid)` (`LS.customSimGet`, кэш+дедуп) → `spec.id=regId` → `registerSpecSim(spec)`
    (Ф0-адаптер, заменяет заглушку на месте) → `setActive(real)`+`real.open(ctx)` (монтирует SimEngine).
    **spec лениво** — на старте /lab не грузится. Движок (`_sim_*`) уже eager (Ф0), ленивый файл не нужен.
  - **Карточка**: preview-SVG + cat-бейдж + бейджи «Моя»(owner)/«Опубликована»(status)/«Черновик»
    + кнопки «Редактировать»→`/sim-builder?id=<dbid>` / «Удалить»→`LS.customSimDelete` (владельцу,
    `owner_id===user.id`). Делегированный клик по `#custom-sim-section`. Иконки — inline SVG `.ic`.
  - Верификация: `node --check` обоих изменённых файлов OK; эмодзи нет (скан кодпойнтов — только
    math/box-drawing глифы ∑/═/─/→, как в существующем коде); eval/Function нет; headless-смоук
    (vm + DOM/SimEngine/LS-стабы, РЕАЛЬНЫЕ `_registry.js`+`_sim_adapter.js`) 22/22: resolveId,
    регистрация ленивых манифестов+флаг `_custom`, секция/карточки, бейджи, owner-only edit/del,
    deep-link `data-open`, lazy spec→registerSpecSim→mount, reopen синхронно, delete, встроенные не сломаны.
    git status: изменены только lab-init.js/lab-glue.js (+ плановые .md); classroom.html/backend чисты.
- **Фаза 4 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Только
  новые файлы `frontend/sim-builder.html` + `frontend/js/sim-builder.js` + аддитивная правка
  `js/sidebar.js` (lab.html/lab-glue.js НЕ тронуты — зона параллельной сессии).
  - **Учительский редактор `/sim-builder`** (гейт teacher/admin через `LS.initPage()`): панели-
    аккордеоны (Мета+сцена / Параметры / Объекты / Графики / Физика) слева + живое превью
    (`SimEngine.mount`, перемонтаж с debounce 280мс) справа + тулбар (Тест/Сброс/Сохранить/
    Опубликовать). `window.SimBuilder.create({host,previewHost,panelHost,toolbarHost})`.
  - **Генерация спеки** `buildSpec()` → JSON v1 (specVersion:1, meta, viewport, time, params[],
    objects[]+merged plots, physics?). `_uid` — UI-метка, вырезается; plot материализуется
    (range_a/range_b → range[a,b]); числовые поля — число ИЛИ строка-выражение (движок ест оба).
  - **Выражения**: каждое поле проверяется `SimExpr.compile` → inline-ошибка у поля; палитра
    функций/констант/параметров/`id.x` через модалку. **Запрет имени param `e`** (и pi/t/w/h/...).
  - **Drag-on-preview**: кнопка-«прицел» у объекта → клик/перетаскивание по `inst.canvas` (px→мир
    через `inst._toWorld()`) пишет x/y (или конец segment/vector) в свойства. Только на паузе.
  - **Save/Load**: `customSimCreate`/`customSimUpdate` (?id= → update + replaceState), публикация
    `status:'published'`; `?id=<id>` → `customSimGet` → `loadFromSim` раскладывает по панелям.
  - **Клиентская валидация** зеркалит серверную (params≤50/objects≤200/walls≤20/springs≤50/
    expr≤500/restitution 0..1/JSON≤200КБ) с дружелюбной модалкой-списком ошибок ДО запроса.
  - **Сайдбар**: пункт `/sim-builder` «Конструктор симуляций» (teacher-only, icon pencil-ruler)
    в группе «Практика и игры» после «Лаборатория» — минимальная правка `js/sidebar.js`.
  - Верификация: `node --check` обоих новых .js + извлечённого инлайна html OK; эмодзи нет (скан
    кодпойнтов, включая no-entry sign — заменён на текст); eval/Function нет (вычисления — SimExpr);
    headless-смоук (vm + DOM/Blob-стаб) 23/23: buildSpec форма, merge plot+range, strip _uid,
    physics-блок, валидация valid/reserved-`e`/syntax-error, loadFromSim round-trip стабилен.
    lab.html/lab-glue.js/_sim_engine.js/_sim_expr.js НЕ тронуты (git status).
- **Фаза 3 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Только backend + клиент `js/api.js` (lab.html/lab-glue.js НЕ тронуты — зона параллельной сессии).
  - **Миграция 071** `backend/src/db/migrations/071_custom_sims.sql` — таблица `custom_sims` (применена к живой БД через `npm run migrate`, без ошибок).
  - **API `/api/custom-sims`** (роутер `backend/src/routes/customSims.js`, контроллер `backend/src/controllers/customSimController.js`, смонтировано в `server.js`): GET `/` (свои+published), GET `/:id` (own ИЛИ published), POST `/` (teacher/admin), PUT `/:id` (owner/admin), DELETE `/:id` (owner/admin). Read — router-level authMiddleware; мутации — inline requireRole + per-row ownership.
  - **`validateSpec(spec)`** в контроллере — серверная валидация БЕЗ исполнения: ≤200KB, specVersion=1, лимиты (params≤50/objects≤200/walls≤20/springs≤50/expr≤500/глубина≤8/points≤1000), whitelist типов объектов, physics (restitution 0..1, dt 1/2000..1/30, mass>0), санитизация текст-полей (escape &<>). Возврат `{ ok, error?, clean? }`.
  - **Клиент** `js/api.js`: `customSimsList/Get/Create/Update/Delete` → `req(...)`, добавлены в `window.LS`.
  - Верификация: `node --check` всех новых/изменённых .js OK; `npm run migrate` OK; `npm run lint:routes` чисто (0 unprotected, baseline 0); `backend/tests/custom-sims.test.js` 24/24 pass; общий suite 201/209 (8 fail = 3 baseline auth.test.js + 5 page-тестов без devDep `jsdom` — окружение, не моя фаза). Эмодзи нет; БД через node:sqlite.
- **Фаза 2 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Только `_sim_engine.js` + `_sim_demo.js` (lab.html/lab-glue.js НЕ тронуты — зона параллельной сессии).
  - **Физический режим**: блок `physics:{ enabled, gravity:{x,y}, friction?, restitution?, dt?, walls?:[...], springs?:[...] }` + `body:{ mass, vx, vy, fixed }` на point/circle. Фикс-шаговый полу-неявный Эйлер (накопитель dt, кламп шага/скорости), опора на математику `_fx_motion.spring`. Упругие столкновения круг-круг и круг-стена (restitution), пружины (Гук+демпф) между телами/якорями. Drag тел (тащишь — позиция, отпускаешь — бросок со скоростью). Тела сосуществуют с формульными объектами Ф0/Ф1.
  - **env-поля тел**: `<id>.x/.y/.vx/.vy` берутся из СОСТОЯНИЯ интегратора и кладутся в env первыми — снимает forward-ref проблему однопроходного env для тел.
  - **Интегратор экспортирован** как `window.SimPhysics` (для билдера/доски/headless). Отдельного файла `_sim_physics.js` НЕТ (нельзя подключить без правки lab.html — зона параллельной сессии); код внутри `_sim_engine.js`.
  - Демо за флагом: +`customphys` (пружинный маятник), +`customballs` (упругие шары). Гочи: имя param `e` зарезервировано (число Эйлера) — в демо «шары» упругость названа `el`.
  - Верификация: `node --check` обоих файлов OK; eval/Function — только в комментарии; эмодзи нет (скан кодпойнтов); headless (vm+DOM/canvas-стаб) 28/28: падение под гравитацией (парабола, без NaN), упругие шары (скорости меняются, тела в коробке, ограничены), пружинный маятник (колебания, без взрыва), drag тела (позиция+бросок), смешанная сцена (формульный point + segment на ball.x/y + readout ball.y живут вместе), `SimPhysics.step` raw.
- **Фаза 1 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Только `_sim_engine.js` + `_sim_demo.js` (lab.html/lab-glue.js НЕ тронуты — зона параллельной сессии).
  - Новые типы объектов спеки: **plot** (график `f(var)` на canvas движка, `trace` — след по `t`), **readout** (живой бейдж, мягкая ошибка через `evalSafe`), **vector** с формой `origin+dx/dy`. **drag** на point/circle (`drag:{param,axis,min,max,paramY}`) — pointer events (мышь+тач), хит-тест в px (16px), двойной clamp (drag.min/max + диапазон параметра). Точные поля — в шапке `_sim_engine.js` и handoff phase-1.
  - Демо `customdemo` расширено: +слайдеры x0/y0, draggable-старт (axis xy), plot траектории, 2 readout (R, H). По-прежнему за флагом.
  - Верификация: `node --check` обоих файлов OK; eval/Function — только в комментарии, ни одного call-site; эмодзи нет (скан кодпойнтов); headless-тесты (vm + DOM-стаб): подготовка типов, vector end=origin+(dx,dy), plot evaluate, readout evalSafe, drag clamp+slider-sync, рендер всех 8 типов демо ×6 кадров без ошибок, trail/readout-слоты накапливаются корректно.
- **Фаза 0 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Ветка `feature/sim-builder` от `master`.
  - `frontend/js/labs/_sim_expr.js` → `window.SimExpr` (безопасный движок выражений, без eval/Function; `compile/evaluate/evalSafe/compileValue/parse/tokenize`, whitelist + сравнения/логика/тернарник/multi-var env).
  - `frontend/js/labs/_sim_engine.js` → `window.SimEngine.mount(host, spec) -> { play, pause, reset, setParam, getParam, isRunning, destroy, el }`. Canvas (мир→экран, Y вверх) + KaTeX-оверлей подписей + слайдеры/play/pause/reset. Формат спеки v1 задокументирован в шапке файла.
  - `frontend/js/labs/_sim_adapter.js` → `window.registerSpecSim(spec)` / `window.SimAdapter` — строит манифест LabRegistry (ленивый хост `#sim-spec-host-<id>` в `#lab-sim`).
  - `frontend/js/labs/_sim_demo.js` — демо `customdemo` (бросок тела) за флагом `?simdemo=1` / `?sim=customdemo` / `LAB_SHOW_SPEC_DEMO` / localStorage `lab-spec-demo=1`. Ученикам не светится.
  - Подключение в `frontend/lab.html`: 3 каркасных модуля eager после `_graph_panel.js`, демо после `_register-all.js`. `_sim_deps.js` НЕ тронут.
  - Верификация: `node --check` все 4 файла OK; eval/Function отсутствуют (только в комментариях); эмодзи нет; SimExpr self-test 29/30 (единственный «FAIL» `-2^2=4` — это парити с graph.js).
- Лаборатория уже декларативна на уровне регистрации: `frontend/js/labs/_registry.js`
  (`LabRegistry.register/get/all/setActive/stop/destroy/resolvePreview`), манифест с
  `open(ctx)/mount(host)/stop/destroy`. ~40 симуляций — рукописные JS-модули в `frontend/js/labs/`.
- Каталог в БД: миграция `042_lab_sims.sql` (`lab_sims`), роуты `backend/src/routes/lab.js`
  (`GET /api/lab/sims`, PATCH/:id, POST /reorder, links). Привязка к программе: `043_lab_sim_links.sql`.

## Архитектурные решения (зафиксированы при планировании)
- **Спека = JSON-данные.** Версия `specVersion`. Корень: `{ specVersion, meta, viewport, params[], objects[], physics?, plots[], controls }`.
- **Движок выражений безопасный** — собственный парсер (расширение `y=f(x)` из graph.js):
  токенайзер → AST → eval по окружению `{ params, t, объекты, whitelisted Math fns }`.
  ⛔ Без `eval`/`Function`. Whitelist: + - * / ^ %, sin cos tan asin acos atan sqrt abs exp ln log min max floor ceil round sign pi e, сравнения, ?:.
- **Рантайм** `window.SimEngine.mount(host, spec) -> instance{ play, pause, reset, setParam, destroy }`.
  Рендер: canvas для геометрии/трасс + SVG/absolute-div оверлей для подписей (KaTeX).
  Регистрируется в LabRegistry адаптером (одна функция строит манифест из спеки).
- **Объект**: `{ id, type, ...props-with-bindings }`. type ∈ point|segment|vector|circle|rect|polyline|path|label|image. Любое числовое свойство может быть числом ИЛИ строкой-выражением.
- **Физический режим (Фаза 2)**: объект с `body:{ mass, vx, vy, fixed }` интегрируется `_fx_motion`; силы `physics:{ gravity, springs[], collisions, friction, walls }`. Формульный и физический режимы сосуществуют (формульные объекты — кинематические).
- **Безопасность шаринга**: published-спека валидируется на сервере (размер, схема, глубина AST, число объектов/параметров); подписи-строки санитизируются как svg/текст.

## Temporary Workarounds
- (нет)

## Cross-Phase Dependencies
- Ф1 (графики/drag) зависит от рантайма Ф0.
- Ф2 (физика) зависит от Ф0 (модель объектов/цикл).
- Ф4 (билдер) зависит от Ф0–Ф2 (что строить) + Ф3 (куда сохранять).
- Ф5 (каталог) зависит от Ф3 (БД) + Ф0 (адаптер LabRegistry).
- Ф6 (раздача) зависит от Ф3+Ф5.
- Ф7 (доска) зависит от Ф0 (рантайм) + Ф5 (источник sim) + существующего `simOpen/simState`.

## Implementation Notes
- Каждая фаза должна оставлять /lab рабочим (Incremental).
- Тестировать рантайм Ф0–Ф2 рукописными спеками-фикстурами (без билдера).
- Reuse > переписывание: сначала смотреть `_fx_motion`, `_graph_panel`, `graph.js`.

## RESUME STATE
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md):** P1+P2+P3 закоммичены; **P4 «UI билдера + контролы стиля»
  РЕАЛИЗОВАН** (рабочее дерево, не закоммичено — ждёт ревьюера/оркестратора). Файлы: только
  `frontend/sim-builder.html` + `frontend/js/sim-builder.js`. Дальше — независимый ревью P4, затем P5
  (прямое манипулирование на сцене для всех типов + snap/выравнивание + undo/redo; правки `_sim_engine.js`
  + `sim-builder.js`). Контракт стилей/кривых из P2/P3-handoff полностью покрыт контролами билдера.
- Последний коммит фичи: — (Ф0..Ф7 ВСЕ реализованы, ещё не закоммичены — ждут оркестратора)
- Текущая фаза: Phase 7 — Доска онлайн-урока (✅ Implemented, pending commit) — ФИНАЛЬНАЯ.
  Дальше: Final Review (final-reviewer + security review) → коммит всех фаз → merge в master.
- **Ф7 файлы (аддитивно, рабочее дерево по ним было чистым до правок):**
  `backend/src/controllers/classroom/sim.js` (simOpen принимает `custom:<dbid>` + access-check
  own|published|admin), `frontend/classroom.html` (пикер: свои+published custom через `_crLoadCustomSims`/
  `_crRenderSimGrid`; id `custom:<dbid>`), `frontend/js/labs/lab-glue.js` (`_bridgeCustomSimState` —
  мост sim_state/apply_sim_state для custom-sim поверх SimEngine; вызов в `_registerLazy.open`).
  js/api.js НЕ менялся. Синхрон: параметры+play/pause (не время t). Открытие — iframe `/lab?embed=1&sim=custom:<id>`.
- Эндпоинты Ф6: share/clone/related/links на `/api/custom-sims/:id/*`; клиент `LS.customSimShare/
  Clone/Related/AddLink/DelLink`. Раздача = авто-publish + pushNotif (НЕ копия). Связи — lab_sim_links
  `sim_id='custom:<id>'`. Остаток Ф6: UI-редактор связей в билдере + чипы в каталоге (backend готов).
- Файлы Ф5 (аддитивные правки зоны параллельной сессии — БЕЗ рефактора): `frontend/js/labs/lab-init.js`
  (+7 строк: хук `LabCustom.resolveId` в `openSim`), `frontend/js/labs/lab-glue.js` (renderSims +`!m._custom`
  и вызов renderSection; init зовёт `LabCustom.init()`; новый IIFE `window.LabCustom`). `_sim_deps.js`,
  classroom.html, backend — НЕ тронуты. Публичное API: `window.LabCustom.{init,resolveId,renderSection,ensureSpec,del}`.
- id-неймспейс custom: deep-link/клик/`data-open` = `custom:<dbid>`; LabRegistry/host = `customsim_<dbid>`.
- Режим: Automated / Orchestrator / Incremental
- Файлы Ф4 (несведённые с параллельной сессией): `frontend/sim-builder.html` (new),
  `frontend/js/sim-builder.js` (new), `js/sidebar.js` (modify, аддитивный пункт `/sim-builder`).
  lab.html/lab-glue.js НЕ тронуты. Публичное API билдера: `window.SimBuilder.create(...)`.
- **Номер миграции Ф3: 071** (`071_custom_sims.sql`); следующая свободная — 072.
- Новые публичные API для следующих фаз: `window.SimExpr`, `window.SimEngine.mount`, `window.SimPhysics` (step/integrate/resolveCollisions), `window.registerSpecSim` / `window.SimAdapter`. Формат спеки v1 + типы plot/readout/drag/vector + блок `physics`/`body`/`springs`/`walls` — в шапке `_sim_engine.js` и в handoff phase-0/1/2.
- **API персистентности (Ф3)**: `/api/custom-sims` (GET `/`, GET/PUT/DELETE `/:id`, POST `/`) + клиент `LS.customSimsList/Get/Create/Update/Delete`. Контракт спеки на вход/санитизация — в handoff phase-3.
- Файлы Ф2 (несведённые с параллельной сессией): `frontend/js/labs/_sim_engine.js`, `frontend/js/labs/_sim_demo.js`.
- Файлы Ф3: `backend/src/db/migrations/071_custom_sims.sql`, `backend/src/controllers/customSimController.js`, `backend/src/routes/customSims.js`, `backend/tests/custom-sims.test.js` (new); `backend/src/server.js`, `js/api.js` (точечные добавления). lab.html/lab-glue.js НЕ тронуты.
- Для Ф4 (билдер): слать/получать спеку через `LS.customSimCreate/Update/Get`; сервер вернёт спеку санитизированной (escaped-текст). Лимиты/коды 400 — см. handoff phase-3.
