docs(spec): define one thickness resolver for a wall chain

Issue: #234
User-Visible: no
This commit is contained in:
Matysh
2026-08-21 19:41:37 +03:00
committed by Sergey Matyunin
parent 1e2eba95b1
commit 05b2c74442
+252
View File
@@ -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.