mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
374 lines
25 KiB
Markdown
374 lines
25 KiB
Markdown
# 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`
|
||
- Статус ТЗ: редакция r2, повторное ревью зелёное
|
||
|
||
Канонические документы: `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 `d <= R`;
|
||
2. если пересечения нет, оно не конечное либо `d > R`, join содержит две
|
||
штатные offset-точки соседних граней — прямую фаску;
|
||
3. epsilon не расширяет область выбора митры: branch decision всегда разделён
|
||
единственной границей `R`. Допуск `epsilon` используется только при проверке
|
||
и сравнении уже вычисленных floating-point координат;
|
||
4. каждая созданная для этого node join-вершина остаётся не дальше
|
||
`R + epsilon` при численной проверке результата;
|
||
5. локальное соединение имеет ненулевую площадь, node не попадает в hole wall
|
||
body, а тела всех инцидентных rays входят в один связный кладочный компонент;
|
||
6. 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. Риски и меры снижения
|
||
|
||
### R1. Широкий blast radius для существующих T-стыков
|
||
|
||
Новый контракт действует не только на экспорт #249, а на каждый валидный узел
|
||
степени `3+`. Сегодня визуально приемлемая митра в диапазоне `(1.25 × H, 4 × H]`
|
||
станет фаской. Такие T-стыки встречаются в обычных сохранённых планах, поэтому
|
||
изменение может затронуть golden-сценарии, не связанные с #249.
|
||
|
||
**Митигация:** targeted unit/golden не заменяют полный `golden:verify`. Перед
|
||
бетой обязателен полный Linux golden artifact; просматриваются все изменённые
|
||
кадры и diff, а не только новый crop #249. Необъяснимое изменение другого узла
|
||
останавливает baseline acceptance. Существующие двухлучевые expected values
|
||
дополнительно остаются неизменными по AC3.
|
||
|
||
### R2. Фаска может создать щель или ложную кладку
|
||
|
||
Независимое ограничение двух room rings способно оставить pinhole, разорвать
|
||
один ray от узла либо, наоборот, заполнить сектор пола лишним материалом.
|
||
Расхождение особенно опасно для Glow/sun: визуально малый дефект становится
|
||
ложным световым проходом или барьером.
|
||
|
||
**Митигация:** единая node map применяется до boolean union всеми producers;
|
||
AC1/AC2 требуют ненулевой положительной связности с каждым ray, отсутствия hole
|
||
и единой canonical geometry для рендера и occlusion. Отдельный SVG overlay
|
||
запрещён.
|
||
|
||
### R3. Порядок, winding и масштаб могут менять классификацию
|
||
|
||
Shared interval присутствует в профилях двух комнат, а атомарные отрезки могут
|
||
иметь обратное направление. Ошибка дедупликации превратит двухлучевой угол в
|
||
multi-wall node либо даст разные фаски при перестановке данных. Абсолютный
|
||
epsilon может разойтись между normalized и production scale.
|
||
|
||
**Митигация:** канонический interval/ray key, scale-relative tolerance и
|
||
обязательная permutation/winding/`coordScale = 1000` матрица AC2.
|
||
|
||
### R4. Локальная boolean-ошибка может погасить весь structural pass
|
||
|
||
Новая ограничивающая геометрия проходит через `polyclip-ts`; вырожденный patch
|
||
может бросить исключение или дать zero-area polygon.
|
||
|
||
**Митигация:** локальный candidate проверяется до union и отбрасывается
|
||
изолированно по §7.5. Существующий fail-dark контракт обязательных стадий не
|
||
маскируется; regression #197 остаётся зелёной.
|
||
|
||
### R5. Дополнительный topology pass может ухудшить live rendering
|
||
|
||
Повторный поиск всех совпадающих endpoints на каждом HA tick сделал бы стоимость
|
||
видимого bugfix непропорциональной.
|
||
|
||
**Митигация:** карта строится только в cached structural pass с ограничением
|
||
`O(E)`/`O(E log E)` из §8. Targeted smoke проверяет cache parity, а обязательный
|
||
предрелизный performance smoke контролирует тяжёлый дом.
|
||
|
||
## 12. Откат
|
||
|
||
Откатывается pure multi-wall node classification/limit и связанные tests/docs.
|
||
Данные не мигрируются, поэтому отдельный data rollback не нужен. Golden baseline
|
||
откатывается только вместе с соответствующей product geometry.
|
||
|
||
## 13. Принятые технические предположения
|
||
|
||
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; это не отдельный
|
||
визуальный слой.
|