docs: spec for #230 — hatch density normalised to plan centimetres

Issue: #230
User-Visible: no
This commit is contained in:
Codex
2026-08-21 14:35:29 +03:00
parent 619516806f
commit 2396044bae
@@ -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
<pattern id="hp-wall-hatch" patternUnits="userSpaceOnUse" width="8" height="8"
patternTransform="rotate(45) scale(${inv})">
```
Толщина стены переводится в юниты через `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. Применение
Оба рендерера строят `<pattern>` с `width` и `height`, равными
`wallHatchStepUnits(cellCm)`, и штрихом `M0 0 L0 <step>`. Толщина штриха
масштабируется тем же множителем — `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
Одно деление на кадр на пространство, `<pattern>` как был один, так и остаётся.
Убирается зависимость паттерна от `_zoom` — при зуме `<defs>` перестаёт
перерисовываться, то есть изменение работает в плюс.
## 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`,
чуть агрессивнее, потому что шаг по определению меньше толщины тела.