#!/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(/ ` \n${points.map(([x, y]) => ` `).join('\n')}\n `; const wall = (record) => ` `; const doorOrWindow = (record) => ` `; const level = (record) => ` `; const home = (record, body) => `\n\n` + `\n${body.join('\n')}\n\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} Б`); }