import * as THREE from 'three'; import { STLExporter, mergeBufferGeometries } from 'three-stdlib'; import { AppConfig, LayoutSplits, GeneratedPart, Partition, PerforationConfig } from '../types'; 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); parts.push({ id: `part-${partCounter}`, name: `Ячейка ${i + 1}-${j + 1}`, width: realWidth, depth: realDepth, height: config.drawer.height, x: realX, y: realY, color: `hsl(${Math.random() * 360}, 70%, 50%)`, internalPartitions: internalPartitions }); partCounter++; } } return parts; }; // --- ГЕОМЕТРИЯ --- const createRoundedRectShape = (width: number, height: 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); 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); } 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); } return shape; }; const createConcaveFilletShape = (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; }; // Вспомогательная функция: Создание перфорированной пластины (Вертикальная стенка) const createPerforatedPlate = ( width: number, height: number, thickness: number, perf: PerforationConfig, marginLeft: number = 0, marginRight: number = 0, exclusions: { start: number, end: number, yMax?: number }[] = [] ): THREE.BufferGeometry => { const shape = new THREE.Shape(); shape.moveTo(0, 0); shape.lineTo(width, 0); shape.lineTo(width, height); shape.lineTo(0, height); shape.lineTo(0, 0); // Генерация отверстий if (perf && perf.enabled && width > perf.size && height > perf.size) { const { shape: shapeType, size, gap } = perf; const step = size + gap; // Отступы: предотвращаем попадание отверстий на сплошные края const startX = Math.max(gap, marginLeft + gap); const startY = gap; const endX = width - Math.max(gap, marginRight + gap); const endY = height - gap; // Строки let row = 0; for (let y = startY + size / 2; y < endY; y += (shapeType === 'triangle' || shapeType === 'honeycomb' ? step * 0.866 : step)) { const isStaggered = (row % 2 !== 0); const xOffset = (isStaggered && (shapeType === 'honeycomb' || shapeType === 'triangle')) ? step / 2 : 0; for (let x = startX + size / 2 + xOffset; x < endX; x += step) { const hole = new THREE.Path(); const r = size / 2; // Проверка границ if (x - r < marginLeft || x + r > width - marginRight || y - r < 0 || y + r > height) continue; // Проверка зон исключения // Зона активна, если X отверстия входит в [start, end] И Y отверстия ниже yMax (если указан). // Если y > yMax, исключение не применяется (отверстие выше пересекающей стенки). // Примечание: y измеряется от низа (0) до верха (height). // yMax - высота пересекающей перегородки. const inExclusion = exclusions.some(zone => { if (x + r <= zone.start || x - r >= zone.end) return false; // Проверка по оси X if (zone.yMax !== undefined && y - r > zone.yMax) return false; // Проверка по оси Y (отверстие выше стенки) return true; }); if (inExclusion) continue; if (shapeType === 'circle') { hole.absarc(x, y, r, 0, Math.PI * 2, true); } else if (shapeType === 'honeycomb') { // Шестиугольник (Соты) for (let i = 0; i < 6; i++) { const ang = (i * 60 + 30) * Math.PI / 180; const px = x + r * Math.cos(ang); const py = y + r * Math.sin(ang); if (i === 0) hole.moveTo(px, py); else hole.lineTo(px, py); } hole.closePath(); } else if (shapeType === 'triangle') { // Треугольник const ang1 = -90 * Math.PI / 180; const ang2 = 30 * Math.PI / 180; const ang3 = 150 * Math.PI / 180; hole.moveTo(x + r * Math.cos(ang1), y + r * Math.sin(ang1)); hole.lineTo(x + r * Math.cos(ang2), y + r * Math.sin(ang2)); hole.lineTo(x + r * Math.cos(ang3), y + r * Math.sin(ang3)); hole.closePath(); } shape.holes.push(hole); } row++; } } const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }); // Выдавливание по Z. Стенка плоская в XY. // Мы хотим, чтобы "thickness" была глубиной по Z. return geo; }; // Вспомогательная функция: Создание профиля угла (Выдавленный вертикально) const createCornerProfile = (radius: number, thickness: number, height: number): THREE.BufferGeometry => { if (radius <= 0) return new THREE.BufferGeometry(); const shape = new THREE.Shape(); // Создаем сегмент кольца (Полый угол) как единый контур. // Центр в (0,0). const innerRadius = Math.max(0.01, radius - thickness); // Гарантируем чуть > 0 для целостности формы // 1. Начало внешней дуги shape.moveTo(radius, 0); // 2. Внешняя дуга (против часовой стрелки) -> К (0, radius) shape.absarc(0, 0, radius, 0, Math.PI / 2, false); // 3. Линия к внутреннему концу (0, innerRadius) shape.lineTo(0, innerRadius); // 4. Внутренняя дуга (по часовой стрелке) -> К (innerRadius, 0) shape.absarc(0, 0, innerRadius, Math.PI / 2, 0, true); // 5. Замыкаем контур shape.lineTo(radius, 0); // Выдавливание // curveSegments 32 для гладкости const geo = new THREE.ExtrudeGeometry(shape, { depth: height, bevelEnabled: false, curveSegments: 32 }); return geo; }; export const createBinGeometry = ( width: number, depth: number, height: number, thickness: number, radius: number = 0, partitions: Partition[] = [], perforation?: PerforationConfig ): THREE.BufferGeometry => { const geometries: THREE.BufferGeometry[] = []; // ДНО (Floor) - Always same const floorShape = createRoundedRectShape(width, depth, radius); const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false }); floorGeo.rotateX(-Math.PI / 2); // Lay flat geometries.push(floorGeo); // СТЕНКИ if (!perforation || !perforation.enabled) { // --- ЛОГИКА ДЛЯ СПЛОШНЫХ СТЕН (Без перфорации) --- const outerShape = createRoundedRectShape(width, depth, radius); const innerRadius = Math.max(0.1, radius - thickness); const innerWidth = width - (2 * thickness); const innerDepth = depth - (2 * thickness); if (innerWidth > 0.1 && innerDepth > 0.1) { const innerHole = createRoundedRectShape(innerWidth, innerDepth, innerRadius); outerShape.holes.push(innerHole); } const wallHeight = height - thickness; const wallGeo = new THREE.ExtrudeGeometry(outerShape, { depth: wallHeight, bevelEnabled: false }); wallGeo.rotateX(-Math.PI / 2); wallGeo.translate(0, thickness, 0); geometries.push(wallGeo); } else { // --- ЛОГИКА ДЛЯ ПЕРФОРИРОВАННЫХ СТЕН (Раздельные части) --- const wallHeight = height - thickness; // Ограничиваем радиус минимум толщиной стенки для корректных углов const effRadius = Math.max(radius, thickness); const straightW = width - 2 * effRadius; const straightD = depth - 2 * effRadius; // 1. Углы (4 шт) if (effRadius > 0) { const cornerGeoBase = createCornerProfile(effRadius, thickness, wallHeight); // 1. Поднимаем: Выдавливание Z -> Y. Форма перемещается в X(+)/Z(+). cornerGeoBase.rotateX(-Math.PI / 2); // Базовая форма угла (после rotateX) это Q4 (+X, -Z). // Логика вращения: -90 градусов на квадрант (Стандартный Y-Rot в Three.js). // Q4 (0) -> Q1 (-90) -> Q2 (-180/180) -> Q3 (-270/+90). const positions = [ // Задний Правый (+X, +Z). Q1. // Поворот -90 (-PI/2). { x: width / 2 - effRadius, z: depth / 2 - effRadius, rot: -Math.PI / 2 }, // Задний Левый (-X, +Z). Q2. // Поворот 180 (PI). { x: -(width / 2 - effRadius), z: depth / 2 - effRadius, rot: Math.PI }, // Передний Левый (-X, -Z). Q3. // Поворот 90 (PI/2). (Эквивалентно -270). { x: -(width / 2 - effRadius), z: -(depth / 2 - effRadius), rot: Math.PI / 2 }, // Передний Правый (+X, -Z). Q4. // Поворот 0. { x: width / 2 - effRadius, z: -(depth / 2 - effRadius), rot: 0 } ]; positions.forEach(pos => { const corner = cornerGeoBase.clone(); corner.rotateY(pos.rot); corner.translate(pos.x, 0, pos.z); // Старт на Y=0 corner.translate(0, thickness, 0); // Сдвиг на толщину дна geometries.push(corner); }); } // 2. Прямые стены (4 шт) - Центрированы по краям // ЛОГИКА СБОРА ИСКЛЮЧЕНИЙ ДЛЯ СТЕН const exclusionsBack: { start: number, end: number, yMax: number }[] = []; const exclusionsFront: { start: number, end: number, yMax: number }[] = []; const exclusionsLeft: { start: number, end: number, yMax: number }[] = []; const exclusionsRight: { start: number, end: number, yMax: number }[] = []; // Внутренние размеры const effectiveInnerW = width - 2 * thickness; const effectiveInnerD = depth - 2 * thickness; partitions.forEach(p => { const hEff = Math.max(0.1, p.height - thickness); // Исключаем только до высоты перегородки // Гарантируем сплошную полосу вокруг перегородки добавлением отступа к зоне исключения const padding = (perforation?.gap ?? 2) + 1; if (p.axis === 'x') { // Идет по глубине (Ось Y в макете). // Пересекает Переднюю и Заднюю стенки. const partGlobalX = (-effectiveInnerW / 2) + (effectiveInnerW * p.offset); const sW = width - 2 * effRadius; const localXOnWall = partGlobalX + sW / 2; // Проверка пересечения с активной зоной стены + отступ if (localXOnWall + thickness / 2 + padding > 0 && localXOnWall - thickness / 2 - padding < sW) { const zone = { start: localXOnWall - thickness / 2 - padding, end: localXOnWall + thickness / 2 + padding, yMax: hEff }; if ((p.max ?? 1) > 0.99) exclusionsFront.push(zone); // Ближняя if ((p.min ?? 0) < 0.01) exclusionsBack.push(zone); // Дальняя } } else { // p.axis === 'y' // Идет по ширине (Ось X в макете). // Пересекает Левую и Правую стенки. const partGlobalZ = (-effectiveInnerD / 2) + (effectiveInnerD * p.offset); const sD = depth - 2 * effRadius; const localXOnWall = partGlobalZ + sD / 2; if (localXOnWall + thickness / 2 + padding > 0 && localXOnWall - thickness / 2 - padding < sD) { const zone = { start: localXOnWall - thickness / 2 - padding, end: localXOnWall + thickness / 2 + padding, yMax: hEff }; if ((p.min ?? 0) < 0.01) { exclusionsLeft.push(zone); } if ((p.max ?? 1) > 0.99) { exclusionsRight.push(zone); } } } }); // Передняя/Задняя if (straightW > 0.1) { // Стенка 1 (+Z). Это "Передняя" (Ближняя). Контактирует с p.max. // Использует exclusionsFront. const wGeoFront = createPerforatedPlate(straightW, wallHeight, thickness, perforation, 0, 0, exclusionsFront); wGeoFront.translate(-straightW / 2, 0, 0); const w1 = wGeoFront.clone(); w1.translate(0, thickness, depth / 2 - thickness); geometries.push(w1); // Стенка 2 (-Z). Это "Задняя" (Дальняя). Контактирует с p.min. // Использует exclusionsBack. // Нужна проверка маппинга (Поворот 180). // p.offset увеличивает X. Стенка повернута на 180, значит X инвертирован. // Маппим exclusionsBack. const exclusionsBackMapped = exclusionsBack.map(e => ({ start: straightW - e.end, end: straightW - e.start, yMax: e.yMax })); const wGeoBack = createPerforatedPlate(straightW, wallHeight, thickness, perforation, 0, 0, exclusionsBackMapped); wGeoBack.translate(-straightW / 2, 0, 0); const w2 = wGeoBack.clone(); w2.rotateY(Math.PI); w2.translate(0, thickness, -(depth / 2 - thickness)); geometries.push(w2); } // Левая/Правая if (straightD > 0.1) { // Стенка 3 (Правая +X). // Пластина 0..straightD по X, 0..wallHeight по Y. Толщина по Z. // Хотим разместить на X = width/2. // Нужно повернуть -PI/2 вокруг Y чтобы выровнять ось X пластины с мировой осью Z. const wGeoRight = createPerforatedPlate(straightD, wallHeight, thickness, perforation, 0, 0, exclusionsRight); wGeoRight.translate(-straightD / 2, 0, 0); // Центр X пластины const w3 = wGeoRight.clone(); w3.rotateY(-Math.PI / 2); // Поворот w3.translate(width / 2, thickness, 0); // Позиция на X=width/2 geometries.push(w3); // Стенка 4 (Левая -X). // Также повернута на PI/2. // Толщина по Z станем Мировой X (направо). // Хотим на X=-width/2. const exclusionsLeftMapped = exclusionsLeft.map(e => ({ start: straightD - e.end, end: straightD - e.start, yMax: e.yMax })); const wGeoLeft = createPerforatedPlate(straightD, wallHeight, thickness, perforation, 0, 0, exclusionsLeftMapped); wGeoLeft.translate(-straightD / 2, 0, 0); // Центр X пластины const w4 = wGeoLeft.clone(); w4.rotateY(Math.PI / 2); w4.translate(-width / 2, thickness, 0); // Позиция на X=-width/2 geometries.push(w4); } } // ВНУТРЕННИЕ ПЕРЕГОРОДКИ const innerWidth = width - (2 * thickness); const innerDepth = depth - (2 * thickness); // Приблизительное полезное пространство partitions.forEach(p => { const pMin = p.min ?? 0; const pMax = p.max ?? 1; if (pMax - pMin < 0.01) return; const lengthRatio = pMax - pMin; const midRatio = pMin + (lengthRatio / 2); // ИСПРАВЛЕНИЕ: Вычитаем толщину, так как перегородки стоят НА дне const effectiveHeight = Math.max(0.1, p.height - thickness); // --- ЛОГИКА ДЛЯ ПЕРФОРИРОВАННЫХ ПЕРЕГОРОДОК --- // Если включено, используем Пластину. Иначе - сплошное Выдавливание. const usePerf = perforation && perforation.enabled; let pX = 0, pY = 0; // Объявляем здесь для видимости в Скруглениях if (usePerf) { // Вычисляем точную геометрию let pLen = 0; if (p.axis === 'x') { // Ось X -> Разделитель идет вдоль Y (Глубина) pLen = lengthRatio * innerDepth; pX = (-innerWidth / 2) + (innerWidth * p.offset); pY = (-innerDepth / 2) + (innerDepth * midRatio); // Центр перегородки // Вычисляем исключения для внутренней перегородки const partExclusions: { start: number, end: number, yMax?: number }[] = []; // Эта перегородка 'p' (Ось X, идет по Глубине). // Пересекается перегородками 'n' (Ось Y, идут по Ширине). partitions.forEach(n => { if (n.axis !== 'y') return; // Пересекаются только ортогональные перегородки // Проверяем, пересекает ли n перегородку p // Y-координата пересечения: const nY = (-innerDepth / 2) + (innerDepth * n.offset); const pStartGlobal = pY - pLen / 2; const pEndGlobal = pY + pLen / 2; // И проверяем, покрывает ли n позицию X перегородки p. // n идет по X от nMin до nMax. const nXStart = (-innerWidth / 2) + (innerWidth * (n.min ?? 0)); const nXEnd = (-innerWidth / 2) + (innerWidth * (n.max ?? 1)); const pXLoc = pX; if (nY >= pStartGlobal && nY <= pEndGlobal && pXLoc >= nXStart && pXLoc <= nXEnd) { // Пересечение подтверждено. // Вычисляем локальные координаты на пластине p. // p идет по Y. Локальный X пластины мапится на глобальный Y. const nHeight = Math.max(0.1, n.height - thickness); const localX = nY - pY + pLen / 2; partExclusions.push({ start: localX - thickness / 2, end: localX + thickness / 2, yMax: nHeight }); } }); // Добавляем отступы (сплошные концы) const margin = thickness; const plate = createPerforatedPlate(pLen, effectiveHeight, thickness, perforation!, margin, margin, partExclusions); plate.translate(-pLen / 2, 0, 0); // Центр X // Поворачиваем для выравнивания по Глубине (вдоль Z) // Пластина X -> Z plate.rotateY(-Math.PI / 2); // Позиционирование // Пластина теперь вертикально по Z. Толщина по X. plate.translate(pX + thickness / 2, thickness, pY); geometries.push(plate); } else { // Ось Y -> Разделитель идет вдоль X (Ширина) pLen = lengthRatio * innerWidth; pX = (-innerWidth / 2) + (innerWidth * midRatio); pY = (-innerDepth / 2) + (innerDepth * p.offset); // Вычисление исключений const partExclusions: { start: number, end: number, yMax?: number }[] = []; // Эта перегородка 'p' (Ось Y, идет по Ширине). // Пересекается перегородками 'n' (Ось X, идут по Глубине). partitions.forEach(n => { if (n.axis !== 'x') return; const nX = (-innerWidth / 2) + (innerWidth * n.offset); const pStartGlobal = pX - pLen / 2; // Диапазон X для p const pEndGlobal = pX + pLen / 2; const nYStart = (-innerDepth / 2) + (innerDepth * (n.min ?? 0)); const nYEnd = (-innerDepth / 2) + (innerDepth * (n.max ?? 1)); const pYLoc = pY; if (nX >= pStartGlobal && nX <= pEndGlobal && pYLoc >= nYStart && pYLoc <= nYEnd) { // Пересечение подтверждено const nHeight = Math.max(0.1, n.height - thickness); // ВЫСОТА ПЕРЕСЕКАЮЩЕЙ ПЕРЕГОРОДКИ const localX = nX - pX + pLen / 2; partExclusions.push({ start: localX - thickness / 2, end: localX + thickness / 2, yMax: nHeight }); } }); const margin = thickness; const plate = createPerforatedPlate(pLen, effectiveHeight, thickness, perforation!, margin, margin, partExclusions); plate.translate(-pLen / 2, 0, 0); // Центр X // Уже выровнено по X. Толщина по Z. // Диапазон Z [0, th]. Мы хотим [-th/2, th/2] относительно pY. plate.translate(0, 0, -thickness / 2); // Перемещение на позицию plate.translate(pX, thickness, pY); geometries.push(plate); } } else { // --- ЛОГИКА ДЛЯ СПЛОШНЫХ ПЕРЕГОРОДОК --- let pWidth = 0, pDepth = 0; if (p.axis === 'x') { pWidth = thickness; pDepth = lengthRatio * innerDepth; pX = (-innerWidth / 2) + (innerWidth * p.offset); pY = (-innerDepth / 2) + (innerDepth * midRatio); } else { pWidth = lengthRatio * innerWidth; pDepth = thickness; pX = (-innerWidth / 2) + (innerWidth * midRatio); pY = (-innerDepth / 2) + (innerDepth * p.offset); } const partShape = createRoundedRectShape(pWidth, pDepth, 0.1); const partGeo = new THREE.ExtrudeGeometry(partShape, { depth: effectiveHeight, bevelEnabled: false }); partGeo.rotateX(-Math.PI / 2); partGeo.translate(pX, thickness, pY); geometries.push(partGeo); } // Логика скруглений для перегородок (Оставляем сплошными для прочности/эстетики) 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 nMin = n.min ?? 0; const nMax = n.max ?? 1; if (Math.abs(n.offset - pos) > 0.002) return false; return p.offset > nMin && p.offset < nMax; }); return neighbor ? neighbor.height : 0; }; const hStart = Math.min(p.height, getNeighborHeight(pMin)); const hEnd = Math.min(p.height, getNeighborHeight(pMax)); const addFillet = (x: number, y: number, rotY: number, h: number) => { const hEff = Math.max(0.1, h - thickness); if (hEff <= 1) return; const geo = new THREE.ExtrudeGeometry(filletShape, { depth: hEff, bevelEnabled: false }); geo.rotateX(-Math.PI / 2); geo.rotateY(rotY); geo.translate(x, thickness, y); geometries.push(geo); }; const t = thickness / 2; if (p.axis === 'x') { const topY = (-innerDepth / 2) + (innerDepth * pMin); const botY = (-innerDepth / 2) + (innerDepth * pMax); addFillet(pX - t, topY, Math.PI, hStart); addFillet(pX + t, topY, -Math.PI / 2, hStart); addFillet(pX - t, botY, Math.PI / 2, hEnd); addFillet(pX + t, botY, 0, hEnd); } else { const leftX = (-innerWidth / 2) + (innerWidth * pMin); const rightX = (-innerWidth / 2) + (innerWidth * pMax); addFillet(leftX, pY - t, 0, hStart); addFillet(leftX, pY + t, -Math.PI / 2, hStart); addFillet(rightX, pY - t, Math.PI / 2, hEnd); addFillet(rightX, pY + t, Math.PI, hEnd); } } }); 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 as any], { type: 'application/octet-stream' }); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = filename; link.click(); };