mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
test: measure how far the stored geometry sits off the lattice
Stage 0 of ADR #282. A lattice node is k/240, which has no exact binary representation, and a stored coordinate is a float. Nobody could say how much of a real plan is affected, and Optimize promises to remove coordinate noise without a definition of noise that can be checked. latticeProfile splits every coordinate of the model into three populations, because they are three different problems: exactly on a node, near a node but not exact, and legitimately off grid. The middle one is the defect class behind \#258, \#279 and the non-converging Optimize; the last one is authored geometry the current model allows and must not be called a violation. Measured on the owner's installation: space 1 has 208 coordinates, 33.65 per cent exactly on a node and 65.38 per cent in the noise class; space 2 has 21.23 against 78.77. The worst deviation is 8e-8 of a step — invisible, and enough to put a wall key in the neighbouring bucket. The counterpart is what makes it worth having: every shipped fixture has zero noise, all of its off-grid values being authored. Our own test data therefore cannot reproduce this class by construction, which is why the owner finds these defects and the gates do not. A test pins that property so it cannot drift. No violations are produced, no gate turns red, and nothing is repaired: what to do with a vertex 8e-8 from a node is the owner's decision, and this measures its price first. Issue: #283 User-Visible: no
This commit is contained in:
@@ -279,6 +279,119 @@ export function checkWallKeys(config, { notes = [] } = {}) {
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Стадия 0 из ADR #282: профиль отклонений от решётки.
|
||||
*
|
||||
* Весь класс #258/#279/#248 держится на одном свойстве представления: узел
|
||||
* решётки это `k/240`, число без точного двоичного представления, а хранимая
|
||||
* координата это float. Сколько реальной геометрии лежит вне решётки и
|
||||
* насколько далеко — не знает никто, а Optimize берётся «убрать шум
|
||||
* координат», не имея проверяемого определения шума.
|
||||
*
|
||||
* Здесь ничего не судится и не чинится: считается профиль. Три населения
|
||||
* разделены намеренно, потому что это разные проблемы, а не разные степени
|
||||
* одной:
|
||||
*
|
||||
* exact — точно на узле, делать нечего;
|
||||
* noise — рядом с узлом, но не точно. ЭТО класс дефектов: ключ стены
|
||||
* попадает не в тот бакет, Optimize не сходится, «почти
|
||||
* ортогональная» стена рисует клин;
|
||||
* offGrid — далеко от узла. Законная по нынешней модели геометрия: авторские
|
||||
* координаты фикстур (0.06 — это 14.4 шага от узла) ничем не
|
||||
* запрещены. Объявить их нарушением значит получить проверку,
|
||||
* которую отключат в первую неделю.
|
||||
*
|
||||
* Граница между noise и offGrid — доля шага, а не абсолют: `NOISE_STEPS`.
|
||||
* Взята на четыре порядка ниже шага, то есть заведомо ниже любого осмысленного
|
||||
* пользовательского ввода и заведомо выше двоичного мусора одиночной операции.
|
||||
*/
|
||||
const NOISE_STEPS = 1e-4;
|
||||
|
||||
const latticeDeviation = (value) => {
|
||||
const steps = value * GRID_N;
|
||||
return Math.abs(steps - Math.round(steps));
|
||||
};
|
||||
|
||||
/** Все координаты модели с адресом, по которому их можно найти глазами. */
|
||||
function* modelCoordinates(config, layout = {}) {
|
||||
for (const space of Array.isArray(config?.spaces) ? config.spaces : []) {
|
||||
const spaceId = String(space?.id ?? '?');
|
||||
for (const room of space?.rooms || []) {
|
||||
const poly = Array.isArray(room?.poly) ? room.poly : [];
|
||||
for (const [index, p] of poly.entries()) {
|
||||
const point = pointOf(p);
|
||||
if (point) yield { kind: 'room', owner: `${spaceId}:${room?.id ?? '?'}#${index}`, point };
|
||||
}
|
||||
for (const field of ['x', 'y', 'w', 'h']) {
|
||||
if (isFiniteNumber(room?.[field])) {
|
||||
yield { kind: 'room', owner: `${spaceId}:${room?.id ?? '?'}.${field}`, point: [room[field], room[field]] };
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const part of space?.partitions || []) {
|
||||
for (const end of ['a', 'b']) {
|
||||
const point = pointOf(part?.[end]);
|
||||
if (point) yield { kind: 'partition', owner: `${spaceId}:${part?.id ?? '?'}.${end}`, point };
|
||||
}
|
||||
}
|
||||
for (const wall of space?.walls || []) {
|
||||
for (const end of ['a', 'b']) {
|
||||
const point = pointOf(wall?.[end]);
|
||||
if (point) yield { kind: 'wall', owner: `${spaceId}:${wall?.key ?? '?'}.${end}`, point };
|
||||
}
|
||||
}
|
||||
for (const span of space?.open_spans || []) {
|
||||
for (const end of ['a', 'b']) {
|
||||
const point = pointOf(span?.[end]);
|
||||
if (point) yield { kind: 'open_span', owner: `${spaceId}:${span?.id ?? '?'}.${end}`, point };
|
||||
}
|
||||
}
|
||||
for (const column of space?.wall_columns || []) {
|
||||
const point = pointOf(column?.center);
|
||||
if (point) yield { kind: 'column', owner: `${spaceId}:${column?.id ?? '?'}`, point };
|
||||
}
|
||||
}
|
||||
for (const [key, position] of Object.entries(layout || {})) {
|
||||
const point = pointOf([position?.x, position?.y]);
|
||||
if (point) yield { kind: 'layout', owner: key, point };
|
||||
}
|
||||
}
|
||||
|
||||
const pointOf = (value) => (Array.isArray(value) && isFiniteNumber(value[0])
|
||||
&& isFiniteNumber(value[1]) ? [value[0], value[1]] : null);
|
||||
|
||||
/**
|
||||
* Профиль модели: сколько координат точно на узле, сколько в шуме, сколько
|
||||
* законно вне сетки. Ни одного нарушения не возвращается — по построению.
|
||||
*/
|
||||
export function latticeProfile({ config, layout = {} } = {}) {
|
||||
const buckets = { exact: 0, noise: 0, offGrid: 0 };
|
||||
const byKind = new Map();
|
||||
let worstNoise = null;
|
||||
let total = 0;
|
||||
for (const { kind, owner, point } of modelCoordinates(config, layout)) {
|
||||
for (const [axis, value] of [['x', point[0]], ['y', point[1]]]) {
|
||||
const deviation = latticeDeviation(value);
|
||||
const bucket = deviation === 0 ? 'exact' : deviation < NOISE_STEPS ? 'noise' : 'offGrid';
|
||||
total++;
|
||||
buckets[bucket]++;
|
||||
const seen = byKind.get(kind) || { exact: 0, noise: 0, offGrid: 0 };
|
||||
seen[bucket]++;
|
||||
byKind.set(kind, seen);
|
||||
if (bucket === 'noise' && (!worstNoise || deviation > worstNoise.steps)) {
|
||||
worstNoise = { kind, owner, axis, value, steps: deviation };
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
total,
|
||||
...buckets,
|
||||
noiseSteps: NOISE_STEPS,
|
||||
worstNoise,
|
||||
byKind: Object.fromEntries([...byKind].map(([kind, counts]) => [kind, counts])),
|
||||
};
|
||||
}
|
||||
|
||||
/** Разобрать любой из трёх форматов, в которых приходит конфигурация. */
|
||||
export function readModel(text) {
|
||||
const parsed = JSON.parse(text);
|
||||
@@ -314,6 +427,33 @@ function noteSummary(notes) {
|
||||
return [...counts].map(([kind, n]) => `${n} — ${titles[kind] || kind}`).join('; ') + '.';
|
||||
}
|
||||
|
||||
/** Профиль решётки на языке решения, а не на языке счётчиков (ADR #282). */
|
||||
function latticeReport(profile) {
|
||||
const share = (n) => (profile.total ? `${(n / profile.total * 100).toFixed(2)}%` : '—');
|
||||
const lines = [
|
||||
`Координат в модели: ${profile.total}.`,
|
||||
'',
|
||||
` точно на узле ${profile.exact} (${share(profile.exact)})`,
|
||||
` шум у узла ${profile.noise} (${share(profile.noise)})`
|
||||
+ ` — ближе ${profile.noiseSteps} шага, но не точно`,
|
||||
` законно вне сетки ${profile.offGrid} (${share(profile.offGrid)})`,
|
||||
'',
|
||||
];
|
||||
if (profile.worstNoise) {
|
||||
const w = profile.worstNoise;
|
||||
lines.push(`Худший шум: ${w.owner} по ${w.axis} = ${w.value}`
|
||||
+ ` — ${w.steps.toExponential(2)} шага от узла.`, '');
|
||||
}
|
||||
lines.push('По видам объектов (точно / шум / вне сетки):');
|
||||
for (const [kind, counts] of Object.entries(profile.byKind)) {
|
||||
lines.push(` ${kind.padEnd(11)} ${counts.exact} / ${counts.noise} / ${counts.offGrid}`);
|
||||
}
|
||||
lines.push('', 'Шум — это класс дефектов #258/#279/#248: ключ стены попадает не в тот',
|
||||
'бакет, Optimize не сходится. «Вне сетки» — законная геометрия нынешней модели.',
|
||||
'Стадия 0 ADR #282 измеряет, а не судит: нарушений здесь не бывает.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function report(violations, notes = []) {
|
||||
if (!violations.length) {
|
||||
const tail = notes.length
|
||||
@@ -358,12 +498,19 @@ function main(argv) {
|
||||
};
|
||||
const configPath = arg('--config');
|
||||
if (!configPath) {
|
||||
console.error('использование: model-invariants.mjs --config <файл> [--layout <файл>] [--json]');
|
||||
console.error('использование: model-invariants.mjs --config <файл> [--layout <файл>]'
|
||||
+ ' [--lattice] [--json]');
|
||||
return 2;
|
||||
}
|
||||
const model = readModel(readFileSync(configPath, 'utf8'));
|
||||
if (arg('--layout')) model.layout = readModel(readFileSync(arg('--layout'), 'utf8')).layout;
|
||||
const notes = [];
|
||||
if (argv.includes('--lattice')) {
|
||||
const profile = latticeProfile(model);
|
||||
if (argv.includes('--json')) console.log(JSON.stringify({ lattice: profile }, null, 2));
|
||||
else console.log(latticeReport(profile));
|
||||
return 0;
|
||||
}
|
||||
const violations = [
|
||||
...checkReferences(model, { notes }),
|
||||
...checkWallKeys(model.config, { notes }),
|
||||
|
||||
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
checkReferences, checkWallKeys, checkWallRecordsPreserved, keyMidpoint,
|
||||
readModel, wallKey,
|
||||
latticeProfile, readModel, wallKey,
|
||||
} from '../scripts/model-invariants.mjs';
|
||||
import { wallKey as productWallKey } from '../test-build/wall-thickness.js';
|
||||
import { GRID_STEP_N } from '../test-build/space-geometry.js';
|
||||
@@ -298,3 +298,74 @@ test('near-grid key normalization removes last-bit grading (#258, #259)', () =>
|
||||
assert.deepEqual(checkWallKeys({ spaces: [{ id: 'sp2', cell_cm: 1, walls }] }, { notes }), []);
|
||||
assert.deepEqual(notes, []);
|
||||
});
|
||||
|
||||
// ------------------- стадия 0 ADR #282: профиль решётки ----------------------
|
||||
// Мера, а не приговор. Нарушений здесь не бывает по построению: авторская
|
||||
// координата вне сетки законна, а «шум» — это отдельное население, из которого
|
||||
// растут #258, #279 и несходящийся Optimize.
|
||||
|
||||
const oneRoom = (poly) => ({
|
||||
config: { spaces: [{ id: 'sp1', cell_cm: 5, rooms: [{ id: 'r1', poly }] }] },
|
||||
layout: {},
|
||||
});
|
||||
|
||||
test('профиль различает узел, шум и законную геометрию вне сетки (#282)', () => {
|
||||
// 83/240 выбрано не случайно: это та самая вершина с дачи владельца. В
|
||||
// двоичном виде она 0.34583333333333333, в хранилище лежит как 0.345833333 —
|
||||
// и эти 8e-8 шага перебросили ключ стены в соседний бакет в #258. Узел вида
|
||||
// 24/240 = 0.1 для примера не годится: он выживает округление до девяти
|
||||
// знаков без изменений, и «шума» на нём не получить.
|
||||
const node = 83 / 240;
|
||||
const noise = Number((83 / 240).toFixed(9));
|
||||
assert.notEqual(node, noise, 'выбранный узел обязан терять точность при записи');
|
||||
const offGrid = 0.06; // 14.4 шага от узла — авторская координата
|
||||
const profile = latticeProfile(oneRoom([[node, node], [noise, noise], [offGrid, offGrid]]));
|
||||
assert.equal(profile.total, 6);
|
||||
assert.equal(profile.exact, 2, 'узел обязан считаться точным');
|
||||
assert.equal(profile.noise, 2, '9 знаков от того же узла — это шум, а не узел');
|
||||
assert.equal(profile.offGrid, 2, '0.06 — законная геометрия, а не дефект');
|
||||
assert.equal(profile.worstNoise.kind, 'room');
|
||||
assert.ok(profile.worstNoise.steps > 0 && profile.worstNoise.steps < profile.noiseSteps);
|
||||
});
|
||||
|
||||
test('профиль видит все виды объектов, а не только комнаты (#282)', () => {
|
||||
const off = 0.0605;
|
||||
const profile = latticeProfile({
|
||||
config: { spaces: [{
|
||||
id: 'sp1',
|
||||
rooms: [{ id: 'r1', poly: [[off, off]] }],
|
||||
partitions: [{ id: 'p1', a: [off, off], b: [off, off] }],
|
||||
walls: [{ key: 'k', cm: 20, a: [off, off], b: [off, off] }],
|
||||
open_spans: [{ id: 's1', a: [off, off], b: [off, off] }],
|
||||
wall_columns: [{ id: 'c1', center: [off, off] }],
|
||||
}] },
|
||||
layout: { m1: { s: 'sp1', x: off, y: off } },
|
||||
});
|
||||
assert.deepEqual(Object.keys(profile.byKind).sort(),
|
||||
['column', 'layout', 'open_span', 'partition', 'room', 'wall']);
|
||||
assert.equal(profile.total, 18);
|
||||
});
|
||||
|
||||
test('модели проекта не несут шума решётки (#282)', async () => {
|
||||
// Свойство, которое стоит знать про себя: тестовые данные проекта чисты —
|
||||
// весь их офф-грид авторский. Значит воспроизвести на них #258/#279 нельзя
|
||||
// по построению, а на реальном плане владельца шум составляет две трети
|
||||
// координат. Именно поэтому этот класс дефектов находит владелец, а не гейт.
|
||||
const models = [];
|
||||
for (const file of fixtureModules()) {
|
||||
const loaded = await import(`../demo/fixtures/${file}`);
|
||||
for (const [name, value] of Object.entries(loaded)) {
|
||||
if (typeof value !== 'function' || !/^make.*Fixture$/.test(name)) continue;
|
||||
const fixture = value();
|
||||
if (fixture?.config) models.push([`${file}:${name}`, fixture]);
|
||||
}
|
||||
}
|
||||
models.push(['demo/srv/demo.html', demoStandModel()]);
|
||||
assert.ok(models.length >= 3);
|
||||
for (const [label, fixture] of models) {
|
||||
const profile = latticeProfile({ config: fixture.config, layout: fixture.layout || {} });
|
||||
assert.equal(profile.noise, 0,
|
||||
`${label}: в тестовых данных появился шум решётки`
|
||||
+ `${profile.worstNoise ? ` — ${profile.worstNoise.owner}` : ''}`);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user