mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
docs(spec): define one thickness resolver for a wall chain
Issue: #234 User-Visible: no
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user