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 a16108a..9485a88 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -4,7 +4,6 @@ import { AppConfig, LayoutSplits, GeneratedPart, PerforationConfig } from '../ty /** * 1. Расчет списка ящиков на основе сетки - * Это создает массив отдельных коробочек, которые визуально образуют органайзер */ export const calculateParts = ( config: AppConfig, @@ -12,7 +11,7 @@ export const calculateParts = ( ): GeneratedPart[] => { const parts: GeneratedPart[] = []; - // Сортируем линии реза и добавляем границы (0 и 1) + // Сортируем линии реза const xPoints = [0, ...[...splits.x].sort((a, b) => a - b), 1]; const yPoints = [0, ...[...splits.y].sort((a, b) => a - b), 1]; @@ -21,26 +20,24 @@ export const calculateParts = ( for (let i = 0; i < xPoints.length - 1; i++) { for (let j = 0; j < yPoints.length - 1; j++) { - // Размеры текущей ячейки сетки 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; - // Применяем толерантность (зазор между ящиками) - // Уменьшаем размер ящика, сдвигаем его к центру + // Применяем Tolerance (зазор) const realWidth = segmentW - config.printerTolerance; const realDepth = segmentD - config.printerTolerance; const realX = segmentX + (config.printerTolerance / 2); const realY = segmentY + (config.printerTolerance / 2); - // Защита от слишком мелких (фантомных) ячеек - if (realWidth < 2 || realDepth < 2) { + // Фильтр слишком мелких ячеек + if (realWidth < 1 || realDepth < 1) { continue; } parts.push({ - id: `part-${partCounter}-${Date.now()}`, // Уникальный ID + id: `part-${partCounter}`, name: `Ячейка ${i+1}-${j+1}`, width: realWidth, depth: realDepth, @@ -57,9 +54,7 @@ export const calculateParts = ( }; /** - * 2. Создание 2D профиля стены с отверстиями - * ВАЖНО: Контур стены -> CCW (Против часовой) - * ВАЖНО: Отверстия -> CW (По часовой) + * 2. Создание формы стены с отверстиями (алгоритм из архива) */ const createPerforatedWallShape = ( width: number, @@ -67,8 +62,7 @@ const createPerforatedWallShape = ( perf: PerforationConfig ): THREE.Shape => { const shape = new THREE.Shape(); - - // Внешний прямоугольник (Против часовой стрелки) + // Основной контур shape.moveTo(0, 0); shape.lineTo(width, 0); shape.lineTo(width, height); @@ -79,7 +73,7 @@ const createPerforatedWallShape = ( const { size, spacing, shape: type, border } = perf; - // Эффективная зона перфорации + // Эффективная зона const startX = border; const endX = width - border; const startY = border; @@ -87,22 +81,21 @@ const createPerforatedWallShape = ( 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(); - const r = size / 2; if (type === 'circle') { - // aClockwise = true (По часовой стрелке) - holePath.absarc(cx, cy, r, 0, Math.PI * 2, true); + holePath.absarc(cx, cy, size / 2, 0, Math.PI * 2, true); } else if (type === 'hexagon') { - // Шестиугольник (По часовой стрелке) - // angle идет в минус: 90, 30, -30... + const r = size / 2; for (let k = 0; k < 6; k++) { - const angle = (-k * 60 + 90) * (Math.PI / 180); + 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); @@ -110,8 +103,8 @@ const createPerforatedWallShape = ( } holePath.closePath(); } else if (type === 'triangle') { - // Треугольник (По часовой стрелке) - const angles = [90, -30, 210]; // 90 -> -30 (CW) + 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); @@ -127,24 +120,21 @@ const createPerforatedWallShape = ( // Генерация сетки if (type === 'hexagon') { - // Сотовая структура (смещенные ряды) - const hexWidth = size * 0.866; // sqrt(3)/2 + const hexHeight = size; + const hexWidth = size * 0.866; const colDist = hexWidth + spacing; - const rowDist = (size * 0.75) + spacing; + const rowDist = (hexHeight * 0.75) + spacing; - let rowIndex = 0; + let row = 0; for (let y = startY + size/2; y < endY; y += rowDist) { - const isOddRow = rowIndex % 2 === 1; - const offset = isOddRow ? colDist / 2 : 0; - + const offset = (row % 2) === 1 ? colDist / 2 : 0; for (let x = startX + size/2 + offset; x < endX; x += colDist) { addHole(x, y); } - rowIndex++; + row++; } } else { - // Обычная сетка (Круг, Треугольник) - const cellSize = size + spacing; + // Обычная сетка for (let x = startX + size/2; x < endX; x += cellSize) { for (let y = startY + size/2; y < endY; y += cellSize) { addHole(x, y); @@ -156,7 +146,7 @@ const createPerforatedWallShape = ( }; /** - * 3. Создание 3D геометрии для ОДНОГО ящика + * 3. Генерация 3D геометрии одного ящика */ export const createBinGeometry = ( width: number, @@ -168,7 +158,8 @@ export const createBinGeometry = ( const geometries: THREE.BufferGeometry[] = []; const perfConfig = perforation || { enabled: false, shape: 'circle', size: 0, spacing: 0, border: 0 }; - // 1. Пол (Всегда сплошной) + // 1. Пол - Всегда сплошной + // ВАЖНО: .toNonIndexed() нужен для корректного слияния с ExtrudeGeometry const floorGeo = new THREE.BoxGeometry(width, thickness, depth).toNonIndexed(); floorGeo.translate(0, thickness / 2, 0); geometries.push(floorGeo); @@ -182,58 +173,47 @@ export const createBinGeometry = ( }; // 2. Левая и Правая стенки (Полная глубина) - // Рисуем профиль (Ширина профиля = Глубине ящика) + // Рисуем профиль шириной = глубине ящика const lrShape = createPerforatedWallShape(depth, wallHeight, perfConfig); + // ВАЖНО: .toNonIndexed() const lrGeo = new THREE.ExtrudeGeometry(lrShape, extrudeSettings).toNonIndexed(); - // Центрируем геометрию для удобного вращения - lrGeo.center(); - - // Левая стенка (Left) - // Поворачиваем: Профиль лежит вдоль X -> поворот на 90 -> вдоль Z + // Left Wall const leftWall = lrGeo.clone(); - leftWall.rotateY(Math.PI / 2); - // Позиция: X = -width/2 + thickness/2, Y = пол + пол_стены - leftWall.translate(-(width/2) + thickness/2, thickness + wallHeight/2, 0); + leftWall.rotateY(-Math.PI / 2); + leftWall.translate(-(width/2) + thickness, thickness, -(depth/2)); geometries.push(leftWall); - // Правая стенка (Right) + // Right Wall const rightWall = lrGeo.clone(); - rightWall.rotateY(Math.PI / 2); - rightWall.translate((width/2) - thickness/2, thickness + wallHeight/2, 0); + rightWall.rotateY(-Math.PI / 2); + rightWall.translate((width/2), thickness, -(depth/2)); geometries.push(rightWall); - // 3. Передняя и Задняя стенки (Вставляются МЕЖДУ боковыми) - // Их ширина меньше на 2 толщины - const wallFBWidth = width - (2 * thickness); - + // 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(); - - fbGeo.center(); - // Передняя стенка (Front) + // Front Wall const frontWall = fbGeo.clone(); - frontWall.translate(0, thickness + wallHeight/2, (depth/2) - thickness/2); + frontWall.translate(-(wallFBWidth/2), thickness, (depth/2) - thickness); geometries.push(frontWall); - // Задняя стенка (Back) + // Back Wall const backWall = fbGeo.clone(); - backWall.translate(0, thickness + wallHeight/2, -(depth/2) + thickness/2); + backWall.translate(-(wallFBWidth/2), thickness, -(depth/2)); geometries.push(backWall); } } - // Сливаем всё в один меш + // Слияние в один меш const merged = mergeBufferGeometries(geometries); - if (merged) merged.computeVertexNormals(); - 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 });