Files
houseplan-card/docs/specs/447-exterior-furniture-snap-keyboard-nudge.md
T
2026-09-04 08:40:44 +03:00

28 KiB
Raw Blame History

ТЗ #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 в render-координатах по соответствующей оси холста, с явным переводом в нормализованные persisted-поля;
  • общий 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 renderX -= gridPitch
ArrowRight renderX += gridPitch
ArrowUp renderY -= gridPitch
ArrowDown renderY += gridPitch

Persisted-координаты декора нормализованы, поэтому render-дельта переводится перед записью тем же преобразованием, что и pointer move:

  • dxN = renderDx / NORM_W для x, x1, x2;
  • dyN = renderDy / decorH для y, y1, y2.

При квадратном холсте текущего контракта оба значения по модулю равны GRID_STEP_N = 1 / GRID_N. Деления на cell_cm нет: cell_cm определяет физический размер видимой клетки, но не меняет её нормализованный шаг. Для 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 четыре направления дают видимую render-дельту ровно gridPitch, а нормализованные поля меняют на gridPitch / NORM_W по X и gridPitch / decorH по Y (в текущем квадратном холсте — GRID_STEP_N). Тест сохраняет off-grid остаток и все непозиционные поля. Mutation, прибавляющая gridPitch прямо к persisted-полю, использующая шаг 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 и контракт остаются теми же.