mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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:
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user