diff --git a/package.json b/package.json index 6290c6b..8709851 100644 --- a/package.json +++ b/package.json @@ -13,23 +13,23 @@ "start": "vite preview --port 3000 --host" }, "dependencies": { + "@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-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", "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/components/ConfigStep.tsx b/src/components/ConfigStep.tsx index 37c856e..b71989e 100644 --- a/src/components/ConfigStep.tsx +++ b/src/components/ConfigStep.tsx @@ -1,153 +1,284 @@ -import React from 'react'; -import { AppConfig } from '../types'; -import { Ruler, Box, Layers, Minimize2, CircleDashed } from 'lucide-react'; +import React, { useEffect } from 'react'; +import { AppConfig, PerforationPattern } from '../types'; +import { Settings2, Box, Ruler, LayoutGrid, Circle, Hexagon, Triangle, Scan } from 'lucide-react'; interface Props { config: AppConfig; - onChange: (newConfig: AppConfig) => void; + onChange: (config: AppConfig) => void; } -const InputGroup: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => ( -
- -
{children}
-
-); - -const NumberInput = ({ - label, - value, - onChange, - max -}: { - label: string; - value: number; - onChange: (val: number) => void; - max?: number -}) => ( -
- {label} - onChange(parseFloat(e.target.value) || 0)} - className="w-full bg-slate-800 border border-slate-700 rounded p-2 pl-8 text-white focus:ring-2 focus:ring-primary outline-none" - /> - мм -
-); - export const ConfigStep: React.FC = ({ config, onChange }) => { - const updateDrawer = (key: keyof AppConfig['drawer'], val: number) => { - onChange({ ...config, drawer: { ...config.drawer, [key]: val } }); + + // Инициализация дефолтных значений перфорации + useEffect(() => { + if (!config.perforation) { + onChange({ + ...config, + perforation: { enabled: false, pattern: 'hexagon', diameter: 8, spacing: 2 } + }); + } + }, []); + + const perf = config.perforation || { enabled: false, pattern: 'hexagon', diameter: 8, spacing: 2 }; + + const updatePerf = (updates: Partial) => { + onChange({ ...config, perforation: { ...perf, ...updates } }); + }; + + const updateDrawer = (key: keyof typeof config.drawer, value: number) => { + onChange({ ...config, drawer: { ...config.drawer, [key]: value } }); + }; + + // --- RENDER PREVIEW --- + const renderPreview = () => { + if (!perf.enabled) return
Перфорация выключена
; + + const size = perf.diameter; + const gap = Math.max(2, perf.spacing); + const step = size + gap; + const W = 200; + const H = 120; + + const elements = []; + // Приблизительный расчет для превью + const rows = Math.floor(H / (step * 0.866)); + const cols = Math.floor(W / step); + + const startX = (W - (cols * step)) / 2; + const startY = (H - (rows * step * 0.866)) / 2; + + for(let j=0; j W - size || y > H - size) continue; + + const color = "#3b82f6"; + + if (perf.pattern === 'circle') { + elements.push(); + } else if (perf.pattern === 'hexagon') { + const r = size / 2; + const points = []; + for (let k = 0; k < 6; k++) { + const angle = (k * 60 + 30) * Math.PI / 180; + points.push(`${x + r * Math.cos(angle)},${y + r * Math.sin(angle)}`); + } + elements.push(); + } else if (perf.pattern === 'triangle') { + const r = size / 2; + const angleOffset = isOdd ? 180 : 0; + const points = []; + for (let k = 0; k < 3; k++) { + const angle = (k * 120 - 90 + angleOffset) * Math.PI / 180; + points.push(`${x + r * Math.cos(angle)},${y + r * Math.sin(angle)}`); + } + elements.push(); + } + } + } + + return ( + + {elements} + + ); }; return ( -
-

- 1. Размеры -

+
+ + {/* ВЕРХНИЙ БЛОК: Размеры и Параметры печати */} +
+ + {/* 1. РАЗМЕРЫ */} +
+

+ 1. Размеры +

+ +
+
+

Внутренние размеры ящика

+
+
+ +
+ updateDrawer('width', parseFloat(e.target.value))} className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-4 pr-12 py-2.5 text-white focus:ring-2 focus:ring-blue-500/50 outline-none transition-all font-mono"/> + MM +
+
+
+ +
+ updateDrawer('depth', parseFloat(e.target.value))} className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-4 pr-12 py-2.5 text-white focus:ring-2 focus:ring-blue-500/50 outline-none transition-all font-mono"/> + MM +
+
+
+ +
+ updateDrawer('height', parseFloat(e.target.value))} className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-4 pr-12 py-2.5 text-white focus:ring-2 focus:ring-blue-500/50 outline-none transition-all font-mono"/> + MM +
+
+
+
+
+
-
- {/* Drawer Dimensions */} -
-

