Files
BoxGenerator/src/components/PreviewStep.tsx
Халимов Рустам 3263645c3b Fix
2025-12-27 20:02:20 +03:00

273 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, 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';
// --- DrawerFrame (Каркас) ---
const DrawerFrame = ({ config }: { config: AppConfig }) => {
const { width, depth, height } = config.drawer;
const offset = 0.5;
return (
<group position={[width / 2, height / 2, depth / 2]}>
<lineSegments>
<edgesGeometry args={[new THREE.BoxGeometry(width + offset, height + offset, depth + offset)]} />
<lineBasicMaterial color="#475569" />
</lineSegments>
</group>
)
}
// --- BinMesh (Ячейка) ---
interface BinMeshProps {
part: GeneratedPart;
thickness: number;
isSelected: boolean;
onClick: () => void;
}
const BinMesh: React.FC<BinMeshProps> = ({ part, thickness, isSelected, onClick }) => {
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>
{isSelected && (
<lineSegments position={[0, part.height/2, 0]}>
<edgesGeometry args={[new THREE.BoxGeometry(part.width, part.height, part.depth)]} />
<lineBasicMaterial color="white" linewidth={2} />
</lineSegments>
)}
</group>
);
};
// --- PreviewStep (Основной) ---
interface Props {
parts: GeneratedPart[];
config: AppConfig;
splits: LayoutSplits;
}
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' });
}
}, [selectedId]);
const handleDownload = (part: GeneratedPart) => {
const geometry = createBinGeometry(part.width, part.depth, part.height, config.wallThickness);
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);
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 (
<div className="flex flex-col h-full">
{/* Верхняя панель: Размеры + Поделиться */}
<div className="flex flex-col xl: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 flex-wrap items-center gap-6 justify-center md:justify-start">
<div className="hidden md:flex items-center gap-2 text-gray-300 mr-2">
<Ruler className="text-primary" size={20} />
<span className="font-medium text-sm uppercase tracking-wide opacity-70">Размеры ящика:</span>
</div>
{/* --- ОБНОВЛЕННЫЙ БЛОК РАЗМЕРОВ --- */}
<div className="flex flex-wrap gap-6 font-mono text-white items-baseline">
<div className="flex items-baseline gap-2">
<span className="text-slate-500 text-sm font-bold uppercase tracking-wider">Ширина:</span>
<span className="text-2xl font-bold text-white drop-shadow-sm">{config.drawer.width}</span>
</div>
<div className="flex items-baseline gap-2">
<span className="text-slate-500 text-sm font-bold uppercase tracking-wider">Глубина:</span>
<span className="text-2xl font-bold text-white drop-shadow-sm">{config.drawer.depth}</span>
</div>
<div className="flex items-baseline gap-2">
<span className="text-slate-500 text-sm font-bold uppercase tracking-wider">Высота:</span>
<span className="text-2xl font-bold text-white drop-shadow-sm">{config.drawer.height}</span>
</div>
<span className="text-sm text-slate-500 font-bold self-baseline">мм</span>
</div>
</div>
<button
onClick={handleShare}
className={`
flex items-center gap-2 px-6 py-3 rounded-lg text-sm font-bold transition-all border shadow-md active:scale-95 shrink-0
${shareUrlCopied
? 'bg-green-600 border-green-500 text-white shadow-green-900/20'
: 'bg-blue-600 hover:bg-blue-500 border-blue-500 text-white shadow-blue-900/20'
}
`}
>
{shareUrlCopied ? <Check size={18} /> : <Share2 size={18} />}
{shareUrlCopied ? 'СКОПИРОВАНО!' : 'Поделиться'}
</button>
</div>
<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,
near: 1,
far: 20000
}}
>
<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>
{/* 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>
);
};