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
96 lines
4.2 KiB
JavaScript
96 lines
4.2 KiB
JavaScript
/**
|
|
* Чтение `.sh3d` (Sweet Home 3D) в плоскую модель в сантиметрах (#446).
|
|
*
|
|
* Формат: zip, внутри `Home.xml` — тот, что описан их публичной DTD. Читаются
|
|
* ровно четыре сущности: уровни, комнаты (полигоны), стены (осевая линия +
|
|
* толщина), двери и окна. Всё остальное — мебель, материалы, текстуры, свет,
|
|
* камеры, размерные линии, фон уровня — сознательно не читается: см. SCOPE,
|
|
* «мы живая пространственная карта, а не редактор интерьера».
|
|
*
|
|
* Единицы Sweet Home 3D — сантиметры, ось Y вниз, как у нас.
|
|
*/
|
|
import { parseXml, childrenOf } from './xml.mjs';
|
|
import { readZipEntry } from './zip.mjs';
|
|
|
|
export class Sh3dError extends Error {
|
|
constructor(code, message) {
|
|
super(message || code);
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
const num = (raw, fallback = NaN) => {
|
|
if (raw === undefined || raw === null || raw === '') return fallback;
|
|
const value = Number(String(raw).trim());
|
|
return Number.isFinite(value) ? value : fallback;
|
|
};
|
|
|
|
/** Дверь или окно: формат их не различает, различаем по каталогу и имени. */
|
|
const WINDOW_HINTS = [
|
|
'window', 'fenetre', 'fenêtre', 'fenster', 'ventana', 'finestra', 'raam',
|
|
'окно', 'окн',
|
|
];
|
|
export function openingKind(piece) {
|
|
const haystack = `${piece.catalogId || ''} ${piece.name || ''}`.toLowerCase();
|
|
return WINDOW_HINTS.some((hint) => haystack.includes(hint)) ? 'window' : 'door';
|
|
}
|
|
|
|
/** Разобрать уже распакованный `Home.xml`. */
|
|
export function parseHomeXml(xml) {
|
|
const home = parseXml(xml);
|
|
if (home.tag !== 'home') {
|
|
throw new Sh3dError('not_home', 'Home.xml не начинается с элемента <home>');
|
|
}
|
|
const levels = childrenOf(home, 'level').map((node, index) => ({
|
|
id: String(node.attrs.id ?? `level${index}`),
|
|
name: String(node.attrs.name ?? '').trim(),
|
|
elevation: num(node.attrs.elevation, 0),
|
|
elevationIndex: num(node.attrs.elevationIndex, index),
|
|
}));
|
|
const rooms = childrenOf(home, 'room').map((node, index) => ({
|
|
id: String(node.attrs.id ?? `room${index}`),
|
|
level: node.attrs.level === undefined ? null : String(node.attrs.level),
|
|
name: String(node.attrs.name ?? '').trim(),
|
|
points: childrenOf(node, 'point')
|
|
.map((point) => [num(point.attrs.x), num(point.attrs.y)])
|
|
.filter((point) => Number.isFinite(point[0]) && Number.isFinite(point[1])),
|
|
}));
|
|
const walls = childrenOf(home, 'wall').map((node, index) => ({
|
|
id: String(node.attrs.id ?? `wall${index}`),
|
|
level: node.attrs.level === undefined ? null : String(node.attrs.level),
|
|
a: [num(node.attrs.xStart), num(node.attrs.yStart)],
|
|
b: [num(node.attrs.xEnd), num(node.attrs.yEnd)],
|
|
thickness: num(node.attrs.thickness, NaN),
|
|
arcExtent: num(node.attrs.arcExtent, 0),
|
|
})).filter((wall) => wall.a.every(Number.isFinite) && wall.b.every(Number.isFinite));
|
|
const openings = childrenOf(home, 'doorOrWindow').map((node, index) => {
|
|
const piece = {
|
|
id: String(node.attrs.id ?? `piece${index}`),
|
|
level: node.attrs.level === undefined ? null : String(node.attrs.level),
|
|
name: String(node.attrs.name ?? '').trim(),
|
|
catalogId: String(node.attrs.catalogId ?? ''),
|
|
x: num(node.attrs.x),
|
|
y: num(node.attrs.y),
|
|
width: num(node.attrs.width, NaN),
|
|
angle: num(node.attrs.angle, 0),
|
|
};
|
|
return { ...piece, kind: openingKind(piece) };
|
|
}).filter((piece) => Number.isFinite(piece.x) && Number.isFinite(piece.y));
|
|
const unit = String(home.attrs.unit ?? '').trim().toLowerCase();
|
|
return {
|
|
name: String(home.attrs.name ?? '').trim(),
|
|
version: String(home.attrs.version ?? ''),
|
|
unit,
|
|
levels,
|
|
rooms,
|
|
walls,
|
|
openings,
|
|
};
|
|
}
|
|
|
|
/** Прочитать байты `.sh3d` целиком. */
|
|
export async function readSh3d(bytes) {
|
|
const xml = await readZipEntry(bytes, 'Home.xml');
|
|
return parseHomeXml(new TextDecoder('utf-8').decode(xml));
|
|
}
|