diff --git a/src/App.tsx b/src/App.tsx index 9dffe1b..93dc741 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,14 +1,16 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useEffect } from 'react'; import { AppConfig, LayoutSplits, GeneratedPart } from './types'; -// Убедись, что этот файл существует по этому пути +// Импорт должен быть правильным, проверь путь import { calculateParts } from './services/geometryGenerator'; import { ConfigStep } from './components/ConfigStep'; import { LayoutStep } from './components/LayoutStep'; import { PreviewStep } from './components/PreviewStep'; +import { parseShareUrl } from './utils/share'; // <--- Новый импорт import { ChevronRight, ChevronLeft, Box } from 'lucide-react'; const App = () => { const [step, setStep] = useState(1); + const [isLoadedFromUrl, setIsLoadedFromUrl] = useState(false); // State const [config, setConfig] = useState({ @@ -22,7 +24,22 @@ const App = () => { y: [] }); - // Derived State: Parts (Мгновенный пересчет без API) + // --- ЛОГИКА ВОССТАНОВЛЕНИЯ ИЗ ССЫЛКИ --- + useEffect(() => { + const sharedData = parseShareUrl(); + if (sharedData) { + setConfig(sharedData.config); + setSplits(sharedData.splits); + setStep(3); // Сразу прыгаем на превью + setIsLoadedFromUrl(true); + + // Очищаем URL, чтобы он не мозолил глаза (опционально) + window.history.replaceState({}, '', window.location.pathname); + } + }, []); + // --------------------------------------- + + // Derived State: Parts const parts: GeneratedPart[] = useMemo(() => { return calculateParts(config, splits); }, [config, splits]); @@ -38,7 +55,7 @@ const App = () => {

PrintFit

-

Генератор без ИИ

+

Генератор органайзеров

@@ -77,7 +94,8 @@ const App = () => { {step === 3 && (
- + {/* Передаем splits, чтобы кнопка Share могла их использовать */} +
)} @@ -109,6 +127,8 @@ const App = () => { onClick={() => { setStep(1); setSplits({x: [], y: []}); + // Сбрасываем URL если он был + window.history.replaceState({}, '', window.location.pathname); }} className="flex items-center gap-2 px-6 py-3 rounded-lg font-semibold text-gray-400 hover:text-white transition-colors border border-transparent hover:border-slate-700" > diff --git a/src/components/PreviewStep.tsx b/src/components/PreviewStep.tsx index 8a91087..030e255 100644 --- a/src/components/PreviewStep.tsx +++ b/src/components/PreviewStep.tsx @@ -1,19 +1,17 @@ -import React, { useMemo, Suspense, useEffect, useRef, useState } from 'react'; +import React, { 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 } from '../types'; -import { createBinGeometry, exportSTL, generateSTL } from '../services/geometryGenerator'; -import { Download, Package, Info, Loader2 } from 'lucide-react'; +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'; // <--- Импорт утилиты -// --- 3D Helper Components --- - -// Каркас ящика (только ребра, без диагоналей) +// ... (DrawerFrame и BinMesh остаются без изменений) ... const DrawerFrame = ({ config }: { config: AppConfig }) => { const { width, depth, height } = config.drawer; - const offset = 0.5; // Небольшой отступ наружу - + const offset = 0.5; return ( @@ -24,8 +22,6 @@ const DrawerFrame = ({ config }: { config: AppConfig }) => { ) } -// --- Bin Component --- - interface BinMeshProps { part: GeneratedPart; thickness: number; @@ -34,29 +30,15 @@ interface BinMeshProps { } const BinMesh: React.FC = ({ part, thickness, isSelected, onClick }) => { - // REMOVED ARTIFICIAL GAP: The part dimensions now include the printer tolerance physically. - // The gap will be visible naturally because part.width is smaller than grid size. - - // Генерируем реальную геометрию, как для STL - const geometry = useMemo(() => { + const geometry = React.useMemo(() => { return createBinGeometry(part.width, part.depth, part.height, thickness); }, [part, thickness]); return ( - {/* Основной меш ячейки */} - { e.stopPropagation(); onClick(); }} - > - + { e.stopPropagation(); onClick(); }}> + - - {/* Подсветка выделения (Bounding Box) */} {isSelected && ( @@ -67,23 +49,23 @@ const BinMesh: React.FC = ({ part, thickness, isSelected, onClick ); }; +// --- Основной компонент --- + interface Props { parts: GeneratedPart[]; config: AppConfig; + splits: LayoutSplits; // <--- Добавили splits в пропсы } -export const PreviewStep: React.FC = ({ parts, config }) => { +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' - }); + itemRefs.current[selectedId]?.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, [selectedId]); @@ -96,162 +78,151 @@ export const PreviewStep: React.FC = ({ parts, config }) => { const handleDownloadAll = async () => { if (isZipping) return; setIsZipping(true); - try { - console.log("Starting ZIP generation..."); - // Ensure JSZip is available - if (typeof JSZip === 'undefined' && !JSZip) { - throw new Error("Библиотека JSZip не загружена."); - } - const zip = new JSZip(); - - // Генерация STL для каждой части и добавление в архив parts.forEach(part => { const geometry = createBinGeometry(part.width, part.depth, part.height, config.wallThickness); const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial()); const stlData = generateSTL(mesh); - // stlData is Uint8Array or string here, which is supported zip.file(`${part.name.replace(/\s+/g, '_')}.stl`, stlData); }); - - console.log("Files added to ZIP. Generating blob..."); - - // Генерация самого ZIP файла const content = await zip.generateAsync({ type: "blob" }); - - console.log("ZIP blob generated. Size:", content.size); - - // Скачивание 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) { - console.error("Failed to create zip archive", e); - alert(`Ошибка при создании архива: ${e.message || 'Неизвестная ошибка'}`); + alert(`Ошибка архивации: ${e.message}`); } finally { setIsZipping(false); } }; - return ( -
- {/* 3D Viewer */} -
-
-
- Управление -
-
    -
  • • ЛКМ: Вращение
  • -
  • • ПКМ: Перемещение
  • -
  • • Скролл: Масштаб
  • -
  • • Клик по детали для выбора
  • -
-
- - - - - - - - - + // Логика кнопки "Поделиться" + const handleShare = () => { + const url = generateShareUrl(config, splits); + navigator.clipboard.writeText(url).then(() => { + setShareUrlCopied(true); + setTimeout(() => setShareUrlCopied(false), 2000); + }); + }; -
- - - {parts.map(part => ( - setSelectedId(part.id)} - /> - ))} - -
- - -
-
+ return ( +
+ {/* 1. ВЕРХНЯЯ ПАНЕЛЬ С РАЗМЕРАМИ И КНОПКОЙ SHARE */} +
+ +
+
+ + Размеры ящика: +
+
+
+ W: {config.drawer.width} +
+
+ D: {config.drawer.depth} +
+
+ H: {config.drawer.height} +
+ мм +
+
+ +
- {/* Sidebar List */} -
-
-

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

- +
+ {/* 3D Viewer */} +
+
+
+ Управление +
+
    +
  • • ЛКМ: Вращение
  • +
  • • ПКМ: Перемещение
  • +
  • • Скролл: Масштаб
  • +
+
+ + + + + + + +
+ + + {parts.map(part => ( + setSelectedId(part.id)} + /> + ))} + +
+ +
+
-
- {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)} -
-
- -
- ))} + {/* Sidebar List */} +
+
+

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

