Files
houseplan-card/docs/specs/302-junction-node-material.md
T
Codex d8c7652d0e docs: AC6 names the #271 trim as the one subtraction the node keeps (#302 r2 M3)
Код честно держал адресный латеральный трим с самого решения №5, дока после
M1 описывала его верно — расходился только текст ТЗ, писавший «демонтирован
целиком». §4.5, §8.2 и AC6 приведены к фактическому контракту.

Issue: #302
User-Visible: no
2026-08-25 17:34:20 +03:00

262 lines
20 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` — торчащие
углы). Следствия: узловая механика чисто аддитивная, слой
`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 зелёными; порядок — на усмотрение реализации.