# 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, если это не ухудшит его читаемость; иначе создаётся отдельный файл.