diff --git a/src/App.tsx b/src/App.tsx index e8b8131..7070898 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,24 @@ import React, { useState, useMemo, useEffect } from 'react'; import { AppConfig, LayoutSplits, GeneratedPart } from './types'; -import { calculateParts } from './services/geometryGenerator'; +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 { parseShareUrl } from './utils/share'; import { ChevronRight, ChevronLeft, Box, AlertTriangle } from 'lucide-react'; // ВАЖНО: ErrorBoundary должен быть ЗДЕСЬ, снаружи компонента App -class ErrorBoundary extends React.Component<{children: React.ReactNode}, {hasError: boolean}> { +class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean }> { state = { hasError: false }; static getDerivedStateFromError() { return { hasError: true }; } render() { if (this.state.hasError) return (
- -

Ошибка отрисовки интерфейса.

- + +

Ошибка отрисовки интерфейса.

+
); return this.props.children; @@ -27,18 +27,24 @@ class ErrorBoundary extends React.Component<{children: React.ReactNode}, {hasErr const App = () => { const [step, setStep] = useState(1); - + const [config, setConfig] = useState({ drawer: { width: 300, depth: 400, height: 80 }, wallThickness: 1.2, printerTolerance: 0.5, cornerRadius: 4, + perforation: { + enabled: false, + shape: 'honeycomb', + size: 8, + gap: 2 + } }); const [splits, setSplits] = useState({ x: [], y: [], - partitions: {} + partitions: {} }); useEffect(() => { @@ -47,11 +53,11 @@ const App = () => { if (sharedData) { setConfig(sharedData.config); setSplits({ - x: Array.isArray(sharedData.splits.x) ? sharedData.splits.x : [], - y: Array.isArray(sharedData.splits.y) ? sharedData.splits.y : [], - partitions: sharedData.splits.partitions || {} + x: Array.isArray(sharedData.splits.x) ? sharedData.splits.x : [], + y: Array.isArray(sharedData.splits.y) ? sharedData.splits.y : [], + partitions: sharedData.splits.partitions || {} }); - setStep(3); + setStep(3); window.history.replaceState({}, '', window.location.pathname); } } catch (e) { @@ -72,41 +78,41 @@ const App = () => { {/* Header */}
-
-
-

PrintFit

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

-
-
- {[1, 2, 3].map((num) => ( -
- {num} - {num === 1 ? 'Настройки' : num === 2 ? 'Макет' : 'Экспорт'} -
- ))} -
+
+
+

PrintFit

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

+
+
+ {[1, 2, 3].map((num) => ( +
+ {num} + {num === 1 ? 'Настройки' : num === 2 ? 'Макет' : 'Экспорт'} +
+ ))} +
{/* Main Content */}
{step === 1 && ( -
- -
+
+ +
)} - + {step === 2 && ( -
- - - -
+
+ + + +
)} {step === 3 && ( -
- -
+
+ +
)}
@@ -116,9 +122,9 @@ const App = () => {
{step === 2 && Ячеек: {parts.length}}
{step < 3 ? ( - + ) : ( - + )} diff --git a/src/components/ConfigStep.tsx b/src/components/ConfigStep.tsx index 37c856e..b782c1e 100644 --- a/src/components/ConfigStep.tsx +++ b/src/components/ConfigStep.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { AppConfig } from '../types'; -import { Ruler, Box, Layers, Minimize2, CircleDashed } from 'lucide-react'; +import { AppConfig, PerforationShape } from '../types'; +import { Ruler, Box, Layers, Minimize2, CircleDashed, Grid, Circle, Hexagon, Triangle } from 'lucide-react'; interface Props { config: AppConfig; @@ -14,16 +14,16 @@ const InputGroup: React.FC<{ label: string; children: React.ReactNode }> = ({ la ); -const NumberInput = ({ - label, - value, - onChange, - max -}: { - label: string; - value: number; - onChange: (val: number) => void; - max?: number +const NumberInput = ({ + label, + value, + onChange, + max +}: { + label: string; + value: number; + onChange: (val: number) => void; + max?: number }) => (
{label} @@ -39,113 +39,280 @@ const NumberInput = ({
); +const PerforationPreview = ({ config }: { config: AppConfig['perforation'] }) => { + if (!config.enabled) return ( +
+ Перфорация отключена +
+ ); + + // Simple canvas-like SVG generation + const width = 200; + const height = 120; + const size = config.size * 2; // Scale up mostly for visibility + const gap = config.gap * 2; + const step = size + gap; + + const elements = []; + + if (config.shape === 'circle') { + for (let y = 0; y < height; y += step) { + for (let x = 0; x < width; x += step) { + elements.push(); + } + } + } else if (config.shape === 'honeycomb') { + const hStep = size * 0.866; // height of equilateral triangle + for (let y = 0; y < height; y += (size + gap) * 0.85) { + const row = Math.floor(y / ((size + gap) * 0.85)); + const xOffset = row % 2 === 0 ? 0 : (size + gap) / 2; + for (let x = xOffset - step; x < width; x += step) { + // Hexagon points + const r = size / 2; + const cx = x + step / 2; + const cy = y + step / 2; + // Points for flat-topped hexagon + const points = []; + for (let i = 0; i < 6; i++) { + const angle_deg = 60 * i + 30; + const angle_rad = Math.PI / 180 * angle_deg; + points.push(`${cx + r * Math.cos(angle_rad)},${cy + r * Math.sin(angle_rad)}`); + } + elements.push(); + } + } + } else if (config.shape === 'triangle') { + for (let y = 0; y < height; y += step * 0.866) { // Staggered rows + const row = Math.floor(y / (step * 0.866)); + const xOffset = row % 2 === 0 ? 0 : step / 2; + for (let x = -step + xOffset; x < width; x += step) { + const cx = x + step / 2; + const cy = y + step / 2; + const r = size / 2; + // Upright triangle + const points = [ + `${cx},${cy - r}`, + `${cx + r * 0.866},${cy + r * 0.5}`, + `${cx - r * 0.866},${cy + r * 0.5}` + ]; + elements.push(); + } + } + } + + return ( +
+ + {elements} + +
:: Масштаб условен
+
+ ); +} + + export const ConfigStep: React.FC = ({ config, onChange }) => { const updateDrawer = (key: keyof AppConfig['drawer'], val: number) => { onChange({ ...config, drawer: { ...config.drawer, [key]: val } }); }; + const updatePerforation = (key: keyof AppConfig['perforation'], val: any) => { + onChange({ ...config, perforation: { ...config.perforation, [key]: val } }) + } + return ( -
-

- 1. Размеры -

+
-
- {/* Drawer Dimensions */} -
-

- Внутренние размеры ящика -

- - updateDrawer('width', v)} /> - - - updateDrawer('depth', v)} /> - - - updateDrawer('height', v)} /> - -
+ {/* SECTION 1: Dimensions & Settings */} +
+

+ 1. Размеры +

- {/* Settings */} -
-

- Параметры печати -

- - {/* Wall Thickness */} -
-
+
+ {/* Drawer Dimensions */} +
+

+ Внутренние размеры ящика +

+ + updateDrawer('width', v)} /> + + + updateDrawer('depth', v)} /> + + + updateDrawer('height', v)} /> + +
+ + {/* Settings */} +
+

+ Параметры печати +

+ + {/* Wall Thickness */} +
+
{config.wallThickness.toFixed(1)} мм -
-
- 0.4 - +
+ 0.4 + onChange({...config, wallThickness: parseFloat(e.target.value)})} + value={config.wallThickness} + onChange={(e) => 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 -
-
+ /> + 3.2 +
+
- {/* Corner Radius (NEW) */} -
-
+ {/* Corner Radius */} +
+
{config.cornerRadius?.toFixed(0) || 0} мм -
-
- 0 - +
+ 0 + onChange({...config, cornerRadius: parseFloat(e.target.value)})} + value={config.cornerRadius || 0} + onChange={(e) => 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 -
-
+ /> + 20 +
+
- {/* Printer Tolerance */} -
-
+ {/* Printer Tolerance */} +
+
{config.printerTolerance.toFixed(1)} мм -
-
- 0.0 - +
+ 0.0 + onChange({...config, printerTolerance: parseFloat(e.target.value)})} + value={config.printerTolerance} + onChange={(e) => 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 -
-
+ /> + 2.0 +
+
+
+
+
+ + {/* SECTION 2: Perforation */} +
+
+

+ 2. Перфорация (узоры) +

+ +
+ Включено + +
+
+ +
+
+ +
+ {[ + { id: 'circle', icon: Circle, label: 'Круг' }, + { id: 'honeycomb', icon: Hexagon, label: 'Соты' }, + { id: 'triangle', icon: Triangle, label: 'Треуг.' } + ].map((item) => ( + + ))} +
+ + {/* Resize Controls */} +
+
+
+ + {config.perforation.size} мм +
+
+ 2 мм + updatePerforation('size', parseFloat(e.target.value))} + className="flex-1 h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> + 25 мм +
+
+ +
+
+ + {config.perforation.gap} мм +
+
+ 1 мм + updatePerforation('gap', parseFloat(e.target.value))} + className="flex-1 h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> + 10 мм +
+
+
+
+ + {/* Preview */} +
+ + +
diff --git a/src/components/PreviewStep.tsx b/src/components/PreviewStep.tsx index 8334b76..3baa028 100644 --- a/src/components/PreviewStep.tsx +++ b/src/components/PreviewStep.tsx @@ -3,7 +3,7 @@ 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 { 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'; @@ -24,310 +24,315 @@ const DrawerFrame = ({ config }: { config: AppConfig }) => { // --- BinMesh (Ячейка) --- interface BinMeshProps { - part: GeneratedPart; - thickness: number; - cornerRadius: number; - isSelected: boolean; - onClick: () => void; + part: GeneratedPart; + thickness: number; + cornerRadius: number; + perforation: PerforationConfig; + isSelected: boolean; + onClick: () => void; } -const BinMesh: React.FC = ({ part, thickness, cornerRadius, isSelected, onClick }) => { - // 1. Создаем геометрию, учитывая ВНУТРЕННИЕ ПЕРЕГОРОДКИ - const geometry = useMemo(() => { - return createBinGeometry( - part.width, - part.depth, - part.height, - thickness, - cornerRadius, - part.internalPartitions // <--- ВАЖНО: передаем перегородки в генератор +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]); + + return ( + + {/* Сама модель */} + { e.stopPropagation(); onClick(); }}> + + + + {/* Белая подсветка при выборе */} + {isSelected && ( + + + + )} + ); - }, [part, thickness, cornerRadius]); - - // 2. Создаем контур выделения (EdgesGeometry) - // Threshold 20 градусов скрывает линии на плавных скруглениях - const edgesGeometry = useMemo(() => { - return new THREE.EdgesGeometry(geometry, 20); - }, [geometry]); - - return ( - - {/* Сама модель */} - { e.stopPropagation(); onClick(); }}> - - - - {/* Белая подсветка при выборе */} - {isSelected && ( - - - - )} - - ); }; // --- PreviewStep (Основной компонент) --- interface Props { - parts: GeneratedPart[]; - config: AppConfig; - splits: LayoutSplits; + 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 }>({}); + 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]); + // Скролл к выбранной детали в списке + 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 - ); - const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial()); - exportSTL(mesh, `${part.name.replace(/\s+/g, '_')}.stl`); - }; + // Скачивание одной детали + 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 - ); - 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 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); - } - }; + // Поделиться ссылкой + 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} -
- мм -
-
+ return ( +
+ {/* Верхняя панель: Размеры + Поделиться */} +
-
+ + -
+ > + {shareUrlCopied ? : } + {shareUrlCopied ? 'СКОПИРОВАНО!' : 'Поделиться'} + +
-
- {/* 3D Viewer */} -
-
-
- Управление -
-
    -
  • • ЛКМ: Вращение
  • -
  • • ПКМ: Перемещение
  • -
  • • Скролл: Масштаб
  • -
-
- - - - - - - -
- - - {parts.map(part => ( - setSelectedId(part.id)} - /> - ))} - -
- -
-
-
+
+ {/* 3D Viewer */} +
+
+
+ Управление +
+
    +
  • • ЛКМ: Вращение
  • +
  • • ПКМ: Перемещение
  • +
  • • Скролл: Масштаб
  • +
+
- {/* Sidebar List (Grid Layout) */} -
-
-

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

- -
+ + + + + + +
+ + + {parts.map(part => ( + setSelectedId(part.id)} + /> + ))} + +
+ +
+
+
-
-
- {parts.map(part => ( -
{ itemRefs.current[part.id] = el }} - className={` + {/* Sidebar List (Grid Layout) */} +
+
+

+ Детали ({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' - } + ${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)} - > - {/* Индикатор цвета */} -
+ onClick={() => setSelectedId(part.id)} + > + {/* Индикатор цвета */} +
- {/* Заголовок */} -
- - {part.name} - -
-
+ {/* Заголовок */} +
+ + {part.name} + +
+
- {/* Размеры */} -
- {part.width.toFixed(0)} × {part.depth.toFixed(0)} × {part.height.toFixed(0)} -
- - {/* Кнопка скачивания */} - -
- ))} -
-
+ {/* Размеры */} +
+ {part.width.toFixed(0)} × {part.depth.toFixed(0)} × {part.height.toFixed(0)} +
+ + {/* Кнопка скачивания */} + +
+ ))} +
+
+
+
-
-
- ); + ); }; \ No newline at end of file diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index 8633c7d..702cdcc 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -1,77 +1,77 @@ import * as THREE from 'three'; import { STLExporter, mergeBufferGeometries } from 'three-stdlib'; -import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; +import { AppConfig, LayoutSplits, GeneratedPart, Partition, PerforationConfig } from '../types'; export const calculateParts = (config: AppConfig, splits: LayoutSplits): GeneratedPart[] => { - const parts: GeneratedPart[] = []; - const safeX = Array.isArray(splits?.x) ? splits.x : []; - const safeY = Array.isArray(splits?.y) ? splits.y : []; - const safeParts = splits?.partitions || {}; + const parts: GeneratedPart[] = []; + const safeX = Array.isArray(splits?.x) ? splits.x : []; + const safeY = Array.isArray(splits?.y) ? splits.y : []; + const safeParts = splits?.partitions || {}; - const xPoints = [0, ...[...safeX].sort((a, b) => a - b), 1]; - const yPoints = [0, ...[...safeY].sort((a, b) => a - b), 1]; + const xPoints = [0, ...[...safeX].sort((a, b) => a - b), 1]; + const yPoints = [0, ...[...safeY].sort((a, b) => a - b), 1]; - let partCounter = 1; + let partCounter = 1; - for (let i = 0; i < xPoints.length - 1; i++) { - for (let j = 0; j < yPoints.length - 1; j++) { - const rawW = (xPoints[i + 1] - xPoints[i]) * config.drawer.width; - const rawD = (yPoints[j + 1] - yPoints[j]) * config.drawer.depth; - - if (rawW < 5 || rawD < 5) continue; + for (let i = 0; i < xPoints.length - 1; i++) { + for (let j = 0; j < yPoints.length - 1; j++) { + const rawW = (xPoints[i + 1] - xPoints[i]) * config.drawer.width; + const rawD = (yPoints[j + 1] - yPoints[j]) * config.drawer.depth; - const rawX = xPoints[i] * config.drawer.width; - const rawY = yPoints[j] * config.drawer.depth; - const internalPartitions = safeParts[`${i}-${j}`] || []; + if (rawW < 5 || rawD < 5) continue; - const realWidth = rawW - config.printerTolerance; - const realDepth = rawD - config.printerTolerance; - const realX = rawX + (config.printerTolerance / 2); - const realY = rawY + (config.printerTolerance / 2); + const rawX = xPoints[i] * config.drawer.width; + const rawY = yPoints[j] * config.drawer.depth; + const internalPartitions = safeParts[`${i}-${j}`] || []; - parts.push({ - id: `part-${partCounter}`, - name: `Ячейка ${i+1}-${j+1}`, - width: realWidth, - depth: realDepth, - height: config.drawer.height, - x: realX, - y: realY, - color: `hsl(${Math.random() * 360}, 70%, 50%)`, - internalPartitions: internalPartitions - }); - partCounter++; + const realWidth = rawW - config.printerTolerance; + const realDepth = rawD - config.printerTolerance; + const realX = rawX + (config.printerTolerance / 2); + const realY = rawY + (config.printerTolerance / 2); + + parts.push({ + id: `part-${partCounter}`, + name: `Ячейка ${i + 1}-${j + 1}`, + width: realWidth, + depth: realDepth, + height: config.drawer.height, + x: realX, + y: realY, + color: `hsl(${Math.random() * 360}, 70%, 50%)`, + internalPartitions: internalPartitions + }); + partCounter++; + } } - } - return parts; + return parts; }; // --- ГЕОМЕТРИЯ --- const createRoundedRectShape = (width: number, height: number, radius: number): THREE.Shape => { - const shape = new THREE.Shape(); - const x = -width / 2; - const y = -height / 2; - const r = Math.min(radius, width / 2 - 0.1, height / 2 - 0.1); + const shape = new THREE.Shape(); + const x = -width / 2; + const y = -height / 2; + const r = Math.min(radius, width / 2 - 0.1, height / 2 - 0.1); - if (r <= 0.1) { - shape.moveTo(x, y); - shape.lineTo(x + width, y); - shape.lineTo(x + width, y + height); - shape.lineTo(x, y + height); - shape.lineTo(x, y); - } else { - shape.moveTo(x, y + r); - shape.lineTo(x, y + height - r); - shape.quadraticCurveTo(x, y + height, x + r, y + height); - shape.lineTo(x + width - r, y + height); - shape.quadraticCurveTo(x + width, y + height, x + width, y + height - r); - shape.lineTo(x + width, y + r); - shape.quadraticCurveTo(x + width, y, x + width - r, y); - shape.lineTo(x + r, y); - shape.quadraticCurveTo(x, y, x, y + r); - } - return shape; + if (r <= 0.1) { + shape.moveTo(x, y); + shape.lineTo(x + width, y); + shape.lineTo(x + width, y + height); + shape.lineTo(x, y + height); + shape.lineTo(x, y); + } else { + shape.moveTo(x, y + r); + shape.lineTo(x, y + height - r); + shape.quadraticCurveTo(x, y + height, x + r, y + height); + shape.lineTo(x + width - r, y + height); + shape.quadraticCurveTo(x + width, y + height, x + width, y + height - r); + shape.lineTo(x + width, y + r); + shape.quadraticCurveTo(x + width, y, x + width - r, y); + shape.lineTo(x + r, y); + shape.quadraticCurveTo(x, y, x, y + r); + } + return shape; }; const createConcaveFilletShape = (radius: number): THREE.Shape => { @@ -83,122 +83,365 @@ const createConcaveFilletShape = (radius: number): THREE.Shape => { return shape; }; +// New Helper: Create Perforated Plate (Vertical Wall) +const createPerforatedPlate = (width: number, height: number, thickness: number, perf: PerforationConfig): THREE.BufferGeometry => { + const shape = new THREE.Shape(); + shape.moveTo(0, 0); + shape.lineTo(width, 0); + shape.lineTo(width, height); + shape.lineTo(0, height); + shape.lineTo(0, 0); + + // Hole Generation + if (perf && perf.enabled && width > perf.size && height > perf.size) { + const { shape: shapeType, size, gap } = perf; + const step = size + gap; + const startX = gap; // Margin + const startY = gap; // Margin + const endX = width - gap; // Margin + const endY = height - gap; + + // Rows + let row = 0; + for (let y = startY + size / 2; y < endY; y += (shapeType === 'triangle' || shapeType === 'honeycomb' ? step * 0.866 : step)) { + const isStaggered = (row % 2 !== 0); + const xOffset = (isStaggered && (shapeType === 'honeycomb' || shapeType === 'triangle')) ? step / 2 : 0; + + for (let x = startX + size / 2 + xOffset; x < endX; x += step) { + const hole = new THREE.Path(); + const r = size / 2; + + // Boundary check (approximate center check) + if (x - r < 0 || x + r > width || y - r < 0 || y + r > height) continue; + + if (shapeType === 'circle') { + hole.absarc(x, y, r, 0, Math.PI * 2, true); + } else if (shapeType === 'honeycomb') { + // Hexagon + for (let i = 0; i < 6; i++) { + const ang = (i * 60 + 30) * Math.PI / 180; + const px = x + r * Math.cos(ang); + const py = y + r * Math.sin(ang); + if (i === 0) hole.moveTo(px, py); + else hole.lineTo(px, py); + } + hole.closePath(); + } else if (shapeType === 'triangle') { + // Triangle + const ang1 = -90 * Math.PI / 180; + const ang2 = 30 * Math.PI / 180; + const ang3 = 150 * Math.PI / 180; + hole.moveTo(x + r * Math.cos(ang1), y + r * Math.sin(ang1)); + hole.lineTo(x + r * Math.cos(ang2), y + r * Math.sin(ang2)); + hole.lineTo(x + r * Math.cos(ang3), y + r * Math.sin(ang3)); + hole.closePath(); + } + shape.holes.push(hole); + } + row++; + } + } + + const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }); + // Extruded along Z. Wall is flat on XY. + // We want "thickness" to be Z depth. + return geo; +}; + +// New Helper: Create Corner Profile (Extruded Vertical) +const createCornerProfile = (radius: number, thickness: number, height: number): THREE.BufferGeometry => { + if (radius <= 0) return new THREE.BufferGeometry(); + const shape = new THREE.Shape(); + // External Arc (from X-axis to Y-axis) + shape.absarc(0, 0, radius, 0, Math.PI / 2, false); + // Line to inner + shape.lineTo(0, radius - thickness); // Assuming innerRadius = radius - thickness + // Inner Arc (backwards) + const innerRadius = Math.max(0.1, radius - thickness); + shape.absarc(0, 0, innerRadius, Math.PI / 2, 0, true); + // Close + shape.lineTo(radius, 0); + + // Extrude vertically (Height is Z for now, usually Extrude goes Z) + const geo = new THREE.ExtrudeGeometry(shape, { depth: height, bevelEnabled: false, curveSegments: 16 }); + // Rotate so height is along Y? No, Extrude defaults to Z depth. + // We want the Profile on XZ plane extruded up Y? + // Shape is on XY. Extrude is Z. + // If shape is on XY (top view of corner), Extrude Z creates Height. + // This matches standard logic if we rotate whole object later. + return geo; +}; + export const createBinGeometry = ( - width: number, depth: number, height: number, thickness: number, radius: number = 0, partitions: Partition[] = [] + width: number, + depth: number, + height: number, + thickness: number, + radius: number = 0, + partitions: Partition[] = [], + perforation?: PerforationConfig ): THREE.BufferGeometry => { - const geometries: THREE.BufferGeometry[] = []; + const geometries: THREE.BufferGeometry[] = []; - // ДНО И ВНЕШНИЕ СТЕНКИ - const floorShape = createRoundedRectShape(width, depth, radius); - const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false }); - floorGeo.rotateX(-Math.PI / 2); - geometries.push(floorGeo); + // ДНО (Floor) - Always same + const floorShape = createRoundedRectShape(width, depth, radius); + const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false }); + floorGeo.rotateX(-Math.PI / 2); // Lay flat + geometries.push(floorGeo); - const outerShape = createRoundedRectShape(width, depth, radius); - const innerRadius = Math.max(0.1, radius - thickness); - const innerWidth = width - (2 * thickness); - const innerDepth = depth - (2 * thickness); - - if (innerWidth > 0.1 && innerDepth > 0.1) { - const innerHole = createRoundedRectShape(innerWidth, innerDepth, innerRadius); - outerShape.holes.push(innerHole); - } + // WALLS + if (!perforation || !perforation.enabled) { + // --- ORIGINAL LOGIC (Optimized for Solid Walls) --- + const outerShape = createRoundedRectShape(width, depth, radius); + const innerRadius = Math.max(0.1, radius - thickness); + const innerWidth = width - (2 * thickness); + const innerDepth = depth - (2 * thickness); - const wallHeight = height - thickness; - const wallGeo = new THREE.ExtrudeGeometry(outerShape, { depth: wallHeight, bevelEnabled: false }); - wallGeo.rotateX(-Math.PI / 2); - wallGeo.translate(0, thickness, 0); - geometries.push(wallGeo); + if (innerWidth > 0.1 && innerDepth > 0.1) { + const innerHole = createRoundedRectShape(innerWidth, innerDepth, innerRadius); + outerShape.holes.push(innerHole); + } - // ВНУТРЕННИЕ ПЕРЕГОРОДКИ (СТРОГО ПО ДАННЫМ, БЕЗ SOLVER) - partitions.forEach(p => { - // Берем данные напрямую. Если в 2D нарисовано от 0.2 до 0.8, тут будет 0.2 до 0.8. - const pMin = p.min ?? 0; - const pMax = p.max ?? 1; + const wallHeight = height - thickness; + const wallGeo = new THREE.ExtrudeGeometry(outerShape, { depth: wallHeight, bevelEnabled: false }); + wallGeo.rotateX(-Math.PI / 2); + wallGeo.translate(0, thickness, 0); + geometries.push(wallGeo); + } else { + // --- PERFORATED LOGIC (Split Walls) --- + const wallHeight = height - thickness; + // Clamp radius to at least thickness for valid corners in this mode + const effRadius = Math.max(radius, thickness); - if (pMax - pMin < 0.01) return; + const straightW = width - 2 * effRadius; + const straightD = depth - 2 * effRadius; - const lengthRatio = pMax - pMin; - const midRatio = pMin + (lengthRatio / 2); + // 1. Corners (4 pcs) + if (effRadius > 0) { + const cornerGeoBase = createCornerProfile(effRadius, thickness, wallHeight); - let pWidth = 0, pDepth = 0, pX = 0, pY = 0; - - if (p.axis === 'x') { - pWidth = thickness; - pDepth = lengthRatio * innerDepth; - pX = (-innerWidth / 2) + (innerWidth * p.offset); - pY = (-innerDepth / 2) + (innerDepth * midRatio); - } else { - pWidth = lengthRatio * innerWidth; - pDepth = thickness; - pX = (-innerWidth / 2) + (innerWidth * midRatio); - pY = (-innerDepth / 2) + (innerDepth * p.offset); - } + // 1. Stand Up: Extrusion Z -> Y. Shape moves to X(+)/Z(+). + cornerGeoBase.rotateX(-Math.PI / 2); - const partShape = createRoundedRectShape(pWidth, pDepth, 0.1); - const partGeo = new THREE.ExtrudeGeometry(partShape, { depth: p.height, bevelEnabled: false }); - partGeo.rotateX(-Math.PI / 2); - partGeo.translate(pX, thickness, pY); - geometries.push(partGeo); + const positions = [ + { x: width / 2 - effRadius, z: depth / 2 - effRadius, rot: -Math.PI / 2 }, // Front Right (X+, Z+) -> Needs (X+, Z+). Base is (X+, Z-). Rot -90 -> (Z+, X+) + { x: -(width / 2 - effRadius), z: depth / 2 - effRadius, rot: Math.PI }, // Front Left (X-, Z+) -> Needs (X-, Z+). Rot 180 -> (X-, Z+) + { x: -(width / 2 - effRadius), z: -(depth / 2 - effRadius), rot: Math.PI / 2 }, // Back Left (X-, Z-) -> Needs (X-, Z-). Rot 90 -> (Z-, X-) which is X-, Z-? No wait. + // Rot 90 on (X+, Z-): X->Z, Z->-X. (X+, Z-) -> (-Z, -X) = (X-, Z-). Correct. + { x: width / 2 - effRadius, z: -(depth / 2 - effRadius), rot: 0 } // Back Right (X+, Z-) -> Matches Base. + ]; - // СКРУГЛЕНИЯ (Fillets) - if (p.rounded && radius > 1) { - const filletR = Math.min(radius, 5); - const filletShape = createConcaveFilletShape(filletR); + positions.forEach(pos => { + const c = cornerGeoBase.clone(); + c.rotateY(pos.rot); + c.translate(pos.x, thickness, pos.z); + geometries.push(c); + }); + } - // Функция проверки высоты соседа (простая проверка на пересечение) - const getNeighborHeight = (pos: number) => { - if (pos < 0.001 || pos > 0.999) return height; // Край ящика - - const neighbor = partitions.find(n => { - if (n.axis === p.axis) return false; // Перпендикуляр - const nMin = n.min ?? 0; - const nMax = n.max ?? 1; - // Совпадает ли позиция? - if (Math.abs(n.offset - pos) > 0.002) return false; - // Перекрывает ли? - return p.offset > nMin && p.offset < nMax; - }); - return neighbor ? neighbor.height : 0; - }; + // 2. Straight Walls (4 pcs) - Centered on edges + // Front/Back + if (straightW > 0.1) { + const wGeo = createPerforatedPlate(straightW, wallHeight, thickness, perforation); + // Plate: 0..W in X, 0..H in Y, 0..Th in Z. + // Center Horizontally: + wGeo.translate(-straightW / 2, 0, 0); - const hStart = Math.min(p.height, getNeighborHeight(pMin)); - const hEnd = Math.min(p.height, getNeighborHeight(pMax)); + // Wall 1 (Back / Top? +Z): + // Needs to be at Z = Depth/2. + // Plate thickness is along Z (positive). + // If we put it at Z = D/2 - thickness, it occupies [D/2 - th, D/2]. + // Inner face at D/2 - th. Outer face at D/2. Correct. + const w1 = wGeo.clone(); + w1.translate(0, thickness, depth / 2 - thickness); + geometries.push(w1); - const addFillet = (x: number, y: number, rotY: number, h: number) => { - if (h <= 1) return; - const geo = new THREE.ExtrudeGeometry(filletShape, { depth: h, bevelEnabled: false }); - geo.rotateX(-Math.PI / 2); - geo.rotateY(rotY); - geo.translate(x, thickness, y); - geometries.push(geo); - }; + // Wall 2 (Front / Bottom? -Z): + // Needs to be at Z = -Depth/2. + // Occupies [-D/2, -D/2 + th]. + // RotateY(180)? + // Plate (X, Z-thick). Rot180 -> (-X, -Z-thick). + // If original in [-W/2, W/2]x[0,th]. + // Rot180 -> [W/2, -W/2]x[0,-th]. + // Translate to Z = -(Depth/2 - thickness). -> [-th - (D/2 - th)] = -D/2. + // Wait. [-th - D/2 + th] = -D/2. Correct? + // Let's just translate manually without rotation for robustness, assuming pattern symmetric or acceptable. - const t = thickness / 2; + const w2 = wGeo.clone(); + // Rotate to face out? + w2.rotateY(Math.PI); + // After RotY(180): Z becomes negative. Range [-th, 0]. + // We want range [-D/2, -D/2 + th]. + // So translate Z by -D/2 + th. + w2.translate(0, thickness, -(depth / 2 - thickness)); + geometries.push(w2); + } - if (p.axis === 'x') { - const topY = (-innerDepth / 2) + (innerDepth * pMin); - const botY = (-innerDepth / 2) + (innerDepth * pMax); + // Left/Right + if (straightD > 0.1) { + const dGeo = createPerforatedPlate(straightD, wallHeight, thickness, perforation); + dGeo.translate(-straightD / 2, 0, 0); - addFillet(pX - t, topY, Math.PI, hStart); - addFillet(pX + t, topY, -Math.PI / 2, hStart); - addFillet(pX - t, botY, Math.PI / 2, hEnd); - addFillet(pX + t, botY, 0, hEnd); - } else { - const leftX = (-innerWidth / 2) + (innerWidth * pMin); - const rightX = (-innerWidth / 2) + (innerWidth * pMax); + // Wall 3 (Right? +X). + // RotateY(-90). X -> Z, Z -> -X. + // Plate Z[0, th] -> X[-th, 0]. + // We want X [W/2 - th, W/2]. + // So Translate X by W/2. + const w3 = dGeo.clone(); + w3.rotateY(-Math.PI / 2); + w3.translate(width / 2, thickness, 0); + geometries.push(w3); - addFillet(leftX, pY - t, 0, hStart); - addFillet(leftX, pY + t, -Math.PI / 2, hStart); - addFillet(rightX, pY - t, Math.PI / 2, hEnd); - addFillet(rightX, pY + t, Math.PI, hEnd); - } - } - }); + // Wall 4 (Left? -X). + // RotateY(90). X -> -Z, Z -> X. + // Plate Z[0, th] -> X[0, th]. + // We want X [-W/2, -W/2 + th]. + // Translate X by -W/2. + const w4 = dGeo.clone(); + w4.rotateY(Math.PI / 2); + w4.translate(-(width / 2), thickness, 0); + geometries.push(w4); + } + } - const merged = mergeBufferGeometries(geometries); - if (merged) merged.computeVertexNormals(); - return merged || new THREE.BoxGeometry(1, 1, 1); + + // ВНУТРЕННИЕ ПЕРЕГОРОДКИ + const innerWidth = width - (2 * thickness); + const innerDepth = depth - (2 * thickness); // Approximate usable space logic + + partitions.forEach(p => { + const pMin = p.min ?? 0; + const pMax = p.max ?? 1; + + if (pMax - pMin < 0.01) return; + + const lengthRatio = pMax - pMin; + const midRatio = pMin + (lengthRatio / 2); + + // --- PERFORATED LOGIC FOR PARTITIONS --- + // If enabled, use Plate. Else use Extrude Solid. + const usePerf = perforation && perforation.enabled; + let pX = 0, pY = 0; // Declare here for visibility in Fillets + + if (usePerf) { + // Calculate exact geometry + let pLen = 0; + + if (p.axis === 'x') { + // Axis X -> Divider runs along Y (Depth) + pLen = lengthRatio * innerDepth; + pX = (-innerWidth / 2) + (innerWidth * p.offset); + pY = (-innerDepth / 2) + (innerDepth * midRatio); // Center of partition + + // Create Plate (Length, Height) + const plate = createPerforatedPlate(pLen, p.height, thickness, perforation!); + plate.translate(-pLen / 2, 0, 0); // Center X + + // Rotate to align with Depth (along Z) + // Plate X -> Z + plate.rotateY(-Math.PI / 2); + + // Position + // Plate is now vertical Z-aligned. Thickness along X. + plate.translate(pX + thickness / 2, thickness, pY); + geometries.push(plate); + + } else { + // Axis Y -> Divider runs along X (Width) + pLen = lengthRatio * innerWidth; + pX = (-innerWidth / 2) + (innerWidth * midRatio); + pY = (-innerDepth / 2) + (innerDepth * p.offset); + + const plate = createPerforatedPlate(pLen, p.height, thickness, perforation!); + plate.translate(-pLen / 2, 0, 0); // Center X + + // Already aligned with X. Thickness along Z. + // Z range [0, th]. We want [-th/2, th/2] relative to pY. + // Translate Z by -th/2. + plate.translate(0, 0, -thickness / 2); + + // Move to position + plate.translate(pX, thickness, pY); + geometries.push(plate); + } + + } else { + // --- SOLID LOGIC --- + let pWidth = 0, pDepth = 0; + if (p.axis === 'x') { + pWidth = thickness; + pDepth = lengthRatio * innerDepth; + pX = (-innerWidth / 2) + (innerWidth * p.offset); + pY = (-innerDepth / 2) + (innerDepth * midRatio); + } else { + pWidth = lengthRatio * innerWidth; + pDepth = thickness; + pX = (-innerWidth / 2) + (innerWidth * midRatio); + pY = (-innerDepth / 2) + (innerDepth * p.offset); + } + + const partShape = createRoundedRectShape(pWidth, pDepth, 0.1); + const partGeo = new THREE.ExtrudeGeometry(partShape, { depth: p.height, bevelEnabled: false }); + partGeo.rotateX(-Math.PI / 2); + partGeo.translate(pX, thickness, pY); + geometries.push(partGeo); + } + + // Fillets Logic for partitions (Keep solid for strength/aesthetics) + if (p.rounded && radius > 1) { + const filletR = Math.min(radius, 5); + const filletShape = createConcaveFilletShape(filletR); + + // Helper to get neighbor height + const getNeighborHeight = (pos: number) => { + if (pos < 0.001 || pos > 0.999) return height; + const neighbor = partitions.find(n => { + if (n.axis === p.axis) return false; + const nMin = n.min ?? 0; + const nMax = n.max ?? 1; + if (Math.abs(n.offset - pos) > 0.002) return false; + return p.offset > nMin && p.offset < nMax; + }); + return neighbor ? neighbor.height : 0; + }; + + const hStart = Math.min(p.height, getNeighborHeight(pMin)); + const hEnd = Math.min(p.height, getNeighborHeight(pMax)); + + const addFillet = (x: number, y: number, rotY: number, h: number) => { + if (h <= 1) return; + const geo = new THREE.ExtrudeGeometry(filletShape, { depth: h, bevelEnabled: false }); + geo.rotateX(-Math.PI / 2); + geo.rotateY(rotY); + geo.translate(x, thickness, y); + geometries.push(geo); + }; + + const t = thickness / 2; + + if (p.axis === 'x') { + const topY = (-innerDepth / 2) + (innerDepth * pMin); + const botY = (-innerDepth / 2) + (innerDepth * pMax); + + addFillet(pX - t, topY, Math.PI, hStart); + addFillet(pX + t, topY, -Math.PI / 2, hStart); + addFillet(pX - t, botY, Math.PI / 2, hEnd); + addFillet(pX + t, botY, 0, hEnd); + } else { + const leftX = (-innerWidth / 2) + (innerWidth * pMin); + const rightX = (-innerWidth / 2) + (innerWidth * pMax); + + addFillet(leftX, pY - t, 0, hStart); + addFillet(leftX, pY + t, -Math.PI / 2, hStart); + addFillet(rightX, pY - t, Math.PI / 2, hEnd); + addFillet(rightX, pY + t, Math.PI, hEnd); + } + } + }); + + const merged = mergeBufferGeometries(geometries); + if (merged) merged.computeVertexNormals(); + return merged || new THREE.BoxGeometry(1, 1, 1); }; export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => { @@ -210,7 +453,7 @@ export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => { export const exportSTL = (mesh: THREE.Object3D, filename: string) => { const result = generateSTL(mesh); - const blob = new Blob([result], { type: 'application/octet-stream' }); + const blob = new Blob([result as any], { type: 'application/octet-stream' }); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = filename; diff --git a/src/types.ts b/src/types.ts index c2a7c17..6c8623f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,16 @@ export interface AppConfig { wallThickness: number; printerTolerance: number; cornerRadius: number; + perforation: PerforationConfig; +} + +export type PerforationShape = 'circle' | 'honeycomb' | 'triangle' | 'diamond'; + +export interface PerforationConfig { + enabled: boolean; + shape: PerforationShape; + size: number; + gap: number; } export interface Partition {