# 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.** Все фикстуры используют `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`, чуть агрессивнее, потому что шаг по определению меньше толщины тела.