Files
houseplan-card/docs/specs/261-white-wedges-root-cause.md
T
2026-08-23 15:41:55 +03:00

412 lines
25 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 #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`
- Статус ТЗ: реализовано, ожидает код-ревью
- Реализация после rebase на текущий `dev`: `0dc6c39`; проверочные артефакты и
smoke registry: `2e53cb0`.
Канонические документы: `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.