mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
450 lines
31 KiB
Markdown
450 lines
31 KiB
Markdown
# 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.
|