Files
houseplan-card/docs/specs/138-adjacent-room-autoclose.md
T
2026-08-14 12:59:09 +00:00

460 lines
35 KiB
Markdown
Raw 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 #138 — автозамыкание комнаты по существующей стене
- **Issue:** https://github.com/Matysh/houseplan-card/issues/138
- **Редакция:** r2 после High из `docs/reviews/SPEC-REVIEW-138-r1.md`;
статус определяется только метками issue
- **Тип / приоритет:** bug / P1
- **Оценка:** пользовательская ценность 9/10; ценность для разработки 7/10;
сложность 5/10; риск 7/10
- **Область:** редактор Плана, инструмент «Контур комнаты», архитектурная
endpoint/line-привязка #137, замыкание и сохранение room draft
- **Модель данных:** без новых полей, миграции и backend-изменений
- **Связано:** #137, `docs/SCOPE.md`, `docs/ARCHITECTURE.md`,
`docs/CANVAS.md`, `docs/WALL-THICKNESS.md`, `docs/TOUCH-SUPPORT.md`
## 1. Сценарий и продуктовый контекст
**Персона:** администратор дома, который дорисовывает соседнее помещение в
редакторе Плана.
**Поверхность:** desktop browser с мышью или точным pointer. Touch editor остаётся
best effort по `docs/TOUCH-SUPPORT.md`.
**Момент:** пользователь начинает новый контур на углу либо середине стены
существующей комнаты, обходит новое помещение и заканчивает контур на другом месте
той же непрерывной стены.
Задача поддерживает:
- **J4:** план можно точно нарисовать встроенным GUI без ручного редактирования SVG;
- **J6:** соседние помещения используют общую границу без микрозазоров и лишних
обходных действий.
## 2. Что человек увидит до и после
**До:** #137 точно привязывает финальный клик к существующему углу или линии, но
редактор сохраняет его как ещё одну точку открытого draft. Диалог новой комнаты не
открывается, хотя последний и первый узлы уже однозначно соединены существующей
сплошной стеной.
**После:** если первая и последняя точки нового контура лежат на одном непрерывном
сплошном интервале существующей стены, финальный клик сразу использует участок этой
стены как замыкающее ребро и открывает обычный диалог новой комнаты.
## 3. Причина дефекта
`_resolvePlanDrawPoint()` уже возвращает точную endpoint- или line-snap координату,
но `_markupClick()` считает контур замкнутым только при клике в собственную первую
точку либо при `Ctrl`/`Cmd` + click. При клике в другую точку существующей стены
результат snap добавляется в `_path` и сохраняется в `room_drafts`, после чего
рисование остаётся открытым.
Это не ошибка точности #137: отсутствует продуктовый контракт, связывающий два узла
одного существующего сплошного wall interval с неявным замыкающим ребром.
## 4. Решения владельца
Владелец подтвердил 2026-08-14 все предложенные defaults:
1. Автозамыкание действует только тогда, когда первая и последняя точки лежат на
одном прямом каноническом ребре существующей комнаты. Допустимы исходные
endpoints/углы и промежуточные line-snap точки. Разные рёбра одной комнаты не
подходят.
2. Door/window/gate и `open_span` разрывают допустимую стену. Первая и последняя
точки должны принадлежать одному непрерывному видимому сплошному интервалу #137.
3. Подходящий финальный клик сразу добавляет существующий интервал как неявное
замыкающее ребро и открывает стандартный диалог новой комнаты, без отдельного
подтверждения.
4. Новая визуальная индикация и новые строки не нужны: используются увеличенная
snap-точка, live-preview и существующий диалог.
5. Приоритет задачи — P1.
## 5. Scope
В issue входят:
1. Распознавание подходящего финального клика инструмента «Контур комнаты» после
актуального endpoint/line resolution #137.
2. Проверка принадлежности первой и финальной точек одному room-owned solid
interval из общего архитектурного snap snapshot.
3. Автозамыкание валидного контура существующим участком стены и открытие обычного
диалога комнаты.
4. Сохранение существующих validation, draft, per-segment thickness, history,
Cancel/Save и Undo/Redo контрактов.
5. Unit и production-bundle smoke для положительных, отрицательных и touch-safety
сценариев.
6. Обновление пользовательской и архитектурной документации.
## 6. Non-scope
В issue не входят:
- автозамыкание между разными рёбрами одной или разных комнат;
- поиск пути по нескольким коллинеарным либо угловым стенам;
- автозамыкание по `room_drafts`, `partitions`, колоннам, декору или подложке;
- проведение замыкающего ребра через дверь, окно, ворота или виртуальную границу;
- автоматическое дробление, переписывание или удаление существующей стены;
- новая кнопка, настройка, toast, анимация, цвет или i18n-ключ;
- изменение endpoint/line hit radius, приоритета и визуального слоя #137;
- исправление старой off-grid геометрии;
- новая schema, backend API, storage key, импорт/экспорт или миграция;
- полный hover-паритет редактора на coarse pointer.
## 7. Контракт распознавания
### 7.1 Порядок разрешения клика
Финальный click/tap сначала повторно получает авторитетную точку через действующий
resolver #137. Hover-кандидат не используется как сохранённое обещание. Проверка
автозамыкания выполняется до обычного добавления точки в draft и до `_draftEndAt()`,
чтобы существующий endpoint draft не перехватил подходящий клик.
Существующие явные варианты имеют приоритет и не меняются:
1. клик в собственную первую точку замыкает контур как сейчас;
2. `Ctrl`/`Cmd` + click выполняет существующее быстрое замыкание;
3. только отличный от первой точки обычный клик, после добавления которого в
prospective path будет не меньше трёх вершин, проверяется как новое
автозамыкание по существующей стене.
Повторный клик в текущий anchor остаётся no-op и не создаёт нулевой сегмент.
Если в path пока находится только первая точка `A`, второй клик `B` не считается
попыткой автозамыкания даже при общем solid interval `A—B`: он без validation
toast добавляется в draft обычным способом. Это тот же минимальный gate, который
уже защищает ручное замыкание кликом в первую точку.
### 7.2 Допустимый существующий интервал
После прохождения gate минимального числа вершин автозамыкание разрешено, если
существует хотя бы один сегмент текущего архитектурного snap snapshot, который
одновременно:
- имеет `sourceKind: room` и происходит из завершённого контура комнаты;
- является одним прямым сплошным интервалом после канонических cuts #137;
- содержит первую точку нового контура;
- содержит финальную resolved-точку нового контура;
- имеет положительную длину, а две точки на нём различны.
«Содержит» означает коллинеарность и положение внутри закрытого интервала с
геометрической точностью существующих pure helpers, а не визуальную близость в CSS
pixels. Если угол принадлежит двум стенам, достаточно одного общего подходящего
интервала для первой и финальной точки. Стабильный порядок решает технические ties,
не меняя полученную линию замыкания.
`room_draft` и `partition` остаются видимыми snap-кандидатами #137, но не могут
стать неявной границей новой комнаты.
### 7.3 Проёмы и виртуальные участки
Используется уже разрезанная геометрия #137:
- door/window/gate и `open_span` удаляются из room axis до проверки;
- граница cut не становится новым постоянным endpoint;
- точки по разные стороны любого cut не принадлежат одному solid interval и не
запускают автозамыкание;
- точка внутри cut не получает line-snap, как и до этой задачи.
Новый код не вводит второй resolver проёмов или виртуальных границ.
## 8. Контракт замыкания и валидации
### 8.1 Успешный финальный клик
Пусть `A` — первая точка draft, `P` — его текущий конец, `B` — отличный от `A`
resolved-финал на общем solid interval `A—B`.
Проверка автозамыкания начинается только когда до клика path уже содержит минимум
две вершины, поэтому добавление `B` создаёт prospective path минимум из трёх
вершин. Для подходящего клика редактор рассматривает prospective polygon
`[..., P, B] + B—A` и требует минимум три различные вершины. До пользовательского
диалога он применяет те же проверки минимального размера, нулевой площади,
self-intersection, overlap и общих geometry limits, что и обычное замыкание.
Если контур валиден, одним пользовательским действием:
1. `B` становится настоящей последней вершиной нового контура;
2. нарисованный сегмент `P—B` сохраняется как обычный завершённый draft segment с
выбранной для него толщиной;
3. `B—A` считается замыкающим ребром комнаты;
4. открывается существующий диалог имени/HA-зоны комнаты.
Отдельного confirm, toast об успехе или промежуточного UI нет.
### 8.2 Невалидный prospective contour
Если при достаточном количестве вершин подходящий клик дал невалидный prospective
polygon:
- диалог комнаты не открывается;
- `B`, `P—B` и `B—A` не записываются;
- draft остаётся в том же редактируемом состоянии, что до клика;
- показывается существующий подходящий validation toast;
- config, layout и history не получают частично применённой операции.
Клик не превращается после ошибки в обычное добавление `B`: подходящая точка уже
означает намерение замкнуть контур, как клик в собственную первую точку.
Недостаточное количество вершин не является такой ошибкой: оно отсекается до
eligibility и обрабатывается как обычный клик по §8.3 без toast.
### 8.3 Обычный клик
Если общего room-owned solid interval нет либо после добавления resolved-точки в
path всё ещё будет меньше трёх вершин, поведение остаётся полностью текущим:
resolved-точка добавляется в открытый draft, сегмент сохраняется, а диалог не
открывается. Это относится в том числе к:
- второму клику `B` после единственной первой точки `A`, даже если `A` и `B` —
разные endpoints одной существующей стены;
- разным рёбрам одной комнаты;
- разным комнатам;
- точкам по разные стороны opening/open-span cut;
- `room_draft` и `partition`;
- обычной точке сетки рядом со стеной.
После такого клика пользователь по-прежнему может явно замкнуть контур первой
точкой или `Ctrl`/`Cmd` + click.
## 9. Draft, диалог, история и толщина
- Каждый явно нарисованный сегмент до и включая `P—B` сохраняет текущую толщину в
`room_drafts` по существующему контракту.
- Неявный `B—A` не создаёт independent partition или отдельную физическую запись.
Он становится room-boundary edge только при сохранении комнаты.
- **Save** удаляет соответствующий draft и создаёт обычную комнату через текущий
commit/history boundary.
- **Cancel** стандартного диалога снимает состояние замкнутого контура, но оставляет
открытый draft с последней настоящей точкой `B` и сегментом `P—B`, как при
существующем ручном замыкании.
- **Оставить замкнутыми стенами** продолжает использовать текущий путь конвертации
рёбер в independent partitions.
- Undo/Redo, смена инструмента, reload/resume, external config adoption и лимит
50 команд не получают отдельной ветки поведения.
- На общей границе `B—A` сохраняется уже существующая толщина соседней комнаты.
Выбранная толщина рисования применяется только к новым внешним участкам по
действующему `applyWallThicknessToNewRoom`-контракту.
- Room-boundary walls остаются производными и дедуплицированными. Автозамыкание не
создаёт вторую физическую стену поверх общей границы.
## 10. Touch, accessibility и визуальная деградация
**Touch editor: best effort / intentionally degraded.**
- Tap без предварительного pointermove повторно решает snap и выполняет тот же
контракт автозамыкания.
- Hover или увеличенная точка до tap на no-hover устройстве не обещаются.
- Pinch, pan, pointercancel, второй touch и suppressed synthetic click не должны
замыкать или менять draft.
- Новый DOM, focus target, ARIA-содержимое или клавиатурная команда не создаются.
- View и kiosk не меняют pixels, gestures или действия.
## 11. Модель данных, совместимость и миграция
Новых данных нет.
- `rooms[].poly`, `room_drafts`, `walls`, `openings` и `open_spans` сохраняют
текущую схему;
- старые и импортированные планы читаются без миграции;
- backend validation и integration API не меняются;
- downgrade не требует data rollback: созданная комната является обычным polygon;
- новая версия меняет только момент интерпретации подходящего финального клика.
## 12. Архитектурный и performance-контракт
1. Проверка общего интервала является чистой геометрической операцией над
существующим immutable snap snapshot; SVG DOM не является источником данных.
2. Canonical opening/open-span cuts переиспользуются из `plan-snap-overlay.ts` или
эквивалентного общего helper, без дублирования wall topology.
3. Проверка выполняется только на click/tap. Она не добавляет работу в pointermove,
не пересобирает статическую геометрию и не меняет O(E) overlay DOM #137.
4. Линейный O(S) просмотр room-owned solid segments на click допустим. Новый
постоянно растущий cache или spatial index не требуется.
5. Prospective validation завершается до mutation. Успех использует существующие
draft/save/history границы; ошибка не требует rollback частичного состояния.
6. Решение не создаёт websocket, HA service, fetch, timer, storage key или внешнюю
зависимость.
## 13. Acceptance criteria
- **AC1 (`unit` + `smoke`; разработчик):** контур, начатый в endpoint `A`
завершённой room wall, после обхода нового помещения заканчивается в другом
endpoint `B` того же непрерывного solid interval; финальный click сохраняет
`P—B`, замыкает polygon через `B—A` и сразу открывает стандартный room dialog.
- **AC2 (`unit` + `smoke`; разработчик):** тот же результат получается, когда `A`,
`B` или обе точки являются line-snap точками внутри одного solid room interval;
сохранённые координаты остаются точно на существующей стене.
- **AC3 (`unit` + `smoke`; разработчик):** если path содержит только первую точку
`A`, второй click в отличный endpoint/line-node `B` того же solid room interval
не запускает автозамыкание и validation: `B` добавляется как обычная точка
открытого draft без dialog и toast, после чего контур можно продолжить.
- **AC4 (`unit` + `smoke`; разработчик):** разные рёбра одной комнаты, стены разных
комнат, saved draft и partition не запускают автозамыкание: финальная точка
остаётся обычной точкой открытого draft, а явное замыкание первой точкой и
`Ctrl`/`Cmd` продолжают работать.
- **AC5 (`unit` + `smoke`; разработчик):** door/window/gate или `open_span` между
`A` и `B` разрывает общий interval и не допускает автозамыкание; cut boundary не
становится новым endpoint и отдельный resolver проёмов не появляется.
- **AC6 (`unit` + `smoke`; разработчик):** при достаточном числе вершин zero-area,
self-intersecting, overlapping, слишком маленький или выходящий за лимиты
prospective contour не
открывает диалог, показывает существующий validation toast и оставляет draft,
config и history в состоянии до финального клика.
- **AC7 (`unit` + `smoke`; разработчик):** успешный финальный `P—B` сохраняет
выбранную толщину; Cancel оставляет открытый draft с `B`, Save создаёт комнату и
удаляет draft, а secondary action создаёт замкнутые стены по текущему контракту.
- **AC8 (`unit` + wall-thickness regression; разработчик):** `B—A` использует
существующую толщину общей стены, новые внешние рёбра сохраняют свои per-segment
значения, room geometry не создаёт duplicate partition или двойное физическое
wall body.
- **AC9 (`unit` + code review; разработчик/ревьюер):** собственная первая точка и
`Ctrl`/`Cmd` имеют прежний приоритет, current anchor остаётся no-op, а eligibility
решается до resume/draft endpoint handling.
- **AC10 (`smoke`; разработчик):** tap без hover выполняет тот же результат; pan,
pinch, pointercancel и suppressed synthetic click не меняют geometry. View,
kiosk, остальные Plan tools и другие editors не меняются.
- **AC11 (`unit` + performance review; разработчик/ревьюер):** общий interval
определяется из уже кэшированного snapshot чистым O(S) helper только на click;
pointermove, cache size, DOM count и network/storage activity #137 не растут.
- **AC12 (`typecheck` + `unit` + `build` + documentation review; разработчик):**
implementation-loop gates зелёные, три bundle-копии побайтно одинаковы,
пользовательская документация и оба changelog обновлены в том же видимом
коммите.
- **AC13 (`schema/security review`; ревьюер):** backend, schema, import/export,
i18n, HA permissions/calls и зависимости не меняются; старые планы совместимы.
## 14. План автотестов
### 14.1 Unit
Добавить pure-helper покрытие:
1. оба endpoints одного room segment дают общий interval;
2. endpoint + interior point и две interior line-snap точки дают общий interval;
3. при path `[A]` второй click `B` на том же room interval не вызывает eligibility,
dialog или toast, а добавляет `B` и `A—B` в открытый draft;
4. угол, принадлежащий двум рёбрам, соединяется только с точкой на одном из них;
5. разные рёбра/rooms, draft и partition возвращают отсутствие eligibility;
6. opening/open-span cut разделяет исходную прямую на разные intervals;
7. reversed segment, floating tolerance и stable tie дают тот же результат;
8. одинаковые `A/B`, zero-length и current anchor не подходят;
9. prospective polygon валидируется до mutation для success и каждого класса
существующей ошибки;
10. shared-wall thickness и внешние per-segment thickness сохраняются.
Каждый тест должен падать отдельно при ослаблении `sourceKind`, игнорировании cut,
смешивании разных рёбер, добавлении точки до validation или перезаписи толщины.
### 14.2 Targeted browser smoke
Расширить `demo/smoke_plan_snap_overlay.mjs` либо добавить отдельный
production-bundle smoke:
1. создать существующую комнату, opening/open span, draft и partition;
2. начать `A→B` по двум точкам одной существующей стены и проверить, что второй
click добавляет `B` без dialog/toast и оставляет открытый draft;
3. нарисовать соседний контур endpoint→внешние точки→endpoint той же стены и
проверить немедленное открытие room dialog;
4. повторить с mid-line point и diagonal room edge;
5. доказать отрицательные случаи different edge, cut, draft и partition;
6. доказать отсутствие partial write при self-intersection/overlap;
7. проверить Cancel, Save, secondary action, reload/resume и Undo/Redo;
8. проверить inherited shared thickness и разные толщины внешних сегментов;
9. повторить tap без pointermove и gesture-safety случаи.
Targeted smoke пишется вместе с кодом; полный smoke-suite запускается перед бетой.
### 14.3 Golden и performance
Новых pixels, стилей и состояний overlay нет, поэтому новый golden baseline не
нужен. Существующие editor/View golden должны остаться без изменений; любое
изменение baseline требует отдельного объяснения и review.
Отдельный performance fixture не нужен: новый O(S) поиск выполняется только на
click. Перед бетой обязательны существующие performance smoke и Full Performance
на точном SHA без ослабления budgets.
### 14.4 Backend
Backend не меняется. Нового backend-теста не требуется; полный Linux Validate
остаётся release gate.
## 15. План реализации
1. Добавить чистый helper принадлежности двух точек одному room-owned solid
segment в snap geometry #137.
2. Отделить prospective validation от mutation либо дать текущему close helper
безопасно проверить путь с новым терминальным `B`.
3. Подключить eligibility в `_markupClick()` после click re-resolution и до
обычного draft/resume handling.
4. На success сохранить `P—B` обычным draft-механизмом и открыть существующий
dialog с `B—A`; на failure не менять state.
5. Добавить unit и production-bundle smoke.
6. Обновить `docs/ARCHITECTURE.md`, `docs/CANVAS.md`,
`docs/USER-GUIDE.ru.md`, оба changelog и три bundle-копии.
Имена helper-функций и приватных полей не являются продуктовым контрактом.
## 16. Release-артефакты
Изменение пользовательское: implementation-коммит имеет `User-Visible: yes` и в
том же коммите обновляет:
- `docs/CHANGELOG.md`;
- `docs/CHANGELOG.ru.md`;
- `docs/USER-GUIDE.ru.md` — автозамыкание соседней комнаты;
- `docs/CANVAS.md` и `docs/ARCHITECTURE.md` — внутренний snap/markup контракт;
- три поставляемые bundle-копии.
Новый screenshot/golden не требуется, потому что визуал #137 не меняется.
Перед бетой обязательны:
- exact-SHA Linux Validate;
- полный smoke-suite;
- существующие golden без необъяснённого diff;
- performance smoke и Full Performance на точном SHA;
- code review с отрицательным security/network/schema verdict.
Отдельный security report не нужен: новых внешних данных, HTML input, HA calls,
network или storage путей нет. Публикация проходит через бету до stable.
## 17. Риски и меры
| Риск | Вероятность / влияние | Мера |
|---|---|---|
| Разные рёбра ошибочно считаются одной стеной | средняя / высокий | проверять один canonical segment, unit на corner/different edge |
| Автозамыкание проходит через проём | средняя / высокий | использовать cut snapshot #137, unit + smoke opening/open-span |
| Второй узел общей стены ошибочно трактуется как невалидное замыкание | средняя / высокий | minimum-vertex gate до eligibility, отдельные unit + smoke |
| Невалидный click частично сохраняет draft | средняя / высокий | prospective validation до mutation, state/history assertions |
| Resume endpoint перехватывает финальный click | средняя / высокий | eligibility до `_draftEndAt()`, integration smoke |
| Общая стена получает новую толщину | средняя / высокий | действующий wall inheritance helper + regression matrix |
| Cancel теряет финальный нарисованный сегмент | средняя / средний | явный Cancel contract и smoke snapshot draft |
| Поведение touch расходится с click | низкая / средний | re-resolve на tap, gesture-safety regression |
| Click начинает пересобирать overlay | низкая / средний | использовать cached snapshot, performance/code review |
## 18. Откат
Откат — revert implementation-коммита #138 вместе с тестами, документацией,
changelog и bundle-копиями. Persisted schema не меняется, поэтому data rollback и
миграция не нужны. Комнаты, сохранённые новой версией, являются обычными polygons и
полностью читаются предыдущей версией.
## 19. Принятые технические предположения — можно менять без продуктового ревью
1. Eligibility helper рекомендуется разместить в `src/plan-snap-overlay.ts` и
передавать ему готовый snapshot; точное имя и сигнатура свободны.
2. Геометрическая точность использует существующий `samePoint`/segment epsilon или
более строгий эквивалент, совместимый с grid- и wall-bound координатами. Она не
превращается в новую CSS hit tolerance.
3. При нескольких одинаково подходящих room segments применяется стабильный ключ
#137; persisted id результата не нужен.
4. Prospective validation может быть вынесена из `_closeRoomContour()` в pure helper
либо параметризовать существующий путь, если success/error contract не меняется.
5. Успешное добавление `B` может использовать текущий `_persistActiveDraftSegment()`;
отдельной транзакции backend не требуется.
6. При достаточном числе вершин geometry validation error не добавляет `B`. Если
же `B` была бы только второй вершиной, eligibility не запускается и `B`
добавляется обычным способом без ошибки; это воспроизводит существующий
minimum-vertex gate ручного замыкания.
7. Никаких открытых продуктовых вопросов нет: Q1–Q5 и технические defaults приняты
владельцем 2026-08-14.