From e90348ab3235ec38a201bf6313627b02de481b65 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 01:35:02 +0300 Subject: [PATCH] Use three-bvh-csg --- package.json | 15 +- src/services/geometryGenerator.ts | 406 ++++++++++++------------------ 2 files changed, 162 insertions(+), 259 deletions(-) diff --git a/package.json b/package.json index 6290c6b..56fd87e 100644 --- a/package.json +++ b/package.json @@ -13,23 +13,24 @@ "start": "vite preview --port 3000 --host" }, "dependencies": { - "react": "^19.2.3", - "react-dom": "^19.2.3", - "three": "^0.182.0", - "three-stdlib": "^2.36.1", - "lucide-react": "^0.562.0", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.4.2", "jszip": "3.10.1", + "lucide-react": "^0.562.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "three": "^0.182.0", + "three-bvh-csg": "^0.0.17", + "three-stdlib": "^2.36.1", "uuid": "^9.0.1" }, "devDependencies": { + "@types/node": "^22.14.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", - "@types/node": "^22.14.0", "@types/uuid": "^9.0.8", "@vitejs/plugin-react": "^5.0.0", "typescript": "~5.8.2", "vite": "^6.2.0" } -} \ No newline at end of file +} diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index e4882b0..65881dc 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -1,10 +1,19 @@ import * as THREE from 'three'; -import { STLExporter, mergeBufferGeometries } from 'three-stdlib'; +import { STLExporter } from 'three-stdlib'; +import { SUBTRACTION, UNION, Brush, Evaluator } from 'three-bvh-csg'; import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; // --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ --- -// Извлекаем ВСЕ перегородки из всех ячеек в один плоский список +// Функция для очистки дубликатов точек (убирает фантомные микро-ячейки) +const cleanPoints = (points: number[]) => { + // Округляем до 3 знака и сортируем + 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 []; return Object.values(splits.partitions).flat(); @@ -14,11 +23,10 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat const parts: GeneratedPart[] = []; const safeX = Array.isArray(splits?.x) ? splits.x : []; const safeY = Array.isArray(splits?.y) ? splits.y : []; - const safeParts = splits?.partitions || {}; - - // Просто сортируем точки, без сложной фильтрации, чтобы совпадало с 2D - 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,17 +37,16 @@ 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; const rawD = (y2 - y1) * config.drawer.depth; + + // Игнорируем слишком мелкие ячейки (защита от фантомов) + if (rawW < 2 || rawD < 2) continue; + const rawX = x1 * config.drawer.width; const rawY = y1 * config.drawer.depth; - const internalPartitions = safeParts[`${i}-${j}`] || []; - - // Отступ для визуализации объемов (gap) + // Отступ для визуализации (цветные кубики внутри ячеек) const gap = config.wallThickness / 2 + 0.1; parts.push({ @@ -47,11 +54,11 @@ 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%)`, - internalPartitions: internalPartitions + internalPartitions: [] }); partCounter++; } @@ -59,130 +66,50 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat return parts; }; -// --- ГЕОМЕТРИЯ --- +// --- CSG ГЕОМЕТРИЯ --- -// Прямоугольник с отверстиями -const createPerforatedShape = (length: number, height: number, config: AppConfig): THREE.Shape => { - const shape = new THREE.Shape(); - - // Внешний контур (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 < 10 || height < 10) return shape; - - const { pattern, diameter, spacing } = config.perforation; - const step = diameter + Math.max(2, spacing); - const margin = 3; - - 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; - - // ДЫРКИ СТРОГО ПО ЧАСОВОЙ (CW) - 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; - 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(); - // Floor shape centered at 0,0 for ease of rotation later if needed, - // BUT createBinGeometry expects floor to be from -W/2 to W/2 - 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; -}; - -// Галтель (вогнутая) для стыков -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; -}; - -// --- MAIN BUILDER --- - -// Обратите внимание: сигнатура изменена, теперь мы принимаем splits целиком export const createBinGeometry = ( width: number, depth: number, height: number, thickness: number, radius: number = 0, - splits: LayoutSplits | Partition[] = [], // Поддержка и старого, и нового формата + splits: LayoutSplits | Partition[] = [], config?: AppConfig ): THREE.BufferGeometry => { - const geometries: THREE.BufferGeometry[] = []; const safeConfig = config || { perforation: { enabled: false } } as AppConfig; + const evaluator = new Evaluator(); - // Нормализация входных данных: нам нужен плоский список стенок + // 1. БАЗОВАЯ ГЕОМЕТРИЯ (ПОЛ) + // Brush - это специальный объект для CSG операций + const floorGeo = new THREE.BoxGeometry(width, thickness, depth); + floorGeo.translate(0, thickness / 2, 0); // Поднимаем на уровень пола + let resultBrush = new Brush(floorGeo); + + // Материал для CSG (нужен для вычислений, но не влияет на экспорт) + resultBrush.updateMatrixWorld(); + + // 2. СТЕНКИ (ВНЕШНИЕ) + const wallH = height - thickness; + const innerW = width - 2 * thickness; + const innerD = depth - 2 * thickness; + + // Функция создания блока стены + const addWallBlock = (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); + const wallBrush = new Brush(geo); + wallBrush.updateMatrixWorld(); + // Объединяем (UNION) стену с полом + resultBrush = evaluator.evaluate(resultBrush, wallBrush, UNION); + }; + + // Передняя и Задняя (Вдоль X) + addWallBlock(innerW, wallH, thickness, 0, thickness + wallH/2, depth/2 - thickness/2); // Front + addWallBlock(innerW, wallH, thickness, 0, thickness + wallH/2, -depth/2 + thickness/2); // Back + + // Левая и Правая (Вдоль Z) - Полная глубина, перекрывают углы + addWallBlock(thickness, wallH, depth, -width/2 + thickness/2, thickness + wallH/2, 0); // Left + addWallBlock(thickness, wallH, depth, width/2 - thickness/2, thickness + wallH/2, 0); // Right + + // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ let partitions: Partition[] = []; if (Array.isArray(splits)) { partitions = splits; @@ -190,145 +117,120 @@ export const createBinGeometry = ( partitions = getAllPartitions(splits); } - // 1. ПОЛ - const floorShape = createFloorShape(width, depth, radius); - const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false }); - floorGeo.rotateX(-Math.PI / 2); // XZ plane - geometries.push(floorGeo); - - // Размеры внутреннего пространства - const innerW = width - 2 * thickness; - const innerD = depth - 2 * thickness; - const wallH = height - thickness; - - // 2. ВНЕШНИЕ СТЕНКИ - const shapeFB = createPerforatedShape(innerW, wallH, safeConfig); - const shapeLR = createPerforatedShape(depth, wallH, safeConfig); - - // Front (вдоль X, спереди) - const geoF = new THREE.ExtrudeGeometry(shapeFB, { depth: thickness, bevelEnabled: false }); - geoF.translate(-innerW/2, thickness, depth/2 - thickness); - geometries.push(geoF); - - // Back (вдоль X, сзади) - const geoB = new THREE.ExtrudeGeometry(shapeFB, { depth: thickness, bevelEnabled: false }); - geoB.translate(-innerW/2, thickness, -depth/2); - geometries.push(geoB); - - // Left (вдоль Z, слева) - const geoL = new THREE.ExtrudeGeometry(shapeLR, { depth: thickness, bevelEnabled: false }); - geoL.rotateY(Math.PI / 2); - geoL.translate(-width/2, thickness, -depth/2); - geometries.push(geoL); - - // Right (вдоль Z, справа) - const geoR = new THREE.ExtrudeGeometry(shapeLR, { depth: thickness, bevelEnabled: false }); - geoR.rotateY(Math.PI / 2); - geoR.translate(width/2 - thickness, thickness, -depth/2); - geometries.push(geoR); - - // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ (Исправлено позиционирование) partitions.forEach(p => { const pMin = p.min ?? 0; const pMax = p.max ?? 1; - // Игнорируем ошибки данных if (pMax - pMin < 0.001) return; - let length = 0; - let isVertical = false; // Vertical on 2D screen = Along Z axis in 3D - - // Вычисляем координаты центра и длины - let posX = 0; // Центр по X (для верт) или Начало по X (для гориз) - let posZ = 0; // Начало по Z (для верт) или Центр по Z (для гориз) + let w=0, h=p.height, d=0, x=0, z=0; if (p.axis === 'x') { - // Вертикальная на экране (Z-axis in 3D) - isVertical = true; - length = (pMax - pMin) * innerD; - // В 2D X идет слева направо (0..1). В 3D X идет от -innerW/2 до innerW/2. - posX = (-innerW/2) + (p.offset * innerW); - // В 2D Y идет сверху вниз (0..1). В 3D Z идет от -innerD/2 (зад) до innerD/2 (перед). - posZ = (-innerD/2) + (pMin * innerD); + // Вертикальная на 2D (Вдоль Z в 3D) + w = thickness; + d = (pMax - pMin) * innerD; + x = (-innerW/2) + (p.offset * innerW); + z = (-innerD/2) + (pMin * innerD) + (d / 2); } else { - // Горизонтальная на экране (X-axis in 3D) - isVertical = false; - length = (pMax - pMin) * innerW; - posX = (-innerW/2) + (pMin * innerW); - posZ = (-innerD/2) + (p.offset * innerD); + // Горизонтальная на 2D (Вдоль X в 3D) + w = (pMax - pMin) * innerW; + d = thickness; + x = (-innerW/2) + (pMin * innerW) + (w / 2); + z = (-innerD/2) + (p.offset * innerD); } - // Генерируем 2D профиль - const partShape = createPerforatedShape(length, wallH, safeConfig); - const partGeo = new THREE.ExtrudeGeometry(partShape, { depth: thickness, bevelEnabled: false }); - - if (isVertical) { - // Поворот чтобы шла вдоль Z - partGeo.rotateY(Math.PI / 2); - // Смещаем в позицию. - // Центр X = posX. Но так как толщина экструзии идет в +X (после поворота), надо сместить на -thickness/2 - partGeo.translate(posX - thickness/2, thickness, posZ); - } else { - // Вдоль X - // Центр Z = posZ. Смещаем на -thickness/2 - 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; - - // Top junction (Z-min / Back) - addFillet(posX - t, zStart, Math.PI); // Face Back-Left - addFillet(posX + t, zStart, -Math.PI/2); // Face Back-Right - - // Bottom junction (Z-max / Front) - addFillet(posX - t, zEnd, Math.PI/2); // Face Front-Left - addFillet(posX + t, zEnd, 0); // Face Front-Right - } else { - const xStart = posX; - const xEnd = posX + length; - - // Left junction (X-min / Left) - addFillet(xStart, posZ - t, 0); // Face Left-Back - addFillet(xStart, posZ + t, -Math.PI/2); // Face Left-Front - - // Right junction (X-max / Right) - addFillet(xEnd, posZ - t, Math.PI/2); // Face Right-Back - addFillet(xEnd, posZ + t, Math.PI); // Face Right-Front - } - } + addWallBlock(w, h, d, x, thickness + h/2, z); }); - const merged = mergeBufferGeometries(geometries); - if (merged) { - merged.computeVertexNormals(); - return merged; + // 4. ПЕРФОРАЦИЯ (ВЫЧИТАНИЕ) + // Чтобы не тормозить, мы создаем ОДИН сложный объект из всех "сверл" и вычитаем его один раз + if (safeConfig.perforation?.enabled) { + const { pattern, diameter, spacing } = safeConfig.perforation; + const holeGeo = new THREE.CylinderGeometry(diameter/2, diameter/2, thickness * 4, 16); + + // Поворачиваем цилиндр, чтобы он "сверлил" вдоль оси X (для боковых стенок) + holeGeo.rotateZ(Math.PI / 2); + + const step = diameter + Math.max(2, spacing); + const margin = 4; + + // Массив геометрий для слияния (это быстрее, чем 1000 раз вызывать CSG) + const cutters: THREE.BufferGeometry[] = []; + + // Функция генерации "сверл" для плоскости + const generateCutters = (W: number, H: number, startX: number, startY: number, startZ: number, rotateY: boolean) => { + 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 j=0; j W - margin || hy > H - margin) continue; + + const cutter = holeGeo.clone(); + + if (rotateY) { + // Для стенок, идущих вдоль X (Передняя/Задняя) + // Изначально цилиндр вдоль X. Поворачиваем на 90 -> Вдоль Z. + cutter.rotateY(Math.PI / 2); + // Позиционируем + // В локальной системе стенки: X=Длина, Y=Высота. + // Глобально: X=startX+hx, Y=startY+hy, Z=startZ + cutter.translate(startX + hx, startY + hy, startZ); + } else { + // Для стенок, идущих вдоль Z (Левая/Правая) + // Цилиндр вдоль X (по умолчанию). + // Глобально: X=startX, Y=startY+hy, Z=startZ+hx + cutter.translate(startX, startY + hy, startZ + hx); + } + cutters.push(cutter); + } + } + }; + + // Генерируем сверла для всех 4 сторон + // Front/Back (Сверлим вдоль Z) + generateCutters(innerW, wallH, -innerW/2, thickness, depth/2, true); // Front plane + generateCutters(innerW, wallH, -innerW/2, thickness, -depth/2, true); // Back plane + + // Left/Right (Сверлим вдоль X) + // Для боковых стенок (Left/Right) W = depth. + generateCutters(depth, wallH, -width/2, thickness, -depth/2, false); // Left plane + generateCutters(depth, wallH, width/2, thickness, -depth/2, false); // Right plane + + // Если есть внутренние стенки, их тоже надо бы сверлить, но это сложнее рассчитать. + // Пока сверлим только внешний периметр, как в Gridfinity. + // (Можно добавить логику для внутренних, перебирая partitions, если нужно) + + if (cutters.length > 0) { + // Объединяем все сверла в один Mesh + // Используем mergeBufferGeometries из three-stdlib, так как в чистом three его вынесли + const mergedCutters = mergeBufferGeometries(cutters); + if (mergedCutters) { + const cutterBrush = new Brush(mergedCutters); + cutterBrush.updateMatrixWorld(); + // ВЫЧИТАНИЕ (SUBTRACTION) + resultBrush = evaluator.evaluate(resultBrush, cutterBrush, SUBTRACTION); + } + } } - - return new THREE.BoxGeometry(1, 1, 1); + + // Возвращаем чистую геометрию + return resultBrush.geometry; }; export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => { const exporter = new STLExporter(); + // Для CSG геометрии иногда нужно убедиться, что она корректно интерпретируется const result = exporter.parse(mesh, { binary: true }); if (result instanceof DataView) return new Uint8Array(result.buffer, result.byteOffset, result.byteLength); return result as string;