docs(spec): define hidden obstacle optimization

Issue: #296
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-24 19:10:27 +00:00
committed by claude[bot]
parent 4feeebc355
commit f7a19a35f1
2 changed files with 450 additions and 0 deletions
+449
View File
@@ -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.
+1
View File
@@ -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