mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
171 lines
8.3 KiB
JavaScript
171 lines
8.3 KiB
JavaScript
// Кладка реального плана не должна прерываться (#285).
|
|
//
|
|
// Зачем это отдельно от golden и от синтетических фикстур. Восемь задач подряд
|
|
// (#271, #272, #275, #276, #277, #278, #279, #280) прошли ТЗ, ревью, golden и
|
|
// смоки — и разрыв кладки на реальном плане владельца остался. У всех восьми
|
|
// критерии приёмки стояли на синтетике: «cell-5 mixed-depth T», «прямоугольный
|
|
// T с тремя равными полутолщинами». На ней фиксы работают.
|
|
//
|
|
// Здесь проверяется другое: берётся геометрия настоящего этажа и спрашивается
|
|
// единственное — есть ли кладка там, где по модели она обязана быть. Ответ даёт
|
|
// сам продукт через `isPointInFill` на пути стены, поэтому проверка не зависит
|
|
// ни от разрешения, ни от порогов пиксельного сравнения, которые этот класс
|
|
// дефектов пропускают.
|
|
//
|
|
// В фикстуре намеренно нет проёмов: тогда правило не требует никакой
|
|
// бухгалтерии — КАЖДАЯ точка на осевой линии ребра комнаты обязана лежать
|
|
// внутри кладки.
|
|
import { readFileSync } from 'node:fs';
|
|
import { launch, checkAll, finish } from './serve.mjs';
|
|
|
|
/**
|
|
* Реальные планы, каждый со своим классом дефектов (#285, #286).
|
|
*
|
|
* Второй этаж — стыки: коридор многостенного узла съедает соседнюю кладку.
|
|
* Первый этаж — другое: у него есть виртуальные пролёты, колонны и два
|
|
* сплошных ребра, у которых вообще нет записи толщины. Ни то, ни другое
|
|
* синтетика не воспроизводит.
|
|
*/
|
|
const PLANS = [
|
|
{ file: 'real-plan-second-floor.json', gapCount: 0, gapSteps: 0 },
|
|
{ file: 'real-plan-first-floor.json', gapCount: 0, gapSteps: 0 },
|
|
];
|
|
|
|
/**
|
|
* Регрессия #288: beta.9 содержала четыре разрыва на 181 шаг.
|
|
*
|
|
* Четыре разрыва — это две стены, посчитанные с двух сторон каждой общей пары:
|
|
* «Элина | Холл» и «Холл | Кабинет» в исходных названиях.
|
|
*
|
|
* 45.25 шага на разрыв — не случайное число. В узле сходятся наружная стена
|
|
* 30 см, выступ 30 см и луч длиной ВСЕГО 5 шагов. Для 30 см полутолщина 15
|
|
* шагов, `MITRE_LIMIT = 4`, радиус коридора 4 × 15 = 60 шагов, и из соседней
|
|
* стены 20 см вырезается 60 − 15 = 45. Совпадение с измерением показывает, что
|
|
* съедает кладку именно коридор стыка.
|
|
*
|
|
* После #288 оба значения обязаны оставаться точным нулём: локальная mask
|
|
* сохраняет finite shared wall, прикреплённую к дальнему концу короткого луча.
|
|
*/
|
|
const KNOWN_GAP_COUNT = 0;
|
|
const KNOWN_GAP_STEPS = 0;
|
|
|
|
const { page, browser } = await launch();
|
|
const out = {};
|
|
|
|
const measure = (space) => page.evaluate(async (space) => {
|
|
const card = window.__card;
|
|
card._serverCfg = { spaces: [space], markers: [], settings: {} };
|
|
card._cfgEpoch = (card._cfgEpoch || 0) + 1;
|
|
card._modelCache = null;
|
|
card._space = space.id;
|
|
card._setMode?.('view');
|
|
card.requestUpdate();
|
|
await card.updateComplete;
|
|
await new Promise((done) => setTimeout(done, 250));
|
|
card._fitAll?.();
|
|
card.requestUpdate();
|
|
await card.updateComplete;
|
|
await new Promise((done) => setTimeout(done, 250));
|
|
|
|
const root = card.shadowRoot || card.renderRoot;
|
|
const wall = root.querySelector('[data-hp="wall"]');
|
|
if (!wall?.isPointInFill) return { error: 'wall path not found' };
|
|
|
|
const NORM = 1000;
|
|
const height = card._spaceH || NORM;
|
|
const at = (x, y) => new DOMPoint(x * NORM, y * height);
|
|
const STEP = 1 / 240;
|
|
// Шаг выборки — четверть шага решётки: мельче любого осмысленного разрыва и
|
|
// достаточно грубо, чтобы прогон оставался быстрым.
|
|
const SAMPLE = STEP / 4;
|
|
|
|
// Виртуальный пролёт — объявленный разрыв, а не дефект: там кладки быть не
|
|
// должно. Без этого исключения план с виртуальными границами покраснел бы
|
|
// на собственном контракте.
|
|
const spans = (space.open_spans || []).map((s) => [s.a, s.b]);
|
|
const onDeclaredSpan = (x, y) => spans.some(([a, b]) => {
|
|
const dx = b[0] - a[0], dy = b[1] - a[1];
|
|
const len2 = dx * dx + dy * dy;
|
|
if (len2 < 1e-18) return false;
|
|
const t = ((x - a[0]) * dx + (y - a[1]) * dy) / len2;
|
|
if (t < -0.02 || t > 1.02) return false;
|
|
const px = a[0] + dx * Math.max(0, Math.min(1, t));
|
|
const py = a[1] + dy * Math.max(0, Math.min(1, t));
|
|
return Math.hypot(x - px, y - py) <= 1 / 240;
|
|
});
|
|
|
|
const gaps = [];
|
|
let sampled = 0;
|
|
let missing = 0;
|
|
let skipped = 0;
|
|
for (const room of space.rooms) {
|
|
const poly = room.poly;
|
|
for (let i = 0; i < poly.length; i++) {
|
|
const a = poly[i];
|
|
const b = poly[(i + 1) % poly.length];
|
|
const length = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
const count = Math.max(2, Math.round(length / SAMPLE));
|
|
let runStart = null;
|
|
for (let k = 0; k <= count; k++) {
|
|
const t = k / count;
|
|
const x = a[0] + (b[0] - a[0]) * t;
|
|
const y = a[1] + (b[1] - a[1]) * t;
|
|
if (onDeclaredSpan(x, y)) {
|
|
skipped++;
|
|
if (runStart !== null) {
|
|
gaps.push({ room: room.id, edge: i, steps: (t - runStart) * length / STEP });
|
|
runStart = null;
|
|
}
|
|
continue;
|
|
}
|
|
sampled++;
|
|
const solid = wall.isPointInFill(at(x, y));
|
|
if (!solid) {
|
|
missing++;
|
|
if (runStart === null) runStart = t;
|
|
} else if (runStart !== null) {
|
|
gaps.push({ room: room.id, edge: i, steps: (t - runStart) * length / STEP });
|
|
runStart = null;
|
|
}
|
|
}
|
|
if (runStart !== null) {
|
|
gaps.push({ room: room.id, edge: i, steps: (1 - runStart) * length / STEP });
|
|
}
|
|
}
|
|
}
|
|
const worst = gaps.slice().sort((x, y) => y.steps - x.steps).slice(0, 5);
|
|
return {
|
|
sampled,
|
|
skipped,
|
|
missing,
|
|
gapCount: gaps.length,
|
|
totalGapSteps: gaps.reduce((sum, g) => sum + g.steps, 0),
|
|
worst,
|
|
};
|
|
}, space);
|
|
|
|
for (const plan of PLANS) {
|
|
const { space } = JSON.parse(readFileSync(
|
|
new URL(`../test/fixtures/${plan.file}`, import.meta.url), 'utf8'));
|
|
const measured = await measure(space);
|
|
const tag = plan.file.replace('real-plan-', '').replace('.json', '');
|
|
console.log(tag, JSON.stringify(measured, null, 2));
|
|
out[`${tag}:wallPath`] = !measured.error;
|
|
out[`${tag}:sampledEnough`] = measured.sampled > 5000;
|
|
if (plan.gapCount === null) {
|
|
console.log(`ИЗМЕРЕНИЕ ${tag}: разрывов ${measured.gapCount},`
|
|
+ ` суммарно ${measured.totalGapSteps.toFixed(2)} шага`);
|
|
} else {
|
|
out[`${tag}:gapCount`] = measured.gapCount === plan.gapCount;
|
|
out[`${tag}:gapLength`] = Math.abs(measured.totalGapSteps - plan.gapSteps) < 1;
|
|
if (!out[`${tag}:gapCount`] || !out[`${tag}:gapLength`]) {
|
|
console.log(`Долг ${tag} изменился: было ${plan.gapCount} разрывов на`
|
|
+ ` ${plan.gapSteps} шага, стало ${measured.gapCount} на`
|
|
+ ` ${measured.totalGapSteps.toFixed(2)}. Меньше — обновите числа и приложите`
|
|
+ ' прогон; больше — это регрессия.');
|
|
}
|
|
}
|
|
}
|
|
checkAll(out);
|
|
await finish(browser, out);
|