docs: specify multi-wall wedge repair

Issue: #261
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-23 15:35:26 +03:00
parent 18d504631c
commit 9f6efd0c11
+409
View File
@@ -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.