Волна 5 эпика #674, перенос ТЗ (класс C). Из 240 файлов `docs/specs/` в `legacy/specs/` уехали 219: на них не ссылается ни один живой файл (код, тесты, скрипты, workflow, документы вне архива и ревью). Остались 21 ТЗ — на которые ссылаются код, ADR, ISOMETRIC, SUN, RADAR, LIGHT (`docs/specs/067`), DECOR-EDITOR, support-relay, и те, на которые ссылаются они сами; README каталога объясняет, где искать остальное. Открытых issue с файлом ТЗ среди перенесённых нет. Относительные ссылки перенесённых файлов переписаны (`../X` → `../../docs/X`, соседние оставшиеся ТЗ → `../../docs/specs/…`) — все 26 резолвятся. Попутно: битая ссылка в `089-isometric-view-stage1.md:8` на удалённый `089-isometric-view.md` — теперь команда `git show` по истории. Строка в `legacy/README.md`. Issue: #682 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
28 KiB
ТЗ #447 — Наружная грань для мебели и точный сдвиг декора стрелками
- Issue: https://github.com/Matysh/houseplan-card/issues/447
- Приоритет: P2,
bug - Маршрут: full; меняются физическая семантика wall magnet и публичный клавиатурный контракт Редактора подложки
- Связанные задачи: #445 (физическая внутренняя грань стены), #41 (более широкий keyboard editing), #383 (трансформации мебели)
Сценарий
Home admin в desktop-Редакторе подложки размещает мебель с наружной стороны дома — например, скамейку на террасе или ящик у фасада — либо выбирает уже размещённый предмет декора и доводит его положение по видимой сетке клавишами со стрелками.
Что человек увидит до и после
До: мебель у наружной стороны стены либо не магнитится, либо прыгает сквозь кладку внутрь комнаты; выбранный декор нельзя сдвинуть на одну клетку без точного drag или ручного ввода координат.
После: мебель остаётся с той стороны стены, с которой её подвёл пользователь, и может лечь как на внутреннюю, так и на наружную поверхность; каждая стрелка сдвигает выбранный декор ровно на одну клетку сетки без повторного магнита.
Подтверждённая проблема
roomFurnitureWallSurfaces()создаёт на каждом атоме только одну поверхность:axis + inwardNormal * halfDepth. Наружной поверхности внешней стены в кандидатах нет.snapFurnitureToWall()вычисляетsideScore = -2для поверхности за стеной, но использует score только при равном расстоянии. Если внутренняя грань — единственный кандидат в радиусе, она выигрывает и переносит предмет через кладку._keyHandlerРедактора подложки обрабатывает Undo/Redo, Delete/Backspace и Escape, но не Arrow-клавиши для_decorSel.- 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 поверхность сохраняется. Для атомарного осевого интервала дополнительно создаётся наружная поверхность, если одновременно выполнено следующее:
- интервал принадлежит ровно одной комнате в текущем пространстве;
- его локальная физическая полутолщина больше числового epsilon;
- ось, обе конечные точки и нормаль конечны, а длина интервала ненулевая.
Наружная поверхность равна 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.mjsexterior/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 через workflowDocs 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 и контракт остаются теми же.