Files
houseplan-card/scripts/sh3d-convert/dist/index.html
T
Claude f6cd99cef4 feat(tools): страница /convert для конвертера Sweet Home 3D, этап 2
Артефакт один — scripts/sh3d-convert/dist/index.html, самодостаточный
файл, как и лендинг: раскладывается копированием, без сборщиков и CDN.
Собирается склейкой тех же модулей, которые проверяют гейты этапа 1;
относительных импортов на странице нет, поэтому она работает из любого
каталога и без сети.

Три обещания страницы проверяются тестами, а не текстом:

1. Ни одного сетевого вызова и ни одной внешней загрузки. Ищутся формы
   ВЫЗОВА, а не имена API: первая редакция теста краснела на собственном
   комментарии, где эти API перечислены как запрещённые. Шрифты
   системные — <link> к шрифтам это внешний запрос при загрузке, а
   обещание «файл не покидает браузер» должно быть буквальным. Заодно
   поэтому на странице нет аналитики.
2. Конвертер внутри страницы даёт тот же результат, что модули
   репозитория: код извлекается из собранного артефакта, прогоняется на
   фикстуре и сравнивается с golden. Склейка, потерявшая модуль,
   краснеет.
3. Собранная страница не отстала от исходников: build-page.mjs --check
   сверяет закоммиченный артефакт с пересборкой.

Плюс полнота переводов: у каждой подписи есть ru и en, словари
совпадают по ключам, у каждого кода отчёта и каждого кода отказа есть
человеческий текст. Иначе пользователь увидит пустую надпись или
`not_zip`.

Отчёт показывается ДО скачивания и по этажам: сколько комнат, стен и
проёмов, какая клетка сетки, и полный список мест, где конвертер принял
решение, — спрямлённая дуга, обрезанная толщина, непривязанный проём,
пропущенный этаж без комнат. Это требование задачи: искать чужие ошибки
в готовом плане из сотен объектов хуже, чем прочитать их заранее.

Гайды (ru/en) получили раздел про импорт из Sweet Home 3D с прямой
записью, что это необязательный ярлык, а не шаг настройки: SCOPE обещает
«no external editors», и обходить это молчанием нельзя.

Свидетели этапа 2, все проверены отрицательным прогоном: добавленный в
страницу fetch, потерянный русский перевод, правка dist руками, склейка
без модуля sh3d.mjs — каждая краснит свой тест.

Гейты: npm test 1871 tests, 1870 pass, 0 fail; pytest без HA 378 passed,
3 skipped.

Осталось владельцу: раскладка на хост (из песочницы SSH недоступен) и
проверка на настоящем файле Sweet Home 3D.

Issue: #446
User-Visible: no
2026-09-04 00:41:40 +03:00

