import * as THREE from 'three'; import { STLExporter, mergeBufferGeometries } from 'three-stdlib'; import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; // --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ --- // Очистка только для визуализации (цветные кубики), чтобы не рябило в глазах const cleanPointsForVisuals = (points: number[]) => { const rounded = points.map(p => Math.round(p * 1000) / 1000).sort((a, b) => a - b); return [...new Set(rounded)]; }; // --- 1. ВИЗУАЛИЗАЦИЯ (ЦВЕТНЫЕ БЛОКИ) --- export const calculateParts = (config: AppConfig, splits: LayoutSplits): GeneratedPart[] => { const parts: GeneratedPart[] = []; const safeX = Array.isArray(splits?.x) ? splits.x : []; const safeY = Array.isArray(splits?.y) ? splits.y : []; // Для визуализации используем очищенную сетку, чтобы было красиво const uniqueX = cleanPointsForVisuals([0, ...safeX, 1]); const uniqueY = cleanPointsForVisuals([0, ...safeY, 1]); let partCounter = 1; for (let i = 0; i < uniqueX.length - 1; i++) { for (let j = 0; j < uniqueY.length - 1; j++) { const x1 = uniqueX[i]; const x2 = uniqueX[i+1]; const y1 = uniqueY[j]; const y2 = uniqueY[j+1]; const rawW = (x2 - x1) * config.drawer.width; const rawD = (y2 - y1) * config.drawer.depth; if (rawW < 2 || rawD < 2) continue; const rawX = x1 * config.drawer.width; const rawY = y1 * config.drawer.depth; const gap = config.wallThickness / 2 + 0.1; parts.push({ id: `part-${partCounter}`, name: `Ячейка ${partCounter}`, width: Math.max(1, rawW - gap * 2), depth: Math.max(1, rawD - gap * 2), height: config.drawer.height - config.wallThickness, x: rawX + gap, y: rawY + gap, color: `hsl(${(partCounter * 137.5) % 360}, 70%, 50%)`, internalPartitions: [] }); partCounter++; } } return parts; }; // --- ГЕНЕРАЦИЯ ГЕОМЕТРИИ (СТРОГО ПО ДАННЫМ) --- // Функция создания 2D профиля стены с отверстиями const createWallProfile = (width: number, height: number, config: AppConfig): THREE.Shape => { const shape = new THREE.Shape(); // 1. Внешний контур (CCW - Против часовой) shape.moveTo(0, 0); shape.lineTo(width, 0); shape.lineTo(width, height); shape.lineTo(0, height); shape.lineTo(0, 0); // Проверка на включение перфорации if (!config.perforation?.enabled || width < 10 || height < 10) return shape; const { pattern, diameter, spacing } = config.perforation; const step = diameter + Math.max(2, spacing); const margin = 3; // Отступ от краев const effW = width - margin * 2; const effH = height - margin * 2; if (effW <= diameter || effH <= diameter) return shape; // Сетка отверстий const rowH = pattern === 'circle' ? step : step * 0.866; const cols = Math.floor(effW / step); const rows = Math.floor(effH / rowH); // Центрирование const startX = margin + (effW - (cols - 1) * step) / 2; const startY = margin + (effH - (rows - 1) * rowH) / 2; for (let j = 0; j < rows; j++) { const isOdd = j % 2 !== 0; const cy = startY + j * rowH; for (let i = 0; i < cols; i++) { let cx = startX + i * step; // Смещение для сот/треугольников if ((pattern === 'hexagon' || pattern === 'triangle') && isOdd) cx += step / 2; // Проверка границ (чтобы дырка не вылезла за край) if (cx - diameter/2 < margin || cx + diameter/2 > width - margin || cy - diameter/2 < margin || cy + diameter/2 > height - margin) continue; const hole = new THREE.Path(); const r = diameter / 2; // 2. Дырки (CW - По часовой стрелке). ВАЖНО! if (pattern === 'circle') { hole.absarc(cx, cy, r, 0, Math.PI * 2, true); } else if (pattern === 'hexagon') { for (let k = 0; k < 6; k++) { const angle = (-k * 60 + 90) * Math.PI / 180; const px = cx + r * Math.cos(angle); const py = cy + r * Math.sin(angle); if (k === 0) hole.moveTo(px, py); else hole.lineTo(px, py); } hole.closePath(); } else if (pattern === 'triangle') { const rot = isOdd ? 180 : 0; for (let k = 0; k < 3; k++) { const angle = (-k * 120 + 90 + rot) * Math.PI / 180; const px = cx + r * Math.cos(angle); const py = cy + r * Math.sin(angle); if (k === 0) hole.moveTo(px, py); else hole.lineTo(px, py); } hole.closePath(); } shape.holes.push(hole); } } return shape; }; // Обычный прямоугольник для пола const createRectShape = (w: number, d: number): THREE.Shape => { const s = new THREE.Shape(); s.moveTo(0,0); s.lineTo(w,0); s.lineTo(w,d); s.lineTo(0,d); s.lineTo(0,0); return s; }; // Цилиндр для скругления const createFilletGeo = (radius: number, height: number) => { const r = Math.min(radius, 5); const geo = new THREE.CylinderGeometry(r, r, height, 16); // Центрируем по Y, чтобы ставить от пола geo.translate(0, height/2, 0); return geo; }; // --- СБОРЩИК ГЕОМЕТРИИ --- export const createBinGeometry = ( width: number, depth: number, height: number, thickness: number, radius: number = 0, splits: LayoutSplits | Partition[] = [], config?: AppConfig ): THREE.BufferGeometry => { const geometries: THREE.BufferGeometry[] = []; const safeConfig = config || { perforation: { enabled: false } } as AppConfig; // 1. ПОЛ // Используем простую геометрию для пола const floorGeo = new THREE.BoxGeometry(width, thickness, depth); floorGeo.translate(width/2, thickness/2, depth/2); // Сдвигаем в 0..W, 0..D систему // Но стоп, у нас система координат: центр ящика в 0,0,0? Или угол в 0,0,0? // В calculateParts мы используем абсолютные значения (0..width). // Давайте строить всё от угла (0,0,0) - так проще считать координаты. // Сбрасываем позицию пола: центр (W/2, T/2, D/2) floorGeo.center(); floorGeo.translate(width/2, thickness/2, depth/2); // Угол (0,0,0) - это левый задний угол пола geometries.push(floorGeo); const wallH = height - thickness; const innerW = width - 2 * thickness; const innerD = depth - 2 * thickness; // Хелпер для установки стен const addWall = (len: number, h: number, x: number, z: number, isVert: boolean) => { // 2D профиль с дырками const shape = createWallProfile(len, h, safeConfig); const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }); // По умолчанию: Shape в XY (0..L, 0..H), Extrude в Z (0..T) if (isVert) { // Вертикальная (идет вдоль Z) geo.rotateY(Math.PI / 2); // Теперь идет вдоль Z (0..L), толщина вдоль X (0..T) // Позиция: X, Z. // Начало: (0,0,0) -> повернулось. // Нам нужно поставить начало стены в (x, thickness, z). geo.translate(x, thickness, z); } else { // Горизонтальная (идет вдоль X) // X = длина, Y = высота, Z = толщина. // Нам нужно Z центрировать? Нет, обычно стенки имеют толщину. // Ставим как есть. geo.translate(x, thickness, z); } geometries.push(geo); }; // 2. ВНЕШНИЕ СТЕНЫ (Коробка) // Используем систему координат 0..Width, 0..Depth // Задняя (вдоль X) // X=thickness (внутри левой стены), Z=0 // Длина = innerW addWall(innerW, wallH, thickness, 0, false); // Передняя (вдоль X) // X=thickness, Z=depth-thickness addWall(innerW, wallH, thickness, depth - thickness, false); // Левая (вдоль Z) // X=thickness (сдвиг из-за поворота), Z=0. // При повороте на 90: (0,0,0) -> (0,0,0). Длина ушла в -Z? Или +Z? // RotateY(PI/2): X->Z, Z->-X. // Shape (L, 0, 0) -> (0, 0, -L). Стенка ушла в минус по Z. // Нам нужно чтобы шла в плюс. RotateY(-PI/2). // Исправим хелпер для поворота: // Если RotateY(-PI/2): X->-Z. // Давайте проще: создадим и сдвинем. // LEFT (Полная глубина) const leftGeo = new THREE.ExtrudeGeometry(createWallProfile(depth, wallH, safeConfig), { depth: thickness, bevelEnabled: false }); leftGeo.rotateY(Math.PI / 2); // Вдоль Z // После +90: начало (0,0,0). Длина вдоль -Z. Толщина вдоль -X. // Нам нужно начало в (0,0,0). Стенка должна идти в +Z. // rotateY(-PI/2) -> Длина в +Z. Толщина в +X. leftGeo.rotateY(-Math.PI); // Коррекция // Теперь она смотрит куда надо. leftGeo.translate(thickness, thickness, 0); // Стоп, это сложно угадать. // ДАВАЙТЕ ПРОЩЕ: Центрируем каждую стену и ставим по центру. // Это 100% рабочий метод. const placeCenteredWall = (len: number, h: number, centerX: number, centerZ: number, isVert: boolean) => { const shape = createWallProfile(len, h, safeConfig); const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }); geo.center(); // Центр в (0,0,0) if (isVert) geo.rotateY(Math.PI / 2); // Ставим: Y = thickness + h/2 geo.translate(centerX, thickness + h/2, centerZ); geometries.push(geo); }; // Пересчет центров для внешних стен: // Центр пола: W/2, D/2. placeCenteredWall(innerW, wallH, width/2, thickness/2, false); // Back (Z=thick/2) placeCenteredWall(innerW, wallH, width/2, depth - thickness/2, false); // Front placeCenteredWall(depth, wallH, thickness/2, depth/2, true); // Left placeCenteredWall(depth, wallH, width - thickness/2, depth/2, true); // Right // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ (Связь с данными редактора) // Берем исходные данные о сетке для расчета точных позиций const splitX = [0, ...(Array.isArray(splits?.x) ? splits.x : []), 1].sort((a,b)=>a-b); const splitY = [0, ...(Array.isArray(splits?.y) ? splits.y : []), 1].sort((a,b)=>a-b); const partitionsObj = splits.partitions || {}; // Проходим по всем ячейкам Object.keys(partitionsObj).forEach(key => { const parts = partitionsObj[key]; if (!parts || parts.length === 0) return; const [iStr, jStr] = key.split('-'); const i = parseInt(iStr); const j = parseInt(jStr); // Получаем границы ячейки (0..1) const x1 = splitX[i]; const x2 = splitX[i+1]; const y1 = splitY[j]; const y2 = splitY[j+1]; if (x2 === undefined || y2 === undefined) return; // Конвертируем в миллиметры const cellX = x1 * width; const cellY = y1 * depth; const cellW = (x2 - x1) * width; const cellD = (y2 - y1) * depth; parts.forEach(p => { const pMin = p.min ?? 0; const pMax = p.max ?? 1; if (Math.abs(pMax - pMin) < 0.001) return; let len = 0, cx = 0, cz = 0, isVert = false; if (p.axis === 'x') { // Vert (Z) isVert = true; len = (pMax - pMin) * cellD; // X центр: начало ячейки + смещение cx = cellX + (p.offset * cellW); // Z центр: начало ячейки + середина отрезка стены const midRatio = (pMin + pMax) / 2; cz = cellY + (midRatio * cellD); } else { // Horiz (X) isVert = false; len = (pMax - pMin) * cellW; const midRatio = (pMin + pMax) / 2; cx = cellX + (midRatio * cellW); cz = cellY + (p.offset * cellD); } placeCenteredWall(len, p.height, cx, cz, isVert); // Скругления if (p.rounded && radius > 0) { const r = Math.min(radius, 5); const fGeo = createFilletGeo(r, p.height); // Определяем концы if (isVert) { const zStart = cz - len/2; const zEnd = cz + len/2; // Добавляем цилиндры в концы (подняв на пол) const c1 = fGeo.clone(); c1.translate(cx, thickness, zStart); geometries.push(c1); const c2 = fGeo.clone(); c2.translate(cx, thickness, zEnd); geometries.push(c2); } else { const xStart = cx - len/2; const xEnd = cx + len/2; const c1 = fGeo.clone(); c1.translate(xStart, thickness, cz); geometries.push(c1); const c2 = fGeo.clone(); c2.translate(xEnd, thickness, cz); geometries.push(c2); } } }); }); // 4. СЛИЯНИЕ const merged = mergeBufferGeometries(geometries); if (merged) merged.computeVertexNormals(); return merged || new THREE.BoxGeometry(1, 1, 1); }; export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => { const exporter = new STLExporter(); const result = exporter.parse(mesh, { binary: true }); if (result instanceof DataView) return new Uint8Array(result.buffer, result.byteOffset, result.byteLength); return result as string; }; export const exportSTL = (mesh: THREE.Object3D, filename: string) => { const result = generateSTL(mesh); const blob = new Blob([result], { type: 'application/octet-stream' }); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = filename; link.click(); };