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

35 KiB
Raw Blame History

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.