+ +
+ +
+ {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} +
+
+ + +
+ ))} +
diff --git a/src/utils/share.ts b/src/utils/share.ts new file mode 100644 index 0000000..4bce9d4 --- /dev/null +++ b/src/utils/share.ts @@ -0,0 +1,54 @@ +import { AppConfig, LayoutSplits } from '../types'; + +interface ShareData { + c: AppConfig; // config + s: LayoutSplits; // splits +} + +/** + * Генерирует ссылку на текущее состояние + */ +export const generateShareUrl = (config: AppConfig, splits: LayoutSplits): string => { + try { + // 1. Собираем объект данных + const data: ShareData = { c: config, s: splits }; + + // 2. Превращаем в JSON строку + const jsonString = JSON.stringify(data); + + // 3. Кодируем в Base64 (чтобы URL был чище) + // btoa работает только с ASCII, поэтому для надежности кодируем через URI + const base64 = btoa(encodeURIComponent(jsonString)); + + // 4. Формируем полный URL + return `${window.location.origin}?share=${base64}`; + } catch (e) { + console.error('Ошибка генерации ссылки:', e); + return ''; + } +}; + +/** + * Пытается восстановить состояние из URL + */ +export const parseShareUrl = (): { config: AppConfig, splits: LayoutSplits } | null => { + try { + const params = new URLSearchParams(window.location.search); + const shareParam = params.get('share'); + + if (!shareParam) return null; + + // Декодируем обратно + const jsonString = decodeURIComponent(atob(shareParam)); + const data: ShareData = JSON.parse(jsonString); + + // Простая валидация, что данные похожи на правду + if (data.c && data.s && Array.isArray(data.s.x)) { + return { config: data.c, splits: data.s }; + } + return null; + } catch (e) { + console.error('Ошибка чтения ссылки:', e); + return null; + } +}; \ No newline at end of file