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

486 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.