diff --git a/docs/specs/278-wall-union-isolation.md b/docs/specs/278-wall-union-isolation.md new file mode 100644 index 00000000..73a513a4 --- /dev/null +++ b/docs/specs/278-wall-union-isolation.md @@ -0,0 +1,303 @@ +# Issue #278 — локальный сбой extra-body union не гасит всю кладку + +- **Issue:** https://github.com/Matysh/houseplan-card/issues/278 +- **Статус:** первая редакция для ревью; канонический статус задаётся метками issue +- **Тип / приоритет:** bug / P1 +- **Оценка:** пользовательская ценность 10/10; ценность для разработки 9/10; + сложность 7/10; риск 9/10 +- **Область:** canonical wall geometry, independent physical bodies, strict + geometry commit barrier, model invariants, Plan/View/static/hidden Iso/Glow +- **Модель данных:** без schema/migration и без записи во время render +- **Связано:** #141, #197, #199, #218, #264, #276, #277, + `docs/WALL-THICKNESS.md`, `docs/ARCHITECTURE.md` + +## 1. Сценарий и персона + +После Resize пользователь открывает пространство и видит комнаты, проёмы, +подписи и осевые линии, но не видит кладку ни одной стены. Persisted `walls` +при этом содержит 25 записей. Один конфликт physical geometry превращает +полноценный этаж в визуально пустой. + +После исправления core room masonry остаётся видимой и физически канонической, +даже если отдельный independent extra нельзя объединить с ней одной boolean +операцией. Локальный extra сохраняется отдельным structural component либо +локально деградирует; он не обнуляет уже построенные стены. Ни один новый +geometry commit не может записать такой degraded candidate. + +## 2. Доказательная база + +На приложенном `33.json` из `1.67.0-beta.7`: + +- 8 rooms, 25 wall records, 8 partitions и 14 openings; +- `wallBodiesUnionPath()` возвращает `null`; +- `_renderWallBodies()` вследствие этого возвращает пустой wall layer; +- production bundle детерминированно показывает кладку на состоянии до Resize + и полностью теряет её после; +- `scripts/model-invariants.mjs` считает состояние валидным; +- shared preflight #199 на точном `payload.config` корректно возвращает + `failed / wall-null` и блокирует Optimize Apply. + +Последний пункт уточняет границу: #199 не сломан. Чистый `optimizePlans()` сам +не ремонтирует input, но UI не записывает его повторно. Проблема в том, что +Resize сохранил candidate без такого барьера, renderer применяет общий catch к +последовательному extra union, а CLI invariants не повторяют production pass. + +Текущая причинная точка — цикл `extraBodies` в `wallBodiesGeometry()`: room +rings, atomic edges и junction patches имеют локальную изоляцию, а exception +при `union(body, extra)` попадает в внешний catch и возвращает `null` для всего +результата. + +## 3. Зафиксированный продуктовый контракт + +1. Один local independent body не может удалить валидную кладку комнат и + остальные успешно построенные physical bodies. +2. Existing saved config читается без фоновой записи, удаления объектов или + автоматического Optimize. +3. Render может использовать локализованный component fallback, но exact + candidate любой geometry-changing операции обязан пройти strict preflight; + degraded результат нельзя сохранить как success. +4. Основной structural failure room masonry остаётся fail-closed. Задача не + воскрешает старые raw room rings, создававшие facade teeth до #141/#197. +5. Optimize preflight #199 остаётся strict и по-прежнему блокирует `wall-null` + либо degraded candidate. Восстановление конкретной redundant partition + выполняет #276. +6. Все canonical consumers получают одно множество физических components; + SVG-only обход, отличный от floor/light/static, запрещён. + +Открытых продуктовых вопросов нет. + +## 4. Два результата: strict и render-safe + +Pure production preparation возвращает типизированный structural result: + +- `ok` — core и все extras успешно объединены; +- `degraded-extra` — core room masonry успешно, но один или несколько extras + нельзя слить с общей geometry; сохранены primary geometry и локальные + isolated components; +- `failed-core` — обязательная room/exterior/opening geometry не построена; +- `not-applicable` — пространство не содержит физических стен. + +**Strict mode** принимает только `ok`/`not-applicable`. Его используют +Optimize и commit barrier редакторов. + +**Render-safe mode** принимает `ok` и `degraded-extra`. `failed-core` остаётся +`null`/fail-dark. Режимы используют один computation/result, а не две реализации +boolean geometry. + +## 5. Изоляция independent extras + +1. Core room result завершается до extra pass и сохраняется транзакционно: + masonry после room openings, `roomGeom`, paper geometry, depth и opening + index. +2. Extra bodies уже представлены после собственных hosted cuts. Каждый body + нормализуется/проверяется независимо и получает стабильный порядковый + component id, не persisted id. +3. Для каждого extra попытка union выполняется независимо. Присваивание общей + geometry происходит только после успешной операции. +4. Если union выбрасывает либо возвращает structurally invalid result, последняя + успешная primary geometry сохраняется, а валидный исходный extra добавляется + как isolated component. Следующие extras продолжают обрабатываться. +5. Если сам extra non-finite/zero-area/non-simple и не может быть безопасно + представлен даже отдельно, пропускается только этот body и результат всё + равно `degraded-extra`; core/остальные extras не исчезают. +6. Isolated component остаётся физическим препятствием для render, hit/source + guard, Glow/sun и clean-floor там, где соответствующий consumer ранее + учитывал independent bodies. Он не добавляется в `roomGeom` и room area. +7. Coincident component не вычитается по `evenodd` из primary body. Renderer + получает отдельные path fragments/groups либо эквивалентный non-cancelling + representation; простая конкатенация противоположно ориентированных rings + в один evenodd path запрещена. +8. Component order и итоговое множество не зависят от порядка partitions, + drafts, columns или room records. + +## 6. Canonical consumers + +Один structural result определяет: + +- full card Plan и View/киоск; +- `houseplan-space-card` static render; +- hidden Iso footprint/wall faces; +- room inner contour, paper/floor footprint и displayed clean area; +- Glow barriers, source-inside-body guard и spill; +- солнечные препятствия; +- geometry preflight и model invariants. + +Presentation может рисовать primary/isolated components разными ``, но +ни один consumer не может молча отбросить isolated physical component, если +он способен принять component set. `roomGeom` намеренно остаётся только room +masonry, чтобы independent partitions/columns не меняли площадь комнаты. + +## 7. Strict commit barrier + +Pure `checkSpacePhysicalGeometry()` (имя не API) проверяет exact candidate одной +операции через ту же preparation и structural result, что renderer/#199. + +В этой задаче аудитом покрываются все frontend операции, способные изменить: + +- `rooms`, `walls`, `open_spans`; +- ordinary/hosted `openings` geometry/host; +- `partitions`, `room_drafts`, `wall_columns`. + +Каждая такая операция должна пройти общую transaction boundary до записи. +Существующие config edits, не меняющие geometry (marker, colors, names), не +перепроверяют весь план и не блокируются из-за legacy degraded space. + +Контракт boundary: + +1. получает immutable before и exact after; +2. no-op не запускает WS/history; +3. `ok/not-applicable` разрешает существующий один atomic save; +4. `degraded-extra/failed-core/exception` откатывает in-memory candidate к + before, делает 0 WS calls и 0 Undo entries; +5. показывает bounded RU/EN toast: + `Изменение отменено: геометрию стен нельзя безопасно построить.` / + `Change canceled: wall geometry could not be built safely.`; +6. stale fingerprint перед фактическим save требует повторной проверки exact + current candidate; успешный результат другой ревизии не переиспользуется. + +#277 использует этот barrier для Resize. Если последовательность реализации +временно требует локального adapter, перед merge #278 остаётся один exported +source of truth, а дублирующий adapter удаляется. + +## 8. Optimize и recovery + +- `checkOptimizeGeometry()` переиспользует общий strict result и не принимает + `degraded-extra` как green. +- Действующий failure dialog #199 и zero-write behavior сохраняются. +- #276 может превратить exact redundant partition в valid candidate до + preflight; после этого Optimize становится green и ремонтирует конкретный + fixture. +- #278 не удаляет/перемещает пользовательские extras и не предлагает новый + Repair UI. + +## 9. Model invariants + +`scripts/model-invariants.mjs` получает production structural check для каждого +пространства экспорта: + +- `failed-core` и `degraded-extra` являются ошибкой с bounded reason/count; +- никакие title, ids, координаты или полное exception message не печатаются в + публичный CI artifact без явного debug-флага; +- script возвращает non-zero на `33.json`-классе fixture; +- валидные empty/image-only/virtual-only spaces не становятся false positive; +- source preparation импортируется из общего pure module либо проверяется + строгим parity guard; копия production условий в script запрещена. + +## 10. В скоупе + +- per-extra transactional union и isolated component representation; +- strict/render-safe typed structural result; +- parity full/static/iso/floor/Glow/sun consumers; +- reusable strict geometry transaction barrier и audit geometry writers; +- integration с #199 без ослабления preflight; +- production structural model invariant; +- анонимизированный minimized fixture из `33.json`; +- unit, production-bundle smoke, mutation, performance и targeted golden; +- RU/EN failure toast, docs/changelogs. + +## 11. Не входит + +- автоматическое удаление/rehost partition (#276); +- safe Resize eligibility/range (#277); +- замена `polyclip-ts`; +- оптимистичный raw-room-ring fallback при core failure; +- новая пользовательская recovery/repair панель; +- background write/migration при load/render; +- исправление произвольного invalid source polygon; +- публикация hidden Iso. + +## 12. Архитектурный контракт + +1. Общий typed result и per-extra isolation живут в canonical geometry modules, + не в Lit render methods. +2. Existing `wallBodiesGeometry()` может получить совместимый richer result; + callers мигрируют атомарно, без периода, когда часть видит isolated bodies, + а часть нет. +3. Structural components immutable; caches keyed точным geometry fingerprint и + не удерживают config/history. +4. `wallBodiesUnionPath()` больше не кодирует global success только наличием + одной строки `d`; successful component set и successful-empty различаются. +5. Static и full render используют одну component-to-path projection. +6. Commit barrier вызывается до mutation visibility/save. Если старый handler + мутирует config in place, adapter обязан восстановить before до toast и + requestUpdate; предпочтителен candidate-first путь. + +## 13. Производительность + +На валидном large-house fixture новый component-aware pass не более чем на 10% +и 20 ms p95 медленнее текущего wall geometry same-run baseline. Degraded fixture +завершается не более чем за 100 ms p95. Render cache сохраняет прежний bounded +lifecycle; strict commit check вызывается только при geometry commit, не на +pointermove/state tick. Model-invariants допускает линейный per-space pass. + +## 14. Touch и accessibility + +View/киоск touch release-blocking: fallback wall components pointer-transparent +и визуально совпадают с desktop. Rejected geometry gesture показывает toast, +не требует hover и не оставляет captured pointer. Plan editor остаётся +desktop-first; safety behavior обязательно для mouse/touch/pen. + +## 15. Критерии приёмки + +| AC | Критерий | Доказательство | +|---|---|---| +| AC1 | Анонимизированный fixture исходно даёт extra union failure при валидном core; новый result — `degraded-extra`, не global null. | Regression unit with failure proof. | +| AC2 | Exception/invalid result одного extra сохраняет primary и следующие extras; permutations дают то же component set. | Unit + property/permutation matrix. | +| AC3 | Invalid raw extra локально пропускается, count/reason bounded; core room failure остаётся `failed-core`. | Negative unit matrix. | +| AC4 | Coincident isolated component не вырезает primary path по evenodd и wall layer остаётся видимым в Plan/View. | Production-bundle smoke + golden. | +| AC5 | Full/static/hidden Iso/floor/paper/Glow/source guard/sun используют один component set; room area не включает extras. | Consumer parity tests + code review. | +| AC6 | Strict preflight принимает только `ok/not-applicable`; #199 остаётся red на degraded candidate и green после valid #276 reconciliation. | Unit + Optimize smoke. | +| AC7 | Каждая geometry writer surface из §7 проходит common barrier; forced degraded result даёт rollback, toast, 0 WS/Undo. | Call-site table + browser mutation smoke. | +| AC8 | Non-geometry config edit не запускает strict pass и не блокируется legacy degraded space. | Call-count unit/browser smoke. | +| AC9 | Stale candidate fingerprint rechecks; exception не оставляет in-memory partial state. | Controlled async unit + smoke. | +| AC10 | `model-invariants` красный на fixture и зелёный на valid/empty/image-only/virtual-only matrix. | CLI tests. | +| AC11 | Existing #197 junction isolation и core fail-dark regressions остаются зелёными; raw room fallback не возвращён. | Existing + focused unit. | +| AC12 | Mutation guards ловят global-catch regression, discarded isolated body, strict-accepts-degraded, writer-bypass и invariants-bypass. | Mutation gate, 1/1 each. | +| AC13 | Performance/cache budgets §13 выполняются. | Targeted benchmark + cache test. | +| AC14 | Typecheck, unit, build, targeted smokes зелёные; tracked bundles byte-identical. | Fast gates. | +| AC15 | RU/EN changelog и user/wall/architecture/testing/status docs актуальны. | Docs gate + review. | + +## 16. План тестов + +- минимизировать `33.json` до synthetic fixture без пользовательских строк; +- `test/wall-thickness.test.mjs`: core/extra isolation, invalid/permutations; +- новый pure transaction-barrier test и source/call-site inventory guard; +- `test/plan-geometry-preflight.test.mjs`: strict degraded classification; +- CLI fixture tests для `model-invariants`; +- `demo/smoke_wall_union_isolation.mjs`: production bundle Plan/View/static, + failed geometry write, toast/zero-write и valid retry; +- targeted light/dark golden до/после local failure; +- mutation ids из AC12; +- valid/degraded large-house benchmark. + +Полные golden/smoke/performance и Linux HA harness запускаются перед бетой. + +## 17. Release-артефакты и rollback + +Изменение пользовательское. Implementation commit имеет `User-Visible: yes` и +обновляет: + +- `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md` со ссылкой #278; +- `docs/USER-GUIDE.md`, `docs/USER-GUIDE.ru.md` — safe rejection/recovery; +- `docs/WALL-THICKNESS.md`, `docs/ARCHITECTURE.md`, `docs/CANVAS.md`; +- `docs/TESTING.md`, `docs/STATUS.md` и model-invariants documentation; +- RU/EN i18n, tests/smoke/mutations/benchmark, reviewed golden candidates; +- tracked bundles и docs screenshot fingerprint при source diff. + +Rollback — revert implementation commit. Persisted schema не меняется, поэтому +обратной миграции нет. После rollback affected legacy plans снова могут терять +весь wall layer, а geometry writers — сохранить unchecked candidate; release +rollback должен рекомендовать не редактировать геометрию до обновления. + +## 18. Принятые технические предположения + +1. Exact class/reason names не API; strict/render-safe semantics являются API + внутри продукта. +2. Bounded toast §7 — минимальная обратная связь для редкого race/boolean + failure; отдельный dialog и список объектов не нужны. +3. Internal diagnostic может считать failed extras, но production console не + печатает пользовательские ids/coordinates и не спамит каждый render. +4. #276, затем #277, затем #278 — порядок merge. При интеграции #278 удаляет + временный strict adapter #277, сохраняя его AC. +5. `degraded-extra` допустим только для чтения существующего config; ни Optimize, + ни editor commit не считает его успешной моделью. diff --git a/docs/specs/README.md b/docs/specs/README.md index 7931fa51..26c662ab 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -72,6 +72,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным | [#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) | | [#277](https://github.com/Matysh/houseplan-card/issues/277) Безопасный Resize без изменения топологии | [277-safe-resize.md](277-safe-resize.md) | +| [#278](https://github.com/Matysh/houseplan-card/issues/278) Локальный сбой extra-body union не гасит всю кладку | [278-wall-union-isolation.md](278-wall-union-isolation.md) | ## P2