Files
houseplan-card/docs/specs/137-plan-snap-overlay.md
T
2026-08-14 08:27:45 +00:00

35 KiB
Raw Blame History

Issue #137 — узлы и линии привязки в редакторе Плана

  • Issue: https://github.com/Matysh/houseplan-card/issues/137
  • Редакция: первая редакция для независимого ревью; статус определяется только метками issue
  • Тип / приоритет: feature + polish / P2
  • Оценка: пользовательская ценность 7/10; ценность для разработки 5/10; сложность и риск 6/10
  • Область: редактор Плана, инструменты «Контур» и «Перегородка», производный SVG-слой архитектурных сегментов и узлов, pointer/click snap
  • Модель данных: без новых полей, миграции и backend-изменений
  • Связано: #75, #91, docs/SCOPE.md, docs/TOUCH-SUPPORT.md, docs/CANVAS.md, docs/UX-MODES.md

1. Сценарий и продуктовый контекст

Персона: администратор дома — основной пользователь редакторов House Plan.

Поверхность: desktop browser с мышью или точным pointer; редактор Плана. Touch editor остаётся best effort по docs/TOUCH-SUPPORT.md.

Момент: пользователь выбрал «Контур» или «Перегородка» и собирается поставить первую либо следующую точку нового отрезка рядом с уже нарисованной стеной, сохранённым открытым контуром или независимой перегородкой.

Задача поддерживает:

  • J4: план можно точно нарисовать встроенным GUI, без SVG/Inkscape;
  • J6: существующую геометрию можно продолжать без микрозазоров и визуально неопределённых соединений.

2. Что человек увидит до и после

До: существующие стены скрывают свои точные осевые линии и координаты концов, поэтому следующий клик выглядит приблизительным и заранее не сообщает, соединятся ли отрезки.

После: поверх стен видны тонкие осевые линии и точки их концов, а endpoint или динамическая точка на линии увеличивается там, куда точно присоединится следующий клик.

3. Проблема

Сейчас _snapDrawPoint() знает только глобальную сетку и Shift/45°. Текущий _renderMarkupLayer() показывает вершины live-пути, но не даёт единого верхнего слоя уже сохранённой архитектуры. Основная markup-геометрия также рисуется до физических wall bodies и может быть ими закрыта.

В результате пользователь не различает три разные ситуации:

  1. клик попадёт точно в существующий endpoint;
  2. клик соединит новый отрезок с серединой существующей стены;
  3. клик останется обычной точкой сетки рядом со стеной и оставит зазор.

4. Scope

В issue входят:

  1. Производный pointer-transparent overlay только для активных инструментов «Контур» и «Перегородка» в редакторе Плана.
  2. Тонкие линии по фактически сплошным интервалам:
    • завершённых контуров комнат;
    • сохранённых открытых контуров (room_drafts);
    • независимых перегородок (partitions).
  3. Статические точки в реальных координатах начала и конца исходных сегментов.
  4. Endpoint-snap и line-snap для первого и последующих кликов обоих инструментов.
  5. Динамический промежуточный узел на выбранной линии.
  6. Единый resolver кандидата для hover-preview и commit клика.
  7. Светлая, тёмная и forced-colours читаемость без нового пользовательского параметра.
  8. Unit, production-bundle smoke, editor golden и performance-покрытие.
  9. Описание принятого snap-контракта в документации редактора.

5. Non-scope

В issue не входят:

  • View, kiosk, редактор Устройств и редактор Подложки;
  • новые инструменты, режимы, кнопки, настройки или i18n-текст;
  • колонны, мебель, декор, устройства и сами проёмы как snap-кандидаты;
  • привязка к пересечению продолжений линий, центрам, касательным или произвольным направляющим;
  • автоматическое дробление существующей комнаты, draft или partition при T-соединении;
  • изменение толщины, ключей, порядка или семантики существующих сегментов;
  • исправление старой off-grid геометрии и массовая оптимизация планов;
  • полноценный hover-паритет редактора на coarse pointer;
  • новая схема, backend API, storage key, импорт/экспорт или миграция.

