diff --git a/docs/specs/230-hatch-density-normalization.md b/docs/specs/230-hatch-density-normalization.md new file mode 100644 index 00000000..572bca9c --- /dev/null +++ b/docs/specs/230-hatch-density-normalization.md @@ -0,0 +1,267 @@ +# Issue #230 — плотность штриховки стен не зависит от масштаба пространства + +- Issue: [#230](https://github.com/Matysh/houseplan-card/issues/230) +- Класс изменения: A (продукт) +- Размер: `small` +- Автор ТЗ: Codex, 2026-08-21 +- Touch editor: not exposed — задача ничего не добавляет в редактор, меняется + только отрисовка тела стены, одинаковая при любом способе ввода. + +## 1. Сценарий и персона + +Человек ведёт планы двух домов. Один он завёл с мелкой клеткой (`cell_cm: 1`), +чтобы точно поставить перегородки в санузле; второй — с крупной (`cell_cm: 25`), +потому что это участок с хозблоком и сантиметры там не нужны. + +Стены в обоих он задал одинаково — 15 см. Но на первом плане стена выглядит +плотно заштрихованной, а на втором штриховки почти нет: одна черта наискось +или вообще ничего. Стена перестаёт читаться как стена — просто серая полоса, +которую на глаз не отличить от декоративной линии. + +## 2. Что человек увидит до и после + +**До.** Один и тот же материал — стена 15 см — выглядит по-разному в +зависимости от масштаба сетки пространства. Разброс между крайними значениями +`cell_cm` — 25 раз по числу полос. + +**После.** Стена 15 см заштрихована одинаково при любом `cell_cm`, и ровно так +же, как сегодня выглядит при `cell_cm: 5`. Стена 30 см получает вдвое больше +полос, чем 15 см, — как на строительном чертеже. При изменении зума плотность +не «плывёт»: стена всегда выглядит собой. + +## 3. Подтверждённая причина + +Шаг паттерна задан в юнитах плана и постоянен — 8 юнитов +(`houseplan-card.ts:11088`, `space-render.ts:509`): + +```ts + +``` + +Толщина стены переводится в юниты через `cell_cm` (`wall-thickness.ts:70`): + +```ts +return (clampWallCm(cm) / c) * gridPitch; // c = cell_cm +``` + +Толщина стены в юнитах обратно пропорциональна `cell_cm`, а шаг паттерна от +`cell_cm` не зависит вовсе — отсюда число полос внутри стены пропорционально +`1 / cell_cm`. Посчитано по тем же формулам (стена 15 см, +`gridPitch = 1000 / 240`): + +| `cell_cm` | толщина, юнитов | полос сейчас | шаг сейчас, см | +|---|---|---|---| +| 1 | 62.50 | 7.81 | 1.92 | +| 2 | 31.25 | 3.91 | 3.84 | +| **5 (эталон)** | **12.50** | **1.56** | **9.60** | +| 10 | 6.25 | 0.78 | 19.20 | +| 25 | 2.50 | 0.31 | 48.00 | +| 50 | 1.25 | 0.16 | 96.00 | + +**Формула из описания issue неверна.** Там предложено домножить масштаб +паттерна на `cell_cm / 5`; это увеличивает шаг там, где стена и без того тонкая +в юнитах, так что разброс не исчезает, а растёт: при `cell_cm: 1` вышло бы +39 полос, при `cell_cm: 25` — 0.06. Проверено расчётом по формулам кода. Верный +множитель обратный — `5 / cell_cm`, что эквивалентно фиксированному шагу в +сантиметрах плана. + +Зумовая компенсация `inv = max(0.4, 1 / max(zoom, 0.4))` разницы не лечит: она +держит шаг постоянным **на экране** и про `cell_cm` ничего не знает. В +статическом рендерере (`space-render.ts`) её нет вовсе — то есть второй путь +отрисовки уже сегодня расходится с картой при зуме ≠ 1. + +## 4. Продуктовые решения владельца (2026-08-21) + +1. **Плотность привязана к сантиметрам плана, а не к толщине конкретной + стены.** Шаг штриховки — фиксированное физическое расстояние, поэтому стена + 30 см получает вдвое больше полос, чем 15 см. Эталон — сегодняшний вид при + `cell_cm: 5`, то есть шаг ровно 9.6 см (8 юнитов при `cell_cm: 5`). +2. **Плотность строго физическая, зумовая компенсация убирается.** Число полос + внутри стены одинаково на любом зуме — стена всегда выглядит собой. От + вырождения в кашу защищает порог: когда полосы становятся слишком частыми на + экране, тело стены заливается сплошным цветом существующим механизмом + `solid`. + +Вариант «плотность относительно толщины стены» и вариант «сохранить множитель +`1/zoom`» отклонены владельцем явно. + +## 5. Цели + +- Стена одной толщины выглядит одинаково при любом `cell_cm`. +- При `cell_cm: 5` вид не меняется — ни на карте, ни в статическом рендерере, + ни в golden-эталонах. +- Правило живёт в одном месте и одинаково применяется всеми, кто рисует тело + стены. + +## 6. Scope + +- `src/wall-thickness.ts` — чистые функции шага и порога. +- `src/houseplan-card.ts` — `_wallHatchDefs` использует их, зумовая компенсация + убирается. +- `src/space-render.ts` — тот же паттерн через ту же функцию. +- Тесты: `test/wall-thickness.test.mjs`, новый смок, мутанты. + +## 7. Не входит в задачу + +- Угол штриховки (45°), цвет, соотношение штриха и просвета при `cell_cm: 5`. +- Порог `WALL_HATCH_MIN_PX = 3` для перехода в `solid` по толщине тела. +- Изометрия: `_renderProjection === 'iso'` тело стены не штрихует. +- Штриховка чего-либо, кроме тела стены (декор, мебель, колонны). + +## 8. Контракт поведения + +### 8.1. Шаг штриховки + +Новое в `src/wall-thickness.ts`: + +```ts +export const HATCH_REFERENCE_CELL_CM = 5; +export const HATCH_BASE_STEP_UNITS = 8; +export const HATCH_MIN_STEP_UNITS = 0.5; +export const HATCH_MAX_STEP_UNITS = 80; + +/** Шаг паттерна штриховки в юнитах плана для данного масштаба сетки. */ +export function wallHatchStepUnits(cellCm: number): number; +``` + +- Возвращает `HATCH_BASE_STEP_UNITS * (HATCH_REFERENCE_CELL_CM / cellCm)`. +- При `cellCm === 5` возвращает ровно `8` — эталон соблюдается тождественно. +- Нечисловой, нулевой или отрицательный `cellCm` трактуется как `5` — так же, + как это делает геттер `_cellCm` (`houseplan-card.ts:6163`). +- Результат клампится в `[HATCH_MIN_STEP_UNITS, HATCH_MAX_STEP_UNITS]` (§8.3). + +### 8.2. Применение + +Оба рендерера строят `` с `width` и `height`, равными +`wallHatchStepUnits(cellCm)`, и штрихом `M0 0 L0 `. Толщина штриха +масштабируется тем же множителем — `2 * (step / HATCH_BASE_STEP_UNITS)`, — чтобы +соотношение «штрих к просвету» осталось эталонным. + +`patternTransform` — только `rotate(45)`. Множителя `1/zoom` больше нет ни в +одном рендерере: после правки оба пути дают одинаковую картинку на одинаковых +данных, чего сегодня нет. + +### 8.3. Пределы + +- `HATCH_MIN_STEP_UNITS = 0.5` — соответствует `cell_cm = 80`; ниже штрихи + сливаются в заливку даже при максимальном зуме. +- `HATCH_MAX_STEP_UNITS = 80` — соответствует `cell_cm = 0.5`; при более мелкой + клетке дальнейшее сгущение уже не читается. +- Диапазон `cell_cm` в продукте — 0.1…1000, поэтому оба предела достижимы и + обязаны быть проверены. + +### 8.4. Защита от каши на экране + +Существующий переход в `solid` по толщине тела (`wallBodyNeedsSolid`, +`WALL_HATCH_MIN_PX = 3`) сохраняется без изменений. + +Дополнительно: + +```ts +export const HATCH_MIN_STEP_PX = 2; +export function wallHatchNeedsSolid(stepUnits: number, pxPerUnit: number): boolean; +``` + +Истинна, только когда оба аргумента конечны и положительны и +`stepUnits * pxPerUnit < HATCH_MIN_STEP_PX`. Вызывается там же, где уже +вызывается `wallBodyNeedsSolid`; результаты объединяются по «или». + +## 9. Данные, i18n, a11y, privacy, security + +Конфиг не меняется, миграции нет, новых строк интерфейса нет. Штриховка — +визуальный слой, скринридеру он ничего не сообщает; сплошная заливка при +срабатывании порога — уже существующее поведение. Приватности и безопасности +изменение не касается. + +## 10. Performance + +Одно деление на кадр на пространство, `` как был один, так и остаётся. +Убирается зависимость паттерна от `_zoom` — при зуме `` перестаёт +перерисовываться, то есть изменение работает в плюс. + +## 11. Риски + +1. **Тонкие стены.** Стена 3 см при `cell_cm: 5` — 2.5 юнита, меньше трети + шага: в неё попадает 0–1 полоса в зависимости от фазы паттерна. Поведение + сегодняшнее и остаётся; порог `solid` по толщине его прикрывает. Проверяется + отдельным AC, чтобы правка не превратила такую стену в пятно. +2. **Golden.** Все golden-фикстуры используют `cell_cm: 5` + (`demo/fixtures/*.mjs`, `demo/golden/harness.mjs`) и не меняют зум, поэтому + эталоны не должны измениться ни в одном пикселе. Это проверяемое + утверждение: `golden:verify` обязан пройти без переснятия. Расхождение хоть + одной сцены — сигнал, что правка задела больше заявленного, и разбирается до + мержа. +3. **Расхождение рендереров.** Сегодня карта и статический рендерер расходятся + при зуме ≠ 1; правка их сводит. Побочный эффект желателен, но должен быть + зафиксирован тестом, иначе следующий рефакторинг разведёт их снова. + +## 12. Acceptance criteria + +**AC1.** `wallHatchStepUnits(5) === 8` — ровно, без погрешности. + +**AC2.** Число полос в стене одной толщины одинаково при `cell_cm` 1, 2, 5, 10, +25 и 50: `wallCmToUnits(15, cell, GRID_PITCH) / wallHatchStepUnits(cell)` +совпадает для всех значений с точностью 1e-9. + +**AC3.** Плотность привязана к сантиметрам, а не к толщине стены: при одном +`cell_cm` стена 30 см получает ровно вдвое больше полос, чем стена 15 см. + +**AC4.** `wallHatchStepUnits` возвращает эталонные 8 для `0`, отрицательного, +`NaN` и нечислового аргумента. + +**AC5.** Результат клампится: `wallHatchStepUnits(0.1)` не превышает +`HATCH_MAX_STEP_UNITS`, `wallHatchStepUnits(1000)` не меньше +`HATCH_MIN_STEP_UNITS`. + +**AC6.** `wallHatchNeedsSolid` истинна только когда шаг на экране меньше +`HATCH_MIN_STEP_PX`, и ложна при нулевых, отрицательных и нечисловых +аргументах. + +**AC7.** В разметке карты `width`/`height` паттерна равны +`wallHatchStepUnits(cell_cm)` пространства, а `patternTransform` не содержит +`scale` — проверяется в браузере при двух разных `cell_cm`. + +**AC8.** Смена зума не меняет разметку паттерна: `width`, `height` и +`patternTransform` идентичны при zoom 1 и zoom 3 — проверяется в браузере. + +**AC9.** Стена 3 см при `cell_cm: 5` не превращается в сплошное пятно из-за +нового множителя: `wallHatchNeedsSolid` для неё ложна при типичном `pxPerUnit`. + +**AC10.** `golden:verify` проходит без переснятия эталонов. + +## 13. План автотестов + +- `test/wall-thickness.test.mjs` — AC1–AC6, AC9. +- `demo/smoke_wall_hatch_density.mjs` (новый) — AC7, AC8: разметка паттерна на + реальной карте при `cell_cm` 5 и 25 и при двух значениях зума. +- `npm run golden:verify` — AC10. + +## 14. Мутационный гейт (`scripts/mutation-gate.mjs`) + +| id | Что ломает | Гвард | +|---|---|---| +| `hatch-step-ignores-cell-cm` | шаг снова константа 8 | юниты `wall-thickness` | +| `hatch-step-inverted` | множитель `cell/5` вместо `5/cell` — ошибка из описания issue | юниты `wall-thickness` | +| `hatch-step-unclamped` | кламп пределов снят | юниты `wall-thickness` | +| `hatch-stroke-not-scaled` | толщина штриха не следует за шагом | смок | +| `hatch-zoom-compensation-back` | вернулся множитель `1/zoom` | смок | +| `hatch-density-solid-threshold-off` | порог по шагу на экране не срабатывает | юниты `wall-thickness` | + +## 15. Release-артефакты + +`User-Visible: yes`: обе редакции CHANGELOG, строка в `docs/USER-GUIDE.ru.md` в +разделе про толщину стены. Скриншоты документации переснимаются, если этого +потребует `check-docs.mjs`. + +## 16. Откат + +Одним revert: правка локальна, конфиг не меняется, обратной миграции не нужно. + +## 17. Принятые предположения (техническое, менять свободно) + +- Пределы `HATCH_MIN_STEP_UNITS = 0.5` и `HATCH_MAX_STEP_UNITS = 80` выбраны + как границы, за которыми паттерн перестаёт читаться; из требований владельца + они не следуют и могут быть уточнены по итогам ревью. +- `HATCH_MIN_STEP_PX = 2` — по аналогии с существующим `WALL_HATCH_MIN_PX = 3`, + чуть агрессивнее, потому что шаг по определению меньше толщины тела.