- Внутренние размеры ящика -

- - updateDrawer('width', v)} /> - - - updateDrawer('depth', v)} /> - - - updateDrawer('height', v)} /> - -
+ {/* 2. ПАРАМЕТРЫ ПЕЧАТИ */} +
+

+ Параметры печати +

- {/* Settings */} -
-

- Параметры печати -

- - {/* Wall Thickness */} -
-
- - - {config.wallThickness.toFixed(1)} мм - -
-
- 0.4 - onChange({...config, wallThickness: parseFloat(e.target.value)})} - className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-primary hover:accent-blue-400 transition-all" - /> - 3.2 -
-
+
+ + {/* Wall Thickness */} +
+
+ Толщина стенок + {config.wallThickness} мм +
+ onChange({...config, wallThickness: parseFloat(e.target.value)})} + className="w-full h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ 0.84.0 +
+
- {/* Corner Radius (NEW) */} -
-
- - - {config.cornerRadius?.toFixed(0) || 0} мм - -
-
- 0 - onChange({...config, cornerRadius: parseFloat(e.target.value)})} - className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-purple-500 hover:accent-purple-400 transition-all" - /> - 20 -
-
+ {/* Corner Radius */} +
+
+ Радиус скругления + {config.cornerRadius} мм +
+ onChange({...config, cornerRadius: parseFloat(e.target.value)})} + className="w-full h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-purple-500" + /> +
+ 020 +
+
- {/* Printer Tolerance */} -
-
- - - {config.printerTolerance.toFixed(1)} мм - -
-
- 0.0 - onChange({...config, printerTolerance: parseFloat(e.target.value)})} - className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-accent hover:accent-amber-400 transition-all" - /> - 2.0 -
-
+ {/* Tolerance */} +
+
+ Зазор (Tolerance) + {config.printerTolerance} мм +
+ onChange({...config, printerTolerance: parseFloat(e.target.value)})} + className="w-full h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-yellow-500" + /> +
+ 0.01.0 +
+
-
+
+
+ + {/* НИЖНИЙ БЛОК: ПЕРФОРАЦИЯ */} +
+
+

+ 2. Перфорация (узоры) +

