mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Решение владельца по итогам исследования: из чужих форматов планировок
работать имеет смысл только с .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
119 lines
5.5 KiB
JavaScript
119 lines
5.5 KiB
JavaScript
/**
|
||
* Минимальный читатель 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;
|
||
}
|