From 3d79abf4d45796035e9ebc68b769dede93fdc10c 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:07:55 +0300 Subject: [PATCH] 4 --- src/services/geometryGenerator.ts | 274 +++++++++++++++--------------- 1 file changed, 139 insertions(+), 135 deletions(-) diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index cdff19c..77dc906 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -2,28 +2,37 @@ import * as THREE from 'three'; import { STLExporter, mergeBufferGeometries } from 'three-stdlib'; import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; -// --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ --- +// --- CLEANUP UTILS --- -// Очистка дубликатов и сортировка точек (убирает фантомные ячейки) -const cleanPoints = (points: number[]) => { - const sorted = [...points].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.005) { // Игнорируем точки ближе 0.5% - unique.push(sorted[i]); +// Удаляет дублирующиеся перегородки (фантомы) +const deduplicatePartitions = (partitions: Partition[]): Partition[] => { + const unique: Partition[] = []; + const seen = new Set(); + + partitions.forEach(p => { + // Округляем координаты для создания уникального ключа + const k = `${p.axis}-${p.offset.toFixed(3)}-${p.min?.toFixed(3)}-${p.max?.toFixed(3)}`; + if (!seen.has(k)) { + seen.add(k); + unique.push(p); } - } + }); return unique; }; +// Очистка точек для генерации цветных объемов +const cleanPoints = (points: number[]) => { + return Array.from(new Set(points.map(p => parseFloat(p.toFixed(3))))).sort((a, b) => a - b); +}; + +// --- CALCULATE VOLUMES (ЦВЕТНЫЕ КУБИКИ) --- export const calculateParts = (config: AppConfig, splits: LayoutSplits): GeneratedPart[] => { const parts: GeneratedPart[] = []; - // Используем защищенные массивы - const rawX = Array.isArray(splits?.x) ? splits.x : []; - const rawY = Array.isArray(splits?.y) ? splits.y : []; + const safeX = Array.isArray(splits?.x) ? splits.x : []; + const safeY = Array.isArray(splits?.y) ? splits.y : []; - const uniqueX = cleanPoints([0, ...rawX, 1]); - const uniqueY = cleanPoints([0, ...rawY, 1]); + const uniqueX = cleanPoints([0, ...safeX, 1]); + const uniqueY = cleanPoints([0, ...safeY, 1]); let partCounter = 1; @@ -37,22 +46,21 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat const w = (x2 - x1) * config.drawer.width; const d = (y2 - y1) * config.drawer.depth; - // Пропускаем слишком маленькие или некорректные объемы if (w < 2 || d < 2) continue; - // Визуальный отступ, чтобы кубики не слипались со стенками - const gap = config.wallThickness / 2 + 0.5; + // Отступ для визуализации (gap) + const gap = config.wallThickness / 2 + 0.2; parts.push({ id: `part-${partCounter}`, name: `Ячейка ${partCounter}`, width: Math.max(1, w - gap * 2), depth: Math.max(1, d - gap * 2), - height: config.drawer.height, + height: config.drawer.height - config.wallThickness, x: (x1 * config.drawer.width) + gap, y: (y1 * config.drawer.depth) + gap, color: `hsl(${(partCounter * 137.5) % 360}, 70%, 50%)`, - internalPartitions: [] // Внутренние перегородки обрабатываются отдельно в createBinGeometry + internalPartitions: [] }); partCounter++; } @@ -60,25 +68,24 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat return parts; }; -// --- ГЕОМЕТРИЯ СТЕН И ОТВЕРСТИЙ --- +// --- SHAPE GENERATION (PERFORATION) --- -const createPerforatedWallShape = (length: number, height: number, config: AppConfig): THREE.Shape => { +const createPerforatedShape = (length: number, height: number, config: AppConfig): THREE.Shape => { const shape = new THREE.Shape(); - // 1. Внешний контур: Против часовой стрелки (CCW) - // (0,0) -> (len,0) -> (len,h) -> (0,h) -> (0,0) + // 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; + // Если перфорация выключена или стена слишком мала + 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 = 4; // Отступ от края стенки + const margin = 4; // Отступ от краев const effW = length - margin * 2; const effH = height - margin * 2; @@ -92,8 +99,6 @@ const createPerforatedWallShape = (length: number, height: number, config: AppCo const startX = margin + (effW - (cols - 1) * step) / 2; const startY = margin + (effH - (rows - 1) * rowH) / 2; - const holes: THREE.Path[] = []; - for (let j = 0; j < rows; j++) { const isOdd = j % 2 !== 0; const cy = startY + j * rowH; @@ -102,24 +107,20 @@ const createPerforatedWallShape = (length: number, height: number, config: AppCo 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) - // Это критически важно для корректного отображения и экспорта! - + // 2. Отверстия: CW (По часовой) - Это критично для Three.js! if (pattern === 'circle') { - // aClockwise = true (CW) hole.absarc(cx, cy, r, 0, Math.PI * 2, true); } else if (pattern === 'hexagon') { - // Рисуем 6 точек по часовой стрелке for (let k = 0; k < 6; k++) { - // -k (отрицательный шаг) обеспечивает CW порядок + // -k обеспечивает CW порядок const angle = (-k * 60 + 90) * Math.PI / 180; const px = cx + r * Math.cos(angle); const py = cy + r * Math.sin(angle); @@ -129,7 +130,6 @@ const createPerforatedWallShape = (length: number, height: number, config: AppCo } 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); @@ -138,21 +138,19 @@ const createPerforatedWallShape = (length: number, height: number, config: AppCo } hole.closePath(); } - holes.push(hole); + shape.holes.push(hole); } } - shape.holes = holes; return shape; }; -// Пол (всегда сплошной) +// Floor Shape (Solid) 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); - // CCW Order if (r <= 0.1) { shape.moveTo(x, y); shape.lineTo(x + width, y); @@ -173,18 +171,16 @@ const createFloorShape = (width: number, depth: number, radius: number): THREE.S return shape; }; -// Галтель (вогнутая) const createFilletShape = (radius: number): THREE.Shape => { const shape = new THREE.Shape(); shape.moveTo(0, 0); shape.lineTo(radius, 0); - // Дуга CW для выреза, но так как это тело вращения/экструзии, тут важна форма профиля shape.absarc(radius, radius, radius, -Math.PI / 2, -Math.PI, true); shape.lineTo(0, 0); return shape; }; -// --- СБОРКА МОДЕЛИ --- +// --- BUILDER --- export const createBinGeometry = ( width: number, depth: number, height: number, thickness: number, radius: number = 0, partitions: Partition[] = [], config?: AppConfig @@ -192,10 +188,10 @@ export const createBinGeometry = ( const geometries: THREE.BufferGeometry[] = []; const safeConfig = config || { perforation: { enabled: false } } as AppConfig; - // 1. ПОЛ + // 1. FLOOR 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); geometries.push(floorGeo); // Размеры внутреннего пространства @@ -203,129 +199,137 @@ export const createBinGeometry = ( const innerD = depth - 2 * thickness; const wallH = height - thickness; - // 2. ВНЕШНИЕ СТЕНКИ - // Мы создаем их вертикально. Базовая форма рисуется в XY (Width x Height), потом вращается. + // Helper для установки стенки + const placeWall = (length: number, isVertical: boolean, centerX: number, centerZ: number) => { + // Генерируем 2D форму с дырками + const shape = createPerforatedShape(length, wallH, safeConfig); + const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }); + + if (isVertical) { + // Вертикальная (идет вдоль Z) + // Shape рисуется в XY. Extrude в Z. + // Поворачиваем вокруг Y на 90. + // X -> Z, Y -> Y, Z -> X. + // Теперь длина (бывший X) идет вдоль Z. Толщина (бывший Z) идет вдоль X. + geo.rotateY(Math.PI / 2); + + // Центр по X: centerX. Начало по Z: centerZ - length/2. + // После поворота: начало в (0,0,0) перешло в (0,0,0). + // Длина ушла в -Z (или +Z в зависимости от правил). + // Проще: ставим центр геометрии в центр позиции. + geo.center(); // Центрируем геометрию локально + geo.translate(centerX, thickness + wallH/2, centerZ); // Ставим на место + } else { + // Горизонтальная (идет вдоль X) + // Shape в XY. Extrude в Z. + // Длина вдоль X. Толщина вдоль Z. + geo.center(); + geo.translate(centerX, thickness + wallH/2, centerZ); + } + geometries.push(geo); + }; + + // 2. EXTERNAL WALLS + // Front (вдоль X) + placeWall(innerW, false, -width/2 + innerW/2 + thickness, depth/2 - thickness/2); // Исправленные координаты + // Проще: Front стоит на Z = depth/2 - thick/2. X центр = 0 (если floor от -W/2 до W/2). + // Floor shape: -W/2..W/2. - // -- Передняя и Задняя (Вдоль X) -- - const shapeFB = createPerforatedWallShape(innerW, wallH, safeConfig); + // Давайте пересчитаем позиции точно относительно центра (0,0) + // Front: CenterX=0, CenterZ = (depth - thickness)/2 + placeWall(innerW, false, 0, (depth - thickness)/2); - // Front - const geoF = new THREE.ExtrudeGeometry(shapeFB, { depth: thickness, bevelEnabled: false }); - geoF.translate(-innerW/2, thickness, depth/2 - thickness); // Центр X, на полу, край Z - geometries.push(geoF); - - // Back - const geoB = new THREE.ExtrudeGeometry(shapeFB, { depth: thickness, bevelEnabled: false }); - geoB.translate(-innerW/2, thickness, -depth/2); // Центр X, на полу, задний край Z - geometries.push(geoB); - - // -- Левая и Правая (Вдоль Z) -- - // Они идут по всей глубине (depth), перекрывая торцы передней/задней - const shapeLR = createPerforatedWallShape(depth, wallH, safeConfig); - - // Left - const geoL = new THREE.ExtrudeGeometry(shapeLR, { depth: thickness, bevelEnabled: false }); - geoL.rotateY(Math.PI / 2); // Поворот +90 (теперь идет вдоль Z) - // При повороте +90 вокруг (0,0,0): X+ -> Z-. Начало (0,0) остается (0,0). - // Нам нужно сместить начало в (X=-width/2, Z=-depth/2) - geoL.translate(-width/2, thickness, -depth/2); - geometries.push(geoL); - - // Right - 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); + // Back: CenterX=0, CenterZ = -(depth - thickness)/2 + placeWall(innerW, false, 0, -(depth - thickness)/2); + + // Left: CenterX=-(width - thickness)/2, CenterZ=0. Length = depth. + placeWall(depth, true, -(width - thickness)/2, 0); + + // Right: CenterX=(width - thickness)/2, CenterZ=0. Length = depth. + placeWall(depth, true, (width - thickness)/2, 0); - // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ - partitions.forEach(p => { + // 3. INTERNAL PARTITIONS + // Используем дедупликацию, чтобы убрать двойные стенки + const uniquePartitions = deduplicatePartitions(partitions); + + uniquePartitions.forEach(p => { const pMin = p.min ?? 0; const pMax = p.max ?? 1; - // Игнорируем некорректные if (pMax - pMin < 0.01) return; let length = 0; - let posX = 0; - let posZ = 0; - let isVert = false; + let cX = 0; + let cZ = 0; + let isVertical = false; if (p.axis === 'x') { - // Вертикальная на 2D-схеме (идет вдоль Z в 3D) - isVert = true; + // Вертикальная на экране 2D (вдоль Z в 3D) + isVertical = true; length = (pMax - pMin) * innerD; - // Центр по X - posX = (-innerW/2) + (p.offset * innerW); - // Начало по Z - posZ = (-innerD/2) + (pMin * innerD); + + // X: offset * innerW. Но innerW начинается от -innerW/2. + cX = (-innerW/2) + (p.offset * innerW); + + // Z центр: Середина между pMin и pMax + const midRatio = (pMin + pMax) / 2; + cZ = (-innerD/2) + (midRatio * innerD); } else { - // Горизонтальная на 2D-схеме (идет вдоль X в 3D) - isVert = false; + // Горизонтальная на экране 2D (вдоль X в 3D) + isVertical = false; length = (pMax - pMin) * innerW; - // Начало по X - posX = (-innerW/2) + (pMin * innerW); - // Центр по Z - posZ = (-innerD/2) + (p.offset * innerD); + + // X центр + const midRatio = (pMin + pMax) / 2; + cX = (-innerW/2) + (midRatio * innerW); + + // Z: offset * innerD + cZ = (-innerD/2) + (p.offset * innerD); } - // Генерируем форму с дырками - const partShape = createPerforatedWallShape(length, wallH, safeConfig); - const partGeo = new THREE.ExtrudeGeometry(partShape, { depth: thickness, bevelEnabled: false }); + placeWall(length, isVertical, cX, cZ); - if (isVert) { - // Поворачиваем вдоль Z - partGeo.rotateY(Math.PI / 2); - // Смещаем. Учитываем толщину, чтобы центрировать по линии реза. - partGeo.translate(posX - thickness/2, thickness, posZ); - } else { - // Вдоль X. Поворот не нужен. - // Смещаем. - partGeo.translate(posX, thickness, posZ - thickness/2); - } - - geometries.push(partGeo); - - // --- ГАЛТЕЛИ (FILLETS) --- + // --- FILLETS --- if (p.rounded && radius > 1) { - const fR = Math.min(radius, 5); + const fR = Math.min(radius, 5); const fShape = createFilletShape(fR); 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(fShape, { depth: h, bevelEnabled: false }); - geo.rotateX(-Math.PI / 2); // Кладем плашмя - geo.rotateY(rot); // Крутим - 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 (isVert) { - const zStart = posZ; - const zEnd = posZ + length; - // Top junction - addFillet(posX - t, zStart, Math.PI); - addFillet(posX + t, zStart, -Math.PI/2); - // Bottom junction - addFillet(posX - t, zEnd, Math.PI/2); - addFillet(posX + t, zEnd, 0); + + // Вычисляем концы стенки для скруглений + if (isVertical) { + const zStart = cZ - length/2; + const zEnd = cZ + length/2; + + // Верхний стык (дальний по Z, если смотреть в 2D) -> Min + addFillet(cX - t, zStart, Math.PI); + addFillet(cX + t, zStart, -Math.PI/2); + // Нижний стык -> Max + addFillet(cX - t, zEnd, Math.PI/2); + addFillet(cX + t, zEnd, 0); } else { - const xStart = posX; - const xEnd = posX + length; - // Left junction - addFillet(xStart, posZ - t, 0); - addFillet(xStart, posZ + t, -Math.PI/2); - // Right junction - addFillet(xEnd, posZ - t, Math.PI/2); - addFillet(xEnd, posZ + t, Math.PI); + const xStart = cX - length/2; + const xEnd = cX + length/2; + + addFillet(xStart, cZ - t, 0); + addFillet(xStart, cZ + t, -Math.PI/2); + addFillet(xEnd, cZ - t, Math.PI/2); + addFillet(xEnd, cZ + t, Math.PI); } } }); const merged = mergeBufferGeometries(geometries); - // Пересчет нормалей критичен для правильного освещения (убирает "прозрачность") if (merged) { merged.computeVertexNormals(); return merged;