Add share

This commit is contained in:
Халимов Рустам
2025-12-27 19:04:48 +03:00
parent 50cdd3a945
commit 8010c172c7
3 changed files with 214 additions and 169 deletions

View File

@@ -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 (
<group position={[width / 2, height / 2, depth / 2]}>
<lineSegments>
@@ -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<BinMeshProps> = ({ 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 (
<group position={[part.x + part.width/2, 0, part.y + part.depth/2]}>
{/* Основной меш ячейки */}
<mesh
geometry={geometry}
onClick={(e) => { e.stopPropagation(); onClick(); }}
>
<meshStandardMaterial
color={isSelected ? '#f59e0b' : part.color}
roughness={0.5}
metalness={0.1}
/>
<mesh geometry={geometry} onClick={(e) => { e.stopPropagation(); onClick(); }}>
<meshStandardMaterial color={isSelected ? '#f59e0b' : part.color} roughness={0.5} metalness={0.1}/>
</mesh>
{/* Подсветка выделения (Bounding Box) */}
{isSelected && (
<lineSegments position={[0, part.height/2, 0]}>
<edgesGeometry args={[new THREE.BoxGeometry(part.width, part.height, part.depth)]} />
@@ -67,23 +49,23 @@ const BinMesh: React.FC<BinMeshProps> = ({ part, thickness, isSelected, onClick
);
};
// --- Основной компонент ---
interface Props {
parts: GeneratedPart[];
config: AppConfig;
splits: LayoutSplits; // <--- Добавили splits в пропсы
}
export const PreviewStep: React.FC<Props> = ({ parts, config }) => {
export const PreviewStep: React.FC<Props> = ({ parts, config, splits }) => {
const [selectedId, setSelectedId] = useState<string | null>(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<Props> = ({ 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 (
<div className="flex flex-col lg:flex-row h-full gap-6">
{/* 3D Viewer */}
<div className="flex-1 bg-slate-900 rounded-xl overflow-hidden shadow-2xl border border-slate-800 relative min-h-[400px]">
<div className="absolute top-4 right-4 z-10 bg-black/60 p-3 rounded-lg text-xs text-gray-300 backdrop-blur pointer-events-none border border-slate-700">
<div className="flex items-center gap-2 mb-1 text-primary font-bold">
<Info size={14} /> Управление
</div>
<ul className="space-y-1">
<li> ЛКМ: Вращение</li>
<li> ПКМ: Перемещение</li>
<li> Скролл: Масштаб</li>
<li className="text-accent mt-2 font-semibold"> Клик по детали для выбора</li>
</ul>
</div>
<Canvas
shadows
dpr={[1, 2]}
camera={{
position: [config.drawer.width * 1.5, config.drawer.height * 3, config.drawer.depth * 1.5],
fov: 45,
near: 1,
far: 20000
}}
>
<Suspense fallback={null}>
<color attach="background" args={['#0f172a']} />
<ambientLight intensity={0.7} />
<directionalLight position={[100, 200, 50]} intensity={1.2} />
<directionalLight position={[-100, 100, -50]} intensity={0.5} />
<Environment preset="city" />
// Логика кнопки "Поделиться"
const handleShare = () => {
const url = generateShareUrl(config, splits);
navigator.clipboard.writeText(url).then(() => {
setShareUrlCopied(true);
setTimeout(() => setShareUrlCopied(false), 2000);
});
};
<Center>
<group>
<DrawerFrame config={config} />
{parts.map(part => (
<BinMesh
key={part.id}
part={part}
thickness={config.wallThickness}
isSelected={selectedId === part.id}
onClick={() => setSelectedId(part.id)}
/>
))}
</group>
</Center>
<OrbitControls makeDefault minDistance={10} maxDistance={10000} />
</Suspense>
</Canvas>
return (
<div className="flex flex-col h-full">
{/* 1. ВЕРХНЯЯ ПАНЕЛЬ С РАЗМЕРАМИ И КНОПКОЙ SHARE */}
<div className="flex flex-col md:flex-row justify-between items-center bg-slate-800/80 p-4 rounded-xl border border-slate-700 mb-4 gap-4 backdrop-blur-sm shadow-lg">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2 text-gray-300">
<Ruler className="text-primary" size={20} />
<span className="font-medium text-sm uppercase tracking-wide opacity-70">Размеры ящика:</span>
</div>
<div className="flex gap-4 font-mono text-lg font-bold text-white">
<div className="flex items-center gap-1">
<span className="text-slate-500 text-xs">W:</span> {config.drawer.width}
</div>
<div className="flex items-center gap-1">
<span className="text-slate-500 text-xs">D:</span> {config.drawer.depth}
</div>
<div className="flex items-center gap-1">
<span className="text-slate-500 text-xs">H:</span> {config.drawer.height}
</div>
<span className="text-xs text-slate-500 self-end mb-1">мм</span>
</div>
</div>
<button
onClick={handleShare}
className={`
flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-all border
${shareUrlCopied
? 'bg-green-600/20 border-green-500/50 text-green-400'
: 'bg-slate-700 hover:bg-slate-600 border-slate-600 text-white'
}
`}
>
{shareUrlCopied ? <Check size={16} /> : <Share2 size={16} />}
{shareUrlCopied ? 'Ссылка скопирована!' : 'Поделиться проектом'}
</button>
</div>
{/* Sidebar List */}
<div className="w-full lg:w-96 bg-slate-900 p-6 rounded-xl border border-slate-800 flex flex-col h-full shadow-xl">
<div className="flex justify-between items-center mb-6 shrink-0">
<h2 className="text-xl font-bold flex items-center gap-2 text-primary">
<Package size={24} /> Детали ({parts.length})
</h2>
<button
onClick={handleDownloadAll}
disabled={isZipping || parts.length === 0}
className={`bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 rounded-md text-sm font-medium flex items-center gap-1 transition-all active:scale-95 disabled:opacity-50 disabled:scale-100 disabled:cursor-not-allowed`}
>
{isZipping ? (
<>
<Loader2 size={16} className="animate-spin" /> ZIP...
</>
) : (
<>
<Download size={16} /> Скачать все
</>
)}
</button>
<div className="flex flex-col lg:flex-row h-full gap-6 relative flex-1 min-h-0">
{/* 3D Viewer */}
<div className="flex-1 bg-slate-900 rounded-xl overflow-hidden shadow-2xl border border-slate-800 relative min-h-[400px]">
<div className="absolute top-4 right-4 z-10 bg-black/60 p-3 rounded-lg text-xs text-gray-300 backdrop-blur pointer-events-none border border-slate-700">
<div className="flex items-center gap-2 mb-1 text-primary font-bold">
<Info size={14} /> Управление
</div>
<ul className="space-y-1">
<li> ЛКМ: Вращение</li>
<li> ПКМ: Перемещение</li>
<li> Скролл: Масштаб</li>
</ul>
</div>
<Canvas shadows dpr={[1, 2]} camera={{ position: [config.drawer.width * 1.5, config.drawer.height * 3, config.drawer.depth * 1.5], fov: 45 }}>
<Suspense fallback={null}>
<color attach="background" args={['#0f172a']} />
<ambientLight intensity={0.7} />
<directionalLight position={[100, 200, 50]} intensity={1.2} />
<Environment preset="city" />
<Center>
<group>
<DrawerFrame config={config} />
{parts.map(part => (
<BinMesh
key={part.id} part={part} thickness={config.wallThickness}
isSelected={selectedId === part.id}
onClick={() => setSelectedId(part.id)}
/>
))}
</group>
</Center>
<OrbitControls makeDefault minDistance={10} maxDistance={10000} />
</Suspense>
</Canvas>
</div>
<div className="flex-1 overflow-y-auto pr-2 space-y-3 custom-scrollbar">
{parts.map(part => (
<div
key={part.id}
ref={(el) => { 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)}
>
<div className="flex justify-between items-start mb-2">
<span className="font-semibold text-gray-200 group-hover:text-white transition-colors">{part.name}</span>
<div
className="w-3 h-3 rounded-full border border-white/10"
style={{ backgroundColor: part.color }}
/>
</div>
<div className="grid grid-cols-3 gap-2 text-xs text-gray-400 mb-3">
<div className="bg-slate-900/50 p-1.5 rounded">
<span className="block text-gray-500 uppercase text-[9px] mb-0.5">Ширина</span>
{part.width.toFixed(1)}
</div>
<div className="bg-slate-900/50 p-1.5 rounded">
<span className="block text-gray-500 uppercase text-[9px] mb-0.5">Глубина</span>
{part.depth.toFixed(1)}
</div>
<div className="bg-slate-900/50 p-1.5 rounded">
<span className="block text-gray-500 uppercase text-[9px] mb-0.5">Высота</span>
{part.height.toFixed(1)}
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); handleDownload(part); }}
className="w-full py-2 bg-slate-700 hover:bg-primary hover:text-white text-gray-300 rounded text-xs flex items-center justify-center gap-2 transition-colors font-medium"
>
<Download size={14} /> Скачать STL
</button>
</div>
))}
{/* Sidebar List */}
<div className="w-full lg:w-96 bg-slate-900 p-6 rounded-xl border border-slate-800 flex flex-col h-full shadow-xl">
<div className="flex justify-between items-center mb-6 shrink-0">
<h2 className="text-xl font-bold flex items-center gap-2 text-primary">
<Package size={24} /> Детали ({parts.length})
</h2>
<button
onClick={handleDownloadAll}
disabled={isZipping || parts.length === 0}
className="bg-slate-700 hover:bg-slate-600 text-white px-3 py-1.5 rounded-md text-sm font-medium flex items-center gap-1 transition-all"
>
{isZipping ? <Loader2 size={16} className="animate-spin" /> : <Download size={16} />}
Архив
</button>
</div>
<div className="flex-1 overflow-y-auto pr-2 space-y-3 custom-scrollbar">
{parts.map(part => (
<div
key={part.id}
ref={(el) => { 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)}
>
<div className="flex justify-between items-center mb-2">
<span className="font-semibold text-gray-200">{part.name}</span>
<div className="w-3 h-3 rounded-full border border-white/10" style={{ backgroundColor: part.color }} />
</div>
<button
onClick={(e) => { e.stopPropagation(); handleDownload(part); }}
className="w-full py-2.5 bg-slate-700 hover:bg-primary hover:text-white text-gray-300 rounded text-sm flex items-center justify-center gap-2 transition-colors font-medium mt-2"
>
<Download size={16} /> Скачать STL
</button>
</div>
))}
</div>
</div>
</div>
</div>