docs: specify furniture stroke zoom

Issue: #361
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-29 07:55:23 +00:00
committed by claude[bot]
parent 3ccda408ba
commit 64114d2219
2 changed files with 286 additions and 0 deletions
+285
View File
@@ -0,0 +1,285 @@
# #361 — Физическая толщина линий мебели при camera zoom
Issue: [#361](https://github.com/Matysh/houseplan-card/issues/361)
## Сценарий
Персона **администратор дома** (J4/J6 из `docs/SCOPE.md`) размещает или уже
настроил мебель в **Редакторе подложки**, а затем приближает план либо смотрит
его в обычном View. Толщина контура задана в физических единицах и должна
вести себя как остальные физические линии декора.
View и kiosk остаются release-blocking на desktop и touch. Само редактирование
на touch остаётся best effort по `docs/TOUCH-SUPPORT.md`.
## Что человек увидит до и после
Сейчас при приближении плана мебель сохраняет прежнюю экранную толщину линии;
после исправления её контур увеличивается и уменьшается вместе с планом, не
искажаясь при изменении ширины и глубины предмета.
## Проблема
`DecorShape.width_cm` и session default `DecorStyle.widthCm` уже задают
физическую толщину, а `decorStrokeUnits()` корректно переводит её в единицы
плана. Обычные line/rect/ellipse рисуют этот `stroke-width` в пространстве
плана, поэтому camera zoom естественно меняет его экранный размер.
Сохранённая мебель и placement-preview дополнительно используют
`vector-effect="non-scaling-stroke"`. Он нужен для защиты линии от локального
неравномерного `scale(x, y)` native artwork, но одновременно отменяет внешний
camera zoom. В результате мебель выглядит как screen-space пиктограмма и
нарушает физический контракт декора.
Удалить `vector-effect` недостаточно: после независимого resize ширины и
глубины одинаковый штрих получит разную толщину по осям. Исправление обязано
разделить локальное масштабирование рисунка и внешний масштаб плана.
## Скоуп
- сохранённая мебель во View и Редакторе подложки;
- полупрозрачный placement-preview мебели до клика;
- единый расчёт физической экранной толщины для обоих render-путей;
- designer-art из native `viewBox` и 12 retained primitive symbols;
- независимый resize ширины/глубины, поворот и camera zoom;
- unit/source-contract проверки, browser smoke с растровым измерением и
детерминированное визуальное доказательство;
- актуализация документации мебели, пользовательского руководства и двух
changelog.
## Не-скоуп
- миниатюры категорий и вариантов в палитре: это screen-space UI, их
`non-scaling-stroke` и визуальная толщина не меняются;
- изменение рисунков, состава или размеров по умолчанию библиотеки;
- изменение сохранённого `width_cm`, `w`, `h`, `x`, `y`, `angle` или `symbol`;
- изменение выбора, hit-area, erase-hit, drag, resize, rotate, wall magnet и
placement resolver;
- изменение толщины line/rect/ellipse/text либо других элементов плана;
- новая настройка, новый UI, миграция config или backend;
- публичное изменение скрытого изометрического режима. Его текущая геометрия
не должна падать или терять мебель, но отдельная визуальная калибровка
изометрического stroke не входит в #361.
## Контракт поведения
1. `width_cm` остаётся единственным каноническим источником толщины контура
мебели. Для нового preview источником до записи служит текущий
`DecorStyle.widthCm`.
2. При неизменных `width_cm`, размерах предмета и viewport изменение camera
zoom с `z1` на `z2` меняет видимую толщину контура в отношении `z2 / z1`, с
тем же raster tolerance, что у контрольной decor-line.
3. На одном zoom мебель и обычная decor-line с одинаковым `width_cm` имеют
одинаковую видимую толщину независимо от размера карточки и fit-view.
4. Неравномерный resize предмета не меняет толщину его линии: горизонтальные,
вертикальные и диагональные штрихи одного symbol остаются визуально
одинаковыми в пределах raster tolerance.
5. Поворот предмета не меняет толщину. Комбинация rotate + anisotropic resize
+ camera zoom выполняет пункты 2–4 одновременно.
6. Placement-preview и сохранённый после клика предмет используют один и тот
же stroke resolver. При одинаковом symbol, box, angle и style их геометрия
и видимая толщина совпадают; различается только штатная общая прозрачность
preview `0.55`.
7. Входы с нулевым/неизмеренным viewport, невалидным zoom или невалидной
толщиной никогда не создают `NaN`, `Infinity` или исчезающий path. До
получения валидного layout используется безопасный конечный fallback, а
первый открытый пользователю кадр после boot veil уже пересчитан по
фактическому viewport.
8. Неизвестный `symbol` остаётся валидными данными и безопасным no-op. Никакой
новый runtime SVG/path из config не принимается.
## Технический render-контракт
- Локальный transform native artwork продолжает отвечать только за коробку
предмета. Он не может анизотропно менять stroke.
- Внешний plan CTM/viewBox отвечает за camera zoom. Физический stroke обязан
учитывать именно его равномерный user-unit-to-screen scale.
- Канонический pure helper принимает физический stroke в render units и
измеримый plan viewport/viewBox и возвращает конечную толщину для
non-scaling furniture path. Saved и preview не повторяют формулу отдельно.
- Для `preserveAspectRatio="xMidYMid meet"` учитывается фактический uniform
scale `min(viewportWidth / viewBoxWidth, viewportHeight / viewBoxHeight)`, а
не только `_zoom`: это сохраняет паритет с обычным decor при разных размерах
карточки и letterboxing.
- Допустима эквивалентная реализация с заранее преобразованной геометрией,
только если она доказывает те же AC и не парсит/пересобирает все SVG path на
каждом render. Предпочтительный малорисковый путь — сохранить компактный
artwork и локальный `non-scaling-stroke`, компенсировав только внешний plan
scale.
- Erase-hit остаётся screen-oriented технической hit-area и не становится
пользовательской физической линией. Его текущая интерактивная ширина не
должна сузиться или начать зависеть от `width_cm`.
- `data-hp="decor"`, `data-kind="furniture"`, `data-id`, `data-symbol`, class
names и один основной `<path>` на объект остаются стабильными.
## UX и доступность
Новых контролов, подписей, состояний focus/hover и действий нет. Исправление
видно только как корректная толщина уже существующего контура. Preview остаётся
`aria-hidden="true"` и `pointer-events="none"`; palette thumbnails и их
доступные имена не меняются.
## Модель данных, миграция и совместимость
Persisted schema не меняется:
```ts
{ kind: "furniture", symbol, x, y, w, h, color, opacity, width_cm, angle? }
```
- schema/config version и backend validation не меняются;
- сохранение или открытие старого плана не переписывает furniture records;
- прежние физические значения `width_cm` начинают отображаться правильно без
migration;
- unknown symbol, card-mod hooks и forward compatibility #159 сохраняются;
- поведение palette preview остаётся screen-fixed и не наследует новый
physical-plan resolver.
## i18n
Новых или изменённых UI-строк нет. Файлы `src/i18n/*.json` не меняются.
## Производительность и bundle
- сложность render остаётся O(число предметов), без DOM-узлов сверх прежнего
одного основного path и условного erase-hit;
- запрещено DOM-измерение на каждый предмет и повторный parse больших designer
path на каждый Lit render;
- один viewport scale вычисляется один раз на кадр/слой и переиспользуется для
saved furniture и preview;
- новых runtime dependencies нет;
- `npm run bundle:budget` проходит без изменения budget; заметный initial gzip
рост требует упрощения реализации, а не поднятия лимита в #361.
## Touch и темы
- View/kiosk на touch получают тот же физический zoom-контракт, что desktop;
- touch placement не получает hover-preview и не меняет действующий безопасный
tap/cancel flow;
- светлая и тёмная темы не меняют арифметику толщины, цвет/opacity остаются
текущими;
- Background editor остаётся desktop-first; новый touch UX не вводится.
## Затронутые файлы и модули
- `src/houseplan-card.ts` — saved furniture и placement-preview render;
- `src/furniture.ts` либо узкий pure helper рядом с decor geometry — единый
расчёт внешнего plan scale/stroke;
- `test/furniture.test.mjs` — арифметика, finite fallback, designer/primitive и
anisotropic invariants;
- узкий source-contract/mutation test — saved/preview используют общий helper,
физический path не возвращается к безусловному screen-fixed stroke;
- `demo/smoke_furniture.mjs` — raster zoom, resize/rotate и preview/commit;
- `demo/golden/matrix.mjs` и Linux golden artifact — только если существующая
сцена не даёт ревьюеру достаточного визуального доказательства двух zoom;
- `docs/FURNITURE.md`, релевантный абзац `docs/USER-GUIDE.ru.md`;
- `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`.
## Критерии приёмки
- **AC1 — физический zoom:** для сохранённого designer symbol и retained
primitive symbol raster-smoke на двух camera zoom доказывает пропорциональное
изменение экранной толщины и паритет с decor-line того же `width_cm`.
**Доказательство:** browser smoke с pixel measurement; unit-тест формулы.
- **AC2 — независимый resize:** после выраженно неравномерного resize
горизонтальные, вертикальные и диагональные штрихи имеют одинаковую толщину
в пределах `max(1 CSS px, 10%)`; их box действительно изменён по двум осям.
**Доказательство:** raster browser smoke и unit-тест transform inputs.
- **AC3 — rotate + zoom:** повёрнутый anisotropic designer symbol сохраняет
равномерный stroke, а переход между двумя zoom даёт ожидаемое отношение.
**Доказательство:** raster browser smoke.
- **AC4 — preview/commit parity:** preview до клика и сохранённый path после
клика совпадают по symbol/box/angle/stroke resolver; preview отличается
только штатной прозрачностью и оба состояния масштабируют линию одинаково.
**Доказательство:** `demo/smoke_furniture.mjs` и source-contract test.
- **AC5 — совместимость:** все 56 symbols, unknown no-op, один основной path,
card-mod hooks, selection/drag/resize/rotate/erase и wall magnet не
регрессируют; palette thumbnails остаются screen-fixed.
**Доказательство:** `test/furniture.test.mjs`, существующий furniture smoke,
ревью кода.
- **AC6 — layout safety:** нулевые и невалидные измерения дают конечный
fallback без `NaN`/исчезновения, а layout update пересчитывает толщину по
фактическому viewport.
**Доказательство:** unit-тесты и browser smoke с resize карточки.
- **AC7 — поддерживаемые поверхности:** flat View, kiosk и Background editor
используют один контракт; light/dark не меняют толщину, touch View не падает.
**Доказательство:** browser smoke и релевантная golden/screenshot matrix.
- **AC8 — производительность и сборка:** нет per-item DOM measurement,
per-render path parser и новых dependencies; typecheck, unit, build,
bundle sync и budget зелёные.
**Доказательство:** ревью кода, `npm run typecheck`, `npm test`,
`npm run build`, `npm run bundle:sync`, `npm run bundle:budget`.
- **AC9 — документация и релиз:** канон больше не утверждает, что безусловный
`non-scaling-stroke` является физическим; RU/EN changelog описывают видимое
исправление со ссылкой на #361.
**Доказательство:** docs/process gates и ревью кода.
## План автотестов
1. Добавить pure unit matrix для plan screen scale: разные zoom/viewBox,
portrait/landscape viewport, letterboxing, zero/NaN fallback.
2. Проверить одинаковый resolver для designer и retained artwork, saved и
preview; mutation guard должен падать при возврате безусловно screen-fixed
физического stroke либо при удалении camera-scale compensation.
3. Расширить `demo/smoke_furniture.mjs` контролируемой сценой: рядом находятся
decor-line, designer furniture и retained primitive с одинаковым
`width_cm`; screenshot декодируется и толщина измеряется по пикселям на
zoom 1 и zoom 2.
4. В той же сцене неравномерно изменить box, повернуть предмет и повторить
raster measurement по горизонтальному, вертикальному и диагональному
штрихам.
5. Вооружить placement-preview, измерить его до клика, кликнуть и доказать
совпадение saved path после commit; затем resize viewport и повторить scale
assertion.
6. Оставить palette thumbnail control: его толщина между zoom плана не
меняется.
7. В цикле реализации запускать только `npm run typecheck`, `npm test`,
`npm run build` по принятому процессу. Browser smoke, golden и полный gate
запускаются в предрелизном прогоне; тестовые сценарии и assertions входят в
продуктовый коммит заранее.
## Риски
- **Компенсирован не тот transform.** `_zoom` без viewport/letterboxing даёт
совпадение только на одном размере карточки. Снижается матрицей viewBox ×
viewport и сравнением с decor-line.
- **Наивное удаление vector-effect.** Создаёт анизотропную линию после resize.
Снижается raster AC2 и mutation guard.
- **Preview расходится с saved path.** Снижается одним helper и AC4.
- **Первый layout не измерен.** Снижается finite fallback, boot veil и smoke с
resize observer/layout update.
- **Дорогая подготовка SVG.** Снижается запретом per-render parsing и bundle /
code review gate.
- **Случайно меняются thumbnails или erase-hit.** Они явно отделены контрактом
и имеют контрольные assertions.
## Откат
Вернуть прежний furniture stroke resolver/render attributes и удалить новые
тестовые assertions. Persisted schema и пользовательские records не меняются,
поэтому обратной миграции и очистки данных нет. Откат вернёт только известный
визуальный дефект screen-fixed толщины.
## Release-артефакты
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`: пользовательское исправление со
ссылкой на #361;
- `docs/FURNITURE.md`: точное разделение local artwork scale и physical plan
zoom;
- `docs/USER-GUIDE.ru.md`: коротко зафиксировать, что физическая толщина мебели
масштабируется вместе с планом;
- целевой browser artifact из `demo/smoke_furniture.mjs` с двумя zoom и
anisotropic resize;
- если меняется или добавляется golden-сцена, baseline принимается только из
полного Linux CI artifact через `npm run golden:accept -- --reviewed`;
- backend, migration, security и новые performance artifacts не требуются;
общий bundle budget остаётся обязательным.
## Принято предположительно, поменять свободно
- имя и размещение pure helper;
- способ передачи viewport/viewBox scale в decor renderer;
- точные тестовые symbols и цвета контролируемой raster-сцены;
- реализация pixel measurement без новой runtime dependency;
- добавлять отдельный golden-сценарий только если raster smoke не даёт
ревьюеру однозначного визуального доказательства.
+1
View File
@@ -124,6 +124,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#137](https://github.com/Matysh/houseplan-card/issues/137) Узлы и линии привязки в редакторе Плана | [137-plan-snap-overlay.md](137-plan-snap-overlay.md) |
| [#141](https://github.com/Matysh/houseplan-card/issues/141) Бесшовные стыки перегородок и открытых контуров | [141-wall-junctions.md](141-wall-junctions.md) |
| [#159](https://github.com/Matysh/houseplan-card/issues/159) Новый набор мебели и двухуровневая библиотека | [159-furniture-pack.md](159-furniture-pack.md) |
| [#361](https://github.com/Matysh/houseplan-card/issues/361) Физическая толщина линий мебели при camera zoom | [361-furniture-stroke-zoom.md](361-furniture-stroke-zoom.md) |
| [#157](https://github.com/Matysh/houseplan-card/issues/157) Тип проёма «Открытый проём» | [157-open-passage.md](157-open-passage.md) |
| [#150](https://github.com/Matysh/houseplan-card/issues/150) Точная геометрия коллинеарного перепада толщины | [150-wall-thickness-transition.md](150-wall-thickness-transition.md) |
| [#172](https://github.com/Matysh/houseplan-card/issues/172) Нулевой Split-разделитель не получает ложную толщину | [172-zero-divider-taper.md](172-zero-divider-taper.md) |