fix: габарит мебели равен заявленным размерам (#584)

Указанные ширина и глубина теперь совпадают с видимым габаритом предмета.
Раньше рисунок занимал внутри своего бокса около 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
This commit is contained in:
Codex
2026-09-16 23:02:11 +03:00
parent e0b16c76d7
commit ef3bc6d133
97 changed files with 974 additions and 447 deletions
+32
View File
@@ -0,0 +1,32 @@
// Склейка нескольких `<path>` одного символа в один `d` (#584).
//
// У каждого исходного `path` своя текущая точка, и начинается она в нуле. При
// простой конкатенации `d` (так генератор и делал) второй путь, начинающийся
// относительной командой `m`, продолжает координаты первого — рисунок уезжает.
// Подтверждено на `coffee_table_round` и `table_round`: внутренние детали
// смещались относительно исходного SVG и могли выйти за `viewBox`.
//
// Канонизируется РОВНО начальный `moveto` каждого пути. Всё остальное внутри
// пути обязано сохранить смысл: после относительного `m` неявные пары координат
// — это относительные `l`, и превращать их в абсолютные нельзя.
//
// Приём подсказан патчем в поставке дизайнера (`fix-584-1`); реализация и
// свидетели здесь свои.
const NUMBER = '[-+]?(?:\\d*\\.\\d+|\\d+\\.?\\d*)(?:[eE][-+]?\\d+)?';
const INITIAL_RELATIVE = new RegExp(`^m\\s*(${NUMBER})[\\s,]*(${NUMBER})`);
export function joinFurniturePaths(paths) {
if (!Array.isArray(paths) || !paths.length) throw new Error('Furniture SVG has no paths');
return paths.map((value) => {
const d = String(value).trim();
if (d.startsWith('M')) return d;
const match = INITIAL_RELATIVE.exec(d);
if (!match) throw new Error('Furniture path must begin with M or m');
const tail = d.slice(match[0].length).replace(/^[\s,]+/, '');
// Неявные пары после `m` — относительные линии; без явной `l` они достались
// бы канонизированному `M` и стали бы абсолютными.
const implicitLine = tail && !/^[A-Za-z]/.test(tail) ? 'l ' : '';
return `M ${match[1]} ${match[2]} ${implicitLine}${tail}`;
}).join(' ');
}
+27 -1
View File
@@ -1,5 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { joinFurniturePaths } from './furniture-path-join.mjs';
import { boxFillDeviation } from './svg-path-bounds.mjs';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
@@ -15,6 +17,13 @@ const PLAN_OUT = path.join(ROOT, 'src', 'furniture-plan-art.generated.ts');
const MENU_OUT = path.join(ROOT, 'src', 'furniture-menu-art.generated.ts');
const CHECK = process.argv.includes('--check');
/**
* Допуск контракта #584: видимые границы планового символа обязаны заполнять
* `viewBox` с точностью 0,1 единицы (в сантиметрах предмета). Число пришло из
* ТЗ дизайнеру; присланный пак укладывается в 0,000001.
*/
export const PLAN_BOUNDS_TOLERANCE = 0.1;
const fail = (message) => { throw new Error(`Furniture pack: ${message}`); };
const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
const plainObject = (value, label) => {
@@ -94,7 +103,24 @@ function svgArt(file, expectedViewBox) {
paths.push(d);
}
if (!paths.length) fail(`${label} has no paths`);
return { d: paths.join(' '), viewW: view[2], viewH: view[3] };
// #584: у каждого исходного пути своя текущая точка. Простая конкатенация
// `d` продолжала координаты предыдущего пути — рисунок уезжал.
const d = joinFurniturePaths(paths);
if (expectedViewBox) {
// Плановый символ описывает физический предмет: один путь, подконтуры с
// абсолютной `M`, и рисунок заполняет `viewBox` целиком. Пока последнего
// не требовали, два предмета 60 × 60 выглядели разными (#584).
if (paths.length !== 1) fail(`${label} plan symbol must be a single path`);
if (!d.startsWith('M') || /(?<![0-9eE.,\-])m/.test(d)) {
fail(`${label} plan subpaths must start with an absolute M`);
}
const deviation = boxFillDeviation(d, view[2], view[3]);
if (deviation > PLAN_BOUNDS_TOLERANCE) {
fail(`${label} drawable bounds miss the viewBox by ${deviation.toFixed(4)} `
+ `(tolerance ${PLAN_BOUNDS_TOLERANCE})`);
}
}
return { d, viewW: view[2], viewH: view[3] };
}
function loadPack() {
+27
View File
@@ -9917,6 +9917,33 @@ const MUTANT_DEFINITIONS = [
replace: " if (settings?.fill_mode === 'never-a-fill-mode') return spaceFill;",
}],
},
{
id: 'furniture-symbol-may-keep-inner-padding',
guard: 'node --test --test-name-pattern="AC1" test/furniture-visual-bounds.test.mjs',
because: '#584: «ширина × глубина» обязаны совпадать с видимым габаритом. Пока гейт '
+ 'проверял только viewBox, все 44 дизайнерских символа занимали около 88 % стороны, и '
+ 'предмет 60 × 60 выглядел меньше соседа 60 × 60 — цифры совпадали, картинка врала',
patches: [{
file: 'scripts/svg-path-bounds.mjs',
// рантайм-ложь, а не мёртвая ветка (#568): модуль обязан импортироваться
find: 'export function boxFillDeviation(d, width, height) {\n const bounds = svgPathBounds(d);',
replace: 'export function boxFillDeviation(d, width, height) {\n'
+ ' if (String(width) !== \'mutant-never-a-width\') return 0;\n'
+ ' const bounds = svgPathBounds(d);',
}],
},
{
id: 'furniture-paths-joined-without-reset',
guard: 'node --test --test-name-pattern="AC4" test/furniture-path-join.test.mjs',
because: '#584: у каждого исходного path своя текущая точка. Простая конкатенация `d` '
+ 'продолжала координаты предыдущего пути — детали уезжали за viewBox (stairs, tv, '
+ 'coffee_table_round, table_round), причём молча: метаданные и viewBox сходились',
patches: [{
file: 'scripts/furniture-path-join.mjs',
find: " if (d.startsWith('M')) return d;",
replace: " if (d.startsWith('M') || d.length >= 0) return d;",
}],
},
{
id: 'screenshot-freshness-never-strict',
guard: 'node --test --test-name-pattern="#586" test/classify-changes.test.mjs',
+176
View File
@@ -0,0 +1,176 @@
// Фактические границы рисунка пути (#584).
//
// Контракт мебели говорит о ВИДИМОМ габарите: «ширина × глубина» из каталога
// обязаны совпасть с тем, что человек видит на плане. Проверять это по
// `viewBox` нельзя — именно расхождение между `viewBox` и рисунком внутри него
// и было дефектом: у всех 44 дизайнерских символов рисунок занимал около 88 %
// стороны, а legacy-примитивы заполняли бокс целиком, поэтому два предмета
// 60 × 60 выглядели разными.
//
// Границы считаются по осевой линии контура (решение владельца по Q1): обводка
// выступает наружу на половину толщины и в габарит не входит. Иначе смена
// толщины линии пересчитывала бы геометрию.
//
// Почему здесь свой разбор, а не `parseSvgPath` из `src/pdf/svg-path.ts`:
// генератор — `.mjs` и обязан работать без сборки TypeScript. Чтобы две
// реализации не разъехались молча, тест
// `test/furniture-visual-bounds.test.mjs` сверяет их на всей библиотеке: та же
// математика кубик, те же числа.
const NUMBER = /[-+]?(?:\d*\.\d+|\d+\.?\d*)(?:[eE][-+]?\d+)?/g;
const ARITY = { M: 2, L: 2, H: 1, V: 1, C: 6, S: 4, Q: 4, T: 2, A: 7, Z: 0 };
/** Дуга → кубики: та же формула, что у продакшен-экспорта в PDF. */
function arcToCubics(x0, y0, rx, ry, rotation, largeArc, sweep, x, y) {
if (!rx || !ry) return [[x0, y0, x, y, x, y]];
const radians = (rotation * Math.PI) / 180;
const cos = Math.cos(radians), sin = Math.sin(radians);
const dx = (x0 - x) / 2, dy = (y0 - y) / 2;
const x1 = cos * dx + sin * dy, y1 = -sin * dx + cos * dy;
let radiusX = Math.abs(rx), radiusY = Math.abs(ry);
const lambda = (x1 * x1) / (radiusX * radiusX) + (y1 * y1) / (radiusY * radiusY);
if (lambda > 1) { const s = Math.sqrt(lambda); radiusX *= s; radiusY *= s; }
const sign = largeArc === sweep ? -1 : 1;
const numerator = radiusX * radiusX * radiusY * radiusY
- radiusX * radiusX * y1 * y1 - radiusY * radiusY * x1 * x1;
const denominator = radiusX * radiusX * y1 * y1 + radiusY * radiusY * x1 * x1;
const factor = sign * Math.sqrt(Math.max(0, numerator / (denominator || 1)));
const cx1 = (factor * radiusX * y1) / radiusY;
const cy1 = (-factor * radiusY * x1) / radiusX;
const cx = cos * cx1 - sin * cy1 + (x0 + x) / 2;
const cy = sin * cx1 + cos * cy1 + (y0 + y) / 2;
const angle = (ux, uy, vx, vy) => {
const norm = Math.hypot(ux, uy) * Math.hypot(vx, vy) || 1;
const cosine = Math.max(-1, Math.min(1, (ux * vx + uy * vy) / norm));
return (ux * vy - uy * vx < 0 ? -1 : 1) * Math.acos(cosine);
};
const start = angle(1, 0, (x1 - cx1) / radiusX, (y1 - cy1) / radiusY);
let sweepAngle = angle((x1 - cx1) / radiusX, (y1 - cy1) / radiusY,
(-x1 - cx1) / radiusX, (-y1 - cy1) / radiusY);
if (!sweep && sweepAngle > 0) sweepAngle -= 2 * Math.PI;
if (sweep && sweepAngle < 0) sweepAngle += 2 * Math.PI;
const steps = Math.max(1, Math.ceil(Math.abs(sweepAngle) / (Math.PI / 2)));
const delta = sweepAngle / steps;
const alpha = (4 / 3) * Math.tan(delta / 4);
const out = [];
let theta = start;
let px = x0, py = y0;
for (let index = 0; index < steps; index++) {
const next = theta + delta;
const point = (t) => {
const ex = cos * radiusX * Math.cos(t) - sin * radiusY * Math.sin(t) + cx;
const ey = sin * radiusX * Math.cos(t) + cos * radiusY * Math.sin(t) + cy;
return [ex, ey];
};
const derivative = (t) => {
const ex = -cos * radiusX * Math.sin(t) - sin * radiusY * Math.cos(t);
const ey = -sin * radiusX * Math.sin(t) + cos * radiusY * Math.cos(t);
return [ex, ey];
};
const [ex, ey] = point(next);
const [dx1, dy1] = derivative(theta);
const [dx2, dy2] = derivative(next);
out.push([px + alpha * dx1, py + alpha * dy1, ex - alpha * dx2, ey - alpha * dy2, ex, ey]);
px = ex; py = ey; theta = next;
}
return out;
}
/** Экстремумы кубики по одной оси: концы плюс корни производной. */
function cubicExtrema(p0, p1, p2, p3) {
const values = [p0, p3];
const a = -p0 + 3 * p1 - 3 * p2 + p3;
const b = 2 * (p0 - 2 * p1 + p2);
const c = p1 - p0;
const at = (t) => {
if (!(t > 0 && t < 1)) return;
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 discriminant = b * b - 4 * a * c;
if (discriminant >= 0) {
const root = Math.sqrt(discriminant);
at((-root - b) / (2 * a));
at((root - b) / (2 * a));
}
}
return values;
}
/**
* Границы рисунка пути: `{ minX, minY, maxX, maxY }`.
*
* Точки управления в габарит НЕ входят: считаются настоящие экстремумы кривых,
* иначе контракт «рисунок заполняет бокс» проверялся бы по выпуклой оболочке и
* пропускал бы поля.
*/
export function svgPathBounds(d) {
const tokens = String(d).match(/[AaCcHhLlMmQqSsTtVvZz]|[-+]?(?:\d*\.\d+|\d+\.?\d*)(?:[eE][-+]?\d+)?/g) || [];
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const noteX = (value) => { minX = Math.min(minX, value); maxX = Math.max(maxX, value); };
const noteY = (value) => { minY = Math.min(minY, value); maxY = Math.max(maxY, value); };
const note = (x, y) => { noteX(x); noteY(y); };
let x = 0, y = 0, startX = 0, startY = 0;
let prevControl = null, prevQuadratic = null, command = '';
let index = 0;
while (index < tokens.length) {
if (/^[A-Za-z]$/.test(tokens[index])) { command = tokens[index]; index++; }
else if (!command) throw new Error('путь начинается не с команды');
else if (command === 'M') command = 'L';
else if (command === 'm') command = 'l';
const upper = command.toUpperCase();
const relative = command !== upper;
const arity = ARITY[upper];
if (arity === undefined) throw new Error(`неизвестная команда ${command}`);
const args = tokens.slice(index, index + arity).map(Number);
if (args.length < arity) break;
index += arity;
const px = relative ? x : 0, py = relative ? y : 0;
const cubic = (x1, y1, x2, y2, ex, ey) => {
for (const value of cubicExtrema(x, x1, x2, ex)) noteX(value);
for (const value of cubicExtrema(y, y1, y2, ey)) noteY(value);
x = ex; y = ey;
};
if (upper === 'M') { x = px + args[0]; y = py + args[1]; startX = x; startY = y; note(x, y); prevControl = prevQuadratic = null; }
else if (upper === 'L') { x = px + args[0]; y = py + args[1]; note(x, y); prevControl = prevQuadratic = null; }
else if (upper === 'H') { x = px + args[0]; note(x, y); prevControl = prevQuadratic = null; }
else if (upper === 'V') { y = py + args[0]; note(x, y); prevControl = prevQuadratic = null; }
else if (upper === 'C') {
const x1 = px + args[0], y1 = py + args[1], x2 = px + args[2], y2 = py + args[3];
const ex = px + args[4], ey = py + args[5];
cubic(x1, y1, x2, y2, ex, ey); prevControl = [x2, y2]; prevQuadratic = null;
} else if (upper === 'S') {
const x1 = prevControl ? 2 * x - prevControl[0] : x;
const y1 = prevControl ? 2 * y - prevControl[1] : y;
const x2 = px + args[0], y2 = py + args[1], ex = px + args[2], ey = py + args[3];
cubic(x1, y1, x2, y2, ex, ey); prevControl = [x2, y2]; prevQuadratic = null;
} else if (upper === 'Q' || upper === 'T') {
const qx = upper === 'Q' ? px + args[0] : (prevQuadratic ? 2 * x - prevQuadratic[0] : x);
const qy = upper === 'Q' ? py + args[1] : (prevQuadratic ? 2 * y - prevQuadratic[1] : y);
const ex = upper === 'Q' ? px + args[2] : px + args[0];
const ey = upper === 'Q' ? py + args[3] : py + args[1];
cubic(x + (2 / 3) * (qx - x), y + (2 / 3) * (qy - y),
ex + (2 / 3) * (qx - ex), ey + (2 / 3) * (qy - ey), ex, ey);
prevQuadratic = [qx, qy]; prevControl = null;
} else if (upper === 'A') {
const ex = px + args[5], ey = py + args[6];
for (const segment of arcToCubics(x, y, args[0], args[1], args[2], !!args[3], !!args[4], ex, ey)) {
cubic(segment[0], segment[1], segment[2], segment[3], segment[4], segment[5]);
}
prevControl = prevQuadratic = null;
} else if (upper === 'Z') { x = startX; y = startY; prevControl = prevQuadratic = null; }
}
if (!Number.isFinite(minX)) throw new Error('путь не содержит точек');
return { minX, minY, maxX, maxY };
}
/** Насколько рисунок НЕ дотягивается до своего бокса (или вылезает из него). */
export function boxFillDeviation(d, width, height) {
const bounds = svgPathBounds(d);
return Math.max(
Math.abs(bounds.minX), Math.abs(bounds.minY),
Math.abs(width - bounds.maxX), Math.abs(height - bounds.maxY),
);
}