Files
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

164 lines
7.4 KiB
JavaScript

/**
* Минимальный читатель XML для `Home.xml` из `.sh3d` (#446).
*
* Почему свой, а не библиотека. Инструмент собирается без зависимостей: в
* браузере он должен грузиться как обычный модуль, а в Node — исполняться в
* CI без установки чего-либо. `Home.xml` машинно сгенерирован и предсказуем:
* элементы, атрибуты, текст, без namespace'ов.
*
* Безопасность важнее полноты. Файл приходит от постороннего, поэтому:
* `<!DOCTYPE>` пропускается и НЕ разрешается (Sweet Home 3D ссылается на свою
* DTD, и попытка её загрузить — это XXE), объявления сущностей отвергаются,
* из ссылок понимаются только пять предопределённых и числовые. Глубина и
* число узлов ограничены: zip-бомба и «миллион смешков» не должны вешать
* вкладку.
*/
const PREDEFINED = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" };
export class XmlError extends Error {
constructor(code, message) {
super(message || code);
this.code = code;
}
}
/** Раскрытие ссылок: только предопределённые и числовые, всё прочее — отказ. */
export function decodeText(raw) {
return String(raw).replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g, (whole, body) => {
if (body[0] === '#') {
const code = body[1] === 'x' || body[1] === 'X'
? Number.parseInt(body.slice(2), 16)
: Number.parseInt(body.slice(1), 10);
if (!Number.isFinite(code) || code < 0 || code > 0x10ffff) {
throw new XmlError('bad_reference', `Недопустимая ссылка: ${whole}`);
}
return String.fromCodePoint(code);
}
if (body in PREDEFINED) return PREDEFINED[body];
// Своя сущность — это либо DTD, либо расширение: то и другое мы не берём.
throw new XmlError('entity_reference', `Сущность ${whole} не поддерживается`);
});
}
const LIMITS = { maxNodes: 400_000, maxDepth: 64, maxAttrChars: 65_536 };
/**
* Разобрать документ в дерево `{ tag, attrs, children }`.
*
* Текст элементов не нужен ни одному полю `.sh3d`, которое мы читаем, поэтому
* он не собирается — это осознанное сужение, а не забывчивость.
*/
export function parseXml(source, limits = LIMITS) {
const text = String(source);
if (text.includes('<!ENTITY')) {
throw new XmlError('entity_declaration', 'Объявления сущностей запрещены');
}
const root = { tag: '#document', attrs: {}, children: [] };
const stack = [root];
let nodes = 0;
let at = 0;
while (at < text.length) {
const open = text.indexOf('<', at);
if (open < 0) break;
at = open;
if (text.startsWith('<!--', at)) {
const end = text.indexOf('-->', at + 4);
if (end < 0) throw new XmlError('unterminated', 'Незакрытый комментарий');
at = end + 3;
continue;
}
if (text.startsWith('<?', at)) {
const end = text.indexOf('?>', at + 2);
if (end < 0) throw new XmlError('unterminated', 'Незакрытая инструкция');
at = end + 2;
continue;
}
if (text.startsWith('<![CDATA[', at)) {
const end = text.indexOf(']]>', at + 9);
if (end < 0) throw new XmlError('unterminated', 'Незакрытый CDATA');
at = end + 3;
continue;
}
if (text.startsWith('<!DOCTYPE', at)) {
// Пропускаем, ничего не загружая. Внутренний блок [...] допустим только
// без объявлений сущностей — это проверено выше по всему тексту.
let depth = 0;
let cursor = at + 9;
for (; cursor < text.length; cursor++) {
const ch = text[cursor];
if (ch === '[') depth++;
else if (ch === ']') depth--;
else if (ch === '>' && depth <= 0) break;
}
if (cursor >= text.length) throw new XmlError('unterminated', 'Незакрытый DOCTYPE');
at = cursor + 1;
continue;
}
if (text.startsWith('</', at)) {
const end = text.indexOf('>', at + 2);
if (end < 0) throw new XmlError('unterminated', 'Незакрытый закрывающий тег');
const name = text.slice(at + 2, end).trim();
const top = stack[stack.length - 1];
if (stack.length < 2 || top.tag !== name) {
throw new XmlError('mismatched_tag', `Тег ${name} закрыт не там, где открыт`);
}
stack.pop();
at = end + 1;
continue;
}
// Открывающий тег: имя, атрибуты, возможный самозакрывающий слэш.
const end = findTagEnd(text, at);
const body = text.slice(at + 1, end);
const selfClosing = body.endsWith('/');
const inner = selfClosing ? body.slice(0, -1) : body;
const match = /^([^\s/>]+)([\s\S]*)$/.exec(inner);
if (!match) throw new XmlError('bad_tag', 'Не разобран открывающий тег');
if (++nodes > limits.maxNodes) throw new XmlError('too_many_nodes', 'Слишком много узлов');
const node = { tag: match[1], attrs: parseAttrs(match[2], limits), children: [] };
stack[stack.length - 1].children.push(node);
if (!selfClosing) {
stack.push(node);
if (stack.length > limits.maxDepth) throw new XmlError('too_deep', 'Слишком глубоко');
}
at = end + 1;
}
if (stack.length !== 1) throw new XmlError('unterminated', 'Документ закончился внутри элемента');
const element = root.children.find((child) => child.tag[0] !== '#');
if (!element) throw new XmlError('empty', 'В документе нет корневого элемента');
return element;
}
/** Конец тега с учётом `>` внутри значений атрибутов. */
function findTagEnd(text, from) {
let quote = '';
for (let at = from + 1; at < text.length; at++) {
const ch = text[at];
if (quote) {
if (ch === quote) quote = '';
continue;
}
if (ch === '"' || ch === "'") quote = ch;
else if (ch === '>') return at;
}
throw new XmlError('unterminated', 'Незакрытый тег');
}
function parseAttrs(source, limits) {
const attrs = {};
if (source.length > limits.maxAttrChars) {
throw new XmlError('attrs_too_large', 'Атрибуты элемента превышают предел');
}
const re = /([^\s=]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
let match = re.exec(source);
while (match) {
attrs[match[1]] = decodeText(match[3] !== undefined ? match[3] : match[4]);
match = re.exec(source);
}
return attrs;
}
/** Все дочерние элементы с указанным тегом (на один уровень). */
export const childrenOf = (node, tag) =>
(node?.children || []).filter((child) => child.tag === tag);