6. Контракт отображения

6.1 Когда слой существует

Overlay создаётся только когда одновременно выполняются условия:

  • режим — редактор Плана;
  • активный инструмент — «Контур» или «Перегородка»;
  • существует текущее пространство.

Он виден уже до первого клика. При смене инструмента, пространства, выходе в View, закрытии редактора или потере pointer из сцены активный кандидат очищается; сам статический слой исчезает, когда инструмент больше не подходит.

6.2 Геометрия линий

Линии повторяют оси существующих стен и перегородок и рисуются поверх их физических тел. Они строятся из модели, не измеряют SVG DOM и не становятся новым источником геометрии.

Из линии и line-snap исключаются:

  • дверные, оконные и воротные вырезы;
  • намеренно открытые open-span интервалы;
  • нулевые и невалидные интервалы;
  • сегменты текущего активного draft и live-preview.

Вырезы применяются к каноническому сегменту до отображения и hit resolution. Статический endpoint исходного сегмента остаётся кандидатом только если к нему примыкает положительный сплошной интервал. Граница проёма сама по себе не создаёт новый постоянный endpoint.

6.3 Геометрия точек

  • Обычная статическая точка имеет физический радиус 5 см в координатах плана.
  • Активный endpoint и динамический line-node имеют физический радиус 10 см и дополнительный контрастный контур.
  • Совпадающие endpoints разных сегментов отображаются одной точкой.
  • Точка остаётся привязана к модели при pan/zoom; её физический размер масштабируется вместе с планом и не превращается в фиксированный screen-space marker.
  • Тонкая линия имеет толщину 1 CSS px независимо от zoom.

Точные цветовые токены являются технической деталью, но линия, обычная точка и активная точка должны различаться в светлой, тёмной и forced-colours теме. Никакой пульсации или постоянной анимации нет.

6.4 Layering и интерактивность

Архитектурный overlay рисуется после room/partition wall bodies и до верхнего интерактивного editor chrome. Он:

  • имеет pointer-events:none на группе и дочерних элементах;
  • не создаёт focusable/ARIA-содержимое;
  • не перехватывает click, pan, pinch, room card, opening и context tray;
  • не меняет hit-testing существующих инструментов.

7. Контракт выбора кандидата

7.1 Общая зона

Resolver использует зону захвата 12 CSS px, переведённую в координаты текущего view. Поэтому зона pointer-попадания стабильна на экране и не зависит от zoom, хотя видимый радиус точек остаётся физическим.

Один и тот же чистый resolver вызывается:

  • на pointermove — для отображения активной точки и live-preview;
  • непосредственно на click/tap — для commit, даже если до него не было pointermove.

Hover-состояние никогда не считается авторитетом для commit: клик повторно решает кандидата из своих координат и актуальной геометрии.

7.2 Приоритет и стабильность

Результат всегда один:

  1. если в зоне есть существующий endpoint, выбирается ближайший endpoint;
  2. только при отсутствии endpoint выбирается ближайший сплошной сегмент;
  3. совпадающие endpoints дедуплицируются;
  4. равные расстояния разрешаются детерминированным стабильным порядком геометрии, не зависящим от частоты pointermove;
  5. текущий anchor исключается как кандидат нулевого отрезка.

Endpoint имеет приоритет, даже если другая линия формально ближе. Это делает визуально увеличенную точку точным обещанием следующего клика и предотвращает мерцание между endpoint и line-node.

7.3 Endpoint-snap

Если выбран endpoint:

  • live-preview оканчивается в его точной сохранённой координате;
  • точка увеличивается до 10 см;
  • click сохраняет ровно ту же координату;
  • правило действует для первого и последующих кликов «Контура» и «Перегородки».

