From fa2f02bd183de1bd7fbb320c445a1d15726cca70 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:57:09 +0300 Subject: [PATCH] 1 --- src/services/geometryGenerator.ts | 358 ++++++++++++++++++++---------- 1 file changed, 245 insertions(+), 113 deletions(-) diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index b611b54..46db0fe 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -1,28 +1,24 @@ import * as THREE from 'three'; -import { STLExporter } from 'three-stdlib'; -import { SUBTRACTION, ADDITION, Brush, Evaluator } from 'three-bvh-csg'; // ИСПРАВЛЕНО: ADDITION вместо UNION +import { STLExporter, mergeBufferGeometries } from 'three-stdlib'; import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; // --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ --- -const cleanPoints = (points: number[]) => { - const rounded = points.map(p => Math.round(p * 1000) / 1000).sort((a, b) => a - b); - return [...new Set(rounded)]; -}; - +// 1. Собираем все перегородки из всех ячеек в один плоский список const getAllPartitions = (splits: LayoutSplits): Partition[] => { if (!splits || !splits.partitions) return []; + // Проходимся по всем ключам ("0-0", "0-1" и т.д.) и собираем массивы в один return Object.values(splits.partitions).flat(); }; -// Функция для визуализации "кубиков" ячеек (цветной предпросмотр) 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 = cleanPoints([0, ...safeX, 1]); - const uniqueY = cleanPoints([0, ...safeY, 1]); + // Уникальные точки реза для визуализации "цветных кубиков" + const uniqueX = [0, ...safeX, 1].sort((a, b) => a - b); + const uniqueY = [0, ...safeY, 1].sort((a, b) => a - b); let partCounter = 1; @@ -33,6 +29,7 @@ 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; @@ -47,7 +44,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, + height: config.drawer.height - config.wallThickness, // Учитываем пол x: rawX + gap, y: rawY + gap, color: `hsl(${(partCounter * 137.5) % 360}, 70%, 50%)`, @@ -59,44 +56,175 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat return parts; }; -// --- ГЕНЕРАЦИЯ ГЕОМЕТРИИ ЧЕРЕЗ CSG (ВЫЧИТАНИЕ) --- +// --- ГЕОМЕТРИЯ --- + +// Создание формы стены с отверстиями (Правильный 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; +}; + +// --- СБОРКА МОДЕЛИ --- export const createBinGeometry = ( width: number, depth: number, height: number, thickness: number, radius: number = 0, - splits: LayoutSplits | Partition[] = [], + splits: LayoutSplits | Partition[] = [], // Принимаем весь объект splits config?: AppConfig ): THREE.BufferGeometry => { + const geometries: THREE.BufferGeometry[] = []; const safeConfig = config || { perforation: { enabled: false } } as AppConfig; - const evaluator = new Evaluator(); - // 1. БАЗА (ПОЛ) - const floorGeo = new THREE.BoxGeometry(width, thickness, depth); - floorGeo.translate(0, thickness / 2, 0); - let resultBrush = new Brush(floorGeo); - resultBrush.updateMatrixWorld(); + // 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); - // 2. СТЕНКИ - const wallH = height - thickness; + // Внутренние размеры (без учета толщины внешних стен) const innerW = width - 2 * thickness; const innerD = depth - 2 * thickness; + const wallH = height - thickness; - // Функция добавления блока стены (ADDITION) - 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(); - resultBrush = evaluator.evaluate(resultBrush, wallBrush, ADDITION); // ИСПРАВЛЕНО ЗДЕСЬ - }; + // 2. ВНЕШНИЕ СТЕНКИ + // Создаем 2D профили с дырками + const shapeFrontBack = createWallShapeWithHoles(innerW, wallH, safeConfig); + const shapeLeftRight = createWallShapeWithHoles(depth, wallH, safeConfig); // Боковые на всю глубину - // Внешние стенки - 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 - 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 + // Front (Спереди) + const geoF = new THREE.ExtrudeGeometry(shapeFrontBack, { depth: thickness, bevelEnabled: false }); + geoF.translate(-innerW/2, thickness, depth/2 - thickness); + geometries.push(geoF); - // Внутренние перегородки + // 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; @@ -107,91 +235,95 @@ export const createBinGeometry = ( partitions.forEach(p => { const pMin = p.min ?? 0; const pMax = p.max ?? 1; + + // Игнорируем ошибки данных if (pMax - pMin < 0.001) return; - let w=0, h=p.height, d=0, x=0, z=0; + let length = 0; + let posX = 0; + let posZ = 0; + let isVertical = false; - 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); + // Рассчитываем координаты и размеры + 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); + } } - addWallBlock(w, h, d, x, thickness + h/2, z); }); - // 3. ПЕРФОРАЦИЯ (ВЫЧИТАНИЕ) - if (safeConfig.perforation?.enabled) { - const { pattern, diameter, spacing } = safeConfig.perforation; - // Цилиндр для вырезания (длинный, чтобы прошел насквозь) - const holeGeo = new THREE.CylinderGeometry(diameter/2, diameter/2, thickness * 4, 16); - holeGeo.rotateZ(Math.PI / 2); // По умолчанию вдоль X (для боковых стен) - - const step = diameter + Math.max(2, spacing); - const margin = 4; - - // Собираем все "сверла" в одну геометрию для скорости - 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) { - // Для передней/задней стенки (сверлим вдоль Z) - cutter.rotateY(Math.PI / 2); - cutter.translate(startX + hx, startY + hy, startZ); - } else { - // Для боковых стенок (сверлим вдоль X) - cutter.translate(startX, startY + hy, startZ + hx); - } - cutters.push(cutter); - } - } - }; - - // Генерируем отверстия для внешних стен - generateCutters(innerW, wallH, -innerW/2, thickness, depth/2, true); // Front - generateCutters(innerW, wallH, -innerW/2, thickness, -depth/2, true); // Back - generateCutters(depth, wallH, -width/2, thickness, -depth/2, false); // Left - generateCutters(depth, wallH, width/2, thickness, -depth/2, false); // Right - - // Применяем вычитание (SUBTRACTION) - if (cutters.length > 0) { - // Используем функцию слияния из three-stdlib (так как в core three её может не быть в старых версиях) - // Если mergeBufferGeometries не импортирован, убедитесь что он есть в импортах - const mergedCutters = mergeBufferGeometries(cutters); - if (mergedCutters) { - const cutterBrush = new Brush(mergedCutters); - cutterBrush.updateMatrixWorld(); - resultBrush = evaluator.evaluate(resultBrush, cutterBrush, SUBTRACTION); - } - } + const merged = mergeBufferGeometries(geometries); + + // Исправление нормалей (убирает прозрачность) + if (merged) { + merged.computeVertexNormals(); + return merged; } - - // Возвращаем результат - return resultBrush.geometry; + + return new THREE.BoxGeometry(1, 1, 1); }; export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => {