mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Указанные ширина и глубина теперь совпадают с видимым габаритом предмета.
Раньше рисунок занимал внутри своего бокса около 88 % стороны, поэтому
«Кухонный напольный модуль» 60 × 60 выглядел меньше «Посудомоечной машины»
60 × 60 примерно на 14 % по стороне и на 30 % по площади: цифры совпадали,
картинка врала.
Пак `fix-584-1` от дизайнера (issue, комментарий от 16.09) проверен исполнением
своей метрикой, а не его отчётом: границы считаются аналитически по той же
математике, что у продакшен-экспорта в PDF. Все 44 плановых SVG заполняют
`viewBox` с худшим отклонением 0.000000 при допуске ТЗ 0,1; `pack.json` и 33
иконки меню побайтно прежние. Для сравнения, прежний набор в `dev` давал 65
нарушений того же контракта.
Что сделано в коде:
- `scripts/svg-path-bounds.mjs` — границы рисунка по настоящим экстремумам
кривых (дуги приводятся к кубикам). Контрольные точки в габарит не входят:
иначе «заполняет бокс» проверялось бы по выпуклой оболочке и поля проходили
бы мимо. Генератор — `.mjs` и обязан работать без сборки TypeScript, поэтому
разбор здесь свой; тест сверяет его с `parseSvgPath` продакшена на всей
библиотеке, чтобы реализации не разъехались молча.
- `scripts/furniture-path-join.mjs` — склейка нескольких `<path>` с
канонизацией только начального `moveto`. Приём подсказан патчем в поставке
дизайнера; реализация и свидетели свои. Прежний `paths.join(' ')` продолжал
координаты предыдущего пути: замер показал, что у иконок меню `stairs` и `tv`
части рисунка уезжали за `viewBox` 110 × 110 — до 119. Теперь склейка
побайтно воспроизводит «каждый путь из своего нуля».
- генератор проверяет плановый символ по контракту: один путь, подконтуры с
абсолютной `M`, рисунок заполняет `viewBox` (допуск 0,1).
- `src/furniture.ts` — legacy `plant` растянут от центра в 1/0.96. Это
единственный из двенадцати retained-примитивов с полями (0.02…0.98 unit box);
форма прежняя, габарит стал заявленным.
Свидетели: `test/furniture-visual-bounds.test.mjs` (AC1–AC3, AC5 и сверка двух
реализаций границ) и `test/furniture-path-join.test.mjs` (AC4, включая реальные
`stairs`/`tv`). У AC1 есть положительный контроль: та же функция обязана видеть
поля на геометрии из шапки issue — утверждение «полей нет» стоит ровно столько,
сколько стоит измерение. Мутанты `furniture-symbol-may-keep-inner-padding` и
`furniture-paths-joined-without-reset` возвращают прежнее поведение и краснеют;
прогнаны лично через `mutation-gate --id=`.
Миграции пользовательских данных нет: координаты, размеры, поворот и
зеркалирование сохраняются.
npm test 2743/2742/0 fail, typecheck чистый, furniture:check зелёный.
Issue: #584
User-Visible: yes
234 lines
13 KiB
JavaScript
234 lines
13 KiB
JavaScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { readFileSync } from 'node:fs';
|
||
|
||
import { FURNITURE, furnitureGraphic, furnitureRenderTransform } from '../test-build/furniture.js';
|
||
import { GENERATED_FURNITURE_ART } from '../test-build/furniture-plan-art.generated.js';
|
||
import { parseSvgPath } from '../test-build/pdf/svg-path.js';
|
||
import { svgPathBounds, boxFillDeviation } from '../scripts/svg-path-bounds.mjs';
|
||
import { PLAN_BOUNDS_TOLERANCE } from '../scripts/generate-furniture-assets.mjs';
|
||
|
||
// #584. Контракт задачи говорит о ВИДИМОМ габарите, поэтому и проверять надо
|
||
// рисунок, а не метаданные: до этой задачи `viewBox` совпадал с каталогом у
|
||
// всех 44 дизайнерских символов, а рисунок внутри занимал около 88 % стороны.
|
||
// Тесты на `viewBox` были зелёными, и предмет 60 × 60 всё равно выглядел
|
||
// меньше соседа 60 × 60.
|
||
|
||
/** Путь символа в его собственном боксе: дизайнерский art либо legacy-примитив. */
|
||
const artOf = (symbol) => (symbol.designer
|
||
? {
|
||
d: GENERATED_FURNITURE_ART[symbol.id].d,
|
||
w: GENERATED_FURNITURE_ART[symbol.id].viewW,
|
||
h: GENERATED_FURNITURE_ART[symbol.id].viewH,
|
||
}
|
||
// Legacy-символы рисуются в unit box и приходят тем же публичным путём, что
|
||
// и в продакшене.
|
||
: { d: furnitureGraphic(symbol.id).d, w: 1, h: 1 });
|
||
|
||
test('AC1: рисунок каждого символа заполняет свой физический бокс', () => {
|
||
const misses = [];
|
||
for (const symbol of FURNITURE) {
|
||
const art = artOf(symbol);
|
||
const tolerance = symbol.designer ? PLAN_BOUNDS_TOLERANCE : PLAN_BOUNDS_TOLERANCE / Math.max(symbol.w, symbol.h);
|
||
const deviation = boxFillDeviation(art.d, art.w, art.h);
|
||
if (deviation > tolerance) misses.push(`${symbol.id}: ${deviation.toFixed(4)} > ${tolerance.toFixed(4)}`);
|
||
}
|
||
assert.deepEqual(misses, [], 'символы с полями внутри своего бокса');
|
||
assert.equal(FURNITURE.length, 56, 'библиотека: 44 дизайнерских + 12 legacy');
|
||
|
||
// Положительный контроль. Утверждение «полей нет» стоит ровно столько,
|
||
// сколько стоит измерение: та же функция на той же геометрии обязана поля
|
||
// УВИДЕТЬ. Числа — из шапки issue: рисунок `kitchen_floor` занимал
|
||
// 52,615 × 52,615 внутри viewBox 60 × 60.
|
||
const padded = 'M 3.6925 3.6925 L 56.3075 3.6925 L 56.3075 56.3075 L 3.6925 56.3075 Z';
|
||
const paddedDeviation = boxFillDeviation(padded, 60, 60);
|
||
assert.ok(paddedDeviation > PLAN_BOUNDS_TOLERANCE,
|
||
`измерение обязано видеть поля, а вернуло ${paddedDeviation}`);
|
||
assert.ok(Math.abs(paddedDeviation - 3.6925) < 1e-9, 'поля меряются, а не округляются');
|
||
assert.equal(boxFillDeviation('M 0 0 L 60 0 L 60 60 L 0 60 Z', 60, 60), 0);
|
||
});
|
||
|
||
test('AC2: одинаковые размеры дают одинаковый видимый габарит', () => {
|
||
// Пара из шапки issue: оба предмета 60 × 60, но посудомойка была крупнее.
|
||
const floor = FURNITURE.find((s) => s.id === 'kitchen_floor');
|
||
const dishwasher = FURNITURE.find((s) => s.id === 'dishwasher');
|
||
assert.deepEqual([floor.w, floor.h], [60, 60]);
|
||
assert.deepEqual([dishwasher.w, dishwasher.h], [60, 60]);
|
||
const extent = (symbol) => {
|
||
const art = artOf(symbol);
|
||
const bounds = svgPathBounds(art.d);
|
||
return [
|
||
((bounds.maxX - bounds.minX) / art.w) * symbol.w,
|
||
((bounds.maxY - bounds.minY) / art.h) * symbol.h,
|
||
];
|
||
};
|
||
const [floorW, floorH] = extent(floor);
|
||
const [dishW, dishH] = extent(dishwasher);
|
||
assert.ok(Math.abs(floorW - dishW) <= PLAN_BOUNDS_TOLERANCE, `ширина ${floorW} против ${dishW}`);
|
||
assert.ok(Math.abs(floorH - dishH) <= PLAN_BOUNDS_TOLERANCE, `глубина ${floorH} против ${dishH}`);
|
||
assert.ok(Math.abs(floorW - 60) <= PLAN_BOUNDS_TOLERANCE, 'кухонный модуль занимает заявленные 60 см');
|
||
});
|
||
|
||
test('AC3: после продакшен-трансформа геометрия занимает бокс фигуры', () => {
|
||
const symbol = FURNITURE.find((s) => s.id === 'kitchen_floor');
|
||
const art = artOf(symbol);
|
||
const canvasW = 1000, canvasH = 800;
|
||
const shape = { x: 0.2, y: 0.1, w: 0.3, h: 0.25 };
|
||
const box = { x: shape.x * canvasW, y: shape.y * canvasH, w: shape.w * canvasW, h: shape.h * canvasH };
|
||
const tolerance = 0.1 * Math.max(box.w / symbol.w, box.h / symbol.h);
|
||
|
||
// Прямые углы: габарит рисунка совпадает с боксом фигуры. Зеркалирование —
|
||
// отражение того же бокса, поэтому равенство обязано сохраниться.
|
||
for (const angle of [0, 90, 180, 270]) {
|
||
for (const flip_h of [false, true]) {
|
||
for (const flip_v of [false, true]) {
|
||
const transform = furnitureRenderTransform({ ...shape, angle, flip_h, flip_v }, canvasW, canvasH, art.w, art.h);
|
||
const bounds = transformedBounds(art.d, transform);
|
||
const expected = rotatedBox(box, angle);
|
||
const state = `angle=${angle} flip=${flip_h ? 'h' : ''}${flip_v ? 'v' : ''}`;
|
||
for (const [key, value] of Object.entries(expected)) {
|
||
assert.ok(Math.abs(bounds[key] - value) <= tolerance,
|
||
`${state}: ${key} ${bounds[key].toFixed(3)} против ${value.toFixed(3)}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Произвольный угол: габарит рисунка обязан ЛЕЖАТЬ в повёрнутом боксе.
|
||
// Равенства здесь и не должно быть — у скруглённых углов повёрнутый рисунок
|
||
// занимает чуть меньше, чем повёрнутый прямоугольник, и это геометрия, а не
|
||
// поля внутри символа.
|
||
for (const angle of [15, 37]) {
|
||
const transform = furnitureRenderTransform({ ...shape, angle, flip_h: false, flip_v: false }, canvasW, canvasH, art.w, art.h);
|
||
const bounds = transformedBounds(art.d, transform);
|
||
const expected = rotatedBox(box, angle);
|
||
const inside = bounds.minX >= expected.minX - tolerance && bounds.maxX <= expected.maxX + tolerance
|
||
&& bounds.minY >= expected.minY - tolerance && bounds.maxY <= expected.maxY + tolerance;
|
||
assert.ok(inside, `angle=${angle}: рисунок вышел за повёрнутый бокс`);
|
||
const cover = (bounds.maxX - bounds.minX) / (expected.maxX - expected.minX);
|
||
assert.ok(cover > 0.97, `angle=${angle}: рисунок занимает лишь ${(cover * 100).toFixed(1)} % повёрнутого бокса`);
|
||
}
|
||
});
|
||
|
||
/**
|
||
* Тот же разбор трансформа, что применяет браузер: rotate → translate → scale.
|
||
* Аффинное преобразование переносит контрольные точки кубик, поэтому границы
|
||
* считаются по настоящим экстремумам уже преобразованной кривой, а не по
|
||
* выпуклой оболочке: иначе скруглённый угол дал бы габарит больше бокса.
|
||
*/
|
||
function transformedBounds(d, transform) {
|
||
const rotate = /rotate\(([-\d.]+) ([-\d.]+) ([-\d.]+)\)/.exec(transform);
|
||
const translate = /translate\(([-\d.]+) ([-\d.]+)\)/.exec(transform);
|
||
const scale = /scale\(([-\d.]+) ([-\d.]+)\)/.exec(transform);
|
||
const apply = (x, y) => {
|
||
let px = x * Number(scale[1]) + Number(translate[1]);
|
||
let py = y * Number(scale[2]) + Number(translate[2]);
|
||
if (rotate) {
|
||
const radians = (Number(rotate[1]) * Math.PI) / 180;
|
||
const cx = Number(rotate[2]), cy = Number(rotate[3]);
|
||
const dx = px - cx, dy = py - cy;
|
||
px = cx + dx * Math.cos(radians) - dy * Math.sin(radians);
|
||
py = cy + dx * Math.sin(radians) + dy * Math.cos(radians);
|
||
}
|
||
return [px, py];
|
||
};
|
||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||
const noteX = (v) => { minX = Math.min(minX, v); maxX = Math.max(maxX, v); };
|
||
const noteY = (v) => { minY = Math.min(minY, v); maxY = Math.max(maxY, v); };
|
||
const extrema = (p0, p1, p2, p3) => {
|
||
const values = [p0, p3];
|
||
const a = -p0 + 3 * p1 - 3 * p2 + p3, b = 2 * (p0 - 2 * p1 + p2), c = p1 - p0;
|
||
const at = (t) => { if (t > 0 && t < 1) { const u = 1 - t;
|
||
values.push(u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3); } };
|
||
if (Math.abs(a) < 1e-12) { if (Math.abs(b) > 1e-12) at(-c / b); }
|
||
else { const disc = b * b - 4 * a * c; if (disc >= 0) { const r = Math.sqrt(disc);
|
||
at((-b + r) / (2 * a)); at((-b - r) / (2 * a)); } }
|
||
return values;
|
||
};
|
||
let cur = [0, 0];
|
||
for (const op of parseSvgPath(d)) {
|
||
if (op.op === 'Z') continue;
|
||
if (op.op === 'M' || op.op === 'L') {
|
||
const [x, y] = apply(op.x, op.y);
|
||
noteX(x); noteY(y); cur = [x, y];
|
||
continue;
|
||
}
|
||
const [x1, y1] = apply(op.x1, op.y1);
|
||
const [x2, y2] = apply(op.x2, op.y2);
|
||
const [x, y] = apply(op.x, op.y);
|
||
for (const v of extrema(cur[0], x1, x2, x)) noteX(v);
|
||
for (const v of extrema(cur[1], y1, y2, y)) noteY(v);
|
||
cur = [x, y];
|
||
}
|
||
return { minX, minY, maxX, maxY };
|
||
}
|
||
|
||
/** Габарит повёрнутого прямоугольника — то, что бокс занимает на экране. */
|
||
function rotatedBox(box, angle) {
|
||
const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
|
||
const radians = (angle * Math.PI) / 180;
|
||
const corners = [[box.x, box.y], [box.x + box.w, box.y], [box.x, box.y + box.h], [box.x + box.w, box.y + box.h]];
|
||
const points = corners.map(([x, y]) => {
|
||
const dx = x - cx, dy = y - cy;
|
||
return [cx + dx * Math.cos(radians) - dy * Math.sin(radians), cy + dx * Math.sin(radians) + dy * Math.cos(radians)];
|
||
});
|
||
return {
|
||
minX: Math.min(...points.map((p) => p[0])), maxX: Math.max(...points.map((p) => p[0])),
|
||
minY: Math.min(...points.map((p) => p[1])), maxY: Math.max(...points.map((p) => p[1])),
|
||
};
|
||
}
|
||
|
||
test('границы считаются той же математикой, что у продакшен-экспорта', () => {
|
||
// Генератор — .mjs и работает без сборки TypeScript, поэтому у него свой
|
||
// разбор пути. Две реализации не имеют права разъехаться молча: сверяем их
|
||
// на всей библиотеке.
|
||
for (const symbol of FURNITURE) {
|
||
const art = artOf(symbol);
|
||
const mine = svgPathBounds(art.d);
|
||
const production = productionBounds(art.d);
|
||
for (const key of ['minX', 'minY', 'maxX', 'maxY']) {
|
||
assert.ok(Math.abs(mine[key] - production[key]) < 1e-6,
|
||
`${symbol.id}.${key}: ${mine[key]} против ${production[key]}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
/** Границы по операциям продакшен-парсера: те же экстремумы кубик. */
|
||
function productionBounds(d) {
|
||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||
let cur = [0, 0];
|
||
const noteX = (v) => { minX = Math.min(minX, v); maxX = Math.max(maxX, v); };
|
||
const noteY = (v) => { minY = Math.min(minY, v); maxY = Math.max(maxY, v); };
|
||
const extrema = (p0, p1, p2, p3) => {
|
||
const values = [p0, p3];
|
||
const a = -p0 + 3 * p1 - 3 * p2 + p3, b = 2 * (p0 - 2 * p1 + p2), c = p1 - p0;
|
||
const at = (t) => { if (t > 0 && t < 1) { const u = 1 - t;
|
||
values.push(u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3); } };
|
||
if (Math.abs(a) < 1e-12) { if (Math.abs(b) > 1e-12) at(-c / b); }
|
||
else { const disc = b * b - 4 * a * c; if (disc >= 0) { const r = Math.sqrt(disc);
|
||
at((-b + r) / (2 * a)); at((-b - r) / (2 * a)); } }
|
||
return values;
|
||
};
|
||
for (const op of parseSvgPath(d)) {
|
||
if (op.op === 'M' || op.op === 'L') { noteX(op.x); noteY(op.y); cur = [op.x, op.y]; }
|
||
else if (op.op === 'C') {
|
||
for (const v of extrema(cur[0], op.x1, op.x2, op.x)) noteX(v);
|
||
for (const v of extrema(cur[1], op.y1, op.y2, op.y)) noteY(v);
|
||
cur = [op.x, op.y];
|
||
}
|
||
}
|
||
return { minX, minY, maxX, maxY };
|
||
}
|
||
|
||
test('AC5: гейт пака читает репозиторий и падает на символе с полями', async () => {
|
||
const generator = readFileSync(new URL('../scripts/generate-furniture-assets.mjs', import.meta.url), 'utf8');
|
||
assert.match(generator, /boxFillDeviation/, 'проверка границ обязана быть в гейте, а не только в тестах');
|
||
assert.match(generator, /plan symbol must be a single path/);
|
||
assert.match(generator, /plan subpaths must start with an absolute M/);
|
||
assert.equal(PLAN_BOUNDS_TOLERANCE, 0.1, 'допуск контракта из ТЗ дизайнеру');
|
||
// Символ с полями внутри бокса обязан отвергаться: это и есть дефект #584.
|
||
const padded = 'M 5 5 L 55 5 L 55 55 L 5 55 Z';
|
||
assert.ok(boxFillDeviation(padded, 60, 60) > PLAN_BOUNDS_TOLERANCE);
|
||
assert.ok(boxFillDeviation('M 0 0 L 60 0 L 60 60 L 0 60 Z', 60, 60) <= PLAN_BOUNDS_TOLERANCE);
|
||
});
|