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

119 lines
5.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Минимальный читатель zip для `.sh3d` (#446).
*
* `.sh3d` — это zip, внутри которого нужен ровно один вход: `Home.xml`.
* Библиотеки не берём (см. `xml.mjs`), поэтому здесь читается центральный
* каталог и распаковывается одна запись: метод 0 (без сжатия) и метод 8
* (deflate). Распаковка берётся из окружения: в браузере
* `DecompressionStream('deflate-raw')`, в Node — `node:zlib`.
*
* Границы недоверенного ввода жёсткие и все на входе: шифрованные записи,
* zip64, слишком большая распакованная запись и подозрительные имена
* отвергаются кодом ошибки, а не исключением из недр.
*/
export class ZipError extends Error {
constructor(code, message) {
super(message || code);
this.code = code;
}
}
const EOCD = 0x06054b50;
const CENTRAL = 0x02014b50;
const LOCAL = 0x04034b50;
const MAX_ENTRY_BYTES = 64 * 1024 * 1024;
const u16 = (view, at) => view.getUint16(at, true);
const u32 = (view, at) => view.getUint32(at, true);
async function inflateRaw(bytes) {
if (typeof DecompressionStream === 'function') {
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
const zlib = await import('node:zlib');
return new Uint8Array(zlib.inflateRawSync(bytes));
}
/** Смещение EOCD: ищем подпись с конца, учитывая возможный комментарий. */
function findEndOfCentralDirectory(view) {
const limit = Math.min(view.byteLength, 0xffff + 22);
for (let back = 22; back <= limit; back++) {
const at = view.byteLength - back;
if (at < 0) break;
if (u32(view, at) === EOCD) return at;
}
throw new ZipError('not_zip', 'Это не zip-архив: не найден конец центрального каталога');
}
/**
* Список записей архива: имя, метод, смещение, размеры.
* Данные не читаются — только каталог, чтобы решение «брать или отказать»
* принималось до распаковки.
*/
export function listZipEntries(source) {
const bytes = source instanceof Uint8Array ? source : new Uint8Array(source);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const eocd = findEndOfCentralDirectory(view);
const count = u16(view, eocd + 10);
let at = u32(view, eocd + 16);
if (at === 0xffffffff || count === 0xffff) {
throw new ZipError('zip64', 'zip64 не поддерживается');
}
const entries = [];
for (let index = 0; index < count; index++) {
if (at + 46 > bytes.byteLength || u32(view, at) !== CENTRAL) {
throw new ZipError('bad_central', 'Повреждён центральный каталог');
}
const flags = u16(view, at + 8);
const nameLength = u16(view, at + 28);
const extraLength = u16(view, at + 30);
const commentLength = u16(view, at + 32);
const name = new TextDecoder(flags & 0x800 ? 'utf-8' : 'utf-8')
.decode(bytes.subarray(at + 46, at + 46 + nameLength));
entries.push({
name,
encrypted: (flags & 0x1) !== 0,
method: u16(view, at + 10),
compressedSize: u32(view, at + 20),
uncompressedSize: u32(view, at + 24),
localOffset: u32(view, at + 42),
});
at += 46 + nameLength + extraLength + commentLength;
}
return entries;
}
/** Распаковать одну запись по имени. */
export async function readZipEntry(source, name, maxBytes = MAX_ENTRY_BYTES) {
const bytes = source instanceof Uint8Array ? source : new Uint8Array(source);
const entry = listZipEntries(bytes).find((item) => item.name === name);
if (!entry) throw new ZipError('entry_missing', `В архиве нет ${name}`);
if (entry.encrypted) throw new ZipError('encrypted', 'Архив защищён паролем');
if (entry.uncompressedSize > maxBytes) {
throw new ZipError('entry_too_large',
`${name}: ${entry.uncompressedSize} Б распакованных — больше предела ${maxBytes} Б`);
}
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const local = entry.localOffset;
if (local + 30 > bytes.byteLength || u32(view, local) !== LOCAL) {
throw new ZipError('bad_local', 'Повреждён локальный заголовок записи');
}
const start = local + 30 + u16(view, local + 26) + u16(view, local + 28);
const raw = bytes.subarray(start, start + entry.compressedSize);
if (entry.method === 0) {
if (raw.byteLength > maxBytes) throw new ZipError('entry_too_large', 'Запись больше предела');
return raw.slice();
}
if (entry.method !== 8) {
throw new ZipError('unsupported_method', `Метод сжатия ${entry.method} не поддерживается`);
}
const out = await inflateRaw(raw);
// Объявленный размер не авторитетен: бомба объявляет мало, отдаёт много.
if (out.byteLength > maxBytes) {
throw new ZipError('entry_too_large', `${name}: распаковано больше предела ${maxBytes} Б`);
}
return out;
}