docs: specify exterior furniture snap and keyboard nudge

Issue: #447
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-04 08:28:20 +03:00
parent effb36295d
commit a1fbea07d2
2 changed files with 362 additions and 1 deletions
@@ -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 и контракт остаются теми же.
+2 -1
View File
@@ -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