diff --git a/docs/specs/234-chain-segment-thickness.md b/docs/specs/234-chain-segment-thickness.md new file mode 100644 index 00000000..7f451c81 --- /dev/null +++ b/docs/specs/234-chain-segment-thickness.md @@ -0,0 +1,252 @@ +# Issue #234 — Толщина отрезка цепочки не расходится между превью и записью + +- Дата: 2026-08-21 +- Тип: bug · приоритет P2 · ценность 8/10 · сложность/риск 4/10 и 5/10 +- Issue: [#234](https://github.com/Matysh/houseplan-card/issues/234) +- Ветка: `issue/234-chain-segment-thickness` +- Статус ТЗ: на ревью + +Канонические документы: `docs/SCOPE.md`, `docs/WALL-THICKNESS.md`, +`docs/CANVAS.md`, `docs/CONFIG-COMPATIBILITY.md`, `docs/TOUCH-SUPPORT.md`, +`docs/USER-GUIDE.ru.md`. + +## 1. Сценарий и персона + +Администратор дома рисует на десктопе цепочку стен инструментом «Стены», меняя +поле толщины между отрезками. Цепочка завершается как независимые перегородки +либо замыкается в комнату. + +Иногда сохранённая толщина отрезка оказывается равной 15 см, хотя на экране во +время рисования была нарисована другая. Обнаруживается это позже и случайно: при +наведении на комнату контур идёт по 15 см, а инструмент «Толщина» подсвечивает +15 вместо видимых 30. Ручная правка толщины исправляет запись насовсем. + +## 2. Что человек увидит до и после + +**До:** нарисовано 30 см — сохранено 15 см, и об этом ничто не сообщает. +Расхождение живёт в конфиге, пока пользователь не заметит его глазами. + +**После:** сохраняется ровно то, что было показано в превью. Если запись +толщины для отрезка отсутствует (черновик, сохранённый до этого исправления), +отрезок получает толщину предыдущего отрезка своей цепочки — то есть то, что +человек видел на экране, — а не глобальные 15 см. + +Пользователь не видит новых элементов интерфейса: задача про достоверность +чисел, а не про новый контракт. + +## 3. Подтверждённый диагноз + +Расхождение доказано исполнением: `wallChainSegments(path, [30, 30], 15)` на +цепочке из трёх отрезков даёт `30 | 30 | 15`, а формула превью +(`houseplan-card.ts:18507`) на тех же данных — `30 | 30 | 30`. + +Причина: «толщина отрезка i» решается в пяти местах **тремя разными способами**, +и они расходятся ровно там, где записи нет. + +| Место | Fallback при отсутствии `cms[i]` | +|---|---| +| превью цепочки, `houseplan-card.ts:18507` | текущее поле `drawCm`, иначе 15 | +| незамкнутая цепочка → перегородки, `:6550` → `wall-face-graph.ts:69` | 15 | +| замкнутая цепочка → перегородки, `:13002` | 15 | +| замкнутый контур → комната, `:12618`, `:12645` | `edgeCms[0]` либо поле | +| `_wallSourceCmAt`, `:12329-12351` | 15 | + +Пятое место питает подсветку инструмента «Толщина» — тот самый способ, которым +дефект и обнаруживается. + +Пропуск в `_draftSegmentCms` возможен потому, что инвариант +«`_draftSegmentCms.length === _path.length - 1`» нигде не выражен: точка +добавляется в `_path` вызывающим (`:7259-7260`), а толщина пишется отдельным +методом `_persistActiveDraftSegment` (`:7376`), который **молча выходит**, если +`_drawWallCm == null` — состояние «поле толщины в этот момент пусто или вне +1…100», ровно возникающее при правке толщины на ходу. Дублирующая проверка в +`_canAppendRoomDraftPoint` (`:7130-7131`) сегодня закрывает нормальный путь, но +согласованность двух проверок ничем не закреплена, а восстановление черновика из +persisted `segments` (`:2618`, `:7237`, `:12721`, `:7364`) и из батча граней +(`:3083`, `:12738`) переносит уже существующий пропуск дальше. + +## 4. Зафиксированные продуктовые решения + +1. **Отсутствующая запись наследует толщину предыдущего отрезка** той же + цепочки; если предыдущего нет — текущее значение поля; если и его нет — + `DRAW_WALL_DEFAULT_CM`. Решение владельца 2026-08-21: это ближе к тому, что + человек видел на экране, чем глобальные 15 см, и совпадает с уже + существующим поведением пути «контур → комната». +2. **Превью и запись обязаны давать одинаковый вектор толщин** на любых входных + данных. Это не оптимизация, а определение корректности задачи. +3. Формат конфигурации не меняется: `partitions[].cm` и + `room_drafts[].segments[].cm` остаются как есть. + +## 5. Границы задачи + +### Входит + +- единая функция разрешения толщины отрезка цепочки и её применение во всех + пяти местах из §3; +- явный инвариант длины `_draftSegmentCms` относительно `_path`; +- поведение при загрузке черновика с недостающими `segments`; +- тесты, мутанты, changelog. + +### Не входит + +- изменение самого поля толщины и его валидации (диапазон 1…100 остаётся); +- изменение того, как толщина применяется к общим участкам между комнатами + (`applyWallThicknessToNewRoom`, `setWallThickness`) — задача про выбор + значения, а не про его раскладку по интервалам; +- миграция уже испорченных конфигов: исправление действует на новые записи и на + чтение черновиков; уже сохранённые перегородки с 15 см правятся инструментом + «Толщина», как сейчас. Автоматически «дочинить» их нельзя — исходная + задуманная толщина не сохранена нигде. + +## 6. Контракт поведения + +Вводится чистая функция (рабочее имя `chainSegmentCms`) в +`src/wall-face-graph.ts`: + +``` +chainSegmentCms(segmentCount, recorded, activeCm, defaultCm) -> number[] +``` + +- `segmentCount` — число отрезков (`path.length - 1`, для замкнутого контура — + число вершин); +- `recorded` — `_draftSegmentCms` как есть, любой длины, с любым мусором; +- `activeCm` — текущее значение поля толщины либо `null`; +- `defaultCm` — `DRAW_WALL_DEFAULT_CM`. + +Правило для каждого индекса `i`: + +1. `recorded[i]` — число и `> 0` → берётся оно; +2. иначе — последнее валидное значение среди `recorded[0…i-1]` (то есть + «предыдущий отрезок»); +3. иначе — `activeCm`, если он валиден; +4. иначе — `defaultCm`. + +Функция **всегда** возвращает массив ровно длины `segmentCount`, каждый элемент +`> 0`. Она не знает про замыкание контура: значение закрывающего отрезка +подаётся вызывающим как `recorded[segmentCount - 1]`, если оно известно +(`_closingWallCm`). + +`wallChainSegments` перестаёт иметь собственный fallback: она принимает уже +разрешённый массив толщин. + +## 7. Инвариант и его нарушение + +`_draftSegmentCms.length` обязана равняться числу отрезков `_path`. + +- **Запись становится атомарной:** добавление точки в `_path` и запись её + толщины выполняются одним методом; отдельный вызов, способный «промолчать», + устраняется. Если толщина невалидна, точка не добавляется вовсе — это уже + проверяет `_canAppendRoomDraftPoint`, и теперь проверка не дублируется, а + является единственной. +- **Чтение чинит молча, но не тихо:** при загрузке черновика или батча массив + приводится к нужной длине через `chainSegmentCms`; факт дозаполнения пишется в + `console.debug` с id черновика. Пользователю ничего не показывается: он не + причина расхождения и починить его не может. + +## 8. Поверхности + +Plan-редактор (десктоп, мышь): рисование цепочки, замыкание в комнату, +превращение в перегородки, подсветка инструмента «Толщина». View и киоск не +затронуты: там ничего не рисуется. + +`Touch editor: not exposed` — рисование цепочки стен на тач-устройствах не +поддерживается сегодня и этой задачей не вводится; safety floor +`docs/TOUCH-SUPPORT.md` соблюдён по построению, поведение на тач не меняется. + +## 9. Изменяемые файлы + +- `src/wall-face-graph.ts` — новая `chainSegmentCms`, `wallChainSegments` без + собственного fallback; +- `src/houseplan-card.ts` — пять мест из §3 плюс атомарная запись точки; +- `test/wall-face-graph.test.mjs` — юниты правила; +- `test/` — юнит на согласие превью и записи (общая функция, один вход — один + результат); +- `demo/smoke_wall_chain_thickness.mjs` — новый смок; +- `scripts/mutation-gate.mjs` — три записи (§11); +- `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`. + +Миграции и compatibility-полей нет: формат не меняется (§4.3). + +## 10. Acceptance criteria + +| AC | Требование | Доказательство | +|---|---|---| +| AC1 | `chainSegmentCms` возвращает массив длины `segmentCount`, все элементы `> 0`, для любых входов, включая пустой `recorded`, дырки в середине, мусор (`NaN`, `-5`, `null`) | unit | +| AC2 | Отсутствующая запись наследует предыдущий валидный отрезок; при отсутствии предыдущего — `activeCm`; при отсутствии и его — `defaultCm` | unit | +| AC3 | Превью и запись дают **идентичный** вектор толщин на одних входных данных — проверяется через общую функцию, а не сравнением двух формул | unit | +| AC4 | Незамкнутая цепочка из трёх отрезков, нарисованных 30 см при пропуске записи последнего, сохраняется как `30, 30, 30`, а не `30, 30, 15` | unit + smoke | +| AC5 | Замыкание в комнату сохраняет то же распределение толщин, что показывало превью; общие участки с соседней комнатой сохраняют свою толщину | unit | +| AC6 | Подсветка инструмента «Толщина» (`_wallSourceCmAt`) показывает то же значение, что записано | smoke | +| AC7 | Черновик с `segments` короче `points - 1` после загрузки даёт полный вектор по правилу §6 и не роняет рендер | unit | +| AC8 | Инвариант: точка не добавляется в `_path`, если толщина невалидна; после любой последовательности добавлений длины согласованы | unit | +| AC9 | release-артефакты: обе записи changelog в том же коммите | ревью кода | + +## 11. Mutation guards + +| id | Что ломает | Что обязано покраснеть | +|---|---|---| +| `chain-thickness-falls-back-to-default` | правило §6 п.2 заменяется на `defaultCm` | AC2, AC4 | +| `chain-thickness-preview-diverges` | превью снова считает толщину своей формулой | AC3 | +| `chain-thickness-ignores-invariant` | снимается приведение длины при загрузке черновика | AC7 | + +Каждый мутант обязан краснеть на **своём** тесте: две формулы разошлись именно +потому, что ни один тест не сравнивал их между собой. + +## 12. План автотестов + +1. Юниты `chainSegmentCms` — таблица входов из AC1/AC2, включая `recorded` + длиннее `segmentCount` (лишнее отбрасывается). +2. Юнит согласия: собрать вектор для превью и для записи через общий путь, + сравнить. +3. Смок `demo/smoke_wall_chain_thickness.mjs`: нарисовать цепочку из трёх + отрезков, меняя поле толщины между кликами, завершить как перегородки, + прочитать `partitions[].cm` из конфига и сверить с нарисованным; отдельная + проверка — навести инструмент «Толщина» и сверить подсвеченное значение. +4. Регресс: `demo/smoke_draw_wall_thickness.mjs` и + `demo/smoke_wall_thickness_transition.mjs` остаются зелёными. + +## 13. Производительность, безопасность, touch + +Функция чистая, вызывается на кадр рисования на массиве длиной не больше +`MAX_DRAFT_POINTS`; влияния на перф нет. Безопасность не затронута. Touch — см. +§8. + +## 14. Откат + +Одна ревизия: вернуть прежние формулы. Данные при откате не страдают — +сохранённые `cm` остаются валидными числами в существующем формате. + +## 15. Риски + +1. **Путь «контур → комната» уже имеет свою логику** (`edgeCms[source] || cm`) и + раскладывает толщину по атомарным интервалам. Подмена его на общую функцию + может изменить поведение на общих участках между комнатами. Митигация: AC5 и + регрессионные смоки; при сомнении — оставить раскладку как есть, заменив + только выбор значения. +2. **Замыкающий отрезок** (`_closingWallCm`) участвует в двух путях по-разному. + Митигация: он подаётся в `recorded` вызывающим, функция про него не знает. +3. **Старые черновики** могут содержать `segments` с нулями и отрицательными + значениями. Митигация: правило §6 считает валидным только `> 0`. + +## 16. Release-артефакты + +- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` — запись про сохранение + нарисованной толщины; +- `docs/USER-GUIDE.ru.md` — правки не требуются: поведение приводится к тому, + что документация и так описывает; +- golden не затронут: визуальное представление не меняется, менялась только + запись. + +## 17. Принятые предположения (техническое, менять свободно) + +1. Имя `chainSegmentCms` и место в `wall-face-graph.ts` — рабочее решение; + допустимо перенести в `logic.ts`, если так короче цепочка импортов. +2. Дозаполнение при чтении логируется в `console.debug`; если это сочтут шумом, + можно убрать — на контракт не влияет. +3. Приведение длины делается в момент чтения черновика, а не при сохранении: + правка чужих записей на диске без действия пользователя мне кажется хуже, чем + починка на входе. + +**Не является предположением:** согласие превью и записи (§4.2, AC3) и правило +наследования (§4.1, AC2) — это продуктовые решения, зафиксированные владельцем; +менять их можно только через issue.