Files
houseplan-card/scripts/sh3d-convert/cli.mjs
Claude 2695307ef0 feat(tools): конвертер Sweet Home 3D → документ импорта, этап 1
Решение владельца по итогам исследования: из чужих форматов планировок
работать имеет смысл только с .sh3d, и конвертер живёт на сайте, а не в
карточке. Продуктового кода задача не касается вовсе — документ импорта
не подписан и не привязан к инстансу, поэтому сторонний генератор это
легальный сценарий уже сегодня.

Этап 1 — всё, что должно жить в репозитории и проверяться в CI:

- scripts/sh3d-convert/{xml,zip}.mjs — читатели XML и zip без единой
  зависимости, работают и в Node, и в браузере (DecompressionStream либо
  node:zlib). Недоверенный ввод отбивается на входе: DOCTYPE
  пропускается и не загружается, объявления сущностей отвергаются,
  шифрованные записи, zip64 и распаковка сверх предела — отказ с кодом;
- sh3d.mjs — уровни, комнаты, стены, двери и окна в сантиметрах;
  мебель, материалы, свет, камеры не читаются вовсе;
- convert.mjs — маппинг в документ kind=space, plan-only, model 7.

Форма v7 выбрана намеренно и это главное техническое решение задачи.
При v8+ схема требует полный каталог сегментов: wall_ids по числу рёбер,
один-два владельца у каждого сегмента, проекция walls, совпадающая с
сегментами. Всё это на стороне сайта означало бы повторить серверный
алгоритм и разойтись с ним на первом изменении модели. В форме v7 ту же
работу делает commit_wall_segment_model — тот же путь, которым едут
старые бэкапы: сегменты собираются сами, общая граница двух комнат
склеивается в один сегмент с двумя владельцами, проёмы получают хозяина.
Проверено прогоном: v7 → валидный v9.

Второе решение — план строится по комнатам. Стена в нашей модели
существует как ребро контура комнаты, поэтому стены Sweet Home 3D дают
рёбрам только толщину, а уровень без комнат конвертировать нечем: это
отказ с объяснением, а не пустой план.

Геометрия: вершины комнат привязываются к осевым линиям стен (Sweet Home
3D обводит комнаты по внутренним граням, «как есть» получились бы две
параллельные стены вместо общей), затем сваривются с точностью до
сантиметра. Проёмы проецируются на ребро, угол берётся у ребра, длина
обрезается до ребра — серверная привязка допускает 8° и 0.02 шага
решётки, поэтому ни угол, ни центр из файла доверия не заслуживают.

Гейт против дрейфа версий (AC5) — две половины:
- test/sh3d-convert.test.mjs: фикстуры → конвертер → сравнение с
  закоммиченными golden. Правка конвертера без пересборки golden красная;
- tests_backend/test_sh3d_convert.py: golden проверяются настоящими
  CONFIG_SCHEMA и commit_wall_segment_model, плюс кросс-рантаймовый пин
  формул _wall_key и канонизации решётки. Правка модели, не отражённая в
  конвертере, красная — до того, как это увидит пользователь;
- tests_backend/test_ha_sh3d_convert.py: golden проходят настоящий
  create_preview (нужен HA, идёт в Linux CI). Там же отрицательная
  проверка: документ с приватным полем обязан получить отказ.

Свидетели, все проверены отрицательным прогоном: снятое выравнивание
вершин, отключённая сварка, угол проёма из файла, непроецированный
центр, необрезанная длина, снятая обрезка толщины, объявленная модель
v9, разошедшийся порт _wall_key, правка golden руками, поднятая
PLAN_MODEL_VERSION, изменённая серверная формула, изменённая канонизация
— каждая краснит свой тест.

Две фикстуры пришлось усилить именно из-за таких прогонов: углы и центры
проёмов в первой редакции совпадали со стенами случайно, и мутации
проходили молча; появилась и фикстура с общей границей без стены и шумом
в доли сантиметра — иначе сварка вершин не исполнялась ни разу.

Фикстуры синтетические, собраны генератором по опубликованному формату:
настоящих .sh3d в сборке нет и взять их автоматически негде. Проверка на
реальном файле — ручная приёмка владельца, записана в issue.

Гейты: npm test 1867 tests, 1866 pass, 0 fail; pytest без HA 378 passed,
3 skipped.

Этап 2 (страница /convert на houseplan.tech, ru/en) — следующим шагом.

Issue: #446
User-Visible: no
2026-09-04 00:35:21 +03:00

51 lines
2.2 KiB
JavaScript

#!/usr/bin/env node
/**
* CLI конвертера (#446): нужен гейтам и фикстурам, не пользователю.
*
* node scripts/sh3d-convert/cli.mjs <файл.sh3d> [--out <каталог>] [--report]
*
* Пользовательский путь — страница /convert на houseplan.tech, где та же
* конверсия идёт в браузере. Один и тот же код в двух средах: расхождение
* между тем, что проверяет CI, и тем, что получает человек, — то, ради чего
* инструмент вообще живёт в этом репозитории.
*/
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { basename, join } from 'node:path';
import { readSh3d } from './sh3d.mjs';
import { convertHome } from './convert.mjs';
const args = process.argv.slice(2);
const input = args.find((arg) => !arg.startsWith('--'));
const outIndex = args.indexOf('--out');
const outDir = outIndex >= 0 ? args[outIndex + 1] : null;
const now = args.includes('--now') ? args[args.indexOf('--now') + 1] : undefined;
if (!input) {
console.error('использование: cli.mjs <файл.sh3d> [--out <каталог>] [--report] [--now <iso>]');
process.exit(2);
}
try {
const home = await readSh3d(new Uint8Array(readFileSync(input)));
const { documents, report } = convertHome(home, {
now: now || '1970-01-01T00:00:00Z',
toolVersion: 'sh3d-convert 0.1',
});
const stem = basename(input).replace(/\.sh3d$/i, '');
if (outDir) {
mkdirSync(outDir, { recursive: true });
for (const [index, document] of documents.entries()) {
const name = `${stem}.space-${index + 1}.json`;
writeFileSync(join(outDir, name), `${JSON.stringify(document, null, 2)}\n`);
console.log(name);
}
writeFileSync(join(outDir, `${stem}.report.json`), `${JSON.stringify(report, null, 2)}\n`);
console.log(`${stem}.report.json`);
} else {
process.stdout.write(`${JSON.stringify(args.includes('--report') ? report : documents, null, 2)}\n`);
}
} catch (error) {
console.error(`FAILED ${error.code || 'error'}: ${error.message}`);
process.exit(1);
}