mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
#!/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);
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* Маппинг модели Sweet Home 3D в документ импорта House Plan (#446).
|
||||
*
|
||||
* Форма выхода — «v7»: комнаты с полигонами, стены с толщиной, проёмы по
|
||||
* координатам. Ни сегментов, ни `wall_ids`, ни хостов проёмов конвертер не
|
||||
* строит: их собирает серверный писатель импорта (`commit_wall_segment_model`),
|
||||
* тот же, которым едут старые бэкапы. Проверено прогоном: v7 → валидный v9,
|
||||
* общая граница двух комнат склеивается в один сегмент, дверь получает хозяина
|
||||
* сама. Строить каталог сегментов на стороне сайта означало бы повторить этот
|
||||
* алгоритм и разойтись с ним на первом же изменении модели.
|
||||
*
|
||||
* Главное геометрическое решение: **план строится по комнатам.** Стена в нашей
|
||||
* модели существует как ребро контура комнаты (валидатор требует у сегмента
|
||||
* одного-двух владельцев-комнат), поэтому стены Sweet Home 3D дают рёбрам
|
||||
* только толщину. Уровень, где комнат нет, конвертировать нечем — и это отказ
|
||||
* с объяснением, а не пустой план.
|
||||
*/
|
||||
|
||||
export const FORMAT = 'houseplan-export';
|
||||
export const EXPORT_VERSION = 2;
|
||||
/** Форма документа: структуру из неё собирает сервер (см. шапку). */
|
||||
export const MODEL_VERSION = 7;
|
||||
/** Версия House Plan, на которой проверен путь импорта. */
|
||||
export const MIN_HOUSEPLAN = '1.71';
|
||||
|
||||
/** Сантиметров в клетке. Выбирается из лестницы — «красивый» масштаб читаем. */
|
||||
export const CELL_LADDER = [1, 2, 2.5, 5, 10, 20, 25, 50, 100, 200, 500];
|
||||
/** Доля единичного квадрата под план: остаток — поля. */
|
||||
export const TARGET_EXTENT = 0.86;
|
||||
/** Решётка нашего редактора: 240 клеток на ширину плана. */
|
||||
export const GRID_N = 240;
|
||||
/** Сварка вершин: две точки ближе этого считаются одной (см). */
|
||||
export const WELD_CM = 1;
|
||||
/** Допуск привязки вершины к осевой линии стены сверх её полутолщины (см). */
|
||||
export const SNAP_TOL_CM = 3;
|
||||
/** Допуск поиска хозяина проёма сверх полутолщины стены (см). */
|
||||
export const OPENING_TOL_CM = 8;
|
||||
export const MAX_POLY_POINTS = 500;
|
||||
export const MIN_THICKNESS_CM = 1;
|
||||
export const MAX_THICKNESS_CM = 100;
|
||||
|
||||
export class ConvertError extends Error {
|
||||
constructor(code, message) {
|
||||
super(message || code);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
// --- геометрия в сантиметрах -------------------------------------------------
|
||||
|
||||
const hypot = (dx, dy) => Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
const projectT = (point, a, b) => {
|
||||
const dx = b[0] - a[0];
|
||||
const dy = b[1] - a[1];
|
||||
const denominator = dx * dx + dy * dy;
|
||||
if (denominator <= 1e-18) return 0;
|
||||
return ((point[0] - a[0]) * dx + (point[1] - a[1]) * dy) / denominator;
|
||||
};
|
||||
|
||||
const pointAt = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
||||
|
||||
const distanceToLine = (point, a, b) => {
|
||||
const t = projectT(point, a, b);
|
||||
const foot = pointAt(a, b, t);
|
||||
return hypot(point[0] - foot[0], point[1] - foot[1]);
|
||||
};
|
||||
|
||||
const distanceToSegment = (point, a, b) => {
|
||||
const t = Math.max(0, Math.min(1, projectT(point, a, b)));
|
||||
const foot = pointAt(a, b, t);
|
||||
return hypot(point[0] - foot[0], point[1] - foot[1]);
|
||||
};
|
||||
|
||||
const direction = (a, b) => {
|
||||
const dx = b[0] - a[0];
|
||||
const dy = b[1] - a[1];
|
||||
const length = hypot(dx, dy);
|
||||
return length < 1e-12 ? [1, 0] : [dx / length, dy / length];
|
||||
};
|
||||
|
||||
const lineIntersection = (a1, a2, b1, b2) => {
|
||||
const [dx1, dy1] = [a2[0] - a1[0], a2[1] - a1[1]];
|
||||
const [dx2, dy2] = [b2[0] - b1[0], b2[1] - b1[1]];
|
||||
const denominator = dx1 * dy2 - dy1 * dx2;
|
||||
if (Math.abs(denominator) < 1e-9) return null;
|
||||
const t = ((b1[0] - a1[0]) * dy2 - (b1[1] - a1[1]) * dx2) / denominator;
|
||||
return [a1[0] + dx1 * t, a1[1] + dy1 * t];
|
||||
};
|
||||
|
||||
/** Угол между направлениями без учёта знака, 0…90°. */
|
||||
const angleBetween = (first, second) => {
|
||||
const dot = Math.abs(first[0] * second[0] + first[1] * second[1]);
|
||||
return Math.acos(Math.min(1, dot)) * (180 / Math.PI);
|
||||
};
|
||||
|
||||
// --- порты серверных формул (совпадать обязаны побайтово) --------------------
|
||||
|
||||
const jsRound = (value) => Math.floor(value + 0.5);
|
||||
|
||||
/** `canonicalize_number`: одна стабильная запись с девятью знаками. */
|
||||
export function canonicalizeNumber(value) {
|
||||
if (!Number.isFinite(value)) return value;
|
||||
const sign = Object.is(value, -0) || value < 0 ? -1 : 1;
|
||||
const result = sign * (Math.floor(Math.abs(value) * 1e9 + 0.5) / 1e9);
|
||||
return result === 0 ? 0 : result;
|
||||
}
|
||||
|
||||
/** `canonicalize_lattice_coordinate`: шум у узла решётки сваливается в узел. */
|
||||
export function canonicalizeLattice(value) {
|
||||
if (!Number.isFinite(value)) return value;
|
||||
const scaled = value * GRID_N;
|
||||
const nearest = Math.floor(scaled + 0.5);
|
||||
if (Math.abs(scaled - nearest) < 1e-4) {
|
||||
const result = nearest / GRID_N;
|
||||
return result === 0 ? 0 : result;
|
||||
}
|
||||
return canonicalizeNumber(value);
|
||||
}
|
||||
|
||||
/** `_wall_key`: квантованная середина и угол — так сервер ищет толщину. */
|
||||
export function wallKey(a, b) {
|
||||
const quantize = (value) => (jsRound(value * GRID_N) / GRID_N);
|
||||
const midX = quantize((a[0] + b[0]) / 2);
|
||||
const midY = quantize((a[1] + b[1]) / 2);
|
||||
const dx = b[0] - a[0];
|
||||
const dy = b[1] - a[1];
|
||||
const length = hypot(dx, dy);
|
||||
let [ux, uy] = length < 1e-12 ? [1, 0] : [dx / length, dy / length];
|
||||
if (ux < -1e-12 || (Math.abs(ux) <= 1e-12 && uy < 0)) {
|
||||
ux = -ux;
|
||||
uy = -uy;
|
||||
}
|
||||
let angle = Math.atan2(uy, ux);
|
||||
if (angle < 0) angle += Math.PI;
|
||||
angle = jsRound(angle * 1800) / 1800;
|
||||
return `${midX.toFixed(6)},${midY.toFixed(6)}@${angle.toFixed(4)}`;
|
||||
}
|
||||
|
||||
// --- подготовка одного уровня ------------------------------------------------
|
||||
|
||||
const weld = (value) => Math.round(value / WELD_CM) * WELD_CM;
|
||||
const vertexKey = (point) => `${weld(point[0])},${weld(point[1])}`;
|
||||
|
||||
/** Привязать вершины комнат к осевым линиям стен и сварить совпадающие. */
|
||||
function alignRooms(rooms, walls, report) {
|
||||
const usable = walls.filter((wall) => hypot(wall.b[0] - wall.a[0], wall.b[1] - wall.a[1]) > 1e-6);
|
||||
let snapped = 0;
|
||||
const aligned = rooms.map((room) => ({
|
||||
...room,
|
||||
points: room.points.map((point) => {
|
||||
const near = usable
|
||||
.map((wall) => ({
|
||||
wall,
|
||||
distance: distanceToSegment(point, wall.a, wall.b),
|
||||
reach: (Number.isFinite(wall.thickness) ? wall.thickness : 0) / 2 + SNAP_TOL_CM,
|
||||
}))
|
||||
.filter((item) => item.distance <= item.reach)
|
||||
.sort((first, second) => first.distance - second.distance);
|
||||
if (!near.length) return point;
|
||||
const first = near[0];
|
||||
const crossing = near.find((item) =>
|
||||
angleBetween(direction(item.wall.a, item.wall.b), direction(first.wall.a, first.wall.b)) > 20);
|
||||
const target = crossing
|
||||
? lineIntersection(first.wall.a, first.wall.b, crossing.wall.a, crossing.wall.b)
|
||||
|| pointAt(first.wall.a, first.wall.b, projectT(point, first.wall.a, first.wall.b))
|
||||
: pointAt(first.wall.a, first.wall.b, projectT(point, first.wall.a, first.wall.b));
|
||||
if (hypot(target[0] - point[0], target[1] - point[1]) > 1e-9) snapped += 1;
|
||||
return target;
|
||||
}),
|
||||
}));
|
||||
if (snapped) report.push({ code: 'vertices_snapped', count: snapped });
|
||||
// Сварка: рёбра двух комнат обязаны совпасть точка-в-точку, иначе общая
|
||||
// граница станет двумя параллельными стенами.
|
||||
const welded = new Map();
|
||||
for (const room of aligned) {
|
||||
for (const point of room.points) {
|
||||
const key = vertexKey(point);
|
||||
if (!welded.has(key)) welded.set(key, [weld(point[0]), weld(point[1])]);
|
||||
}
|
||||
}
|
||||
return aligned.map((room) => ({
|
||||
...room,
|
||||
points: room.points.map((point) => welded.get(vertexKey(point))),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Убрать нулевые рёбра и дубли; вернуть null, если полигон рассыпался. */
|
||||
function cleanPolygon(points) {
|
||||
const out = [];
|
||||
for (const point of points) {
|
||||
const previous = out[out.length - 1];
|
||||
if (previous && previous[0] === point[0] && previous[1] === point[1]) continue;
|
||||
out.push(point);
|
||||
}
|
||||
while (out.length > 1) {
|
||||
const first = out[0];
|
||||
const last = out[out.length - 1];
|
||||
if (first[0] === last[0] && first[1] === last[1]) out.pop();
|
||||
else break;
|
||||
}
|
||||
return out.length >= 3 ? out : null;
|
||||
}
|
||||
|
||||
const edgesOf = (points) =>
|
||||
points.map((point, index) => [point, points[(index + 1) % points.length]]);
|
||||
|
||||
const spanKey = (a, b) => {
|
||||
const first = `${a[0]},${a[1]}`;
|
||||
const second = `${b[0]},${b[1]}`;
|
||||
return first <= second ? `${first}|${second}` : `${second}|${first}`;
|
||||
};
|
||||
|
||||
/** Толщина ребра: стена, чья осевая линия его накрывает; шире — та, что ближе. */
|
||||
function thicknessFor(edge, walls) {
|
||||
const [a, b] = edge;
|
||||
const length = hypot(b[0] - a[0], b[1] - a[1]);
|
||||
let best = null;
|
||||
for (const wall of walls) {
|
||||
if (!Number.isFinite(wall.thickness) || wall.thickness <= 0) continue;
|
||||
const reach = wall.thickness / 2 + SNAP_TOL_CM;
|
||||
if (distanceToLine(a, wall.a, wall.b) > reach) continue;
|
||||
if (distanceToLine(b, wall.a, wall.b) > reach) continue;
|
||||
const ta = projectT(a, wall.a, wall.b);
|
||||
const tb = projectT(b, wall.a, wall.b);
|
||||
const low = Math.max(0, Math.min(ta, tb));
|
||||
const high = Math.min(1, Math.max(ta, tb));
|
||||
const wallLength = hypot(wall.b[0] - wall.a[0], wall.b[1] - wall.a[1]);
|
||||
const overlap = Math.max(0, high - low) * wallLength;
|
||||
if (overlap < length - 1e-6) continue;
|
||||
const score = overlap - distanceToSegment(pointAt(a, b, 0.5), wall.a, wall.b);
|
||||
if (!best || score > best.score) best = { score, thickness: wall.thickness };
|
||||
}
|
||||
return best ? best.thickness : 0;
|
||||
}
|
||||
|
||||
const clampThickness = (cm) =>
|
||||
Math.min(MAX_THICKNESS_CM, Math.max(MIN_THICKNESS_CM, canonicalizeNumber(cm)));
|
||||
|
||||
/** Масштаб: наименьшая «красивая» клетка, при которой план влезает с полями. */
|
||||
export function pickCellCm(extentCm) {
|
||||
const needed = extentCm / (GRID_N * TARGET_EXTENT);
|
||||
return CELL_LADDER.find((cell) => cell >= needed) ?? CELL_LADDER[CELL_LADDER.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Идентификатор: только ASCII. Схема разрешает любой текст, но id уезжает в
|
||||
* ключи layout, в DOM и в URL предпросмотра, а кириллица там читается плохо.
|
||||
* Имя человека при этом не теряется — оно живёт в `name`/`title`.
|
||||
*/
|
||||
const slug = (raw, fallback) => {
|
||||
const cleaned = String(raw || '').toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24);
|
||||
return cleaned || fallback;
|
||||
};
|
||||
|
||||
// --- собственно конверсия ----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Собрать документы импорта: по одному на уровень.
|
||||
* Возвращает `{ documents, report }`; отказы — исключение `ConvertError`.
|
||||
*/
|
||||
export function convertHome(home, options = {}) {
|
||||
const toolVersion = options.toolVersion || 'sh3d-convert';
|
||||
const createdAt = options.now || new Date().toISOString().replace(/\.\d+Z$/, 'Z');
|
||||
if (home.unit && !['centimeter', 'millimeter', 'meter'].includes(home.unit)) {
|
||||
throw new ConvertError('unit_not_metric',
|
||||
`Единицы плана «${home.unit}» не метрические: пересохраните план в сантиметрах`);
|
||||
}
|
||||
const levels = home.levels.length
|
||||
? [...home.levels].sort((first, second) => first.elevationIndex - second.elevationIndex)
|
||||
: [{ id: null, name: home.name, elevation: 0, elevationIndex: 0 }];
|
||||
const documents = [];
|
||||
const report = { levels: [], items: [], toolVersion, minHouseplan: MIN_HOUSEPLAN };
|
||||
const onLevel = (item, level) =>
|
||||
item.level === null || item.level === undefined || item.level === level.id
|
||||
|| (level.id === null && true);
|
||||
|
||||
for (const [index, level] of levels.entries()) {
|
||||
const notes = [];
|
||||
const rawRooms = home.rooms
|
||||
.filter((room) => onLevel(room, level))
|
||||
.filter((room) => {
|
||||
if (room.points.length >= 3) return true;
|
||||
notes.push({ code: 'room_without_polygon', id: room.id });
|
||||
return false;
|
||||
});
|
||||
const walls = home.walls.filter((wall) => onLevel(wall, level));
|
||||
const curved = walls.filter((wall) => Math.abs(wall.arcExtent) > 1e-6);
|
||||
if (curved.length) notes.push({ code: 'curved_wall_straightened', count: curved.length });
|
||||
const thick = walls.filter((wall) => wall.thickness > MAX_THICKNESS_CM);
|
||||
if (thick.length) {
|
||||
notes.push({ code: 'thickness_clamped', count: thick.length, limit: MAX_THICKNESS_CM });
|
||||
}
|
||||
if (!rawRooms.length) {
|
||||
report.levels.push({
|
||||
id: level.id, title: level.name, rooms: 0, walls: 0, openings: 0,
|
||||
skipped: 'no_rooms', notes,
|
||||
});
|
||||
report.items.push({ code: 'level_without_rooms', id: level.id, title: level.name });
|
||||
continue;
|
||||
}
|
||||
|
||||
const aligned = alignRooms(rawRooms, walls, notes);
|
||||
const polygons = [];
|
||||
for (const room of aligned) {
|
||||
const polygon = cleanPolygon(room.points);
|
||||
if (!polygon) {
|
||||
notes.push({ code: 'room_collapsed', id: room.id });
|
||||
continue;
|
||||
}
|
||||
if (polygon.length > MAX_POLY_POINTS) {
|
||||
throw new ConvertError('room_too_complex',
|
||||
`Комната «${room.name || room.id}»: ${polygon.length} вершин, предел ${MAX_POLY_POINTS}`);
|
||||
}
|
||||
polygons.push({ ...room, points: polygon });
|
||||
}
|
||||
if (!polygons.length) {
|
||||
report.levels.push({
|
||||
id: level.id, title: level.name, rooms: 0, walls: 0, openings: 0,
|
||||
skipped: 'no_rooms', notes,
|
||||
});
|
||||
report.items.push({ code: 'level_without_rooms', id: level.id, title: level.name });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Масштаб и сдвиг: план центрируется в единичном квадрате.
|
||||
const xs = polygons.flatMap((room) => room.points.map((point) => point[0]));
|
||||
const ys = polygons.flatMap((room) => room.points.map((point) => point[1]));
|
||||
const minX = Math.min(...xs);
|
||||
const minY = Math.min(...ys);
|
||||
const width = Math.max(...xs) - minX;
|
||||
const height = Math.max(...ys) - minY;
|
||||
if (!(width > 0 && height > 0)) {
|
||||
throw new ConvertError('degenerate_level',
|
||||
`Уровень «${level.name || level.id}»: у плана нет площади`);
|
||||
}
|
||||
const cellCm = pickCellCm(Math.max(width, height));
|
||||
const unitCm = cellCm * GRID_N;
|
||||
const offsetX = (1 - width / unitCm) / 2;
|
||||
const offsetY = (1 - height / unitCm) / 2;
|
||||
const toNorm = (point) => [
|
||||
canonicalizeLattice((point[0] - minX) / unitCm + offsetX),
|
||||
canonicalizeLattice((point[1] - minY) / unitCm + offsetY),
|
||||
];
|
||||
|
||||
const rooms = polygons.map((room, roomIndex) => ({
|
||||
id: `${slug(room.id, `room-${roomIndex + 1}`)}`,
|
||||
name: room.name || `Room ${roomIndex + 1}`,
|
||||
poly: room.points.map(toNorm),
|
||||
}));
|
||||
|
||||
// Стены: одна запись на уникальное ребро контура. Толщина берётся у стены
|
||||
// Sweet Home 3D, накрывающей это ребро; ребро без стены остаётся открытой
|
||||
// границей (сервер даст сегменту нулевую толщину).
|
||||
const wallByKey = new Map();
|
||||
const spanByKey = new Map();
|
||||
for (const room of polygons) {
|
||||
for (const [a, b] of edgesOf(room.points)) {
|
||||
const key = spanKey(a, b);
|
||||
if (wallByKey.has(key)) continue;
|
||||
const cm = thicknessFor([a, b], walls);
|
||||
spanByKey.set(key, { a, b, cm });
|
||||
if (cm <= 0) continue;
|
||||
const [na, nb] = [toNorm(a), toNorm(b)];
|
||||
wallByKey.set(key, { key: wallKey(na, nb), cm: clampThickness(cm), a: na, b: nb });
|
||||
}
|
||||
}
|
||||
const openBoundaries = [...spanByKey.values()].filter((span) => span.cm <= 0).length;
|
||||
if (openBoundaries) notes.push({ code: 'edge_without_wall', count: openBoundaries });
|
||||
|
||||
// Проёмы: центр проецируется на ребро, угол берётся у ребра. Полагаться на
|
||||
// угол из файла нельзя — в нём иначе заданы единицы, и проверка хоста на
|
||||
// сервере допускает расхождение всего 8°.
|
||||
const openings = [];
|
||||
for (const piece of home.openings.filter((item) => onLevel(item, level))) {
|
||||
const width_cm = Number.isFinite(piece.width) && piece.width > 0 ? piece.width : 0;
|
||||
if (!width_cm) {
|
||||
notes.push({ code: 'opening_without_width', id: piece.id });
|
||||
continue;
|
||||
}
|
||||
let best = null;
|
||||
for (const span of spanByKey.values()) {
|
||||
if (span.cm <= 0) continue;
|
||||
const distance = distanceToSegment([piece.x, piece.y], span.a, span.b);
|
||||
if (distance > span.cm / 2 + OPENING_TOL_CM) continue;
|
||||
if (!best || distance < best.distance) best = { span, distance };
|
||||
}
|
||||
if (!best) {
|
||||
notes.push({ code: 'opening_unhosted', id: piece.id, kind: piece.kind });
|
||||
continue;
|
||||
}
|
||||
const { a, b } = best.span;
|
||||
const spanLength = hypot(b[0] - a[0], b[1] - a[1]);
|
||||
const length_cm = Math.min(width_cm, spanLength);
|
||||
if (length_cm < width_cm - 1e-6) {
|
||||
notes.push({ code: 'opening_shortened', id: piece.id });
|
||||
}
|
||||
const half = length_cm / 2;
|
||||
const raw = projectT([piece.x, piece.y], a, b) * spanLength;
|
||||
const centreAlong = Math.min(spanLength - half, Math.max(half, raw));
|
||||
const centre = pointAt(a, b, spanLength > 0 ? centreAlong / spanLength : 0.5);
|
||||
const [na, nb] = [toNorm(a), toNorm(b)];
|
||||
const [nc] = [toNorm(centre)];
|
||||
const angle = canonicalizeNumber(
|
||||
Math.atan2(nb[1] - na[1], nb[0] - na[0]) * (180 / Math.PI));
|
||||
openings.push({
|
||||
id: `${slug(piece.id, `opening-${openings.length + 1}`)}`,
|
||||
type: piece.kind,
|
||||
x: nc[0],
|
||||
y: nc[1],
|
||||
angle,
|
||||
length: canonicalizeNumber(length_cm / unitCm),
|
||||
});
|
||||
}
|
||||
|
||||
const polyPoints = rooms.flatMap((room) => room.poly);
|
||||
const boxMinX = Math.min(...polyPoints.map((point) => point[0]));
|
||||
const boxMinY = Math.min(...polyPoints.map((point) => point[1]));
|
||||
const boxMaxX = Math.max(...polyPoints.map((point) => point[0]));
|
||||
const boxMaxY = Math.max(...polyPoints.map((point) => point[1]));
|
||||
const margin = 0.02;
|
||||
const space = {
|
||||
id: `sh3d-${index + 1}-${slug(level.name || home.name, `level-${index + 1}`)}`,
|
||||
title: level.name || home.name || `Level ${index + 1}`,
|
||||
cell_cm: cellCm,
|
||||
view_box: [
|
||||
canonicalizeNumber(boxMinX - margin),
|
||||
canonicalizeNumber(boxMinY - margin),
|
||||
canonicalizeNumber(boxMaxX - boxMinX + margin * 2),
|
||||
canonicalizeNumber(boxMaxY - boxMinY + margin * 2),
|
||||
],
|
||||
rooms,
|
||||
...(wallByKey.size ? { walls: [...wallByKey.values()] } : {}),
|
||||
...(openings.length ? { openings } : {}),
|
||||
};
|
||||
documents.push({
|
||||
format: FORMAT,
|
||||
export_version: EXPORT_VERSION,
|
||||
kind: 'space',
|
||||
created_at: createdAt,
|
||||
card_version: toolVersion,
|
||||
integration_version: toolVersion,
|
||||
model_version: MODEL_VERSION,
|
||||
payload: { config: { spaces: [space], markers: [] }, layout: {} },
|
||||
placement_manifest: [],
|
||||
content_manifest: [],
|
||||
transfer: { plan_only: true },
|
||||
});
|
||||
report.levels.push({
|
||||
id: level.id,
|
||||
title: space.title,
|
||||
spaceId: space.id,
|
||||
cellCm: cellCm,
|
||||
rooms: rooms.length,
|
||||
walls: wallByKey.size,
|
||||
openings: openings.length,
|
||||
notes,
|
||||
});
|
||||
}
|
||||
|
||||
if (!documents.length) {
|
||||
throw new ConvertError('nothing_to_convert',
|
||||
'В плане нет ни одной комнаты: House Plan строит геометрию по комнатам, '
|
||||
+ 'нарисуйте их в Sweet Home 3D и сохраните файл заново');
|
||||
}
|
||||
// Мебель и прочее не читается вовсе — но человек должен знать, что её нет.
|
||||
report.items.push({ code: 'furniture_dropped' });
|
||||
return { documents, report };
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"levels": [
|
||||
{
|
||||
"id": "main",
|
||||
"title": "Main",
|
||||
"spaceId": "sh3d-1-main",
|
||||
"cellCm": 2,
|
||||
"rooms": 1,
|
||||
"walls": 3,
|
||||
"openings": 1,
|
||||
"notes": [
|
||||
{
|
||||
"code": "room_without_polygon",
|
||||
"id": "thin"
|
||||
},
|
||||
{
|
||||
"code": "curved_wall_straightened",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"code": "thickness_clamped",
|
||||
"count": 1,
|
||||
"limit": 100
|
||||
},
|
||||
{
|
||||
"code": "vertices_snapped",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"code": "edge_without_wall",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"code": "opening_unhosted",
|
||||
"id": "lost-door",
|
||||
"kind": "door"
|
||||
},
|
||||
{
|
||||
"code": "opening_shortened",
|
||||
"id": "wide-door"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "empty",
|
||||
"title": "Walls only",
|
||||
"rooms": 0,
|
||||
"walls": 0,
|
||||
"openings": 0,
|
||||
"skipped": "no_rooms",
|
||||
"notes": []
|
||||
}
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"code": "level_without_rooms",
|
||||
"id": "empty",
|
||||
"title": "Walls only"
|
||||
},
|
||||
{
|
||||
"code": "furniture_dropped"
|
||||
}
|
||||
],
|
||||
"toolVersion": "sh3d-convert 0.1",
|
||||
"minHouseplan": "1.71"
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"format": "houseplan-export",
|
||||
"export_version": 2,
|
||||
"kind": "space",
|
||||
"created_at": "1970-01-01T00:00:00Z",
|
||||
"card_version": "sh3d-convert 0.1",
|
||||
"integration_version": "sh3d-convert 0.1",
|
||||
"model_version": 7,
|
||||
"payload": {
|
||||
"config": {
|
||||
"spaces": [
|
||||
{
|
||||
"id": "sh3d-1-main",
|
||||
"title": "Main",
|
||||
"cell_cm": 2,
|
||||
"view_box": [
|
||||
0.125833333,
|
||||
0.1675,
|
||||
0.748333333,
|
||||
0.665
|
||||
],
|
||||
"rooms": [
|
||||
{
|
||||
"id": "ok-room",
|
||||
"name": "Комната",
|
||||
"poly": [
|
||||
[
|
||||
0.14583333333333334,
|
||||
0.1875
|
||||
],
|
||||
[
|
||||
0.8541666666666666,
|
||||
0.1875
|
||||
],
|
||||
[
|
||||
0.8541666666666666,
|
||||
0.8125
|
||||
],
|
||||
[
|
||||
0.14583333333333334,
|
||||
0.8125
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{
|
||||
"key": "0.500000,0.187500@0.0000",
|
||||
"cm": 100,
|
||||
"a": [
|
||||
0.14583333333333334,
|
||||
0.1875
|
||||
],
|
||||
"b": [
|
||||
0.8541666666666666,
|
||||
0.1875
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.854167,0.500000@1.5706",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.8541666666666666,
|
||||
0.1875
|
||||
],
|
||||
"b": [
|
||||
0.8541666666666666,
|
||||
0.8125
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.500000,0.812500@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.8541666666666666,
|
||||
0.8125
|
||||
],
|
||||
"b": [
|
||||
0.14583333333333334,
|
||||
0.8125
|
||||
]
|
||||
}
|
||||
],
|
||||
"openings": [
|
||||
{
|
||||
"id": "wide-door",
|
||||
"type": "door",
|
||||
"x": 0.5,
|
||||
"y": 0.8125,
|
||||
"angle": 180,
|
||||
"length": 0.708333333
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"markers": []
|
||||
},
|
||||
"layout": {}
|
||||
},
|
||||
"placement_manifest": [],
|
||||
"content_manifest": [],
|
||||
"transfer": {
|
||||
"plan_only": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"levels": [
|
||||
{
|
||||
"id": "level0",
|
||||
"title": "Первый этаж",
|
||||
"spaceId": "sh3d-1-level-1",
|
||||
"cellCm": 5,
|
||||
"rooms": 2,
|
||||
"walls": 7,
|
||||
"openings": 2,
|
||||
"notes": [
|
||||
{
|
||||
"code": "vertices_snapped",
|
||||
"count": 8
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"code": "furniture_dropped"
|
||||
}
|
||||
],
|
||||
"toolVersion": "sh3d-convert 0.1",
|
||||
"minHouseplan": "1.71"
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
{
|
||||
"format": "houseplan-export",
|
||||
"export_version": 2,
|
||||
"kind": "space",
|
||||
"created_at": "1970-01-01T00:00:00Z",
|
||||
"card_version": "sh3d-convert 0.1",
|
||||
"integration_version": "sh3d-convert 0.1",
|
||||
"model_version": 7,
|
||||
"payload": {
|
||||
"config": {
|
||||
"spaces": [
|
||||
{
|
||||
"id": "sh3d-1-level-1",
|
||||
"title": "Первый этаж",
|
||||
"cell_cm": 5,
|
||||
"view_box": [
|
||||
0.23,
|
||||
0.313333333,
|
||||
0.54,
|
||||
0.373333333
|
||||
],
|
||||
"rooms": [
|
||||
{
|
||||
"id": "room-kitchen",
|
||||
"name": "Кухня",
|
||||
"poly": [
|
||||
[
|
||||
0.25,
|
||||
0.3333333333333333
|
||||
],
|
||||
[
|
||||
0.5,
|
||||
0.3333333333333333
|
||||
],
|
||||
[
|
||||
0.5,
|
||||
0.6666666666666666
|
||||
],
|
||||
[
|
||||
0.25,
|
||||
0.6666666666666666
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "room-bedroom",
|
||||
"name": "Спальня",
|
||||
"poly": [
|
||||
[
|
||||
0.5,
|
||||
0.3333333333333333
|
||||
],
|
||||
[
|
||||
0.75,
|
||||
0.3333333333333333
|
||||
],
|
||||
[
|
||||
0.75,
|
||||
0.6666666666666666
|
||||
],
|
||||
[
|
||||
0.5,
|
||||
0.6666666666666666
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{
|
||||
"key": "0.375000,0.333333@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.25,
|
||||
0.3333333333333333
|
||||
],
|
||||
"b": [
|
||||
0.5,
|
||||
0.3333333333333333
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.500000,0.500000@1.5706",
|
||||
"cm": 10,
|
||||
"a": [
|
||||
0.5,
|
||||
0.3333333333333333
|
||||
],
|
||||
"b": [
|
||||
0.5,
|
||||
0.6666666666666666
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.375000,0.666667@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.5,
|
||||
0.6666666666666666
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.6666666666666666
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.250000,0.500000@1.5706",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.25,
|
||||
0.6666666666666666
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.3333333333333333
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.625000,0.333333@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.5,
|
||||
0.3333333333333333
|
||||
],
|
||||
"b": [
|
||||
0.75,
|
||||
0.3333333333333333
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.750000,0.500000@1.5706",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.75,
|
||||
0.3333333333333333
|
||||
],
|
||||
"b": [
|
||||
0.75,
|
||||
0.6666666666666666
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.625000,0.666667@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.75,
|
||||
0.6666666666666666
|
||||
],
|
||||
"b": [
|
||||
0.5,
|
||||
0.6666666666666666
|
||||
]
|
||||
}
|
||||
],
|
||||
"openings": [
|
||||
{
|
||||
"id": "door-mid",
|
||||
"type": "door",
|
||||
"x": 0.5,
|
||||
"y": 0.5041666666666667,
|
||||
"angle": 90,
|
||||
"length": 0.075
|
||||
},
|
||||
{
|
||||
"id": "window-north",
|
||||
"type": "window",
|
||||
"x": 0.375,
|
||||
"y": 0.3333333333333333,
|
||||
"angle": 0,
|
||||
"length": 0.1
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"markers": []
|
||||
},
|
||||
"layout": {}
|
||||
},
|
||||
"placement_manifest": [],
|
||||
"content_manifest": [],
|
||||
"transfer": {
|
||||
"plan_only": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"levels": [
|
||||
{
|
||||
"id": "flat",
|
||||
"title": "Flat",
|
||||
"spaceId": "sh3d-1-flat",
|
||||
"cellCm": 5,
|
||||
"rooms": 2,
|
||||
"walls": 6,
|
||||
"openings": 0,
|
||||
"notes": [
|
||||
{
|
||||
"code": "vertices_snapped",
|
||||
"count": 8
|
||||
},
|
||||
{
|
||||
"code": "edge_without_wall",
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"code": "furniture_dropped"
|
||||
}
|
||||
],
|
||||
"toolVersion": "sh3d-convert 0.1",
|
||||
"minHouseplan": "1.71"
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"format": "houseplan-export",
|
||||
"export_version": 2,
|
||||
"kind": "space",
|
||||
"created_at": "1970-01-01T00:00:00Z",
|
||||
"card_version": "sh3d-convert 0.1",
|
||||
"integration_version": "sh3d-convert 0.1",
|
||||
"model_version": 7,
|
||||
"payload": {
|
||||
"config": {
|
||||
"spaces": [
|
||||
{
|
||||
"id": "sh3d-1-flat",
|
||||
"title": "Flat",
|
||||
"cell_cm": 5,
|
||||
"view_box": [
|
||||
0.146666667,
|
||||
0.355,
|
||||
0.706666667,
|
||||
0.29
|
||||
],
|
||||
"rooms": [
|
||||
{
|
||||
"id": "zone-left",
|
||||
"name": "Гостиная",
|
||||
"poly": [
|
||||
[
|
||||
0.16666666666666666,
|
||||
0.375
|
||||
],
|
||||
[
|
||||
0.5,
|
||||
0.375
|
||||
],
|
||||
[
|
||||
0.5,
|
||||
0.625
|
||||
],
|
||||
[
|
||||
0.16666666666666666,
|
||||
0.625
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "zone-right",
|
||||
"name": "Столовая",
|
||||
"poly": [
|
||||
[
|
||||
0.5,
|
||||
0.375
|
||||
],
|
||||
[
|
||||
0.8333333333333334,
|
||||
0.375
|
||||
],
|
||||
[
|
||||
0.8333333333333334,
|
||||
0.625
|
||||
],
|
||||
[
|
||||
0.5,
|
||||
0.625
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{
|
||||
"key": "0.333333,0.375000@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.16666666666666666,
|
||||
0.375
|
||||
],
|
||||
"b": [
|
||||
0.5,
|
||||
0.375
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.333333,0.625000@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.5,
|
||||
0.625
|
||||
],
|
||||
"b": [
|
||||
0.16666666666666666,
|
||||
0.625
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.166667,0.500000@1.5706",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.16666666666666666,
|
||||
0.625
|
||||
],
|
||||
"b": [
|
||||
0.16666666666666666,
|
||||
0.375
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.666667,0.375000@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.5,
|
||||
0.375
|
||||
],
|
||||
"b": [
|
||||
0.8333333333333334,
|
||||
0.375
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.833333,0.500000@1.5706",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.8333333333333334,
|
||||
0.375
|
||||
],
|
||||
"b": [
|
||||
0.8333333333333334,
|
||||
0.625
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.666667,0.625000@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.8333333333333334,
|
||||
0.625
|
||||
],
|
||||
"b": [
|
||||
0.5,
|
||||
0.625
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"markers": []
|
||||
},
|
||||
"layout": {}
|
||||
},
|
||||
"placement_manifest": [],
|
||||
"content_manifest": [],
|
||||
"transfer": {
|
||||
"plan_only": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"levels": [
|
||||
{
|
||||
"id": "ground",
|
||||
"title": "Ground",
|
||||
"spaceId": "sh3d-1-ground",
|
||||
"cellCm": 2.5,
|
||||
"rooms": 1,
|
||||
"walls": 4,
|
||||
"openings": 0,
|
||||
"notes": [
|
||||
{
|
||||
"code": "vertices_snapped",
|
||||
"count": 4
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "first",
|
||||
"title": "First",
|
||||
"spaceId": "sh3d-2-first",
|
||||
"cellCm": 2.5,
|
||||
"rooms": 1,
|
||||
"walls": 4,
|
||||
"openings": 0,
|
||||
"notes": [
|
||||
{
|
||||
"code": "vertices_snapped",
|
||||
"count": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"code": "furniture_dropped"
|
||||
}
|
||||
],
|
||||
"toolVersion": "sh3d-convert 0.1",
|
||||
"minHouseplan": "1.71"
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"format": "houseplan-export",
|
||||
"export_version": 2,
|
||||
"kind": "space",
|
||||
"created_at": "1970-01-01T00:00:00Z",
|
||||
"card_version": "sh3d-convert 0.1",
|
||||
"integration_version": "sh3d-convert 0.1",
|
||||
"model_version": 7,
|
||||
"payload": {
|
||||
"config": {
|
||||
"spaces": [
|
||||
{
|
||||
"id": "sh3d-1-ground",
|
||||
"title": "Ground",
|
||||
"cell_cm": 2.5,
|
||||
"view_box": [
|
||||
0.063333333,
|
||||
0.063333333,
|
||||
0.873333333,
|
||||
0.873333333
|
||||
],
|
||||
"rooms": [
|
||||
{
|
||||
"id": "hall",
|
||||
"name": "Hall",
|
||||
"poly": [
|
||||
[
|
||||
0.08333333333333333,
|
||||
0.08333333333333333
|
||||
],
|
||||
[
|
||||
0.9166666666666666,
|
||||
0.08333333333333333
|
||||
],
|
||||
[
|
||||
0.9166666666666666,
|
||||
0.9166666666666666
|
||||
],
|
||||
[
|
||||
0.08333333333333333,
|
||||
0.9166666666666666
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{
|
||||
"key": "0.500000,0.083333@0.0000",
|
||||
"cm": 30,
|
||||
"a": [
|
||||
0.08333333333333333,
|
||||
0.08333333333333333
|
||||
],
|
||||
"b": [
|
||||
0.9166666666666666,
|
||||
0.08333333333333333
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.916667,0.500000@1.5706",
|
||||
"cm": 30,
|
||||
"a": [
|
||||
0.9166666666666666,
|
||||
0.08333333333333333
|
||||
],
|
||||
"b": [
|
||||
0.9166666666666666,
|
||||
0.9166666666666666
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.500000,0.916667@0.0000",
|
||||
"cm": 30,
|
||||
"a": [
|
||||
0.9166666666666666,
|
||||
0.9166666666666666
|
||||
],
|
||||
"b": [
|
||||
0.08333333333333333,
|
||||
0.9166666666666666
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.083333,0.500000@1.5706",
|
||||
"cm": 30,
|
||||
"a": [
|
||||
0.08333333333333333,
|
||||
0.9166666666666666
|
||||
],
|
||||
"b": [
|
||||
0.08333333333333333,
|
||||
0.08333333333333333
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"markers": []
|
||||
},
|
||||
"layout": {}
|
||||
},
|
||||
"placement_manifest": [],
|
||||
"content_manifest": [],
|
||||
"transfer": {
|
||||
"plan_only": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"format": "houseplan-export",
|
||||
"export_version": 2,
|
||||
"kind": "space",
|
||||
"created_at": "1970-01-01T00:00:00Z",
|
||||
"card_version": "sh3d-convert 0.1",
|
||||
"integration_version": "sh3d-convert 0.1",
|
||||
"model_version": 7,
|
||||
"payload": {
|
||||
"config": {
|
||||
"spaces": [
|
||||
{
|
||||
"id": "sh3d-2-first",
|
||||
"title": "First",
|
||||
"cell_cm": 2.5,
|
||||
"view_box": [
|
||||
0.063333333,
|
||||
0.063333333,
|
||||
0.873333333,
|
||||
0.873333333
|
||||
],
|
||||
"rooms": [
|
||||
{
|
||||
"id": "attic",
|
||||
"name": "Attic",
|
||||
"poly": [
|
||||
[
|
||||
0.08333333333333333,
|
||||
0.08333333333333333
|
||||
],
|
||||
[
|
||||
0.9166666666666666,
|
||||
0.08333333333333333
|
||||
],
|
||||
[
|
||||
0.9166666666666666,
|
||||
0.9166666666666666
|
||||
],
|
||||
[
|
||||
0.08333333333333333,
|
||||
0.9166666666666666
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{
|
||||
"key": "0.500000,0.083333@0.0000",
|
||||
"cm": 30,
|
||||
"a": [
|
||||
0.08333333333333333,
|
||||
0.08333333333333333
|
||||
],
|
||||
"b": [
|
||||
0.9166666666666666,
|
||||
0.08333333333333333
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.916667,0.500000@1.5706",
|
||||
"cm": 30,
|
||||
"a": [
|
||||
0.9166666666666666,
|
||||
0.08333333333333333
|
||||
],
|
||||
"b": [
|
||||
0.9166666666666666,
|
||||
0.9166666666666666
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.500000,0.916667@0.0000",
|
||||
"cm": 30,
|
||||
"a": [
|
||||
0.9166666666666666,
|
||||
0.9166666666666666
|
||||
],
|
||||
"b": [
|
||||
0.08333333333333333,
|
||||
0.9166666666666666
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.083333,0.500000@1.5706",
|
||||
"cm": 30,
|
||||
"a": [
|
||||
0.08333333333333333,
|
||||
0.9166666666666666
|
||||
],
|
||||
"b": [
|
||||
0.08333333333333333,
|
||||
0.08333333333333333
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"markers": []
|
||||
},
|
||||
"layout": {}
|
||||
},
|
||||
"placement_manifest": [],
|
||||
"content_manifest": [],
|
||||
"transfer": {
|
||||
"plan_only": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Синтетические фикстуры `.sh3d` (#446).
|
||||
*
|
||||
* Настоящих файлов Sweet Home 3D у сборки нет и быть не может: их негде взять
|
||||
* автоматически. Поэтому фикстуры собираются здесь по опубликованному формату
|
||||
* и коммитятся вместе с генератором — чтобы было видно, что именно проверяется,
|
||||
* и чтобы это можно было пересобрать. Проверка на реальном файле остаётся
|
||||
* ручной приёмкой владельца, и это записано в issue.
|
||||
*
|
||||
* Геометрия нарочно «как у людей»: комнаты Sweet Home 3D обводят по ВНУТРЕННИМ
|
||||
* граням стен, а не по осевым линиям, поэтому фикстура 1 проверяет главное
|
||||
* геометрическое решение конвертера — привязку вершин к осевым линиям и сварку
|
||||
* общей границы двух комнат.
|
||||
*
|
||||
* node scripts/sh3d-convert/make-fixtures.mjs
|
||||
*/
|
||||
import { deflateRawSync, crc32 } from 'node:zlib';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const OUT = join(HERE, 'fixtures');
|
||||
|
||||
const crc = (bytes) => {
|
||||
if (typeof crc32 === 'function') return crc32(bytes) >>> 0;
|
||||
let table = crcTable();
|
||||
let value = 0xffffffff;
|
||||
for (const byte of bytes) value = (value >>> 8) ^ table[(value ^ byte) & 0xff];
|
||||
return (value ^ 0xffffffff) >>> 0;
|
||||
};
|
||||
const crcTable = () => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let index = 0; index < 256; index++) {
|
||||
let value = index;
|
||||
for (let bit = 0; bit < 8; bit++) value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
||||
table[index] = value >>> 0;
|
||||
}
|
||||
return table;
|
||||
};
|
||||
|
||||
/** Минимальный писатель zip: ровно то, что читает `zip.mjs`. */
|
||||
function zip(entries) {
|
||||
const chunks = [];
|
||||
const central = [];
|
||||
let offset = 0;
|
||||
for (const entry of entries) {
|
||||
const name = Buffer.from(entry.name, 'utf8');
|
||||
const raw = Buffer.from(entry.data, 'utf8');
|
||||
const deflated = entry.store ? raw : deflateRawSync(raw);
|
||||
const method = entry.store ? 0 : 8;
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(20, 4);
|
||||
local.writeUInt16LE(0x800, 6); // имена в utf-8
|
||||
local.writeUInt16LE(method, 8);
|
||||
local.writeUInt32LE(crc(raw), 14);
|
||||
local.writeUInt32LE(deflated.length, 18);
|
||||
local.writeUInt32LE(raw.length, 22);
|
||||
local.writeUInt16LE(name.length, 26);
|
||||
chunks.push(local, name, deflated);
|
||||
const header = Buffer.alloc(46);
|
||||
header.writeUInt32LE(0x02014b50, 0);
|
||||
header.writeUInt16LE(20, 4);
|
||||
header.writeUInt16LE(20, 6);
|
||||
header.writeUInt16LE(0x800, 8);
|
||||
header.writeUInt16LE(method, 10);
|
||||
header.writeUInt32LE(crc(raw), 16);
|
||||
header.writeUInt32LE(deflated.length, 20);
|
||||
header.writeUInt32LE(raw.length, 24);
|
||||
header.writeUInt16LE(name.length, 28);
|
||||
header.writeUInt32LE(offset, 42);
|
||||
central.push(header, name);
|
||||
offset += local.length + name.length + deflated.length;
|
||||
}
|
||||
const directory = Buffer.concat(central);
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0);
|
||||
eocd.writeUInt16LE(entries.length, 8);
|
||||
eocd.writeUInt16LE(entries.length, 10);
|
||||
eocd.writeUInt32LE(directory.length, 12);
|
||||
eocd.writeUInt32LE(offset, 16);
|
||||
return Buffer.concat([...chunks, directory, eocd]);
|
||||
}
|
||||
|
||||
const attrs = (record) => Object.entries(record)
|
||||
.filter(([, value]) => value !== undefined && value !== null)
|
||||
.map(([key, value]) => ` ${key}='${String(value).replace(/&/g, '&').replace(/'/g, ''').replace(/</g, '<')}'`)
|
||||
.join('');
|
||||
|
||||
const room = (record, points) =>
|
||||
` <room${attrs(record)}>\n${points.map(([x, y]) => ` <point x='${x}' y='${y}'/>`).join('\n')}\n </room>`;
|
||||
const wall = (record) => ` <wall${attrs(record)}/>`;
|
||||
const doorOrWindow = (record) => ` <doorOrWindow${attrs(record)}/>`;
|
||||
const level = (record) => ` <level${attrs(record)}/>`;
|
||||
|
||||
const home = (record, body) =>
|
||||
`<?xml version='1.0'?>\n<!DOCTYPE home SYSTEM 'http://www.sweethome3d.com/SweetHome3D.dtd'>\n`
|
||||
+ `<home${attrs({ version: 7500, ...record })}>\n${body.join('\n')}\n</home>\n`;
|
||||
|
||||
/** 1. Квартира из двух комнат: контуры по внутренним граням, дверь и окно. */
|
||||
const flat = home({ name: 'Flat', wallHeight: 250 }, [
|
||||
level({ id: 'level0', name: 'Первый этаж', elevation: 0, elevationIndex: 0, height: 250, floorThickness: 12 }),
|
||||
wall({ id: 'w-n', level: 'level0', xStart: 0, yStart: 0, xEnd: 600, yEnd: 0, thickness: 20, height: 250 }),
|
||||
wall({ id: 'w-e', level: 'level0', xStart: 600, yStart: 0, xEnd: 600, yEnd: 400, thickness: 20, height: 250 }),
|
||||
wall({ id: 'w-s', level: 'level0', xStart: 600, yStart: 400, xEnd: 0, yEnd: 400, thickness: 20, height: 250 }),
|
||||
wall({ id: 'w-w', level: 'level0', xStart: 0, yStart: 400, xEnd: 0, yEnd: 0, thickness: 20, height: 250 }),
|
||||
wall({ id: 'w-mid', level: 'level0', xStart: 300, yStart: 0, xEnd: 300, yEnd: 400, thickness: 10, height: 250 }),
|
||||
room({ id: 'room-kitchen', level: 'level0', name: 'Кухня', areaVisible: 'true' },
|
||||
[[10, 10], [295, 10], [295, 390], [10, 390]]),
|
||||
room({ id: 'room-bedroom', level: 'level0', name: 'Спальня', areaVisible: 'true' },
|
||||
[[305, 10], [590, 10], [590, 390], [305, 390]]),
|
||||
// Углы в файле НАМЕРЕННО не совпадают со стенами: дверь на вертикальной
|
||||
// стене объявлена под 0°, окно на горизонтальной — под 33°. Единицы и смысл
|
||||
// угла в `.sh3d` доверия не заслуживают, а серверная привязка допускает 8°.
|
||||
// Конвертер обязан брать угол у ребра — иначе оба проёма не получат хозяина.
|
||||
// Центры тоже НАМЕРЕННО сдвинуты с осевой линии: дверь на 2 см, окно на 1.5.
|
||||
// Так и бывает в жизни — проём тащат мышью вдоль грани стены. Серверная
|
||||
// привязка допускает 0.02 шага решётки (при клетке 5 см это ~0.2 мм),
|
||||
// поэтому центр обязан проецироваться на ребро, а не браться как есть.
|
||||
doorOrWindow({ id: 'door-mid', level: 'level0', catalogId: 'eTeks#doorFrame', name: 'Door',
|
||||
x: 302, y: 205, width: 90, depth: 10, height: 200, angle: 0 }),
|
||||
doorOrWindow({ id: 'window-north', level: 'level0', catalogId: 'eTeks#fixedWindow85x123',
|
||||
name: 'Window', x: 150, y: 1.5, width: 120, depth: 20, height: 120, angle: 33 }),
|
||||
]);
|
||||
|
||||
/** 2. Два уровня по комнате — проверяет разделение на документы. */
|
||||
const house = home({ name: 'House' }, [
|
||||
level({ id: 'ground', name: 'Ground', elevation: 0, elevationIndex: 0 }),
|
||||
level({ id: 'first', name: 'First', elevation: 280, elevationIndex: 1 }),
|
||||
wall({ id: 'g-n', level: 'ground', xStart: 0, yStart: 0, xEnd: 500, yEnd: 0, thickness: 30 }),
|
||||
wall({ id: 'g-e', level: 'ground', xStart: 500, yStart: 0, xEnd: 500, yEnd: 500, thickness: 30 }),
|
||||
wall({ id: 'g-s', level: 'ground', xStart: 500, yStart: 500, xEnd: 0, yEnd: 500, thickness: 30 }),
|
||||
wall({ id: 'g-w', level: 'ground', xStart: 0, yStart: 500, xEnd: 0, yEnd: 0, thickness: 30 }),
|
||||
room({ id: 'hall', level: 'ground', name: 'Hall' }, [[15, 15], [485, 15], [485, 485], [15, 485]]),
|
||||
wall({ id: 'f-n', level: 'first', xStart: 0, yStart: 0, xEnd: 500, yEnd: 0, thickness: 30 }),
|
||||
wall({ id: 'f-e', level: 'first', xStart: 500, yStart: 0, xEnd: 500, yEnd: 500, thickness: 30 }),
|
||||
wall({ id: 'f-s', level: 'first', xStart: 500, yStart: 500, xEnd: 0, yEnd: 500, thickness: 30 }),
|
||||
wall({ id: 'f-w', level: 'first', xStart: 0, yStart: 500, xEnd: 0, yEnd: 0, thickness: 30 }),
|
||||
room({ id: 'attic', level: 'first', name: 'Attic' }, [[15, 15], [485, 15], [485, 485], [15, 485]]),
|
||||
]);
|
||||
|
||||
/** 3. Кривые случаи: дуга, стена 120 см, комната из двух точек, уровень без
|
||||
* комнат, дверь в чистом поле. */
|
||||
const awkward = home({ name: 'Awkward' }, [
|
||||
level({ id: 'main', name: 'Main', elevation: 0, elevationIndex: 0 }),
|
||||
level({ id: 'empty', name: 'Walls only', elevation: 300, elevationIndex: 1 }),
|
||||
wall({ id: 'a-n', level: 'main', xStart: 0, yStart: 0, xEnd: 400, yEnd: 0, thickness: 120 }),
|
||||
wall({ id: 'a-e', level: 'main', xStart: 400, yStart: 0, xEnd: 400, yEnd: 300, thickness: 20, arcExtent: 45 }),
|
||||
wall({ id: 'a-s', level: 'main', xStart: 400, yStart: 300, xEnd: 0, yEnd: 300, thickness: 20 }),
|
||||
wall({ id: 'a-w', level: 'main', xStart: 0, yStart: 300, xEnd: 0, yEnd: 0, thickness: 20 }),
|
||||
room({ id: 'ok-room', level: 'main', name: 'Комната' },
|
||||
[[60, 10], [390, 10], [390, 290], [60, 290]]),
|
||||
room({ id: 'thin', level: 'main', name: 'Полоска' }, [[10, 10], [20, 10]]),
|
||||
doorOrWindow({ id: 'lost-door', level: 'main', catalogId: 'eTeks#door', name: 'Door',
|
||||
x: 5000, y: 5000, width: 80 }),
|
||||
// Проём шире ребра: 9 метров на стене длиной 3.3. Хост допускает только
|
||||
// проём, целиком лежащий внутри сегмента, поэтому длина обрезается до ребра
|
||||
// — с записью в отчёт. Без обрезки проём просто не получил бы хозяина.
|
||||
doorOrWindow({ id: 'wide-door', level: 'main', catalogId: 'eTeks#door', name: 'Door',
|
||||
x: 225, y: 300, width: 900 }),
|
||||
wall({ id: 'e-n', level: 'empty', xStart: 0, yStart: 0, xEnd: 200, yEnd: 0, thickness: 10 }),
|
||||
wall({ id: 'e-e', level: 'empty', xStart: 200, yStart: 0, xEnd: 200, yEnd: 200, thickness: 10 }),
|
||||
]);
|
||||
|
||||
/** 4. Общая граница без стены и с шумом: проверяет сварку вершин. */
|
||||
const noisy = home({ name: 'Noisy' }, [
|
||||
level({ id: 'flat', name: 'Flat', elevation: 0, elevationIndex: 0 }),
|
||||
wall({ id: 'n-n', level: 'flat', xStart: 0, yStart: 0, xEnd: 800, yEnd: 0, thickness: 20 }),
|
||||
wall({ id: 'n-e', level: 'flat', xStart: 800, yStart: 0, xEnd: 800, yEnd: 300, thickness: 20 }),
|
||||
wall({ id: 'n-s', level: 'flat', xStart: 800, yStart: 300, xEnd: 0, yEnd: 300, thickness: 20 }),
|
||||
wall({ id: 'n-w', level: 'flat', xStart: 0, yStart: 300, xEnd: 0, yEnd: 0, thickness: 20 }),
|
||||
// Между зонами стены нет вовсе, а их общая граница набрана мышью: 400.4
|
||||
// против 399.7. Без сварки это две границы в полусантиметре друг от друга,
|
||||
// и сервер получит два сегмента с одним владельцем вместо одного с двумя.
|
||||
room({ id: 'zone-left', level: 'flat', name: 'Гостиная' },
|
||||
[[10, 10], [400.4, 10], [400.4, 290], [10, 290]]),
|
||||
room({ id: 'zone-right', level: 'flat', name: 'Столовая' },
|
||||
[[399.7, 10], [790, 10], [790, 290], [399.7, 290]]),
|
||||
]);
|
||||
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
const files = [
|
||||
['flat-two-rooms.sh3d', zip([{ name: 'Home.xml', data: flat }])],
|
||||
// Метод 0: читатель обязан уметь и несжатые записи.
|
||||
['two-levels.sh3d', zip([{ name: 'Home.xml', data: house, store: true }])],
|
||||
['noisy-shared-edge.sh3d', zip([{ name: 'Home.xml', data: noisy }])],
|
||||
['awkward.sh3d', zip([
|
||||
{ name: 'Home.xml', data: awkward },
|
||||
{ name: 'ContentDigests', data: 'ignored\n' },
|
||||
])],
|
||||
];
|
||||
for (const [name, bytes] of files) {
|
||||
writeFileSync(join(OUT, name), bytes);
|
||||
console.log(`${name}: ${bytes.length} Б`);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Чтение `.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));
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Минимальный читатель 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);
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Минимальный читатель 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;
|
||||
}
|
||||
Reference in New Issue
Block a user