diff --git a/docs/specs/276-coincident-partition-reconciliation.md b/docs/specs/276-coincident-partition-reconciliation.md new file mode 100644 index 00000000..427e6242 --- /dev/null +++ b/docs/specs/276-coincident-partition-reconciliation.md @@ -0,0 +1,248 @@ +# Issue #276 — совпадающая partition становится одной общей стеной + +- **Issue:** https://github.com/Matysh/houseplan-card/issues/276 +- **Статус:** первая редакция для ревью; канонический статус задаётся метками issue +- **Тип / приоритет:** bug / P1 +- **Оценка:** пользовательская ценность 9/10; ценность для разработки 8/10; + сложность 7/10; риск 8/10 +- **Область:** Optimize, независимые стены и hosted openings, общие границы, + Wall thickness, Boundary, Plan/View/static/hidden Iso и световая геометрия +- **Модель данных:** без schema migration; явная lossless-канонизация по команде +- **Связано:** #132, #173, #177, #186, #199, #229, #276, #277, #278, + `docs/WALL-THICKNESS.md`, `docs/PLAN-OPTIMIZE.md` + +## 1. Сценарий и персона + +Домашний администратор построил соседние комнаты и отдельно нарисовал стену на +уже существующей общей границе. На independent partition размещена дверь. Для +человека это одна физическая стена, однако модель хранит два тела: room wall и +partition. + +В `1.67.0-beta.7` уменьшение толщины room wall визуально не действует, увеличение +создаёт ступень, а инструмент «Граница» отказывается сделать участок +виртуальным. Optimize сливает только соседние partitions и не устраняет +совпадение с room wall. + +После исправления явная команда Optimize превращает доказанно эквивалентную +пару в одну общую room wall, безопасно переводит hosted openings в обычные +проёмы этой границы и становится идемпотентной. После этого Thickness и Boundary +редактируют ровно один физический объект. + +## 2. Подтверждённая причина + +На исследованном экспорте две shared room boundaries полностью совпадают с +independent partitions одинаковой толщины. Обе partitions содержат hosted +doors; у концов границ есть корректные короткие ортогональные рёбра. + +- `physicalBodyParts()` добавляет partition независимо от room masonry; +- hosted cut режет partition и, через composite-room-wall resolver, room wall, + но идентичность объектов не устраняет; +- `_boundaryBlocked()` считает partition препятствием поверх shared boundary; +- wall-thickness изменяет room interval, оставляя partition прежней; +- `optimizePlans()` умеет `mergeCollinearPartitions()`, но не умеет + room-wall/partition reconciliation. + +Короткое 5-см ребро не является причиной. Дефект — семантически дублированная +физическая стена. + +## 3. Зафиксированное продуктовое решение + +1. Исправление существующих данных выполняется только явной командой + «Оптимизировать планы», с preview/report, единым Apply и прежним Undo. +2. Автоматически удаляется только partition, для которой доказана полная + физическая эквивалентность одной solid shared room boundary. +3. Все hosted openings удаляемой partition сохраняются как ordinary room-wall + openings с теми же id, типом, длиной, ориентацией, contact/lock и прочими + неизвестными полями. +4. Неоднозначный случай остаётся без изменений. Optimize никогда не выбирает + одну из конфликтующих толщин, не удаляет проём и не угадывает ближайшую стену. +5. После успешной канонизации видимая кладка и проёмы не меняют положение; + Thickness и Boundary начинают работать с единственной общей стеной. +6. Рендер сам по себе ничего не записывает. Общая защита от исчезновения всей + кладки относится к #278. + +Открытых продуктовых вопросов нет. + +## 4. Точное условие безопасной канонизации + +Одна partition является кандидатом только если одновременно выполняется всё: + +1. `a/b`, `cm` и длина конечны; длина больше действующего geometry epsilon. +2. Её ось совпадает endpoint-to-endpoint, с учётом направления, ровно с одним + атомарным solid shared interval двух разных комнат. Частичное покрытие, + несколько составных intervals и внешний/одиночный room edge не подходят. +3. Shared interval не virtual/open и имеет одну ненулевую эффективную толщину + на всей длине. +4. `partition.cm` равна этой эффективной толщине. Сравниваются физические + сантиметры, а не SVG half-depth либо compatibility key. +5. На той же оси нет второй independent partition/draft/column и нет + неоднозначного перекрытия с другой room boundary. +6. Каждый hosted opening partition успешно разрешается compat-resolver'ом, + полностью помещается на shared interval и после снятия `host` однозначно + разрешается на ту же ось, центр и угол как ordinary opening. +7. После преобразования проёмы не перекрываются между собой и не конфликтуют с + уже существующим ordinary opening на том же месте. +8. Точный candidate проходит общий geometry preflight #199 до того, как + Optimize предложит Apply. + +Проверка использует production coordinate contract: room geometry в render +coordinates, persisted `partition.a/b`, `walls[].a/b` и openings — в своих +документированных координатах. Пользовательская сетка не является epsilon. + +## 5. Преобразование + +Для каждого безопасного кандидата в детерминированном порядке: + +1. разрешить все hosted openings на исходной partition; +2. для каждого opening записать совместимые `x/y/angle` из разрешённого центра, + удалить только `host`, сохранить все остальные известные и неизвестные поля; +3. удалить partition; +4. не создавать новую wall-запись, если effective room wall уже имеет ту же + толщину; существующие `walls` и `open_spans` не переписывать сверх обычной + lossless canonicalization Optimize; +5. после всей пачки повторно подготовить exact production geometry и выполнить + preflight; +6. если любой шаг либо preflight неуспешен, весь Optimize candidate отклоняется + до WS-вызова; исходный config/layout/Undo остаются byte-equivalent. + +Преобразование идемпотентно. Повторный `optimizePlans()` не удаляет ничего и +возвращает нулевые новые счётчики. + +## 6. UI и отчёт Optimize + +Preview добавляет две строки/счётчика: + +- `Совпадающие перегородки преобразованы в стены: N` / + `Coincident partitions converted to room walls: N`; +- `Проёмы перепривязаны к стенам комнат: N` / + `Openings reattached to room walls: N`. + +Нулевые строки скрыты по действующему правилу отчёта. Общая сумма изменений и +кнопка Apply учитывают оба счётчика. Failure preflight использует существующий +диалог #199 и не раскрывает внутренние id/координаты. + +После Apply: + +- Wall thickness 20→10 и 20→30 немедленно дают выбранную видимую толщину; +- Boundary разрешает сделать общую стену virtual; +- door/window/gate остаётся на прежнем месте и продолжает использовать + прежние contact/lock bindings; +- Undo одним действием восстанавливает partition и исходный hosted `host`, Redo + снова канонизирует. + +## 7. В скоупе + +- pure reconciliation helper и типизированный отчёт; +- интеграция в exact candidate `optimizePlans()` до storage canonicalization; +- перенос hosted opening к ordinary shared-room opening; +- UI/i18n отчёта Optimize; +- parity канонической геометрии всех runtime consumers после Apply; +- синтетическая fixture с двумя комнатами, короткими 5-см end offsets, + совпадающей partition и hosted door; +- unit, production-bundle smoke, Optimize preflight, Undo/Redo, mutation и + targeted visual regression; +- документация и release artifacts. + +## 8. Не входит + +- частично совпадающие, более длинные/короткие либо составные partitions; +- разные толщины room wall и partition; +- перенос или удаление drafts/columns; +- автоматическая запись при render/load; +- nearest-wall восстановление orphan hosted opening; +- изменение schema `OpeningCfg` либо backend migration; +- исправление Resize (#277) и generic wall-union fallback (#278); +- публикация hidden Iso. + +## 9. Архитектурный контракт + +1. Pure helper живёт рядом с Optimize/wall merge либо в отдельном модуле и + возвращает candidate + counts/reasons без мутации входа. +2. Shared-wall eligibility переиспользует canonical wall intervals и opening + resolvers; отдельная упрощённая модель совпадения запрещена. +3. Рендер, Boundary и Thickness не получают скрытых runtime-исключений. Их + исправление является следствием одной persisted physical object после Apply. +4. `PLAN_MODEL_VERSION` повышается только если новый pass должен один раз + маркировать реально изменённый план; version marker без meaningful diff не + создаётся. +5. Backend остаётся atomic storage/schema/revision boundary. Frontend exact + geometry preflight выполняется до единственного WS call. +6. Unknown fields partition не переносятся в room wall: auto-reconciliation + разрешён только для действующей `PartitionCfg`; неизвестное поле, способное + означать особую семантику, делает candidate неоднозначным и оставляет его. + +## 10. Производительность + +Pass запускается только при открытии Optimize и повторной проверке изменившегося +fingerprint перед Apply, не на pointer/render/state paths. Для large-house +fixture дополнительный p95 не превышает 15% от текущего Optimize candidate pass +и 25 ms абсолютного overhead на той же машине. Память не кэширует geometry +между диалогами. + +## 11. Touch и accessibility + +Новых жестов нет. Optimize dialog доступен мышью и touch; failure и Apply не +зависят от hover. Существующие focus trap, Escape, disabled/busy и restore-focus +сохраняются. View/киоск остаются release-blocking и получают тот же structural +result после Apply. + +## 12. Критерии приёмки + +| AC | Критерий | Доказательство | +|---|---|---| +| AC1 | Exact same-thickness shared-wall partition определяется независимо от направления endpoints и порядка комнат. | Unit matrix. | +| AC2 | Partial/longer/shorter/composite/non-shared/virtual/different-cm/ambiguous-extra cases остаются byte-equivalent. | Negative unit matrix. | +| AC3 | Один и несколько hosted door/window/gate переводятся в ordinary openings без сдвига центра/угла и без потери любых полей. | Unit + schema/unknown-field test. | +| AC4 | Orphan, non-fitting, overlapping или ambiguous opening запрещает преобразование без частичного результата. | Unit mutation matrix. | +| AC5 | После candidate physical geometry и opening cuts эквивалентны исходному видимому результату; после 20→10/30 существует ровно одно тело выбранной толщины. | Canonical geometry unit + targeted golden. | +| AC6 | Boundary после Apply не blocked и virtual conversion сохраняет opening; до Apply никакого скрытого config write нет. | Production-bundle smoke. | +| AC7 | Optimize report показывает точные RU/EN counters; Apply — один WS call, Undo/Redo восстанавливают обе формы. | i18n unit + browser smoke. | +| AC8 | Первый Optimize меняет fixture, второй является no-op; backend echo/canonical coordinates не создают третий diff. | Optimizer round-trip unit. | +| AC9 | Exact candidate проходит shared #199 preflight; forced failure блокирует весь Apply и делает 0 writes. | Injectable unit + browser smoke. | +| AC10 | Plan, View/static, hidden Iso, floor/paper, Glow/source guard и солнце получают один canonical body после Apply. | Consumer parity test + code review. | +| AC11 | Large-house benchmark выполняет бюджет §10 и helper не вызывается на render/pointer. | Benchmark + call-count test. | +| AC12 | Мутации «удалить без rehost», «принять partial» и «обойти post-pass preflight» пойманы 1/1. | Mutation gate. | +| AC13 | Typecheck, unit, build и targeted smokes зелёные; три tracked bundle-копии byte-identical. | Fast gates. | +| AC14 | RU/EN changelog и пользовательская/архитектурная/тестовая документация актуальны. | Docs gate + review. | + +## 13. План тестов + +- `test/coincident-partition-reconciliation.test.mjs`: AC1–AC5, AC8; +- расширение `test/plan-optimizer.test.mjs`: report, idempotence, backend echo; +- расширение `test/partition-openings.test.mjs`: exact rehost и ambiguity; +- `demo/smoke_optimize_coincident_partition.mjs`: production bundle, + RU/EN preview, Apply/Undo/Redo, Thickness/Boundary; +- targeted golden: short 5-cm offsets + hosted door до/после 10/30/virtual; +- mutation ids для AC12; +- targeted benchmark поверх large-house fixture. + +Полные golden/smoke/performance и Linux HA harness выполняются перед бетой по +процессу, а не в implementation loop. + +## 14. Release-артефакты и rollback + +Изменение пользовательское. Коммит реализации содержит `User-Visible: yes` и: + +- `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md` со ссылкой #276; +- `docs/USER-GUIDE.md`, `docs/USER-GUIDE.ru.md` — что исправляет Optimize; +- `docs/PLAN-OPTIMIZE.md`, `docs/WALL-THICKNESS.md`, `docs/ARCHITECTURE.md`; +- `docs/TESTING.md`, `docs/STATUS.md`; +- RU/EN i18n, tests/smoke/mutations/benchmark и reviewed golden candidate; +- синхронные tracked bundles и docs screenshot fingerprint при необходимости. + +Rollback — revert implementation commit. Уже преобразованный plan остаётся +валидным: ordinary opening и room wall читаются старыми версиями. Обратная +автоматическая миграция в redundant partition не выполняется; пользователь +может вернуть exact исходное состояние штатным Undo до следующей операции либо +из backup/export. + +## 15. Принятые технические предположения + +1. Geometry helper/module names и внутренние reason codes не являются API. +2. Equality thickness использует точное persisted integer/finite `cm` после + действующей validation, без цветового/пиксельного сравнения. +3. Report хранит два счётчика, потому что один partition может иметь несколько + openings; UI показывает человеку и structural, и reference change. +4. Короткие 5-см рёбра fixture сохраняются; pass не упрощает room topology. +5. Existing ordinary opening с иным id, но тем же slot, считается ambiguity и + не дедуплицируется автоматически. diff --git a/docs/specs/README.md b/docs/specs/README.md index e16ce22a..8def5629 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -70,6 +70,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным | [#239](https://github.com/Matysh/houseplan-card/issues/239) Масштаб сетки не меняет внешний вид плана; default 1 см/1 дюйм | [239-grid-scale-invariance.md](239-grid-scale-invariance.md) | | [#231](https://github.com/Matysh/houseplan-card/issues/231) Декоративный слой виден поверх заливок комнат | [231-decor-layer-order.md](231-decor-layer-order.md) | | [#243](https://github.com/Matysh/houseplan-card/issues/243) Рабочее перетаскивание вкладок и точный указатель вставки | [243-space-tab-drop-target.md](243-space-tab-drop-target.md) | +| [#276](https://github.com/Matysh/houseplan-card/issues/276) Совпадающая partition становится одной общей стеной | [276-coincident-partition-reconciliation.md](276-coincident-partition-reconciliation.md) | ## P2