diff --git a/docs/specs/261-white-wedges-root-cause.md b/docs/specs/261-white-wedges-root-cause.md new file mode 100644 index 00000000..15fc9c1e --- /dev/null +++ b/docs/specs/261-white-wedges-root-cause.md @@ -0,0 +1,409 @@ +# Issue #261 — белые клинья в T-стыках после ограниченного multi-wall bevel + +- Дата: 2026-08-23 +- Тип: bug · приоритет P1 +- Оценка: пользовательская ценность 8/10 · ценность для разработки 8/10 · + сложность/риск 7/10 +- Issue: [#261](https://github.com/Matysh/houseplan-card/issues/261) +- Ветка: `issue/261-white-wedges-root-cause` +- Статус ТЗ: первая редакция, ожидает ревью + +Канонические документы: `docs/SCOPE.md`, `docs/USER-GUIDE.ru.md`, +`docs/ARCHITECTURE.md`, `docs/WALL-THICKNESS.md`, +`docs/CONFIG-COMPATIBILITY.md`, `docs/TOUCH-SUPPORT.md` и +`docs/TESTING.md`. + +Связанные, но не дублирующие задачи: +[#197](https://github.com/Matysh/houseplan-card/issues/197), +[#249](https://github.com/Matysh/houseplan-card/issues/249), +[#253](https://github.com/Matysh/houseplan-card/issues/253) и +[#258](https://github.com/Matysh/houseplan-card/issues/258). + +## 1. Сценарий и персона + +Администратор дома открывает в `v1.67.0-beta.4` обычный сохранённый план с +толстыми стенами разной толщины. В нескольких T-образных стыках вместо единого +тела кладки видны белые треугольные клинья. Они остаются после reload и видны +не только администратору в Plan, но также семье в View, kiosk и Static. + +Это нарушает J1 из `docs/SCOPE.md`: план перестаёт правдиво показывать +физические стены дома. Исправление также обслуживает J6: все поверхности должны +получать одну каноническую геометрию, а не отдельную визуальную заплатку. + +## 2. Подтверждённое воспроизведение + +Один и тот же реальный экспорт владельца выполнен production-проекцией +`spaceModels()` → `prepareSpacePhysicalGeometryInputs()` → +`wallBodiesGeometry()` на `v1.67.0-beta.3`, `v1.67.0-beta.4` и текущем `dev`. +Вход содержит 8 комнат, 24 записи толщины, 3 виртуальных участка, 15 проёмов и +3 независимых физических тела. + +Результат сравнения: + +- `wallIntervals` beta.3/beta.4 совпадает побайтово; +- `wallEdgeBodies` beta.3/beta.4 совпадает побайтово; +- `buildMultiWallNodeMap()` beta.4 находит 12 физических degree-3 узлов; +- `paperGeom` beta.3 имеет площадь `727303.8201095833`, а beta.4/current dev — + `727248.4381651384`; +- из beta.4 исчез один реальный треугольник площадью `55.3819444449` + render-unit² с вершинами около `(887.5, 559.1667)`, `(899.5833, 550)` и + `(899.5833, 559.1667)`; +- безопасная внутренняя точка исчезнувшего тела: `(895.5, 556.0)` в + production render coordinates, или `(0.8955, 0.5560)` в сохранённой + нормализованной конфигурации. + +Этот узел уже присутствует в обезличенной фикстуре +`test/fixtures/197-junction-patch.json`, поэтому пользовательский экспорт в +репозиторий не добавляется. Фикстура #197 содержит 8 комнат, 25 wall entries и +3 virtual cuts; дополнительная ULP-запись не меняет сам проблемный узел. + +## 3. Подтверждённая причина + +Первый дефектный SHA — коммит #249 `29904df` (`fix: bound multi-wall junction +bevels`). Уже на нём masonry теряет `495.579151` render-unit² относительно +beta.3, а paper — указанный треугольник `55.381944`. Изменения #253 на этом SHA +ещё отсутствуют и причиной не являются. + +У #249 есть два разных варианта одной bevel-формулы: + +1. `multiWallBevelTriangles(map)` оставляет физическое пересечение лучей до + `R = 1.25 × H` и возвращает только малый excessive wedge за этим радиусом; +2. внутренний `multiWallBevelTrianglesAt(map, false)` начинает cut от исходных + offset-точек граней и возвращает весь треугольник до старой mitre-вершины. + +`bevelMultiWallPaper()` применяет второй вариант, затем делает +`union(centre, beveled)`. Room centre возвращает только площадь внутри +объединения комнат; наружная половина физической стены не возвращается. +`bevelMultiWallBody()` использует тот же полный cut перед локальной +реконструкцией, но reconstructed ray strips клипуются к `centre`, поэтому их +внешняя часть также теряется. Алгоритм, предназначенный для удаления длинного +mitre-зуба, вырезает допустимое пересечение двух положительных физических +лучей. + +Общая грань `0.620833,0.545833@1.5706` без толщины из исходной гипотезы не +причастна: её `half = 0`, она не входит в degree-3 карту и находится у +`(620.833, 545.833)`, а подтверждённый клин — у `(887.5, 550)`. + +## 4. Что человек увидит до и после + +**До:** у T-стыка положительных стен разной толщины штрихованное тело и бумага +теряют треугольный сектор между двумя реальными лучами. Через него виден белый +фон/пол, словно в кладке есть отверстие. + +**После:** те же сохранённые комнаты и толщины образуют одно непрерывное тело +без белого клина. Старый чрезмерный mitre-зуб по-прежнему ограничивается прямой +фаской за пределом `1.25 × H`; исправление не возвращает выступ #249 и не +округляет стык. Результат одинаков в Plan, View, kiosk, Static и скрытой +изометрии, а свет не проходит сквозь восстановленную кладку. + +Новых сообщений, настроек и ремонтных действий нет. План исправляется при +чтении без Save или Optimize. + +## 5. Scope + +### Входит + +- устранение полного destructive cut для положительных ray strips в + degree-3+ room-wall nodes; +- сохранение физического пересечения лучей вплоть до действующего + `R = 1.25 × H` внутри и снаружи room-centre union; +- согласованные `roomGeom`, final masonry и `paperGeom` без клина; +- Full/Plan/View/kiosk, Static, hidden Iso, clean floor/room fill/hover и + Glow/sun/light-barrier consumers одной геометрии; +- regression на существующей обезличенной фикстуре #197; +- усиление unit, browser smoke, golden semantic gate и mutation gate; +- обновление канонических документов, пользовательского руководства и двух + changelog. + +### Не входит + +- изменение `R = 1.25 × H`, `MITRE_LIMIT = 4` или прямой формы фаски #249; +- возвращение старых неограниченных mitre-зубов beta.3; +- изменение wall-key/stored-endpoint ремонта #258; +- добавление толщины нулевой общей грани у `(620.833, 545.833)`; +- изменение interval classification, shared/outer/virtual semantics, + толщины стен, openings, partitions, drafts или columns; +- изменение сохранённых rooms/walls/open spans либо новый Optimize-pass; +- новый UI, i18n, backend, schema/model/store version или WebSocket API. + +## 6. Геометрический контракт + +### 6.1 Физическая область multi-wall node + +Для каждого canonical node степени `3+` используются существующие уникальные +положительные rays. Повторы shared interval и сонаправленные дубликаты остаются +схлопнутыми; zero-thickness и virtual intervals материала не добавляют. + +Локальное физическое тело node определяется union конечных полос всех rays. +Из него разрешено удалить только ту часть pairwise mitre-overlap, которая +лежит за `R = 1.25 × H`, где `H` — максимальная incident half-depth. + +Полный треугольник от offset origins до старой mitre-вершины не является +допустимым cut: его часть до `R` принадлежит физическим полосам и должна +остаться заполненной. Это правило одинаково по обе стороны room-centre union. + +### 6.2 Masonry и exterior + +В bounded mask проблемного node canonical room masonry: + +1. сохраняет complete positive ray strips; +2. удаляет только excessive area за `R`; +3. остаётся в допустимой exterior/paper envelope; +4. имеет ненулевое заполнение в node centre и area-connected связь с каждым + incident ray; +5. не зависит от rooms/walls order, interval direction и room winding. + +Room-centre union не может быть единственным clip для repaired local strips, +потому что физическая outer half-wall по контракту растёт наружу. Одновременно +local reconstruction не вправе выходить за каноническую ограниченную exterior +envelope и возвращать старый фасадный spike. + +### 6.3 Paper + +`paperGeom` обязан покрывать room centre и всю каноническую room masonry, +которой нужен фон плана. Bevel paper удаляет только область за `R`; он не +вырезает bounded overlap положительных rays. Внутри node не образуется новый +hole/component. + +Открытия и independent physical bodies сохраняют нынешний порядок: openings +режут masonry после canonical `roomGeom`, а independent bodies не расширяют +room paper. + +### 6.4 Clean floor и physics + +`roomGeom` остаётся единственным pre-opening/pre-independent wall authority. +Clean-floor/room-fill/hover subtract именно его; восстановленный wall probe не +может стать полом соседней комнаты. Final masonry, hidden Iso и light barriers +получают тот же node: визуальная щель не заменяется невидимым occluder и +наоборот. + +Структурные caches и fingerprints сохраняют нынешнюю зависимость только от +геометрии. Theme, hover и HA state tick не перестраивают topology. + +### 6.5 Failure isolation + +Каждый node остаётся локальной транзакцией по контракту #197: invalid/nonfinite +candidate не удаляет успешную геометрию других nodes и не превращает весь план +в `null`. Обязательные exterior/body/opening failures продолжают fail-dark; +исправление не маскирует их как успешный результат. + +## 7. Данные, compatibility, touch, security и performance + +- Config/layout и `model_version` не меняются; чтение и render не мутируют + вход. +- Обе сохранённые wall-key формы #258 продолжают давать одну геометрию; + Optimize не требуется для визуального исправления. +- Legacy key-only records и нулевые/virtual intervals сохраняют действующие + правила. +- Новых UI, focus, ARIA, клавиатурных, pointer или touch-контрактов нет. View и + kiosk получают исправление через общий render path; редакторы остаются + desktop-first. +- Новых данных, HTML, URL, service calls, permissions и security surfaces нет. +- Node map не строится повторно. Локальный repair остаётся в существующем + cached structural pass; запрещён новый полный `O(E²)` обход на HA state tick. + Отдельный performance budget не нужен, но prerelease performance smoke + остаётся обязательным. + +## 8. Acceptance criteria и доказательства + +### AC1. Реальный T-узел сохраняет физический сектор + +На `test/fixtures/197-junction-patch.json` при `coordScale = 1000`: + +- `wallBodiesGeometry()` возвращает непустые `roomGeom`, `geom` и `paperGeom`; +- probe `(895.5, 556.0)` находится внутри `roomGeom`, final `geom` и + `paperGeom` с устойчивым ненулевым покрытием, а не только на границе; +- probe не находится ни в одном clean-floor результате соседних rooms; +- локальное тело у `(887.5, 550)` связано со всеми тремя incident rays; +- повторный расчёт детерминирован. + +**Доказательство:** unit в `test/wall-thickness.test.mjs`, использующий +существующую fixture #197 и polygon-area/point coverage, а не только общую +непустоту или brittle SVG-строку. + +### AC2. Ограничение #249 не ослаблено + +На fixture #249 и table-driven 3/4-ray cases: + +- old excessive wedge остаётся пустым; +- join не выходит дальше `1.25 × H + epsilon`; +- ordinary two-ray joins сохраняют `MITRE_LIMIT = 4` и прежние expected values; +- равные/неравные толщины, permutations, reversed intervals, room winding и + normalized/production scale дают эквивалентный результат. + +**Доказательство:** существующие и усиленные unit +`test/wall-thickness.test.mjs`; прежние expected values #249 не переписываются +ради прохождения нового теста. + +### AC3. #197, #258 и zero-depth regressions остаются закрыты + +- forced failure одного optional junction candidate сохраняет остальные nodes + и непустую canonical geometry; +- affected/canonical wall-key variants #258 дают равные masonry/paper paths; +- нулевая общая грань `(620.833, 541.667..550)` не становится физической и не + увеличивает degree; +- render не мутирует rooms, walls, open spans, openings или extra bodies. + +**Доказательство:** unit #197/#258, deep equality и mutation gate. Новый mutant +возвращает full-origin bevel cut в paper/body; AC1 обязан на нём краснеть. + +### AC4. Все browser-поверхности видят один заполненный node + +В `demo/smoke_junction_patch_resilience.mjs` fixture #197: + +- Plan/View/kiosk wall path и paper path содержат normalized probe + `(0.8955, 0.5560)` через реальный SVG `isPointInFill()` либо эквивалентный + geometry probe; +- Static получает тот же canonical wall path и заполненный probe; +- hidden Iso source masonry и light-barrier masonry содержат тот же probe; +- clean-floor/hover не присваивают probe полу; +- theme и HA state tick сохраняют path/fingerprint и повторно используют cache. + +**Доказательство:** targeted browser smoke. Проверка одной лишь непустоты path +недостаточна. + +### AC5. Golden защищает видимый клин семантически + +Сценарии `junction-patch-resilience-plan-dark` и +`junction-patch-resilience-view-dark` используют существующую полную fixture и +получают явный `retainedWedgeProbe: [0.8955, 0.5560]`. Golden capture до записи +PNG проверяет, что wall и paper SVG действительно заполняют probe; пустая, +неверно кадрированная или сохранившая клин сцена завершается ошибкой. + +Изменившиеся Linux CI PNG и diff просматриваются до принятия baseline. +Локальный Windows raster не является каноном. + +**Доказательство:** `demo/golden/matrix.mjs`, `demo/golden/harness.mjs`, +`demo/golden/run.mjs`, `test/golden-matrix.test.mjs` и полный +`npm run golden:verify`. + +### AC6. Данные и производительность не меняются + +- до/после render входной config побайтово одинаков; +- schema/backend/i18n отсутствуют в diff; +- structural cache строит node map один раз; state/theme ticks его используют; +- `node scripts/model-invariants.mjs --config <реальный экспорт>` не получает + новых нарушений. + +**Доказательство:** unit/smoke, review diff и model-invariants на исходном +экспорте владельца. + +### AC7. Локальные гейты реализации зелёные + +- `npx tsc --noEmit`; +- `npm test`; +- `npm run build` с действующей проверкой shipped bundle copies; +- `node scripts/check-docs.mjs`; +- `node scripts/smoke-select.mjs --base origin/dev --head HEAD` с явным решением + по каждой предложенной строке; +- targeted `node demo/smoke_junction_patch_resilience.mjs` и + `node demo/smoke_multiwall_junction.mjs`; +- `npm run golden:verify` для видимого изменения; +- `node scripts/model-invariants.mjs --config + C:\\Temp\\houseplan-space-1-2026-08-23_02-30-42.json`. + +Полный smoke и performance остаются prerelease gates; Python/backend suite не +требуется при отсутствии Python diff. Canonical HA harness остаётся Linux CI. + +## 9. Затронутые файлы и модули + +Ожидаемый product diff: + +- `src/wall-thickness.ts` — canonical multi-wall body/paper repair. + +Ожидаемые доказательства: + +- `test/wall-thickness.test.mjs`; +- `demo/smoke_junction_patch_resilience.mjs`; +- при необходимости `demo/smoke_multiwall_junction.mjs`; +- `demo/golden/matrix.mjs`, `demo/golden/harness.mjs`, + `demo/golden/run.mjs`, `test/golden-matrix.test.mjs`; +- `scripts/mutation-gate.mjs`. + +Документация: + +- `docs/WALL-THICKNESS.md`, `docs/ARCHITECTURE.md`, `docs/TESTING.md`; +- `docs/USER-GUIDE.ru.md` и синхронный `docs/USER-GUIDE.md`; +- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`; +- docs screenshots/manifest, если обязательный `check-docs` требует их + обновления для текущего `src/**` fingerprint. + +Новых i18n keys нет. + +## 10. Release artifacts + +- User-visible bugfix записывается в оба changelog в том же коммите, что + `src/**`. +- Каноническая wall/architecture/testing документация обновляется тем же + коммитом. +- Golden baseline меняется только после просмотра полного Linux CI artifact и + `npm run golden:accept -- --reviewed`. +- Если source fingerprint делает docs screenshots stale, кандидаты снимаются + только workflow `Docs screenshots` и принимаются из его полного artifact; + локальная Windows-пересъёмка не коммитится. +- Перед бетой обязательны полный smoke, golden и performance/Validate exact-SHA + по действующему release process. + +## 11. Риски и меры снижения + +### R1. Возврат фасадного spike #249 + +Если просто убрать full-origin cut, старый длинный mitre может вернуться. + +**Митигация:** AC2 сохраняет bound `1.25 × H`, discarded-wedge probe и +перестановочную матрицу #249; меняется область cut, а не лимит. + +### R2. Ложная кладка снаружи дома + +Если разрешить rebuilt strips без exterior envelope, child-room ray может +расширить фасад. + +**Митигация:** §6.2 требует clip к канонической bounded paper/exterior envelope, +corner-Split и concave-facade тесты остаются неизменными. + +### R3. Paper, masonry и physics расходятся + +Исправление только SVG path скроет белый клин, но оставит ложный clean floor или +проход света. + +**Митигация:** AC1/AC4 проверяют `roomGeom`, final masonry, paper, clean floor, +Iso и light barrier одной точкой. Отдельный render-only patch запрещён. + +### R4. Существующая fixture снова проверит только непустоту + +#197 уже содержала проблемный node, но её unit/smoke принимали общую площадь и +непустой path, поэтому регрессия прошла. + +**Митигация:** обязательные point-coverage assertions, semantic golden field и +mutant полного cut. Тест должен быть продемонстрирован красным на мутанте. + +### R5. Local boolean repair погасит соседние nodes + +Изменение order union/intersection может вернуть failure mode #197. + +**Митигация:** node-by-node transaction сохраняется; forced-failure test и +полная fixture проверяются вместе с новым probe. + +## 12. Откат + +Откатывается локальная multi-wall body/paper correction вместе с unit, +smoke/golden и документацией. Данные не мигрируются, поэтому отдельного +data rollback нет. Golden baseline откатывается только вместе с product +geometry; частичный откат оставит ложный зелёный визуальный гейт. + +## 13. Принятые технические предположения + +1. Наблюдаемый пользовательский контракт — непрерывная физическая кладка с + сохранённым bound #249; точное разбиение функции на helpers не является + продуктовым решением. +2. Existing fixture #197 является достаточной обезличенной regression fixture; + полный реальный export используется только локально для cross-version и + model-invariants проверок. +3. Probe `(895.5, 556.0)` выбран внутри измеренного исчезнувшего треугольника с + запасом от его границ; он не является brittle vertex equality. +4. Paper может отличаться от beta.3 на малую область excessive bevel за `R`: + задача требует физический контракт, а не полное byte/area равенство старой + версии. +5. Нулевая общая грань остаётся отдельным корректным zero-depth interval. Её + материализация для маскировки клина была бы исправлением симптома и выходит + из scope.