diff --git a/package.json b/package.json
index 6290c6b..8709851 100644
--- a/package.json
+++ b/package.json
@@ -13,23 +13,23 @@
"start": "vite preview --port 3000 --host"
},
"dependencies": {
+ "@react-three/drei": "^10.7.7",
+ "@react-three/fiber": "^9.4.2",
+ "jszip": "3.10.1",
+ "lucide-react": "^0.562.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"three": "^0.182.0",
"three-stdlib": "^2.36.1",
- "lucide-react": "^0.562.0",
- "@react-three/drei": "^10.7.7",
- "@react-three/fiber": "^9.4.2",
- "jszip": "3.10.1",
"uuid": "^9.0.1"
},
"devDependencies": {
+ "@types/node": "^22.14.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
- "@types/node": "^22.14.0",
"@types/uuid": "^9.0.8",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
-}
\ No newline at end of file
+}
diff --git a/src/components/ConfigStep.tsx b/src/components/ConfigStep.tsx
index 37c856e..b71989e 100644
--- a/src/components/ConfigStep.tsx
+++ b/src/components/ConfigStep.tsx
@@ -1,153 +1,284 @@
-import React from 'react';
-import { AppConfig } from '../types';
-import { Ruler, Box, Layers, Minimize2, CircleDashed } from 'lucide-react';
+import React, { useEffect } from 'react';
+import { AppConfig, PerforationPattern } from '../types';
+import { Settings2, Box, Ruler, LayoutGrid, Circle, Hexagon, Triangle, Scan } from 'lucide-react';
interface Props {
config: AppConfig;
- onChange: (newConfig: AppConfig) => void;
+ onChange: (config: AppConfig) => void;
}
-const InputGroup: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
-
-);
-
-const NumberInput = ({
- label,
- value,
- onChange,
- max
-}: {
- label: string;
- value: number;
- onChange: (val: number) => void;
- max?: number
-}) => (
-
- {label}
- onChange(parseFloat(e.target.value) || 0)}
- className="w-full bg-slate-800 border border-slate-700 rounded p-2 pl-8 text-white focus:ring-2 focus:ring-primary outline-none"
- />
- мм
-
-);
-
export const ConfigStep: React.FC = ({ config, onChange }) => {
- const updateDrawer = (key: keyof AppConfig['drawer'], val: number) => {
- onChange({ ...config, drawer: { ...config.drawer, [key]: val } });
+
+ // Инициализация дефолтных значений перфорации
+ useEffect(() => {
+ if (!config.perforation) {
+ onChange({
+ ...config,
+ perforation: { enabled: false, pattern: 'hexagon', diameter: 8, spacing: 2 }
+ });
+ }
+ }, []);
+
+ const perf = config.perforation || { enabled: false, pattern: 'hexagon', diameter: 8, spacing: 2 };
+
+ const updatePerf = (updates: Partial) => {
+ onChange({ ...config, perforation: { ...perf, ...updates } });
+ };
+
+ const updateDrawer = (key: keyof typeof config.drawer, value: number) => {
+ onChange({ ...config, drawer: { ...config.drawer, [key]: value } });
+ };
+
+ // --- RENDER PREVIEW ---
+ const renderPreview = () => {
+ if (!perf.enabled) return Перфорация выключена
;
+
+ const size = perf.diameter;
+ const gap = Math.max(2, perf.spacing);
+ const step = size + gap;
+ const W = 200;
+ const H = 120;
+
+ const elements = [];
+ // Приблизительный расчет для превью
+ const rows = Math.floor(H / (step * 0.866));
+ const cols = Math.floor(W / step);
+
+ const startX = (W - (cols * step)) / 2;
+ const startY = (H - (rows * step * 0.866)) / 2;
+
+ for(let j=0; j W - size || y > H - size) continue;
+
+ const color = "#3b82f6";
+
+ if (perf.pattern === 'circle') {
+ elements.push();
+ } else if (perf.pattern === 'hexagon') {
+ const r = size / 2;
+ const points = [];
+ for (let k = 0; k < 6; k++) {
+ const angle = (k * 60 + 30) * Math.PI / 180;
+ points.push(`${x + r * Math.cos(angle)},${y + r * Math.sin(angle)}`);
+ }
+ elements.push();
+ } else if (perf.pattern === 'triangle') {
+ const r = size / 2;
+ const angleOffset = isOdd ? 180 : 0;
+ const points = [];
+ for (let k = 0; k < 3; k++) {
+ const angle = (k * 120 - 90 + angleOffset) * Math.PI / 180;
+ points.push(`${x + r * Math.cos(angle)},${y + r * Math.sin(angle)}`);
+ }
+ elements.push();
+ }
+ }
+ }
+
+ return (
+
+ );
};
return (
-
-
- 1. Размеры
-
+
+
+ {/* ВЕРХНИЙ БЛОК: Размеры и Параметры печати */}
+
+
+ {/* 1. РАЗМЕРЫ */}
+
+
+ 1. Размеры
+
+
+
+
+
Внутренние размеры ящика
+
+
+
+
+ updateDrawer('width', parseFloat(e.target.value))} className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-4 pr-12 py-2.5 text-white focus:ring-2 focus:ring-blue-500/50 outline-none transition-all font-mono"/>
+ MM
+
+
+
+
+
+ updateDrawer('depth', parseFloat(e.target.value))} className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-4 pr-12 py-2.5 text-white focus:ring-2 focus:ring-blue-500/50 outline-none transition-all font-mono"/>
+ MM
+
+
+
+
+
+ updateDrawer('height', parseFloat(e.target.value))} className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-4 pr-12 py-2.5 text-white focus:ring-2 focus:ring-blue-500/50 outline-none transition-all font-mono"/>
+ MM
+
+
+
+
+
+
-
- {/* Drawer Dimensions */}
-
-
- Внутренние размеры ящика
-
-
- updateDrawer('width', v)} />
-
-
- updateDrawer('depth', v)} />
-
-
- updateDrawer('height', v)} />
-
-
+ {/* 2. ПАРАМЕТРЫ ПЕЧАТИ */}
+
+
+ Параметры печати
+
- {/* Settings */}
-
-
- Параметры печати
-
-
- {/* Wall Thickness */}
-
-
-
-
- {config.wallThickness.toFixed(1)} мм
-
-
-
- 0.4
- onChange({...config, wallThickness: parseFloat(e.target.value)})}
- className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-primary hover:accent-blue-400 transition-all"
- />
- 3.2
-
-
+
+
+ {/* Wall Thickness */}
+
+
+ Толщина стенок
+ {config.wallThickness} мм
+
+
onChange({...config, wallThickness: parseFloat(e.target.value)})}
+ className="w-full h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
+ />
+
+ 0.84.0
+
+
- {/* Corner Radius (NEW) */}
-
-
-
-
- {config.cornerRadius?.toFixed(0) || 0} мм
-
-
-
- 0
- onChange({...config, cornerRadius: parseFloat(e.target.value)})}
- className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-purple-500 hover:accent-purple-400 transition-all"
- />
- 20
-
-
+ {/* Corner Radius */}
+
+
+ Радиус скругления
+ {config.cornerRadius} мм
+
+
onChange({...config, cornerRadius: parseFloat(e.target.value)})}
+ className="w-full h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-purple-500"
+ />
+
+ 020
+
+
- {/* Printer Tolerance */}
-
-
-
-
- {config.printerTolerance.toFixed(1)} мм
-
-
-
- 0.0
- onChange({...config, printerTolerance: parseFloat(e.target.value)})}
- className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-accent hover:accent-amber-400 transition-all"
- />
- 2.0
-
-
+ {/* Tolerance */}
+
+
+ Зазор (Tolerance)
+ {config.printerTolerance} мм
+
+
onChange({...config, printerTolerance: parseFloat(e.target.value)})}
+ className="w-full h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-yellow-500"
+ />
+
+ 0.01.0
+
+
-
+
+
+
+ {/* НИЖНИЙ БЛОК: ПЕРФОРАЦИЯ */}
+
+
+
+ 2. Перфорация (узоры)
+
+
+
{perf.enabled ? 'Включено' : 'Выключено'}
+
+
+
+
+
+ {/* Настройки */}
+
+
+ {/* Тип узора */}
+
+
+
+
+
+
+
+
+
+ {/* Ползунки параметров */}
+
+ {/* Diameter */}
+
+
+ Диаметр отверстий
+ {perf.diameter} мм
+
+
updatePerf({ diameter: parseFloat(e.target.value) })}
+ className="w-full h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
+ />
+
+ 2 мм12 мм
+
+
+
+ {/* Spacing */}
+
+
+ Зазор (между отверстиями)
+ {perf.spacing} мм
+
+
updatePerf({ spacing: parseFloat(e.target.value) })}
+ className="w-full h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
+ />
+
+ 2 мм10 мм
+
+
+
+
+
+ {/* Preview */}
+
+
+
+ Масштаб условен
+
+
+
+ {renderPreview()}
+
+
+
+
+
+
);
};
\ No newline at end of file
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 */}
-
-
-
- Управление
-
-
- - • ЛКМ: Вращение
- - • ПКМ: Перемещение
- - • Скролл: Масштаб
-
-
-
-