Endpoint-snap внутри активной зоны сильнее обычной сетки и Shift/45°. Вне зоны текущий grid-bound + Shift/45° контракт остаётся без изменений.

7.4 Line-snap и динамический узел

Если endpoint не выбран, но pointer находится в зоне линии:

  1. raw pointer проецируется на ближайший сплошной сегмент;
  2. расстояние вдоль сегмента квантуется с текущим шагом сетки, отсчитанным от стабильного начала сегмента;
  3. итоговая точка остаётся точно на линии;
  4. на ней отображается единственный динамический узел радиусом 10 см;
  5. live-preview и click используют одну и ту же итоговую координату.

На диагонали wall-bound точка не обязана одновременно быть глобальным узлом по X и Y. Это тот же класс координат, который уже применяется для wall-bound opening и split points в docs/CANVAS.md §9.3.

Динамический узел — только preview. После клика существующий сегмент не дробится и не переписывается; новый отрезок получает endpoint в wall-bound координате, поэтому возникает реальное геометрическое T-соединение.

7.5 Текущий контур и валидация

Активный draft и live-preview исключены из общего overlay и resolver. Исключение — первая точка текущего контура, когда существующий сценарий разрешает его замыкание. Другие вершины и сегменты текущего пути не создают ответвления или self-snap.

Snap не обходит существующие правила:

  • повторный click в текущий anchor остаётся no-op;
  • самопересечение, overlap, минимальное число сторон и лимиты сохраняются;
  • невалидное замыкание продолжает показывать существующий toast;
  • отмена, Undo/Redo и history boundary остаются прежними.

8. Touch, accessibility и безопасная деградация

Touch editor: best effort / intentionally degraded.

  • Статические линии и точки могут отображаться на coarse pointer.
  • Tap непосредственно выполняет тот же resolver и может точно соединить endpoint или линию без предварительного hover.
  • Отдельное состояние увеличенной точки до tap на устройстве без hover не гарантируется.
  • Pinch, cancelled pointer и синтетический click после pan не должны создавать геометрию; действующий _suppressClick/gesture safety contract сохраняется.
  • View и kiosk не создают overlay DOM и не меняют gestures, действия или pixels.

Вспомогательная геометрия декоративна для accessibility: она скрыта от assistive technology, а новая клавиатурная навигация в editor не вводится.

9. Модель данных, совместимость и миграция

Новых данных нет.

  • rooms[].poly, room_drafts, partitions, walls и openings сохраняют текущую схему;
  • mid-line snap не вставляет точку в существующий сегмент;
  • overlay/hover не пишет config, layout или local storage;
  • write происходит только в существующем commit действия «Контур» или «Перегородка»;
  • импорт, экспорт, downgrade и старые конфиги не требуют миграции;
  • backend validation и integration API не меняются.

10. UX и i18n

Новых строк, кнопок и настроек нет, поэтому i18n-ключи не добавляются.

docs/USER-GUIDE.ru.md должен объяснить одной короткой секцией:

  • линии/точки появляются при рисовании контура или перегородки;
  • увеличенная точка показывает точное место следующего соединения;
  • endpoint имеет приоритет, а точка на линии создаёт T-соединение;
  • desktop остаётся рекомендуемой поверхностью редактора.

Публичное обещание не распространяется на hover-паритет touch editor.

11. Архитектурный и performance-контракт

  1. Производная архитектурная геометрия собирается из модели и может быть вынесена в чистый helper; SVG DOM не является источником данных.
  2. Статический набор линий/endpoints кэшируется по структурному fingerprint либо эквивалентному стабильному ключу и не пересобирается на каждый pointermove.
  3. Pointermove не пишет состояние модели, config/storage и не создаёт новый массив SVG-узлов сверх обновления единственного active candidate.
  4. Количество статических SVG-элементов ограничено O(E): один элемент на сплошной интервал и один на уникальный endpoint. Динамический line-node — не более одного.
  5. Resolver может быть линейным от числа кандидатов на первом этапе, но большой fixture обязан пройти неизменные performance-бюджеты. Пространственный индекс допустим как техническая оптимизация, если не меняет контракт выбора.
  6. Cut-геометрия переиспользует канонические opening/open-span решения и не изобретает второй способ трактовать проёмы.
  7. Overlay не создаёт websocket, HA service, fetch, timer или внешнюю зависимость.

