From a1fbea07d2876d51afac3a9df12bfa756424df99 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Fri, 4 Sep 2026 08:28:20 +0300 Subject: [PATCH] docs: specify exterior furniture snap and keyboard nudge Issue: #447 User-Visible: no --- ...-exterior-furniture-snap-keyboard-nudge.md | 360 ++++++++++++++++++ docs/specs/README.md | 3 +- 2 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 docs/specs/447-exterior-furniture-snap-keyboard-nudge.md diff --git a/docs/specs/447-exterior-furniture-snap-keyboard-nudge.md b/docs/specs/447-exterior-furniture-snap-keyboard-nudge.md new file mode 100644 index 00000000..5003f599 --- /dev/null +++ b/docs/specs/447-exterior-furniture-snap-keyboard-nudge.md @@ -0,0 +1,360 @@ +# ТЗ #447 — Наружная грань для мебели и точный сдвиг декора стрелками + +- Issue: https://github.com/Matysh/houseplan-card/issues/447 +- Приоритет: P2, `bug` +- Маршрут: full; меняются физическая семантика wall magnet и публичный + клавиатурный контракт Редактора подложки +- Связанные задачи: #445 (физическая внутренняя грань стены), #41 + (более широкий keyboard editing), #383 (трансформации мебели) + +## Сценарий + +Home admin в desktop-Редакторе подложки размещает мебель с наружной стороны +дома — например, скамейку на террасе или ящик у фасада — либо выбирает уже +размещённый предмет декора и доводит его положение по видимой сетке клавишами со +стрелками. + +## Что человек увидит до и после + +**До:** мебель у наружной стороны стены либо не магнитится, либо прыгает сквозь +кладку внутрь комнаты; выбранный декор нельзя сдвинуть на одну клетку без точного +drag или ручного ввода координат. + +**После:** мебель остаётся с той стороны стены, с которой её подвёл пользователь, +и может лечь как на внутреннюю, так и на наружную поверхность; каждая стрелка +сдвигает выбранный декор ровно на одну клетку сетки без повторного магнита. + +## Подтверждённая проблема + +1. `roomFurnitureWallSurfaces()` создаёт на каждом атоме только одну поверхность: + `axis + inwardNormal * halfDepth`. Наружной поверхности внешней стены в + кандидатах нет. +2. `snapFurnitureToWall()` вычисляет `sideScore = -2` для поверхности за стеной, + но использует score только при равном расстоянии. Если внутренняя грань — + единственный кандидат в радиусе, она выигрывает и переносит предмет через + кладку. +3. `_keyHandler` Редактора подложки обрабатывает Undo/Redo, Delete/Backspace и + Escape, но не Arrow-клавиши для `_decorSel`. +4. Pointer-move умеет перемещать разные виды декора, но его повторное использование + для клавиатуры было бы неверным: обычный декор снова проходит `_decorSnap`, а + мебель — `snapFurnitureToWall`. Точная доводка должна применять готовую дельту. + +## Скоуп + +В скоупе: + +- наружная физическая поверхность каждого ненулевого атома внешней комнатной + стены; +- определение, является ли атом внешним или общим, без зависимости от порядка + комнат, winding и направления ребра; +- выбор внутренней либо наружной поверхности по стороне исходной точки + намерения; +- прежний внутренний результат при точном попадании нового предмета на ось + внешней стены; +- одинаковая наружная геометрия для hover preview, clean placement и drag + существующей мебели; +- ArrowLeft/Right/Up/Down для любого выбранного элемента `space.decor[]`; +- шаг ровно в один `gridPitch` по соответствующей оси холста; +- общий Undo/Redo, сохранение и canvas clamp для клавиатурного сдвига; +- unit, browser smoke, отрицательные witnesses и пользовательская документация. + +## Не-скоуп + +- collision model мебели с проёмами, комнатами, другой мебелью или стенами после + завершения drag; +- автоматическое следование мебели за последующим изменением стены; +- изменение радиуса магнита, `Shift`-обхода, размеров, resize, rotate, + зеркалирования или hit area мебели; +- клавиатурный выбор объектов, roving focus, режимы navigate/move, Enter, + Delete либо доступные объявления из полного #41; +- ускоренный шаг с `Shift` или отдельная настройка шага; +- движение картинки-подложки плана: она не входит в `_decorSel`; +- touch-жест для точной доводки; +- миграция конфигурации, backend, новые настройки или i18n-строки. + +## Контракт поведения + +### 1. Каталог поверхностей внешней стены + +Существующая room-facing поверхность сохраняется. Для атомарного осевого +интервала дополнительно создаётся наружная поверхность, если одновременно +выполнено следующее: + +1. интервал принадлежит ровно одной комнате в текущем пространстве; +2. его локальная физическая полутолщина больше числового epsilon; +3. ось, обе конечные точки и нормаль конечны, а длина интервала ненулевая. + +Наружная поверхность равна `axis - inwardNormal * halfDepth`, а её нормаль +направлена от кладки наружу: `-inwardNormal`. Внутренняя поверхность остаётся +`axis + inwardNormal * halfDepth` с нормалью внутрь комнаты. Обе получают +стабильные identity с явным признаком стороны. + +Общий атом двух комнат не получает третью «наружную» поверхность: его две +room-facing записи уже описывают обе стороны кладки. Если геометрия комнат +содержит одинаковый атом с противоположным направлением или winding, результат +остаётся тем же. Атом с нулевой толщиной сохраняет одну поверхность на оси, как +в модели v9; дубликат с противоположной нормалью не создаётся. + +Уже физические грани независимых перегородок и колонн не меняются и не получают +дополнительного offset. + +### 2. Выбор стороны и положение мебели + +Для ненулевой внешней стены resolver видит парные внутреннюю и наружную +поверхности. Точка намерения до decor/grid snap определяет сторону: + +- внутри комнаты выигрывает внутренняя поверхность; +- снаружи выигрывает наружная поверхность; +- предмет не переносится через ось на сторону, противоположную точке намерения; +- при новом размещении точно на оси сохраняется прежний контракт #445: + детерминированно выбирается внутренняя поверхность; +- при drag точно на оси `preferredNormal` сохраняет текущую сторону предмета; +- расстояние eligibility и `dist` считается от выбранной физической поверхности, + поэтому действующий радиус `FURN_WALL_CELLS` одинаков с обеих сторон. + +BACK мебели лежит на выбранной поверхности, центр смещается по её нормали на +половину глубины, а координата вдоль конечного атома квантуется прежним grid +step. Hover preview, commit и drag используют один и тот же каталог и low-level +resolver. `Shift` по-прежнему полностью отключает wall magnet. + +### 3. Сдвиг выбранного декора стрелками + +Команда доступна, когда: + +- открыт Редактор подложки; +- активен инструмент «Выбрать»; +- `_decorSel` указывает на существующий `line`, `rect`, `ellipse`, `text`, + `furniture` или `image`; +- нет активного draft/move/resize/rotate жеста; +- фокус не находится в `input`, `textarea`, `select`, contenteditable, + открытом диалоге или панели управления редактора. + +Направления задаются по осям холста: + +| Клавиша | Дельта render-координат | +|---|---:| +| `ArrowLeft` | `x -= gridPitch` | +| `ArrowRight` | `x += gridPitch` | +| `ArrowUp` | `y -= gridPitch` | +| `ArrowDown` | `y += gridPitch` | + +Для box-kind меняются `x/y`; у линии одной и той же дельтой меняются обе +конечные точки. Угол, размеры, отражение, стиль, identity и порядок слоя не +меняются. Сдвиг сохраняет текущую off-grid фазу: к координате прибавляется +ровно одна клетка, сама координата не округляется к ближайшему узлу. + +Команда не вызывает `_decorSnap`, `snapFurnitureToWall` либо placement resolver. +Поэтому мебель можно отвести от поверхности на одну клетку и магнит не отменит +доводку. Картинка-декор двигается тем же путём; картинка-подложка плана — нет. + +`Shift+Arrow` имеет тот же шаг в одну клетку. `Ctrl`/`Cmd`/`Alt` с Arrow не +перехватываются. Для принятой команды вызывается `preventDefault`, чтобы страница +не прокручивалась. + +### 4. Bounds, история и сохранение + +Дельта ограничивается как единое целое: все точки/границы объекта остаются в +действующем диапазоне `CANVAS_LIMIT`, а линия или box не деформируются. Последний +шаг у границы может быть короче клетки, если ровно столько осталось до лимита; +следующая команда в ту же сторону — no-op. + +Каждый принятый `keydown`, включая события browser auto-repeat, является одной +операцией `history.decor_move`: перед изменением снимается geometry snapshot, +после изменения вызываются обычные config save и render update. No-op у границы +не пишет конфигурацию и не занимает слот истории. Один Undo возвращает ровно +один клавиатурный шаг, Redo повторяет его. + +Внешнее обновление конфигурации, лимит 50 команд и поведение истории между +пространствами остаются прежними. + +## UX, клавиатура и доступность + +- Новых кнопок, подсказок и режимов нет: доступна обычная desktop-конвенция + «выбрать объект и довести стрелками». +- Команда не крадёт Arrow-клавиши у полей, диалогов и editor toolbar. +- Selection остаётся на том же объекте после сдвига и после Undo/Redo. +- Touch editor остаётся best effort; View и kiosk не меняются. +- Это узкая реализация части #41 для декора, без обещания полной клавиатурной + навигации по объектам. + +## Данные, миграция и i18n + +- Схема `space.decor[]` и версия config не меняются. +- Новые wall surfaces — transient editor data и не сохраняются. +- Уже размещённая мебель не перемещается при load, render, Optimize, export или + import. +- Клавиатурный сдвиг сохраняет те же canonical поля, что pointer move; новых + compatibility-полей нет. +- Backend и API не меняются; миграции нет. +- Новых текстов и ключей i18n нет. + +## Производительность + +- Внешние кандидаты строятся в существующем epoch-cache, а не на pointermove. + Число room-wall candidates увеличивается не более чем на число внешних + ненулевых атомов; asymptotic snap остаётся линейным. +- Один Arrow-keydown выполняет один проход по `space.decor[]`, как pointer move, + и не пересчитывает wall profile. +- Новых imports в initial View graph нет: изменение остаётся в lazy editor + graph. `bundle:budget` обязан остаться зелёным. + +## Ошибки и крайние случаи + +| Случай | Ожидаемое поведение | +|---|---| +| pointer у наружной грани стены 20 см | BACK на наружной поверхности, предмет снаружи | +| pointer у внутренней грани той же стены | прежний BACK на внутренней поверхности | +| новое placement точно на оси | внутренняя поверхность, стабильно от порядка/winding | +| drag по оси | сохраняется текущая сторона | +| общая стена | только две room-facing поверхности, прежний выбор стороны | +| нулевая внешняя стена | одна осевая поверхность, прежнее поведение | +| independent body | прежний bidirectional physical-face snap | +| malformed или вырожденный atom | пропускается, остальные кандидаты остаются finite | +| выбранная off-grid мебель | Arrow добавляет клетку к текущей координате, без wall snap | +| выбранная линия | обе точки получают одну дельту, длина и угол неизменны | +| объект у canvas limit | сокращённый последний шаг, затем no-op без history/save | +| фокус в поле/диалоге/toolbar | Arrow остаётся у контрола, декор не меняется | +| активен pointer gesture | Arrow не создаёт параллельную транзакцию | + +## Acceptance criteria и доказательства + +### AC1. Наружная поверхность внешней стены доступна магниту + +`unit`: комната со стеной 20 см создаёт две поверхности на внешнем атоме. +Pointer снаружи кладёт BACK на наружную грань и всё тело мебели на стороне +pointer; pointer внутри сохраняет внутренний результат. Mutation, удаляющая +наружный candidate либо меняющая знак его normal, краснит тест. + +### AC2. Общие, нулевые и независимые стены не регрессируют + +`unit`: общая стена даёт ровно две room-facing стороны без третьего кандидата; +нулевая внешняя стена даёт одну осевую поверхность; independent body остаётся +без дополнительного offset. Результат инвариантен к room order, winding и +surface order. Mutation, безусловно добавляющая внешнюю грань, краснит count и +geometry assertions. + +### AC3. Preview, commit и drag сохраняют сторону намерения + +`smoke`: на production bundle внешний pointer показывает preview снаружи, clean +click сохраняет те же `x/y/angle`, а drag с наружной стороны остаётся снаружи. +Внутренняя контрольная проба остаётся внутри; exact-axis placement выбирает +внутреннюю сторону, exact-axis drag сохраняет прежнюю. Mutation, подменяющая raw +intent на snapped point либо удаляющая наружную поверхность, краснит smoke. + +### AC4. Каждый вид выбранного декора двигается на одну клетку + +`unit`: для `line`, `rect`, `ellipse`, `text`, `furniture` и `image` четыре +направления меняют только позиционные поля на `gridPitch`, сохраняют off-grid +остаток и все непозиционные поля. Mutation с шагом `1 cm`, округлением anchor к +grid либо движением только одной точки линии краснит соответствующую проверку. + +### AC5. Клавиатурная доводка не включает магниты + +`smoke`: выбранная мебель у стены после Arrow имеет точную дельту в одну клетку, +даже если новое положение остаётся в радиусе wall magnet; image и обычный decor +дают ту же дельту. Spy/negative probe подтверждает, что wall/decor snap resolver +не вызван. Mutation, направляющая Arrow через pointer-move path, краснит тест. + +### AC6. Bounds не деформируют объект + +`unit`: box и линия у всех четырёх границ получают общую допустимую дельту, +сохраняют размер/длину/угол, затем дают no-op. Mutation, clamp-ящая координаты +линии независимо либо допускающая выход за `CANVAS_LIMIT`, краснит тест. + +### AC7. History и save соответствуют одному нажатию + +`smoke`: Arrow сохраняет selection, создаёт один `history.decor_move` и один +persisted шаг; Undo возвращает его, Redo повторяет. No-op не создаёт history/save. +Mutation, пропускающая snapshot либо записывающая no-op, краснит проверку. + +### AC8. Поля, диалоги и жесты защищены + +`smoke`: Arrow в input/textarea/select/contenteditable, открытом диалоге и +editor toolbar не меняет декор; активный drag/resize/rotate также не меняется +параллельно. Обычный Arrow и `Shift+Arrow` на холсте дают одну клетку, а +Ctrl/Cmd/Alt+Arrow не перехватываются. Mutation, снимающая focus/dialog guard, +зарегистрирована в `mutation-gate` и обязана покраснить smoke. + +### AC9. Совместимость и бюджет сохранены + +`unit + review`: существующие furniture/decor fixtures читаются без миграции, +сохранённые объекты не двигаются сами, touch path не получает нового жеста. +`typecheck`, `npm test`, `build`, `bundle:sync`, `bundle:budget`, `check-docs` и +выбранные `smoke-select` сценарии зелёные; wall surface cache по-прежнему строится +один раз на geometry epoch. + +## План тестирования + +- расширить `test/furniture.test.mjs` exterior/internal/shared/zero/independent + surface fixtures и threshold относительно наружной поверхности; +- вынести pure keyboard-delta helper и покрыть все decor kinds, off-grid phase, + bounds и no-op unit-тестами; +- расширить `demo/smoke_furniture.mjs` наружными preview/commit/drag сценариями; +- расширить релевантный decor smoke Arrow-сдвигом, Undo/Redo, focus/dialog guards + и проверкой отсутствия повторного magnet resolver; +- добавить дорогие отрицательные варианты AC3/AC5/AC8 в + `scripts/mutation-gate.mjs`; дешёвые AC1/AC2/AC4/AC6 доказывать локальной + мутацией unit-кода в отчёте реализации; +- выполнить `node scripts/smoke-select.mjs --base origin/dev --head HEAD` и + запустить каждый релевантный smoke до `S7-code-review`; +- визуальный golden не обязателен: задача меняет результат интерактивного + placement, который измеряется координатами production DOM в smoke, а не + статическую композицию готового плана. + +## Карта реализации + +- `src/furniture-wall-surface.ts` — paired exterior surface и определение числа + room owners атома; +- `src/furniture-placement.ts` — при необходимости явный inside tie-break для + нового exact-axis placement; +- `src/editors/decor/geometry.ts` либо маленький pure-модуль рядом — единый + расчёт keyboard delta для всех decor kinds; +- `src/houseplan-card.ts` / `src/houseplan-editor-runtime.ts` — keyboard guard, + history/save orchestration без pointer resolver; +- `test/furniture.test.mjs` и decor unit-тесты; +- `demo/smoke_furniture.mjs`, релевантный decor smoke, + `scripts/mutation-gate.mjs`; +- `docs/FURNITURE.md`, `docs/DECOR-EDITOR.md`, `docs/USER-GUIDE.ru.md`, оба + changelog и screenshot fingerprint по общему `src/**` правилу. + +## Риски и rollback + +- Главный риск — принять частично общий атом за внешний и создать лишнюю грань. + Его закрывают атомарная owner-группировка и fixtures с разной сегментацией. +- Второй риск — exact-axis tie начнёт зависеть от порядка массива. Его закрывают + permutation/winding tests и явный приоритет внутренней стороны. +- Третий риск — глобальная Arrow-команда украдёт навигацию у формы либо toolbar. + Его закрывают focus/dialog guards, smoke и mutation witness. +- Четвёртый риск — отдельный keyboard move разойдётся с pointer bounds/history. + Его закрывают pure delta helper и общие snapshot/save primitives. +- Rollback — revert frontend/tests/docs/bundle. Data rollback не нужен: схема и + сохранённый смысл полей не меняются. + +## Release-артефакты + +- User-visible implementation commit обновляет `docs/CHANGELOG.md` и + `docs/CHANGELOG.ru.md` со ссылкой на #447. +- `docs/FURNITURE.md` описывает внутреннюю и наружную стороны внешней стены; + `docs/DECOR-EDITOR.md` и `docs/USER-GUIDE.ru.md` — Arrow-сдвиг на клетку. +- Любой `src/**` diff обновляет canonical Docs screenshots fingerprint через + workflow `Docs screenshots` и `npm run docs:accept -- --reviewed`. +- Отдельный golden baseline не планируется, если ревью не обнаружит статической + визуальной поверхности, не покрытой DOM-coordinate smoke. +- Release notes называют изменение отдельно только при попадании в четыре + значимых bullet; иначе оно остаётся в changelog и общем пункте мелких + исправлений. + +## Принятые предположения + +- Внешним считается атом, принадлежащий ровно одной комнате; внешняя поверхность + не создаётся для общей или нулевой стены. +- При новом placement точно на оси внешней стены сохраняется внутренний default + #445; при drag сохраняется текущая сторона. +- Arrow двигает по мировым осям холста, а не по локальным осям повёрнутого объекта. +- Off-grid объект сохраняет свой остаток, потому что команда применяет дельту, + а не повторный snap. +- Каждый browser keydown/auto-repeat — отдельный Undo-шаг; `Shift` не меняет шаг. +- Последний шаг у canvas limit может быть короче клетки, чтобы не деформировать + объект; полный no-op не сохраняется. +- Техническую раскладку pure helper можно менять на ревью без продуктового + решения, если AC и контракт остаются теми же. diff --git a/docs/specs/README.md b/docs/specs/README.md index c163cd70..4ad02dc6 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -1,6 +1,6 @@ # Спецификации задач -Актуально на 2026-09-03. +Актуально на 2026-09-04. GitHub Issues и GitHub Projects (v2) остаются единственным каноническим backlog проекта. Этот каталог содержит развёрнутые ТЗ: каждое ТЗ ссылается на issue, а issue — на соответствующий файл. Статус, приоритет и факт завершения меняются только в GitHub. @@ -172,6 +172,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным | [#432](https://github.com/Matysh/houseplan-card/issues/432) Ограниченный resolve и единая проверка целостности изображений | [432-asset-resolve-authorization-cache.md](432-asset-resolve-authorization-cache.md) | | [#440](https://github.com/Matysh/houseplan-card/issues/440) Полиш аудита v1.71.0-beta.2 | [440-v171-beta2-polish.md](440-v171-beta2-polish.md) | | [#445](https://github.com/Matysh/houseplan-card/issues/445) Магнит мебели к физической поверхности стены | [445-furniture-wall-face-snap.md](445-furniture-wall-face-snap.md) | +| [#447](https://github.com/Matysh/houseplan-card/issues/447) Наружная грань для мебели и сдвиг декора стрелками | [447-exterior-furniture-snap-keyboard-nudge.md](447-exterior-furniture-snap-keyboard-nudge.md) | ## P3