Files
houseplan-card/scripts/sh3d-convert/page/app.mjs
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

289 lines
15 KiB
JavaScript
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.
/**
* Страница /convert: интерфейс конвертера (#446).
*
* Вся конверсия идёт здесь, в браузере. Ни одного сетевого вызова на этой
* странице нет и быть не должно: план дома — приватные данные, а сама страница
* живёт на хосте, который по нашему описанию расходный и потенциально
* враждебный. Отсутствие `fetch`/`XMLHttpRequest`/`WebSocket`/`sendBeacon`
* проверяется тестом по собранному артефакту, а не обещанием в тексте.
*
* Отчёт показывается ДО скачивания — это требование задачи, а не украшение:
* человек должен узнать про спрямлённую дугу и непривязанную дверь заранее, а
* не искать их потом в плане из сотен объектов.
*/
import { readSh3d } from '../sh3d.mjs';
import { ConvertError, MIN_HOUSEPLAN, convertHome } from '../convert.mjs';
const TOOL_VERSION = 'sh3d-convert 0.1';
const MAX_FILE_BYTES = 64 * 1024 * 1024;
/** Словари вынесены в экспорт: полноту ключей проверяет тест страницы. */
export 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 || ''));
}
}
export 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'));
}