+
+ {perf.enabled ? 'Включено' : 'Выключено'} + +
+
+ +
+ {/* Настройки */} +
+ + {/* Тип узора */} +
+ +
+ + + +
+
+ + {/* Ползунки параметров */} +
+ {/* Diameter */} +
+
+ Диаметр отверстий + {perf.diameter} мм +
+ updatePerf({ diameter: parseFloat(e.target.value) })} + className="w-full h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ 2 мм12 мм +
+
+ + {/* Spacing */} +
+
+ Зазор (между отверстиями) + {perf.spacing} мм +
+ updatePerf({ spacing: parseFloat(e.target.value) })} + className="w-full h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ 2 мм10 мм +
+
+
+
+ + {/* Preview */} +
+
+ + Масштаб условен +
+
+
+ {renderPreview()} +
+
+
+
+
+
); }; \ No newline at end of file diff --git a/src/components/PreviewStep.tsx b/src/components/PreviewStep.tsx index 8334b76..0a394bb 100644 --- a/src/components/PreviewStep.tsx +++ b/src/components/PreviewStep.tsx @@ -1,17 +1,18 @@ -import React, { Suspense, useEffect, useRef, useState, useMemo } from 'react'; +import React, { useMemo, Suspense, useEffect, useRef, useState } from 'react'; import { Canvas } from '@react-three/fiber'; import { OrbitControls, Center, Environment } from '@react-three/drei'; import * as THREE from 'three'; import JSZip from 'jszip'; -import { AppConfig, GeneratedPart, LayoutSplits } from '../types'; -import { createBinGeometry, generateSTL, exportSTL } from '../services/geometryGenerator'; -import { Download, Package, Info, Loader2, Share2, Check, Ruler } from 'lucide-react'; -import { generateShareUrl } from '../utils/share'; +import { AppConfig, GeneratedPart } from '../types'; +import { createBinGeometry, exportSTL, generateSTL } from '../services/geometryGenerator'; +import { Download, Package, Info, Loader2 } from 'lucide-react'; + +// --- 3D Helper Components --- -// --- DrawerFrame (Каркас ящика) --- const DrawerFrame = ({ config }: { config: AppConfig }) => { const { width, depth, height } = config.drawer; const offset = 0.5; + return ( @@ -22,49 +23,43 @@ const DrawerFrame = ({ config }: { config: AppConfig }) => { ) } -// --- BinMesh (Ячейка) --- +// --- Bin Component --- + interface BinMeshProps { part: GeneratedPart; - thickness: number; - cornerRadius: number; + config: AppConfig; isSelected: boolean; onClick: () => void; } -const BinMesh: React.FC = ({ part, thickness, cornerRadius, isSelected, onClick }) => { - // 1. Создаем геометрию, учитывая ВНУТРЕННИЕ ПЕРЕГОРОДКИ +const BinMesh: React.FC = ({ part, config, isSelected, onClick }) => { + // Мемоизация геометрии для производительности const geometry = useMemo(() => { return createBinGeometry( part.width, part.depth, part.height, - thickness, - cornerRadius, - part.internalPartitions // <--- ВАЖНО: передаем перегородки в генератор + config.wallThickness, + config.perforation // Передаем конфиг перфорации! ); - }, [part, thickness, cornerRadius]); - - // 2. Создаем контур выделения (EdgesGeometry) - // Threshold 20 градусов скрывает линии на плавных скруглениях - const edgesGeometry = useMemo(() => { - return new THREE.EdgesGeometry(geometry, 20); - }, [geometry]); + }, [part, config.wallThickness, config.perforation]); return ( - {/* Сама модель */} - { e.stopPropagation(); onClick(); }}> + { e.stopPropagation(); onClick(); }} + > - {/* Белая подсветка при выборе */} {isSelected && ( - + + )} @@ -72,260 +67,181 @@ const BinMesh: React.FC = ({ part, thickness, cornerRadius, isSele ); }; -// --- PreviewStep (Основной компонент) --- interface Props { parts: GeneratedPart[]; config: AppConfig; - splits: LayoutSplits; } -export const PreviewStep: React.FC = ({ parts, config, splits }) => { +export const PreviewStep: React.FC = ({ parts, config }) => { const [selectedId, setSelectedId] = useState(null); const [isZipping, setIsZipping] = useState(false); - const [shareUrlCopied, setShareUrlCopied] = useState(false); const itemRefs = useRef<{ [key: string]: HTMLDivElement | null }>({}); - // Скролл к выбранной детали в списке useEffect(() => { if (selectedId && itemRefs.current[selectedId]) { - itemRefs.current[selectedId]?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + itemRefs.current[selectedId]?.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }); } }, [selectedId]); - // Скачивание одной детали const handleDownload = (part: GeneratedPart) => { - const geometry = createBinGeometry( - part.width, - part.depth, - part.height, - config.wallThickness, - config.cornerRadius, - part.internalPartitions // <--- ВАЖНО для STL - ); + const geometry = createBinGeometry(part.width, part.depth, part.height, config.wallThickness, config.perforation); const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial()); exportSTL(mesh, `${part.name.replace(/\s+/g, '_')}.stl`); }; - // Скачивание всего архивом const handleDownloadAll = async () => { if (isZipping) return; setIsZipping(true); + try { + console.log("Starting ZIP generation..."); + if (typeof JSZip === 'undefined' && !JSZip) { + throw new Error("Библиотека JSZip не загружена."); + } + const zip = new JSZip(); + parts.forEach(part => { - const geometry = createBinGeometry( - part.width, - part.depth, - part.height, - config.wallThickness, - config.cornerRadius, - part.internalPartitions // <--- ВАЖНО для STL - ); + const geometry = createBinGeometry(part.width, part.depth, part.height, config.wallThickness, config.perforation); const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial()); const stlData = generateSTL(mesh); zip.file(`${part.name.replace(/\s+/g, '_')}.stl`, stlData); }); + const content = await zip.generateAsync({ type: "blob" }); + const link = document.createElement('a'); link.href = URL.createObjectURL(content); link.download = "PrintFit_Project.zip"; document.body.appendChild(link); link.click(); document.body.removeChild(link); + } catch (e: any) { - alert(`Ошибка архивации: ${e.message}`); + console.error("Failed to create zip archive", e); + alert(`Ошибка при создании архива: ${e.message || 'Неизвестная ошибка'}`); } finally { setIsZipping(false); } }; - // Поделиться ссылкой - const handleShare = async () => { - const url = generateShareUrl(config, splits); - let success = false; - try { - if (navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(url); - success = true; - } else { throw new Error('Clipboard API unavailable'); } - } catch (err) { - try { - const textArea = document.createElement("textarea"); - textArea.value = url; - textArea.style.position = "fixed"; - textArea.style.left = "-9999px"; - textArea.style.top = "0"; - document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); - const result = document.execCommand('copy'); - document.body.removeChild(textArea); - if (result) success = true; - } catch (e) { console.error("Copy failed", e); } - } - if (success) { - setShareUrlCopied(true); - setTimeout(() => setShareUrlCopied(false), 3000); - } else { - prompt("Скопируйте ссылку вручную:", url); - } - }; - return ( -
- {/* Верхняя панель: Размеры + Поделиться */} -
- -
-
- - Размеры ящика: -
-
-
- Ширина: - {config.drawer.width} -
-
- Глубина: - {config.drawer.depth} -
-
- Высота: - {config.drawer.height} -
- мм -
-
- - -
- -
- {/* 3D Viewer */} -
-
-
- Управление -
-
    -
  • • ЛКМ: Вращение
  • -
  • • ПКМ: Перемещение
  • -
  • • Скролл: Масштаб
  • -
