diff --git a/docs/specs/138-adjacent-room-autoclose.md b/docs/specs/138-adjacent-room-autoclose.md new file mode 100644 index 00000000..7cf50cd4 --- /dev/null +++ b/docs/specs/138-adjacent-room-autoclose.md @@ -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. diff --git a/docs/specs/README.md b/docs/specs/README.md index bc629063..44cb3333 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -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