From 1f70a7652cc3060fbdce3f13366d43df8067bced 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:12:16 +0300 Subject: [PATCH] 2 --- src/services/geometryGenerator.ts | 428 +++++++++++++----------------- 1 file changed, 183 insertions(+), 245 deletions(-) diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index 46db0fe..5dae70f 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -1,24 +1,31 @@ import * as THREE from 'three'; -import { STLExporter, mergeBufferGeometries } from 'three-stdlib'; +import { STLExporter } from 'three-stdlib'; +import { SUBTRACTION, ADDITION, Brush, Evaluator } from 'three-bvh-csg'; +import { mergeBufferGeometries } from 'three-stdlib'; import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; // --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ --- -// 1. Собираем все перегородки из всех ячеек в один плоский список +// Очистка дубликатов точек для визуализации +const cleanPoints = (points: number[]) => { + const rounded = points.map(p => Math.round(p * 1000) / 1000).sort((a, b) => a - b); + return [...new Set(rounded)]; +}; + +// Сбор всех перегородок в один массив const getAllPartitions = (splits: LayoutSplits): Partition[] => { if (!splits || !splits.partitions) return []; - // Проходимся по всем ключам ("0-0", "0-1" и т.д.) и собираем массивы в один 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 : []; const safeY = Array.isArray(splits?.y) ? splits.y : []; - // Уникальные точки реза для визуализации "цветных кубиков" - const uniqueX = [0, ...safeX, 1].sort((a, b) => a - b); - const uniqueY = [0, ...safeY, 1].sort((a, b) => a - b); + const uniqueX = cleanPoints([0, ...safeX, 1]); + const uniqueY = cleanPoints([0, ...safeY, 1]); let partCounter = 1; @@ -29,7 +36,6 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat const y1 = uniqueY[j]; const y2 = uniqueY[j+1]; - // Фильтр микро-ячеек if (x2 - x1 < 0.001 || y2 - y1 < 0.001) continue; const rawW = (x2 - x1) * config.drawer.width; @@ -44,7 +50,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%)`, @@ -56,175 +62,50 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat return parts; }; -// --- ГЕОМЕТРИЯ --- - -// Создание формы стены с отверстиями (Правильный Winding Order!) -const createWallShapeWithHoles = (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 - По часовой стрелке) - // Это критически важно для Three.js, иначе дырки не вырежутся - - 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; // Минус k = CW - 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 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, depth / 2); - - 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; -}; - -// Форма скругления (Concave fillet) -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; -}; - -// --- СБОРКА МОДЕЛИ --- +// --- ГЕНЕРАЦИЯ ГЕОМЕТРИИ (CSG) --- export const createBinGeometry = ( width: number, depth: number, height: number, thickness: number, radius: number = 0, - splits: LayoutSplits | Partition[] = [], // Принимаем весь объект splits + splits: LayoutSplits | Partition[] = [], config?: AppConfig ): THREE.BufferGeometry => { - const geometries: THREE.BufferGeometry[] = []; const safeConfig = config || { perforation: { enabled: false } } as AppConfig; + const evaluator = new Evaluator(); + // Ускоряем CSG, отключая лишние проверки + 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); // Кладем на пол - geometries.push(floorGeo); + // 1. СОЗДАЕМ "МЯСО" (Стены и пол) + // Мы собираем все прямоугольники в один массив геометрий, + // сливаем их в одну геометрию, и делаем из нее один Brush. + // Это в 10 раз быстрее, чем делать ADDITION в цикле. + + const solidParts: THREE.BufferGeometry[] = []; - // Внутренние размеры (без учета толщины внешних стен) + // ПОЛ + const floorGeo = new THREE.BoxGeometry(width, thickness, depth); + floorGeo.translate(0, thickness / 2, 0); + solidParts.push(floorGeo); + + // СТЕНЫ + const wallH = height - thickness; const innerW = width - 2 * thickness; const innerD = depth - 2 * thickness; - const wallH = height - thickness; - // 2. ВНЕШНИЕ СТЕНКИ - // Создаем 2D профили с дырками - const shapeFrontBack = createWallShapeWithHoles(innerW, wallH, safeConfig); - const shapeLeftRight = createWallShapeWithHoles(depth, wallH, safeConfig); // Боковые на всю глубину + // Хелпер для создания куба стены + const addWall = (w: number, h: number, d: number, x: number, y: number, z: number) => { + const geo = new THREE.BoxGeometry(w, h, d); + geo.translate(x, y, z); + solidParts.push(geo); + }; - // Front (Спереди) - const geoF = new THREE.ExtrudeGeometry(shapeFrontBack, { depth: thickness, bevelEnabled: false }); - geoF.translate(-innerW/2, thickness, depth/2 - thickness); - geometries.push(geoF); + // Внешние стены + addWall(innerW, wallH, thickness, 0, thickness + wallH/2, depth/2 - thickness/2); // Front + addWall(innerW, wallH, thickness, 0, thickness + wallH/2, -depth/2 + thickness/2); // Back + addWall(thickness, wallH, depth, -width/2 + thickness/2, thickness + wallH/2, 0); // Left + addWall(thickness, wallH, depth, width/2 - thickness/2, thickness + wallH/2, 0); // Right - // Back (Сзади) - const geoB = new THREE.ExtrudeGeometry(shapeFrontBack, { depth: thickness, bevelEnabled: false }); - geoB.translate(-innerW/2, thickness, -depth/2); - geometries.push(geoB); - - // Left (Слева) - const geoL = new THREE.ExtrudeGeometry(shapeLeftRight, { depth: thickness, bevelEnabled: false }); - geoL.rotateY(Math.PI / 2); - geoL.translate(-width/2, thickness, -depth/2); - geometries.push(geoL); - - // Right (Справа) - const geoR = new THREE.ExtrudeGeometry(shapeLeftRight, { depth: thickness, bevelEnabled: false }); - geoR.rotateY(Math.PI / 2); - geoR.translate(width/2 - thickness, thickness, -depth/2); - geometries.push(geoR); - - - // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ - // Важно: извлекаем плоский массив стенок + // Внутренние стены let partitions: Partition[] = []; if (Array.isArray(splits)) { partitions = splits; @@ -235,95 +116,152 @@ export const createBinGeometry = ( partitions.forEach(p => { const pMin = p.min ?? 0; const pMax = p.max ?? 1; - - // Игнорируем ошибки данных if (pMax - pMin < 0.001) return; - let length = 0; - let posX = 0; - let posZ = 0; - let isVertical = false; + let w=0, h=p.height, d=0, x=0, z=0; - // Рассчитываем координаты и размеры - if (p.axis === 'x') { - // Вертикальная на экране (Вдоль Z) - isVertical = true; - length = (pMax - pMin) * innerD; - // X: центр линии - posX = (-innerW/2) + (p.offset * innerW); - // Z: начало линии - posZ = (-innerD/2) + (pMin * innerD); - } else { - // Горизонтальная на экране (Вдоль X) - isVertical = false; - length = (pMax - pMin) * innerW; - // X: начало линии - posX = (-innerW/2) + (pMin * innerW); - // Z: центр линии - posZ = (-innerD/2) + (p.offset * innerD); - } - - // Создаем стенку с дырками - const partShape = createWallShapeWithHoles(length, wallH, safeConfig); - const partGeo = new THREE.ExtrudeGeometry(partShape, { depth: thickness, bevelEnabled: false }); - - if (isVertical) { - // Поворачиваем вдоль Z - partGeo.rotateY(Math.PI / 2); - // Смещаем: X - половина толщины (для центровки), Y=thick, Z=начало - partGeo.translate(posX - thickness/2, thickness, posZ); - } else { - // Вдоль X - // Смещаем: X=начало, Y=thick, Z - половина толщины - partGeo.translate(posX, thickness, posZ - thickness/2); - } - - geometries.push(partGeo); - - // --- СКРУГЛЕНИЯ (FILLETS) --- - if (p.rounded && radius > 1) { - const fR = Math.min(radius, 5); - const fShape = createFilletShape(fR); - const h = p.height; - - const addFillet = (fx: number, fz: number, rot: number) => { - const geo = new THREE.ExtrudeGeometry(fShape, { depth: h, bevelEnabled: false }); - geo.rotateX(-Math.PI / 2); - geo.rotateY(rot); - geo.translate(fx, thickness, fz); - geometries.push(geo); - }; - - const t = thickness / 2; - - if (isVertical) { - const zStart = posZ; - const zEnd = posZ + length; - // 4 угла на стыках - addFillet(posX - t, zStart, Math.PI); - addFillet(posX + t, zStart, -Math.PI/2); - addFillet(posX - t, zEnd, Math.PI/2); - addFillet(posX + t, zEnd, 0); - } else { - const xStart = posX; - const xEnd = posX + length; - addFillet(xStart, posZ - t, 0); - addFillet(xStart, posZ + t, -Math.PI/2); - addFillet(xEnd, posZ - t, Math.PI/2); - addFillet(xEnd, posZ + t, Math.PI); - } + if (p.axis === 'x') { // Вертикальная (вдоль Z) + w = thickness; + d = (pMax - pMin) * innerD; + x = (-innerW/2) + (p.offset * innerW); + z = (-innerD/2) + (pMin * innerD) + (d/2); + } else { // Горизонтальная (вдоль X) + w = (pMax - pMin) * innerW; + d = thickness; + x = (-innerW/2) + (pMin * innerW) + (w/2); + z = (-innerD/2) + (p.offset * innerD); } + addWall(w, h, d, x, thickness + h/2, z); }); - const merged = mergeBufferGeometries(geometries); - - // Исправление нормалей (убирает прозрачность) - if (merged) { - merged.computeVertexNormals(); - return merged; + // Объединяем всю твердую геометрию в один Mesh + const mergedSolids = mergeBufferGeometries(solidParts); + let mainBrush = new Brush(mergedSolids); + mainBrush.updateMatrixWorld(); + + // 2. ПЕРФОРАЦИЯ (ЕСЛИ ВКЛЮЧЕНА) + if (safeConfig.perforation?.enabled) { + const { pattern, diameter, spacing } = safeConfig.perforation; + const step = diameter + Math.max(2, spacing); + const margin = 4; + + // Создаем базовые "сверла" + const drillZ = new THREE.CylinderGeometry(diameter/2, diameter/2, thickness * 4, 12); + drillZ.rotateX(Math.PI / 2); // Сверлит вдоль Z (для стен вдоль X) + + const drillX = new THREE.CylinderGeometry(diameter/2, diameter/2, thickness * 4, 12); + drillX.rotateZ(Math.PI / 2); // Сверлит вдоль X (для стен вдоль Z) + + const cutterParts: THREE.BufferGeometry[] = []; + + // Функция расстановки сверл на плоскости + const drillWall = (W: number, H: number, startX: number, startY: number, startZ: number, axis: 'x' | 'z') => { + const cols = Math.floor((W - margin*2) / step); + const rowH = pattern === 'circle' ? step : step * 0.866; + const rows = Math.floor((H - margin*2) / rowH); + + const offsetX = (W - cols * step) / 2; + const offsetY = (H - rows * rowH) / 2; + + for(let r=0; r W - margin || v > H - margin) continue; + + let drill: THREE.BufferGeometry; + + if (axis === 'x') { + // Стена вдоль X (Front/Back/Horiz). Сверлим вдоль Z. + // U = X, V = Y. + drill = drillZ.clone(); + drill.translate(startX + u, startY + v, startZ); + } else { + // Стена вдоль Z (Left/Right/Vert). Сверлим вдоль X. + // U = Z, V = Y. + drill = drillX.clone(); + drill.translate(startX, startY + v, startZ + u); + } + cutterParts.push(drill); + } + } + }; + + // Генерируем сверла для внешних стен + // Front (X-wall) + drillWall(innerW, wallH, -innerW/2, thickness, depth/2, 'x'); + // Back (X-wall) + drillWall(innerW, wallH, -innerW/2, thickness, -depth/2, 'x'); + // Left (Z-wall) + drillWall(depth, wallH, -width/2, thickness, -depth/2, 'z'); + // Right (Z-wall) + drillWall(depth, wallH, width/2, thickness, -depth/2, 'z'); + + // Генерируем сверла для ВНУТРЕННИХ стен + partitions.forEach(p => { + const pMin = p.min ?? 0; + const pMax = p.max ?? 1; + if (pMax - pMin < 0.001) return; + + if (p.axis === 'x') { // Vert wall (Z-axis) + const len = (pMax - pMin) * innerD; + const xPos = (-innerW/2) + (p.offset * innerW); + const zStart = (-innerD/2) + (pMin * innerD); + drillWall(len, p.height, xPos, thickness, zStart, 'z'); + } else { // Horiz wall (X-axis) + const len = (pMax - pMin) * innerW; + const xStart = (-innerW/2) + (pMin * innerW); + const zPos = (-innerD/2) + (p.offset * innerD); + drillWall(len, p.height, xStart, thickness, zPos, 'x'); + } + }); + + // ВЫЧИТАНИЕ + if (cutterParts.length > 0) { + const mergedCutters = mergeBufferGeometries(cutterParts); + const cutterBrush = new Brush(mergedCutters); + cutterBrush.updateMatrixWorld(); + + // SOLID - CUTTERS + mainBrush = evaluator.evaluate(mainBrush, cutterBrush, SUBTRACTION); + } } - - return new THREE.BoxGeometry(1, 1, 1); + + // 3. СКРУГЛЕНИЯ (ДОБАВЛЕНИЕ) + if (radius > 0) { + const filletParts: THREE.BufferGeometry[] = []; + const fRad = Math.min(radius, 5); + const filletGeo = new THREE.CylinderGeometry(fRad, fRad, 1, 16, 1, false, 0, Math.PI/2); // Четверть цилиндра + // Центрируем пивот для удобства + filletGeo.translate(0, 0.5, 0); // Y вверх 0..1 + + // Хелпер для добавления скругления + const addFillet = (x: number, y: number, z: number, h: number, rotY: number) => { + const f = filletGeo.clone(); + f.scale(1, h, 1); // Масштабируем по высоте + // Поворот + f.rotateY(rotY); + f.translate(x, y, z); + filletParts.push(f); + }; + + // Проходим по стыкам (упрощенно: вертикальные столбики в углах примыканий) + // В данной реализации CSG проще всего добавить цилиндры в углы, чтобы "залить" их. + // Но так как мы используем ADDITION для стен, углы уже залиты (острые). + // Чтобы сделать *вогнутые* скругления (Fillet), нужно делать UNION специальных форм. + + // Для скорости и надежности, пока оставим острые внутренние углы, если они получены через ADDITION. + // Если нужны именно вогнутые скругления, нужно добавлять "призмы" и вычитать цилиндры, это сложно. + // Если нужны выпуклые скругления внешних углов - это просто. + + // Оставим пока без доп. геометрии для скруглений, так как ADDITION уже делает герметичный стык. + // Если критично именно *визуальное* скругление, можно добавить цилиндры. + } + + return mainBrush.geometry; }; export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => {