Files
houseplan-card/docs/specs/230-hatch-density-normalization.md
T

298 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.** Все фикстуры используют `cell_cm: 5`
(`demo/fixtures/*.mjs`, `demo/golden/harness.mjs`), где новая формула даёт
ровно сегодняшние 8 юнитов. Но две сцены снимают план при зуме, отличном от
единицы (`demo/golden/matrix.mjs:328-331`):
| сцена | zoom | шаг сейчас | шаг после |
|---|---|---|---|
| `large-house-zoom-040-dark` | 0.4 | 20.0 юнитов | 8.0 |
| `large-house-zoom-250-dark` | 2.5 | 3.2 юнита | 8.0 |
Обе изменятся — и это прямое следствие решения владельца §4.2, а не побочный
ущерб: именно ради этого зумовая компенсация и убирается. Их переснятие
входит в задачу (§13, AC11) и делается отдельным шагом с доказательством:
`npm run golden:accept -- --reviewed` после того, как расхождение осмотрено
глазами и признано ожидаемым.
Все остальные сцены обязаны совпасть побайтно (AC10). Расхождение любой
третьей сцены — сигнал, что правка задела больше заявленного, и разбирается
до мержа.
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` расходится ровно на двух сценах —
`large-house-zoom-040-dark` и `large-house-zoom-250-dark`. Любая третья
разошедшаяся сцена означает провал этого критерия.
**AC11.** Обе зумовые сцены переснимаются осознанно: расхождение осмотрено,
принято `npm run golden:accept -- --reviewed`, и в отчёте на код-ревью
объяснено, что именно изменилось и почему это ожидалось.
**AC12.** Статический рендерер (`space-render.ts`) строит паттерн по той же
функции: при `cell_cm: 25` его `width`/`height` равны `wallHatchStepUnits(25)`,
а `patternTransform` не содержит `scale`. Без этого критерия нереализованная
правка второго рендерера прошла бы незамеченной — все golden-сцены снимаются
при `cell_cm: 5`, где старая и новая формулы совпадают.
## 13. План автотестов
- `test/wall-thickness.test.mjs` — AC1–AC6, AC9.
- `demo/smoke_wall_hatch_density.mjs` (новый) — AC7, AC8, AC12: разметка
паттерна при `cell_cm` 5 и 25 и при двух значениях зума, на интерактивной
карте и в статическом рендерере (`hp-space-card`) в одном прогоне.
- `npm run golden:verify` — AC10; затем `npm run golden:accept -- --reviewed`
и повторный `verify` — AC11.
## 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-static-renderer-untouched` | `space-render.ts` снова с константой 8 | смок |
| `hatch-density-solid-threshold-off` | порог по шагу на экране не срабатывает | юниты `wall-thickness` |
## 15. Release-артефакты
`User-Visible: yes`: обе редакции CHANGELOG, строка в `docs/USER-GUIDE.ru.md` в
разделе про толщину стены. `docs/WALL-THICKNESS.md` — канонический документ
подсистемы, которую задача меняет: правило шага штриховки описывается там же,
где живёт правило толщины стены. Скриншоты документации переснимаются, если
этого потребует `check-docs.mjs`; два golden-эталона — по AC11.
## 16. Откат
Одним revert: правка локальна, конфиг не меняется, обратной миграции не нужно.
## 17. Принятые предположения (техническое, менять свободно)
- Пределы `HATCH_MIN_STEP_UNITS = 0.5` и `HATCH_MAX_STEP_UNITS = 80` выбраны
как границы, за которыми паттерн перестаёт читаться; из требований владельца
они не следуют и могут быть уточнены по итогам ревью.
- `HATCH_MIN_STEP_PX = 2` — по аналогии с существующим `WALL_HATCH_MIN_PX = 3`,
чуть агрессивнее, потому что шаг по определению меньше толщины тела.