Files
houseplan-card/legacy/specs/445-furniture-wall-face-snap.md
Claudeandclaude[bot] df46fd1c3e docs(hygiene): ТЗ выпущенных задач без живых ссылок — в legacy/specs (#682)
Волна 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
2026-09-27 22:10:46 +00:00

30 KiB
Raw Permalink Blame History

ТЗ #445 — Магнит мебели к физической поверхности стены

  • Issue: https://github.com/Matysh/houseplan-card/issues/445
  • Приоритет: P2, bug
  • Статус ТЗ: готово к ревью
  • Маршрут: full; меняется физический контракт магнита мебели одновременно для preview, первого размещения и перемещения
  • Связанные контракты: #359 (preview равен сохранённому результату), #383 (трансформации мебели), #233 (размеры от внутренних граней), #201 (локальная атомарная толщина стены)

Сценарий

Home admin в Редакторе подложки выбирает телевизор, шкаф или другой предмет с выраженной задней гранью и подводит его к стене комнаты. Магнит должен поставить предмет вплотную к видимой поверхности стены со стороны комнаты. То же правило действует, когда уже сохранённый предмет перетаскивают к другой стене.

Что человек увидит до и после

До: задняя грань мебели притягивается к невидимой осевой линии. На толстой стене предмет частично или полностью погружается в кладку, а при попадании указателя точно на ось может внезапно оказаться снаружи дома или по другую сторону общей стены.

После: задняя грань мебели лежит на ближайшей физической поверхности стены, а всё тело предмета остаётся с выбранной стороны. Толщина стены не уменьшает зону магнита. Preview, клик и последующее перетаскивание показывают один и тот же результат без скачка.

Подтверждённая проблема

  1. HouseplanEditorRuntime._furnWalls объединяет roomEdges с гранями только независимых тел. Для комнатных стен передаются осевые линии без толщины.
  2. snapFurnitureToWall принимает голый [x1,y1,x2,y2], кладёт BACK мебели на этот сегмент и смещает центр только на половину глубины предмета. Половина толщины стены в расчёте отсутствует.
  3. _decorSnap до вызова resolver штатно переносит указатель на контур комнаты. В результате исходная сторона теряется, а ветка nl < 1e-9 выбирает левую нормаль из направления записи ребра. Эта нормаль не является внутренней стороной комнаты.
  4. И placement preview, и commit используют _resolveFurniturePlacement, но перемещение существующей мебели вызывает snapFurnitureToWall отдельно. Без общего физического входа эти пути могут разойтись.
  5. test/furniture.test.mjs использует roomEdges голого полигона и прямо ожидает центр = осевая + глубина/2. Ненулевая толщина стены в фикстуре отсутствует, поэтому тест закрепляет дефект.

Скоуп

В скоупе:

  • явная модель кандидата поверхности для магнита мебели вместо голого центролинейного сегмента;
  • внутренние поверхности внешних стен комнат;
  • обе внутренние поверхности общих стен с выбором стороны по исходному указателю;
  • локальная атомарная толщина на участке проекции;
  • сохранение исходной, ещё не притянутой точки до _decorSnap;
  • стабильная сторона при точном попадании на ось;
  • радиус магнита от физической поверхности, а не от осевой линии;
  • один pure resolver и одна семантика для hover preview, clean tap и drag существующей мебели;
  • прежнее поведение нулевых стен и физических граней независимых тел;
  • unit, browser smoke, mutation witnesses и визуальный regression fixture;
  • документация и оба changelog.

Не-скоуп

  • collision model мебели с дверями, окнами, колоннами, другой мебелью или границами комнаты;
  • автоматический поиск свободного места вдоль стены;
  • привязка сохранённой мебели к wall id и автоматическое следование будущему resize стены;
  • изменение размеров, rotation/Shift, зеркалирования и hit area из #383;
  • изменение каталога либо SVG-рисунков мебели;
  • изменение толщины, геометрии или рендера самих стен;
  • новая настройка силы магнита или UI-индикация поверхности;
  • миграция уже сохранённых предметов;
  • новые жесты, диалоги, тексты или ключи i18n.

Контракт поведения

1. Физический кандидат стены

Магнит получает не только ось, но и готовую сторону, на которую можно положить BACK мебели. Минимальный смысл записи кандидата:

  • конечный атомарный интервал оси;
  • параллельная ему поверхность;
  • единичная нормаль от поверхности в сторону допустимого тела мебели;
  • вид владельца: внутренняя грань комнаты либо обычная грань независимого тела;
  • стабильная identity/tie-break информация, не зависящая от порядка комнат и направления ребра.

Для комнатной стены поверхность строится из существующего атомарного wall profile: axis + inwardNormal * halfDepth. Значение halfDepth берётся у локального атома под проекцией, поэтому перепад толщины на том же исходном ребре не размазывается на соседний участок.

Внешняя стена даёт один допустимый room-facing candidate. Общая стена даёт по одному candidate для каждой смежной комнаты. Нулевая/виртуальная стена имеет halfDepth = 0, поэтому её поверхность совпадает с осью и видимое положение остаётся прежним.

Грани partitions и wall_columns, которые уже приходят как физические полигоны, не получают повторного offset: их сегмент уже является поверхностью.

2. Выбор стороны

Resolver различает:

  • точку размещения после действующего grid/decor snap — она определяет положение вдоль поверхности;
  • точку намерения до snap — она определяет, с какой стороны пользователь подвёл предмет;
  • при перемещении — предыдущую сторону предмета как tie-break для точного равенства.

Правила выбора:

  1. У внешней комнатной стены всегда выбирается единственная внутренняя поверхность. Положение указателя чуть снаружи не отправляет предмет на фасад; намеренное свободное размещение снаружи остаётся доступно через Shift.
  2. У общей стены выбирается candidate на стороне точки намерения относительно оси.
  3. Если точка намерения лежит на оси в пределах числового epsilon, drag сохраняет текущую сторону предмета, чтобы тот не прыгнул через стену.
  4. Для нового предмета без прежней стороны точное равенство разрешается устойчивым tie-break по стабильной identity смежных комнат. Результат не зависит от winding, направления ребра или порядка массива.
  5. У независимого физического тела сохраняется выбор ближайшей к точке намерения грани; его можно обойти с любой стороны, как сейчас.
  6. В углу либо при равном расстоянии до нескольких стен выигрывает физически ближайшая поверхность, затем сторона намерения, затем стабильный tie-break. Перестановка входных массивов не меняет результат.

Левая нормаль направления ребра больше не является допустимым продуктовым fallback для комнатной стены.

3. Положение предмета

После выбора candidate:

  • BACK мебели лежит на его surface segment;
  • локальная +y предмета направлена по нормали в допустимую сторону;
  • центр равен точке поверхности плюс normal * depth/2;
  • координата вдоль поверхности по-прежнему квантуется действующим grid step;
  • квантизация ограничивается конечным интервалом candidate и не переносит предмет на соседний атом с другой толщиной;
  • angle следует направлению поверхности с прежней точностью хранения;
  • результат проходит существующий canvas clamp только после физического snap.

Для стены толщиной 20 см и мебели глубиной 10 см BACK лежит в 10 см от оси на внутренней грани, а центр — ещё в 5 см внутрь комнаты. Ни одна часть глубины предмета не лежит между двумя физическими поверхностями стены.

4. Радиус магнита

Действующее значение FURN_WALL_CELLS не меняется. Расстояние для eligibility и поля dist считается от точки намерения до surface segment. Поэтому шесть клеток соответствуют тем же физическим 30 см от видимой поверхности и для нулевой, и для толстой стены.

За пределами радиуса resolver возвращает отсутствие магнита и сохраняет действующее grid-bound размещение. Shift по-прежнему не строит и не выбирает wall candidate вообще.

5. Preview, commit и move

Первичное размещение сохраняет контракт #359:

  • hover preview и clean click вызывают один resolveFurniturePlacement с одной и той же raw/intent точкой и одним снимком wall candidates;
  • клик сохраняет точные x/y/w/h/angle, которые показывал последний валидный preview;
  • pointerleave, pointercancel, pinch и смена инструмента не создают объект.

Перемещение существующей мебели использует тот же low-level selection/snap helper, а не собственную центролинейную ветку. В resolver передаётся исходный центр текущего drag и сторона исходного предмета для tie-break. После отпускания сохраняются те же координаты, которые видны во время drag.

Mouse, pen и clean touch tap получают одинаковую конечную геометрию. Hover остаётся необязательным на coarse pointer; новых touch-жестов нет.

6. Кэш и производительность

Wall candidates зависят от пространства, wall/room geometry, wall thickness, open/virtual profile и масштаба физических единиц, но не от каждого движения указателя. Runtime кэширует либо переиспользует готовый список на действующий geometry/config epoch и сбрасывает его при изменении любой из этих зависимостей.

Pointermove не должен заново строить полный roomWallProfile для всех комнат. Pure snap по уже готовому списку остаётся линейным по числу кандидатов, как нынешний поиск по _furnWalls. Новых initial-view imports нет: код остаётся в существующем lazy editor graph.

Данные и совместимость

  • Persisted schema и формат decor.kind = "furniture" не меняются.
  • x, y, w, h, angle, flip_h, flip_v, цвет, opacity и width_cm сохраняют текущий смысл.
  • Уже сохранённая мебель не перемещается при load, render, Optimize, export или import. Новый алгоритм действует только во время нового placement/drag.
  • Успешная операция сохраняет обычный config write и Undo/Redo contract Редактора подложки.
  • Пространство без физической толщины получает прежний центролинейный результат.
  • Backend и версия config не меняются; миграции нет.

Touch, клавиатура и доступность

  • Touch editor остаётся best effort по docs/TOUCH-SUPPORT.md, но safety floor обязателен: clean tap создаёт не более одного предмета в той же физической позиции, что и pure resolver; pinch, второй pointer и pointercancel ничего не сохраняют.
  • Shift на desktop сохраняет действующий смысл «временно отключить магнит».
  • Клавиатурных focus targets, ARIA-строк и диалогов не добавляется.
  • View и kiosk не получают новых интеракций; уже сохранённая мебель рендерится без изменений.

Ошибки и крайние случаи

Случай Ожидаемое поведение
внешняя стена 20 см, мебель 10 см, pointer внутри BACK на внутренней поверхности, тело внутри комнаты
тот же pointer попал точно на ось после grid snap используется исходная unsnapped сторона, не winding ребра
pointer чуть снаружи внешней стены магнит всё равно ставит предмет внутрь; Shift оставляет снаружи свободно
общая стена, pointer с каждой стороны предмет оказывается в соответствующей комнате
drag попал точно на ось общей стены сохраняется сторона предмета до drag
новое размещение точно на оси общей стены устойчивый результат, одинаковый при перестановке rooms/edges
локальный переход 10 → 20 см каждая проекция использует толщину своего атома
нулевая/виртуальная стена поверхность совпадает с осью, прежнее положение
независимая перегородка/колонна привязка к ближайшей уже физической грани без двойного offset
вне шести клеток от поверхности магнита нет
Shift магнита нет независимо от близости стены
malformed/нулевой segment кандидат пропускается, finite-результат остальных сохраняется
угол с двумя равноудалёнными стенами стабильный tie-break, без зависимости от порядка массива

Acceptance criteria и доказательства

AC1. Толстая внешняя стена использует внутреннюю поверхность

Unit fixture с комнатой, стеной 20 см и мебелью глубиной 10 см проверяет BACK, центр, angle и отсутствие пересечения глубины с телом стены для pointer внутри, на оси и чуть снаружи. Mutation, возвращающая halfDepth = 0, обязана сделать этот тест красным.

AC2. Общая стена сохраняет намеренную сторону

Unit проверяет обе стороны общей стены, точное попадание на ось при новом placement и сохранение прежней стороны при drag. Перестановка комнат, разворот ребра и изменение winding дают эквивалентный результат. Mutation, возвращающая fallback к левой нормали, обязана краснить witness.

AC3. Используется локальная атомарная толщина

Unit fixture с коллинеарными участками 10 и 20 см проверяет разные surface offset в соответствующих проекциях и отсутствие переноса после along-grid quantization. Mutation с whole-edge/default thickness обязана краснить witness.

AC4. Радиус считается от поверхности

Unit проверяет порог ровно вокруг FURN_WALL_CELLS: толстая и нулевая стены срабатывают на одинаковом физическом расстоянии от видимой поверхности и не срабатывают сразу за порогом. Mutation с distance-to-axis обязана краснить witness.

AC5. Preview, commit и move используют один контракт

Browser smoke на production bundle размещает и затем двигает мебель у толстой внешней и общей стены. Он сравнивает preview transform с сохранёнными x/y/angle, проверяет отсутствие скачка, устойчивую сторону и Shift bypass. Отрицательная подмена одного из путей на legacy centrelines делает smoke красным и регистрируется в scripts/mutation-gate.mjs.

AC6. Нулевые стены и независимые тела не регрессировали

Unit сохраняет прежние ожидаемые координаты для halfDepth = 0, partitions и column faces, доказывает отсутствие двойного offset и finite fallback для битого сегмента.

AC7. Touch safety и данные сохранены

Существующий furniture browser smoke остаётся зелёным для clean touch tap, pinch/second-pointer/pointercancel, Undo/Redo и одного config write. Contract test подтверждает отсутствие schema/migration changes и отсутствие переписи уже сохранённой мебели при load/render.

AC8. Видимый результат защищён

Canonical furniture-placement visual fixture показывает мебель у стены с ненулевой толщиной; BACK совпадает с внутренней поверхностью в light и dark темах либо одна тема используется как пиксельный witness, а вторая проверяется browser geometry assertions. Принятие golden выполняется только через Linux CI перед бетой по действующему процессу.

AC9. Производительность и общие гейты

Unit/code review подтверждает, что wall candidates строятся не чаще одного раза на geometry/config epoch, pointermove использует готовый массив и initial-view graph не растёт новым импортом. Обязательны typecheck, unit и build; перед бетой — выбранные smoke_furniture, golden, docs screenshots, performance и полный exact-SHA Validate. Для src/** обновляется canonical docs source fingerprint. Backend pytest и model-invariants неприменимы, если реализация не меняет Python или сохранённую геометрию стен.

AC10. Выбор стены в углу детерминирован

Unit fixture с двумя неколлинеарными поверхностями одного угла проверяет три ветки выбора: при разных расстояниях выигрывает физически ближайшая поверхность; при равном расстоянии сторона точки намерения учитывается раньше стабильного tie-break; при полном равенстве перестановка входного массива не меняет выбранную поверхность и конечные x/y/angle. Mutation, возвращающая прежний выбор первого минимума из массива либо меняющая местами intent-критерий и стабильный tie-break, обязана сделать соответствующий witness красным.

План тестирования

  • заменить центролинейные expectations секции wall magnet в test/furniture.test.mjs typed wall-face fixtures с 0/10/20 см;
  • добавить pure tests внешней/общей стены, intent point, exact-axis tie, permutation/winding, local thickness, threshold и independent faces;
  • добавить отдельный AC10 unit для двух неколлинеарных стен в углу: ближайшая поверхность, приоритет стороны намерения и инвариантность к порядку массива при точном равенстве;
  • расширить demo/smoke_furniture.mjs production-bundle сценарием preview → commit → drag у толстой стены и touch safety;
  • зарегистрировать дорогие отрицательные варианты в scripts/mutation-gate.mjs, включая legacy centreline и произвольную left normal;
  • обновить либо добавить canonical furniture golden, который визуально видит ненулевую толщину;
  • выполнить в реализации typecheck, unit, build и целевые witnesses; полный golden/smoke/performance/HA gate — перед следующей бетой.

Карта реализации

  • src/wall-thickness.ts либо небольшой pure-модуль рядом — построение атомарных room-facing surface candidates на существующем wall profile;
  • src/furniture.ts — typed candidate input, distance-to-surface, выбор стороны и общий pure snap;
  • src/houseplan-editor-runtime.ts — кэш candidates, передача raw intent point и прежней стороны в placement/move;
  • test/furniture.test.mjs, при необходимости отдельный wall-surface unit;
  • demo/smoke_furniture.mjs, scripts/mutation-gate.mjs, golden fixture;
  • docs/FURNITURE.md, RU/EN changelog, docs screenshots provenance.

Риски и rollback

  • Главный риск — неверно определить внешнюю/общую сторону на частично общей или разнотолщинной стене. Его закрывают атомарные candidates, permutation fixtures и запрет fallback на winding.
  • Второй риск — preview и commit получают разные raw точки. Его закрывает один placement resolver и browser-сравнение точных координат.
  • Третий риск — пересчитывать wall profile на каждом pointermove. Его закрывает epoch cache и call-count/code-review evidence.
  • Четвёртый риск — дважды сместить уже физическую грань partition/column. Его закрывает отдельный owner kind и unit regression.
  • Rollback реализации — revert frontend/tests/docs/bundle. Data rollback и миграция не нужны: сохранённый формат не меняется.

Release-артефакты

  • User-visible implementation commit обновляет docs/CHANGELOG.md и docs/CHANGELOG.ru.md со ссылкой на #445.
  • docs/FURNITURE.md заменяет обещание «BACK на wall» точным определением: BACK на room-facing физической поверхности, сторона — intent point, Shift отключает магнит.
  • Любой src/** diff требует canonical Docs screenshots capture и приёмки fingerprint.
  • Видимый regression fixture/golden принимается только через Linux CI. Если существующий furniture preview baseline достаточно ясно показывает стену, обновляется он; иначе добавляется один узкий сценарий без разрастания матрицы.
  • Release notes отдельно называют исправление только если оно входит в число четырёх значимых bullet; иначе оно остаётся в подробном changelog и общем «Мелкие исправления и улучшения».

Принятые предположения

  • Default Q1: внешняя стена всегда выбирает внутреннюю поверхность; общая — сторону исходного указателя; exact-axis drag сохраняет прежнюю сторону, а новое placement использует детерминированный room tie-break.
  • Default Q2: радиус считается от физической поверхности.
  • Preview, placement и move используют один контракт.
  • На участке переменной толщины используется локальная атомарная толщина.
  • Нулевые стены и физические грани независимых тел сохраняют прежнее поведение.
  • Проёмы не участвуют в collision model этой задачи.
  • Shift, persisted schema, i18n и уже сохранённая мебель не меняются.