diff --git a/docs/specs/302-junction-node-material.md b/docs/specs/302-junction-node-material.md new file mode 100644 index 00000000..55d4e17b --- /dev/null +++ b/docs/specs/302-junction-node-material.md @@ -0,0 +1,231 @@ +# 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. Детектор дыр как объективный инвариант — в дополнение к скриншотам. + +## 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. Аддитивное построение — единственное правило узла + +Материал узла = union из: + +1. тел лучей (прямоугольные полосы до узла, как сейчас); +2. **углового веера между каждой парой соседних по азимуту лучей**: пересечение + офсетных прямых двух лучей на обращённой друг к другу стороне даёт + mitre-точку; веер — полигон «узел → конец полосы A → mitre → конец полосы B». + Если mitre-точка дальше `MITRE_LIMIT × max(толщин)` от узла или прямые + параллельны — вместо неё хорда между концами полос (bevel). Правило одно и + то же для внешней и внутренней стороны угла и для любого числа лучей. + +**В узловой механике нет ни одной операции `difference`.** Дыра между телами +лучей невозможна по построению: каждый сектор между соседними лучами накрыт +веером. «Лишний» материал ограничен тем же `MITRE_LIMIT`, что и сегодня. + +### 8.3. Спец-случаи + +- Луч нулевой толщины (виртуальный участок, осевой черновик): в веерах не + участвует, соседями по азимуту становятся его соседи (текущее поведение + «zero divider не порождает кладку» сохраняется, docs/specs/172). +- Два коллинеарных луча одной толщины — вырожденный веер (пустой), стык + бесшовный (согласуется с #229). +- Одинокий конец (degree-1) — плоский торец, веера нет (как сейчас). +- Колонна в узле — самостоятельное тело, union поверх (как сейчас). + +### 8.4. Инвариант «нет дыр» — формальный + +Точка p — «внутренняя дыра», если p ∉ материал, и лучи из p в ≥5 из 8 +направлений (шаг 45°) пересекают материал в радиусе `2.2 × gridPitch`. +Детектор: сетка проб шагом `0.2 × gridPitch` в радиусе `5 × gridPitch` от узла. +Ноль внутренних дыр — обязательство для каждого узла каждой сцены сета §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.** Ортогональные стыки одинаковой толщины: попиксельное совпадение с + текущими эталонами. +- **AC4.** Острый угол 57° со стенами 50/70: сплошная кладка, фаска в пределах + `MITRE_LIMIT`. +- **AC5.** Виртуальный участок в узле не порождает кладку; соседние физические + лучи смыкаются веером через него. +- **AC6.** Узловая механика не содержит `difference` — проверяется чтением и + мутантом на возврат вычитающего слоя. +- **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-difference-back` | возврат вычитающего разреза в узел | детектор + AC6 | +| `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 зелёными; порядок — на усмотрение реализации.