Files
houseplan-card/legacy/specs/230-hatch-density-normalization.md
Claudeandclaude[bot] df46fd1c3e docs(hygiene): ТЗ выпущенных задач без живых ссылок — в legacy/specs (#682)
Волна 5 эпика #674, перенос ТЗ (класс C). Из 240 файлов `docs/specs/` в
`legacy/specs/` уехали 219: на них не ссылается ни один живой файл (код,
тесты, скрипты, workflow, документы вне архива и ревью). Остались 21 ТЗ —
на которые ссылаются код, ADR, ISOMETRIC, SUN, RADAR, LIGHT (`docs/specs/067`),
DECOR-EDITOR, support-relay, и те, на которые ссылаются они сами; README
каталога объясняет, где искать остальное. Открытых issue с файлом ТЗ среди
перенесённых нет. Относительные ссылки перенесённых файлов переписаны
(`../X` → `../../docs/X`, соседние оставшиеся ТЗ → `../../docs/specs/…`) —
все 26 резолвятся. Попутно: битая ссылка в
`089-isometric-view-stage1.md:8` на удалённый `089-isometric-view.md` —
теперь команда `git show` по истории. Строка в `legacy/README.md`.

Issue: #682
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-27 22:10:46 +00:00

19 KiB
Raw Permalink Blame History

Issue #230 — плотность штриховки стен не зависит от масштаба пространства

  • Issue: #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):

<pattern id="hp-wall-hatch" patternUnits="userSpaceOnUse" width="8" height="8"
         patternTransform="rotate(45) scale(${inv})">

Толщина стены переводится в юниты через cell_cm (wall-thickness.ts:70):

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:

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) сохраняется без изменений.

Дополнительно:

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