From f7a19a35f1378a8e5f7a4d63118be6296838ec96 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Mon, 24 Aug 2026 21:37:44 +0300 Subject: [PATCH] docs(spec): define hidden obstacle optimization Issue: #296 User-Visible: no --- docs/specs/296-optimize-hidden-obstacles.md | 449 ++++++++++++++++++++ docs/specs/README.md | 1 + 2 files changed, 450 insertions(+) create mode 100644 docs/specs/296-optimize-hidden-obstacles.md diff --git a/docs/specs/296-optimize-hidden-obstacles.md b/docs/specs/296-optimize-hidden-obstacles.md new file mode 100644 index 00000000..2d24e3d1 --- /dev/null +++ b/docs/specs/296-optimize-hidden-obstacles.md @@ -0,0 +1,449 @@ +# Issue #296 — Optimize удаляет доказанно избыточные скрытые стены + +- **Issue:** https://github.com/Matysh/houseplan-card/issues/296 +- **Редакция:** первая редакция для независимого ревью; статус определяется только метками issue +- **Тип / приоритет:** bug / P1 +- **Трек:** обычный +- **Оценка:** пользовательская ценность 9/10; ценность для разработки 8/10; + сложность и риск 7/10 +- **Область:** Optimize, составные совпадения `partitions`, полностью избыточные + `room_drafts`, backend-доказательство изменения host, диагностический слой редактора +- **Связано:** #137, #173, #276, #277, #280, #281, #282, #292, #294, + `docs/CANVAS.md`, `docs/RESIZE.md`, `docs/WALL-THICKNESS.md` + +## 1. Сценарий и продуктовый контекст + +**Персона:** администратор дома, поддерживающий точную планировку в редакторе Плана. + +**Поверхность:** редактор Плана и явная команда «Оптимизировать планы» в настройках. + +**Момент:** после нескольких циклов рисования, импорта и редактирования одна +независимая стена или сохранённая цепочка геометрически оказывается точно под +стенами комнат. В View лишний объект не различим, но Resize считает его отдельным +физическим препятствием и блокирует безопасную ручку. + +Задача поддерживает **J6**: существующий план можно диагностировать, исправить и +дальше редактировать без ручного исправления JSON. + +## 2. Что человек увидит до и после + +**До:** Optimize сообщает, что изменений нет, хотя невидимые `partition` и +`room_draft` продолжают блокировать Resize. В редакторе их оси и узлы скрыты телом +другой стены, поэтому причину нельзя найти визуально. + +**После:** в редакторе оси и исходные узлы перекрытых независимых стен видны выше +всех тел стен. Optimize по частям преобразует только доказанно совпадающие участки +`partition` в канонические стены комнат, удаляет только целиком доказанно +избыточные drafts, сохраняет все остальные участки и проёмы. После применения и +reload безопасные Resize-ручки становятся активными. + +## 3. Подтверждённая причина + +На приложенном экспорте `houseplan-full-2026-08-24_17-36-50.json` текущий `dev` +возвращает `changed:false`, `partitionsReconciled:0`, `removedDrafts:0` и оставляет: + +- `partition-mt2on9ou-0` — вертикаль `x=-401`, `y=304…928`, `30 cm`; +- `partition-room-mt7ijuyq-0` — вертикаль `x=-85`, `y=549…928`, `20 cm`; +- `draft-mt7igts5` — двухточечную горизонталь `y=928`, `x=-85…254`, `30 cm`. + +`scripts/model-invariants.mjs` независимо классифицирует их как две +`partition_over_room_wall` и один `unusable_draft`. + +Причины в коде: + +1. `reconcileCoincidentPartitions()` принимает лишь полное `sameSegment()` одной + partition и одного `WallInterval`; составная линия из нескольких соседних + интервалов никогда не получает owners. +2. `alignAllToGrid()` удаляет только drafts короче двух точек. Незамкнутая цепочка + из двух точек законна по #173/#294, поэтому point-count нельзя сделать новым + разрушительным критерием. +3. `_safe_optimize_partition_rehost()` на backend доказывает покрытие удалённого + host только одним room edge и не принимает эквивалентное составное покрытие. +4. Snap-геометрия #137 дедуплицирует совпадающую ось в пользу комнаты и существует + только в инструменте рисования; это не является постоянной диагностикой + скрытого самостоятельного объекта. + +## 4. Scope + +В issue входят: + +1. Piecewise reconciliation одной `partition`, совпадающей с несколькими + последовательными solid-интервалами стен комнат. +2. Сохранение непримирённых остатков partition и безопасная перепривязка hosted + openings после её разрезания. +3. Удаление `room_draft` целиком только при доказанной полной избыточности каждого + его сегмента. +4. Симметричное fail-closed доказательство допустимого Optimize-delta на backend. +5. Диагностические оси и исходные узлы перекрытых `partition`/сохранённых drafts + выше всех тел стен во всём редакторе Плана. +6. Точные пользовательские счётчики preview/result и один Undoable Optimize. +7. Реальные обезличенные fixtures второго и первого этажей, unit, backend, + production-bundle smoke, invariant/Resize audit, golden и performance-покрытие. +8. RU/EN документация и changelog. + +## 5. Non-scope + +В issue не входят: + +- автоматический Optimize при загрузке, рисовании или Resize; +- удаление draft только из-за незамкнутости или числа точек; +- частичное разрезание `room_draft`; +- исправление произвольных пересечений, зазоров, почти совпадающих или + неколлинеарных стен; +- удаление законных колонн и не влияющей на Resize перегородки первого этажа; +- новые кнопки, настройки, предупреждения или режим очистки; +- изменение правил доступности Resize, кроме результата удаления реального + дублирующего препятствия; +- изменение snap-приоритетов и hit-testing #137; +- показ диагностического слоя в View, kiosk, editor Устройств/Декора/Подложки или + скрытом изометрическом режиме; +- миграция схемы или изменение `model_version` только ради этой задачи. + +## 6. Piecewise reconciliation partitions + +### 6.1 Источник доказательства + +Алгоритм остаётся чистой частью явного Optimize и не мутирует вход. Для каждой +исходной partition в стабильном порядке `id` он строит одномерную координату вдоль +её оси и собирает breakpoints из: + +- начала и конца partition; +- концов всех коллинеарных solid `WallInterval`, имеющих положительное пересечение + с partition; +- границ hosted openings на этой partition. + +Соседние одинаковые координаты дедуплицируются с действующим геометрическим +epsilon. Нулевые куски отбрасываются. Направление входной partition не влияет на +результат и стабильность идентификаторов. + +Для каждого положительного атомарного участка независимо доказывается контракт +#276: + +- участок целиком совпадает с solid-стеной, а не `open`, `open-span` или вырезом; +- owner-kind однозначен: ровно одна комната для `outer` либо ровно две разные + комнаты для `shared`; +- все owners описывают тот же атомарный участок и одну эффективную толщину; +- нет перекрытия другой независимой partition, draft или wall column; +- неизвестные поля исходной partition отсутствуют; +- преобразование не создаёт пересекающихся проёмов и проходит общий geometry + preflight. + +Неполное покрытие всего исходного сегмента не отменяет доказанные атомарные +участки. Небезопасный участок остаётся независимой стеной. + +### 6.2 Толщина и каноническая стена + +Для каждого согласованного участка итоговая физическая толщина равна +`max(roomWallCm, partition.cm)`. Толщина никогда не уменьшается. Она записывается +через действующий интервальный контракт `walls`, после чего обычная канонизация +может объединить соседние равные участки. + +Для `partition-mt2on9ou-0` профиль `30/20/30 cm` становится `30/30/30 cm`. Для +участков второй partition действует то же правило `max`, без расширения вне её +фактического span. + +### 6.3 Остатки и идентификаторы + +Все непримирённые соседние атомарные участки с одинаковой толщиной и совместимым +набором hosted openings объединяются обратно в максимальные остатки. Если остаток +один, он сохраняет исходный `id`. Если остатков несколько: + +- первый в каноническом порядке сохраняет исходный `id`; +- остальные получают детерминированные collision-safe производные id; +- повторный Optimize/reload не создаёт новые id и не меняет порядок; +- лимит `MAX_PARTITIONS=2000` проверяется до принятия преобразования. + +Если безопасно разместить все остатки и их openings в пределах лимита нельзя, +исходная partition сохраняется целиком и не попадает в счётчик. + +### 6.4 Hosted openings + +Каждый hosted opening сначала разрешается относительно исходной целой partition в +абсолютные `center`, `angle`, `length`. + +- Opening, целиком лежащий на доказанно согласованном участке, материализуется как + обычный room-wall opening по #276; все пользовательские поля сохраняются. +- Opening на остатке получает корректный residual host и новый `t`; абсолютная + геометрия и остальные поля остаются прежними. +- Opening, пересекающий breakpoint или границу согласованного участка, делает + затронутый непрерывный диапазон остатком. Его нельзя делить, обрезать или терять; + доказанные несвязанные куски той же partition можно согласовать. +- Неразрешимый host, неоднозначная room association, наложение openings или + нарушение jamb-margin оставляет затронутый исходный кандидат без изменений. + +`openingsRehosted` считает только openings, с которых действительно снят partition +host; перепривязка между residual partitions в этот счётчик не входит. + +### 6.5 Счётчик + +`partitionsReconciled` считает успешно преобразованные максимальные непрерывные +участки, а не число исходных записей. Один исходный объект может дать несколько +единиц. RU/EN текст результата должен говорить об «участках независимых стен», а +не обещать число удалённых записей. + +## 7. Полностью избыточные room drafts + +Незамкнутость и число точек сами по себе не являются основанием удаления. + +Сохранённый draft удаляется только целиком, когда для **каждого** положительного +сегмента доказано: + +- конечные координаты и ненулевая длина; +- полное коллинеарное покрытие одним или несколькими соседними solid-интервалами + стен комнат; +- отсутствие open/open-span/проёма на покрываемой части; +- отсутствие уникального выступа, зазора или неоднозначности. + +Если любой сегмент не проходит проверку, весь draft, его `points`, `segments`, id, +порядок и неизвестные разрешённые поля сохраняются без изменений. Draft не +нарезается. `draft-mt7igts5` проходит доказательство и удаляется; обычная +двухточечная свободная цепочка #173/#294 остаётся. + +`removedDrafts` увеличивается на число удалённых исходных drafts. Удаление входит +в preview, одну транзакцию Apply и общий Undo. + +## 8. Backend security contract + +Backend не доверяет frontend-счётчикам и независимо доказывает delta относительно +предыдущей конфигурации при `allow_optimize_rehost=true`. + +Расширенное доказательство обязано: + +1. восстановить ось и атомарное покрытие старой partition стенами комнат в новой + конфигурации; +2. доказать, что удалённые диапазоны полностью покрыты solid room walls не уже + старой partition; +3. доказать эквивалентность всех сохранённых остаточных диапазонов, их толщин, + ids/host bindings и абсолютной геометрии openings; +4. разрешать снятие host только для opening, целиком покрытого доказанным room-wall + диапазоном, с неизменными пользовательскими полями и без overlap; +5. отклонять crafted candidate при зазоре, open span, лишнем/потерянном остатке, + уменьшении толщины, изменённом opening, неоднозначном owner или неизвестном + поле. + +Обычные записи вне endpoint Optimize остаются под прежним строгим контрактом. +Ошибка backend не оставляет частично применённый frontend-state. + +## 9. Диагностическая видимость скрытых стен + +### 9.1 Что считается скрытым объектом + +Диагностический кандидат — сегмент независимой `partition` или сохранённого +`room_draft`, имеющий положительное точное коллинеарное совпадение с телом другой +стены. Почти параллельные, пересекающиеся только в точке и просто близкие стены не +подсвечиваются. + +Для каждого кандидата отображаются: + +- полная ось исходного независимого сегмента; +- его реальные исходные endpoints, включая endpoint внутри более длинной стены; +- отдельные исходные endpoints совпадающих самостоятельных объектов, даже когда + snap-resolver дедуплицирует их координату. + +### 9.2 Когда и где виден слой + +Слой существует во всём редакторе Плана при любом активном инструменте, чтобы +препятствие было видно до выбора Resize и во время диагностики. Он отсутствует в +View, kiosk и остальных редакторах. + +Он рисуется после тел **всех** реальных и виртуальных стен и до проёмов, +selection/editor chrome и transient previews. Поэтому кладка не может закрыть ось +или узел. Слой имеет `pointer-events:none`, `aria-hidden=true`, не получает focus и +не меняет существующий выбор/редактирование объекта. + +В режиме рисования действующий snap-overlay #137 продолжает показывать всю +архитектуру и выбирать каноническую комнату для совпадающей оси. Диагностическая +проекция строится отдельно и не меняет `buildPlanSnapGeometry()`, приоритеты, +line-snap, endpoint-snap или ambiguity resolution. + +### 9.3 Визуальный контракт + +Ось — 1 CSS px с `non-scaling-stroke`. Исходный узел сохраняет физический радиус +5 cm из #137. Диагностические линии и узлы используют существующие контрастные +tokens светлой/тёмной/forced-colours тем без анимации. Совпадение с обычным +snap-overlay не должно визуально удваивать stroke/opacity. + +После успешного Optimize слой исчезает только потому, что доказанно избыточный +самостоятельный объект удалён/согласован из модели. Preview Optimize не скрывает +объект до Apply. + +## 10. Atomicity, Undo и идемпотентность + +- Preview не мутирует config/layout и показывает точные счётчики. +- Apply записывает config/layout одной существующей Optimizer-транзакцией. +- Любая ошибка schema, geometry preflight или backend отменяет весь Apply. +- Один Undo возвращает partitions, drafts, hosts, openings и прежние толщины. +- После успешной записи и reload второй Optimize возвращает `changed:false` и + нулевые новые счётчики. +- Optimize не создаёт изменений только ради `model_version` или диагностического + слоя. + +## 11. Данные, i18n и accessibility + +Новых schema fields, storage keys и миграции нет. Разрезание partition использует +действующие `partitions`, `walls` и opening `host`; диагностическая проекция +полностью производная и не сохраняется. + +Новых кнопок и сообщений нет. Изменившаяся строка счётчика участков обновляется в +обоих встроенных словарях RU/EN, без смешения языков. Маркеры декоративны, +`aria-hidden`, не меняют keyboard/focus order и не заменяют существующие controls. + +## 12. Принятые предположения + +1. Действующий геометрический epsilon, wall-key pitch и единицы конфигурации + остаются авторитетными; новый пользовательский tolerance не вводится. +2. Unknown fields на partition делают исходную запись неделимой и сохраняемой + целиком. Для draft действует all-or-nothing сохранение записи. +3. Первый residual span сохраняет исходный id в каноническом направлении, чтобы + минимизировать churn ссылок; это техническая деталь без нового schema field. +4. Диагностический слой показывает только действительно перекрытые независимые + сегменты, а не все стены во всех инструментах; полный общий overlay остаётся + draw-only по #137. +5. Счётчик `partitionsReconciled` относится к максимальным согласованным участкам + после атомарного доказательства. + +## 13. Acceptance criteria + +### AC1. Реальный второй этаж очищается + +На обезличенном fixture второго этажа после Optimize: + +- отсутствуют `partition-mt2on9ou-0`, `partition-room-mt7ijuyq-0` и + `draft-mt7igts5`; +- `partitionsReconciled >= 2`, `removedDrafts == 1`; +- профиль первой линии равен `30/30/30 cm`, а физическая толщина нигде не меньше + исходной room/partition; +- invariant и Resize audit не находят `duplicate-physical-wall`; целевые ручки + Master bath/Garderobe и Bedroom/Office доступны; +- openings, комнаты, layout и несвязанные объекты эквивалентны входу. + +**Evidence:** real-fixture optimizer unit + `scripts/model-invariants.mjs` + +resize-availability audit. + +### AC2. Partial match сохраняет остаток + +Partition с одним совпадающим и одним свободным диапазоном преобразует только +совпадающую часть. Остаток сохраняет толщину, geometry и стабильный id; повторный +Optimize не режет его снова. + +**Evidence:** focused unit для прямого/обратного направления и round-trip. + +### AC3. Openings не теряются + +Opening на согласованной части становится обычным room opening; opening на +остатке остаётся hosted с теми же абсолютными center/angle/length; opening через +breakpoint сохраняет свой непрерывный host и блокирует только затронутый диапазон. +Ни один вариант не меняет пользовательские поля и не создаёт overlap. + +**Evidence:** frontend unit + Python backend positive/negative tests + production +smoke Optimize write. + +### AC4. Законные drafts сохраняются + +Свободная незамкнутая двухточечная цепочка, частично совпадающий draft, draft над +проёмом и неоднозначный draft остаются byte-equivalent. Полностью совпадающий +`draft-mt7igts5` удаляется целиком; частичной нарезки нет. + +**Evidence:** optimizer units и real fixture. + +### AC5. Первый этаж не переочищается + +На fixture первого этажа законная partition с длинным id и две колонны остаются; +доступно прежнее число Resize handles, а `partial-shared`/`unequal-shared` не +маскируются как enabled. + +**Evidence:** exact negative fixture audit. + +### AC6. Backend отклоняет подделанные delta + +Backend принимает только эквивалентный piecewise Optimize/rehost и отклоняет +каждое отдельное нарушение из §8, включая потерянный residual и opening. + +**Evidence:** targeted Python tests и HA/Linux CI harness. + +### AC7. Скрытая геометрия заметна поверх стен + +Во всех инструментах редактора Плана перекрытая partition и сохранённый draft +показывают полную ось и исходные endpoints поверх real/virtual wall bodies. Во +View DOM слоя нет. Snap результата #137 и pointer hit targets не меняются. + +**Evidence:** pure projection unit, production-bundle DOM/layer smoke и editor +golden для светлой/тёмной темы до/после Optimize. + +### AC8. Отчёт, Undo и reload честны + +Preview и result используют одинаковые счётчики; Cancel не меняет данные; один +Undo восстанавливает всё; после Apply+reload повторный Optimize — no-op. + +**Evidence:** plan-optimizer/store-flow tests и smoke. + +### AC9. Лимиты и производительность fail-closed + +Кандидат, превышающий schema limits, сохраняет исходную partition вместо частичной +записи. На large-house fixture Optimize и построение диагностической проекции +остаются в утверждённых budgets без pointermove-dependent полного render. + +**Evidence:** limit unit + benchmarks `coincident-partitions` и +`large-house-plan-snap`/обновлённые budgets только при обоснованной необходимости. + +## 14. Тестовый план + +Обязательны: + +1. `test/coincident-partitions.test.mjs`: composite full/partial/reversed, + differing cm, stable residual ids, limits, unknown fields, columns, other + partitions/drafts, openings на трёх позициях, idempotence. +2. `test/plan-optimizer.test.mjs` и `test/resize-optimize.test.mjs`: counters, + preview/Apply/Undo/reload и Resize reason transitions. +3. Обезличенный `test/fixtures/real-plan-second-floor.json` с тремя blockers и + первый этаж как negative fixture. +4. `test/plan-snap-overlay.test.mjs` либо отдельный pure projection suite: + independent coincident sources не теряют endpoints, snap authority комнаты не + меняется, non-overlap не получает diagnostic marker. +5. Python tests `_safe_optimize_partition_rehost`: positive composite/residual и + отдельный negative case на каждую fail-closed границу. +6. Named production smokes для реального Optimize/Resize и layer ordering; + существующие unrelated smokes остаются зелёными. +7. Mutation gate: удалить all-or-nothing draft guard, заменить `max` толщины, + потерять residual/opening, доверить frontend delta или поместить overlay под + стенами — тест обязан покраснеть. + +В реализации запускаются `npm run typecheck`, unit и build плюс адресные named +smokes. Golden, полный smoke и performance — по канону перед бетой; Linux CI +остаётся каноном полного HA harness. + +## 15. Performance и touch + +Piecewise pass выполняется только по явному Optimize. Индексация интервалов и +breakpoints должна быть ограничена затронутыми коллинеарными осями, без полного +quadratic scan на каждый атомарный кусок. + +Диагностическая проекция вычисляется из immutable model snapshot и кэшируется по +тем же revision inputs, что архитектурная геометрия. Pointermove не перестраивает +её. Touch получает те же статические маркеры; новых hover-only действий нет. + +## 16. Документация и release artifacts + +В том же user-visible коммите обязательны: + +- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`; +- RU/EN user guide: доказательная piecewise-очистка, сохранение незавершённых + цепочек и значение счётчиков; +- `docs/CANVAS.md`: диагностическая проекция и layer order; +- `docs/RESIZE.md`: исчезновение только реального duplicate obstacle; +- `docs/WALL-THICKNESS.md` и при необходимости `docs/ARCHITECTURE.md`: атомарный + Optimize/rehost контракт; +- актуальный screenshot fingerprint, если изменился пользовательский UI; +- editor golden light/dark для скрытой partition/draft; baseline принимается по + Linux CI review, не по случайному Windows raster. + +## 17. Риски и rollback + +Главные риски: потеря residual/opening, разрушение законного draft, уменьшение +толщины, backend/frontend рассинхрон, id churn, лишний editor DOM и ложная +доступность Resize. + +Rollback выполняется единым откатом коммита #296. Новых schema fields нет, поэтому +старые клиенты читают сохранённую каноническую геометрию. Пользовательский rollback +одной операции — существующий Undo до ухода со страницы; после durable save +восстановление возможно из обычного backup/export. diff --git a/docs/specs/README.md b/docs/specs/README.md index 598dbac7..f8fee35c 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -77,6 +77,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным | [#280](https://github.com/Matysh/houseplan-card/issues/280) Backend принимает доказанный Optimize rehost | [280-optimize-rehost-validation.md](280-optimize-rehost-validation.md) | | [#281](https://github.com/Matysh/houseplan-card/issues/281) Честный Resize после outer-partition reconciliation | [281-resize-zero-range.md](281-resize-zero-range.md) | | [#293](https://github.com/Matysh/houseplan-card/issues/293) Активная рукоятка Resize выполняет pointer-жест | [293-resize-pointer-noop.md](293-resize-pointer-noop.md) | +| [#296](https://github.com/Matysh/houseplan-card/issues/296) Optimize удаляет доказанно избыточные скрытые стены | [296-optimize-hidden-obstacles.md](296-optimize-hidden-obstacles.md) | ## P2