Files
houseplan-card/demo/smoke_wall_hatch_density.mjs
T
CodexandCodex edf1cca8e3 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
2026-08-21 15:12:08 +03:00

90 lines
4.0 KiB
JavaScript

// Issue #230: the hatch step is a physical distance, not a coordinate one.
//
// The units own the arithmetic; this smoke owns the wiring — that both renderers
// actually put the computed step into the pattern, that neither of them scales
// it back by zoom, and that the two agree with each other.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 900 }, 1);
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const settle = async () => {
for (let i = 0; i < 3; i++) await new Promise((r) => requestAnimationFrame(r));
await c.updateComplete;
};
const space = () => c._serverCfg.spaces.find((s) => s.id === c._space);
const pattern = (root) => root.querySelector('#hp-wall-hatch');
const read = (p) => p && ({
width: Number(p.getAttribute('width')),
height: Number(p.getAttribute('height')),
transform: p.getAttribute('patternTransform') || '',
stroke: Number(p.querySelector('path')?.getAttribute('stroke-width')),
d: p.querySelector('path')?.getAttribute('d') || '',
});
c._mode = 'plan'; c.requestUpdate(); await settle();
await new Promise((r) => setTimeout(r, 400));
// The demo house has no thick walls, and without a wall body neither renderer
// emits the pattern. Set 15 cm on one wall the way a person would — through
// the thickness tool, so the walls end up keyed exactly as the card expects.
space().settings = { ...(space().settings || {}), show_borders: true };
c._tool = 'wallthick';
c.requestUpdate(); await settle();
c._wallThickClick([50, 250]);
await settle();
c._wallDialog = { ...c._wallDialog, value: '15' };
c._wallThickApply(true);
await new Promise((r) => setTimeout(r, 500));
c._tool = null;
c.requestUpdate(); await settle();
out.wallBodyIsRendered = !!c.shadowRoot.querySelector('.wallbody');
// Reference scale: exactly the historical numbers, so old plans do not move.
const atFive = read(pattern(c.shadowRoot));
out.referenceStepIsEight = atFive?.width === 8 && atFive?.height === 8;
out.referenceStrokeIsTwo = atFive?.stroke === 2;
out.noZoomScaleAtReference = !!atFive && !/scale/.test(atFive.transform);
// Zoom must not touch the pattern any more — that is the whole point.
c._applyView(3); await settle();
const zoomed = read(pattern(c.shadowRoot));
out.zoomDoesNotChangeThePattern = JSON.stringify(zoomed) === JSON.stringify(atFive);
c._applyView(1); await settle();
// A coarse grid: the step follows the centimetres, so it shrinks in units.
space().cell_cm = 25;
// Saved, not just poked locally: the static card reads the config from the
// server, so a local mutation would leave it on the old scale.
c._saveConfig();
await new Promise((r) => setTimeout(r, 500));
c.requestUpdate(); await settle();
const atTwentyFive = read(pattern(c.shadowRoot));
out.coarseGridShrinksTheStep = Math.abs(atTwentyFive.width - 1.6) < 1e-9;
out.coarseGridScalesTheStroke = Math.abs(atTwentyFive.stroke - 0.4) < 1e-9;
out.coarseGridStripeSpansTheCell = atTwentyFive.d === `M0 0 L0 ${atTwentyFive.width}`;
// The static renderer is the second path that draws a wall body, and it used
// to carry its own hard-coded 8 (spec §8.2, AC12).
await customElements.whenDefined('houseplan-space-card');
const host = document.createElement('div');
document.body.appendChild(host);
const card = document.createElement('houseplan-space-card');
card.setConfig({ type: 'custom:houseplan-space-card', space: c._space });
card.hass = c.hass;
host.appendChild(card);
const t0 = Date.now();
while (!card.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) {
await new Promise((r) => setTimeout(r, 80));
}
await card.updateComplete;
const staticPattern = read(pattern(card.renderRoot));
out.staticRendererFollowsTheCell = !!staticPattern
&& Math.abs(staticPattern.width - 1.6) < 1e-9;
out.bothRenderersAgree = JSON.stringify(staticPattern) === JSON.stringify(atTwentyFive);
return out;
});
checkAll(res);
await finish(browser, res);