35 KiB
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:
- Автозамыкание действует только тогда, когда первая и последняя точки лежат на одном прямом каноническом ребре существующей комнаты. Допустимы исходные endpoints/углы и промежуточные line-snap точки. Разные рёбра одной комнаты не подходят.
- Door/window/gate и
open_spanразрывают допустимую стену. Первая и последняя точки должны принадлежать одному непрерывному видимому сплошному интервалу #137. - Подходящий финальный клик сразу добавляет существующий интервал как неявное замыкающее ребро и открывает стандартный диалог новой комнаты, без отдельного подтверждения.
- Новая визуальная индикация и новые строки не нужны: используются увеличенная snap-точка, live-preview и существующий диалог.
- Приоритет задачи — P1.
5. Scope
В issue входят:
- Распознавание подходящего финального клика инструмента «Контур комнаты» после актуального endpoint/line resolution #137.
- Проверка принадлежности первой и финальной точек одному room-owned solid interval из общего архитектурного snap snapshot.
- Автозамыкание валидного контура существующим участком стены и открытие обычного диалога комнаты.
- Сохранение существующих validation, draft, per-segment thickness, history, Cancel/Save и Undo/Redo контрактов.
- Unit и production-bundle smoke для положительных, отрицательных и touch-safety сценариев.
- Обновление пользовательской и архитектурной документации.
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 не перехватил подходящий клик.
Существующие явные варианты имеют приоритет и не меняются:
- клик в собственную первую точку замыкает контур как сейчас;
Ctrl/Cmd+ click выполняет существующее быстрое замыкание;- только отличный от первой точки обычный клик, после добавления которого в 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, что и обычное замыкание.
Если контур валиден, одним пользовательским действием:
Bстановится настоящей последней вершиной нового контура;- нарисованный сегмент
P—Bсохраняется как обычный завершённый draft segment с выбранной для него толщиной; B—Aсчитается замыкающим ребром комнаты;- открывается существующий диалог имени/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-контракт
- Проверка общего интервала является чистой геометрической операцией над существующим immutable snap snapshot; SVG DOM не является источником данных.
- Canonical opening/open-span cuts переиспользуются из
plan-snap-overlay.tsили эквивалентного общего helper, без дублирования wall topology. - Проверка выполняется только на click/tap. Она не добавляет работу в pointermove, не пересобирает статическую геометрию и не меняет O(E) overlay DOM #137.
- Линейный O(S) просмотр room-owned solid segments на click допустим. Новый постоянно растущий cache или spatial index не требуется.
- Prospective validation завершается до mutation. Успех использует существующие draft/save/history границы; ошибка не требует rollback частичного состояния.
- Решение не создаёт websocket, HA service, fetch, timer, storage key или внешнюю зависимость.
13. Acceptance criteria
- AC1 (
unit+smoke; разработчик): контур, начатый в endpointAзавершённой room wall, после обхода нового помещения заканчивается в другом endpointBтого же непрерывного 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-nodeBтого же 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 покрытие:
- оба endpoints одного room segment дают общий interval;
- endpoint + interior point и две interior line-snap точки дают общий interval;
- при path
[A]второй clickBна том же room interval не вызывает eligibility, dialog или toast, а добавляетBиA—Bв открытый draft; - угол, принадлежащий двум рёбрам, соединяется только с точкой на одном из них;
- разные рёбра/rooms, draft и partition возвращают отсутствие eligibility;
- opening/open-span cut разделяет исходную прямую на разные intervals;
- reversed segment, floating tolerance и stable tie дают тот же результат;
- одинаковые
A/B, zero-length и current anchor не подходят; - prospective polygon валидируется до mutation для success и каждого класса существующей ошибки;
- shared-wall thickness и внешние per-segment thickness сохраняются.
Каждый тест должен падать отдельно при ослаблении sourceKind, игнорировании cut,
смешивании разных рёбер, добавлении точки до validation или перезаписи толщины.
14.2 Targeted browser smoke
Расширить demo/smoke_plan_snap_overlay.mjs либо добавить отдельный
production-bundle smoke:
- создать существующую комнату, opening/open span, draft и partition;
- начать
A→Bпо двум точкам одной существующей стены и проверить, что второй click добавляетBбез dialog/toast и оставляет открытый draft; - нарисовать соседний контур endpoint→внешние точки→endpoint той же стены и проверить немедленное открытие room dialog;
- повторить с mid-line point и diagonal room edge;
- доказать отрицательные случаи different edge, cut, draft и partition;
- доказать отсутствие partial write при self-intersection/overlap;
- проверить Cancel, Save, secondary action, reload/resume и Undo/Redo;
- проверить inherited shared thickness и разные толщины внешних сегментов;
- повторить 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. План реализации
- Добавить чистый helper принадлежности двух точек одному room-owned solid segment в snap geometry #137.
- Отделить prospective validation от mutation либо дать текущему close helper
безопасно проверить путь с новым терминальным
B. - Подключить eligibility в
_markupClick()после click re-resolution и до обычного draft/resume handling. - На success сохранить
P—Bобычным draft-механизмом и открыть существующий dialog сB—A; на failure не менять state. - Добавить unit и production-bundle smoke.
- Обновить
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. Принятые технические предположения — можно менять без продуктового ревью
- Eligibility helper рекомендуется разместить в
src/plan-snap-overlay.tsи передавать ему готовый snapshot; точное имя и сигнатура свободны. - Геометрическая точность использует существующий
samePoint/segment epsilon или более строгий эквивалент, совместимый с grid- и wall-bound координатами. Она не превращается в новую CSS hit tolerance. - При нескольких одинаково подходящих room segments применяется стабильный ключ #137; persisted id результата не нужен.
- Prospective validation может быть вынесена из
_closeRoomContour()в pure helper либо параметризовать существующий путь, если success/error contract не меняется. - Успешное добавление
Bможет использовать текущий_persistActiveDraftSegment(); отдельной транзакции backend не требуется. - При достаточном числе вершин geometry validation error не добавляет
B. Если жеBбыла бы только второй вершиной, eligibility не запускается иBдобавляется обычным способом без ошибки; это воспроизводит существующий minimum-vertex gate ручного замыкания. - Никаких открытых продуктовых вопросов нет: Q1–Q5 и технические defaults приняты владельцем 2026-08-14.