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

450 lines
31 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.