mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
docs: spec for #230 — hatch density normalised to plan centimetres
Issue: #230 User-Visible: no
This commit is contained in:
@@ -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`,
|
||||
чуть агрессивнее, потому что шаг по определению меньше толщины тела.
|
||||
Reference in New Issue
Block a user