mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
docs: specify furniture wall-face snapping
Issue: #445 User-Visible: no
This commit is contained in:
@@ -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 и уже сохранённая мебель не меняются.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user