Files
houseplan-card/scripts/furniture-path-join.mjs
Codex ef3bc6d133 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
2026-09-16 23:02:11 +03:00

33 lines
2.2 KiB
JavaScript

// Склейка нескольких `<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(' ');
}