35 KiB
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 и может быть ими закрыта.
В результате пользователь не различает три разные ситуации:
- клик попадёт точно в существующий endpoint;
- клик соединит новый отрезок с серединой существующей стены;
- клик останется обычной точкой сетки рядом со стеной и оставит зазор.
4. Scope
В issue входят:
- Производный pointer-transparent overlay только для активных инструментов «Контур» и «Перегородка» в редакторе Плана.
- Тонкие линии по фактически сплошным интервалам:
- завершённых контуров комнат;
- сохранённых открытых контуров (
room_drafts); - независимых перегородок (
partitions).
- Статические точки в реальных координатах начала и конца исходных сегментов.
- Endpoint-snap и line-snap для первого и последующих кликов обоих инструментов.
- Динамический промежуточный узел на выбранной линии.
- Единый resolver кандидата для hover-preview и commit клика.
- Светлая, тёмная и forced-colours читаемость без нового пользовательского параметра.
- Unit, production-bundle smoke, editor golden и performance-покрытие.
- Описание принятого 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 Приоритет и стабильность
Результат всегда один:
- если в зоне есть существующий endpoint, выбирается ближайший endpoint;
- только при отсутствии endpoint выбирается ближайший сплошной сегмент;
- совпадающие endpoints дедуплицируются;
- равные расстояния разрешаются детерминированным стабильным порядком геометрии, не зависящим от частоты pointermove;
- текущий 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 находится в зоне линии:
- raw pointer проецируется на ближайший сплошной сегмент;
- расстояние вдоль сегмента квантуется с текущим шагом сетки, отсчитанным от стабильного начала сегмента;
- итоговая точка остаётся точно на линии;
- на ней отображается единственный динамический узел радиусом 10 см;
- 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-контракт
- Производная архитектурная геометрия собирается из модели и может быть вынесена в чистый helper; SVG DOM не является источником данных.
- Статический набор линий/endpoints кэшируется по структурному fingerprint либо эквивалентному стабильному ключу и не пересобирается на каждый pointermove.
- Pointermove не пишет состояние модели, config/storage и не создаёт новый массив SVG-узлов сверх обновления единственного active candidate.
- Количество статических SVG-элементов ограничено O(E): один элемент на сплошной интервал и один на уникальный endpoint. Динамический line-node — не более одного.
- Resolver может быть линейным от числа кандидатов на первом этапе, но большой fixture обязан пройти неизменные performance-бюджеты. Пространственный индекс допустим как техническая оптимизация, если не меняет контракт выбора.
- Cut-геометрия переиспользует канонические opening/open-span решения и не изобретает второй способ трактовать проёмы.
- 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:
- room rectangle, polygon, saved open draft и partition дают ожидаемые segments и endpoints;
- совпадающие room/partition endpoints дедуплицируются независимо от направления;
- active draft, zero-length и columns исключаются;
- opening/open-span cuts оставляют только solid intervals и не создают endpoints на границах cut;
- endpoint выигрывает у более близкой линии в общей 12 px зоне;
- nearest line projection и quantisation along segment стабильны для horizontal, vertical и diagonal случаев;
- tie разрешается одинаково при перестановке pointermove и не меняет snapshot;
- current anchor исключён, first contour point разрешён только для closure;
- candidate overrides Shift, а no-candidate сохраняет существующий grid/45 output;
- cache/fingerprint меняется от структурной геометрии и не меняется от hover.
Каждый тест должен уметь падать отдельно: удаление dedup, смена приоритета, возвращение точки в opening gap, off-wall округление или включение active draft делают соответствующий тест красным.
13.2 Targeted browser smoke
Добавить production-bundle сценарий, например demo/smoke_plan_snap_overlay.mjs:
- открыть Plan editor с комнатой, saved draft, partition, door и open span;
- переключать «Контур»/«Перегородка» и другие tools, проверяя наличие/отсутствие overlay и его DOM-порядок после wall bodies;
- pointermove к endpoint — active radius/класс, preview и commit exact coordinate;
- pointermove к середине horizontal и diagonal line — dynamic node, wall-bound quantisation и T-join без split исходного segment;
- доказать endpoint priority, first-click snap и Shift override;
- доказать отсутствие snap в opening/open-span gap и сохранение обычной grid snap;
- closure current first point работает, intermediate self-snap отсутствует;
- tap без pointermove соединяет, а pan/pinch/cancel/suppressed click не пишет;
- выйти в 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. План реализации
- Ввести чистые типы/сборщик architectural snap geometry и resolver кандидата.
- Подключить к нему канонические room/draft/partition segments и существующие cuts.
- Разделить raw pointer, resolved candidate и committed point так, чтобы click всегда re-resolve актуальный snapshot.
- Добавить верхний pointer-inert SVG overlay и theme/forced-colours styles.
- Добавить unit, targeted smoke, golden/performance fixtures.
- Обновить
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. Принятые технические предположения — можно менять без продуктового ревью
- Рекомендуется новый чистый
src/plan-snap-overlay.ts(или эквивалент) с типами segment/endpoint/candidate; точное имя и раскладка файлов свободны. - Stable tie key может состоять из нормализованных координат, kind и persisted id; конкретный формат не хранится и не публикуется.
- Dedup использует существующую точность
samePointлибо более строгую grid-compatible нормализацию; он не объединяет визуально близкие, но разные узлы. - Screen tolerance вычисляется из текущего view/stage scale один раз на event;
допустима единая isotropic scale для SVG
preserveAspectRatio. - Статический geometry cache может быть keyed существующим
_cfgEpochплюс structural fingerprint или только полным fingerprint; HA state/theme/hover в key не входят. - Цвета берутся из существующих editor accent/contrast tokens. Можно выбрать dashed или solid line, если golden сохраняет однозначную непрерывность и 1 px толщину.
- Forced-colours реализация может использовать media query и системные цвета; отдельная настройка не нужна.
- Canonical cuts можно получать до SVG render или через чистый helper, но не через DOM measurement и не через второй независимый opening resolver.
- Unit-тесты могут держать новые helper-файлы отдельно от монолитного card test; browser smoke остаётся авторитетом integration/layer/click контракта.
- Нет открытых продуктовых вопросов: D1–D5 и Q1–Q7 приняты владельцем 2026-08-14.