import React, { Suspense, useEffect, useRef, useState, useMemo } from 'react'; import { Canvas } from '@react-three/fiber'; import { OrbitControls, Center, Environment, Text } from '@react-three/drei'; import * as THREE from 'three'; import JSZip from 'jszip'; import { AppConfig, GeneratedPart, LayoutSplits, PerforationConfig } 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'; // --- DrawerFrame (Каркас ящика) --- const DrawerFrame = ({ config }: { config: AppConfig }) => { const { width, depth, height } = config.drawer; const offset = 0.5; return ( ) } // --- BinMesh (Ячейка) --- interface BinMeshProps { part: GeneratedPart; thickness: number; cornerRadius: number; perforation: PerforationConfig; isSelected: boolean; onClick: () => void; } const BinMesh: React.FC = ({ part, thickness, cornerRadius, perforation, isSelected, onClick }) => { // 1. Создаем геометрию, учитывая ВНУТРЕННИЕ ПЕРЕГОРОДКИ const geometry = useMemo(() => { return createBinGeometry( part.width, part.depth, part.height, thickness, cornerRadius, part.internalPartitions, // <--- ВАЖНО: передаем перегородки в генератор perforation ); }, [part, thickness, cornerRadius, perforation]); // 2. Создаем контур выделения (EdgesGeometry) // Threshold 20 градусов скрывает линии на плавных скруглениях const edgesGeometry = useMemo(() => { return new THREE.EdgesGeometry(geometry, 20); }, [geometry]); // 3. Вычисляем размеры и позиции для каждой под-ячейки // 3. Вычисляем размеры и позиции для реальных отсеков (с учетом мерджинга) const dimLabels = useMemo(() => { const xOffsets = new Set([0, 1]); const zOffsets = new Set([0, 1]); part.internalPartitions.forEach(p => { if (p.axis === 'x') xOffsets.add(p.offset); if (p.axis === 'y') zOffsets.add(p.offset); }); const xSplits = Array.from(xOffsets).sort((a, b) => a - b); const zSplits = Array.from(zOffsets).sort((a, b) => a - b); const numCols = xSplits.length - 1; const numRows = zSplits.length - 1; if (numCols === 0 || numRows === 0) return []; // Union-Find для объединения ячеек, не разделенных стеной const parent = new Int32Array(numCols * numRows).map((_, i) => i); const find = (i: number): number => { if (parent[i] === i) return i; parent[i] = find(parent[i]); return parent[i]; } const union = (i: number, j: number) => { const rootI = find(i); const rootJ = find(j); if (rootI !== rootJ) parent[rootJ] = rootI; } const getIdx = (c: number, r: number) => r * numCols + c; // Вертикальные границы (X) for (let i = 0; i < numCols - 1; i++) { const boundaryX = xSplits[i + 1]; for (let j = 0; j < numRows; j++) { const zMid = (zSplits[j] + zSplits[j + 1]) / 2; // Проверяем наличие перегородки axis='x' const isBlocked = part.internalPartitions.some(p => p.axis === 'x' && Math.abs(p.offset - boundaryX) < 0.001 && (p.min ?? 0) <= zMid && (p.max ?? 1) >= zMid ); if (!isBlocked) union(getIdx(i, j), getIdx(i + 1, j)); } } // Горизонтальные границы (Z/Y) for (let j = 0; j < numRows - 1; j++) { const boundaryZ = zSplits[j + 1]; for (let i = 0; i < numCols; i++) { const xMid = (xSplits[i] + xSplits[i + 1]) / 2; // Проверяем наличие перегородки axis='y' const isBlocked = part.internalPartitions.some(p => p.axis === 'y' && Math.abs(p.offset - boundaryZ) < 0.001 && (p.min ?? 0) <= xMid && (p.max ?? 1) >= xMid ); if (!isBlocked) union(getIdx(i, j), getIdx(i, j + 1)); } } // Агрегируем регионы const regions: Record = {}; for (let j = 0; j < numRows; j++) { for (let i = 0; i < numCols; i++) { const root = find(getIdx(i, j)); if (!regions[root]) regions[root] = { minC: i, maxC: i, minR: j, maxR: j }; else { const r = regions[root]; r.minC = Math.min(r.minC, i); r.maxC = Math.max(r.maxC, i); r.minR = Math.min(r.minR, j); r.maxR = Math.max(r.maxR, j); } } } return Object.values(regions).map((r, idx) => { const fXStart = xSplits[r.minC]; const fXEnd = xSplits[r.maxC + 1]; const fZStart = zSplits[r.minR]; const fZEnd = zSplits[r.maxR + 1]; const fracW = fXEnd - fXStart; const fracD = fZEnd - fZStart; const dimX = Math.max(0, fracW * (part.width - thickness) - thickness); const dimZ = Math.max(0, fracD * (part.depth - thickness) - thickness); const cx = -part.width / 2 + (fXStart + fXEnd) / 2 * part.width; const cz = -part.depth / 2 + (fZStart + fZEnd) / 2 * part.depth; return { key: `region-${idx}`, pos: [cx, thickness + 0.2, cz] as [number, number, number], text: `${dimX.toFixed(0)} x ${dimZ.toFixed(0)}` }; }); }, [part, thickness]); return ( { e.stopPropagation(); onClick(); }} > {/* Сама модель */} {/* Текстовые метки размеров внутри каждой ячейки */} {dimLabels.map(label => ( {label.text} ))} {/* Белая подсветка при выборе */} {isSelected && ( )} ); }; // --- PreviewStep (Основной компонент) --- interface Props { parts: GeneratedPart[]; config: AppConfig; splits: LayoutSplits; } export const PreviewStep: React.FC = ({ parts, config, splits }) => { 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' }); } }, [selectedId]); // Скачивание одной детали const handleDownload = (part: GeneratedPart) => { const geometry = createBinGeometry( part.width, part.depth, part.height, config.wallThickness, config.cornerRadius, part.internalPartitions, // <--- ВАЖНО для STL 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 { const zip = new JSZip(); parts.forEach(part => { const geometry = createBinGeometry( part.width, part.depth, part.height, config.wallThickness, config.cornerRadius, part.internalPartitions, // <--- ВАЖНО для STL 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}`); } 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 Просмотр */}
Управление
  • • ЛКМ: Вращение
  • • ПКМ: Перемещение
  • • Скролл: Масштаб
{parts.map(part => ( setSelectedId(part.id)} /> ))}
{/* Боковая панель (Сетка) */}

Детали ({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)}
{/* Кнопка скачивания */}
))}
); };