12. Acceptance criteria

  • AC1 (unit + smoke; разработчик): только в Plan editor при активном «Контуре»/«Перегородке» поверх физических стен существует pointer-transparent overlay завершённых room contours, неактивных saved drafts и partitions; View, kiosk и остальные editor tools не создают его DOM.
  • AC2 (unit + golden; разработчик): каждый уникальный исходный endpoint с примыкающим сплошным интервалом показан одной точкой радиусом 5 см; линии имеют 1 CSS px и повторяют solid intervals; active endpoint/dynamic node имеют радиус 10 см и читаемый контур в light/dark/forced colours.
  • AC3 (unit + smoke; разработчик): первый и последующие клики обоих инструментов внутри 12 CSS px endpoint показывают один active endpoint, ведут live-preview в него и сохраняют его точную координату; endpoint приоритетнее любой линии.
  • AC4 (unit + smoke; разработчик): при попадании на solid line вне endpoint-priority отображается один динамический узел в wall-bound координате, квантованной вдоль линии; click создаёт точное T-соединение и не дробит/не переписывает существующий сегмент.
  • AC5 (unit; разработчик): совпадающие endpoints дедуплицируются, ближайший кандидат выбирается детерминированно, равные расстояния не мерцают, а resolver pointermove и resolver click возвращают одинаковый результат для одинакового snapshot.
  • AC6 (unit + smoke; разработчик): активный draft/live-preview исключён из общего snap; первая точка остаётся целью разрешённого замыкания, current anchor не создаёт нулевой сегмент, а другие точки текущего пути не создают self-branch.
  • AC7 (unit + golden; разработчик): door/window/gate и open-span интервалы отсутствуют в линии и line-snap; границы выреза не становятся постоянными endpoints; columns/openings/decor/devices не входят в кандидаты.
  • AC8 (unit + smoke; разработчик): активный endpoint/line candidate сильнее grid и Shift/45°, а вне 12 CSS px существующие grid-bound, Shift/45°, contour validity, limits и toast contracts остаются без изменений.
  • AC9 (unit + code review; разработчик/ревьюер): hover и candidate resolution не пишут config/layout/local storage; commit использует существующие history, Undo/Redo и save boundaries и добавляет только новый endpoint нового отрезка.
  • AC10 (smoke; разработчик): tap без предшествующего pointermove повторно решает кандидата и соединяет геометрию; pan, pinch, pointercancel и suppressed synthetic click не создают отрезок. Hover-паритет coarse pointer не требуется.
  • AC11 (smoke + existing View golden; разработчик): overlay не перехватывает pointer/focus/ARIA, не меняет View/kiosk pixels и gestures, room/opening/editor chrome interactions и lock/action contract.
  • AC12 (performance + code review; разработчик/ревьюер): large-house fixture с 60 комнатами и 60 partitions проходит overlay render и серию pointermove без роста cache/DOM между стабильными кадрами; существующие performance budgets не ослаблены, exact-SHA Full Performance перед бетой зелёный.
  • AC13 (unit + backend/schema review; разработчик/ревьюер): config schema, import/export, backend, storage keys, i18n и зависимости не меняются; старые планы читаются без миграции.
  • AC14 (typecheck + unit + build + documentation review; разработчик): implementation-loop gates зелёные, три bundle-копии побайтно одинаковы, оба changelog и пользовательская/internal документация обновлены в видимом коммите.

