diff --git a/docs/specs/445-furniture-wall-face-snap.md b/docs/specs/445-furniture-wall-face-snap.md new file mode 100644 index 00000000..6c191fb7 --- /dev/null +++ b/docs/specs/445-furniture-wall-face-snap.md @@ -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 и уже сохранённая мебель не меняются. + diff --git a/docs/specs/README.md b/docs/specs/README.md index 12ef1d58..c163cd70 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -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