diff --git a/docs/specs/137-plan-snap-overlay.md b/docs/specs/137-plan-snap-overlay.md new file mode 100644 index 00000000..b15c2db0 --- /dev/null +++ b/docs/specs/137-plan-snap-overlay.md @@ -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. diff --git a/docs/specs/README.md b/docs/specs/README.md index b0d67ad3..869fd14b 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -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) | ## Правило актуализации