mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 22:49:16 +00:00
390 lines
30 KiB
Markdown
390 lines
30 KiB
Markdown
# ТЗ #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 и уже сохранённая мебель не меняются.
|