diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index 747c4df..c1b6d58 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -2,59 +2,44 @@ import * as THREE from 'three'; import { STLExporter, mergeBufferGeometries } from 'three-stdlib'; import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; -type Limits = { min: number; max: number }; -type LimitMap = Record; - -// --- SOLVER --- -const solveWallLimits = (partitions: Partition[]): LimitMap => { - const limits: LimitMap = {}; - partitions.forEach(p => { limits[p.id] = { min: 0, max: 1 }; }); - for (let pass = 0; pass < 4; pass++) { - partitions.forEach(target => { - let newMin = 0; - let newMax = 1; - const center = target.offset; - partitions.forEach(obstacle => { - if (target.id === obstacle.id || target.axis === obstacle.axis) return; - const obsMin = limits[obstacle.id].min; - const obsMax = limits[obstacle.id].max; - const EPS = 0.002; - if (target.offset >= obsMin - EPS && target.offset <= obsMax + EPS) { - if (obstacle.offset < center) newMin = Math.max(newMin, obstacle.offset); - else if (obstacle.offset > center) newMax = Math.min(newMax, obstacle.offset); - } - }); - limits[target.id] = { min: newMin, max: newMax }; - }); - } - return limits; -}; +// --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ --- 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 safeParts = splits?.partitions || {}; + const xPoints = [0, ...[...safeX].sort((a, b) => a - b), 1]; const yPoints = [0, ...[...safeY].sort((a, b) => a - b), 1]; + let partCounter = 1; + for (let i = 0; i < xPoints.length - 1; i++) { for (let j = 0; j < yPoints.length - 1; j++) { const rawW = (xPoints[i + 1] - xPoints[i]) * config.drawer.width; const rawD = (yPoints[j + 1] - yPoints[j]) * config.drawer.depth; + if (rawW < 5 || rawD < 5) continue; + const rawX = xPoints[i] * config.drawer.width; const rawY = yPoints[j] * config.drawer.depth; const internalPartitions = safeParts[`${i}-${j}`] || []; - const realWidth = rawW - config.printerTolerance; - const realDepth = rawD - config.printerTolerance; - const realX = rawX + (config.printerTolerance / 2); - const realY = rawY + (config.printerTolerance / 2); + + // Внутренний отступ для визуализации "объема" (цветных кубиков) + // Чтобы они не сливались со стенками + const gap = config.wallThickness + 0.5; + + const realWidth = rawW - gap * 2; + const realDepth = rawD - gap * 2; + const realX = rawX + gap; + const realY = rawY + gap; + parts.push({ id: `part-${partCounter}`, name: `Ячейка ${i+1}-${j+1}`, - width: realWidth, - depth: realDepth, + width: Math.max(1, realWidth), + depth: Math.max(1, realDepth), height: config.drawer.height, x: realX, y: realY, @@ -67,54 +52,67 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat return parts; }; -// --- ГЕОМЕТРИЯ С ОТВЕРСТИЯМИ --- +// --- ГЕНЕРАЦИЯ ФОРМ С ОТВЕРСТИЯМИ --- -// Функция добавляет отверстия в THREE.Shape -const applyPerforationToShape = (shape: THREE.Shape, width: number, height: number, config: AppConfig) => { - if (!config.perforation?.enabled) return; +// Создает форму прямоугольника с отверстиями по паттерну +const createPerforatedShape = (width: number, height: number, config: AppConfig): THREE.Shape => { + const shape = new THREE.Shape(); + // Рисуем внешний контур (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 < 15 || height < 15) return shape; const { pattern, diameter, spacing } = config.perforation; - const step = diameter + Math.max(2, spacing); // Шаг сетки + const step = diameter + Math.max(2, spacing); - // Отступы от краев (чтобы не портить структуру) - const marginX = 4; - const marginY = 4; + // Отступы от краев (чтобы не портить прочность) + const margin = 6; - // Генерируем сетку отверстий - const cols = Math.floor((width - marginX * 2) / step); - const rows = Math.floor((height - marginY * 2) / (pattern === 'circle' ? step : step * 0.866)); + // Эффективная область для дырок + const effW = width - margin * 2; + const effH = height - margin * 2; - // Центрируем паттерн - const startX = (width - ((cols - 1) * step)) / 2; - const startY = (height - ((rows - 1) * (pattern === 'circle' ? step : step * 0.866))) / 2; + if (effW <= 0 || effH <= 0) return shape; + + // Расчет сетки + const rowHeight = pattern === 'circle' ? step : step * 0.866; + const cols = Math.floor(effW / step); + const rows = Math.floor(effH / rowHeight); + + // Центрирование + const startX = margin + (effW - (cols - 1) * step) / 2; + const startY = margin + (effH - (rows - 1) * rowHeight) / 2; for (let j = 0; j < rows; j++) { const isOdd = j % 2 !== 0; - const y = startY + j * (pattern === 'circle' ? step : step * 0.866); + const y = startY + j * rowHeight; for (let i = 0; i < cols; i++) { let x = startX + i * step; if ((pattern === 'hexagon' || pattern === 'triangle') && isOdd) x += step / 2; - // Доп. проверка границ - if (x < marginX + diameter/2 || x > width - marginX - diameter/2) continue; - if (y < marginY + diameter/2 || y > height - marginY - diameter/2) continue; + // Проверка, что отверстие внутри (с запасом на радиус) + const r = diameter / 2; + if (x - r < margin || x + r > width - margin || y - r < margin || y + r > height - margin) continue; const hole = new THREE.Path(); - const r = diameter / 2; - + if (pattern === 'circle') { - hole.absarc(x, y, r, 0, Math.PI * 2, true); + hole.absarc(x, y, r, 0, Math.PI * 2, true); // CW для отверстий } else if (pattern === 'hexagon') { for (let k = 0; k < 6; k++) { - const angle = (k * 60 + 30) * Math.PI / 180; + const angle = (k * 60 + 30) * Math.PI / 180; // 30 deg offset for flat top const px = x + r * Math.cos(angle); const py = y + 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; @@ -127,236 +125,232 @@ const applyPerforationToShape = (shape: THREE.Shape, width: number, height: numb shape.holes.push(hole); } } -}; -const createRectWithHoles = (w: number, h: number, config: AppConfig): THREE.Shape => { - const shape = new THREE.Shape(); - shape.moveTo(0, 0); - shape.lineTo(w, 0); - shape.lineTo(w, h); - shape.lineTo(0, h); - shape.lineTo(0, 0); - applyPerforationToShape(shape, w, h, config); return shape; }; -// Стандартный прямоугольник для пола (без дырок) -const createRoundedRectShape = (width: number, height: number, radius: number): THREE.Shape => { +// Форма пола со скругленными углами +const createFloorShape = (width: number, depth: number, radius: number): THREE.Shape => { const shape = new THREE.Shape(); const x = -width / 2; - const y = -height / 2; - const r = Math.min(radius, width / 2 - 0.1, height / 2 - 0.1); + const y = -depth / 2; + const r = Math.min(radius, width / 2, depth / 2); + if (r <= 0.1) { - shape.moveTo(x, y); shape.lineTo(x + width, y); shape.lineTo(x + width, y + height); shape.lineTo(x, y + height); shape.lineTo(x, y); + 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 + height - r); shape.quadraticCurveTo(x, y + height, x + r, y + height); - shape.lineTo(x + width - r, y + height); shape.quadraticCurveTo(x + width, y + height, x + width, y + height - 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); + 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; }; -const createConcaveFilletShape = (radius: number): THREE.Shape => { +// Галтель (вогнутый уголок) +const createFilletShape = (radius: number): THREE.Shape => { const shape = new THREE.Shape(); - shape.moveTo(0, 0); shape.lineTo(radius, 0); shape.absarc(radius, radius, radius, -Math.PI / 2, -Math.PI, true); shape.lineTo(0, 0); return shape; + shape.moveTo(0, 0); + shape.lineTo(radius, 0); + shape.absarc(radius, radius, radius, -Math.PI / 2, -Math.PI, true); + shape.lineTo(0, 0); + return shape; }; -// --- ГЛАВНАЯ ФУНКЦИЯ ГЕНЕРАЦИИ --- +// --- ГЛАВНАЯ ФУНКЦИЯ --- + export const createBinGeometry = ( - width: number, depth: number, height: number, thickness: number, radius: number = 0, partitions: Partition[] = [], config?: AppConfig // Config нужен для дырок + width: number, depth: number, height: number, thickness: number, radius: number = 0, partitions: Partition[] = [], config?: AppConfig ): THREE.BufferGeometry => { const geometries: THREE.BufferGeometry[] = []; - - // Безопасный конфиг если не передан const safeConfig = config || { perforation: { enabled: false } } as AppConfig; - // 1. ДНО (Floor) - Без дырок - const floorShape = createRoundedRectShape(width, depth, radius); + // 1. ПОЛ (Сплошной) + const floorShape = createFloorShape(width, depth, radius); const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false }); - floorGeo.rotateX(-Math.PI / 2); + floorGeo.rotateX(-Math.PI / 2); // Кладем на плоскость XZ geometries.push(floorGeo); - // 2. ВНЕШНИЕ СТЕНКИ (С ДЫРКАМИ) - // Мы строим их как 4 отдельные панели, чтобы можно было применить 2D паттерн дырок - const innerW = width - 2 * thickness; - const innerD = depth - 2 * thickness; + // 2. ВНЕШНИЕ СТЕНКИ + // Строим их "лежа" в плоскости XY, а потом поворачиваем и ставим на место. + // Это позволяет использовать 2D логику для отверстий. + const wallH = height - thickness; + const sideWallW = depth - (2 * thickness); // Боковые стенки встанут МЕЖДУ передней и задней - // Front & Back Walls (Width x Height) - const wallShapeFB = createRectWithHoles(innerW, wallH, safeConfig); - // Left & Right Walls (Depth x Height) - используем полную глубину минус отступы, чтобы углы сошлись - // Но для простоты: Front/Back стоят "между" Left/Right. - // Left/Right имеют длину = depth. Front/Back длину = width - 2*thick. - const wallShapeLR = createRectWithHoles(depth, wallH, safeConfig); + // Передняя и Задняя (Полная ширина) + const shapeFB = createPerforatedShape(width, wallH, safeConfig); + // Левая и Правая (Укороченные, чтобы встать в паз) + const shapeLR = createPerforatedShape(sideWallW, wallH, safeConfig); - // Функция для создания стенки, её экструзии и поворота - const addWall = (shape: THREE.Shape, len: number, pos: [number, number, number], rotY: number) => { - // Shape рисуется от 0,0. Экструдим на толщину. + // Функция для позиционирования стенки + const placeWall = (shape: THREE.Shape, x: number, y: number, z: number, rotY: number) => { const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }); - // Центрируем shape по X (чтобы вращать удобно) - geo.translate(-len/2, 0, 0); + // Центрируем пивот по X для удобного вращения, если нужно, или просто сдвигаем + // По умолчанию Shape рисуется от 0,0 в +X,+Y. Extrude идет в +Z. - // Поворачиваем: Сначала "поднимаем" shape вертикально? - // По умолчанию shape в XY плоскости. Extrude идет в Z. - // Нам нужно чтобы стена стояла. - // XY plane -> Wall upright. + // Сдвигаем pivot в центр по X (ширине стенки) + // Нет, проще оперировать от угла. + // 0,0 shape -> это нижний левый угол стенки. - // Сдвигаем на позицию (x, y=thickness, z) - // Y в ThreeJS это "вверх". Стенки начинаются с floor thickness. + geo.translate(0, thickness, 0); // Поднимаем на толщину пола (Y) - // Корректировка пивота - geo.translate(len/2, 0, 0); // Вернули в 0..len по X - geo.translate(-len/2, 0, 0); // Центрируем: -len/2 .. len/2 + // Вращаем вокруг Y + // Внимание: вращение идет вокруг (0,0,0) сцены, поэтому сначала вращаем, потом двигаем + + // 1. Поворот самой геометрии относительно её начала + if (rotY !== 0) { + geo.rotateY(rotY); + } - // Вращение - geo.rotateY(rotY); - // Позиция - geo.translate(pos[0], thickness, pos[2]); // Y = thickness (на полу) + // 2. Перенос на позицию + geo.translate(x, 0, z); + geometries.push(geo); }; - // ! ВАЖНО: Текущая реализация createRectWithHoles рисует прямоугольник 0..W, 0..H в плоскости XY. - // Extrude добавляет глубину Z. - // Стенка "стоит" если Z - это толщина. - - // Front Wall (Z = innerD/2 + thick/2 = depth/2 - thick/2) -> Позиция Z чуть смещена - // Back Wall (Z = -depth/2 + thick/2) - - // Front (Z+) - const geoF = new THREE.ExtrudeGeometry(wallShapeFB, { depth: thickness, bevelEnabled: false }); - geoF.translate(-innerW/2, 0, 0); // Центрируем по X - geoF.translate(0, thickness, innerD/2); // Поднимаем на пол, сдвигаем вперед - geometries.push(geoF); + // Back Wall (Задняя) + // Стоит вдоль X. Позиция: x=-width/2, z=-depth/2. + // Рисуется от 0 до width. + const geoBack = new THREE.ExtrudeGeometry(shapeFB, { depth: thickness, bevelEnabled: false }); + geoBack.translate(-width/2, thickness, -depth/2); // Ставим назад + geometries.push(geoBack); - // Back (Z-) - const geoB = new THREE.ExtrudeGeometry(wallShapeFB, { depth: thickness, bevelEnabled: false }); - geoB.translate(-innerW/2, 0, -thickness); // Центрируем, толщина назад - geoB.translate(0, thickness, -innerD/2); - geometries.push(geoB); + // Front Wall (Передняя) + // Стоит вдоль X. Позиция: x=-width/2, z=depth/2 - thickness. + const geoFront = new THREE.ExtrudeGeometry(shapeFB, { depth: thickness, bevelEnabled: false }); + geoFront.translate(-width/2, thickness, depth/2 - thickness); + geometries.push(geoFront); - // Left (X-) - const geoL = new THREE.ExtrudeGeometry(wallShapeLR, { depth: thickness, bevelEnabled: false }); - geoL.rotateY(Math.PI / 2); // Поворачиваем на 90 - geoL.translate(-width/2, thickness, -depth/2); // Ставим слева - geometries.push(geoL); + // Left Wall (Левая) + // Стоит вдоль Z. Повернута на 90 град. + // Длина = sideWallW. + const geoLeft = new THREE.ExtrudeGeometry(shapeLR, { depth: thickness, bevelEnabled: false }); + geoLeft.rotateY(Math.PI / 2); + // После поворота на 90: +X стал +Z. Начало в 0,0. + // Нам нужно поставить её на x = -width/2, z = -sideWallW/2 (центрировать по глубине) + // С учетом толщины пола и стенок: + geoLeft.translate(-width/2, thickness, -sideWallW/2); + geometries.push(geoLeft); - // Right (X+) - const geoR = new THREE.ExtrudeGeometry(wallShapeLR, { depth: thickness, bevelEnabled: false }); - geoR.rotateY(Math.PI / 2); - geoR.translate(width/2 - thickness, thickness, -depth/2); - geometries.push(geoR); + // Right Wall (Правая) + const geoRight = new THREE.ExtrudeGeometry(shapeLR, { depth: thickness, bevelEnabled: false }); + geoRight.rotateY(Math.PI / 2); + geoRight.translate(width/2 - thickness, thickness, -sideWallW/2); + geometries.push(geoRight); - // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ (С ДЫРКАМИ) - const limitMap = solveWallLimits(partitions); - + // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ + // Используем простую логику, как в редакторе (min/max) partitions.forEach(p => { - const { min: pMin, max: pMax } = limitMap[p.id]; + const pMin = p.min ?? 0; + const pMax = p.max ?? 1; + + // Пропускаем слишком короткие или ошибочные if (pMax - pMin < 0.01) return; - const lengthRatio = pMax - pMin; - - // Реальная длина стенки в мм - let wallLen = 0; - let pX = 0, pZ = 0; // Центр стенки - let rotY = 0; + const innerW = width - 2 * thickness; + const innerD = depth - 2 * thickness; + + let partLen = 0; + let posX = 0; + let posZ = 0; + let isVertical = false; if (p.axis === 'x') { - // Вертикальная на экране = Вдоль Z в 3D - wallLen = lengthRatio * innerD; - // Позиция центра - pX = (-innerW / 2) + (innerW * p.offset); - // Начало по Z - const startZ = (-innerD / 2) + (innerD * pMin); - pZ = startZ; - rotY = Math.PI / 2; + // Вертикальная на экране (вдоль Z) + partLen = (pMax - pMin) * innerD; + isVertical = true; + // X координата (центр линии) + posX = (-innerW/2) + (p.offset * innerW); + // Z координата (начало линии) + posZ = (-innerD/2) + (pMin * innerD); } else { - // Горизонтальная на экране = Вдоль X в 3D - wallLen = lengthRatio * innerW; - const startX = (-innerW / 2) + (innerW * pMin); - pX = startX; - pZ = (-innerD / 2) + (innerD * p.offset); - rotY = 0; + // Горизонтальная на экране (вдоль X) + partLen = (pMax - pMin) * innerW; + isVertical = false; + // X координата (начало линии) + posX = (-innerW/2) + (pMin * innerW); + // Z координата (центр линии) + posZ = (-innerD/2) + (p.offset * innerD); } - // Создаем профиль с дырками - const partShape = createRectWithHoles(wallLen, wallH, safeConfig); + // Создаем стенку с дырками + const partShape = createPerforatedShape(partLen, wallH, safeConfig); const partGeo = new THREE.ExtrudeGeometry(partShape, { depth: thickness, bevelEnabled: false }); - // Поворачиваем и ставим на место - // Изначально shape в XY (0..len, 0..height) - if (p.axis === 'x') { - // Нужно повернуть Y 90. + if (isVertical) { partGeo.rotateY(Math.PI / 2); - // После поворота: X -> Z, Y -> Y, Z -> X - // Начало было 0,0,0. Стало 0,0,0. Длина ушла в +Z. - partGeo.translate(pX - thickness/2, thickness, pZ); + // Центрируем толщину: offset - thickness/2 + partGeo.translate(posX - thickness/2, thickness, posZ); } else { - // Вдоль X. Ничего вращать не надо, кроме смещения на толщину - partGeo.translate(pX, thickness, pZ - thickness/2); + // Вдоль X + partGeo.translate(posX, thickness, posZ - thickness/2); } - + geometries.push(partGeo); - // --- FILLETS (Остаются как были, они вертикальные, дырки их не касаются) --- + // --- СКРУГЛЕНИЯ (FILLETS) --- + // Добавляем только если есть примыкание if (p.rounded && radius > 1) { - // Логика галтелей остается прежней (она работает хорошо) const filletR = Math.min(radius, 5); - const filletShape = createConcaveFilletShape(filletR); - - const getNeighborHeight = (pos: number) => { - if (pos < 0.001 || pos > 0.999) return height; - const neighbor = partitions.find(n => { - if (n.axis === p.axis) return false; - const nLims = limitMap[n.id]; - return Math.abs(n.offset - pos) < 0.002 && p.offset >= nLims.min && p.offset <= nLims.max; - }); - return neighbor ? neighbor.height : 0; - }; + const filletShape = createFilletShape(filletR); + const h = p.height; // Пока берем полную высоту, чтобы не усложнять - const hStart = Math.min(p.height, getNeighborHeight(pMin)); - const hEnd = Math.min(p.height, getNeighborHeight(pMax)); - - const addFillet = (x: number, y: number, rot: number, h: number) => { - if (h <= 1) return; + const addFillet = (fx: number, fz: number, rot: number) => { const geo = new THREE.ExtrudeGeometry(filletShape, { depth: h, bevelEnabled: false }); - geo.rotateX(-Math.PI / 2); - geo.rotateY(rot); - geo.translate(x, thickness, y); + geo.rotateX(-Math.PI / 2); // Кладем плашмя + geo.rotateY(rot); // Крутим вокруг оси Y + geo.translate(fx, thickness, fz); geometries.push(geo); }; const t = thickness / 2; - // Координаты для галтелей - if (p.axis === 'x') { - // ... тот же код галтелей - const topZ = (-innerD / 2) + (innerD * pMin); - const botZ = (-innerD / 2) + (innerD * pMax); - const centerX = (-innerW/2) + (innerW * p.offset); - addFillet(centerX - t, topZ, Math.PI, hStart); - addFillet(centerX + t, topZ, -Math.PI / 2, hStart); - addFillet(centerX - t, botZ, Math.PI / 2, hEnd); - addFillet(centerX + t, botZ, 0, hEnd); + if (isVertical) { + // Концы вертикальной стенки (по Z) + const topZ = posZ; // pMin + const botZ = posZ + partLen; // pMax + + // Top junction + addFillet(posX - t, topZ, Math.PI); + addFillet(posX + t, topZ, -Math.PI/2); + // Bottom junction + addFillet(posX - t, botZ, Math.PI/2); + addFillet(posX + t, botZ, 0); } else { - const leftX = (-innerW / 2) + (innerW * pMin); - const rightX = (-innerW / 2) + (innerW * pMax); - const centerZ = (-innerD/2) + (innerD * p.offset); - - addFillet(leftX, centerZ - t, 0, hStart); - addFillet(leftX, centerZ + t, -Math.PI / 2, hStart); - addFillet(rightX, centerZ - t, Math.PI / 2, hEnd); - addFillet(rightX, centerZ + t, Math.PI, hEnd); + // Концы горизонтальной стенки (по X) + const leftX = posX; // pMin + const rightX = posX + partLen; // pMax + + // Left junction + addFillet(leftX, posZ - t, 0); + addFillet(leftX, posZ + t, -Math.PI/2); + // Right junction + addFillet(rightX, posZ - t, Math.PI/2); + addFillet(rightX, posZ + t, Math.PI); } } }); const merged = mergeBufferGeometries(geometries); - if (merged) merged.computeVertexNormals(); - return merged || new THREE.BoxGeometry(1, 1, 1); + if (merged) { + merged.computeVertexNormals(); + return merged; + } + return new THREE.BoxGeometry(1, 1, 1); }; +// Экспорт STL export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => { const exporter = new STLExporter(); const result = exporter.parse(mesh, { binary: true });