mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Решение владельца по итогам исследования: из чужих форматов планировок
работать имеет смысл только с .sh3d, и конвертер живёт на сайте, а не в
карточке. Продуктового кода задача не касается вовсе — документ импорта
не подписан и не привязан к инстансу, поэтому сторонний генератор это
легальный сценарий уже сегодня.
Этап 1 — всё, что должно жить в репозитории и проверяться в CI:
- scripts/sh3d-convert/{xml,zip}.mjs — читатели XML и zip без единой
зависимости, работают и в Node, и в браузере (DecompressionStream либо
node:zlib). Недоверенный ввод отбивается на входе: DOCTYPE
пропускается и не загружается, объявления сущностей отвергаются,
шифрованные записи, zip64 и распаковка сверх предела — отказ с кодом;
- sh3d.mjs — уровни, комнаты, стены, двери и окна в сантиметрах;
мебель, материалы, свет, камеры не читаются вовсе;
- convert.mjs — маппинг в документ kind=space, plan-only, model 7.
Форма v7 выбрана намеренно и это главное техническое решение задачи.
При v8+ схема требует полный каталог сегментов: wall_ids по числу рёбер,
один-два владельца у каждого сегмента, проекция walls, совпадающая с
сегментами. Всё это на стороне сайта означало бы повторить серверный
алгоритм и разойтись с ним на первом изменении модели. В форме v7 ту же
работу делает commit_wall_segment_model — тот же путь, которым едут
старые бэкапы: сегменты собираются сами, общая граница двух комнат
склеивается в один сегмент с двумя владельцами, проёмы получают хозяина.
Проверено прогоном: v7 → валидный v9.
Второе решение — план строится по комнатам. Стена в нашей модели
существует как ребро контура комнаты, поэтому стены Sweet Home 3D дают
рёбрам только толщину, а уровень без комнат конвертировать нечем: это
отказ с объяснением, а не пустой план.
Геометрия: вершины комнат привязываются к осевым линиям стен (Sweet Home
3D обводит комнаты по внутренним граням, «как есть» получились бы две
параллельные стены вместо общей), затем сваривются с точностью до
сантиметра. Проёмы проецируются на ребро, угол берётся у ребра, длина
обрезается до ребра — серверная привязка допускает 8° и 0.02 шага
решётки, поэтому ни угол, ни центр из файла доверия не заслуживают.
Гейт против дрейфа версий (AC5) — две половины:
- test/sh3d-convert.test.mjs: фикстуры → конвертер → сравнение с
закоммиченными golden. Правка конвертера без пересборки golden красная;
- tests_backend/test_sh3d_convert.py: golden проверяются настоящими
CONFIG_SCHEMA и commit_wall_segment_model, плюс кросс-рантаймовый пин
формул _wall_key и канонизации решётки. Правка модели, не отражённая в
конвертере, красная — до того, как это увидит пользователь;
- tests_backend/test_ha_sh3d_convert.py: golden проходят настоящий
create_preview (нужен HA, идёт в Linux CI). Там же отрицательная
проверка: документ с приватным полем обязан получить отказ.
Свидетели, все проверены отрицательным прогоном: снятое выравнивание
вершин, отключённая сварка, угол проёма из файла, непроецированный
центр, необрезанная длина, снятая обрезка толщины, объявленная модель
v9, разошедшийся порт _wall_key, правка golden руками, поднятая
PLAN_MODEL_VERSION, изменённая серверная формула, изменённая канонизация
— каждая краснит свой тест.
Две фикстуры пришлось усилить именно из-за таких прогонов: углы и центры
проёмов в первой редакции совпадали со стенами случайно, и мутации
проходили молча; появилась и фикстура с общей границей без стены и шумом
в доли сантиметра — иначе сварка вершин не исполнялась ни разу.
Фикстуры синтетические, собраны генератором по опубликованному формату:
настоящих .sh3d в сборке нет и взять их автоматически негде. Проверка на
реальном файле — ручная приёмка владельца, записана в issue.
Гейты: npm test 1867 tests, 1866 pass, 0 fail; pytest без HA 378 passed,
3 skipped.
Этап 2 (страница /convert на houseplan.tech, ru/en) — следующим шагом.
Issue: #446
User-Visible: no
181 lines
12 KiB
JavaScript
181 lines
12 KiB
JavaScript
/**
|
|
* #446: конвертер Sweet Home 3D → документ импорта House Plan.
|
|
*
|
|
* Здесь проверяется наша половина цепочки: чтение `.sh3d` и маппинг. Вторая
|
|
* половина — что полученный документ принимают настоящие `CONFIG_SCHEMA` и
|
|
* путь предпросмотра импорта — живёт в `tests_backend/test_sh3d_convert.py`
|
|
* и `tests_backend/test_ha_sh3d_convert.py`. Разрыв между этими половинами и
|
|
* есть тот дрейф версий, из-за которого инструмент на сайте гниёт незаметно.
|
|
*/
|
|
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { readFileSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { readSh3d, parseHomeXml, openingKind } from '../scripts/sh3d-convert/sh3d.mjs';
|
|
import {
|
|
ConvertError, MODEL_VERSION, canonicalizeLattice, convertHome, pickCellCm, wallKey,
|
|
} from '../scripts/sh3d-convert/convert.mjs';
|
|
import { XmlError, parseXml } from '../scripts/sh3d-convert/xml.mjs';
|
|
import { ZipError, readZipEntry } from '../scripts/sh3d-convert/zip.mjs';
|
|
|
|
const NOW = '1970-01-01T00:00:00Z';
|
|
const TOOL = 'sh3d-convert 0.1';
|
|
const bytes = (name) =>
|
|
new Uint8Array(readFileSync(fileURLToPath(
|
|
new URL(`../scripts/sh3d-convert/fixtures/${name}`, import.meta.url))));
|
|
const golden = (name) => JSON.parse(readFileSync(fileURLToPath(
|
|
new URL(`../scripts/sh3d-convert/golden/${name}`, import.meta.url)), 'utf8'));
|
|
const convert = async (name) =>
|
|
convertHome(await readSh3d(bytes(name)), { now: NOW, toolVersion: TOOL });
|
|
|
|
test('#446 квартира из двух комнат: контуры по граням стен становятся общей стеной', async () => {
|
|
// Главное геометрическое решение конвертера. Sweet Home 3D обводит комнаты по
|
|
// внутренним граням, поэтому «как есть» две комнаты дали бы две параллельные
|
|
// стены вместо одной общей, а сервер не смог бы склеить сегмент с двумя
|
|
// владельцами. Вершины привязываются к осевым линиям и сваривются.
|
|
const { documents, report } = await convert('flat-two-rooms.sh3d');
|
|
assert.equal(documents.length, 1);
|
|
const space = documents[0].payload.config.spaces[0];
|
|
assert.deepEqual(space.rooms.map((room) => room.name), ['Кухня', 'Спальня']);
|
|
assert.equal(space.walls.length, 7, 'четыре внешних, одна общая, две половины стены — 7 рёбер');
|
|
const shared = space.rooms[0].poly.filter((point) =>
|
|
space.rooms[1].poly.some((other) => other[0] === point[0] && other[1] === point[1]));
|
|
assert.equal(shared.length, 2, 'общая граница должна совпасть точка-в-точку');
|
|
assert.equal(report.levels[0].notes.find((note) => note.code === 'vertices_snapped').count, 8);
|
|
assert.deepEqual(space.openings.map((opening) => opening.type), ['door', 'window']);
|
|
});
|
|
|
|
test('#446 проём кладётся на ребро, а угол берётся у ребра, не из файла', async () => {
|
|
// Хостинг на сервере допускает 8° расхождения и 0.02 шага решётки по
|
|
// расстоянию. Угол из `.sh3d` в этих единицах доверия не заслуживает,
|
|
// поэтому он вычисляется по ребру, а центр проецируется на него.
|
|
const { documents } = await convert('flat-two-rooms.sh3d');
|
|
const space = documents[0].payload.config.spaces[0];
|
|
const [door, window] = space.openings;
|
|
// В фикстуре у двери объявлен угол 0° на вертикальной стене, у окна — 33° на
|
|
// горизонтальной. Оба значения обязаны быть проигнорированы.
|
|
assert.equal(door.x, 0.5, 'дверь на общей стене x=0.5');
|
|
assert.ok(Math.abs(Math.abs(door.angle) - 90) < 1e-9, `вертикальное ребро → ±90°, а не ${door.angle}`);
|
|
assert.equal(window.angle, 0, 'горизонтальное ребро → 0°');
|
|
assert.ok(door.length > 0 && door.length < 0.34, 'длина в нормализованных единицах');
|
|
// 90 см при клетке 5 см: 90 / (5 * 240) = 0.075
|
|
assert.equal(door.length, 0.075);
|
|
});
|
|
|
|
test('#446 уровни разъезжаются по документам, несжатый zip читается', async () => {
|
|
const { documents } = await convert('two-levels.sh3d');
|
|
assert.equal(documents.length, 2);
|
|
assert.deepEqual(documents.map((doc) => doc.payload.config.spaces[0].title), ['Ground', 'First']);
|
|
assert.equal(documents[0].kind, 'space');
|
|
assert.equal(documents[0].model_version, MODEL_VERSION);
|
|
assert.deepEqual(documents[0].payload.config.markers, []);
|
|
assert.deepEqual(documents[0].payload.layout, {});
|
|
assert.deepEqual(documents[0].placement_manifest, []);
|
|
assert.deepEqual(documents[0].content_manifest, []);
|
|
assert.deepEqual(documents[0].transfer, { plan_only: true });
|
|
});
|
|
|
|
test('#446 кривые случаи попадают в отчёт, а не в тишину', async () => {
|
|
const { documents, report } = await convert('awkward.sh3d');
|
|
assert.equal(documents.length, 1, 'уровень без комнат конвертировать нечем');
|
|
const codes = report.levels[0].notes.map((note) => note.code);
|
|
for (const code of [
|
|
'room_without_polygon', 'curved_wall_straightened', 'thickness_clamped',
|
|
'vertices_snapped', 'edge_without_wall', 'opening_unhosted',
|
|
]) {
|
|
assert.ok(codes.includes(code), `${code} обязан быть в отчёте: ${codes.join(', ')}`);
|
|
}
|
|
assert.ok(report.items.some((item) => item.code === 'level_without_rooms'));
|
|
assert.ok(report.items.some((item) => item.code === 'furniture_dropped'),
|
|
'мебель не читается вовсе — человек обязан узнать это из отчёта, а не из плана');
|
|
const space = documents[0].payload.config.spaces[0];
|
|
assert.ok(space.walls.every((wall) => wall.cm >= 1 && wall.cm <= 100), 'толщина в пределах схемы');
|
|
// Проём шире ребра обрезается до ребра: иначе сервер откажется его привязать,
|
|
// потому что хост требует, чтобы проём лежал в сегменте целиком.
|
|
assert.ok(codes.includes('opening_shortened'), `opening_shortened: ${codes.join(', ')}`);
|
|
const [wide] = space.openings;
|
|
const edge = space.walls.find((wall) => wall.a[1] === wide.y && wall.b[1] === wide.y);
|
|
assert.ok(edge, 'обрезанный проём лежит на горизонтальном ребре');
|
|
const span = Math.abs(edge.b[0] - edge.a[0]);
|
|
assert.ok(Math.abs(wide.length - span) < 1e-9, `длина ${wide.length} обязана равняться ребру ${span}`);
|
|
});
|
|
|
|
test('#446 общая граница без стены сваривается из шумных вершин', async () => {
|
|
// Sweet Home 3D набирают мышью, и общая граница двух зон приезжает как 400.4
|
|
// против 399.7. Без сварки это два сегмента по одному владельцу вместо одного
|
|
// с двумя, то есть двойная стена там, где стены нет вовсе.
|
|
const { documents } = await convert('noisy-shared-edge.sh3d');
|
|
const space = documents[0].payload.config.spaces[0];
|
|
const [left, right] = space.rooms;
|
|
const shared = left.poly.filter((point) =>
|
|
right.poly.some((other) => other[0] === point[0] && other[1] === point[1]));
|
|
assert.equal(shared.length, 2, 'граница обязана совпасть точка-в-точку');
|
|
assert.equal(space.walls.length, 6, 'четыре внешние стены плюс две половины — общей стены нет');
|
|
});
|
|
|
|
test('#446 golden совпадает с прогоном конвертера', async () => {
|
|
// Golden читает питоновский гейт: он проверяет их настоящей схемой. Правка
|
|
// конвертера без пересборки golden — красный тест здесь; правка golden руками
|
|
// — красный тест там.
|
|
for (const [fixture, files] of [
|
|
['flat-two-rooms.sh3d', ['flat-two-rooms.space-1.json']],
|
|
['two-levels.sh3d', ['two-levels.space-1.json', 'two-levels.space-2.json']],
|
|
['noisy-shared-edge.sh3d', ['noisy-shared-edge.space-1.json']],
|
|
['awkward.sh3d', ['awkward.space-1.json']],
|
|
]) {
|
|
const { documents } = await convert(fixture);
|
|
assert.equal(documents.length, files.length, fixture);
|
|
files.forEach((file, index) => {
|
|
assert.deepEqual(documents[index], golden(file),
|
|
`${file} разошёлся с прогоном: пересоберите`
|
|
+ ' node scripts/sh3d-convert/cli.mjs … --out scripts/sh3d-convert/golden');
|
|
});
|
|
}
|
|
});
|
|
|
|
test('#446 отказ вместо догадки', async () => {
|
|
await assert.rejects(() => readSh3d(new Uint8Array([1, 2, 3])),
|
|
(error) => error instanceof ZipError && error.code === 'not_zip');
|
|
const flat = bytes('flat-two-rooms.sh3d');
|
|
await assert.rejects(() => readZipEntry(flat, 'Nope.xml'),
|
|
(error) => error.code === 'entry_missing');
|
|
// XXE и «миллион смешков»: объявления сущностей отвергаются, DTD не грузится.
|
|
assert.throws(() => parseXml('<!DOCTYPE home [<!ENTITY a "b">]><home/>'),
|
|
(error) => error instanceof XmlError && error.code === 'entity_declaration');
|
|
assert.throws(() => parseXml('<home name="&evil;"/>'),
|
|
(error) => error.code === 'entity_reference');
|
|
assert.equal(parseXml("<!DOCTYPE home SYSTEM 'x.dtd'><home name='a'/>").attrs.name, 'a');
|
|
// План без комнат: House Plan строит геометрию по комнатам, и это отказ.
|
|
const wallsOnly = parseHomeXml(
|
|
"<home name='w'><wall id='w1' xStart='0' yStart='0' xEnd='100' yEnd='0' thickness='10'/></home>");
|
|
assert.throws(() => convertHome(wallsOnly, { now: NOW }),
|
|
(error) => error instanceof ConvertError && error.code === 'nothing_to_convert');
|
|
// Слишком сложная комната — предел схемы, а не наш вкус.
|
|
const points = Array.from({ length: 501 }, (_, index) =>
|
|
`<point x='${index}' y='${index % 2 ? 10 : 0}'/>`).join('');
|
|
const huge = parseHomeXml(`<home name='h'><room id='r'>${points}</room></home>`);
|
|
assert.throws(() => convertHome(huge, { now: NOW }),
|
|
(error) => error.code === 'room_too_complex');
|
|
// Не метрические единицы: пересчёт был бы догадкой.
|
|
assert.throws(() => convertHome({ ...wallsOnly, unit: 'inch' }, { now: NOW }),
|
|
(error) => error.code === 'unit_not_metric');
|
|
});
|
|
|
|
test('#446 порты серверных формул совпадают с питоном', () => {
|
|
// Эти же значения проверяются в tests_backend/test_sh3d_convert.py настоящими
|
|
// _wall_key и canonicalize_lattice_coordinate. Расхождение формул означает,
|
|
// что толщина стен не найдётся, — и краснеет один из двух тестов.
|
|
assert.equal(wallKey([0.25, 0.3333333333333333], [0.5, 0.3333333333333333]),
|
|
'0.375000,0.333333@0.0000');
|
|
assert.equal(wallKey([0.5, 0.3333333333333333], [0.5, 0.6666666666666666]),
|
|
'0.500000,0.500000@1.5706');
|
|
assert.equal(canonicalizeLattice(0.2499999), 0.25);
|
|
assert.equal(canonicalizeLattice(0.123456789012), 0.123456789);
|
|
assert.equal(pickCellCm(600), 5);
|
|
assert.equal(pickCellCm(2400), 20);
|
|
assert.equal(openingKind({ catalogId: 'eTeks#fixedWindow85x123', name: '' }), 'window');
|
|
assert.equal(openingKind({ catalogId: 'eTeks#doorFrame', name: 'Дверь' }), 'door');
|
|
assert.equal(openingKind({ catalogId: '', name: 'Окно в сад' }), 'window');
|
|
});
|