13. План автотестов

13.1 Unit

Добавить чистое покрытие сборщика и resolver:

  1. room rectangle, polygon, saved open draft и partition дают ожидаемые segments и endpoints;
  2. совпадающие room/partition endpoints дедуплицируются независимо от направления;
  3. active draft, zero-length и columns исключаются;
  4. opening/open-span cuts оставляют только solid intervals и не создают endpoints на границах cut;
  5. endpoint выигрывает у более близкой линии в общей 12 px зоне;
  6. nearest line projection и quantisation along segment стабильны для horizontal, vertical и diagonal случаев;
  7. tie разрешается одинаково при перестановке pointermove и не меняет snapshot;
  8. current anchor исключён, first contour point разрешён только для closure;
  9. candidate overrides Shift, а no-candidate сохраняет существующий grid/45 output;
  10. cache/fingerprint меняется от структурной геометрии и не меняется от hover.

Каждый тест должен уметь падать отдельно: удаление dedup, смена приоритета, возвращение точки в opening gap, off-wall округление или включение active draft делают соответствующий тест красным.

13.2 Targeted browser smoke

Добавить production-bundle сценарий, например demo/smoke_plan_snap_overlay.mjs:

  1. открыть Plan editor с комнатой, saved draft, partition, door и open span;
  2. переключать «Контур»/«Перегородка» и другие tools, проверяя наличие/отсутствие overlay и его DOM-порядок после wall bodies;
  3. pointermove к endpoint — active radius/класс, preview и commit exact coordinate;
  4. pointermove к середине horizontal и diagonal line — dynamic node, wall-bound quantisation и T-join без split исходного segment;
  5. доказать endpoint priority, first-click snap и Shift override;
  6. доказать отсутствие snap в opening/open-span gap и сохранение обычной grid snap;
  7. closure current first point работает, intermediate self-snap отсутствует;
  8. tap без pointermove соединяет, а pan/pinch/cancel/suppressed click не пишет;
  9. выйти в View/другой editor и убедиться, что overlay DOM исчез и pixels/actions не изменились.

Smoke пишется вместе с кодом, но полный browser-suite запускается перед бетой.

13.3 Golden

Добавить либо расширить editor matrix минимум двумя кадрами:

  • light: статические линии/endpoints и active endpoint поверх толстых стен;
  • dark: dynamic mid-line node, opening/open-span gaps и T-preview поверх стены.

Проверяются физические размеры относительно масштаба плана, слой над wall bodies, контраст и отсутствие линии через вырезы. View baselines должны остаться pixel-identical.

Golden принимаются только из полного просмотренного Linux artifact через npm run golden:accept -- --reviewed с обязательными трейлерами процесса.

13.4 Performance

Расширить large-house harness либо добавить отдельный overlay profile:

  • 60 rooms, 60 partitions, opening cuts и несколько saved drafts;
  • первый render статического overlay;
  • серия не менее 100 pointermove по endpoint, line и miss;
  • bounded DOM, cache cap/growth и отсутствие config/network writes;
  • сравнение exact candidate с base при неизменных budgets.

Локальный performance-run диагностический. Перед бетой обязательны зелёные performance smoke и Full Performance на точном SHA кандидата.

13.5 Backend

Backend не меняется. Нового backend-теста не требуется; полный Linux Validate остаётся release gate.

14. План реализации

  1. Ввести чистые типы/сборщик architectural snap geometry и resolver кандидата.
  2. Подключить к нему канонические room/draft/partition segments и существующие cuts.
  3. Разделить raw pointer, resolved candidate и committed point так, чтобы click всегда re-resolve актуальный snapshot.
  4. Добавить верхний pointer-inert SVG overlay и theme/forced-colours styles.
  5. Добавить unit, targeted smoke, golden/performance fixtures.
  6. Обновить docs/CANVAS.md, docs/USER-GUIDE.ru.md, оба changelog и bundle-копии.

