mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Встроенная библиотека мебели была смешанной: 44 предмета рисовал дизайнер, а 12 — код, прямоугольниками и эллипсами в unit box. Они выбивались из стиля, и три категории меню (`computer`, `hood`, `oven`) вообще не открывались, потому что вида сверху для них не было. Пакет 0.4.0 перерисовывает все 56 существующих публичных ID и добавляет четыре новых: `computer`, `hood`, `oven`, `cactus`. Каталог становится равен поставке: 60 символов, 32 непустые категории. Совместимость. Ни один сохранённый план не мигрирует: положение, размер, поворот, отражение, цвет, прозрачность и толщина линии не трогаются, а default-размеры всех 56 прежних ID совпадают со старыми до числа — таблица зашита в `test/furniture.test.mjs`, чтобы сдвиг было видно тестом, а не глазами. Ловушка, ради которой примитивы удалены целиком. Библиотека собиралась союзом каталога и `LEGACY_FURNITURE`, и обе половины несли ОДНИ И ТЕ ЖЕ публичные ID. `BY_ID` — это `Map`, она оставляет последнюю запись, поэтому примитив молча побеждал дизайнерский рисунок, который его заменял: задача выглядит выполненной, 12 предметов не обновились, ни один существующий тест этого не видит. Поэтому `LEGACY_FURNITURE`, `RETAINED_IDS`, тип `Prim`, `box()` и `primitivePathD` удалены, а на дубликаты в каталоге стоит тест и мутант. Два продуктовых решения владельца (#593): - `cactus` живёт в категории `plant`, а не `exercise`, как предлагал пакет: плитка «Тренажёр» рисует тренажёр, и открывать её на кактус — врать о содержимом палитры. `exercise` остаётся скрытой пустой категорией, непустых становится 32, а не 33; - при отказе ленивого чанка с рисунками не рисуется НИЧЕГО. Прежде 12 примитивов рисовались всегда, и `docs/FURNITURE.md` это обещал; обещание снято. Это единственное место, где задача ухудшает наблюдаемое поведение, и оно проверяется смоком, а не словом. Бюджеты. Ревью ТЗ показало, что «бюджет ленивого графа защищён» было заявлением без гейта: размеры печатались в отчёт и ни с чем не сравнивались. Добавлены `lazyGraphCeilingViolation` и два настоящих потолка со своим мутантом. Стартовый граф ушёл ниже полосы, поэтому `INITIAL_VIEW_GZIP_CEILING` опущен 292 400 → 291 400 — вторая половина храповика, без которой выигрыш отыграется. Провенанс. Документ пакета воссоздан под 0.4.0: грант владельца, имя архива и его SHA-256. Это единственная исполнимая защита от повторения истории #159, и она не ослаблена — число проверок в тесте не уменьшилось, а мутант подменяет хеш и обязан её покраснить. Свидетели: пять новых мутантов, новая golden-сцена `furniture-new-symbols-light` для четырёх новых ID, матрица 63 → 64. Шестой мутант правится, а не добавляется: `furniture-placement-needs-art` ссылался на удалённое поле `g` и переставал компилироваться — раннер честно отличал это от «пойман» и отчитывался setup-failure. Ложь мутанта осталась прежней по смыслу, убрана только ветка про примитив. i18n: 7 ключей × 4 локали. `furn.cat_exercise` не добавлен — категория скрыта. Issue: #593 User-Visible: yes
209 lines
12 KiB
JavaScript
209 lines
12 KiB
JavaScript
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';
|
|
|
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const PACK = path.join(ROOT, 'assets', 'furniture', 'houseplan-0.4.0');
|
|
// Deliberately not named *manifest.json: HACS rejects a repository that
|
|
// contains any second file matching that suffix outside the integration.
|
|
const MANIFEST = path.join(PACK, 'pack.json');
|
|
// #474: каталог (id, группа, категория, размеры) — в стартовом графе, арт
|
|
// (SVG-пути, ~10 КБ gzip) — отдельный модуль, который View грузит лениво.
|
|
const CATALOG_OUT = path.join(ROOT, 'src', 'furniture-plan-catalog.generated.ts');
|
|
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) => {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) fail(`${label} must be an object`);
|
|
};
|
|
const exactKeys = (value, required, optional, label) => {
|
|
plainObject(value, label);
|
|
for (const key of required) if (!own(value, key)) fail(`${label}.${key} is required`);
|
|
const allowed = new Set([...required, ...optional]);
|
|
for (const key of Object.keys(value)) if (!allowed.has(key)) fail(`${label}.${key} is not allowed`);
|
|
};
|
|
const id = (value, label) => {
|
|
if (typeof value !== 'string' || !/^[a-z][a-z0-9_]*$/.test(value)) fail(`${label} is not a stable id`);
|
|
return value;
|
|
};
|
|
const text = (value, label) => {
|
|
if (typeof value !== 'string' || !value.trim()) fail(`${label} must be non-empty text`);
|
|
return value;
|
|
};
|
|
const positive = (value, label) => {
|
|
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) fail(`${label} must be positive`);
|
|
return value;
|
|
};
|
|
const relSvg = (value, prefix, label) => {
|
|
text(value, label);
|
|
if (!new RegExp(`^svg/${prefix}/[a-z][a-z0-9_]*\\.svg$`).test(value)) fail(`${label} has an unsafe path`);
|
|
return value;
|
|
};
|
|
|
|
function parseAttrs(source, label) {
|
|
const attrs = Object.create(null);
|
|
const re = /([:\w-]+)\s*=\s*"([^"]*)"/g;
|
|
let scrubbed = source;
|
|
for (const match of source.matchAll(re)) {
|
|
if (own(attrs, match[1])) fail(`${label} repeats ${match[1]}`);
|
|
attrs[match[1]] = match[2];
|
|
scrubbed = scrubbed.replace(match[0], '');
|
|
}
|
|
if (scrubbed.trim()) fail(`${label} contains malformed attributes`);
|
|
return attrs;
|
|
}
|
|
|
|
function svgArt(file, expectedViewBox) {
|
|
const label = path.relative(ROOT, file).replaceAll('\\', '/');
|
|
const source = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '').trim();
|
|
if (/<!DOCTYPE|<!ENTITY|<\?|<!--|<script|<style/i.test(source)) fail(`${label} contains active XML`);
|
|
const tags = [...source.matchAll(/<\/?([A-Za-z][\w:-]*)([^>]*)>/g)];
|
|
if (!tags.length || tags[0][1] !== 'svg' || !source.endsWith('</svg>')) fail(`${label} is not one SVG root`);
|
|
for (const tag of tags) if (!['svg', 'g', 'path'].includes(tag[1])) fail(`${label} contains <${tag[1]}>`);
|
|
|
|
const root = source.match(/^<svg\s+([^>]*)>/);
|
|
if (!root) fail(`${label} has no SVG root attributes`);
|
|
const svgAttrs = parseAttrs(root[1], `${label} <svg>`);
|
|
const svgKeys = Object.keys(svgAttrs).sort().join(',');
|
|
if (svgKeys !== 'viewBox,xmlns' || svgAttrs.xmlns !== 'http://www.w3.org/2000/svg') fail(`${label} has unsupported SVG attributes`);
|
|
const view = svgAttrs.viewBox.trim().split(/\s+/).map(Number);
|
|
if (view.length !== 4 || view.some((n) => !Number.isFinite(n)) || view[0] !== 0 || view[1] !== 0 || view[2] <= 0 || view[3] <= 0) {
|
|
fail(`${label} has an invalid viewBox`);
|
|
}
|
|
if (expectedViewBox && (view[2] !== expectedViewBox[0] || view[3] !== expectedViewBox[1])) {
|
|
fail(`${label} viewBox does not match manifest dimensions`);
|
|
}
|
|
|
|
for (const match of source.matchAll(/<g([^>]*)>/g)) {
|
|
if (match[1].trim()) fail(`${label} <g> attributes are not allowed`);
|
|
}
|
|
const paths = [];
|
|
for (const match of source.matchAll(/<path\s+([^>]*)\/?\s*>/g)) {
|
|
const attrs = parseAttrs(match[1].replace(/\/$/, ''), `${label} <path>`);
|
|
const allowed = new Set(['d', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin']);
|
|
for (const key of Object.keys(attrs)) if (!allowed.has(key) || key.startsWith('on')) fail(`${label} path attribute ${key} is not allowed`);
|
|
if (attrs.fill !== 'none' || attrs.stroke !== 'currentColor') fail(`${label} paths must use fill=none and stroke=currentColor`);
|
|
if (!/^\d+(?:\.\d+)?$/.test(attrs['stroke-width'] || '')) fail(`${label} has an invalid stroke width`);
|
|
if (attrs['stroke-linecap'] !== 'round' || attrs['stroke-linejoin'] !== 'round') fail(`${label} paths must use rounded joins`);
|
|
const d = text(attrs.d, `${label} path.d`).trim().replace(/\s+/g, ' ');
|
|
if (!/^[0-9eE+.,\-\sMmZzLlHhVvCcSsQqTtAa]+$/.test(d)) fail(`${label} path.d contains unsupported data`);
|
|
paths.push(d);
|
|
}
|
|
if (!paths.length) fail(`${label} has no paths`);
|
|
// #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() {
|
|
const manifest = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'));
|
|
exactKeys(manifest,
|
|
['schema_version', 'view_schema', 'pack_id', 'pack_version', 'title_ru', 'title_en', 'author', 'license', 'source_url', 'menu_icons', 'symbols'],
|
|
[], 'manifest');
|
|
if (manifest.schema_version !== 1 || manifest.view_schema !== 2 || manifest.pack_id !== 'houseplan' || manifest.pack_version !== '0.4.0') fail('unsupported manifest identity');
|
|
if (manifest.author !== 'Sergey Matyunin (Matysh)' || manifest.license !== 'MIT') fail('release provenance is incomplete');
|
|
text(manifest.title_ru, 'manifest.title_ru'); text(manifest.title_en, 'manifest.title_en'); text(manifest.source_url, 'manifest.source_url');
|
|
if (!Array.isArray(manifest.menu_icons) || manifest.menu_icons.length !== 33) fail('exactly 33 menu icons are required');
|
|
if (!Array.isArray(manifest.symbols) || manifest.symbols.length !== 60) fail('exactly 60 plan symbols are required');
|
|
|
|
const menuIds = new Set();
|
|
const menu = manifest.menu_icons.map((entry, index) => {
|
|
exactKeys(entry, ['id', 'name_ru', 'name_en', 'group', 'file'], [], `menu_icons[${index}]`);
|
|
const entryId = id(entry.id, `menu_icons[${index}].id`);
|
|
if (menuIds.has(entryId)) fail(`duplicate menu icon ${entryId}`); menuIds.add(entryId);
|
|
if (!['furniture', 'appliance', 'sanitary', 'other'].includes(entry.group)) fail(`menu icon ${entryId} has an invalid group`);
|
|
relSvg(entry.file, 'menu', `menu icon ${entryId}.file`);
|
|
const base = path.basename(entry.file, '.svg');
|
|
if (base !== entryId) fail(`menu icon ${entryId} filename must match its id`);
|
|
return { id: entryId, group: entry.group, nameEn: text(entry.name_en, `${entryId}.name_en`), nameRu: text(entry.name_ru, `${entryId}.name_ru`), art: svgArt(path.join(PACK, entry.file)) };
|
|
});
|
|
|
|
const symbolIds = new Set();
|
|
const symbols = manifest.symbols.map((entry, index) => {
|
|
exactKeys(entry, ['id', 'operation', 'name_ru', 'name_en', 'group', 'width_cm', 'depth_cm', 'back', 'file', 'menu_icon', 'notes'], [], `symbols[${index}]`);
|
|
const entryId = id(entry.id, `symbols[${index}].id`);
|
|
if (symbolIds.has(entryId)) fail(`duplicate plan symbol ${entryId}`); symbolIds.add(entryId);
|
|
if (!['replace', 'add'].includes(entry.operation)) fail(`symbol ${entryId} has an invalid operation`);
|
|
if (!['furniture', 'appliance', 'sanitary', 'other'].includes(entry.group)) fail(`symbol ${entryId} has an invalid group`);
|
|
if (entry.back !== 'top') fail(`symbol ${entryId} must keep BACK at the top`);
|
|
const width = positive(entry.width_cm, `${entryId}.width_cm`);
|
|
const depth = positive(entry.depth_cm, `${entryId}.depth_cm`);
|
|
relSvg(entry.file, 'plan', `symbol ${entryId}.file`);
|
|
if (path.basename(entry.file, '.svg') !== entryId) fail(`symbol ${entryId} filename must match its id`);
|
|
if (!menuIds.has(entry.menu_icon)) fail(`symbol ${entryId} refers to unknown menu icon ${entry.menu_icon}`);
|
|
const menuGroup = menu.find((item) => item.id === entry.menu_icon)?.group;
|
|
if (menuGroup !== entry.group) fail(`symbol ${entryId} and its menu icon disagree on group`);
|
|
return { id: entryId, operation: entry.operation, group: entry.group, category: entry.menu_icon,
|
|
w: width, h: depth, nameEn: text(entry.name_en, `${entryId}.name_en`), nameRu: text(entry.name_ru, `${entryId}.name_ru`),
|
|
art: svgArt(path.join(PACK, entry.file), [width, depth]) };
|
|
});
|
|
|
|
for (const folder of ['menu', 'plan']) {
|
|
const declared = new Set((folder === 'menu' ? manifest.menu_icons : manifest.symbols).map((entry) => path.basename(entry.file)));
|
|
const actual = fs.readdirSync(path.join(PACK, 'svg', folder)).filter((file) => file.endsWith('.svg'));
|
|
if (actual.length !== declared.size || actual.some((file) => !declared.has(file))) fail(`svg/${folder} contains undeclared SVG files`);
|
|
}
|
|
return { menu, symbols };
|
|
}
|
|
|
|
const header = `/* Generated by scripts/generate-furniture-assets.mjs. DO NOT EDIT. */\n`;
|
|
function renderCatalog(symbols) {
|
|
const runtime = symbols.map(({ id, group, category, w, h }) => ({ id, group, category, w, h }));
|
|
return header + `export const GENERATED_FURNITURE_CATALOG = ${JSON.stringify(runtime)} as const;\n`;
|
|
}
|
|
function renderPlan(symbols) {
|
|
const runtime = Object.fromEntries(symbols.map(({ id, art }) => [id, art]));
|
|
return header
|
|
+ '// Lazy chunk (#474): imported dynamically by furniture-art-runtime.ts and\n'
|
|
+ '// statically by the editor. The fingerprint token is replaced at build time\n'
|
|
+ '// so a chunk from another build is rejected instead of half-applied.\n'
|
|
+ `export const FURNITURE_ART_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';\n`
|
|
+ `export const GENERATED_FURNITURE_ART: Readonly<Record<string, { d: string; viewW: number; viewH: number }>> = ${JSON.stringify(runtime)};\n`;
|
|
}
|
|
function renderMenu(menu) {
|
|
const runtime = menu.map(({ id, group, art }) => ({ id, group, art }));
|
|
return header + `export const GENERATED_FURNITURE_MENU = ${JSON.stringify(runtime)} as const;\n`;
|
|
}
|
|
function output(file, content) {
|
|
const normalized = content.replaceAll('\\/', '/');
|
|
if (CHECK) {
|
|
if (!fs.existsSync(file) || fs.readFileSync(file, 'utf8') !== normalized) fail(`${path.relative(ROOT, file)} is stale; run npm run furniture:generate`);
|
|
} else {
|
|
fs.writeFileSync(file, normalized, 'utf8');
|
|
}
|
|
}
|
|
|
|
const pack = loadPack();
|
|
output(CATALOG_OUT, renderCatalog(pack.symbols));
|
|
output(PLAN_OUT, renderPlan(pack.symbols));
|
|
output(MENU_OUT, renderMenu(pack.menu));
|
|
console.log(`Furniture pack OK: ${pack.symbols.length} plan symbols, ${pack.menu.length} menu icons${CHECK ? ' (generated files current)' : ''}.`);
|