Files
houseplan-card/docs/specs/296-optimize-hidden-obstacles.md
T
2026-08-24 19:10:27 +00:00

31 KiB
Raw Blame History

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.