docs: specify furniture placement preview

Issue: #359
User-Visible: no
This commit is contained in:
Matysh
2026-08-29 00:00:55 +03:00
parent ccbbe94cab
commit 392ef22c0d
@@ -0,0 +1,197 @@
# #359 — Предпросмотр мебели на плане перед размещением
Issue: [#359](https://github.com/Matysh/houseplan-card/issues/359)
## Сценарий
Персона **администратор дома** (J4/J6 из `docs/SCOPE.md`) работает в
**Редакторе подложки** на desktop с мышью: открывает библиотеку мебели,
выбирает предмет, при необходимости меняет его ширину и глубину и ведёт
указатель к месту установки на плане.
Touch editor: **best effort / intentionally degraded**. Безопасное размещение
одним тапом сохраняется, но hover-предпросмотр без hover-capable pointer не
обещается; View и kiosk не меняются.
## Что человек увидит до и после
До изменения предмет появляется только после клика; после изменения до клика
виден полупрозрачный предмет ровно в той позиции, размере и ориентации, в
которых он будет размещён.
## Проблема
Текущее одноразовое размещение мебели скрывает от пользователя три значимых
результата до записи: реальный габарит предмета, итог сеточной привязки и
срабатывание магнита к стене вместе с автоматическим поворотом. Ошибка видна
только после сохранения и требует отдельного перемещения либо удаления.
## Скоуп
- transient preview одного выбранного предмета мебели на SVG-плане;
- полное совпадение preview и commit по координатам, размеру, углу, символу и
текущему стилю контура;
- немедленное обновление при движении мыши, изменении Width/Depth и изменении
состояния `Shift`;
- единый resolver геометрии для preview и фактического размещения;
- очистка preview на всех границах жизненного цикла инструмента;
- целевые unit/source checks и браузерный smoke существующего furniture flow;
- пользовательская документация и RU/EN changelog.
## Не-скоуп
- multi-stamp и изменение текущего возврата в **Выбрать** после одного клика;
- размерные плашки во время первоначального размещения;
- collision detection, запрет размещения вне комнат или автоматическое
исправление пересечений;
- изменение сохранённой схемы `space.decor`, миграция или новые настройки;
- изменение перемещения, resize и rotate уже сохранённой мебели;
- гарантированный hover-preview на touch/coarse-pointer устройствах;
- изменение рисунков и состава библиотеки мебели.
## Контракт поведения
1. Preview существует только когда одновременно выполнены условия:
`mode === decor`, активен инструмент `furniture`, выбран валидный символ и
последний fine/hover-capable mouse pointer находится над планом.
2. Preview использует текущий выбранный символ, введённые Width/Depth и
текущий стиль декоративного контура. Он рисуется как тот же top-view SVG,
но с общей визуальной непрозрачностью `0.55`, `aria-hidden="true"` и
`pointer-events="none"`.
3. Положение сначала проходит существующую сеточную/decor/room привязку.
Затем, если `Shift` не зажат, применяется существующий магнит мебели к
стене и его автоматический поворот. При `Shift` магнит пропускается, а
оставшаяся привязка продолжает работать — ровно как в текущем commit.
4. Габарит целиком ограничивается теми же текущими canvas bounds, что и при
размещении. Предмет у края показывает уже скорректированную позицию, а не
позицию, которая затем «прыгнет» после клика.
5. Один и тот же чистый resolver строит итоговую геометрию preview и запись
`DecorShape`. На `pointerdown` геометрия разрешается для координаты самого
события и сразу передаётся commit; сохранённый объект не вычисляет
привязку вторым независимым путём.
6. Preview не меняет `space.decor`, `cfgEpoch`, очередь записи, selection и
историю Undo/Redo. Движение указателя не вызывает `config/set`.
7. После успешного клика сохраняется ровно один предмет, он выделяется, а
инструмент, категория и палитра сбрасываются как сейчас; preview исчезает
в том же кадре.
8. Preview очищается при `pointerleave` и `pointercancel`, Escape, закрытии
палитры, выборе другого инструмента, смене editor/space, потере выбранного
символа и teardown/remount. Stale state дополнительно не рендерится, если
условия пункта 1 больше не выполняются.
9. Изменение Width/Depth при указателе над планом пересчитывает preview без
необходимости снова двигать мышь. Невалидное/неизвестное изображение не
создаёт preview и не ломает редактор.
10. Touch/pen не создают hover-preview и очищают возможный mouse-preview.
Один тап сохраняет не более одного предмета; второй pointer, pinch и
`pointercancel` не должны создавать запись.
## UX
- Ghost находится в decor composition layer поверх сохранённого decor и не
участвует в hit testing.
- Используется реальный top-view рисунок и реальный будущий размер, а не
bounding-box placeholder.
- Сниженная непрозрачность однозначно отделяет ещё не сохранённый объект от
сохранённых. Дополнительная рамка, анимация и текст не добавляются.
- Cursor и одноразовая модель инструмента не меняются.
- При отсутствии выбранного варианта палитра ведёт себя как сейчас и ничего
на плане не показывает.
## Модель данных и миграция
Сохранённая модель не меняется. Preview — экземпляр локального runtime state,
не сериализуется и не восстанавливается после remount. Фактический предмет
продолжает записываться как существующий `DecorShape` вида
`{ kind: "furniture", symbol, x, y, w, h, angle?, ...style }`.
Миграция не нужна. Старые и новые конфиги читаются и записываются одинаково.
## i18n
Нового текста интерфейса нет. Существующие названия инструмента, палитры и
подсказка размещения не меняются; новые ключи локализации не нужны.
## Критерии приёмки
- **AC1 — появление:** после выбора валидного предмета mouse move над планом
показывает один `.furniture-placement-preview` с правильным `data-symbol`.
**Доказательство:** `demo/smoke_furniture.mjs`.
- **AC2 — геометрический паритет:** при одинаковом input resolver возвращает
идентичные `x/y/w/h/angle/symbol` для preview и commit; отдельно покрыты
свободное место, магнит к горизонтальной/вертикальной стене, `Shift` и край
canvas. **Доказательство:** `test/furniture.test.mjs` плюс smoke.
- **AC3 — живые размеры:** изменение Width/Depth при неподвижном указателе
меняет габарит preview, а последующий клик сохраняет этот габарит.
**Доказательство:** smoke.
- **AC4 — чистота preview:** pointer move не меняет decor/history и не вызывает
сохранение; click добавляет ровно одну запись. **Доказательство:** smoke с
счётчиками конфигурации/истории.
- **AC5 — очистка:** mouse leave, pointercancel, Escape, закрытие палитры и
смена инструмента убирают preview; после commit он также отсутствует.
**Доказательство:** smoke и source-contract test для lifecycle guards.
- **AC6 — ввод:** `Shift` немедленно меняет preview на свободное размещение,
mouse возвращает его обратно; touch/pen не оставляют ghost и cancel/pinch не
создают мебель. **Доказательство:** smoke.
- **AC7 — визуальный контракт:** preview рисует настоящий path выбранного
символа, имеет opacity `0.55`, `aria-hidden` и `pointer-events="none"`.
**Доказательство:** smoke DOM/computed-style assertions.
- **AC8 — совместимость:** существующий выбор, commit, selection, wall magnet,
resize/rotate, erase и View-rendering мебели остаются зелёными.
**Доказательство:** `test/furniture.test.mjs`, `demo/smoke_furniture.mjs`,
общие `typecheck/test/build`.
## План автотестов
1. В `src/furniture.ts` выделить/расширить чистый resolver placement и покрыть
его unit-тестами для обычной точки, стены, `Shift`-пути и clamp у границы.
2. Расширить `demo/smoke_furniture.mjs`: mouse preview, размеры без движения,
Shift parity, отсутствие мутаций, commit parity, lifecycle clear и
touch/pointercancel safety.
3. Добавить узкий source-contract test, если lifecycle очистка распределена
между card shell и lazy editor runtime и её нельзя надёжно доказать одним
smoke.
4. Перед `S7`: `npm run typecheck`, `npm test`, `npm run build`,
`npm run bundle:sync`, `npm run bundle:budget`,
`node demo/smoke_furniture.mjs`.
Новый golden baseline не требуется: smoke проверяет реальный SVG path,
computed style и позиционную геометрию, а принятие нового изображения добавило
бы дорогой платформенный шум к transient editor-only состоянию.
## Риски
- **Расхождение preview/commit.** Снижается единым resolver и сравнением
результата до/после клика.
- **Лишние записи на pointermove.** Preview state отделяется от config и
проверяется счётчиком сохранений/history.
- **Stale ghost после смены состояния.** Все выходы очищают state, а renderer
имеет независимый mode/tool/palette guard.
- **Стоимость render на каждый pointermove.** Меняется один SVG path; никакой
записи, пересчёта всей модели или DOM-измерений. При необходимости обновление
коалесцируется существующим Lit render cycle.
- **Touch misclick.** Preview ограничен real mouse/fine hover; cancel/second
pointer не коммитят объект.
- **Неизвестный symbol.** Fail dark: нет path/preview/commit, редактор жив.
## Откат
Удалить transient preview state, его pointer lifecycle и render path, сохранив
общий resolver placement и прежний вызов commit. Схема данных не меняется,
поэтому откат не требует миграции и не влияет на уже сохранённую мебель.
## Release-артефакты
- обновить `docs/FURNITURE.md` и раздел мебели в
`docs/USER-GUIDE.ru.md`;
- добавить короткие значимые записи со ссылкой на #359 в
`docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`;
- golden/screenshots не требуются;
- performance/security/backend artifacts не требуются;
- целевой browser artifact: зелёный `demo/smoke_furniture.mjs`.
## Принято предположительно, поменять свободно
- имя и форма transient state;
- конкретный модуль общего resolver и границы между card/runtime;
- способ коалесцирования pointermove;
- набор source-contract assertions сверх обязательного browser smoke.