From 8d2e00bbbfbb9151ca2079eaf6e51f916cb59eff Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sun, 23 Aug 2026 05:07:48 +0300 Subject: [PATCH] docs(spec): define bounded multi-wall junctions Issue: #249 User-Visible: no --- docs/specs/249-multiwall-junction-bevel.md | 314 +++++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 docs/specs/249-multiwall-junction-bevel.md diff --git a/docs/specs/249-multiwall-junction-bevel.md b/docs/specs/249-multiwall-junction-bevel.md new file mode 100644 index 00000000..0e2d5c9a --- /dev/null +++ b/docs/specs/249-multiwall-junction-bevel.md @@ -0,0 +1,314 @@ +# Issue #249 — ограниченная геометрия узла из трёх и более стен + +- Дата: 2026-08-23 +- Тип: bug + polish · приоритет P2 +- Ценность: пользовательская 7/10 · для разработки 8/10 +- Сложность/риск: 5/10 и 6/10 +- Issue: [#249](https://github.com/Matysh/houseplan-card/issues/249) +- Ветка: `issue/249-multiwall-junction-bevel` +- Статус ТЗ: первая редакция, ожидает ревью + +Канонические документы: `docs/SCOPE.md`, `docs/WALL-THICKNESS.md`, +`docs/ARCHITECTURE.md`, `docs/TOUCH-SUPPORT.md`, `docs/TESTING.md`. + +## 1. Сценарий и персона + +Администратор рисует обычную пристройку или несколько комнат, сходящихся в +одной точке. У трёх стен могут отличаться толщины. В View, kiosk и Static он +видит не единый кладочный узел, а узкий клин, который выступает наружу на +десятки сантиметров. Контуры пола рядом визуально наезжают друг на друга, хотя +сохранённые оси стен и комнаты корректны. + +Это нарушает J1 из `docs/SCOPE.md`: план перестаёт давать достоверную +пространственную картину дома. Дефект постоянно виден, а не ограничен +редактором. + +## 2. Воспроизведение и измеренный дефект + +Канонический regression fixture минимизируется из приложенного к issue экспорта +`houseplan-space-space-2026-08-22_21-33-04.json` (`card_version +1.67.0-beta.2`, `model_version 6`, `cell_cm = 30`). В узле +`(0.329166667, 0.141666667)` сходятся три положительных физических интервала: + +| Луч от узла | Второй конец | Толщина | Направление | +|---|---|---:|---:| +| A | `(0.308333333, 0.237500000)` | 50 см | 102.3° | +| B | `(0.408333333, 0.100000000)` | 50 см | −27.8° | +| C | `(0.379166667, 0.191666667)` | 70 см | 45.0° | + +Углы между соседними лучами — 57.3°, 72.8° и 130.0°. Максимальная +полутолщина `H = 4.8611` render units. Текущий объединённый wall body содержит +join-вершину на расстоянии `8.7312`, то есть `1.80 × H`, и рисует видимый зубец. + +Причина подтверждена production-вызовами `spaceModels()` и +`wallBodiesGeometry()`: две комнаты независимо строят митры пары 50/70 см через +`outsetContour()`/`insetContour()`. Обе митры проходят общий +`MITRE_LIMIT = 4`, после чего boolean union сохраняет противоположные выступы. +`junctionAt()` и `spaceMergeGeometry()` этот узел не формируют. + +## 3. Что человек увидит до и после + +**До:** штриховка у многолучевого узла выходит клином далеко за физическую +толщину стен; рядом видны наложенные контуры комнат. + +**После:** те же сохранённые оси образуют один заполненный узел. Корректные +короткие митры остаются острыми, а только чрезмерная join-вершина заменяется +прямой локальной фаской. В узле нет щели, округления или выступа за установленный +габарит. Обычные углы ровно из двух лучей выглядят как раньше. + +## 4. Решения владельца + +1. Fallback чрезмерной митры — **прямая локальная фаска**. Она образует единый + заполненный узел; округление не добавляется. +2. В узле из `3+` различных физических лучей допустимый радиус join-вершины: + `R = 1.25 × H`, где `H` — максимальная полутолщина всех сходящихся стен. +3. Правило применяется независимо от числа комнат и от того, равны ли толщины. +4. Узлы ровно из двух лучей сохраняют `MITRE_LIMIT = 4` и существующий вид. +5. Исправляется одна каноническая вычисляемая геометрия для Full, Static, + hidden Iso и световых барьеров. Persisted rooms/walls не переписываются. + +## 5. Scope + +### Входит + +- классификация физических узлов по уникальным инцидентным лучам; +- общий лимит `1.25 × max half-depth` для join-геометрии узлов степени `3+`; +- ограниченная митра либо прямая фаска в room inset/outset и в обязательных + локальных junction pieces; +- согласованная каноническая кладка, clean-floor/room-fill границы, бумага, + Full/Static/hidden-Iso и Glow/sun occlusion; +- unit-набор на 3/4 луча, разные толщины, порядок, winding и `coordScale`; +- browser smoke и golden-сценарий на минимизированном экспорте владельца; +- обновление `docs/WALL-THICKNESS.md`, `docs/ARCHITECTURE.md`, + `docs/TESTING.md` и двух changelog. + +### Не входит + +- изменение сохранённых координат комнат, `walls`, partitions или drafts; +- привязка узлов к сетке и Optimize-миграция старых планов; +- изменение толщины стен либо правил shared/outer/virtual interval; +- новый редакторский инструмент, настройка коэффициента в UI или i18n; +- округлённые joins, отделочные слои и отдельный материал узла; +- изменение двухлучевых острых/тупых углов; +- исправление независимой partition-топологии, уже принадлежащей + `linearWallJoinPatches()`. + +## 6. Термины и классификация узла + +`physical interval` — атомарный room-wall interval с положительной толщиной, +который не является virtual/open. Его полутолщина уже переведена в render units +существующим `wallCmToUnits()`. + +`node` — группа совпадающих концов физических интервалов в существующей +scale-relative геометрической погрешности (`openEps(pitch, coordScale)` либо +единый эквивалент подсистемы). Координаты не округляются к drawing grid. + +`ray` — направление от node к противоположному концу interval: + +- повтор одного физического shared interval от второй комнаты удаляется по + каноническому interval key; +- совпадающие сонаправленные атомарные фрагменты считаются одним лучом и берут + максимальную физическую полутолщину; +- противоположные направления прямой стены являются двумя разными лучами; +- zero-thickness и virtual intervals не увеличивают степень узла. + +`multi-wall node` — node с тремя или более различными rays. Для него +`H = max(ray.halfDepth)`, `R = 1.25 × H`. Если конечные значения и положительный +`H` получить нельзя, локальный limiter не создаёт новую геометрию и не должен +ронять весь structural pass. + +`join vertex` — точка пересечения смещённых граней либо точка локального +junction patch, созданная именно для соединения интервалов в node. Дальние +концы самих стен не являются join vertices и радиусом `R` не ограничиваются. + +## 7. Геометрический контракт + +### 7.1 Единая карта узлов + +Один pure structural pass строит детерминированную карту multi-wall nodes из +эффективных `wallIntervals()`. Карта не зависит от порядка rooms/walls, +направления сохранённого отрезка или winding комнаты. Она строится один раз для +текущей геометрии и передаётся всем contour/junction вычислениям; отдельные +рендереры не распознают узел повторно. + +### 7.2 Ограничение join + +Когда вершина room profile совпадает с multi-wall node: + +1. существующее пересечение смещённых граней остаётся митрой, если его + расстояние от node `<= R + epsilon`; +2. если пересечения нет, оно не конечное либо дальше `R`, join содержит две + штатные offset-точки соседних граней — прямую фаску; +3. каждая созданная для этого node join-вершина остаётся не дальше `R + epsilon`; +4. локальное соединение имеет ненулевую площадь, node не попадает в hole wall + body, а тела всех инцидентных rays входят в один связный кладочный компонент; +5. boolean union не вправе вернуть в body отброшенную дальнюю вершину из другого + независимого room ring. Все ring/piece producers получают одну карту и лимит + до объединения. + +Offset-точка фаски находится на расстоянии собственной полутолщины, поэтому +для валидного interval автоматически укладывается в `H <= R`. Круг либо +аппроксимация окружности не строятся. + +### 7.3 Двухлучевая совместимость + +Для node степени `1` или `2` сигнатуры и результат `insetContour()`, +`outsetContour()` и существующих bounded junction patches не меняются: +`MITRE_LIMIT = 4` остаётся единственным лимитом. Новый коэффициент не становится +глобальной заменой `MITRE_LIMIT`. + +### 7.4 Канонические потребители + +Исправленная structural geometry является общей для: + +- wall body/hatch в Full, kiosk и Static; +- Plan preview сохранённой геометрии; +- hidden isometric masonry; +- clean floor, room fills и room hover, использующих внутреннюю грань; +- paper/exterior shell там, где multi-wall node принадлежит внешней границе; +- Glow/sun/light source occlusion. + +Существующие structural caches продолжают ключеваться полной геометрией. +Курсор, hover и HA state tick не перестраивают topology. Исправление не вводит +расходящиеся SVG-only и physics-only patches. + +### 7.5 Ошибки и fallback + +- Валидный multi-wall node не может молча вернуться к дальнему + `MITRE_LIMIT = 4` join: если точная митра не укладывается в `R`, результатом + служит фаска. +- Неконечный или вырожденный локальный candidate отбрасывается изолированно; + он не превращает успешную остальную кладку, paper или light barriers в + `null`/пустой план. +- Существующий fail-dark контракт обязательного structural pass сохраняется: + исправление не должно маскировать независимую ошибку exterior/body/opening. + +## 8. Данные, совместимость и производительность + +- Схема и `model_version` не меняются; config/layout остаются byte-for-byte. +- Результат вычисляется при чтении и сразу исправляет старые планы без Save. +- Backend и WebSocket не меняются. +- Нет новых i18n, настроек, действий, жестов либо различий mouse/touch. +- Построение node map ограничено structural geometry pass; допускается + линейная либо `O(E log E)` группировка. Новый полный `O(E²)` обход на каждый + render/state tick запрещён. + +## 9. Acceptance criteria и доказательства + +### AC1. Экспорт владельца больше не даёт шип + +Минимизированный fixture хранит только необходимые rooms, wall entries и +scale-параметры из экспорта. Для node `(0.329166667, 0.141666667)`: + +- `wallBodiesGeometry()` возвращает успешную непустую геометрию; +- `H = 4.8611`, и каждая join-вершина node находится не дальше + `1.25 × H + epsilon`; +- node не лежит в hole, локальный wall body имеет ненулевую площадь и один + компонент касается тел всех трёх rays; +- повторный расчёт даёт тот же нормализованный geometry/path. + +**Доказательство:** unit в `test/wall-thickness.test.mjs` и новый fixture +`test/fixtures/249-multiwall-junction.json`. + +### AC2. Матрица многолучевых узлов + +Те же инварианты выполняются минимум для: + +- трёх стен одинаковой толщины; +- трёх стен 15/50/70 см; +- четырёх стен с равными и разными толщинами; +- перестановки rooms/walls, обратного направления intervals, обоих windings и + production `coordScale = 1000`. + +Ни один вариант не создаёт gap, self-intersection, `null` либо второй локальный +кладочный компонент. + +**Доказательство:** table-driven unit с численным bound и сравнением +нормализованной геометрии. + +### AC3. Два луча не меняются + +Ровно две стены одинаковой толщины под углами fixture #249 и существующие +acute/obtuse contour cases возвращают прежние точки/paths и продолжают +использовать `MITRE_LIMIT = 4`. Новый limiter не превращает корректную митру в +фаску. + +**Доказательство:** unit regression на `insetContour()`/`outsetContour()` и +существующий wall-thickness unit-набор без обновления его двухлучевых expected +values. + +### AC4. Все поверхности используют один body + +На fixture #249 Full/View, Plan с сохранённым контуром, kiosk, Static, hidden +Iso и light occlusion получают одну исправленную physical geometry: клина нет, +а луч света не проходит через заполненный узел. Переключение темы и HA state не +меняет structural path и не вызывает его пересчёт. + +**Доказательство:** targeted browser smoke +`demo/smoke_multiwall_junction.mjs` с path/geometry assertions и cache parity. + +### AC5. Golden фиксирует видимый результат + +В golden matrix добавлен отдельный crop узла из минимизированного экспорта: +видны три луча, фаска и отсутствие щели. Сценарий имеет semantic assertions на +число rays и ограниченный габарит, поэтому пустой или неверно кадрированный PNG +не может пройти. + +**Доказательство:** `demo/golden/matrix.mjs`, harness/fixture и +`test/golden-matrix.test.mjs`. Новый baseline принимается только по полному +Linux CI artifact командой `npm run golden:accept -- --reviewed` перед бетой. + +### AC6. Данные и смежные контракты не меняются + +Расчёт не мутирует rooms/walls/open spans, не меняет schema/model version и не +затрагивает independent partition junctions, openings или двухлучевые внешние +углы. + +**Доказательство:** deep-equality unit входа до/после и зелёные существующие +wall/opening/junction tests. + +### AC7. Гейты реализации + +В цикле реализации зелёные: + +- `npm run typecheck`; +- `npm test`; +- `npm run build`; +- targeted `node demo/smoke_multiwall_junction.mjs` после копирования свежего + bundle в demo. + +Полный smoke, golden и performance запускаются перед бетой; полный backend +HA-harness не требуется, если Python/backend не меняются, но общий exact-SHA +Validate остаётся обязательным. + +## 10. Документация и changelog + +- `docs/WALL-THICKNESS.md`: степень node, `R = 1.25 × H`, фаска и сохранение + `MITRE_LIMIT = 4` для двух лучей; +- `docs/ARCHITECTURE.md`: единая node map и потребители canonical masonry; +- `docs/TESTING.md`: unit/smoke/golden evidence #249; +- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`: пользовательский bugfix в том + же коммите, что продуктовая правка. + +## 11. Откат + +Откатывается pure multi-wall node classification/limit и связанные tests/docs. +Данные не мигрируются, поэтому отдельный data rollback не нужен. Golden baseline +откатывается только вместе с соответствующей product geometry. + +## 12. Принятые технические предположения + +1. Коэффициент `1.25` — именованная константа канонической wall geometry, а не + настройка и не литерал в рендерерах. +2. Shared interval, встреченный из двух room profiles, является одним + физическим лучом; иначе обычный T ошибочно классифицировался бы по числу + владельцев. +3. В regression fixture не переносится остальной пользовательский экспорт: + сохраняется минимальная обезличенная геометрия, достаточная для численного и + визуального воспроизведения. +4. `epsilon` берётся из существующей scale-relative геометрической политики и + не ослабляется до шага drawing grid. +5. Если реализация требует отдельного bounded fill patch для положительной + связности, он остаётся частью canonical structural geometry, целиком лежит в + радиусе `R` и проходит тот же детерминизм/failure isolation; это не отдельный + визуальный слой.