Имена helper-файлов и приватных полей не являются продуктовым контрактом.

15. Release-артефакты

Изменение пользовательское: implementation-коммиты имеют User-Visible: yes и в том же коммите обновляют:

  • docs/CHANGELOG.md;
  • docs/CHANGELOG.ru.md;
  • docs/USER-GUIDE.ru.md — видимое поведение editor snap;
  • docs/CANVAS.md — внутренний grid-bound/wall-bound контракт;
  • три поставляемые bundle-копии.

Перед бетой обязательны:

  • exact-SHA Linux Validate;
  • полный smoke-suite;
  • просмотренный и принятый Linux golden artifact;
  • performance smoke и Full Performance на точном SHA с неизменными budgets;
  • code review с отрицательным security/network/schema verdict.

Отдельный security report не требуется: новых внешних данных, HA calls, HTML input, network/storage путей нет. Публикация проходит через бету до stable.

16. Риски и меры

Риск Вероятность / влияние Мера
Overlay закрыт толстой стеной или opening symbol medium / high явный DOM-order smoke и editor golden
Endpoint/line меняются между hover и click medium / high один pure resolver, обязательный re-resolve на click
T-node оказывается off-wall после grid snap medium / high wall-bound projection + along-segment quantisation units
Opening gap остаётся кликабельной линией medium / high единая cut-геометрия, unit + golden gap case
Active draft привязывается сам к себе medium / high исключение по active id, отдельный closure case
Много SVG-узлов ухудшает pointermove medium / high structural cache, O(E) DOM, large-house performance profile
Точки 5 см плохо видны далеко medium / low это принятый физический размер; контрастный stroke и 12 px hit tolerance
Touch tap и hover расходятся medium / medium re-resolve непосредственно на tap, hover parity не обещается
Snap обходит overlap/self-intersection low / high существующая validation после resolution, regression smoke
Overlay попадает в View/kiosk low / high mode/tool guard, DOM absence smoke, unchanged View golden

17. Откат

Откат — revert implementation-коммита #137 вместе с тестами, документацией, changelog и bundle-копиями. Persisted schema не меняется, поэтому data rollback и миграция не нужны; созданные новой версией endpoints остаются валидной существующей геометрией и читаются старой версией.

Если перед бетой красный только новый визуальный/performance gate, релиз блокируется: feature flag или ослабление budget не вводятся как аварийный обход.

18. Принятые технические предположения — можно менять без продуктового ревью

  1. Рекомендуется новый чистый src/plan-snap-overlay.ts (или эквивалент) с типами segment/endpoint/candidate; точное имя и раскладка файлов свободны.
  2. Stable tie key может состоять из нормализованных координат, kind и persisted id; конкретный формат не хранится и не публикуется.
  3. Dedup использует существующую точность samePoint либо более строгую grid-compatible нормализацию; он не объединяет визуально близкие, но разные узлы.
  4. Screen tolerance вычисляется из текущего view/stage scale один раз на event; допустима единая isotropic scale для SVG preserveAspectRatio.
  5. Статический geometry cache может быть keyed существующим _cfgEpoch плюс structural fingerprint или только полным fingerprint; HA state/theme/hover в key не входят.
  6. Цвета берутся из существующих editor accent/contrast tokens. Можно выбрать dashed или solid line, если golden сохраняет однозначную непрерывность и 1 px толщину.
  7. Forced-colours реализация может использовать media query и системные цвета; отдельная настройка не нужна.
  8. Canonical cuts можно получать до SVG render или через чистый helper, но не через DOM measurement и не через второй независимый opening resolver.
  9. Unit-тесты могут держать новые helper-файлы отдельно от монолитного card test; browser smoke остаётся авторитетом integration/layer/click контракта.
  10. Нет открытых продуктовых вопросов: D1–D5 и Q1–Q7 приняты владельцем 2026-08-14.