mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
docs: specify exterior furniture snap and keyboard nudge
Issue: #447 User-Visible: no
This commit is contained in:
@@ -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 и контракт остаются теми же.
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user