docs: specify plan snap overlay

Issue: #137
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-14 08:27:45 +00:00
committed by claude[bot]
parent e9a148315a
commit f5c36da648
2 changed files with 486 additions and 0 deletions
+485
View File
@@ -0,0 +1,485 @@
# 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.
+1
View File
@@ -81,6 +81,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#107](https://github.com/Matysh/houseplan-card/issues/107) Переключение виртуального источника света «Всегда» | [107-virtual-light-toggle.md](107-virtual-light-toggle.md) |
| [#122](https://github.com/Matysh/houseplan-card/issues/122) Изометрический режим Stage 2: скрытый режим и визуальная полировка | [122-isometric-stage2.md](122-isometric-stage2.md) |
| [#123](https://github.com/Matysh/houseplan-card/issues/123) Split из вершины не меняет наружную геометрию стен | [123-corner-split-wall.md](123-corner-split-wall.md) |
| [#137](https://github.com/Matysh/houseplan-card/issues/137) Узлы и линии привязки в редакторе Плана | [137-plan-snap-overlay.md](137-plan-snap-overlay.md) |
## Правило актуализации