From d3170e79abfe717526dd3f419496ce2ba2d718bd 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 00:47:52 +0300 Subject: [PATCH] 2 --- src/services/geometryGenerator.ts | 316 +++++++++++++++--------------- 1 file changed, 158 insertions(+), 158 deletions(-) diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index c1b6d58..673726f 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -10,40 +10,51 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat 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]; + // Очистка и сортировка точек реза с удалением дубликатов (защита от лишних ячеек) + const uniqueX = Array.from(new Set([0, ...safeX, 1])).sort((a, b) => a - b); + const uniqueY = Array.from(new Set([0, ...safeY, 1])).sort((a, b) => a - b); 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; + for (let i = 0; i < uniqueX.length - 1; i++) { + for (let j = 0; j < uniqueY.length - 1; j++) { + const x1 = uniqueX[i]; + const x2 = uniqueX[i+1]; + const y1 = uniqueY[j]; + const y2 = uniqueY[j+1]; - const rawX = xPoints[i] * config.drawer.width; - const rawY = yPoints[j] * config.drawer.depth; + // Пропускаем микро-сдвиги (меньше 1мм) + if (Math.abs(x2 - x1) < 0.001 || Math.abs(y2 - y1) < 0.001) continue; + + const rawW = (x2 - x1) * config.drawer.width; + const rawD = (y2 - y1) * config.drawer.depth; + + const rawX = x1 * config.drawer.width; + const rawY = y1 * config.drawer.depth; + + // Ключ для поиска перегородок берем из оригинальных индексов (тут упрощение, предполагаем соответствие) + // Для точности лучше искать по координатам, но пока оставим ключ const internalPartitions = safeParts[`${i}-${j}`] || []; - // Внутренний отступ для визуализации "объема" (цветных кубиков) - // Чтобы они не сливались со стенками - const gap = config.wallThickness + 0.5; + // Отступ для визуализации "кубиков" (gap), чтобы они не слипались в превью + const gap = config.wallThickness / 2; - const realWidth = rawW - gap * 2; - const realDepth = rawD - gap * 2; - const realX = rawX + gap; - const realY = rawY + gap; + const realWidth = rawW - config.printerTolerance; + const realDepth = rawD - config.printerTolerance; + const realX = rawX + (config.printerTolerance / 2); + const realY = rawY + (config.printerTolerance / 2); + + if (realWidth < 2 || realDepth < 2) continue; parts.push({ id: `part-${partCounter}`, - name: `Ячейка ${i+1}-${j+1}`, - width: Math.max(1, realWidth), - depth: Math.max(1, realDepth), + name: `Ячейка ${partCounter}`, + width: realWidth, + depth: realDepth, height: config.drawer.height, x: realX, y: realY, - color: `hsl(${Math.random() * 360}, 70%, 50%)`, + color: `hsl(${(partCounter * 137.5) % 360}, 70%, 50%)`, // Золотое сечение для цветов internalPartitions: internalPartitions }); partCounter++; @@ -52,66 +63,68 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat return parts; }; -// --- ГЕНЕРАЦИЯ ФОРМ С ОТВЕРСТИЯМИ --- +// --- ГЕОМЕТРИЯ --- -// Создает форму прямоугольника с отверстиями по паттерну +// Прямоугольник с отверстиями (для стен) const createPerforatedShape = (width: number, height: number, config: AppConfig): THREE.Shape => { const shape = new THREE.Shape(); - // Рисуем внешний контур (CCW) + // Внешний контур (Counter-Clockwise) shape.moveTo(0, 0); shape.lineTo(width, 0); shape.lineTo(width, height); shape.lineTo(0, height); shape.lineTo(0, 0); - // Если перфорация выключена или стенка слишком маленькая, возвращаем сплошной if (!config.perforation?.enabled || width < 15 || height < 15) return shape; const { pattern, diameter, spacing } = config.perforation; const step = diameter + Math.max(2, spacing); - - // Отступы от краев (чтобы не портить прочность) - const margin = 6; + const margin = 4; // Отступ от краев стенки - // Эффективная область для дырок const effW = width - margin * 2; const effH = height - margin * 2; if (effW <= 0 || effH <= 0) return shape; - // Расчет сетки - const rowHeight = pattern === 'circle' ? step : step * 0.866; + const rowH = pattern === 'circle' ? step : step * 0.866; const cols = Math.floor(effW / step); - const rows = Math.floor(effH / rowHeight); + const rows = Math.floor(effH / rowH); - // Центрирование const startX = margin + (effW - (cols - 1) * step) / 2; - const startY = margin + (effH - (rows - 1) * rowHeight) / 2; + const startY = margin + (effH - (rows - 1) * rowH) / 2; for (let j = 0; j < rows; j++) { const isOdd = j % 2 !== 0; - const y = startY + j * rowHeight; + const y = startY + j * rowH; for (let i = 0; i < cols; i++) { let x = startX + i * step; if ((pattern === 'hexagon' || pattern === 'triangle') && isOdd) x += step / 2; - // Проверка, что отверстие внутри (с запасом на радиус) - const r = diameter / 2; - if (x - r < margin || x + r > width - margin || y - r < margin || y + r > height - margin) continue; + // Проверка границ + if (x - diameter/2 < margin || x + diameter/2 > width - margin || + y - diameter/2 < margin || y + diameter/2 > height - margin) continue; const hole = new THREE.Path(); + const r = diameter / 2; + + // ВАЖНО: Отверстия должны рисоваться по ЧАСОВОЙ стрелке (Clockwise), + // иначе Three.js не вырежет их, а зальет. if (pattern === 'circle') { - hole.absarc(x, y, r, 0, Math.PI * 2, true); // CW для отверстий + // aClockwise = false + hole.absarc(x, y, r, 0, Math.PI * 2, false); } else if (pattern === 'hexagon') { for (let k = 0; k < 6; k++) { - const angle = (k * 60 + 30) * Math.PI / 180; // 30 deg offset for flat top + const angle = (k * 60 + 30) * Math.PI / 180; const px = x + r * Math.cos(angle); const py = y + r * Math.sin(angle); if (k === 0) hole.moveTo(px, py); else hole.lineTo(px, py); } - hole.closePath(); + // Для многоугольников порядок зависит от порядка точек. + // Создаем их в нужном порядке или используем reverse() если не вырезается. + // Текущий порядок CCW, нужно CW? Проверим на практике. Обычно Path AutoClose работает. + // Если возникнут проблемы, поменяем порядок k (5..0). } else if (pattern === 'triangle') { const rot = isOdd ? 180 : 0; for (let k = 0; k < 3; k++) { @@ -120,21 +133,20 @@ const createPerforatedShape = (width: number, height: number, config: AppConfig) const py = y + r * Math.sin(angle); if (k === 0) hole.moveTo(px, py); else hole.lineTo(px, py); } - hole.closePath(); } + 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); + const r = Math.min(radius, width / 2 - 0.1, depth / 2 - 0.1); if (r <= 0.1) { shape.moveTo(x, y); @@ -156,8 +168,7 @@ const createFloorShape = (width: number, depth: number, radius: number): THREE.S return shape; }; -// Галтель (вогнутый уголок) -const createFilletShape = (radius: number): THREE.Shape => { +const createConcaveFilletShape = (radius: number): THREE.Shape => { const shape = new THREE.Shape(); shape.moveTo(0, 0); shape.lineTo(radius, 0); @@ -166,7 +177,7 @@ const createFilletShape = (radius: number): THREE.Shape => { return shape; }; -// --- ГЛАВНАЯ ФУНКЦИЯ --- +// --- СБОРКА БИНА --- export const createBinGeometry = ( width: number, depth: number, height: number, thickness: number, radius: number = 0, partitions: Partition[] = [], config?: AppConfig @@ -174,183 +185,172 @@ export const createBinGeometry = ( const geometries: THREE.BufferGeometry[] = []; const safeConfig = config || { perforation: { enabled: false } } as AppConfig; - // 1. ПОЛ (Сплошной) + // 1. ПОЛ const floorShape = createFloorShape(width, depth, radius); const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false }); - floorGeo.rotateX(-Math.PI / 2); // Кладем на плоскость XZ + floorGeo.rotateX(-Math.PI / 2); // XZ plane geometries.push(floorGeo); - // 2. ВНЕШНИЕ СТЕНКИ - // Строим их "лежа" в плоскости XY, а потом поворачиваем и ставим на место. - // Это позволяет использовать 2D логику для отверстий. - + // 2. СТЕНКИ (Внешние) const wallH = height - thickness; - const sideWallW = depth - (2 * thickness); // Боковые стенки встанут МЕЖДУ передней и задней + const innerW = width - 2 * thickness; + const innerD = depth - 2 * thickness; - // Передняя и Задняя (Полная ширина) - const shapeFB = createPerforatedShape(width, wallH, safeConfig); - // Левая и Правая (Укороченные, чтобы встать в паз) - const shapeLR = createPerforatedShape(sideWallW, wallH, safeConfig); + // Формы стен с перфорацией (2D профиль) + const shapeFrontBack = createPerforatedShape(innerW, wallH, safeConfig); + const shapeLeftRight = createPerforatedShape(depth, wallH, safeConfig); // Полная глубина для боковин - // Функция для позиционирования стенки - const placeWall = (shape: THREE.Shape, x: number, y: number, z: number, rotY: number) => { + // Helper для установки стены + const addWall = (shape: THREE.Shape, x: number, z: number, rotationY: number, offsetZ: number = 0) => { const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }); - // Центрируем пивот по X для удобного вращения, если нужно, или просто сдвигаем - // По умолчанию Shape рисуется от 0,0 в +X,+Y. Extrude идет в +Z. - // Сдвигаем pivot в центр по X (ширине стенки) - // Нет, проще оперировать от угла. - // 0,0 shape -> это нижний левый угол стенки. + // По умолчанию Shape 0..W, 0..H. Extrude 0..Thick (Z). + // Центрируем по высоте (ставим на пол) - geo.translate(0, thickness, 0); // Поднимаем на толщину пола (Y) - - // Вращаем вокруг Y - // Внимание: вращение идет вокруг (0,0,0) сцены, поэтому сначала вращаем, потом двигаем - - // 1. Поворот самой геометрии относительно её начала - if (rotY !== 0) { - geo.rotateY(rotY); - } - - // 2. Перенос на позицию - geo.translate(x, 0, z); + if (rotationY !== 0) geo.rotateY(rotationY); + // Позиционирование + geo.translate(x, thickness, z); geometries.push(geo); }; - // Back Wall (Задняя) - // Стоит вдоль X. Позиция: x=-width/2, z=-depth/2. - // Рисуется от 0 до width. - const geoBack = new THREE.ExtrudeGeometry(shapeFB, { depth: thickness, bevelEnabled: false }); - geoBack.translate(-width/2, thickness, -depth/2); // Ставим назад - geometries.push(geoBack); + // Front (Спереди, вдоль X) + const geoF = new THREE.ExtrudeGeometry(shapeFrontBack, { depth: thickness, bevelEnabled: false }); + // Центрируем по X (-innerW/2) + geoF.translate(-innerW/2, thickness, depth/2 - thickness); + geometries.push(geoF); - // Front Wall (Передняя) - // Стоит вдоль X. Позиция: x=-width/2, z=depth/2 - thickness. - const geoFront = new THREE.ExtrudeGeometry(shapeFB, { depth: thickness, bevelEnabled: false }); - geoFront.translate(-width/2, thickness, depth/2 - thickness); - geometries.push(geoFront); + // Back (Сзади, вдоль X) + const geoB = new THREE.ExtrudeGeometry(shapeFrontBack, { depth: thickness, bevelEnabled: false }); + // Сдвигаем depth mesh'а назад + geoB.translate(0, 0, -thickness); + geoB.translate(-innerW/2, thickness, -depth/2 + thickness); + geometries.push(geoB); - // Left Wall (Левая) - // Стоит вдоль Z. Повернута на 90 град. - // Длина = sideWallW. - const geoLeft = new THREE.ExtrudeGeometry(shapeLR, { depth: thickness, bevelEnabled: false }); - geoLeft.rotateY(Math.PI / 2); - // После поворота на 90: +X стал +Z. Начало в 0,0. - // Нам нужно поставить её на x = -width/2, z = -sideWallW/2 (центрировать по глубине) - // С учетом толщины пола и стенок: - geoLeft.translate(-width/2, thickness, -sideWallW/2); - geometries.push(geoLeft); + // Left (Слева, вдоль Z) + const geoL = new THREE.ExtrudeGeometry(shapeLeftRight, { depth: thickness, bevelEnabled: false }); + geoL.rotateY(Math.PI / 2); // Поворот +90. X->Z. (Len, 0, 0) -> (0, 0, -Len) ? Нет, (0,0,-Len) + // Коррекция позиции + geoL.translate(-width/2, thickness, -depth/2); + geometries.push(geoL); - // Right Wall (Правая) - const geoRight = new THREE.ExtrudeGeometry(shapeLR, { depth: thickness, bevelEnabled: false }); - geoRight.rotateY(Math.PI / 2); - geoRight.translate(width/2 - thickness, thickness, -sideWallW/2); - geometries.push(geoRight); + // Right (Справа, вдоль Z) + 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. ВНУТРЕННИЕ ПЕРЕГОРОДКИ - // Используем простую логику, как в редакторе (min/max) + // Используем жесткие координаты min/max, без попыток угадать (Solver удален для соответствия 2D) partitions.forEach(p => { const pMin = p.min ?? 0; const pMax = p.max ?? 1; - // Пропускаем слишком короткие или ошибочные if (pMax - pMin < 0.01) return; - const innerW = width - 2 * thickness; - const innerD = depth - 2 * thickness; - + const lengthRatio = pMax - pMin; let partLen = 0; let posX = 0; let posZ = 0; - let isVertical = false; + let isVertical = false; // Vertical on 2D screen = Along Z axis in 3D if (p.axis === 'x') { - // Вертикальная на экране (вдоль Z) - partLen = (pMax - pMin) * innerD; + // Вертикальная на экране -> Вдоль Z isVertical = true; - // X координата (центр линии) - posX = (-innerW/2) + (p.offset * innerW); - // Z координата (начало линии) + partLen = lengthRatio * innerD; + // Центр по X + posX = (-innerW/2) + (p.offset * innerW); + // Начало по Z posZ = (-innerD/2) + (pMin * innerD); } else { - // Горизонтальная на экране (вдоль X) - partLen = (pMax - pMin) * innerW; + // Горизонтальная на экране -> Вдоль X isVertical = false; - // X координата (начало линии) + partLen = lengthRatio * innerW; + // Начало по X posX = (-innerW/2) + (pMin * innerW); - // Z координата (центр линии) + // Центр по Z posZ = (-innerD/2) + (p.offset * innerD); } - // Создаем стенку с дырками + // Создаем профиль с дырками const partShape = createPerforatedShape(partLen, wallH, safeConfig); const partGeo = new THREE.ExtrudeGeometry(partShape, { depth: thickness, bevelEnabled: false }); if (isVertical) { - partGeo.rotateY(Math.PI / 2); - // Центрируем толщину: offset - thickness/2 - partGeo.translate(posX - thickness/2, thickness, posZ); + // Поворот чтобы шла вдоль Z + partGeo.rotateY(Math.PI / 2); + // При повороте +90 вокруг (0,0,0), положительный X уходит в отрицательный Z (или положительный, зависит от системы) + // ThreeJS: Right handed. Y up. + // Shape 0..Len по X. Rotate Y 90 -> 0..-Len по Z. + // Нам нужно поставить начало (0,0) в (posX, floor, posZ). + // Но из-за поворота "длина" ушла в -Z. Значит posZ - это "верхняя" точка? + // Нет, в 2D Y идет вниз. min - это верх. max - это низ. + // В 3D Z идет "на нас" (обычно). minZ - зад, maxZ - перед. + // Если min=0 (верх в 2D) -> -depth/2 (зад в 3D). + // Стенка идет от зада к переду. Значит Z растет. + // Нам нужен поворот -90 (-PI/2), чтобы X перешел в +Z. + partGeo.rotateY(-Math.PI / 2); + + // Центрируем толщину по X + partGeo.translate(posX + thickness/2, thickness, posZ); + // Сдвиг на thickness/2 может зависеть от того, как экструдилось (0..thick или -thick/2..thick/2) + // Extrude создает 0..depth. После поворота это становится X? Нет. + // Extrude по Z локальному. Rotate Y крутит оси X и Z. + // Изначально: Shape в XY. Extrude в Z. + // Rotate Y -90: + // X -> Z. Y -> Y. Z -> -X. + // Толщина ушла в -X. Длина ушла в +Z. + // Позиция: StartX, StartY, StartZ. } else { - // Вдоль X + // Вдоль X. Поворот не нужен. + // Толщина уходит в +Z. + // Нам нужно центрировать толщину вокруг posZ. partGeo.translate(posX, thickness, posZ - thickness/2); } geometries.push(partGeo); - // --- СКРУГЛЕНИЯ (FILLETS) --- - // Добавляем только если есть примыкание + // --- ГАЛТЕЛИ (FILLETS) --- if (p.rounded && radius > 1) { const filletR = Math.min(radius, 5); - const filletShape = createFilletShape(filletR); - const h = p.height; // Пока берем полную высоту, чтобы не усложнять + const filletShape = createConcaveFilletShape(filletR); + const h = p.height; // Упрощаем высоту до полной, чтобы избежать глюков - const addFillet = (fx: number, fz: number, rot: number) => { + const addFillet = (x: number, z: number, rot: number) => { const geo = new THREE.ExtrudeGeometry(filletShape, { depth: h, bevelEnabled: false }); - geo.rotateX(-Math.PI / 2); // Кладем плашмя - geo.rotateY(rot); // Крутим вокруг оси Y - geo.translate(fx, thickness, fz); + geo.rotateX(-Math.PI / 2); + geo.rotateY(rot); + geo.translate(x, thickness, z); geometries.push(geo); }; const t = thickness / 2; if (isVertical) { - // Концы вертикальной стенки (по Z) - const topZ = posZ; // pMin - const botZ = posZ + partLen; // pMax - - // Top junction - addFillet(posX - t, topZ, Math.PI); - addFillet(posX + t, topZ, -Math.PI/2); - // Bottom junction - addFillet(posX - t, botZ, Math.PI/2); - addFillet(posX + t, botZ, 0); + const startZ = posZ; + const endZ = posZ + partLen; + // Стыки + addFillet(posX - t, startZ, Math.PI); + addFillet(posX + t, startZ, -Math.PI/2); + addFillet(posX - t, endZ, Math.PI/2); + addFillet(posX + t, endZ, 0); } else { - // Концы горизонтальной стенки (по X) - const leftX = posX; // pMin - const rightX = posX + partLen; // pMax - - // Left junction - addFillet(leftX, posZ - t, 0); - addFillet(leftX, posZ + t, -Math.PI/2); - // Right junction - addFillet(rightX, posZ - t, Math.PI/2); - addFillet(rightX, posZ + t, Math.PI); + const startX = posX; + const endX = posX + partLen; + addFillet(startX, posZ - t, 0); + addFillet(startX, posZ + t, -Math.PI/2); + addFillet(endX, posZ - t, Math.PI/2); + addFillet(endX, posZ + t, Math.PI); } } }); const merged = mergeBufferGeometries(geometries); - if (merged) { - merged.computeVertexNormals(); - return merged; - } - return new THREE.BoxGeometry(1, 1, 1); + if (merged) merged.computeVertexNormals(); + return merged || new THREE.BoxGeometry(1, 1, 1); }; -// Экспорт STL export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => { const exporter = new STLExporter(); const result = exporter.parse(mesh, { binary: true });