# Issue #275 — multi-wall bevel не вырезает реальные полосы стен - Дата: 2026-08-24 - Тип: regression bug · приоритет P1 - Оценка: пользовательская ценность 10/10 · ценность для разработки 10/10 · сложность 8/10 · риск 9/10 - Issue: [#275](https://github.com/Matysh/houseplan-card/issues/275) - Ветка: `issue/275-multiwall-strip-containment` - Статус ТЗ: редакция r3 после spec review r2, готово к ревью Канонические документы: `docs/SCOPE.md`, `docs/ARCHITECTURE.md`, `docs/WALL-THICKNESS.md`, `docs/USER-GUIDE.ru.md` и `docs/TESTING.md`. Связанные задачи: [#249](https://github.com/Matysh/houseplan-card/issues/249), [#261](https://github.com/Matysh/houseplan-card/issues/261), [#270](https://github.com/Matysh/houseplan-card/issues/270), [#271](https://github.com/Matysh/houseplan-card/issues/271), [#272](https://github.com/Matysh/houseplan-card/issues/272) и [#273](https://github.com/Matysh/houseplan-card/issues/273). ## 1. Сценарий и персона Администратор обновляет карточку до beta.6, нажимает Optimize и открывает два обычных жилых плана. В прямоугольных T-стыках остаются белые треугольники, часть стены выглядит тоньше заданной, а в одном смешанном по толщине стыке видна крупная белая выемка. Оси и узлы редактора при этом показывают непрерывные конечные отрезки. Это нарушает J1 и J6 из `docs/SCOPE.md`: главный план показывает отсутствующий проём, а masonry, paper, floor и light получают одну и ту же неверную вычисляемую геометрию. Дефект выпущен после задач #271–#273, поэтому исправление и тесты обязаны доказываться на тех же полных входах, а не только на похожей синтетике. ## 2. Подтверждённое воспроизведение Владелец повторно прислал два plan-only backup после beta.6: | Вход | Масштаб | Rooms / walls | Degree-3+ nodes | Nodes с потерей incident strip | |---|---:|---:|---:|---:| | `1.json` | `cell_cm: 5` | 8 / 22 | 12 | 4 | | `2.json` | `cell_cm: 1` | 8 / 25 | 14 | 8 | Оба файла прогнаны цепочкой production pure-функций: `spaceModels()` → `wallIntervals()` → `buildMultiWallNodeMap()` → `wallBodiesGeometry()`. В локальном окне каждого node семплируется union его конечных положительных ray-support strips. Beta.6 возвращает точки, которые лежат внутри сохранённой strip, но отсутствуют в `roomGeom`. Измеренные примеры: - `1`, `(204.166667, 645.833333)`: 391 потерянный sample на локальной сетке; - `2`, `(-354.166667, 2087.5)`: 242 sample — крупный видимый провал; - `2`, `(-354.166667, 2287.5)`, `(1058.333333, 2287.5)`, `(2404.166667, 1245.833333)` и `(2404.166667, 2308.333333)`: устойчивые треугольные вырезы внутри реальных полос. Повторный Optimize разделяет источник дефекта: - для `2.json` результат законно `changed: false`; - для `1.json` канонизируются storage-хвосты, но сохраняются те же четыре renderer-нарушения; - сериализация/reload не меняют число нарушений. Persisted оси и wall intervals валидны. Optimize не должен угадывать новую толщину либо удалять координаты; исправляется вычисляемая renderer geometry. Полные backups, layout и названия комнат приватны и в Git не добавляются. Локальный exact-input gate использует файлы владельца вне репозитория, а в Git попадают только минимизированные анонимные topology fixtures. ## 3. Подтверждённая причина и почему прошли тесты beta.6 #249 ввела bounded multi-wall bevel. Когда pairwise пересечение смещённых граней дальше `R = 1.25 × H`, `multiWallBevelCutsAt()` строит треугольный cut. #272 заметила замкнутые белые полости, но приняла слишком слабый инвариант: `enclosedHoles === 0`. Реализация добавила к cut corridor, связывающий его с внешней пустотой. Замкнутая белая полость стала открытой белой выемкой, поэтому semantic inventory и golden стали зелёными, хотя пользовательский дефект остался видимым. `bevelMultiWallBody()` затем: 1. вычитает полный pairwise cut из исходной room-ring masonry; 2. восстанавливает union прямоугольников конечных ray supports; 3. снова вычитает pairwise cut уже из этого физического union. В обычном прямоугольном T пересечение offset faces находится на расстоянии `sqrt(hA² + hB²)`. Для равных толщин это `1.414 × H > 1.25 × H`, хотя весь треугольник лежит внутри пересечения двух настоящих полос. Безусловное вычитание поэтому удаляет не mitre-spike, а сохранённый wall material. Смешанные толщины дают тот же класс ошибки большей площади. Тесты #272 проверяли только связность пустоты, single retained/discarded probes и синтетические cases. Code review прямо не сверял cases с приватными экспортами и не делал ручной UI-check. Ни один gate не проверял отношение сохранение прямоугольного T/X overlap, поэтому релизный escape был возможен. После зелёного spec review r1 автор исполнил буквальную формулу `incident strip union ⊆ canonical masonry` на неортогональном fixture #249. Она вернула в masonry уже запрещённый `discardedWedgeProbe` `(330.3808442725, 148.8560107825)`: точка лежит внутри двух raw incident rectangles, хотя утверждённая владельцем локальная фаска обязана оставить её пустой. Поэтому общий для всех multi-wall nodes strip-containment неверен. Новые backups показывают другой, более узкий класс: все 12 измеренных потерь лежат в T/X nodes, где каждая потерянная ray входит хотя бы в одну перпендикулярную пару. У такого pair raw rectangular strips и их overlap есть правильная кладка; pairwise bevel не решает spike, а создаёт выемку. Неортогональный узел #249 не содержит ни одной перпендикулярной пары и сохраняет прежний bounded-bevel contract. Spec review r2 указал на смешанный node: две перпендикулярные walls плюс диагональная третья. Классификация node целиком оставила бы старому cut право вырезать ортогональные strips. Поэтому r3 защищает rays на уровне пар и вычитает из bevel cuts union всех finite strips, участвующих хотя бы в одной перпендикулярной паре. ## 4. Что человек увидит до и после **До:** на месте непрерывного T/X-стыка белый уголок либо крупная выемка; толщина входящего отрезка визуально уменьшается, хотя editor overlay показывает полную ось. **После:** каждая конечная wall, у которой в node есть перпендикулярная wall, сохраняет заданную полную толщину до node. Это верно и для чистого T/X, и для смешанного узла с дополнительной диагональю. В node без перпендикулярных пар прежняя прямая фаска #249 по-прежнему удаляет внешний mitre-spike и не выходит за `R`. Optimize валидного плана может остаться no-op. Raw, Preview, Apply, reload, Plan/View/Static/Iso и световая модель показывают одну форму. ## 5. Scope ### Входит - каноническая классификация перпендикулярных ray pairs внутри multi-wall node; - защита полного union положительных finite strips каждого ray, участвующего хотя бы в одной перпендикулярной паре; - смешанные orthogonal+diagonal nodes и неизменность узла #249 без перпендикулярных пар; - T/X, mixed-node и regression fixture #249, равные/смешанные толщины, короткие rays, `cell_cm: 1/5/30`; - `roomGeom`, final masonry, paper, clean floor/fill/hover, Plan/View/kiosk, Static, hidden Iso и light/sun barriers; - минимизированные fixtures из обоих новых backups, exact-input local smoke, production-bundle browser smoke, semantic raster crops и mutation; - документация, changelog и корректировка прежнего hole-only testing contract. ### Не входит - изменение persisted rooms/walls/partitions/columns/openings/layout; - новая эвристика Optimize, изменение wall thickness либо удаление валидного micro-dogleg; - отмена finite supports #271; - возврат неограниченного mitre-spike или изменение коэффициента `R = 1.25H`; - новый UI, настройка, schema/model version, backend либо i18n; - принятие golden baseline без просмотра exact-input crops. ## 6. Канонический геометрический контракт ### 6.1 Классификация перпендикулярных ray pairs Для canonical multi-wall node берутся направления его положительных finite rays после действующей дедупликации. Два rays образуют `orthogonal pair`, если нормализованный модуль scalar product `|dot|` не превышает единого angle-epsilon около нуля. Параллельные/противоположные rays не образуют такую пару; pairwise bevel и так не строит cut для разворота на 180°. `protected ray` — ray, участвующий хотя бы в одной orthogonal pair этого node. Чистый T/X защищает все свои rays. Смешанный node защищает ортогональные rays, но не объявляет диагональный ray защищённым, если у него самого нет перпендикулярного партнёра. Классификация не зависит от winding, reversed endpoints, room/wall order и `coordScale`. Произвольно близкий, но физически диагональный луч не округляется к 90°: epsilon компенсирует только storage/normalization noise и покрывается пограничными негативными тестами. ### 6.2 Protected incident strips Для каждого canonical multi-wall node и каждого `ray.support` определяется конечная полоса: - ось начинается в `node.point`; - продольный параметр `t ∈ [0, support.length]`; - поперечное расстояние `≤ support.halfDepth`; - учитывается только положительный, finite, не-open support; - duplicate room owners схлопываются действующей детерминированной картой. `requiredOrthogonalStripUnion(node)` — boolean union полос только protected rays в действующей node mask. Он является физическим минимумом masonry до применения явных opening cuts. Если orthogonal pairs нет, union пуст и #275 не меняет действующий bevel этого node. ### 6.3 Orthogonal-strip containment До opening cuts должно выполняться: ```text requiredOrthogonalStripUnion(node) − roomGeom = ∅ ``` с scale-relative boolean tolerance, существенно меньшим физической/экранной точности. После opening cuts допускается только площадь, принадлежащая подтверждённому opening slot. Pairwise bevel, room-ring winding и paper repair не имеют права удалять остальную часть incident strip. Суммарный bevel cut одного node ограничивается множеством: ```text effectiveCut = pairwiseCuts − requiredOrthogonalStripUnion(node) ``` либо технически эквивалентной операцией. Недостаточно пропустить cut ровно между двумя перпендикулярными соседними rays: cut другой, диагональной пары того же mixed-node также не вправе удалить protected strip. После subtraction локальная реконструкция повторно объединяет protected union как fail-safe от boolean order/rounding loss. Containment проверяется площадью vector difference и устойчивыми interior samples. Одной точки node, числа polygon holes либо connected-component классификации пустоты недостаточно. ### 6.4 Bounded bevel вне protected strips Для ray/cut area, не попавшей в protected union, действующий контракт #249 не расширяется и не заменяется strip-containment: - pairwise offset intersections за `R` по-прежнему дают прямую фаску; - `discardedWedgeProbe` остаётся пустым, даже если он попадает в overlap raw endpoint rectangles; - retained wedge #261, node connectivity и finite support bounds #271 сохраняются; - fixture #249 не имеет orthogonal pairs, поэтому исправление #275 не меняет его форму, площадь либо path сверх неизбежного floating-point normalization. Так явно разрешаются оба найденных конфликта: общий union всех strips не возвращает клин #249, а node-level classification не оставляет ортогональную часть mixed-node без защиты. ### 6.5 Paper, floor и physics Paper покрывает room centre и canonical masonry. Он не может ни скрывать отсутствующую masonry белой подложкой, ни повторно вырезать protected strip. Clean floor не присваивает эту площадь комнате. Light/sun barriers, hidden Iso и SVG используют один corrected structural result, без surface-only patch. ### 6.6 Openings и independent bodies Room opening cuts выполняются после orthogonal-strip-safe reconstruction по существующей association. Independent partitions/drafts/columns объединяются в существующем порядке. Тест отличает разрешённый opening slot от bevel-loss и не объявляет любой белый sample допустимым только из-за близкого opening. ### 6.7 Failure isolation и determinism Неуспешный optional local cut не гасит весь план. Failure fallback не может молча отбросить protected union и вернуть полный pairwise cut. Rooms/walls order, reversed endpoints, winding, duplicate owner, raw/optimized/reloaded storage не меняют classification и semantic result. Product render не мутирует config. ## 7. Совместимость, UX, security и performance - Persisted schema и model version не меняются. - Legacy/canonical keys используют resolved `WallInterval` как сейчас. - Новых controls, touch/keyboard/focus/ARIA и locale keys нет. - Новых HA calls, URL/HTML, permissions и security boundaries нет. - Orthogonal-pair classification и protected-strip geometry строятся внутри cached structural node pass. Новый глобальный `O(E²)` обход и пересчёт на HA/theme/hover tick запрещены. - Local masks и число rays одного node ограничивают boolean work; benchmark large-house не должен выйти за действующий prerelease budget. ## 8. Acceptance criteria и доказательства ### AC1. Оба новых класса дают нулевую потерю protected strips Минимизированные анонимные fixtures содержат точные координаты/толщины минимум узлов `(204.166667, 645.833333)` при `cell_cm: 5` и `(-354.166667, 2087.5)` при `cell_cm: 1`, плюс по одному треугольному T из каждого backup. Для каждого: - `difference(requiredOrthogonalStripUnion, roomGeom)` имеет нулевую площадь; - interior sample inventory не находит пропусков; - final geometry имеет тот же результат вне явных opening slots; - node и все finite rays area-connected. **Доказательство:** fixture + unit в `test/wall-thickness.test.mjs`. ### AC2. #249 остаётся ограниченным Fixture #249 не содержит orthogonal pairs. Его existing `discardedWedgeProbe` остаётся пустым, join не выходит за `R + epsilon`, а normalized geometry area/path не меняются. Retained wedge #261 заполнен, finite endpoints #271 не удлиняются, zero/open ray не материализуется. **Доказательство:** существующие probes, pair-classification unit и geometry difference до/после #275 для fixture #249. ### AC3. Exact-input lifecycle проходит на полных backups владельца Локальный некоммитящийся harness загружает `C:\Temp\4\1.json` и `2.json` и проверяет четыре состояния: raw render, Optimize Preview, Apply + backend echo, reload + повторный render. Для обоих файлов: - нарушений protected-strip containment — `0` во всех degree-3+ nodes; - nodes без orthogonal pairs сохраняют утверждённые #249/#261 probes; - Optimize не мутирует preview input; - `2.json` вправе остаться `changed: false`, но render уже корректен; - targeted crops не содержат прежних белых треугольников/крупного провала. **Доказательство:** локальный отчёт с SHA-256 входов, machine-readable inventory и PNG-crops; приватные backups не попадают в commit/artifacts. ### AC4. Browser и downstream consumers совпадают Production-bundle smoke проверяет настоящий SVG `isPointInFill()` в Plan/View/kiosk/Static и vector geometry hidden Iso/light barriers. Paper и clean-floor consumers сохраняют тот же boundary. Theme/HA tick переиспользуют structural cache и не меняют path/fingerprint. **Доказательство:** targeted browser smoke на обеих minimized fixtures. ### AC5. Visual gate видит именно выемку, а не только enclosed hole Golden/local crop semantic preflight проверяет protected-strip containment до pixel diff. Предрелизный baseline не может пройти, если пустота связана с внешним фоном, но лежит внутри incident strip. Hole inventory #272 остаётся дополнительной, а не достаточной проверкой. **Доказательство:** golden harness/matrix unit и просмотр Linux artifacts. ### AC6. Мутант ловит release escape Исполняемый mutation отключает protected-strip subtraction и возвращает полные pairwise cuts в обычные и смешанные узлы. AC1 либо AC5 краснеют, даже если legacy `enclosedHoles === 0`, retained probe и discarded probe остаются зелёными. **Доказательство:** отдельная запись `scripts/mutation-gate.mjs`, прогнанная в реализации и code review. ### AC7. Privacy и determinism В репозитории отсутствуют полные backups, layout и пользовательские названия. Permutation rooms/walls, reversed interval endpoints, повторный расчёт, canonical storage echo и оба grid scales дают одинаковые protected rays и containment result, не мутируя input. Негативная angle-boundary matrix не считает почти-перпендикулярную диагональ orthogonal pair. Отдельный mixed-node fixture содержит north/south/east rays и диагональный ray 45°: orthogonal rays сохраняются полностью, диагональная pairwise-фаска остаётся bounded, а permutation результата детерминирована. **Доказательство:** table-driven units и проверка Git diff/fixture contents. ### AC8. Локальные гейты реализации - `npm run typecheck`; - `npm test`; - `npm run build` и bundle parity; - `node scripts/check-docs.mjs`; - `node scripts/smoke-select.mjs --base origin/dev --head HEAD` и выбранные targeted smokes; - strip-containment mutation; - targeted semantic golden capture. Полные smoke/golden/performance и Linux HA harness остаются prerelease gates. ## 9. Ожидаемые файлы Product code: - `src/wall-thickness.ts`. Tests/evidence: - `test/wall-thickness.test.mjs` и минимизированные fixtures; - `demo/smoke_multiwall_junction.mjs` либо новый exact-class smoke; - `demo/golden/harness.mjs`, `demo/golden/matrix.mjs` и matrix unit; - `scripts/mutation-gate.mjs`; - локальный diagnostics harness может быть оформлен как reusable script, принимающий внешний путь, но не должен содержать пользовательские данные. Документация: - `docs/WALL-THICKNESS.md`, `docs/ARCHITECTURE.md`, `docs/TESTING.md`; - `docs/USER-GUIDE.md`, `docs/USER-GUIDE.ru.md`; - `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`. ## 10. Release-артефакты Implementation-коммит имеет `Issue: #275`, `User-Visible: yes` и оба changelog. Golden/docs baselines принимаются только после полного Linux artifact и просмотра exact-class crops. Перед beta обязательны полные golden, smoke, performance и exact-SHA Validate. ## 11. Риски и меры | Риск | Мера | |---|---| | Возвратить spike глобальным отключением cuts | Protected subtraction + AC2 сохраняют geometry #249 без orthogonal pairs. | | Заполнить настоящий opening | AC1/AC3 различают pre-opening `roomGeom` и разрешённый slot. | | Починить только SVG | AC4 проверяет paper/floor/Iso/light из общего source. | | Снова принять похожую синтетику | AC1 требует точные minimized coordinates обоих backups, AC3 — полные файлы. | | Golden пропустит внешний-connected notch | AC5 проверяет strip containment независимо от hole connectivity и pixel ratio. | | Увеличить structural cost | Cached local node work и prerelease performance gate. | ## 12. Rollback Откатываются product geometry, strip-containment units/smoke/mutation, visual baselines и документация одним согласованным набором. Нельзя оставить новый baseline при старом безусловном cut либо вернуть hole-only ожидание как достаточное. Миграции данных нет. ## 13. Принятые предположения 1. Продуктовый ответ однозначен из репорта владельца: сохранённая положительная полоса стены не может быть вырезана renderer-bevel. 2. Исправление работает на уровне ray pairs: strips rays с перпендикулярным партнёром защищены от всех cuts этого node; unprotected area сохраняет #249. Общий union strips запрещён контрпримером r1, node-level guard — замечанием spec review r2. 3. Optimize для валидной осевой модели не обязан менять данные ради renderer workaround. 4. Белые треугольники, потеря толщины и крупная выемка имеют одну доказанную причину, поэтому ведутся одной задачей, а не тремя symptom issues. 5. Продуктовых вопросов нет; технические детали выбираются реализацией и защищаются AC1–AC8.