-
- - + {/* 3D Viewer */} +
+
+
+ Управление +
+
    +
  • • ЛКМ: Вращение
  • +
  • • ПКМ: Перемещение
  • +
  • • Скролл: Масштаб
  • +
  • • Клик по детали для выбора
  • +
+
+ + - - - - - -
- - - {parts.map(part => ( - setSelectedId(part.id)} - /> - ))} - -
- -
-
+ > + + + + + + + + +
+ + + {parts.map(part => ( + setSelectedId(part.id)} + /> + ))} + +
+ + +
+ +
+ + {/* Sidebar List */} +
+
+

+ Детали ({parts.length}) +

+
- {/* Sidebar List (Grid Layout) */} -
-
-

- Детали ({parts.length}) -

- -
- -
-
- {parts.map(part => ( -
{ itemRefs.current[part.id] = el }} - className={` - p-3 rounded-lg border transition-all cursor-pointer group flex flex-col gap-2 relative overflow-hidden - ${selectedId === part.id - ? 'bg-slate-800 border-accent shadow-md shadow-accent/10 ring-1 ring-accent' - : 'bg-slate-800/50 border-slate-700 hover:border-slate-500 hover:bg-slate-800' - } - `} - onClick={() => setSelectedId(part.id)} - > - {/* Индикатор цвета */} -
- - {/* Заголовок */} -
- - {part.name} - -
-
- - {/* Размеры */} -
- {part.width.toFixed(0)} × {part.depth.toFixed(0)} × {part.height.toFixed(0)} -
- - {/* Кнопка скачивания */} - -
- ))} -
-
+
+ {parts.map(part => ( +
{ itemRefs.current[part.id] = el }} + className={`p-4 rounded-lg border transition-all cursor-pointer group ${selectedId === part.id ? 'bg-slate-800 border-accent shadow-md shadow-accent/10 ring-1 ring-accent' : 'bg-slate-800/50 border-slate-700 hover:border-slate-500 hover:bg-slate-800'}`} + onClick={() => setSelectedId(part.id)} + > +
+ {part.name} +
+
+
+
+ Ширина + {part.width.toFixed(1)} +
+
+ Глубина + {part.depth.toFixed(1)} +
+
+ Высота + {part.height.toFixed(1)} +
+
+ +
+ ))}
diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index 8633c7d..9485a88 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -1,33 +1,40 @@ import * as THREE from 'three'; import { STLExporter, mergeBufferGeometries } from 'three-stdlib'; -import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; +import { AppConfig, LayoutSplits, GeneratedPart, PerforationConfig } from '../types'; -export const calculateParts = (config: AppConfig, splits: LayoutSplits): GeneratedPart[] => { +/** + * 1. Расчет списка ящиков на основе сетки + */ +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 safeParts = splits?.partitions || {}; - - const xPoints = [0, ...[...safeX].sort((a, b) => a - b), 1]; - const yPoints = [0, ...[...safeY].sort((a, b) => a - b), 1]; + + // Сортируем линии реза + const xPoints = [0, ...[...splits.x].sort((a, b) => a - b), 1]; + const yPoints = [0, ...[...splits.y].sort((a, b) => a - b), 1]; 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; + const segmentX = xPoints[i] * config.drawer.width; + const segmentY = yPoints[j] * config.drawer.depth; + const segmentW = (xPoints[i + 1] - xPoints[i]) * config.drawer.width; + const segmentD = (yPoints[j + 1] - yPoints[j]) * config.drawer.depth; - const rawX = xPoints[i] * config.drawer.width; - const rawY = yPoints[j] * config.drawer.depth; - const internalPartitions = safeParts[`${i}-${j}`] || []; + // Применяем Tolerance (зазор) + const realWidth = segmentW - config.printerTolerance; + const realDepth = segmentD - config.printerTolerance; + const realX = segmentX + (config.printerTolerance / 2); + const realY = segmentY + (config.printerTolerance / 2); - const realWidth = rawW - config.printerTolerance; - const realDepth = rawD - config.printerTolerance; - const realX = rawX + (config.printerTolerance / 2); - const realY = rawY + (config.printerTolerance / 2); + // Фильтр слишком мелких ячеек + if (realWidth < 1 || realDepth < 1) { + continue; + } parts.push({ id: `part-${partCounter}`, @@ -37,174 +44,183 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat height: config.drawer.height, x: realX, y: realY, - color: `hsl(${Math.random() * 360}, 70%, 50%)`, - internalPartitions: internalPartitions + color: `hsl(${(partCounter * 137.5) % 360}, 70%, 50%)` }); partCounter++; } } + return parts; }; -// --- ГЕОМЕТРИЯ --- - -const createRoundedRectShape = (width: number, height: number, radius: number): THREE.Shape => { - const shape = new THREE.Shape(); - const x = -width / 2; - const y = -height / 2; - const r = Math.min(radius, width / 2 - 0.1, height / 2 - 0.1); - - if (r <= 0.1) { - shape.moveTo(x, y); - shape.lineTo(x + width, y); - shape.lineTo(x + width, y + height); - shape.lineTo(x, y + height); - shape.lineTo(x, y); - } else { - shape.moveTo(x, y + r); - shape.lineTo(x, y + height - r); - shape.quadraticCurveTo(x, y + height, x + r, y + height); - shape.lineTo(x + width - r, y + height); - shape.quadraticCurveTo(x + width, y + height, x + width, y + height - 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 createConcaveFilletShape = (radius: number): THREE.Shape => { +/** + * 2. Создание формы стены с отверстиями (алгоритм из архива) + */ +const createPerforatedWallShape = ( + width: number, + height: number, + perf: PerforationConfig +): 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(width, 0); + shape.lineTo(width, height); + shape.lineTo(0, height); shape.lineTo(0, 0); + + if (!perf.enabled) return shape; + + const { size, spacing, shape: type, border } = perf; + + // Эффективная зона + const startX = border; + const endX = width - border; + const startY = border; + const endY = height - border; + + if (startX >= endX || startY >= endY) return shape; + + const cellSize = size + spacing; + + // Хелпер добавления отверстия + const addHole = (cx: number, cy: number) => { + // Проверка границ + if (cx - size/2 < startX || cx + size/2 > endX || cy - size/2 < startY || cy + size/2 > endY) return; + + const holePath = new THREE.Path(); + + if (type === 'circle') { + holePath.absarc(cx, cy, size / 2, 0, Math.PI * 2, true); + } else if (type === 'hexagon') { + const r = size / 2; + for (let k = 0; k < 6; k++) { + const angle = (k * 60 + 30) * (Math.PI / 180); + const px = cx + r * Math.cos(angle); + const py = cy + r * Math.sin(angle); + if (k === 0) holePath.moveTo(px, py); + else holePath.lineTo(px, py); + } + holePath.closePath(); + } else if (type === 'triangle') { + const r = size / 2; + const angles = [90, 210, 330]; + angles.forEach((deg, idx) => { + const rad = deg * (Math.PI / 180); + const px = cx + r * Math.cos(rad); + const py = cy + r * Math.sin(rad); + if (idx === 0) holePath.moveTo(px, py); + else holePath.lineTo(px, py); + }); + holePath.closePath(); + } + + shape.holes.push(holePath); + }; + + // Генерация сетки + if (type === 'hexagon') { + const hexHeight = size; + const hexWidth = size * 0.866; + const colDist = hexWidth + spacing; + const rowDist = (hexHeight * 0.75) + spacing; + + let row = 0; + for (let y = startY + size/2; y < endY; y += rowDist) { + const offset = (row % 2) === 1 ? colDist / 2 : 0; + for (let x = startX + size/2 + offset; x < endX; x += colDist) { + addHole(x, y); + } + row++; + } + } else { + // Обычная сетка + for (let x = startX + size/2; x < endX; x += cellSize) { + for (let y = startY + size/2; y < endY; y += cellSize) { + addHole(x, y); + } + } + } + return shape; }; +/** + * 3. Генерация 3D геометрии одного ящика + */ export const createBinGeometry = ( - width: number, depth: number, height: number, thickness: number, radius: number = 0, partitions: Partition[] = [] + width: number, + depth: number, + height: number, + thickness: number, + perforation?: PerforationConfig ): THREE.BufferGeometry => { const geometries: THREE.BufferGeometry[] = []; - - // ДНО И ВНЕШНИЕ СТЕНКИ - const floorShape = createRoundedRectShape(width, depth, radius); - const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false }); - floorGeo.rotateX(-Math.PI / 2); + const perfConfig = perforation || { enabled: false, shape: 'circle', size: 0, spacing: 0, border: 0 }; + + // 1. Пол - Всегда сплошной + // ВАЖНО: .toNonIndexed() нужен для корректного слияния с ExtrudeGeometry + const floorGeo = new THREE.BoxGeometry(width, thickness, depth).toNonIndexed(); + floorGeo.translate(0, thickness / 2, 0); geometries.push(floorGeo); - const outerShape = createRoundedRectShape(width, depth, radius); - const innerRadius = Math.max(0.1, radius - thickness); - const innerWidth = width - (2 * thickness); - const innerDepth = depth - (2 * thickness); + const wallHeight = height - thickness; - if (innerWidth > 0.1 && innerDepth > 0.1) { - const innerHole = createRoundedRectShape(innerWidth, innerDepth, innerRadius); - outerShape.holes.push(innerHole); + if (wallHeight > 0) { + const extrudeSettings = { + depth: thickness, + bevelEnabled: false, + }; + + // 2. Левая и Правая стенки (Полная глубина) + // Рисуем профиль шириной = глубине ящика + const lrShape = createPerforatedWallShape(depth, wallHeight, perfConfig); + // ВАЖНО: .toNonIndexed() + const lrGeo = new THREE.ExtrudeGeometry(lrShape, extrudeSettings).toNonIndexed(); + + // Left Wall + const leftWall = lrGeo.clone(); + leftWall.rotateY(-Math.PI / 2); + leftWall.translate(-(width/2) + thickness, thickness, -(depth/2)); + geometries.push(leftWall); + + // Right Wall + const rightWall = lrGeo.clone(); + rightWall.rotateY(-Math.PI / 2); + rightWall.translate((width/2), thickness, -(depth/2)); + geometries.push(rightWall); + + // 3. Передняя и Задняя стенки (Вставляются между боковыми) + // Ширина уменьшена на 2 толщины + const wallFBWidth = Math.max(0, width - (2 * thickness)); + if (wallFBWidth > 0) { + const fbShape = createPerforatedWallShape(wallFBWidth, wallHeight, perfConfig); + const fbGeo = new THREE.ExtrudeGeometry(fbShape, extrudeSettings).toNonIndexed(); + + // Front Wall + const frontWall = fbGeo.clone(); + frontWall.translate(-(wallFBWidth/2), thickness, (depth/2) - thickness); + geometries.push(frontWall); + + // Back Wall + const backWall = fbGeo.clone(); + backWall.translate(-(wallFBWidth/2), thickness, -(depth/2)); + geometries.push(backWall); + } } - const wallHeight = height - thickness; - const wallGeo = new THREE.ExtrudeGeometry(outerShape, { depth: wallHeight, bevelEnabled: false }); - wallGeo.rotateX(-Math.PI / 2); - wallGeo.translate(0, thickness, 0); - geometries.push(wallGeo); - - // ВНУТРЕННИЕ ПЕРЕГОРОДКИ (СТРОГО ПО ДАННЫМ, БЕЗ SOLVER) - partitions.forEach(p => { - // Берем данные напрямую. Если в 2D нарисовано от 0.2 до 0.8, тут будет 0.2 до 0.8. - const pMin = p.min ?? 0; - const pMax = p.max ?? 1; - - if (pMax - pMin < 0.01) return; - - const lengthRatio = pMax - pMin; - const midRatio = pMin + (lengthRatio / 2); - - let pWidth = 0, pDepth = 0, pX = 0, pY = 0; - - if (p.axis === 'x') { - pWidth = thickness; - pDepth = lengthRatio * innerDepth; - pX = (-innerWidth / 2) + (innerWidth * p.offset); - pY = (-innerDepth / 2) + (innerDepth * midRatio); - } else { - pWidth = lengthRatio * innerWidth; - pDepth = thickness; - pX = (-innerWidth / 2) + (innerWidth * midRatio); - pY = (-innerDepth / 2) + (innerDepth * p.offset); - } - - const partShape = createRoundedRectShape(pWidth, pDepth, 0.1); - const partGeo = new THREE.ExtrudeGeometry(partShape, { depth: p.height, bevelEnabled: false }); - partGeo.rotateX(-Math.PI / 2); - partGeo.translate(pX, thickness, pY); - geometries.push(partGeo); - - // СКРУГЛЕНИЯ (Fillets) - if (p.rounded && radius > 1) { - const filletR = Math.min(radius, 5); - const filletShape = createConcaveFilletShape(filletR); - - // Функция проверки высоты соседа (простая проверка на пересечение) - const getNeighborHeight = (pos: number) => { - if (pos < 0.001 || pos > 0.999) return height; // Край ящика - - const neighbor = partitions.find(n => { - if (n.axis === p.axis) return false; // Перпендикуляр - const nMin = n.min ?? 0; - const nMax = n.max ?? 1; - // Совпадает ли позиция? - if (Math.abs(n.offset - pos) > 0.002) return false; - // Перекрывает ли? - return p.offset > nMin && p.offset < nMax; - }); - return neighbor ? neighbor.height : 0; - }; - - const hStart = Math.min(p.height, getNeighborHeight(pMin)); - const hEnd = Math.min(p.height, getNeighborHeight(pMax)); - - const addFillet = (x: number, y: number, rotY: number, h: number) => { - if (h <= 1) return; - const geo = new THREE.ExtrudeGeometry(filletShape, { depth: h, bevelEnabled: false }); - geo.rotateX(-Math.PI / 2); - geo.rotateY(rotY); - geo.translate(x, thickness, y); - geometries.push(geo); - }; - - const t = thickness / 2; - - if (p.axis === 'x') { - const topY = (-innerDepth / 2) + (innerDepth * pMin); - const botY = (-innerDepth / 2) + (innerDepth * pMax); - - addFillet(pX - t, topY, Math.PI, hStart); - addFillet(pX + t, topY, -Math.PI / 2, hStart); - addFillet(pX - t, botY, Math.PI / 2, hEnd); - addFillet(pX + t, botY, 0, hEnd); - } else { - const leftX = (-innerWidth / 2) + (innerWidth * pMin); - const rightX = (-innerWidth / 2) + (innerWidth * pMax); - - addFillet(leftX, pY - t, 0, hStart); - addFillet(leftX, pY + t, -Math.PI / 2, hStart); - addFillet(rightX, pY - t, Math.PI / 2, hEnd); - addFillet(rightX, pY + t, Math.PI, hEnd); - } - } - }); - + // Слияние в один меш const merged = mergeBufferGeometries(geometries); - if (merged) merged.computeVertexNormals(); - return merged || new THREE.BoxGeometry(1, 1, 1); + return merged || new THREE.BoxGeometry(1, 1, 1).toNonIndexed(); }; export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => { const exporter = new STLExporter(); const result = exporter.parse(mesh, { binary: true }); - if (result instanceof DataView) return new Uint8Array(result.buffer, result.byteOffset, result.byteLength); + + if (result instanceof DataView) { + return new Uint8Array(result.buffer, result.byteOffset, result.byteLength); + } return result as string; }; diff --git a/src/types.ts b/src/types.ts index c2a7c17..78641f4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,19 +4,29 @@ export interface DrawerDimensions { height: number; } +export type PerforationPattern = 'circle' | 'hexagon' | 'triangle'; + +export interface PerforationConfig { + enabled: boolean; + pattern: PerforationPattern; + diameter: number; // Размер отверстия + spacing: number; // Расстояние между центрами (шаг) +} + export interface AppConfig { drawer: DrawerDimensions; wallThickness: number; printerTolerance: number; cornerRadius: number; + perforation: PerforationConfig; // Новая секция } export interface Partition { id: string; axis: 'x' | 'y'; - offset: number; // Позиция (0.0 - 1.0) - min: number; // Начало стенки (0.0 - 1.0) - max: number; // Конец стенки (0.0 - 1.0) + offset: number; + min: number; + max: number; height: number; rounded: boolean; }