Files
houseplan-card/docs/specs/234-chain-segment-thickness.md
T
MatyshandSergey Matyunin b2024c6626 docs(spec): state that i18n is untouched, name the zero case
The spec review found the i18n section missing outright — the analysis
comment claimed it was untouched, the document itself said nothing, and a
DoR section cannot be inferred from a comment. It now says so explicitly,
and adds that the absence of i18n files from the diff is part of the
contract rather than an accident.

The waived Low is closed too: the old wallChainSegments treated a recorded
zero as valid while the new contract requires strictly positive values, so
zero is now named in the AC1 examples instead of being derivable from the
prose.

Issue: #234
User-Visible: no
2026-08-21 19:41:37 +03:00

261 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. Изменяемые файлы и i18n
- `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`.
**i18n: не затронут.** Ни одной новой пользовательской строки не добавляется и
ни одна существующая не меняется: задача исправляет значение, которое пишется в
конфиг, а не текст интерфейса. Файлы `src/i18n/en.json` и `src/i18n/ru.json` в
диффе отсутствуют, и их отсутствие — часть контракта: появление там правок
означало бы, что скоуп разъехался.
**Миграции и compatibility-полей нет:** формат не меняется (§4.3), поля
`partitions[].cm` и `room_drafts[].segments[].cm` сохраняют тип и смысл,
`docs/CONFIG-COMPATIBILITY.md` править не требуется.
## 10. Acceptance criteria
| AC | Требование | Доказательство |
|---|---|---|
| AC1 | `chainSegmentCms` возвращает массив длины `segmentCount`, все элементы `> 0`, для любых входов, включая пустой `recorded`, дырки в середине, мусор (`NaN`, `-5`, `null`) и **явный `0`** — прежняя `wallChainSegments` считала ноль валидным, контракт §6 сдвигает границу на `> 0`, и это должно быть закреплено примером, а не выводиться из текста | 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.