19 KiB
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)
- Плотность привязана к сантиметрам плана, а не к толщине конкретной
стены. Шаг штриховки — фиксированное физическое расстояние, поэтому стена
30 см получает вдвое больше полос, чем 15 см. Эталон — сегодняшний вид при
cell_cm: 5, то есть шаг ровно 9.6 см (8 юнитов приcell_cm: 5). - Плотность строго физическая, зумовая компенсация убирается. Число полос
внутри стены одинаково на любом зуме — стена всегда выглядит собой. От
вырождения в кашу защищает порог: когда полосы становятся слишком частыми на
экране, тело стены заливается сплошным цветом существующим механизмом
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. Риски
-
Тонкие стены. Стена 3 см при
cell_cm: 5— 2.5 юнита, меньше трети шага: в неё попадает 0–1 полоса в зависимости от фазы паттерна. Поведение сегодняшнее и остаётся; порогsolidпо толщине его прикрывает. Проверяется отдельным AC, чтобы правка не превратила такую стену в пятно. -
Golden. Все фикстуры используют
cell_cm: 5(demo/fixtures/*.mjs,demo/golden/harness.mjs), где новая формула даёт ровно сегодняшние 8 юнитов. Но две сцены снимают план при зуме, отличном от единицы (demo/golden/matrix.mjs:328-331):сцена zoom шаг сейчас шаг после large-house-zoom-040-dark0.4 20.0 юнитов 8.0 large-house-zoom-250-dark2.5 3.2 юнита 8.0 Обе изменятся — и это прямое следствие решения владельца §4.2, а не побочный ущерб: именно ради этого зумовая компенсация и убирается. Их переснятие входит в задачу (§13, AC11) и делается отдельным шагом с доказательством:
npm run golden:accept -- --reviewedпосле того, как расхождение осмотрено глазами и признано ожидаемым.Все остальные сцены обязаны совпасть побайтно (AC10). Расхождение любой третьей сцены — сигнал, что правка задела больше заявленного, и разбирается до мержа.
-
Расхождение рендереров. Сегодня карта и статический рендерер расходятся при зуме ≠ 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_cm5 и 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, чуть агрессивнее, потому что шаг по определению меньше толщины тела.