# Issue #302 — материал узла стен: переработка на аддитивную логику - Issue: [#302](https://github.com/Matysh/houseplan-card/issues/302) - Класс изменения: A (продукт) - Размер: обычный (не `small`) — переработка подсистемы - Автор ТЗ: Codex, 2026-08-25 - Touch editor: not exposed — меняется только построение геометрии тел стен, одинаковое при любом способе ввода. ## 1. Сценарий Владелец рисует двор с двумя пристройками: две комнаты делят острую вершину (~57°), стены 15–70 см на клетке 30 см. На каждом втором стыке — белые треугольные клинья и зазубрины. Артефакты возвращаются из релиза в релиз: 29 коммитов с «junction», четыре ТЗ (141, 197, 249, 279) — и всё равно. ## 2. Что человек увидит до и после **До.** Клинья и щели в стыках; вид зависит от числа лучей, углов и толщин непредсказуемо. **После.** Любой узел — сплошная кладка: mitre в пределах лимита, bevel-фаска за ним, без дыр и без «лишнего» материала, одинаково на карте и в статическом рендерере. ## 3. Подтверждённая причина (всё — исполнением на dev `4b8f17b`) 1. **Дыры — в геометрии, не в отрисовке.** На живой карте по `_wallUnionGeometry().d` сеткой проб `isPointInPath(evenodd)`: 154 пробы у стыка не покрыты материалом, будучи окружены кладкой с ≥5 из 8 сторон. 2. **Дыры рождаются в базовой фазе и никем не лечатся.** Чистый конвейер `wallBodiesGeometry` с пофазными снапшотами на репро владельца (5 узлов, сетка проб): | после фазы | дыр | |---|---| | room-rings + edge-bodies | **359** | | + unionJunctionPatches | 359 | | + facade-clip | 359 | | + exterior-shell | 359 | | + bevelMultiWallBody | **360** (bevel добавил дыру) | Отключение `bevelMultiWallBody`, `unionJunctionPatches` и facade-clip по одному ничего не меняет (359–360) — все «ремонтные» слои для этого класса дыр нерелевантны. 3. **Локализация.** Оба кольца комнат строятся и мержатся без исключений (проверено логом), но клинья сидят на внутренних углах колец в окрестностях multi-wall-узлов — там, где `outsetContour`/`insetContour` получают `multiWallNodes` и обрезают угловой материал, а фаски/патчи вниз по конвейеру эти места не накрывают. Визуализация с отмеченными пробами приложена к issue. Архитектурно: конвейер — 10 фаз, смешивающих аддитивные патчи и вычитающие разрезы с try/catch-фолбэками. Пока в узловой механике есть `difference`, гарантии «дыр нет» не существует по построению; каждый точечный фикс двигает баланс add/subtract и рождает регресс в соседней конфигурации — что история и показывает. ## 4. Продуктовые решения владельца (2026-08-25, issue #302) 1. Не следующий точечный фикс, а **переработка узловой механики на простую и надёжную логику**. 2. **Отдельный полноценный сет тестов со скриншотами стыков крупным планом**: разное число лучей, толщины, углы, виртуальные стены и т.д. 3. Детектор дыр как объективный инвариант — в дополнение к скриншотам. 4. ~~Фаска #249 на внешних углах узлов сохраняется~~ — решение середины дня, **отменено решением №5**. 5. **Полный mitre везде** (финальное решение 2026-08-25, по визуальному сравнению трёх вариантов на T-90 и Y-60 50 см): узлы смыкаются как обычное пересечение стен на чертеже, фаска #249 демонтируется целиком. Поводом стали артефакты фаски на не-ортогональных узлах в новых эталонах (`junction-y-60-equal50` — вырез, `junction-acute30-mixed` — торчащие углы). Следствия: узловая механика чисто аддитивная, слой `bevelMultiWallPaper` удаляется, `bevelMultiWallBody` остаётся только адресным латеральным тримом #271 (уточнение при реализации; §8.2), существующие junction-эталоны переснимаются осознанно. ## 5. Цели - Ни одной внутренней дыры ни в одной конфигурации узла из тестового сета и на репро владельца. - Одна механика узла вместо стека ремонтных слоёв; поведение выводимо из короткого контракта §8. - Оба рендерера получают результат из одного и того же кода (уже так — `wallBodiesUnionPath`; сохраняется). ## 6. Scope - `src/wall-thickness.ts`: новая узловая механика; демонтаж заменённых слоёв (`bevelMultiWallBody`/`bevelMultiWallPaper`, вычитающие разрезы узлов, спец-обработка `multiWallNodes` в `outsetContour`/`insetContour` — в той мере, в какой их роль переходит к новой механике). - Новый чистый модуль детектора дыр (переиспользуется тестами). - `demo/golden/matrix.mjs` + фикстура: сет узловых сцен крупным планом. - Юниты, смок, мутанты. ## 7. Не входит - Модель хранения (docs/ADR: полигоны комнат + walls/partitions) — не меняется. - Правило роста ±cm/2 от оси (docs/WALL-THICKNESS.md §2) — не меняется. - Проёмы: вычитающий слот проёма остаётся как есть (это не узловая механика). - Штриховка, цвета, бумага-подложка вне узлов. ## 8. Контракт поведения ### 8.1. Узел Узел — кластер концов лучей (интервалов стен из `wallIntervals`) в допуске `EPS_NODE = openEps × 4` (текущий допуск `buildMultiWallNodeMap` — сохраняется). Луч приходит в узел со своей полу-толщиной; лучи упорядочиваются по азимуту. ### 8.2. Правило узла: аддитивные веера (решение №5) Для каждой пары соседних по азимуту лучей (сектор ≤ 180°; рефлексные секторы — внешняя сторона выпуклого угла — пропускаются): **Веер (аддитивно):** полигон «узел → край полосы A → mitre → край полосы B», где mitre — пересечение обращённых в сектор офсетных прямых. Границы: - mitre принимается, пока он в пределах классического `MITRE_LIMIT × max(толщин пары)` — то же правило, что у обычных углов комнат; - и пока он не дальше конца ТОЛСТОГО саппорта каждого луча — иначе веер нарисовал бы латеральный фантом рядом с тонким продолжением (#271); - иначе — bevel-хорда: по каждому краю до `min(длина толстого саппорта, √(limit² − half²))`, замыкание хордой. Дополнительно узел аддитивно получает точные саппорт-квады своих лучей (каждый ограничен собственной конечной длиной). Все куски клиппуются гладкой фасадной границей `junctionNodeBound` — узел не растит новый фасад на вогнутой вершине. **Единственное вычитание узловой механики — адресный латеральный трим #271**: `bevelMultiWallBody` вызывается только для узлов с вырожденно-коротким толстым саппортом (короче собственной толщины), где базовые контуры красят лишнюю ширину, которую аддитивно не убрать; его угловые следы на таких узлах перекрываются веерами. На всех остальных узлах механика чисто аддитивная: слой фаски #249 как ВИД демонтирован целиком, дыра между полосами невозможна по построению, «лишний» материал ограничен `MITRE_LIMIT` и фасадной границей. ### 8.3. Спец-случаи - Луч нулевой толщины (виртуальный участок, осевой черновик): в веерах не участвует, соседями по азимуту становятся его соседи (текущее поведение «zero divider не порождает кладку» сохраняется, docs/specs/172). - Два коллинеарных луча одной толщины — вырожденный веер (пустой), стык бесшовный (согласуется с #229). - Одинокий конец (degree-1) — плоский торец, веера нет (как сейчас). - Колонна в узле — самостоятельное тело, union поверх (как сейчас). ### 8.4. Инвариант «нет дыр» — формальный Уточнение по факту реализации (первая формулировка «окружено кладкой с ≥5 из 8 сторон» ложно срабатывала на легитимном полу комнаты в острых внутренних углах — за inset-mitre): детектор проверяет **контрактное покрытие**. Проба p — «дыра», если p ∈ (полоса какого-либо луча узла ∪ веер узла) и p ∉ тело. Сетка проб шагом `0.2 × gridPitch` в радиусе `MITRE_LIMIT × halfDepth + halfDepth` от узла; принадлежность полосе — строго внутри квада (0 ≤ t ≤ длина, |перпендикуляр| < half − ε). Ноль дыр — обязательство для каждого узла каждой сцены сета §13. ### 8.5. Что не меняется наружно - Форма прямых участков, торцы, проёмные тоннели, бумага по контурам комнат. - Ортогональные L/T/X-стыки одинаковой толщины обязаны совпасть с текущим видом попиксельно (golden), кроме сцен, где сегодня есть дефекты. ## 9. Данные, i18n, a11y, privacy, security Конфиг не меняется, миграций нет, строк интерфейса нет. Приватность/безопасность не затрагиваются. ## 10. Performance Замена вычитающих фаз на аддитивные веера уменьшает число булевых операций на узел. Бюджет: `smoke_render_perf` не хуже базовой линии; large-house фикстура — без деградации, замер в отчёте на код-ревью. ## 11. Риски 1. **Golden-переснятие.** Существующие сцены стыков изменятся законно там, где сегодня дефект. Каждое расхождение разбирается поимённо; принятие — только `golden:accept -- --reviewed` отдельным коммитом с `Release:` + `Baseline-Reviewed:` (урок #230). Сцены, не связанные с узлами, обязаны совпасть побайтно. 2. **Демонтаж слоёв.** Убирая `bevelMultiWallBody` и вычитающие разрезы, можно потерять их полезную функцию для конфигураций вне сета. Ответ — широта сета §13 и обязательный прогон всех существующих junction-смоков и golden. 3. **Незамеченный «лишний» материал.** Аддитивные веера могут закрыть то, что раньше было честной щелью (например, два независимых узла рядом). Допуск `EPS_NODE` не расширяется, веера строятся только между лучами ОДНОГО узла. ## 12. Acceptance criteria - **AC1.** Детектор §8.4: ноль внутренних дыр на каждом узле каждой сцены сета §13. - **AC2.** Репро владельца (5 узлов): ноль внутренних дыр; визуально клинья исчезли (golden-сцена репро). - **AC3.** Сцены, не связанные с узлами ≥3 лучей, совпадают с текущими эталонами попиксельно. Junction-сцены меняются законно (решение №5) и переснимаются осознанно с поимённым разбором. - **AC4.** Острый угол 57° со стенами 50/70: сплошная кладка (перекрытие полос не срезается), свободная часть клина срезана по лимиту узла. - **AC5.** Виртуальный участок в узле не порождает кладку; соседние физические лучи смыкаются веером через него. - **AC6.** Единственное вычитание узловой механики — адресный трим #271 на узлах с вырожденно-коротким толстым саппортом; все прочие узлы аддитивны (фаска #249 как вид демонтирована). Мутант возвращает лимит веера к 1.25·h — юнит формы веера обязан покраснеть (вырез Y-60 из отчёта владельца). - **AC7.** Оба рендерера дают идентичную геометрию узла (один вызов `wallBodiesUnionPath`; смок сверяет пути). - **AC8.** Перф: `smoke_render_perf` в бюджете; large-house без деградации. - **AC9.** Существующие смоки стыков (`wall_junctions`, `junction_patch_resilience`, `split_corner_wall`, `zero_divider_taper`, `wall_thickness*`) зелёные. ## 13. Тестовый сет (golden крупным планом + детектор) Каждая сцена: узел занимает весь кадр; тёмная тема; для каждой сцены детектор §8.4 по всем узлам. Матрица: | группа | сцены | |---|---| | число лучей | L (2), T (3), X (4), звезда (5) | | толщины | равные 15; смешанные 15/50, 50/70, 15/70 | | углы | 90°, 60°, 45°, 30°, 15°, 170° | | виртуальные | физический+виртуальный в T; виртуальный сквозь X | | комнаты | вершина комнаты + перегородка; T в середину стены комнаты; общая острая вершина двух комнат (репро) | | прочее | колонна в узле; конец черновика; перекрёсток двух перегородок | Комбинации не декартовы — ~20 сцен, отобранных по одному представителю на класс, плюс сцена-репро из issue. Юниты: чистые функции веера (mitre/bevel, пороги, вырожденные случаи) и детектора. ## 14. Мутационный гейт | id | Что ломает | Гвард | |---|---|---| | `node-fan-disabled` | веера не строятся вовсе | детектор на сете | | `node-fan-outer-only` | веер только с внешней стороны | детектор | | `node-fan-ignores-mitre-limit` | mitre без лимита | юниты веера | | `node-fan-includes-zero-ray` | нулевой луч порождает кладку | юниты + смок | | `node-fan-limit-back-to-249` | лимит веера снова 1.25·h — вырез Y-60 | юниты формы веера | | `hole-detector-blind` | детектор всегда зелёный | самопроверка детектора на заведомо дырявой фикстуре | Все юнит-гварды — с пересборкой `test-build` (урок #230/#235). ## 15. Release-артефакты `User-Visible: yes`: обе редакции CHANGELOG; `docs/WALL-THICKNESS.md` §3 и §9 переписываются под новую механику; строка в USER-GUIDE при необходимости. Golden-переснятие — отдельным коммитом по правилу §11.1. ## 16. Откат Один revert продуктового коммита + revert коммита эталонов. Конфиг не меняется, миграций нет. ## 17. Принятые предположения (техническое, менять свободно) - Константы детектора (§8.4) выбраны по факту воспроизведения; могут быть уточнены, но только в сторону строгости. - Демонтаж старых слоёв допустимо вести поэтапно (веера поверх текущей базы → снятие bevel-слоя → снятие узловой спец-обработки контуров), если каждый шаг держит AC1–AC9 зелёными; порядок — на усмотрение реализации.