feat: hatch density is a distance, not a count of units (#230)

Одна и та же стена 15 см выглядела на планах с разным `cell_cm` по-разному:
шаг паттерна был константой в юнитах, а толщина стены переводится в юниты через
`cell_cm`, — значит число полос было пропорционально `1/cell_cm`. Разброс между
крайними масштабами достигал 25 раз, а при `cell_cm ≥ 10` в стену не попадало
и одной полосы: штриховка вырождалась в случайные штрихи или исчезала.

Шаг стал физической величиной: `wallHatchStepUnits(cellCm)` возвращает
`8 × (5 / cell_cm)` — это 9.6 см плана при любом масштабе сетки и ровно
исторические 8 юнитов при эталонном `cell_cm: 5`, так что старые планы не
двигаются. Толщина штриха следует за шагом, поэтому соотношение «штрих к
просвету» тоже перестало зависеть от масштаба.

Формула из описания issue (`cell_cm / 5`) не годилась: она увеличивает шаг там,
где стена и без того тонкая в юнитах, и разброс не исчезает, а растёт — 39
полос против 0.06 на краях диапазона. Множитель обратный; на эту ошибку
поставлен отдельный мутант `hatch-step-inverted`.

Зумовая компенсация `1/zoom` убрана по решению владельца (§4.2 ТЗ): стена,
которая меняет штриховку при зуме, — это тот же дефект, только по другой оси.
От каши на дальнем конце зума защищает второй порог `wallHatchNeedsSolid`
рядом с существующим `wallBodyNeedsSolid`; шаг клампится в [0.5, 80] юнитов,
чтобы патологический `cell_cm` не выродил паттерн.

Статический рендерер (`space-render.ts`) нёс собственную константу 8 и вообще
не знал про зум — то есть уже сегодня расходился с картой при любом зуме,
кроме единицы. Теперь оба читают шаг из одной функции; смок проверяет, что они
согласны между собой, а не только каждый сам с собой.

Два golden-эталона переснято осознанно (`golden:accept -- --reviewed`):
`large-house-zoom-040-dark` (шаг был 20 юнитов, стал 8) и
`large-house-zoom-250-dark` (был 3.2, стал 8). Расхождение осмотрено: меняется
только плотность штриховки тел стен, колонн и перегородок, геометрия и цвета
идентичны. Остальные 80 сцен не тронуты — `accept` переснимает весь набор, и
пять сцен, разошедшихся на шуме рендера, возвращены к прежним байтам вместе с
их хэшами в индексе.

Issue: #230
User-Visible: yes
This commit is contained in:
Codex
2026-08-21 15:12:08 +03:00
committed by Codex
parent 115a7d4ccd
commit edf1cca8e3
17 changed files with 693 additions and 368 deletions
+65
View File
@@ -17,6 +17,8 @@ import {
intervalCmAt, wallBodyNeedsSolid, openingInnerFaceOffset, openingTunnelGeometry,
openingTunnelGeometries, tunnelFacePath,
WALL_HATCH_MIN_PX,
wallHatchStepUnits, wallHatchNeedsSolid,
HATCH_BASE_STEP_UNITS, HATCH_MIN_STEP_UNITS, HATCH_MAX_STEP_UNITS,
} from '../test-build/wall-thickness.js';
import { polygonArea, paperRoomShapes, splitRoomPath, sharedBoundary } from '../test-build/logic.js';
import { resolveOpenCuts } from '../test-build/open-spans.js';
@@ -1421,3 +1423,66 @@ test('linear wall joins bevel an excessive mitre and ignore malformed or near-mi
{ a: [0, 0], b: [Infinity, 1], halfDepth: 1 },
]), [], 'invalid neighbours do not alter a valid flat-capped segment');
});
// --- issue #230: hatch density follows the plan's centimetres -----------------
test('issue 230 the reference scale is untouched', () => {
assert.equal(wallHatchStepUnits(5), 8, 'exactly, not approximately');
assert.equal(wallHatchStepUnits(5), HATCH_BASE_STEP_UNITS);
});
test('issue 230 one wall carries the same stripes at every grid scale', () => {
const stripes = (cell) => wallCmToUnits(15, cell, GRID_PITCH) / wallHatchStepUnits(cell);
const reference = stripes(5);
for (const cell of [1, 2, 5, 10, 25, 50]) {
assert.ok(
Math.abs(stripes(cell) - reference) < 1e-9,
`cell_cm ${cell}: ${stripes(cell)} stripes vs ${reference}`,
);
}
});
test('issue 230 density is physical, so a thicker wall gets more stripes', () => {
const stripes = (cm, cell) => wallCmToUnits(cm, cell, GRID_PITCH) / wallHatchStepUnits(cell);
for (const cell of [1, 5, 25]) {
assert.ok(
Math.abs(stripes(30, cell) / stripes(15, cell) - 2) < 1e-9,
`cell_cm ${cell}: ratio ${stripes(30, cell) / stripes(15, cell)}`,
);
}
});
test('issue 230 a missing or broken cell_cm falls back to the reference', () => {
for (const bad of [0, -5, NaN, undefined, null, 'wide', {}]) {
assert.equal(wallHatchStepUnits(bad), 8, `input ${String(bad)}`);
}
});
test('issue 230 the step stays inside its limits', () => {
assert.ok(wallHatchStepUnits(0.1) <= HATCH_MAX_STEP_UNITS, 'a hair-fine grid');
assert.ok(wallHatchStepUnits(1000) >= HATCH_MIN_STEP_UNITS, 'a hectare-wide grid');
assert.equal(wallHatchStepUnits(0.5), HATCH_MAX_STEP_UNITS, 'the upper limit is reachable');
assert.equal(wallHatchStepUnits(80), HATCH_MIN_STEP_UNITS, 'the lower limit is reachable');
});
test('issue 230 stripes too close on screen ask for a solid body', () => {
assert.equal(wallHatchNeedsSolid(1, 1), true, '1 px step is noise');
assert.equal(wallHatchNeedsSolid(1, 2), false, 'exactly the threshold is fine');
assert.equal(wallHatchNeedsSolid(8, 10), false, 'a comfortable step');
for (const [step, px] of [[0, 5], [-1, 5], [8, 0], [8, -1], [NaN, 5], [8, NaN]]) {
assert.equal(wallHatchNeedsSolid(step, px), false, `garbage in: ${step}, ${px}`);
}
});
test('issue 230 a thin wall is not turned into a blot by the new rule', () => {
// 3 cm on the reference grid is 2.5 units: fewer stripes than one. Whether it
// is filled or hatched stays the business of the thin-BODY guard, exactly as
// before — the new step guard must have no opinion about it.
const thin = wallCmToUnits(3, 5, GRID_PITCH);
const step = wallHatchStepUnits(5);
for (const px of [1, 1.2, 2, 5]) {
assert.equal(wallHatchNeedsSolid(step, px), false, `step guard fired at px=${px}`);
}
assert.equal(wallBodyNeedsSolid(thin, 1), true, 'body guard still owns the thin case');
assert.equal(wallBodyNeedsSolid(thin, 1.2), false, 'and lets it hatch once it is wide enough');
});