171 lines
21 KiB
Markdown
171 lines
21 KiB
Markdown
# SimForge — раунд улучшений (визуал / графика / рабочее поле)
|
||
|
||
**Branch:** `feature/sim-builder` · **Mode:** Automated · **Execution:** Orchestrator · **Strategy:** Incremental
|
||
**Started:** 2026-06-13
|
||
|
||
Полировка конструктора симуляций по всем направлениям. Каждая фаза — реализатор + независимый ревьюер,
|
||
коммит поимённо. ⛔ Эмодзи нет (SVG .ic); ast-index/Read; общая ветка с параллельной сессией — править
|
||
свои файлы движка/билдера, чужое (materials/quota) не трогать.
|
||
|
||
## Контекст бага «съехало вправо»
|
||
`_sim_engine.js._build` рисует фикс-панель контролов `width:260px` СЛЕВА + сцену справа. У пустой/новой
|
||
симуляции панель всё равно 260px → сцена и сетка визуально смещены вправо (правые ~70% хоста). `_fit`
|
||
(DPR, центрирование по stage) корректен. Фикс — в раскладке (Фаза 1).
|
||
|
||
## Фазы
|
||
|
||
- [x] **P1 — Рабочее поле (fix смещения + основа сцены).** Контролы из фикс-260px-колонки → плавающая/
|
||
нижняя ненавязчивая панель (collapsible); canvas-сцена центрирована и во всю ширину. Сетка major/minor +
|
||
числовые подписи осей + маркер origin. Zoom (колесо к курсору) + pan (drag пустого места) + кнопки
|
||
fit/reset-view. Работает и в билдере, и в /lab, и на доске (один движок). Подтвердить DPR-резкость.
|
||
Файлы: `frontend/js/labs/_sim_engine.js` (sim-builder.html НЕ потребовался — старый CSS превью
|
||
`.sbu-preview .sim-spec-root{position:absolute;inset:0}` уже корректно растягивает новый full-bleed root).
|
||
|
||
**Handoff (P1 → P2):**
|
||
- **Раскладка:** `_build` теперь делает `root` (`position:relative`) → внутри `stage` (`position:absolute;
|
||
inset:0`, canvas+labels на всю площадь) + плавающая `panel` (`position:absolute;left/top:10px;z-index:5;
|
||
pointer-events:auto`, сворачивается кнопкой `_togglePanel`, есть только при наличии params) + бар кнопок
|
||
вида (`right/bottom:10px`). Смещение вправо устранено: панель больше не отжимает сцену.
|
||
- **Transform-модель:** `_fit()` считает БАЗУ `_baseScale/_baseOffX/_baseOffY` (центрированный fit) и
|
||
ЭФФЕКТИВНЫЙ `_scale/_offX/_offY`. `_zoom` — пользовательский множитель к базе, `_viewLocked` — был ли
|
||
zoom/pan (ресайз тогда сохраняет мир-центр и zoom, не сбрасывает вид). `_toPx/_toWorld` — без изменений сигнатур.
|
||
- **API вида (новое, публичное):** `inst.fitView()` / `inst.resetView()` (оба → центрированный viewport).
|
||
Внутреннее: `_zoomAt(lx,ly,factor)` (зум к экранной точке, инвариант мир-точки), `_setupZoomPan()`,
|
||
`_pickHandleAt(lx,ly)` (вынесен из `_setupDrag`, общий хит-тест — pan стартует только если вернул null →
|
||
приоритет ручек/тел сохранён), `_visibleWorld(W,H)`.
|
||
- **Сетка/оси:** `_niceStep(targetPx)` теперь завязан на `_scale` (адаптивен к zoom, шаги 1/2/5·10^n);
|
||
`_drawGrid` рисует minor(~34px) + major(×5) через всю видимую область (`_visibleWorld`), линии на .5px
|
||
(резкость, без «ступенек»); `_drawAxes` рисует оси (прижимаются к краю если 0 вне вида) + числовые подписи
|
||
делений (светлый текст + тень для тёмного фона, хелперы `_axisNum`/`_stepDecimals`) + маркер origin (0,0).
|
||
- **destroy:** снимает wheel + pan-листенеры (`_onWheel/_onPanDown/_onPanMove/_onPanUp`) и ResizeObserver.
|
||
- **На P2:** качество графики ОБЪЕКТОВ (lineJoin/cap, стрелки векторов, dash/opacity/градиент/glow, стили
|
||
точек, затухающие трассы) — это `_drawObject`/`_drawTrail`/`_arrowHead`/`_drawPlot`/`_prepareObjects` в том
|
||
же файле. Поля стилей объектов уже читаются в `_prepareObjects` (color/fill/width) — расширять там.
|
||
- [x] **P2 — Качество графики объектов.** Скругление/сглаживание линий (lineJoin/cap), красивые стрелки
|
||
векторов, стили линий (solid/dashed/dotted), opacity, градиент-заливки, опц. тень/glow, стили точек
|
||
(filled/hollow/cross/ring), затухающие трассы; приятная дефолтная палитра. Файл: `_sim_engine.js`.
|
||
|
||
**Handoff (P2 → P3/P4): новые поля стиля спеки** (контракт для контролов билдера в P4). Все рендерятся
|
||
ТОЛЬКО на canvas (`fillStyle/strokeStyle/createLinearGradient/shadowColor`) — XSS нет, мусорный цвет
|
||
игнорится canvas. Читаются в `_prepareObjects`, применяются в `_drawObject` через хелперы `_applyStroke`
|
||
(alpha/lineWidth/join/cap/dash/glow) и `_fillStyleFor` (градиент или сплошная заливка):
|
||
- `opacity` — число `0..1` (деф. 1) → `globalAlpha` на время отрисовки объекта (восстанавливается).
|
||
- `lineStyle` — `'solid'|'dashed'|'dotted'` (деф. solid) → `setLineDash` (паттерн масштабируется от `width`).
|
||
- `width` — толщина штриха (деф. 2); для circle/rect `width:0` отключает обводку (только заливка).
|
||
- `fill`/`fillColor` — цвет заливки (circle/rect/закрытый path). `gradient:[c0,c1]` — линейный градиент
|
||
(вертикальный по bbox), приоритетнее `fill`. Полигон-заливка только при `closed:true`.
|
||
- `glow:true` ИЛИ `shadow:'#color'` ИЛИ `shadow:{blur}` — свечение (`shadowColor/shadowBlur`); деф. ВЫКЛ
|
||
(производительность). `glowColor`/`glowBlur` — точечная настройка (деф. цвет объекта / blur 12).
|
||
- `pointStyle` (point) — `'filled'|'hollow'|'cross'|'ring'` (деф. filled: заполненный кружок + тонкая
|
||
белая обводка). hollow — только обводка, ring — толстое кольцо, cross — крестик.
|
||
- `trailFade` (деф. true) — затухающая трасса (старые сегменты прозрачнее, посегментно alpha 0.08→0.68);
|
||
`trailWidth` (деф. 1.6), `trailLen` (деф. 2000, макс 5000) — толщина/длина следа. `trailColor` — как было.
|
||
- **Палитра по умолчанию**: если `color` не задан — циклически `DEFAULT_PALETTE[i % 8]` (cyan/violet/pink/
|
||
emerald/amber/blue/rose/green) вместо единого `#06D6E0`. Явный `color` всегда сохраняется.
|
||
- **Стрелки векторов** (`_arrowHead`/`_arrowHeadLen`): заполненный «барбед»-треугольник (вырез у основания),
|
||
длина = `max(9, width*3.2)` px; тело линии укорочено на длину головы (не торчит сквозь остриё).
|
||
- **На P3** (графики/диаграммы): `_drawPlot` уже использует `_applyStroke` (dash/opacity/glow работают на
|
||
кривых). Для P3 расширять `_drawPlot` — оси-делений plot, несколько кривых, заливка под кривой, маркеры
|
||
точек (можно переиспользовать `_drawPoint`), легенда. Хелперы `_applyStroke`/`_fillStyleFor`/`_drawPoint`
|
||
готовы к переиспользованию.
|
||
- [x] **P3 — Графики/диаграммы (визуал charts).** Для plot: несколько кривых, заливка под кривой,
|
||
маркеры точек, легенда; аккуратный стиль диаграмм (оси/сетка/подписи — уже из P1). Файл: `_sim_engine.js`.
|
||
|
||
**Handoff (P3 → P4): новые поля plot-объекта** (контракт для контролов билдера в P4). Все читаются в
|
||
`_prepareObjects` (ветка `type==='plot'`), рендерятся ТОЛЬКО на canvas (без DOM-style/eval). Старый
|
||
одиночный `expr`/`var`/`range`/`samples`/`trace` работает как раньше (обратная совместимость):
|
||
- **Несколько кривых.** Источник (приоритет): `curves:[{...}]` → `exprs:['sin(x)','x^2']` → `expr`
|
||
(легаси). Нормализуются в `prep.curves[]`. Каждой кривой свой цвет: явный `color` или
|
||
`DEFAULT_PALETTE[i%8]`. `prep.exprFn` = первая кривая (для trace-режима).
|
||
- **Поля кривой** (`curves[i]`): `expr` (строка), `color`, `label` (строка → легенда), `width`,
|
||
`lineStyle` (`solid|dashed|dotted`), `opacity` (0..1), `fill` (`true` → полупрозр. цвет кривой / строка
|
||
цвета), `marker` (`none|dot|ring`). Не заданные наследуются от plot-уровня (`width/lineStyle/opacity`)
|
||
или дефолтов.
|
||
- **Plot-уровневые** `fill` и `marker` — дефолт для всех кривых (если у кривой не задано).
|
||
- **Заливка под кривой** — между кривой и осью `y=0`, посегментно (разрывы у не-finite точек не сливаются),
|
||
`_fillUnderCurve`. Прозрачность через `_fillAlpha(color, 0.18)` для `fill:true`.
|
||
- **Маркеры узлов** — `_drawCurveMarkers` (переиспользует `_drawPoint`), прорежены ~28px по экрану
|
||
(не рисуем сотни точек). `dot` → filled, `ring` → hollow.
|
||
- **Легенда** — `_drawLegend` (на canvas: тёмная плашка + цветной свотч + светлый текст), верх-право,
|
||
не наезжает на бар кнопок вида. Включается авто при наличии `label`; `legend:false` отключает.
|
||
- **Качество кривой** — пропуск не-finite (разрывы), переиспользован существующий equidistant sampling
|
||
(`samples`, деф. 200, макс 2000), `_applyStroke` (dash/opacity/glow/lineJoin/cap).
|
||
- **На P4 (билдер):** дать этим полям контролы — список кривых (добавить/удалить, expr + color-picker +
|
||
label + width + lineStyle + opacity + fill toggle/color + marker select), plot-уровневые fill/marker,
|
||
тумблер легенды. Хелпер `_markerStyle`/`_fillAlpha` — модульного уровня, рядом с `_dashFor`/`_opacity`.
|
||
- [x] **P4 — UI билдера + контролы стиля.** Дизайн-полировка панелей/тулбара (ls.css), нативные color-
|
||
пикеры + opacity/width/dash/линестиль на объект, z-order/дублирование/видимость объектов, пустые
|
||
состояния, мобайл. Файлы: `frontend/sim-builder.html`, `frontend/js/sim-builder.js`.
|
||
|
||
**Handoff (P4 → P5):**
|
||
- **Контролы стиля объекта** (блок «Стиль» в каждом редакторе, `STYLE_FOR[type]` решает набор):
|
||
`rangeCtl` непрозр. (слайдер 0..1 → `opacity`), `selectCtl` линия (`lineStyle` solid/dashed/dotted),
|
||
стиль точки (`pointStyle`, только point), тумблер `glow`, тумблер «Градиент-заливка» (circle/rect →
|
||
`gradient:[c0,c1]`, две пары color-инпутов). Цвета — новый `colorCtl`: нативный `<input type=color>`
|
||
+ текстовое поле (источник истины, поддерживает rgba/named) + кнопка очистки для fill/trailColor
|
||
(«нет заливки»). Синхрон пикер↔текст — `Builder.wireColorControls(row)` (текст диспатчит `input`,
|
||
основной `data-of`/`data-cvf` обработчик ловит). `toHexColor` приводит к `#rrggbb` для нативного пикера.
|
||
- **Редактор кривых plot** (`plotEditor`/`curveEditor`): UI-модель plot = `{var, range_a/b, samples,
|
||
trace, legend, plotFill, plotMarker, curves:[...]}`. Кривая = `{_uid, expr, color, label, width,
|
||
lineStyle, opacity, fill(bool), fillColor, marker}`. Список кривых (добавить `[data-curveadd]` /
|
||
удалить `[data-curvedel]`, минимум 1), на кривую — expr+fx, color, label, width, lineStyle, marker,
|
||
opacity, fill+цвет. Plot-уровневые `plotFill`/`plotMarker`/легенда. `loadPlot` нормализует
|
||
spec→UI (curves[]→exprs[]→legacy expr; легаси plot-level width/lineStyle/opacity наследуются кривой),
|
||
`normalizePlotForSpec`+`stripCurve` собирают обратно: **одиночная «простая» кривая (только expr+color,
|
||
нет plot-fill/marker) → легаси-форма** `{expr,color}`; иначе `curves:[...]`. `legend:false` эмитится
|
||
только при выключенной легенде.
|
||
- **Список объектов**: в шапке каждого — z-order вверх/вниз (`[data-oup]`/`[data-odown]`, порядок в
|
||
массиве = порядок отрисовки; крайние disabled), видимость (`[data-ohide]` → `o.hidden=true`),
|
||
дублировать (`[data-odup]`, deep-clone + новый `_uid`, `id+'_copy'`), удалить. Аналогично у plot.
|
||
- **hidden — чисто на стороне билдера** (движок не трогали): `buildSpec` фильтрует объекты/plot с
|
||
`hidden`; `stripObj.isDefaultStyle` гарантирует, что `hidden`/дефолты стиля (glow:false, lineStyle:
|
||
'solid', pointStyle:'filled', opacity:1, trail/closed:false) НЕ попадают в спеку → спека минимальна,
|
||
round-trip save→load→save идемпотентен (проверено vm-смоуком 27+12+2 PASS).
|
||
- **На P5 (прямое манипулирование + история):** в билдере сейчас есть только drag x/y point/circle/label/
|
||
readout/rect и конца segment/vector (`bindPreviewDrag` через `inst._toWorld`). Расширять до всех типов
|
||
+ snap-к-сетке + выравнивание (нужны правки `_sim_engine.js` — хит-тесты/ручки). Undo/redo: состояние
|
||
= `this.st` (сериализуемо JSON); снимать снапшот при `onAdd`/удалении/правке (debounce) — стек в
|
||
Builder, перерисовка `renderPanels`+`scheduleRemount`. Идентичность спеки между билдами уже гарантирована.
|
||
- [x] **P5 — Прямое манипулирование на сцене + история.** Drag всех типов (не только point/circle),
|
||
snap-к-сетке; undo/redo в билдере. Файл: `frontend/js/sim-builder.js` (движок НЕ тронут — `_toWorld`/
|
||
`_toPx`/`_niceStep` уже публичны на инстансе, хука не потребовалось).
|
||
|
||
**Итог / Handoff (P5 — финал раунда):**
|
||
- **Прямое манипулирование (`bindPreviewDrag`, переписан).** «Ручки» объекта строит `handlesOf(obj)`:
|
||
точка/окружность/подпись/показатель/прямоугольник → одна ручка `pos`(x,y); отрезок/вектор → две
|
||
ручки `origin`(x1,y1)+`end`(x2,y2 ИЛИ origin+dx/dy — определяется по наличию полей); ломаная/путь →
|
||
по ручке на каждую числовую вершину `points`. Каждая ручка несёт `set(x,y)` и флаг `blocked`. Хит-тест
|
||
`pickHandle` (допуск 14px через `inst._toPx`) выбирает ближайшую ручку. Режимы pointerdown:
|
||
`handle` (попали в ручку — двигаем её), `place` (единственная ручка, клик ставит точку — сохранён
|
||
исходный смысл «клик ставит»), `body` (несколько ручек — двигаем всё тело относительным сдвигом от
|
||
стартовой мир-точки), `none` (нет двигаемых ручек). Поля-ВЫРАЖЕНИЯ не трогаются: `numField` вернёт
|
||
`null` для нечислового значения → ручка `blocked` (не двигается, не сериализуется молча).
|
||
- **Snap-к-сетке.** Тумблер в тулбаре (иконка `ICON.grid`, флаг `this._snap`, переключатель `toggleSnap`,
|
||
активное состояние — инлайн-стиль `SNAP_ACTIVE_CSS`, без зависимости от CSS-класса). При включённом
|
||
drag округляет мир-координаты к шагу `inst._niceStep(34)` (минорный шаг сетки движка; fallback 0.5).
|
||
Выключенный — `round2`.
|
||
- **Выравнивание** — реализован минимум (snap-к-сетке движка). Прилипание к координатам других объектов
|
||
НЕ делалось (бонус; достаточно snap для зачёта). Зафиксировано как частичное.
|
||
- **Undo/Redo.** Стек снапшотов `JSON.stringify(this.st)` (глубина `_undoMax=50`). `pushHistory` снимает
|
||
снапшот ПЕРЕД мутацией (без дублей верхушки; сбрасывает redo). `snapField` — один снапшот на сессию
|
||
правки поля (focusin сбрасывает флаг `_fieldSnapTaken`, первый input/change снимает) → Ctrl+Z откатывает
|
||
значение целиком, а не посимвольно. Структурные операции (add/delete/z-order/duplicate/hide/toggle,
|
||
включая plot/curve/wall/spring и физ-тумблер) — снапшот сразу. Drag — один снапшот на сессию (пустые
|
||
no-op-снапшоты откатываются в `end()`). Кнопки undo/redo в тулбаре (SVG `.ic`), горячие клавиши
|
||
Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y (`bindKeyboardShortcuts`, вешается один раз, игнорит фокус в полях ввода).
|
||
`loadFromSim` обнуляет историю. `_restoreSnapshot` → `renderPanels`+`scheduleRemount`.
|
||
- **Совместимость.** `buildSpec`/round-trip/валидация не тронуты; идемпотентность спеки сохранена.
|
||
`refreshObjFields` расширен на x1/y1/x2/y2/dx/dy/points. Проверено vm-смоуком: 38/38 PASS
|
||
(drag point/circle/segment-оба-конца/vector-dx,dy/polyline-vertex + body-move polyline/segment; snap к
|
||
0.5; выражение не затирается; undo/redo drag и add; стек ограничен; round-trip идемпотентен; no-op
|
||
drag не плодит историю). `node --check` OK, эмодзи/eval нет.
|
||
|
||
## Progress
|
||
| Phase | Status | Review | Committed |
|
||
|-------|--------|--------|-----------|
|
||
| P1 Working field | Done | ✅ PASS | ✅ |
|
||
| P2 Object graphics | Done | ✅ PASS | ✅ |
|
||
| P3 Charts | Done | ✅ PASS | ✅ |
|
||
| P4 Builder UI | Done | ✅ PASS | ✅ |
|
||
| P5 Direct manip + history | Done | ✅ PASS | ✅ |
|