# 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.