mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
298 lines
19 KiB
Markdown
298 lines
19 KiB
Markdown
# 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`,
|
||
чуть агрессивнее, потому что шаг по определению меньше толщины тела.
|