1259 lines
58 KiB
HTML
Raw 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.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sweet Home 3D → House Plan converter</title>
<meta name="description" content="Convert a Sweet Home 3D .sh3d floor plan into a House Plan import document. Runs entirely in your browser: the file is never uploaded.">
<meta name="robots" content="index,follow">
<meta name="theme-color" content="#0b1420">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🏠</text></svg>">
<style>
/* Палитра лендинга. Шрифты — системные: страница не делает ни одного
сетевого запроса, включая шрифтовые, и работает без сети. */
:root{
--bg:#0b1420; --bg-2:#0e1a2b; --panel:rgba(18,32,52,.62); --panel-solid:#101d30;
--line:rgba(148,180,220,.14); --line-strong:rgba(148,180,220,.28);
--text:#e9f1fb; --muted:#93a7bf; --accent:#3ea6ff; --accent-soft:rgba(62,166,255,.14);
--amber:#ffb454; --ok:#3ecf9a; --danger:#ff5d5d; --radius:18px;
--font:"Manrope",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--bg);color:var(--text);line-height:1.55;
padding:32px 20px 64px;display:flex;justify-content:center}
main{width:100%;max-width:860px}
header{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:28px}
.brand{display:flex;align-items:center;gap:10px;color:var(--text);text-decoration:none;font-weight:700}
.langs{display:flex;gap:6px}
.langs button{background:transparent;border:1px solid var(--line);color:var(--muted);
border-radius:999px;padding:4px 12px;font:inherit;font-size:14px;cursor:pointer}
.langs button.on{border-color:var(--accent);color:var(--accent);background:var(--accent-soft)}
h1{font-size:clamp(24px,4vw,34px);line-height:1.2;margin-bottom:12px}
h2{font-size:20px;margin:28px 0 12px}
h3{font-size:17px;margin-bottom:6px}
h4{font-size:14px;color:var(--muted);font-weight:600;margin:12px 0 4px}
p{color:var(--muted)}
p.lead{color:var(--text);font-size:17px;margin-bottom:16px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);
padding:18px 20px;margin:14px 0}
.privacy{border-color:rgba(62,207,154,.35);background:rgba(62,207,154,.08)}
.requires{border-color:rgba(255,180,84,.35);background:rgba(255,180,84,.08)}
#drop{border:1.5px dashed var(--line-strong);border-radius:var(--radius);padding:36px 20px;
text-align:center;background:var(--bg-2);transition:border-color .15s,background .15s}
#drop.over{border-color:var(--accent);background:var(--accent-soft)}
#drop p{margin:6px 0}
.btn{font:inherit;font-weight:600;border-radius:12px;padding:10px 18px;cursor:pointer;
border:1px solid var(--line-strong);background:var(--panel-solid);color:var(--text)}
.btn.primary{background:var(--accent);border-color:transparent;color:#04121f}
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.level{background:var(--panel-solid);border:1px solid var(--line);border-radius:14px;
padding:16px 18px;margin:12px 0}
.stats{color:var(--text);font-size:15px;margin-bottom:12px}
.notes{margin:6px 0 0 18px;color:var(--muted);font-size:14px}
.notes li{margin:4px 0}
.muted{color:var(--muted)}
ol{margin-left:20px;color:var(--muted)}
ol li{margin:6px 0}
#error{border-color:rgba(255,93,93,.4);background:rgba(255,93,93,.08)}
#error-detail{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;
color:var(--muted);margin-top:8px;word-break:break-word}
footer{margin-top:36px;color:var(--muted);font-size:14px}
footer a{color:var(--accent)}
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
</style>
</head>
<body>
<main>
<header>
<a class="brand" href="/">🏠 House Plan</a>
<div class="langs">
<button type="button" data-lang="ru">RU</button>
<button type="button" data-lang="en">EN</button>
</div>
</header>
<h1 data-i18n="page.title">Sweet Home 3D → House Plan converter</h1>
<p class="lead" data-i18n="page.lead"></p>
<div class="card privacy"><p data-i18n="page.privacy"></p></div>
<div class="card requires"><p data-i18n="page.requires"></p></div>
<div id="drop">
<h3 data-i18n="drop.title"></h3>
<p data-i18n="drop.or"></p>
<button class="btn primary" id="pick" type="button" data-i18n="drop.button"></button>
<p class="muted" data-i18n="drop.hint"></p>
<input id="file" type="file" accept=".sh3d,application/zip" hidden>
</div>
<div class="card" id="busy" hidden><p data-i18n="busy"></p></div>
<div class="card" id="error" hidden>
<h3 data-i18n="error.title"></h3>
<p id="error-text"></p>
<p id="error-detail" hidden></p>
<p><button class="btn" id="retry" type="button" data-i18n="error.retry"></button></p>
</div>
<section id="result" hidden>
<h2 data-i18n="result.title"></h2>
<div id="levels"></div>
<div id="shared-notes"></div>
<h2 data-i18n="howto.title"></h2>
<ol>
<li data-i18n="howto.1"></li>
<li data-i18n="howto.2"></li>
<li data-i18n="howto.3"></li>
</ol>
</section>
<footer>
<a href="/" data-i18n="back">Back to the site</a> ·
<a href="https://github.com/Matysh/houseplan-card">GitHub</a>
</footer>
</main>
<script type="module">
// --- xml.mjs ---
/**
* Минимальный читатель XML для `Home.xml` из `.sh3d` (#446).
*
* Почему свой, а не библиотека. Инструмент собирается без зависимостей: в
* браузере он должен грузиться как обычный модуль, а в Node — исполняться в
* CI без установки чего-либо. `Home.xml` машинно сгенерирован и предсказуем:
* элементы, атрибуты, текст, без namespace'ов.
*
* Безопасность важнее полноты. Файл приходит от постороннего, поэтому:
* `<!DOCTYPE>` пропускается и НЕ разрешается (Sweet Home 3D ссылается на свою
* DTD, и попытка её загрузить — это XXE), объявления сущностей отвергаются,
* из ссылок понимаются только пять предопределённых и числовые. Глубина и
* число узлов ограничены: zip-бомба и «миллион смешков» не должны вешать
* вкладку.
*/
const PREDEFINED = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" };
class XmlError extends Error {
constructor(code, message) {
super(message || code);
this.code = code;
}
}
/** Раскрытие ссылок: только предопределённые и числовые, всё прочее — отказ. */
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`, которое мы читаем, поэтому
* он не собирается — это осознанное сужение, а не забывчивость.
*/
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;
}
/** Все дочерние элементы с указанным тегом (на один уровень). */
const childrenOf = (node, tag) =>
(node?.children || []).filter((child) => child.tag === tag);
// --- zip.mjs ---
/**
* Минимальный читатель zip для `.sh3d` (#446).
*
* `.sh3d` — это zip, внутри которого нужен ровно один вход: `Home.xml`.
* Библиотеки не берём (см. `xml.mjs`), поэтому здесь читается центральный
* каталог и распаковывается одна запись: метод 0 (без сжатия) и метод 8
* (deflate). Распаковка берётся из окружения: в браузере
* `DecompressionStream('deflate-raw')`, в Node — `node:zlib`.
*
* Границы недоверенного ввода жёсткие и все на входе: шифрованные записи,
* zip64, слишком большая распакованная запись и подозрительные имена
* отвергаются кодом ошибки, а не исключением из недр.
*/
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-архив: не найден конец центрального каталога');
}
/**
* Список записей архива: имя, метод, смещение, размеры.
* Данные не читаются — только каталог, чтобы решение «брать или отказать»
* принималось до распаковки.
*/
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;
}
/** Распаковать одну запись по имени. */
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;
}
// --- convert.mjs ---
/**
* Маппинг модели Sweet Home 3D в документ импорта House Plan (#446).
*
* Форма выхода — «v7»: комнаты с полигонами, стены с толщиной, проёмы по
* координатам. Ни сегментов, ни `wall_ids`, ни хостов проёмов конвертер не
* строит: их собирает серверный писатель импорта (`commit_wall_segment_model`),
* тот же, которым едут старые бэкапы. Проверено прогоном: v7 → валидный v9,
* общая граница двух комнат склеивается в один сегмент, дверь получает хозяина
* сама. Строить каталог сегментов на стороне сайта означало бы повторить этот
* алгоритм и разойтись с ним на первом же изменении модели.
*
* Главное геометрическое решение: **план строится по комнатам.** Стена в нашей
* модели существует как ребро контура комнаты (валидатор требует у сегмента
* одного-двух владельцев-комнат), поэтому стены Sweet Home 3D дают рёбрам
* только толщину. Уровень, где комнат нет, конвертировать нечем — и это отказ
* с объяснением, а не пустой план.
*/
const FORMAT = 'houseplan-export';
const EXPORT_VERSION = 2;
/** Форма документа: структуру из неё собирает сервер (см. шапку). */
const MODEL_VERSION = 7;
/** Версия House Plan, на которой проверен путь импорта. */
const MIN_HOUSEPLAN = '1.71';
/** Сантиметров в клетке. Выбирается из лестницы — «красивый» масштаб читаем. */
const CELL_LADDER = [1, 2, 2.5, 5, 10, 20, 25, 50, 100, 200, 500];
/** Доля единичного квадрата под план: остаток — поля. */
const TARGET_EXTENT = 0.86;
/** Решётка нашего редактора: 240 клеток на ширину плана. */
const GRID_N = 240;
/** Сварка вершин: две точки ближе этого считаются одной (см). */
const WELD_CM = 1;
/** Допуск привязки вершины к осевой линии стены сверх её полутолщины (см). */
const SNAP_TOL_CM = 3;
/** Допуск поиска хозяина проёма сверх полутолщины стены (см). */
const OPENING_TOL_CM = 8;
const MAX_POLY_POINTS = 500;
const MIN_THICKNESS_CM = 1;
const MAX_THICKNESS_CM = 100;
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`: одна стабильная запись с девятью знаками. */
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`: шум у узла решётки сваливается в узел. */
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`: квантованная середина и угол — так сервер ищет толщину. */
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)));
/** Масштаб: наименьшая «красивая» клетка, при которой план влезает с полями. */
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`.
*/
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 };
}
// --- sh3d.mjs ---
/**
* Чтение `.sh3d` (Sweet Home 3D) в плоскую модель в сантиметрах (#446).
*
* Формат: zip, внутри `Home.xml` — тот, что описан их публичной DTD. Читаются
* ровно четыре сущности: уровни, комнаты (полигоны), стены (осевая линия +
* толщина), двери и окна. Всё остальное — мебель, материалы, текстуры, свет,
* камеры, размерные линии, фон уровня — сознательно не читается: см. SCOPE,
* «мы живая пространственная карта, а не редактор интерьера».
*
* Единицы Sweet Home 3D — сантиметры, ось Y вниз, как у нас.
*/
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',
'окно', 'окн',
];
function openingKind(piece) {
const haystack = `${piece.catalogId || ''} ${piece.name || ''}`.toLowerCase();
return WINDOW_HINTS.some((hint) => haystack.includes(hint)) ? 'window' : 'door';
}
/** Разобрать уже распакованный `Home.xml`. */
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` целиком. */
async function readSh3d(bytes) {
const xml = await readZipEntry(bytes, 'Home.xml');
return parseHomeXml(new TextDecoder('utf-8').decode(xml));
}
// --- page/app.mjs ---
/**
* Страница /convert: интерфейс конвертера (#446).
*
* Вся конверсия идёт здесь, в браузере. Ни одного сетевого вызова на этой
* странице нет и быть не должно: план дома — приватные данные, а сама страница
* живёт на хосте, который по нашему описанию расходный и потенциально
* враждебный. Отсутствие `fetch`/`XMLHttpRequest`/`WebSocket`/`sendBeacon`
* проверяется тестом по собранному артефакту, а не обещанием в тексте.
*
* Отчёт показывается ДО скачивания — это требование задачи, а не украшение:
* человек должен узнать про спрямлённую дугу и непривязанную дверь заранее, а
* не искать их потом в плане из сотен объектов.
*/
const TOOL_VERSION = 'sh3d-convert 0.1';
const MAX_FILE_BYTES = 64 * 1024 * 1024;
/** Словари вынесены в экспорт: полноту ключей проверяет тест страницы. */
const TEXT = {
ru: {
'page.title': 'Конвертер Sweet Home 3D → House Plan',
'page.lead': 'Файл плана из Sweet Home 3D превращается в документ импорта House Plan — по одному на этаж.',
'page.privacy': 'Конверсия идёт в вашем браузере. Файл никуда не загружается: страница не делает ни одного сетевого запроса.',
'page.requires': `Требуется House Plan ${MIN_HOUSEPLAN} или новее.`,
'drop.title': 'Перетащите сюда файл .sh3d',
'drop.or': 'или',
'drop.button': 'Выбрать файл',
'drop.hint': 'Ничего не уходит с вашего компьютера',
'busy': 'Читаю файл…',
'result.title': 'Что получилось',
'result.download': 'Скачать этаж',
'result.rooms': 'комнат',
'result.walls': 'стен',
'result.openings': 'проёмов',
'result.cell': 'клетка сетки',
'result.skipped': 'этаж пропущен: комнат нет',
'result.notes': 'На что посмотреть после импорта',
'howto.title': 'Как импортировать',
'howto.1': 'Откройте карточку House Plan → Общие настройки → Резервная копия и перенос.',
'howto.2': 'Выберите импорт и укажите скачанный файл: House Plan покажет предпросмотр и ничего не запишет до подтверждения.',
'howto.3': 'Этаж добавится рядом с существующими пространствами. Привязку комнат к областям Home Assistant и расстановку устройств сделайте в редакторе — в файле их нет.',
'error.title': 'Не получилось',
'error.retry': 'Выбрать другой файл',
'back': 'На главную',
'note.vertices_snapped': 'Вершины комнат выровнены по осевым линиям стен: {count}. Sweet Home 3D обводит комнаты по внутренним граням, иначе общая стена превратилась бы в две.',
'note.curved_wall_straightened': 'Круглых стен спрямлено: {count}. Дуг в House Plan нет.',
'note.thickness_clamped': 'Стен толще {limit} см обрезано до предела: {count}.',
'note.edge_without_wall': 'Границ без стены: {count}. Такая граница останется открытой — стены в файле там не было.',
'note.opening_unhosted': 'Проём не привязался ни к одной стене и не перенесён: {id}.',
'note.opening_shortened': 'Проём шире стены и обрезан до неё: {id}.',
'note.opening_without_width': 'Проём без ширины пропущен: {id}.',
'note.room_without_polygon': 'Комната без контура пропущена: {id}.',
'note.room_collapsed': 'Контур комнаты рассыпался после выравнивания и пропущен: {id}.',
'note.level_without_rooms': 'Этаж «{title}» пропущен: в нём нет ни одной комнаты. House Plan строит геометрию по комнатам — нарисуйте их в Sweet Home 3D.',
'note.furniture_dropped': 'Мебель, материалы, свет и камеры не переносятся: House Plan — живая карта дома, а не редактор интерьера.',
'code.not_zip': 'Это не файл Sweet Home 3D: архив не читается.',
'code.entry_missing': 'В архиве нет Home.xml. Пересохраните план в Sweet Home 3D 5.3 или новее.',
'code.encrypted': 'Архив защищён паролем.',
'code.zip64': 'Такой архив (zip64) не поддерживается.',
'code.entry_too_large': 'Файл слишком большой.',
'code.entity_declaration': 'В файле объявлены XML-сущности — такой файл не читается.',
'code.entity_reference': 'В файле есть неизвестная XML-сущность.',
'code.nothing_to_convert': 'В плане нет ни одной комнаты. House Plan строит геометрию по комнатам: нарисуйте их в Sweet Home 3D и сохраните файл заново.',
'code.room_too_complex': 'Слишком сложный контур комнаты.',
'code.unit_not_metric': 'План сохранён не в метрических единицах.',
'code.file_too_large': 'Файл больше 64 МБ.',
'code.unknown': 'Неизвестная ошибка чтения файла.',
},
en: {
'page.title': 'Sweet Home 3D → House Plan converter',
'page.lead': 'Turns a Sweet Home 3D file into House Plan import documents — one per level.',
'page.privacy': 'The conversion runs in your browser. The file is never uploaded: this page makes no network requests at all.',
'page.requires': `Requires House Plan ${MIN_HOUSEPLAN} or newer.`,
'drop.title': 'Drop a .sh3d file here',
'drop.or': 'or',
'drop.button': 'Choose a file',
'drop.hint': 'Nothing leaves your computer',
'busy': 'Reading the file…',
'result.title': 'What came out',
'result.download': 'Download level',
'result.rooms': 'rooms',
'result.walls': 'walls',
'result.openings': 'openings',
'result.cell': 'grid cell',
'result.skipped': 'level skipped: no rooms',
'result.notes': 'What to check after the import',
'howto.title': 'How to import',
'howto.1': 'Open the House Plan card → General settings → Backup & transfer.',
'howto.2': 'Pick import and choose the downloaded file: House Plan shows a preview and writes nothing until you confirm.',
'howto.3': 'The level is added next to your existing spaces. Bind rooms to Home Assistant areas and place devices in the editor — the file carries neither.',
'error.title': 'That did not work',
'error.retry': 'Choose another file',
'back': 'Back to the site',
'note.vertices_snapped': 'Room vertices aligned to wall centrelines: {count}. Sweet Home 3D traces rooms along inner faces, so without this a shared wall would become two.',
'note.curved_wall_straightened': 'Curved walls straightened: {count}. House Plan has no arcs.',
'note.thickness_clamped': 'Walls thicker than {limit} cm clamped to the limit: {count}.',
'note.edge_without_wall': 'Boundaries without a wall: {count}. They stay open — there was no wall in the file.',
'note.opening_unhosted': 'This opening matched no wall and was skipped: {id}.',
'note.opening_shortened': 'Opening wider than its wall, shortened to fit: {id}.',
'note.opening_without_width': 'Opening without a width, skipped: {id}.',
'note.room_without_polygon': 'Room without an outline, skipped: {id}.',
'note.room_collapsed': 'Room outline collapsed after alignment and was skipped: {id}.',
'note.level_without_rooms': 'Level “{title}” skipped: it has no rooms. House Plan builds geometry from rooms — draw them in Sweet Home 3D.',
'note.furniture_dropped': 'Furniture, materials, lights and cameras are not carried over: House Plan is a live map of the home, not an interior editor.',
'code.not_zip': 'This is not a Sweet Home 3D file: the archive cannot be read.',
'code.entry_missing': 'The archive has no Home.xml. Re-save the plan in Sweet Home 3D 5.3 or newer.',
'code.encrypted': 'The archive is password protected.',
'code.zip64': 'This archive (zip64) is not supported.',
'code.entry_too_large': 'The file is too large.',
'code.entity_declaration': 'The file declares XML entities and cannot be read.',
'code.entity_reference': 'The file contains an unknown XML entity.',
'code.nothing_to_convert': 'The plan has no rooms. House Plan builds geometry from rooms: draw them in Sweet Home 3D and save the file again.',
'code.room_too_complex': 'A room outline is too complex.',
'code.unit_not_metric': 'The plan is not saved in metric units.',
'code.file_too_large': 'The file is larger than 64 MB.',
'code.unknown': 'Unknown error while reading the file.',
},
};
let lang = 'en';
const fill = (template, values) =>
String(template).replace(/\{(\w+)\}/g, (whole, key) =>
(values && values[key] !== undefined ? String(values[key]) : whole));
const t = (key, values) => fill(TEXT[lang][key] ?? TEXT.en[key] ?? key, values);
function applyLanguage(next) {
lang = TEXT[next] ? next : 'en';
document.documentElement.lang = lang;
try {
localStorage.setItem('hp-convert-lang', lang);
} catch {
// Приватный режим: язык просто не запомнится, это не ошибка.
}
for (const node of document.querySelectorAll('[data-i18n]')) {
node.textContent = t(node.dataset.i18n);
}
for (const node of document.querySelectorAll('[data-lang]')) {
node.classList.toggle('on', node.dataset.lang === lang);
}
if (lastResult) renderResult(lastResult);
}
let lastResult = null;
const element = (tag, className, text) => {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
};
const show = (id, visible) => {
document.getElementById(id).hidden = !visible;
};
function noteText(note) {
const key = `note.${note.code}`;
const known = TEXT[lang][key] || TEXT.en[key];
return known ? fill(known, note) : `${note.code}: ${JSON.stringify(note)}`;
}
function download(name, document_) {
const blob = new Blob([`${JSON.stringify(document_, null, 2)}\n`], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const link = element('a');
link.href = url;
link.download = name;
link.click();
// Ссылка живёт ровно до клика: держать её незачем, а память жалко.
setTimeout(() => URL.revokeObjectURL(url), 0);
}
function renderResult(result) {
lastResult = result;
const { documents, report, stem } = result;
const host = document.getElementById('levels');
host.textContent = '';
let downloadIndex = 0;
for (const level of report.levels) {
const card = element('div', 'level');
card.append(element('h3', null, level.title || level.id || '—'));
if (level.skipped) {
card.append(element('p', 'muted', t('result.skipped')));
} else {
const stats = element('p', 'stats');
stats.textContent = [
`${level.rooms} ${t('result.rooms')}`,
`${level.walls} ${t('result.walls')}`,
`${level.openings} ${t('result.openings')}`,
`${t('result.cell')}: ${level.cellCm} cm`,
].join(' · ');
card.append(stats);
const index = downloadIndex++;
const button = element('button', 'btn primary', t('result.download'));
button.addEventListener('click', () =>
download(`${stem}.${level.spaceId}.json`, documents[index]));
card.append(button);
}
const notes = [...(level.notes || [])];
if (notes.length) {
card.append(element('h4', 'notes-title', t('result.notes')));
const list = element('ul', 'notes');
for (const note of notes) list.append(element('li', null, noteText(note)));
card.append(list);
}
host.append(card);
}
const shared = element('ul', 'notes');
for (const item of report.items) shared.append(element('li', null, noteText(item)));
document.getElementById('shared-notes').replaceChildren(shared);
show('result', true);
show('error', false);
show('busy', false);
}
function renderError(code, detail) {
const key = `code.${code}`;
const known = TEXT[lang][key] || TEXT.en[key];
document.getElementById('error-text').textContent = known || t('code.unknown');
const details = document.getElementById('error-detail');
details.textContent = detail && !known ? detail : '';
details.hidden = !details.textContent;
show('error', true);
show('result', false);
show('busy', false);
}
async function handleFile(file) {
if (!file) return;
lastResult = null;
show('busy', true);
show('error', false);
show('result', false);
try {
if (file.size > MAX_FILE_BYTES) {
renderError('file_too_large');
return;
}
const bytes = new Uint8Array(await file.arrayBuffer());
const home = await readSh3d(bytes);
const { documents, report } = convertHome(home, { toolVersion: TOOL_VERSION });
renderResult({
documents,
report,
stem: file.name.replace(/\.sh3d$/i, '') || 'plan',
});
} catch (error) {
const code = error && error.code ? error.code : 'unknown';
renderError(code, error instanceof ConvertError ? error.message : String(error?.message || ''));
}
}
function mount() {
const input = document.getElementById('file');
const zone = document.getElementById('drop');
input.addEventListener('change', () => handleFile(input.files[0]));
document.getElementById('pick').addEventListener('click', () => input.click());
document.getElementById('retry').addEventListener('click', () => input.click());
for (const event of ['dragenter', 'dragover']) {
zone.addEventListener(event, (raw) => {
raw.preventDefault();
zone.classList.add('over');
});
}
for (const event of ['dragleave', 'drop']) {
zone.addEventListener(event, () => zone.classList.remove('over'));
}
zone.addEventListener('drop', (raw) => {
raw.preventDefault();
handleFile(raw.dataTransfer?.files?.[0]);
});
for (const node of document.querySelectorAll('[data-lang]')) {
node.addEventListener('click', () => applyLanguage(node.dataset.lang));
}
let saved = null;
try {
saved = localStorage.getItem('hp-convert-lang');
} catch {
saved = null;
}
applyLanguage(saved || (String(navigator.language || '').toLowerCase().startsWith('ru') ? 'ru' : 'en'));
}
mount();
</script>
</body>
</html>