Files
houseplan-card/docs/specs/302-junction-node-material.md
T
Codex aac501516e feat: full mitre at every node — the #249 chamfer retires (#302, decision #5)
Владелец, осмотрев первые эталоны сета, отменил дневное решение о сохранении
фаски: `junction-y-60-equal50` показывал вырез, `junction-acute30-mixed` —
торчащие углы. По визуальному сравнению трёх вариантов принято: узлы
смыкаются полным mitre, как обычное пересечение стен на чертеже.

Итоговое правило веера (одно на все случаи):

- mitre принимается, когда он В СЕКТОРЕ пары (вперёд по лучам для обычной
  пары, назад — для рефлексной: наружный угол между крайними лучами, где и
  жил вырез Y-60), в пределах классического `MITRE_LIMIT` и не дальше конца
  толстого саппорта (#271);
- рефлекс без валидного mitre замыкается плоской хордой между гранями;
- обычная пара без mitre — локальный бевел: ход по граням ограничен толстым
  саппортом, лимитом и двойной толщиной пары, чтобы хорда осталась деталью
  угла. Гигантские бевел-«бабочки» и mitre вне сектора — две реальные ошибки
  промежуточных версий, обе пойманы на сценах сета до пуша.

Слой `bevelMultiWallBody` сохранён только как АДРЕСНЫЙ латеральный трим для
узлов с вырожденно-коротким толстым саппортом (#271); все прочие узлы — чисто
аддитивные, следы трима на них исчезли. `bevelMultiWallPaper` из бумаги
удалён. Обе записи CHANGELOG приведены к финальному контракту.

Тесты: юниты §302 усилены; площадь фикстуры #197 +0.6 юнита²; мутанты
переякорены, краснота каждого проверена исполнением.

Issue: #302
User-Visible: yes
2026-08-25 17:34:19 +03:00

255 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` — торчащие
углы). Следствия: узловая механика чисто аддитивная, слой
`bevelMultiWallBody`/`bevelMultiWallPaper` удаляется, существующие
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` — узел не растит новый фасад на
вогнутой вершине.
**В узловой механике нет ни одной операции `difference`** (вычитающий слой
фаски демонтирован): дыра между полосами невозможна по построению, «лишний»
материал ограничен `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.** Узловая механика не содержит `difference`: слой фаски демонтирован,
веера и саппорты только добавляют. Мутант возвращает лимит веера к 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 зелёными; порядок — на усмотрение реализации.