docs(spec): define adjacent-room autoclose

Issue: #138
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-14 12:59:09 +00:00
committed by claude[bot]
parent 737e7b62aa
commit b57ea94cb8
2 changed files with 435 additions and 0 deletions
+434
View File
@@ -0,0 +1,434 @@
# Issue #138 — автозамыкание комнаты по существующей стене
- **Issue:** https://github.com/Matysh/houseplan-card/issues/138
- **Редакция:** первая редакция для независимого ревью; статус определяется только
метками 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. только отличный от первой точки обычный клик проверяется как новое
автозамыкание по существующей стене.
Повторный клик в текущий anchor остаётся no-op и не создаёт нулевой сегмент.
### 7.2 Допустимый существующий интервал
Автозамыкание разрешено, если существует хотя бы один сегмент текущего
архитектурного 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`.
Для подходящего клика редактор рассматривает 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`: подходящая точка уже
означает намерение замкнуть контур, как клик в собственную первую точку.
### 8.3 Обычный клик
Если общего room-owned solid interval нет, поведение остаётся полностью текущим:
resolved-точка добавляется в открытый draft, сегмент сохраняется, а диалог не
открывается. Это относится в том числе к:
- разным рёбрам одной комнаты;
- разным комнатам;
- точкам по разные стороны 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`; разработчик):** разные рёбра одной комнаты, стены разных
комнат, saved draft и partition не запускают автозамыкание: финальная точка
остаётся обычной точкой открытого draft, а явное замыкание первой точкой и
`Ctrl`/`Cmd` продолжают работать.
- **AC4 (`unit` + `smoke`; разработчик):** door/window/gate или `open_span` между
`A` и `B` разрывает общий interval и не допускает автозамыкание; cut boundary не
становится новым endpoint и отдельный resolver проёмов не появляется.
- **AC5 (`unit` + `smoke`; разработчик):** zero-area, self-intersecting,
overlapping, слишком маленький или выходящий за лимиты prospective contour не
открывает диалог, показывает существующий validation toast и оставляет draft,
config и history в состоянии до финального клика.
- **AC6 (`unit` + `smoke`; разработчик):** успешный финальный `P—B` сохраняет
выбранную толщину; Cancel оставляет открытый draft с `B`, Save создаёт комнату и
удаляет draft, а secondary action создаёт замкнутые стены по текущему контракту.
- **AC7 (`unit` + wall-thickness regression; разработчик):** `B—A` использует
существующую толщину общей стены, новые внешние рёбра сохраняют свои per-segment
значения, room geometry не создаёт duplicate partition или двойное физическое
wall body.
- **AC8 (`unit` + code review; разработчик/ревьюер):** собственная первая точка и
`Ctrl`/`Cmd` имеют прежний приоритет, current anchor остаётся no-op, а eligibility
решается до resume/draft endpoint handling.
- **AC9 (`smoke`; разработчик):** tap без hover выполняет тот же результат; pan,
pinch, pointercancel и suppressed synthetic click не меняют geometry. View,
kiosk, остальные Plan tools и другие editors не меняются.
- **AC10 (`unit` + performance review; разработчик/ревьюер):** общий interval
определяется из уже кэшированного snapshot чистым O(S) helper только на click;
pointermove, cache size, DOM count и network/storage activity #137 не растут.
- **AC11 (`typecheck` + `unit` + `build` + documentation review; разработчик):**
implementation-loop gates зелёные, три bundle-копии побайтно одинаковы,
пользовательская документация и оба changelog обновлены в том же видимом
коммите.
- **AC12 (`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. угол, принадлежащий двум рёбрам, соединяется только с точкой на одном из них;
4. разные рёбра/rooms, draft и partition возвращают отсутствие eligibility;
5. opening/open-span cut разделяет исходную прямую на разные intervals;
6. reversed segment, floating tolerance и stable tie дают тот же результат;
7. одинаковые `A/B`, zero-length и current anchor не подходят;
8. prospective polygon валидируется до mutation для success и каждого класса
существующей ошибки;
9. 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. нарисовать соседний контур endpoint→внешние точки→endpoint той же стены и
проверить немедленное открытие room dialog;
3. повторить с mid-line point и diagonal room edge;
4. доказать отрицательные случаи different edge, cut, draft и partition;
5. доказать отсутствие partial write при self-intersection/overlap;
6. проверить Cancel, Save, secondary action, reload/resume и Undo/Redo;
7. проверить inherited shared thickness и разные толщины внешних сегментов;
8. повторить 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 |
| Невалидный 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. При ошибке подходящий финальный click не добавляет `B`. Это соответствует
существующему поведению невалидного клика в первую точку и оставляет draft
доступным для исправления.
7. Никаких открытых продуктовых вопросов нет: Q1–Q5 и технические defaults приняты
владельцем 2026-08-14.
+1
View File
@@ -46,6 +46,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#89](https://github.com/Matysh/houseplan-card/issues/89) Этап 1: объёмный вид за флагом Labs | [089-isometric-view-stage1.md](089-isometric-view-stage1.md) |
| [#98](https://github.com/Matysh/houseplan-card/issues/98) Единая система пульсаций и активностей устройства | [098-device-pulse-system.md](098-device-pulse-system.md) |
| [#131](https://github.com/Matysh/houseplan-card/issues/131) Полный первый кадр View у read-only-пользователя | [131-readonly-cold-start.md](131-readonly-cold-start.md) |
| [#138](https://github.com/Matysh/houseplan-card/issues/138) Автозамыкание комнаты по существующей стене | [138-adjacent-room-autoclose.md](138-adjacent-room-autoclose.md) |
## P2