docs: specify furniture wall-face snapping

Issue: #445
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-04 00:51:24 +03:00
parent 560ca214b0
commit f6879b3788
2 changed files with 378 additions and 0 deletions
+377
View File
@@ -0,0 +1,377 @@
# ТЗ #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
или сохранённую геометрию стен.
## План тестирования
- заменить центролинейные 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;
- расширить `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 и уже сохранённая мебель не меняются.
+1
View File
@@ -171,6 +171,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#431](https://github.com/Matysh/houseplan-card/issues/431) Канонизация координат пользовательских изображений | [431-image-coordinate-canonicalization.md](431-image-coordinate-canonicalization.md) |
| [#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) |
## P3