From 6d40bc851d32192c0121f443c9fe8097dad16c63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Mon, 12 Jan 2026 02:52:53 +0300 Subject: [PATCH] 6 --- src/services/geometryGenerator.ts | 371 +++++++++++++----------------- 1 file changed, 156 insertions(+), 215 deletions(-) diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index cf1c737..59087f0 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -1,29 +1,26 @@ import * as THREE from 'three'; -import { STLExporter, mergeBufferGeometries } from 'three-stdlib'; +import { STLExporter } from 'three-stdlib'; +import { SUBTRACTION, Brush, Evaluator } from 'three-bvh-csg'; +import { mergeBufferGeometries } from 'three-stdlib'; import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; -// --- УТИЛИТЫ --- +// --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ --- -// Очистка координат (убирает фантомные ячейки) +// Очистка координат с округлением, чтобы убрать "фантомные" микро-ячейки const cleanPoints = (points: number[]) => { - // Округляем и убираем дубликаты с допуском - const sorted = points.map(p => Math.round(p * 1000) / 1000).sort((a, b) => a - b); - const unique = [sorted[0]]; - for (let i = 1; i < sorted.length; i++) { - if (sorted[i] - unique[unique.length - 1] > 0.002) { - unique.push(sorted[i]); - } - } - return unique; + // Округляем до 2 знака (сантиметры/миллиметры), чтобы убрать дрожание float + const rounded = points.map(p => parseFloat(p.toFixed(3))).sort((a, b) => a - b); + // Убираем дубликаты + return [...new Set(rounded)]; }; -// Сбор перегородок +// Получение плоского списка всех перегородок из объекта const getAllPartitions = (splits: LayoutSplits): Partition[] => { if (!splits || !splits.partitions) return []; return Object.values(splits.partitions).flat(); }; -// --- ВИЗУАЛИЗАЦИЯ (ЦВЕТНЫЕ БЛОКИ) --- +// Функция для визуализации (шаг 3 - цветные блоки) export const calculateParts = (config: AppConfig, splits: LayoutSplits): GeneratedPart[] => { const parts: GeneratedPart[] = []; const safeX = Array.isArray(splits?.x) ? splits.x : []; @@ -41,10 +38,9 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat const y1 = uniqueY[j]; const y2 = uniqueY[j+1]; + // Фильтр: если ячейка меньше 2мм - это мусор 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; @@ -57,7 +53,7 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat name: `Ячейка ${partCounter}`, width: Math.max(1, rawW - gap * 2), depth: Math.max(1, rawD - gap * 2), - height: config.drawer.height - config.wallThickness, + height: config.drawer.height, x: rawX + gap, y: rawY + gap, color: `hsl(${(partCounter * 137.5) % 360}, 70%, 50%)`, @@ -69,117 +65,7 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat return parts; }; -// --- ГЕНЕРАЦИЯ СТЕН С ПЕРФОРАЦИЕЙ (2D SHAPE -> EXTRUDE) --- - -const createPerforatedShape = (length: number, height: number, config: AppConfig): THREE.Shape => { - const shape = new THREE.Shape(); - - // 1. Внешний контур (CCW - Против часовой стрелки) - shape.moveTo(0, 0); - shape.lineTo(length, 0); - shape.lineTo(length, height); - shape.lineTo(0, height); - shape.lineTo(0, 0); - - // Если перфорация выключена или стена слишком мала - if (!config.perforation?.enabled || length < 15 || height < 15) return shape; - - const { pattern, diameter, spacing } = config.perforation; - const step = diameter + Math.max(2, spacing); - const margin = 4; // Отступ от края - - // Рабочая зона - const effW = length - 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 > length - margin || - cy - diameter/2 < margin || cy + diameter/2 > height - margin) continue; - - const hole = new THREE.Path(); - const r = diameter / 2; - - // 2. ОТВЕРСТИЯ (CW - По часовой стрелке)!!! - // Это критически важно. Если рисовать CCW, Three.js зальет дырку. - - if (pattern === 'circle') { - // aClockwise = true - hole.absarc(cx, cy, r, 0, Math.PI * 2, true); - } - else if (pattern === 'hexagon') { - // 6 точек по часовой - 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; - // 3 точки по часовой - 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 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; -}; - -// --- СБОРКА МОДЕЛИ --- +// --- ГЕНЕРАЦИЯ ГЕОМЕТРИИ (АТОМАРНЫЙ CSG) --- export const createBinGeometry = ( width: number, depth: number, height: number, thickness: number, radius: number = 0, @@ -187,63 +73,117 @@ export const createBinGeometry = ( config?: AppConfig ): THREE.BufferGeometry => { - // Массив для слияния всех частей - const geometries: THREE.BufferGeometry[] = []; const safeConfig = config || { perforation: { enabled: false } } as AppConfig; + const evaluator = new Evaluator(); + // Отключаем группы, чтобы результат был единым мешем с одним материалом + evaluator.useGroups = false; - // 1. ПОЛ - const floorShape = createFloorShape(width, depth, radius); - const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false }); - floorGeo.rotateX(-Math.PI / 2); // Кладем плашмя (XZ) - geometries.push(floorGeo); + const finalGeometries: THREE.BufferGeometry[] = []; + + // 1. ПОЛ (Всегда сплошной, без дырок) + const floorGeo = new THREE.BoxGeometry(width, thickness, depth); + floorGeo.translate(0, thickness / 2, 0); // Поднимаем, чтобы низ был на 0 + finalGeometries.push(floorGeo); + + // --- ФУНКЦИЯ СОЗДАНИЯ "УМНОЙ" СТЕНКИ --- + // Создает стену в локальных координатах, сверлит её, а потом ставит на место + const createSmartWall = (wallLength: number, wallHeight: number, x: number, z: number, isVertical: boolean) => { + + // 1. Создаем "заготовку" стены в центре координат (лежащую вдоль X) + // Размеры: Длина=wallLength, Высота=wallHeight, Толщина=thickness + const wallGeometry = new THREE.BoxGeometry(wallLength, wallHeight, thickness); + + // Сразу создаем Brush для CSG + let wallBrush = new Brush(wallGeometry); + wallBrush.updateMatrixWorld(); + + // 2. Сверлим дырки (если включено) + if (safeConfig.perforation?.enabled) { + const { pattern, diameter, spacing } = safeConfig.perforation; + const margin = 4; // Отступ от краев + const step = diameter + Math.max(2, spacing); + + // Рассчитываем сетку + const cols = Math.floor((wallLength - margin * 2) / step); + // Для сот (hexagon) шаг по вертикали меньше + const rowH = pattern === 'circle' ? step : step * 0.866; + const rows = Math.floor((wallHeight - margin * 2) / rowH); + + if (cols > 0 && rows > 0) { + const startX = -wallLength / 2 + (wallLength - cols * step) / 2 + diameter / 2; + const startY = -wallHeight / 2 + (wallHeight - rows * rowH) / 2 + diameter / 2; + + // Создаем один шаблон "сверла" + const drillGeo = new THREE.CylinderGeometry(diameter / 2, diameter / 2, thickness * 2, 12); + drillGeo.rotateX(Math.PI / 2); // Поворачиваем, чтобы сверлил сквозь стену (по оси Z локально) + + // Собираем все сверла в одну геометрию (merge), чтобы вычесть 1 раз + const drills: THREE.BufferGeometry[] = []; + + for (let r = 0; r < rows; r++) { + const isOdd = r % 2 !== 0; + for (let c = 0; c < cols; c++) { + let cx = startX + c * step; + let cy = startY + r * rowH; + + // Смещение для сот/треугольников + if ((pattern === 'hexagon' || pattern === 'triangle') && isOdd) { + cx += step / 2; + } + + // Проверка границ, чтобы не сверлить воздух или край + if (cx > wallLength / 2 - margin || cx < -wallLength / 2 + margin) continue; + + const drill = drillGeo.clone(); + drill.translate(cx, cy, 0); + drills.push(drill); + } + } + + if (drills.length > 0) { + const mergedDrills = mergeBufferGeometries(drills); + if (mergedDrills) { + const drillBrush = new Brush(mergedDrills); + drillBrush.updateMatrixWorld(); + // САМОЕ ГЛАВНОЕ: Вычитаем сверла из стены + const result = evaluator.evaluate(wallBrush, drillBrush, SUBTRACTION); + wallBrush = result; // Обновляем стену + } + } + } + } + + // 3. Позиционируем готовую (просверленную) стену в мире + // Сейчас стена в центре (0,0,0) и смотрит вдоль X + const resultGeo = wallBrush.geometry; + + if (isVertical) { + // Если стена вертикальная (вдоль Z), поворачиваем на 90 градусов вокруг Y + resultGeo.rotateY(Math.PI / 2); + } + + // Перемещаем на финальную позицию + // Y = thickness (пол) + wallHeight/2 (центр стены) + resultGeo.translate(x, thickness + wallHeight / 2, z); + + return resultGeo; + }; const wallH = height - thickness; const innerW = width - 2 * thickness; const innerD = depth - 2 * thickness; - // Функция для создания и установки стены - // Мы создаем 2D форму (Length x Height), экструдим её на Thickness, и ставим в 3D - const placeWall = (length: number, isVertical: boolean, centerX: number, centerZ: number) => { - // 1. Создаем 2D профиль с дырками - const shape = createPerforatedShape(length, wallH, safeConfig); - - // 2. Экструдим (получаем толщину) - const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }); - - // 3. Позиционируем - // Изначально: 0..Length по X, 0..Height по Y, 0..Thickness по Z - - // Центрируем геометрию относительно её осей для удобства вращения - geo.center(); - // Теперь она от -L/2 до L/2 по X, -H/2 до H/2 по Y, -T/2 до T/2 по Z - - if (isVertical) { - // Вертикальная стена (идет вдоль Z) - geo.rotateY(Math.PI / 2); // Поворачиваем: теперь длина вдоль Z, толщина вдоль X - } - - // Переносим на финальную позицию - // Y = thickness (пол) + wallH/2 (так как мы центрировали геометрию по Y) - geo.translate(centerX, thickness + wallH/2, centerZ); - - geometries.push(geo); - }; - - // 2. ВНЕШНИЕ СТЕНЫ + // 2. СОЗДАЕМ ВНЕШНИЕ СТЕНЫ // Front (Спереди, вдоль X) - placeWall(innerW, false, 0, depth/2 - thickness/2); - + finalGeometries.push(createSmartWall(innerW, wallH, 0, depth / 2 - thickness / 2, false)); // Back (Сзади, вдоль X) - placeWall(innerW, false, 0, -depth/2 + thickness/2); - - // Left (Слева, вдоль Z, полная глубина) - placeWall(depth, true, -width/2 + thickness/2, 0); - - // Right (Справа, вдоль Z, полная глубина) - placeWall(depth, true, width/2 - thickness/2, 0); + finalGeometries.push(createSmartWall(innerW, wallH, 0, -depth / 2 + thickness / 2, false)); + // Left (Слева, вдоль Z) - полная глубина + finalGeometries.push(createSmartWall(depth, wallH, -width / 2 + thickness / 2, 0, true)); + // Right (Справа, вдоль Z) - полная глубина + finalGeometries.push(createSmartWall(depth, wallH, width / 2 - thickness / 2, 0, true)); - - // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ + // 3. СОЗДАЕМ ВНУТРЕННИЕ ПЕРЕГОРОДКИ let partitions: Partition[] = []; if (Array.isArray(splits)) partitions = splits; else if (splits && splits.partitions) partitions = getAllPartitions(splits); @@ -252,71 +192,72 @@ export const createBinGeometry = ( const pMin = p.min ?? 0; const pMax = p.max ?? 1; + // Защита от нулевых длин if (Math.abs(pMax - pMin) < 0.001) return; - let length = 0; - let cX = 0; - let cZ = 0; + let len = 0; + let xPos = 0; + let zPos = 0; let isVert = false; - if (p.axis === 'x') { - // Вертикальная на схеме (вдоль Z) + if (p.axis === 'x') { + // Вертикальная перегородка (Вдоль Z) isVert = true; - length = (pMax - pMin) * innerD; + len = (pMax - pMin) * innerD; // X: смещение от центра - cX = (-innerW/2) + (p.offset * innerW); + xPos = (-innerW / 2) + (p.offset * innerW); // Z: центр отрезка - const midRatio = (pMin + pMax) / 2; - cZ = (-innerD/2) + (midRatio * innerD); - } else { - // Горизонтальная на схеме (вдоль X) + const midZRatio = (pMin + pMax) / 2; + zPos = (-innerD / 2) + (midZRatio * innerD); + } else { + // Горизонтальная перегородка (Вдоль X) isVert = false; - length = (pMax - pMin) * innerW; + len = (pMax - pMin) * innerW; // X: центр отрезка - const midRatio = (pMin + pMax) / 2; - cX = (-innerW/2) + (midRatio * innerW); + const midXRatio = (pMin + pMax) / 2; + xPos = (-innerW / 2) + (midXRatio * innerW); // Z: смещение от центра - cZ = (-innerD/2) + (p.offset * innerD); + zPos = (-innerD / 2) + (p.offset * innerD); } - placeWall(length, isVert, cX, cZ); + // Генерируем, сверлим и ставим перегородку + const partGeo = createSmartWall(len, p.height, xPos, zPos, isVert); + finalGeometries.push(partGeo); // --- СКРУГЛЕНИЯ (СТОЛБИКИ) --- - // Добавляем цилиндры в места стыков для прочности и визуального скругления + // Добавляем цилиндры в торцы, если включено скругление if (p.rounded && radius > 0) { const r = Math.min(radius, 5); - const cylGeo = new THREE.CylinderGeometry(r, r, p.height, 16); - cylGeo.translate(0, p.height/2, 0); // Пивот внизу - - const addCyl = (x: number, z: number) => { - const c = cylGeo.clone(); - c.translate(x, thickness, z); - geometries.push(c); - }; + const filletGeo = new THREE.CylinderGeometry(r, r, p.height, 12); + filletGeo.translate(0, p.height / 2 + thickness, 0); // Ставим на пол + // Определяем координаты концов стенки if (isVert) { - const startZ = cZ - length/2; - const endZ = cZ + length/2; - addCyl(cX, startZ); - addCyl(cX, endZ); + const zStart = (-innerD / 2) + (pMin * innerD); + const zEnd = (-innerD / 2) + (pMax * innerD); + + const f1 = filletGeo.clone(); f1.translate(xPos, 0, zStart); finalGeometries.push(f1); + const f2 = filletGeo.clone(); f2.translate(xPos, 0, zEnd); finalGeometries.push(f2); } else { - const startX = cX - length/2; - const endX = cX + length/2; - addCyl(startX, cZ); - addCyl(endX, cZ); + const xStart = (-innerW / 2) + (pMin * innerW); + const xEnd = (-innerW / 2) + (pMax * innerW); + + const f1 = filletGeo.clone(); f1.translate(xStart, 0, zPos); finalGeometries.push(f1); + const f2 = filletGeo.clone(); f2.translate(xEnd, 0, zPos); finalGeometries.push(f2); } } }); - // 4. СЛИЯНИЕ ВСЕГО В ОДИН МЕШ - // Это критично для STL экспорта - должен быть один объект - const merged = mergeBufferGeometries(geometries); + // 4. СЛИЯНИЕ ВСЕГО В ОДИН MESH + // Простое слияние геометрий (без CSG Union, так как детали просто соприкасаются) + // Это намного быстрее и не вызывает артефактов + const finalMerged = mergeBufferGeometries(finalGeometries); - if (merged) { - merged.computeVertexNormals(); - return merged; + if (finalMerged) { + finalMerged.computeVertexNormals(); + return finalMerged; } - + return new THREE.BoxGeometry(1, 1, 1); };