diff --git a/scripts/sh3d-convert/cli.mjs b/scripts/sh3d-convert/cli.mjs new file mode 100644 index 00000000..7e1b3435 --- /dev/null +++ b/scripts/sh3d-convert/cli.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +/** + * CLI конвертера (#446): нужен гейтам и фикстурам, не пользователю. + * + * node scripts/sh3d-convert/cli.mjs <файл.sh3d> [--out <каталог>] [--report] + * + * Пользовательский путь — страница /convert на houseplan.tech, где та же + * конверсия идёт в браузере. Один и тот же код в двух средах: расхождение + * между тем, что проверяет CI, и тем, что получает человек, — то, ради чего + * инструмент вообще живёт в этом репозитории. + */ +import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'; +import { basename, join } from 'node:path'; +import { readSh3d } from './sh3d.mjs'; +import { convertHome } from './convert.mjs'; + +const args = process.argv.slice(2); +const input = args.find((arg) => !arg.startsWith('--')); +const outIndex = args.indexOf('--out'); +const outDir = outIndex >= 0 ? args[outIndex + 1] : null; +const now = args.includes('--now') ? args[args.indexOf('--now') + 1] : undefined; + +if (!input) { + console.error('использование: cli.mjs <файл.sh3d> [--out <каталог>] [--report] [--now ]'); + process.exit(2); +} + +try { + const home = await readSh3d(new Uint8Array(readFileSync(input))); + const { documents, report } = convertHome(home, { + now: now || '1970-01-01T00:00:00Z', + toolVersion: 'sh3d-convert 0.1', + }); + const stem = basename(input).replace(/\.sh3d$/i, ''); + if (outDir) { + mkdirSync(outDir, { recursive: true }); + for (const [index, document] of documents.entries()) { + const name = `${stem}.space-${index + 1}.json`; + writeFileSync(join(outDir, name), `${JSON.stringify(document, null, 2)}\n`); + console.log(name); + } + writeFileSync(join(outDir, `${stem}.report.json`), `${JSON.stringify(report, null, 2)}\n`); + console.log(`${stem}.report.json`); + } else { + process.stdout.write(`${JSON.stringify(args.includes('--report') ? report : documents, null, 2)}\n`); + } +} catch (error) { + console.error(`FAILED ${error.code || 'error'}: ${error.message}`); + process.exit(1); +} diff --git a/scripts/sh3d-convert/convert.mjs b/scripts/sh3d-convert/convert.mjs new file mode 100644 index 00000000..530e19c5 --- /dev/null +++ b/scripts/sh3d-convert/convert.mjs @@ -0,0 +1,470 @@ +/** + * Маппинг модели Sweet Home 3D в документ импорта House Plan (#446). + * + * Форма выхода — «v7»: комнаты с полигонами, стены с толщиной, проёмы по + * координатам. Ни сегментов, ни `wall_ids`, ни хостов проёмов конвертер не + * строит: их собирает серверный писатель импорта (`commit_wall_segment_model`), + * тот же, которым едут старые бэкапы. Проверено прогоном: v7 → валидный v9, + * общая граница двух комнат склеивается в один сегмент, дверь получает хозяина + * сама. Строить каталог сегментов на стороне сайта означало бы повторить этот + * алгоритм и разойтись с ним на первом же изменении модели. + * + * Главное геометрическое решение: **план строится по комнатам.** Стена в нашей + * модели существует как ребро контура комнаты (валидатор требует у сегмента + * одного-двух владельцев-комнат), поэтому стены Sweet Home 3D дают рёбрам + * только толщину. Уровень, где комнат нет, конвертировать нечем — и это отказ + * с объяснением, а не пустой план. + */ + +export const FORMAT = 'houseplan-export'; +export const EXPORT_VERSION = 2; +/** Форма документа: структуру из неё собирает сервер (см. шапку). */ +export const MODEL_VERSION = 7; +/** Версия House Plan, на которой проверен путь импорта. */ +export const MIN_HOUSEPLAN = '1.71'; + +/** Сантиметров в клетке. Выбирается из лестницы — «красивый» масштаб читаем. */ +export const CELL_LADDER = [1, 2, 2.5, 5, 10, 20, 25, 50, 100, 200, 500]; +/** Доля единичного квадрата под план: остаток — поля. */ +export const TARGET_EXTENT = 0.86; +/** Решётка нашего редактора: 240 клеток на ширину плана. */ +export const GRID_N = 240; +/** Сварка вершин: две точки ближе этого считаются одной (см). */ +export const WELD_CM = 1; +/** Допуск привязки вершины к осевой линии стены сверх её полутолщины (см). */ +export const SNAP_TOL_CM = 3; +/** Допуск поиска хозяина проёма сверх полутолщины стены (см). */ +export const OPENING_TOL_CM = 8; +export const MAX_POLY_POINTS = 500; +export const MIN_THICKNESS_CM = 1; +export const MAX_THICKNESS_CM = 100; + +export 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`: одна стабильная запись с девятью знаками. */ +export 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`: шум у узла решётки сваливается в узел. */ +export 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`: квантованная середина и угол — так сервер ищет толщину. */ +export 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))); + +/** Масштаб: наименьшая «красивая» клетка, при которой план влезает с полями. */ +export 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`. + */ +export 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 }; +} diff --git a/scripts/sh3d-convert/fixtures/awkward.sh3d b/scripts/sh3d-convert/fixtures/awkward.sh3d new file mode 100644 index 00000000..666f70fb Binary files /dev/null and b/scripts/sh3d-convert/fixtures/awkward.sh3d differ diff --git a/scripts/sh3d-convert/fixtures/flat-two-rooms.sh3d b/scripts/sh3d-convert/fixtures/flat-two-rooms.sh3d new file mode 100644 index 00000000..8e605ec7 Binary files /dev/null and b/scripts/sh3d-convert/fixtures/flat-two-rooms.sh3d differ diff --git a/scripts/sh3d-convert/fixtures/noisy-shared-edge.sh3d b/scripts/sh3d-convert/fixtures/noisy-shared-edge.sh3d new file mode 100644 index 00000000..ee42d7ab Binary files /dev/null and b/scripts/sh3d-convert/fixtures/noisy-shared-edge.sh3d differ diff --git a/scripts/sh3d-convert/fixtures/two-levels.sh3d b/scripts/sh3d-convert/fixtures/two-levels.sh3d new file mode 100644 index 00000000..ae787af9 Binary files /dev/null and b/scripts/sh3d-convert/fixtures/two-levels.sh3d differ diff --git a/scripts/sh3d-convert/golden/awkward.report.json b/scripts/sh3d-convert/golden/awkward.report.json new file mode 100644 index 00000000..7a4207ff --- /dev/null +++ b/scripts/sh3d-convert/golden/awkward.report.json @@ -0,0 +1,66 @@ +{ + "levels": [ + { + "id": "main", + "title": "Main", + "spaceId": "sh3d-1-main", + "cellCm": 2, + "rooms": 1, + "walls": 3, + "openings": 1, + "notes": [ + { + "code": "room_without_polygon", + "id": "thin" + }, + { + "code": "curved_wall_straightened", + "count": 1 + }, + { + "code": "thickness_clamped", + "count": 1, + "limit": 100 + }, + { + "code": "vertices_snapped", + "count": 4 + }, + { + "code": "edge_without_wall", + "count": 1 + }, + { + "code": "opening_unhosted", + "id": "lost-door", + "kind": "door" + }, + { + "code": "opening_shortened", + "id": "wide-door" + } + ] + }, + { + "id": "empty", + "title": "Walls only", + "rooms": 0, + "walls": 0, + "openings": 0, + "skipped": "no_rooms", + "notes": [] + } + ], + "items": [ + { + "code": "level_without_rooms", + "id": "empty", + "title": "Walls only" + }, + { + "code": "furniture_dropped" + } + ], + "toolVersion": "sh3d-convert 0.1", + "minHouseplan": "1.71" +} diff --git a/scripts/sh3d-convert/golden/awkward.space-1.json b/scripts/sh3d-convert/golden/awkward.space-1.json new file mode 100644 index 00000000..df1e6862 --- /dev/null +++ b/scripts/sh3d-convert/golden/awkward.space-1.json @@ -0,0 +1,105 @@ +{ + "format": "houseplan-export", + "export_version": 2, + "kind": "space", + "created_at": "1970-01-01T00:00:00Z", + "card_version": "sh3d-convert 0.1", + "integration_version": "sh3d-convert 0.1", + "model_version": 7, + "payload": { + "config": { + "spaces": [ + { + "id": "sh3d-1-main", + "title": "Main", + "cell_cm": 2, + "view_box": [ + 0.125833333, + 0.1675, + 0.748333333, + 0.665 + ], + "rooms": [ + { + "id": "ok-room", + "name": "Комната", + "poly": [ + [ + 0.14583333333333334, + 0.1875 + ], + [ + 0.8541666666666666, + 0.1875 + ], + [ + 0.8541666666666666, + 0.8125 + ], + [ + 0.14583333333333334, + 0.8125 + ] + ] + } + ], + "walls": [ + { + "key": "0.500000,0.187500@0.0000", + "cm": 100, + "a": [ + 0.14583333333333334, + 0.1875 + ], + "b": [ + 0.8541666666666666, + 0.1875 + ] + }, + { + "key": "0.854167,0.500000@1.5706", + "cm": 20, + "a": [ + 0.8541666666666666, + 0.1875 + ], + "b": [ + 0.8541666666666666, + 0.8125 + ] + }, + { + "key": "0.500000,0.812500@0.0000", + "cm": 20, + "a": [ + 0.8541666666666666, + 0.8125 + ], + "b": [ + 0.14583333333333334, + 0.8125 + ] + } + ], + "openings": [ + { + "id": "wide-door", + "type": "door", + "x": 0.5, + "y": 0.8125, + "angle": 180, + "length": 0.708333333 + } + ] + } + ], + "markers": [] + }, + "layout": {} + }, + "placement_manifest": [], + "content_manifest": [], + "transfer": { + "plan_only": true + } +} diff --git a/scripts/sh3d-convert/golden/flat-two-rooms.report.json b/scripts/sh3d-convert/golden/flat-two-rooms.report.json new file mode 100644 index 00000000..52f9a648 --- /dev/null +++ b/scripts/sh3d-convert/golden/flat-two-rooms.report.json @@ -0,0 +1,26 @@ +{ + "levels": [ + { + "id": "level0", + "title": "Первый этаж", + "spaceId": "sh3d-1-level-1", + "cellCm": 5, + "rooms": 2, + "walls": 7, + "openings": 2, + "notes": [ + { + "code": "vertices_snapped", + "count": 8 + } + ] + } + ], + "items": [ + { + "code": "furniture_dropped" + } + ], + "toolVersion": "sh3d-convert 0.1", + "minHouseplan": "1.71" +} diff --git a/scripts/sh3d-convert/golden/flat-two-rooms.space-1.json b/scripts/sh3d-convert/golden/flat-two-rooms.space-1.json new file mode 100644 index 00000000..9c98cf2f --- /dev/null +++ b/scripts/sh3d-convert/golden/flat-two-rooms.space-1.json @@ -0,0 +1,183 @@ +{ + "format": "houseplan-export", + "export_version": 2, + "kind": "space", + "created_at": "1970-01-01T00:00:00Z", + "card_version": "sh3d-convert 0.1", + "integration_version": "sh3d-convert 0.1", + "model_version": 7, + "payload": { + "config": { + "spaces": [ + { + "id": "sh3d-1-level-1", + "title": "Первый этаж", + "cell_cm": 5, + "view_box": [ + 0.23, + 0.313333333, + 0.54, + 0.373333333 + ], + "rooms": [ + { + "id": "room-kitchen", + "name": "Кухня", + "poly": [ + [ + 0.25, + 0.3333333333333333 + ], + [ + 0.5, + 0.3333333333333333 + ], + [ + 0.5, + 0.6666666666666666 + ], + [ + 0.25, + 0.6666666666666666 + ] + ] + }, + { + "id": "room-bedroom", + "name": "Спальня", + "poly": [ + [ + 0.5, + 0.3333333333333333 + ], + [ + 0.75, + 0.3333333333333333 + ], + [ + 0.75, + 0.6666666666666666 + ], + [ + 0.5, + 0.6666666666666666 + ] + ] + } + ], + "walls": [ + { + "key": "0.375000,0.333333@0.0000", + "cm": 20, + "a": [ + 0.25, + 0.3333333333333333 + ], + "b": [ + 0.5, + 0.3333333333333333 + ] + }, + { + "key": "0.500000,0.500000@1.5706", + "cm": 10, + "a": [ + 0.5, + 0.3333333333333333 + ], + "b": [ + 0.5, + 0.6666666666666666 + ] + }, + { + "key": "0.375000,0.666667@0.0000", + "cm": 20, + "a": [ + 0.5, + 0.6666666666666666 + ], + "b": [ + 0.25, + 0.6666666666666666 + ] + }, + { + "key": "0.250000,0.500000@1.5706", + "cm": 20, + "a": [ + 0.25, + 0.6666666666666666 + ], + "b": [ + 0.25, + 0.3333333333333333 + ] + }, + { + "key": "0.625000,0.333333@0.0000", + "cm": 20, + "a": [ + 0.5, + 0.3333333333333333 + ], + "b": [ + 0.75, + 0.3333333333333333 + ] + }, + { + "key": "0.750000,0.500000@1.5706", + "cm": 20, + "a": [ + 0.75, + 0.3333333333333333 + ], + "b": [ + 0.75, + 0.6666666666666666 + ] + }, + { + "key": "0.625000,0.666667@0.0000", + "cm": 20, + "a": [ + 0.75, + 0.6666666666666666 + ], + "b": [ + 0.5, + 0.6666666666666666 + ] + } + ], + "openings": [ + { + "id": "door-mid", + "type": "door", + "x": 0.5, + "y": 0.5041666666666667, + "angle": 90, + "length": 0.075 + }, + { + "id": "window-north", + "type": "window", + "x": 0.375, + "y": 0.3333333333333333, + "angle": 0, + "length": 0.1 + } + ] + } + ], + "markers": [] + }, + "layout": {} + }, + "placement_manifest": [], + "content_manifest": [], + "transfer": { + "plan_only": true + } +} diff --git a/scripts/sh3d-convert/golden/noisy-shared-edge.report.json b/scripts/sh3d-convert/golden/noisy-shared-edge.report.json new file mode 100644 index 00000000..15a2b1ba --- /dev/null +++ b/scripts/sh3d-convert/golden/noisy-shared-edge.report.json @@ -0,0 +1,30 @@ +{ + "levels": [ + { + "id": "flat", + "title": "Flat", + "spaceId": "sh3d-1-flat", + "cellCm": 5, + "rooms": 2, + "walls": 6, + "openings": 0, + "notes": [ + { + "code": "vertices_snapped", + "count": 8 + }, + { + "code": "edge_without_wall", + "count": 1 + } + ] + } + ], + "items": [ + { + "code": "furniture_dropped" + } + ], + "toolVersion": "sh3d-convert 0.1", + "minHouseplan": "1.71" +} diff --git a/scripts/sh3d-convert/golden/noisy-shared-edge.space-1.json b/scripts/sh3d-convert/golden/noisy-shared-edge.space-1.json new file mode 100644 index 00000000..dcd1bbcd --- /dev/null +++ b/scripts/sh3d-convert/golden/noisy-shared-edge.space-1.json @@ -0,0 +1,153 @@ +{ + "format": "houseplan-export", + "export_version": 2, + "kind": "space", + "created_at": "1970-01-01T00:00:00Z", + "card_version": "sh3d-convert 0.1", + "integration_version": "sh3d-convert 0.1", + "model_version": 7, + "payload": { + "config": { + "spaces": [ + { + "id": "sh3d-1-flat", + "title": "Flat", + "cell_cm": 5, + "view_box": [ + 0.146666667, + 0.355, + 0.706666667, + 0.29 + ], + "rooms": [ + { + "id": "zone-left", + "name": "Гостиная", + "poly": [ + [ + 0.16666666666666666, + 0.375 + ], + [ + 0.5, + 0.375 + ], + [ + 0.5, + 0.625 + ], + [ + 0.16666666666666666, + 0.625 + ] + ] + }, + { + "id": "zone-right", + "name": "Столовая", + "poly": [ + [ + 0.5, + 0.375 + ], + [ + 0.8333333333333334, + 0.375 + ], + [ + 0.8333333333333334, + 0.625 + ], + [ + 0.5, + 0.625 + ] + ] + } + ], + "walls": [ + { + "key": "0.333333,0.375000@0.0000", + "cm": 20, + "a": [ + 0.16666666666666666, + 0.375 + ], + "b": [ + 0.5, + 0.375 + ] + }, + { + "key": "0.333333,0.625000@0.0000", + "cm": 20, + "a": [ + 0.5, + 0.625 + ], + "b": [ + 0.16666666666666666, + 0.625 + ] + }, + { + "key": "0.166667,0.500000@1.5706", + "cm": 20, + "a": [ + 0.16666666666666666, + 0.625 + ], + "b": [ + 0.16666666666666666, + 0.375 + ] + }, + { + "key": "0.666667,0.375000@0.0000", + "cm": 20, + "a": [ + 0.5, + 0.375 + ], + "b": [ + 0.8333333333333334, + 0.375 + ] + }, + { + "key": "0.833333,0.500000@1.5706", + "cm": 20, + "a": [ + 0.8333333333333334, + 0.375 + ], + "b": [ + 0.8333333333333334, + 0.625 + ] + }, + { + "key": "0.666667,0.625000@0.0000", + "cm": 20, + "a": [ + 0.8333333333333334, + 0.625 + ], + "b": [ + 0.5, + 0.625 + ] + } + ] + } + ], + "markers": [] + }, + "layout": {} + }, + "placement_manifest": [], + "content_manifest": [], + "transfer": { + "plan_only": true + } +} diff --git a/scripts/sh3d-convert/golden/two-levels.report.json b/scripts/sh3d-convert/golden/two-levels.report.json new file mode 100644 index 00000000..092c3b8e --- /dev/null +++ b/scripts/sh3d-convert/golden/two-levels.report.json @@ -0,0 +1,41 @@ +{ + "levels": [ + { + "id": "ground", + "title": "Ground", + "spaceId": "sh3d-1-ground", + "cellCm": 2.5, + "rooms": 1, + "walls": 4, + "openings": 0, + "notes": [ + { + "code": "vertices_snapped", + "count": 4 + } + ] + }, + { + "id": "first", + "title": "First", + "spaceId": "sh3d-2-first", + "cellCm": 2.5, + "rooms": 1, + "walls": 4, + "openings": 0, + "notes": [ + { + "code": "vertices_snapped", + "count": 4 + } + ] + } + ], + "items": [ + { + "code": "furniture_dropped" + } + ], + "toolVersion": "sh3d-convert 0.1", + "minHouseplan": "1.71" +} diff --git a/scripts/sh3d-convert/golden/two-levels.space-1.json b/scripts/sh3d-convert/golden/two-levels.space-1.json new file mode 100644 index 00000000..2f118f1b --- /dev/null +++ b/scripts/sh3d-convert/golden/two-levels.space-1.json @@ -0,0 +1,107 @@ +{ + "format": "houseplan-export", + "export_version": 2, + "kind": "space", + "created_at": "1970-01-01T00:00:00Z", + "card_version": "sh3d-convert 0.1", + "integration_version": "sh3d-convert 0.1", + "model_version": 7, + "payload": { + "config": { + "spaces": [ + { + "id": "sh3d-1-ground", + "title": "Ground", + "cell_cm": 2.5, + "view_box": [ + 0.063333333, + 0.063333333, + 0.873333333, + 0.873333333 + ], + "rooms": [ + { + "id": "hall", + "name": "Hall", + "poly": [ + [ + 0.08333333333333333, + 0.08333333333333333 + ], + [ + 0.9166666666666666, + 0.08333333333333333 + ], + [ + 0.9166666666666666, + 0.9166666666666666 + ], + [ + 0.08333333333333333, + 0.9166666666666666 + ] + ] + } + ], + "walls": [ + { + "key": "0.500000,0.083333@0.0000", + "cm": 30, + "a": [ + 0.08333333333333333, + 0.08333333333333333 + ], + "b": [ + 0.9166666666666666, + 0.08333333333333333 + ] + }, + { + "key": "0.916667,0.500000@1.5706", + "cm": 30, + "a": [ + 0.9166666666666666, + 0.08333333333333333 + ], + "b": [ + 0.9166666666666666, + 0.9166666666666666 + ] + }, + { + "key": "0.500000,0.916667@0.0000", + "cm": 30, + "a": [ + 0.9166666666666666, + 0.9166666666666666 + ], + "b": [ + 0.08333333333333333, + 0.9166666666666666 + ] + }, + { + "key": "0.083333,0.500000@1.5706", + "cm": 30, + "a": [ + 0.08333333333333333, + 0.9166666666666666 + ], + "b": [ + 0.08333333333333333, + 0.08333333333333333 + ] + } + ] + } + ], + "markers": [] + }, + "layout": {} + }, + "placement_manifest": [], + "content_manifest": [], + "transfer": { + "plan_only": true + } +} diff --git a/scripts/sh3d-convert/golden/two-levels.space-2.json b/scripts/sh3d-convert/golden/two-levels.space-2.json new file mode 100644 index 00000000..7a21df63 --- /dev/null +++ b/scripts/sh3d-convert/golden/two-levels.space-2.json @@ -0,0 +1,107 @@ +{ + "format": "houseplan-export", + "export_version": 2, + "kind": "space", + "created_at": "1970-01-01T00:00:00Z", + "card_version": "sh3d-convert 0.1", + "integration_version": "sh3d-convert 0.1", + "model_version": 7, + "payload": { + "config": { + "spaces": [ + { + "id": "sh3d-2-first", + "title": "First", + "cell_cm": 2.5, + "view_box": [ + 0.063333333, + 0.063333333, + 0.873333333, + 0.873333333 + ], + "rooms": [ + { + "id": "attic", + "name": "Attic", + "poly": [ + [ + 0.08333333333333333, + 0.08333333333333333 + ], + [ + 0.9166666666666666, + 0.08333333333333333 + ], + [ + 0.9166666666666666, + 0.9166666666666666 + ], + [ + 0.08333333333333333, + 0.9166666666666666 + ] + ] + } + ], + "walls": [ + { + "key": "0.500000,0.083333@0.0000", + "cm": 30, + "a": [ + 0.08333333333333333, + 0.08333333333333333 + ], + "b": [ + 0.9166666666666666, + 0.08333333333333333 + ] + }, + { + "key": "0.916667,0.500000@1.5706", + "cm": 30, + "a": [ + 0.9166666666666666, + 0.08333333333333333 + ], + "b": [ + 0.9166666666666666, + 0.9166666666666666 + ] + }, + { + "key": "0.500000,0.916667@0.0000", + "cm": 30, + "a": [ + 0.9166666666666666, + 0.9166666666666666 + ], + "b": [ + 0.08333333333333333, + 0.9166666666666666 + ] + }, + { + "key": "0.083333,0.500000@1.5706", + "cm": 30, + "a": [ + 0.08333333333333333, + 0.9166666666666666 + ], + "b": [ + 0.08333333333333333, + 0.08333333333333333 + ] + } + ] + } + ], + "markers": [] + }, + "layout": {} + }, + "placement_manifest": [], + "content_manifest": [], + "transfer": { + "plan_only": true + } +} diff --git a/scripts/sh3d-convert/make-fixtures.mjs b/scripts/sh3d-convert/make-fixtures.mjs new file mode 100644 index 00000000..5d1f604d --- /dev/null +++ b/scripts/sh3d-convert/make-fixtures.mjs @@ -0,0 +1,197 @@ +#!/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} Б`); +} diff --git a/scripts/sh3d-convert/sh3d.mjs b/scripts/sh3d-convert/sh3d.mjs new file mode 100644 index 00000000..a6080f13 --- /dev/null +++ b/scripts/sh3d-convert/sh3d.mjs @@ -0,0 +1,95 @@ +/** + * Чтение `.sh3d` (Sweet Home 3D) в плоскую модель в сантиметрах (#446). + * + * Формат: zip, внутри `Home.xml` — тот, что описан их публичной DTD. Читаются + * ровно четыре сущности: уровни, комнаты (полигоны), стены (осевая линия + + * толщина), двери и окна. Всё остальное — мебель, материалы, текстуры, свет, + * камеры, размерные линии, фон уровня — сознательно не читается: см. SCOPE, + * «мы живая пространственная карта, а не редактор интерьера». + * + * Единицы Sweet Home 3D — сантиметры, ось Y вниз, как у нас. + */ +import { parseXml, childrenOf } from './xml.mjs'; +import { readZipEntry } from './zip.mjs'; + +export 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', + 'окно', 'окн', +]; +export function openingKind(piece) { + const haystack = `${piece.catalogId || ''} ${piece.name || ''}`.toLowerCase(); + return WINDOW_HINTS.some((hint) => haystack.includes(hint)) ? 'window' : 'door'; +} + +/** Разобрать уже распакованный `Home.xml`. */ +export function parseHomeXml(xml) { + const home = parseXml(xml); + if (home.tag !== 'home') { + throw new Sh3dError('not_home', 'Home.xml не начинается с элемента '); + } + 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` целиком. */ +export async function readSh3d(bytes) { + const xml = await readZipEntry(bytes, 'Home.xml'); + return parseHomeXml(new TextDecoder('utf-8').decode(xml)); +} diff --git a/scripts/sh3d-convert/xml.mjs b/scripts/sh3d-convert/xml.mjs new file mode 100644 index 00000000..dfca4fd8 --- /dev/null +++ b/scripts/sh3d-convert/xml.mjs @@ -0,0 +1,163 @@ +/** + * Минимальный читатель XML для `Home.xml` из `.sh3d` (#446). + * + * Почему свой, а не библиотека. Инструмент собирается без зависимостей: в + * браузере он должен грузиться как обычный модуль, а в Node — исполняться в + * CI без установки чего-либо. `Home.xml` машинно сгенерирован и предсказуем: + * элементы, атрибуты, текст, без namespace'ов. + * + * Безопасность важнее полноты. Файл приходит от постороннего, поэтому: + * `` пропускается и НЕ разрешается (Sweet Home 3D ссылается на свою + * DTD, и попытка её загрузить — это XXE), объявления сущностей отвергаются, + * из ссылок понимаются только пять предопределённых и числовые. Глубина и + * число узлов ограничены: zip-бомба и «миллион смешков» не должны вешать + * вкладку. + */ + +const PREDEFINED = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" }; + +export class XmlError extends Error { + constructor(code, message) { + super(message || code); + this.code = code; + } +} + +/** Раскрытие ссылок: только предопределённые и числовые, всё прочее — отказ. */ +export 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`, которое мы читаем, поэтому + * он не собирается — это осознанное сужение, а не забывчивость. + */ +export function parseXml(source, limits = LIMITS) { + const text = String(source); + if (text.includes('', at + 4); + if (end < 0) throw new XmlError('unterminated', 'Незакрытый комментарий'); + at = end + 3; + continue; + } + if (text.startsWith('', at + 2); + if (end < 0) throw new XmlError('unterminated', 'Незакрытая инструкция'); + at = end + 2; + continue; + } + if (text.startsWith('', at + 9); + if (end < 0) throw new XmlError('unterminated', 'Незакрытый CDATA'); + at = end + 3; + continue; + } + if (text.startsWith('' && depth <= 0) break; + } + if (cursor >= text.length) throw new XmlError('unterminated', 'Незакрытый DOCTYPE'); + at = cursor + 1; + continue; + } + if (text.startsWith('', 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; +} + +/** Все дочерние элементы с указанным тегом (на один уровень). */ +export const childrenOf = (node, tag) => + (node?.children || []).filter((child) => child.tag === tag); diff --git a/scripts/sh3d-convert/zip.mjs b/scripts/sh3d-convert/zip.mjs new file mode 100644 index 00000000..45e97f8b --- /dev/null +++ b/scripts/sh3d-convert/zip.mjs @@ -0,0 +1,118 @@ +/** + * Минимальный читатель zip для `.sh3d` (#446). + * + * `.sh3d` — это zip, внутри которого нужен ровно один вход: `Home.xml`. + * Библиотеки не берём (см. `xml.mjs`), поэтому здесь читается центральный + * каталог и распаковывается одна запись: метод 0 (без сжатия) и метод 8 + * (deflate). Распаковка берётся из окружения: в браузере + * `DecompressionStream('deflate-raw')`, в Node — `node:zlib`. + * + * Границы недоверенного ввода жёсткие и все на входе: шифрованные записи, + * zip64, слишком большая распакованная запись и подозрительные имена + * отвергаются кодом ошибки, а не исключением из недр. + */ + +export 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-архив: не найден конец центрального каталога'); +} + +/** + * Список записей архива: имя, метод, смещение, размеры. + * Данные не читаются — только каталог, чтобы решение «брать или отказать» + * принималось до распаковки. + */ +export 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; +} + +/** Распаковать одну запись по имени. */ +export 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; +} diff --git a/test/sh3d-convert.test.mjs b/test/sh3d-convert.test.mjs new file mode 100644 index 00000000..b2159b80 --- /dev/null +++ b/test/sh3d-convert.test.mjs @@ -0,0 +1,180 @@ +/** + * #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(']>'), + (error) => error instanceof XmlError && error.code === 'entity_declaration'); + assert.throws(() => parseXml(''), + (error) => error.code === 'entity_reference'); + assert.equal(parseXml("").attrs.name, 'a'); + // План без комнат: House Plan строит геометрию по комнатам, и это отказ. + const wallsOnly = parseHomeXml( + ""); + assert.throws(() => convertHome(wallsOnly, { now: NOW }), + (error) => error instanceof ConvertError && error.code === 'nothing_to_convert'); + // Слишком сложная комната — предел схемы, а не наш вкус. + const points = Array.from({ length: 501 }, (_, index) => + ``).join(''); + const huge = parseHomeXml(`${points}`); + 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'); +}); diff --git a/tests_backend/test_ha_sh3d_convert.py b/tests_backend/test_ha_sh3d_convert.py new file mode 100644 index 00000000..d95abe71 --- /dev/null +++ b/tests_backend/test_ha_sh3d_convert.py @@ -0,0 +1,71 @@ +"""#446: golden конвертера Sweet Home 3D проходят настоящий предпросмотр импорта. + +Самое сильное звено гейта против дрейфа: здесь документ, который получает +пользователь со страницы `/convert`, скармливается той же функции +`create_preview`, что и загрузка через интерфейс карточки. Модуль требует Home +Assistant (импорт тянет `store`), поэтому файл назван `test_ha_*` и локально +пропускается — в Linux CI он исполняется. + +Чистая половина проверок — в `test_sh3d_convert.py`. +""" +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from custom_components.houseplan.import_export import ImportFailure, create_preview + +GOLDEN = Path(__file__).resolve().parent.parent / "scripts" / "sh3d-convert" / "golden" + + +def golden_documents() -> list[tuple[str, dict]]: + files = sorted(GOLDEN.glob("*.space-*.json")) + assert files, "golden конвертера пропали" + return [(path.name, json.loads(path.read_text(encoding="utf-8"))) for path in files] + + +def _preview(document: dict, root: Path) -> dict: + return create_preview( + SimpleNamespace(instance_id="target-instance", import_previews={}), + json.dumps(document).encode(), + owner_id="alice", + duplicate_policy="skip", + current_config_data={"config": {"spaces": [], "markers": []}, "rev": 0}, + current_layout_data={"layout": {}, "rev": 0}, + config_root=root, + ) + + +@pytest.mark.parametrize(("name", "document"), golden_documents()) +def test_issue_446_import_preview_accepts_the_converted_document( + name: str, document: dict, tmp_path, +) -> None: + response = _preview(document, tmp_path / "target") + preview = response["preview"] + assert response["token"], name + assert preview["counts"]["spaces"] == 1, name + assert preview["counts"]["rooms"] >= 1, name + assert preview["counts"]["markers"] == 0, "конвертер не приносит устройств" + assert preview["content"] == [], "и не приносит файлов" + # Чужой источник — законное состояние: документ сделан не этой установкой. + assert "foreign_source" in preview["warnings"], name + assert "full_replaces_current" not in preview["warnings"], "этаж добавляется, не заменяет план" + + +def test_issue_446_plan_only_flag_survives_the_privacy_projection(tmp_path) -> None: + """`plan_only: true` — обещание, которое сервер проверяет пересчётом. + + Если конвертер положит в пространство поле вне разрешённого набора, + предпросмотр откажет с `invalid_format`. Тест держит это обещание честным: + подмена документа лишним полем обязана отказать. + """ + name, document = golden_documents()[0] + forged = json.loads(json.dumps(document)) + forged["payload"]["config"]["spaces"][0]["plan_url"] = "/api/houseplan/content/plans/x.png" + with pytest.raises(ImportFailure): + _preview(forged, tmp_path / "forged") + # А неподделанный проходит — иначе предыдущая проверка ничего не значит. + assert _preview(document, tmp_path / "clean")["token"], name diff --git a/tests_backend/test_sh3d_convert.py b/tests_backend/test_sh3d_convert.py new file mode 100644 index 00000000..78158b42 --- /dev/null +++ b/tests_backend/test_sh3d_convert.py @@ -0,0 +1,150 @@ +"""#446: golden-документы конвертера Sweet Home 3D против настоящей схемы. + +Вторая половина цепочки против дрейфа версий. Конвертер живёт в репозитории и +раздаётся страницей `/convert` на сайте; между сайтом и моделью плана нет +ничего, кроме этого гейта. Поэтому golden проверяются не «структурой», а тем +самым `CONFIG_SCHEMA` и тем самым `commit_wall_segment_model`, которые +исполняются при импорте: правка модели, не отражённая в конвертере, краснеет +здесь, до того как это увидит пользователь. + +Тест чистый — без Home Assistant. Полный путь предпросмотра требует HA и живёт +в `test_ha_sh3d_convert.py`. +""" +from __future__ import annotations + +import json +import re +from pathlib import Path + +import pytest + +from custom_components.houseplan.const import EXPORT_VERSION, PLAN_MODEL_VERSION +from custom_components.houseplan.coordinate_canonicalization import ( + canonicalize_lattice_coordinate, +) +from custom_components.houseplan.validation import CONFIG_SCHEMA +from custom_components.houseplan.wall_segment_model import ( + WALL_SEGMENT_MODEL_VERSION, + _wall_key, + commit_wall_segment_model, +) + +REPO = Path(__file__).resolve().parent.parent +GOLDEN = REPO / "scripts" / "sh3d-convert" / "golden" +CONVERT = REPO / "scripts" / "sh3d-convert" / "convert.mjs" + + +def documents() -> list[tuple[str, dict]]: + files = sorted(GOLDEN.glob("*.space-*.json")) + assert files, "golden конвертера пропали — гейт остался бы зелёным ни на чём" + return [(path.name, json.loads(path.read_text(encoding="utf-8"))) for path in files] + + +def _source_constant(name: str) -> str: + """Значение константы из конвертера: расхождение обязано быть видно здесь.""" + match = re.search(rf"^export const {name} = (.+);$", CONVERT.read_text(encoding="utf-8"), + re.MULTILINE) + assert match, f"в конвертере нет константы {name}" + return match.group(1).strip().strip("'\"") + + +@pytest.mark.parametrize(("name", "document"), documents()) +def test_issue_446_envelope_matches_the_import_contract(name: str, document: dict) -> None: + """Конверт документа — ровно то, что требует `parse_document`.""" + assert document["format"] == "houseplan-export", name + assert document["export_version"] in (1, EXPORT_VERSION), name + assert document["kind"] == "space", name + assert 0 <= document["model_version"] <= PLAN_MODEL_VERSION, name + assert document["transfer"] == {"plan_only": True}, name + assert document["placement_manifest"] == [], name + assert document["content_manifest"] == [], name + assert document["payload"]["layout"] == {}, name + config = document["payload"]["config"] + assert config["markers"] == [], name + assert len(config["spaces"]) == 1, "plan-only требует ровно одно пространство" + + +def test_issue_446_converter_declares_a_version_the_import_accepts() -> None: + """Модель документа не должна обогнать установку. + + Конвертер объявляет форму v7 сознательно: структуру (сегменты, wall_ids, + хосты проёмов) собирает серверный писатель импорта. Значение больше + `PLAN_MODEL_VERSION` импорт отвергнет как `future_model`. + """ + declared = int(_source_constant("MODEL_VERSION")) + assert declared <= PLAN_MODEL_VERSION + assert declared < WALL_SEGMENT_MODEL_VERSION, ( + "форма v7 выбрана намеренно: при v8+ схема требует полный каталог сегментов," + " и конвертер обязан был бы повторить серверный алгоритм" + ) + assert int(_source_constant("EXPORT_VERSION")) in (1, EXPORT_VERSION) + + +@pytest.mark.parametrize(("name", "document"), documents()) +def test_issue_446_golden_passes_the_real_config_schema(name: str, document: dict) -> None: + config = dict(document["payload"]["config"]) + config["model_version"] = document["model_version"] + validated = CONFIG_SCHEMA(json.loads(json.dumps(config))) + space = validated["spaces"][0] + assert space["rooms"], name + assert "wall_segments" not in space, "форма v7 не несёт каталога сегментов" + + +@pytest.mark.parametrize(("name", "document"), documents()) +def test_issue_446_server_writer_turns_the_document_into_a_valid_plan( + name: str, document: dict, +) -> None: + """То, что делает apply: миграция v7 → v9 и повторная проверка схемой.""" + config = dict(document["payload"]["config"]) + config["model_version"] = document["model_version"] + migrated, _ = commit_wall_segment_model(CONFIG_SCHEMA(json.loads(json.dumps(config)))) + assert migrated["model_version"] == WALL_SEGMENT_MODEL_VERSION + space = migrated["spaces"][0] + segments = space["wall_segments"] + assert segments, name + for room in space["rooms"]: + assert len(room["wall_ids"]) == len(room["poly"]), "по идентификатору на ребро" + owners: dict[str, int] = {segment["id"]: 0 for segment in segments} + for room in space["rooms"]: + for segment_id in room["wall_ids"]: + owners[segment_id] += 1 + assert all(1 <= count <= 2 for count in owners.values()), "сегмент без комнаты недопустим" + CONFIG_SCHEMA(json.loads(json.dumps(migrated))) + + +def test_issue_446_shared_boundary_becomes_one_segment_and_doors_get_hosts() -> None: + """Ради этого конвертер и выравнивает вершины по осевым линиям стен.""" + document = json.loads((GOLDEN / "flat-two-rooms.space-1.json").read_text(encoding="utf-8")) + config = dict(document["payload"]["config"]) + config["model_version"] = document["model_version"] + migrated, _ = commit_wall_segment_model(CONFIG_SCHEMA(json.loads(json.dumps(config)))) + space = migrated["spaces"][0] + assert len(space["wall_segments"]) == 7, "восемь рёбер, общая граница склеена в одно" + shared = [ + segment for segment in space["wall_segments"] + if sum(segment["id"] in room["wall_ids"] for room in space["rooms"]) == 2 + ] + assert len(shared) == 1, "общая стена двух комнат обязана быть одним сегментом" + hosts = [opening.get("host") for opening in space["openings"]] + assert all(isinstance(host, dict) and host.get("kind") == "wall" for host in hosts), ( + f"сервер обязан сам привязать проёмы к стенам, получено {hosts}" + ) + + +def test_issue_446_ported_formulas_match_python() -> None: + """Те же значения закреплены в test/sh3d-convert.test.mjs. + + Формулы `_wall_key` и канонизации портированы в JS. Если они разойдутся, + сервер не найдёт толщину для рёбер, и стены приедут нулевыми — молча. + Расхождение краснит один из двух тестов. + """ + assert _wall_key([0.25, 0.3333333333333333], [0.5, 0.3333333333333333]) == ( + "0.375000,0.333333@0.0000") + assert _wall_key([0.5, 0.3333333333333333], [0.5, 0.6666666666666666]) == ( + "0.500000,0.500000@1.5706") + # 0.2499999 отличает две ветки канонизации: сваливание шума в узел даёт + # 0.25, а простое округление до девяти знаков — 0.2499999. Значение с + # одиннадцатью девятками (первая редакция теста) обе ветки давали одинаково, + # то есть пин ничего не проверял. + assert canonicalize_lattice_coordinate(0.2499999) == 0.25 + assert canonicalize_lattice_coordinate(0.123456789012) == 0.123456789