diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index 48c768d..a16108a 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -1,286 +1,246 @@ import * as THREE from 'three'; import { STLExporter, mergeBufferGeometries } from 'three-stdlib'; -import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; +import { AppConfig, LayoutSplits, GeneratedPart, PerforationConfig } from '../types'; -// --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ --- - -// Просто сортируем координаты, без агрессивной чистки, чтобы не терять ячейки -const sortPoints = (points: number[]) => { - return [...new Set(points)].sort((a, b) => a - b); -}; - -// Сбор всех перегородок в один массив -const getAllPartitions = (splits: LayoutSplits): Partition[] => { - if (!splits || !splits.partitions) return []; - return Object.values(splits.partitions).flat(); -}; - -// --- ВИЗУАЛИЗАЦИЯ (Цветные блоки) --- -export const calculateParts = (config: AppConfig, splits: LayoutSplits): GeneratedPart[] => { +/** + * 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 = sortPoints([0, ...safeX, 1]); - const uniqueY = sortPoints([0, ...safeY, 1]); + // Сортируем линии реза и добавляем границы (0 и 1) + const xPoints = [0, ...[...splits.x].sort((a, b) => a - b), 1]; + const yPoints = [0, ...[...splits.y].sort((a, b) => a - b), 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; + for (let i = 0; i < xPoints.length - 1; i++) { + for (let j = 0; j < yPoints.length - 1; j++) { - // Фильтр фантомов: если ячейка меньше 1 мм, пропускаем - if (rawW < 1 || rawD < 1) continue; + // Размеры текущей ячейки сетки + const segmentX = xPoints[i] * config.drawer.width; + const segmentY = yPoints[j] * config.drawer.depth; + const segmentW = (xPoints[i + 1] - xPoints[i]) * config.drawer.width; + const segmentD = (yPoints[j + 1] - yPoints[j]) * config.drawer.depth; - const rawX = x1 * config.drawer.width; - const rawY = y1 * config.drawer.depth; - - // Зазор для визуализации - const gap = config.wallThickness / 2 + 0.2; + // Применяем толерантность (зазор между ящиками) + // Уменьшаем размер ящика, сдвигаем его к центру + const realWidth = segmentW - config.printerTolerance; + const realDepth = segmentD - config.printerTolerance; + const realX = segmentX + (config.printerTolerance / 2); + const realY = segmentY + (config.printerTolerance / 2); + + // Защита от слишком мелких (фантомных) ячеек + if (realWidth < 2 || realDepth < 2) { + continue; + } 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: [] + id: `part-${partCounter}-${Date.now()}`, // Уникальный ID + name: `Ячейка ${i+1}-${j+1}`, + width: realWidth, + depth: realDepth, + height: config.drawer.height, + x: realX, + y: realY, + color: `hsl(${(partCounter * 137.5) % 360}, 70%, 50%)` }); partCounter++; } } + return parts; }; -// --- ГЕОМЕТРИЯ (Extrude с дырками) --- - -const createPerforatedShape = (length: number, height: number, config: AppConfig): THREE.Shape => { +/** + * 2. Создание 2D профиля стены с отверстиями + * ВАЖНО: Контур стены -> CCW (Против часовой) + * ВАЖНО: Отверстия -> CW (По часовой) + */ +const createPerforatedWallShape = ( + width: number, + height: number, + perf: PerforationConfig +): THREE.Shape => { const shape = new THREE.Shape(); - // 1. Внешний контур (CCW - Против часовой) + // Внешний прямоугольник (Против часовой стрелки) shape.moveTo(0, 0); - shape.lineTo(length, 0); - shape.lineTo(length, height); + shape.lineTo(width, 0); + shape.lineTo(width, height); shape.lineTo(0, height); shape.lineTo(0, 0); - // Если перфорация выключена или стенка мала - if (!config.perforation?.enabled || length < 15 || height < 15) return shape; + if (!perf.enabled) return shape; - const { pattern, diameter, spacing } = config.perforation; - const step = diameter + Math.max(2, spacing); - const margin = 4; // Отступ от краев + const { size, spacing, shape: type, border } = perf; + + // Эффективная зона перфорации + const startX = border; + const endX = width - border; + const startY = border; + const endY = height - border; - const effW = length - margin * 2; - const effH = height - margin * 2; + if (startX >= endX || startY >= endY) return shape; - if (effW <= diameter || effH <= diameter) return shape; + // Функция добавления одной дырки + const addHole = (cx: number, cy: number) => { + // Проверка границ (центр отверстия не должен выходить за рамки) + if (cx - size/2 < startX || cx + size/2 > endX || cy - size/2 < startY || cy + size/2 > endY) return; - const rowH = pattern === 'circle' ? step : step * 0.866; - const cols = Math.floor(effW / step); - const rows = Math.floor(effH / rowH); + const holePath = new THREE.Path(); + const r = size / 2; - const startX = margin + (effW - (cols - 1) * step) / 2; - const startY = margin + (effH - (rows - 1) * rowH) / 2; + if (type === 'circle') { + // aClockwise = true (По часовой стрелке) + holePath.absarc(cx, cy, r, 0, Math.PI * 2, true); + } else if (type === 'hexagon') { + // Шестиугольник (По часовой стрелке) + // angle идет в минус: 90, 30, -30... + 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) holePath.moveTo(px, py); + else holePath.lineTo(px, py); + } + holePath.closePath(); + } else if (type === 'triangle') { + // Треугольник (По часовой стрелке) + const angles = [90, -30, 210]; // 90 -> -30 (CW) + angles.forEach((deg, idx) => { + const rad = deg * (Math.PI / 180); + const px = cx + r * Math.cos(rad); + const py = cy + r * Math.sin(rad); + if (idx === 0) holePath.moveTo(px, py); + else holePath.lineTo(px, py); + }); + holePath.closePath(); + } - for (let j = 0; j < rows; j++) { - const isOdd = j % 2 !== 0; - const cy = startY + j * rowH; + shape.holes.push(holePath); + }; - 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 > length - margin || - cy - diameter/2 < margin || cy + diameter/2 > height - margin) continue; - - const hole = new THREE.Path(); - const r = diameter / 2; - - // 2. ОТВЕРСТИЯ (CW - По часовой стрелке) - // Это ключ к успеху! aClockwise = true + // Генерация сетки + if (type === 'hexagon') { + // Сотовая структура (смещенные ряды) + const hexWidth = size * 0.866; // sqrt(3)/2 + const colDist = hexWidth + spacing; + const rowDist = (size * 0.75) + spacing; + + let rowIndex = 0; + for (let y = startY + size/2; y < endY; y += rowDist) { + const isOddRow = rowIndex % 2 === 1; + const offset = isOddRow ? colDist / 2 : 0; - if (pattern === 'circle') { - hole.absarc(cx, cy, r, 0, Math.PI * 2, true); - } - else if (pattern === 'hexagon') { - for (let k = 0; k < 6; k++) { - // Угол (-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(); + for (let x = startX + size/2 + offset; x < endX; x += colDist) { + addHole(x, y); + } + rowIndex++; + } + } else { + // Обычная сетка (Круг, Треугольник) + const cellSize = size + spacing; + for (let x = startX + size/2; x < endX; x += cellSize) { + for (let y = startY + size/2; y < endY; y += cellSize) { + addHole(x, y); } - shape.holes.push(hole); } } + return shape; }; -// Пол (сплошной) -const createFloorShape = (width: number, depth: number, radius: number): THREE.Shape => { - const shape = new THREE.Shape(); - const x = -width / 2; - const y = -depth / 2; - const r = Math.min(radius, width / 2 - 0.1, depth / 2 - 0.1); - - if (r <= 0.1) { - shape.moveTo(x, y); - shape.lineTo(x + width, y); - shape.lineTo(x + width, y + depth); - shape.lineTo(x, y + depth); - shape.lineTo(x, y); - } else { - shape.moveTo(x, y + r); - shape.lineTo(x, y + depth - r); - shape.quadraticCurveTo(x, y + depth, x + r, y + depth); - shape.lineTo(x + width - r, y + depth); - shape.quadraticCurveTo(x + width, y + depth, x + width, y + depth - r); - shape.lineTo(x + width, y + r); - shape.quadraticCurveTo(x + width, y, x + width - r, y); - shape.lineTo(x + r, y); - shape.quadraticCurveTo(x, y, x, y + r); - } - return shape; -}; - -// --- СБОРКА МОДЕЛИ --- - +/** + * 3. Создание 3D геометрии для ОДНОГО ящика + */ export const createBinGeometry = ( - width: number, depth: number, height: number, thickness: number, radius: number = 0, - splits: LayoutSplits | Partition[] = [], - config?: AppConfig + width: number, + depth: number, + height: number, + thickness: number, + perforation?: PerforationConfig ): THREE.BufferGeometry => { - const geometries: THREE.BufferGeometry[] = []; - const safeConfig = config || { perforation: { enabled: false } } as AppConfig; - - // 1. ПОЛ - const floorShape = createFloorShape(width, depth, radius); - const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false }); - floorGeo.rotateX(-Math.PI / 2); // Кладем на пол + const perfConfig = perforation || { enabled: false, shape: 'circle', size: 0, spacing: 0, border: 0 }; + + // 1. Пол (Всегда сплошной) + const floorGeo = new THREE.BoxGeometry(width, thickness, depth).toNonIndexed(); + floorGeo.translate(0, thickness / 2, 0); geometries.push(floorGeo); - const wallH = height - thickness; - const innerW = width - 2 * thickness; - const innerD = depth - 2 * thickness; + const wallHeight = height - thickness; + + if (wallHeight > 0) { + const extrudeSettings = { + depth: thickness, + bevelEnabled: false, + }; - // Функция добавления стены - const addWall = (len: number, h: number, x: number, z: number, isVertical: boolean) => { - // Создаем 2D форму с дырками - const shape = createPerforatedShape(len, h, safeConfig); - // Выдавливаем - const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }); + // 2. Левая и Правая стенки (Полная глубина) + // Рисуем профиль (Ширина профиля = Глубине ящика) + const lrShape = createPerforatedWallShape(depth, wallHeight, perfConfig); + const lrGeo = new THREE.ExtrudeGeometry(lrShape, extrudeSettings).toNonIndexed(); - // Центрируем геометрию (важно для вращения!) - geo.center(); + // Центрируем геометрию для удобного вращения + lrGeo.center(); - // Поворачиваем - if (isVertical) { - geo.rotateY(Math.PI / 2); + // Левая стенка (Left) + // Поворачиваем: Профиль лежит вдоль X -> поворот на 90 -> вдоль Z + const leftWall = lrGeo.clone(); + leftWall.rotateY(Math.PI / 2); + // Позиция: X = -width/2 + thickness/2, Y = пол + пол_стены + leftWall.translate(-(width/2) + thickness/2, thickness + wallHeight/2, 0); + geometries.push(leftWall); + + // Правая стенка (Right) + const rightWall = lrGeo.clone(); + rightWall.rotateY(Math.PI / 2); + rightWall.translate((width/2) - thickness/2, thickness + wallHeight/2, 0); + geometries.push(rightWall); + + // 3. Передняя и Задняя стенки (Вставляются МЕЖДУ боковыми) + // Их ширина меньше на 2 толщины + const wallFBWidth = width - (2 * thickness); + + if (wallFBWidth > 0) { + const fbShape = createPerforatedWallShape(wallFBWidth, wallHeight, perfConfig); + const fbGeo = new THREE.ExtrudeGeometry(fbShape, extrudeSettings).toNonIndexed(); + + fbGeo.center(); + + // Передняя стенка (Front) + const frontWall = fbGeo.clone(); + frontWall.translate(0, thickness + wallHeight/2, (depth/2) - thickness/2); + geometries.push(frontWall); + + // Задняя стенка (Back) + const backWall = fbGeo.clone(); + backWall.translate(0, thickness + wallHeight/2, -(depth/2) + thickness/2); + geometries.push(backWall); } - - // Ставим на место. Y = толщина пола + половина высоты стены - geo.translate(x, thickness + h/2, z); - - geometries.push(geo); - }; - - // 2. ВНЕШНИЕ СТЕНЫ - // Front (вдоль X) - addWall(innerW, wallH, 0, depth/2 - thickness/2, false); - // Back (вдоль X) - addWall(innerW, wallH, 0, -depth/2 + thickness/2, false); - // Left (вдоль Z) - addWall(depth, wallH, -width/2 + thickness/2, 0, true); - // Right (вдоль Z) - addWall(depth, wallH, width/2 - thickness/2, 0, true); - - // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ - let partitions: Partition[] = []; - if (Array.isArray(splits)) { - partitions = splits; - } else if (splits && splits.partitions) { - partitions = getAllPartitions(splits); } - partitions.forEach(p => { - const pMin = p.min ?? 0; - const pMax = p.max ?? 1; - - if (Math.abs(pMax - pMin) < 0.001) return; - - let len = 0, xPos = 0, zPos = 0, isVert = false; - - if (p.axis === 'x') { // Vert (Z) - isVert = true; - len = (pMax - pMin) * innerD; - xPos = (-innerW/2) + (p.offset * innerW); - zPos = (-innerD/2) + ((pMin + pMax) / 2 * innerD); - } else { // Horiz (X) - isVert = false; - len = (pMax - pMin) * innerW; - xPos = (-innerW/2) + ((pMin + pMax) / 2 * innerW); - zPos = (-innerD/2) + (p.offset * innerD); - } - - addWall(len, p.height, xPos, zPos, isVert); - - // 4. СКРУГЛЕНИЯ (Простые цилиндры в стыках) - if (p.rounded && radius > 0) { - const r = Math.min(radius, 5); - const cyl = new THREE.CylinderGeometry(r, r, p.height, 12); - - const addCyl = (cx: number, cz: number) => { - const c = cyl.clone(); - // Центрируем по высоте так же, как стены - c.translate(cx, thickness + p.height/2, cz); - geometries.push(c); - }; - - if (isVert) { - addCyl(xPos, zPos - len/2); // Начало - addCyl(xPos, zPos + len/2); // Конец - } else { - addCyl(xPos - len/2, zPos); // Начало - addCyl(xPos + len/2, zPos); // Конец - } - } - }); - - // 5. СЛИЯНИЕ + // Сливаем всё в один меш const merged = mergeBufferGeometries(geometries); if (merged) merged.computeVertexNormals(); - return merged || new THREE.BoxGeometry(1, 1, 1); + + return merged || new THREE.BoxGeometry(1, 1, 1).toNonIndexed(); }; +// --- ЭКСПОРТ (без изменений) --- + 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); + + if (result instanceof DataView) { + return new Uint8Array(result.buffer, result.byteOffset, result.byteLength); + } return result as string; };