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
198 lines
12 KiB
JavaScript
198 lines
12 KiB
JavaScript
#!/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} Б`);
|
|
}
|