docs: specify atomic wall thickness lookup

Issue: #201
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-19 17:48:56 +03:00
parent b583d663e3
commit 7b759f316b
2 changed files with 218 additions and 0 deletions
+217
View File
@@ -0,0 +1,217 @@
# Issue #201 — наследование толщины для атомарного участка стены
- Дата: 2026-08-19
- Тип: bug · приоритет P2 · ценность 6/10 · сложность 4/10
- Issue: [#201](https://github.com/Matysh/houseplan-card/issues/201)
- Ветка: `issue/201-atomic-thickness-lookup`
Канонические документы: `docs/SCOPE.md`, `docs/UX-MODES.md`,
`docs/WALL-THICKNESS.md`, `docs/TESTING.md`.
## 1. Сценарий и персона
Администратор редактирует план с частично совпадающими границами трёх комнат.
На одном физическом пролёте уже задана толщина стены. Администратор закрывает
виртуальный участок этой границы и ожидает, что восстановленная кладка получит
толщину ближайшего сплошного продолжения, как и обещает текущий инструмент.
Это часть J4/J6: редактирование не должно незаметно заменять сохранённую
физическую характеристику значением по умолчанию.
## 2. Что человек увидит до и после
**До:** если соседний сплошной участок является атомарной частью более длинной
wall-записи, Close не узнаёт его толщину и создаёт восстановленный участок с
default 15 см. На стене 20/22 см появляется непреднамеренная ступень.
**После:** Close наследует фактическую толщину покрывающего точного wall-run.
Default 15 см используется только тогда, когда ни один подходящий сосед
действительно не имеет толщины.
## 3. Подтверждённая проблема и причина
На production-scale fixture из #197 сохранённая запись 20 см точно покрывает
пролёт `[620.83,550]–[887.5,550]`. Третья комната делит его на два атомарных
ребёнка:
- `[620.83,550]–[691.67,550]`;
- `[691.67,550]–[887.5,550]`.
На текущем `dev` `wallIntervals()` правильно разрешает обоих детей в 20 см:
`cmsForPoly()` уже умеет искать покрывающий exact span. Но публичный pure-helper
`thicknessCmAt()` вызывает только `lookupWall()`, рассчитанный на собственный
key/midpoint одного stretch, и возвращает 0 для обоих детей. Полный parent-run
тем же вызовом разрешается в 20 см.
Единственный продуктовый consumer `thicknessCmAt()` — `thicknessOnClose()` в
`src/open-spans.ts`. Он перебирает соседние `solidEdges`; нулевой ответ исключает
реального соседа и приводит к `DRAW_WALL_DEFAULT_CM`.
Исходное предположение issue о нулевой толщине в общем render-path устарело:
render использует `wallIntervals()` и на текущем коде сохраняет 20 см. Баг
ограничен чтением exact parent-run при закрытии виртуальной границы.
## 4. Scope
- научить `thicknessCmAt()` читать наиболее узкий exact stored span, который
коллинеарен и полностью покрывает запрошенный дочерний сегмент;
- сохранить прямой key/tolerant lookup как первый и совместимый путь;
- обеспечить правильное наследование в `thicknessOnClose()` и
`applyThicknessOnClose()`;
- покрыть normalized и production coordinate scales;
- добавить regression unit, browser smoke, mutation gate, тестовый контракт и
пользовательские changelog.
## 5. Non-scope
- изменение `wallIntervals()`, `cmsForPoly()`, body geometry или рендера;
- очистка уже сохранённых коротких интервалов (#198);
- изменение выбора ближайшего коллинеарного соседа в `thicknessOnClose()`;
- изменение default 15 см, UI Close, snapping, open-span ownership или Undo;
- расширение legacy key-only записи на произвольный покрывающий parent;
- schema/backend/API, миграция, Optimize и переписывание config при чтении;
- общая замена midpoint/key модели wall entries.
## 6. Контракт разрешения толщины
### 6.1. Порядок поиска
Для query `[a,b]` функция `thicknessCmAt(walls,a,b,pitch,coordScale)`:
1. сохраняет текущий результат `lookupWall()` при direct key или его
совместимом midpoint fallback;
2. если direct hit отсутствует, рассматривает только записи с валидными
конечными точками `a/b`, положительной толщиной и совпадающим направлением;
3. принимает запись только тогда, когда обе query endpoints лежат на её
конечном отрезке в действующем scale-relative tolerance, то есть stored span
покрывает query целиком;
4. при нескольких кандидатах выбирает наиболее узкий покрывающий span; tie
разрешается детерминированно и независимо от порядка входного массива;
5. если кандидата нет, возвращает 0 как сейчас.
### 6.2. Запрет утечки толщины
Exact запись, покрывающая только часть query, не подходит, даже если направления
и midpoint близки. `[0..4]` не может дать толщину запросу `[0..10]`; тем самым
сохраняется защита AUD-159B6-01. Параллельный сосед на другой линии,
перпендикулярный участок, zero-length/malformed endpoints и `cm <= 0` также не
подходят.
Legacy `{key,cm}` без exact endpoints продолжает работать только по нынешнему
direct/midpoint контракту: новая логика не может доказать его покрытие.
### 6.3. Close
`thicknessOnClose()` не меняет сортировку кандидатов: среди коллинеарных
`solidEdges` побеждает ближайший к закрываемому span участок с разрешённым
`cm > 0`. Исправление меняет только ложный `0` на фактическую толщину exact
parent-run. Default применяется лишь при отсутствии любого такого соседа.
`applyThicknessOnClose()` записывает выбранную толщину существующим
`setWallThickness()`; последующая normalisation, preview/apply и Undo не
получают отдельной ветки поведения.
## 7. Данные, миграция и совместимость
Формат `walls`, ключи и exact endpoints не меняются. Миграции нет. Существующие
планы не переписываются до явного Close. Уже созданные ошибочные 15-см участки
остаются данными пользователя и относятся к #198.
Downgrade возвращает неверное наследование только при следующем Close, не
повреждая уже сохранённый план. Backend validation и старые key-only планы
сохраняют текущую совместимость.
## 8. UX, i18n, accessibility и touch
Новых элементов, строк, уведомлений и жестов нет. Исправляется результат уже
существующего действия. Keyboard/touch policy, focus и editor affordance не
меняются.
## 9. Acceptance criteria и доказательства
| AC | Критерий | Обязательное доказательство |
|---|---|---|
| AC1 | Exact parent `[0..10]` толщиной 20 см даёт 20 для атомарных queries `[0..4]` и `[4..10]`. | Table-driven unit `wall-thickness.test.mjs`. |
| AC2 | AC1 одинаков при `coordScale = 1` и `coordScale = NORM_W`; направление endpoints можно развернуть. | Unit scale/direction matrix. |
| AC3 | Exact partial `[0..4]` не даёт толщину query `[0..10]`; параллельный offset, perpendicular и malformed rows не совпадают. | Negative unit matrix. |
| AC4 | Из нескольких покрывающих exact spans выбирается наиболее узкий; перестановка rows не меняет ответ. | Unit permutation test. |
| AC5 | Legacy key-only direct/midpoint lookup сохраняет текущий результат. | Existing + focused regression unit. |
| AC6 | `thicknessOnClose()` на частично расщеплённом parent-run наследует 20/22 см, а без толстого соседа сохраняет default 15 см. | `open-spans.test.mjs`. |
| AC7 | Реальное Close в Plan на трёхкомнатном partial-overlap fixture сохраняет восстановленный span с соседним cm, без console/page errors; Undo восстанавливает virtual span. | Расширенный targeted browser smoke. |
| AC8 | Render-path полного fixture до/после не меняется вне закрываемого span. | Existing wall/open-span units и targeted smoke. |
| AC9 | Пользовательский и тестовый контракты обновлены. | Оба changelog + `docs/TESTING.md`. |
| AC10 | Regression доказана исполняемым мутантом. | `mutation-gate --check`, clean green / mutant red. |
| AC11 | Рабочие gates зелёные. | typecheck, unit, build, targeted smoke. |
## 10. План автотестов
### 10.1. Unit
В `test/wall-thickness.test.mjs` добавить матрицу exact containment: child,
reversed, scale 1/1000, nested candidates, row permutation и negative cases.
Тест обязан быть красным на текущем `dev` именно потому, что child возвращает 0.
В `test/open-spans.test.mjs` построить parent-run с атомарными `solidEdges` и
проверить, что Close выбирает parent cm. Отдельно сохранить default-case.
### 10.2. Browser smoke
Расширить ближайший по fixture smoke (`smoke_resize_virtual_thick.mjs` либо
выделенный targeted smoke): три комнаты создают частичный breakpoint на одной
линии, более длинный exact wall-run имеет не-default толщину, средний virtual
span закрывается UI-действием. Проверить persisted cm, визуальное отсутствие
15-см ступени, Undo и отсутствие ошибок. Выбор конкретного файла — техническое
решение реализации.
Golden не обязателен: видимая форма уже покрывается числовой browser-проверкой,
а общий wall renderer не меняется. Если реализация добавит новую визуальную
сцену, её baseline принимается только перед бетой.
### 10.3. Mutation gate
Добавить entry `atomic-child-thickness-parent-fallback`. Мутант отключает новый
exact-parent fallback в `thicknessCmAt()` и возвращает прежний direct-only
результат. Guard — focused unit `wall-thickness` + `open-spans`; на чистом коде
green, на мутанте non-zero.
## 11. Риски и меры
| Риск | Мера |
|---|---|
| Частичная запись снова протекает на весь parent. | Полное покрытие обеих query endpoints и AC3. |
| Overlapping exact rows дают order-dependent cm. | Наиболее узкий span + stable tie, permutation AC4. |
| Scale tolerance работает только в normalized units. | AC2 на `coordScale = 1/NORM_W`. |
| Исправление меняет общий renderer. | Render не использует новый fallback; AC8 и non-scope. |
| Поиск по walls дорог. | Он выполняется только при editor Close; линейный проход сопоставим с текущим lookup и не находится в render tick. |
Security/privacy boundary не меняется.
## 12. Rollback
Frontend pure-правка откатывается вместе с тестами и документацией одним
коммитом. Данные и schema не требуют обратной миграции. Уже выполненный Close
остаётся валидной wall-записью с выбранной толщиной.
## 13. Release-артефакты
- записи в `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` в implementation-коммите
с `User-Visible: yes`;
- обновление `docs/TESTING.md`;
- unit, targeted browser smoke и mutation entry;
- синхронные bundle snapshots;
- отдельные backend, i18n, user-guide, golden, performance и migration
artifacts не нужны;
- полные smoke/golden/performance остаются предрелизным гейтом.
## 14. Принятые предположения
Принято предположительно, поменять свободно при ревью:
1. Fallback реализуется внутри `thicknessCmAt()`, а `lookupWall()` сохраняет
узкий контракт «один key — один stretch».
2. Для containment используется уже действующий scale-relative wall lookup
tolerance, без нового пользовательского порога.
3. Stable tie не меняет persisted rows; он только делает чтение независимым от
порядка.
4. Targeted browser-сценарий расширяет ближайший существующий smoke, если это
не ухудшит его читаемость; иначе создаётся отдельный файл.
+1
View File
@@ -100,6 +100,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#174](https://github.com/Matysh/houseplan-card/issues/174) Связанный виртуальный источник следует реальному контроллеру | [174-linked-virtual-light-controller.md](174-linked-virtual-light-controller.md) |
| [#178](https://github.com/Matysh/houseplan-card/issues/178) Выбор сущности для действия «Переключить состояние» | [178-toggle-entity.md](178-toggle-entity.md) |
| [#197](https://github.com/Matysh/houseplan-card/issues/197) Один junction-патч не гасит кладку всего плана | [197-junction-patch-fail-dark.md](197-junction-patch-fail-dark.md) |
| [#201](https://github.com/Matysh/houseplan-card/issues/201) Наследование толщины для атомарного участка стены | [201-atomic-thickness-lookup.md](201-atomic-thickness-lookup.md) |
| [#186](https://github.com/Matysh/houseplan-card/issues/186) Безопасный остаток стены у торцов партиционного проёма | [186-partition-opening-jamb-margin.md](186-partition-opening-jamb-margin.md) |
## P3