diff --git a/src/App.tsx b/src/App.tsx index e8b8131..9c6279f 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, + drawer: { width: 100, depth: 100, height: 100 }, + wallThickness: 0.8, printerTolerance: 0.5, cornerRadius: 4, + perforation: { + enabled: true, + 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..bff0b81 100644 --- a/src/components/PreviewStep.tsx +++ b/src/components/PreviewStep.tsx @@ -1,9 +1,9 @@ import React, { Suspense, useEffect, useRef, useState, useMemo } from 'react'; import { Canvas } from '@react-three/fiber'; -import { OrbitControls, Center, Environment } from '@react-three/drei'; +import { OrbitControls, Center, Environment, Text } 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,435 @@ 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]); + + // 3. Вычисляем размеры и позиции для каждой под-ячейки + // 3. Вычисляем размеры и позиции для реальных отсеков (с учетом мерджинга) + const dimLabels = useMemo(() => { + const xOffsets = new Set([0, 1]); + const zOffsets = new Set([0, 1]); + + part.internalPartitions.forEach(p => { + if (p.axis === 'x') xOffsets.add(p.offset); + if (p.axis === 'y') zOffsets.add(p.offset); + }); + + const xSplits = Array.from(xOffsets).sort((a, b) => a - b); + const zSplits = Array.from(zOffsets).sort((a, b) => a - b); + + const numCols = xSplits.length - 1; + const numRows = zSplits.length - 1; + if (numCols === 0 || numRows === 0) return []; + + // Union-Find для объединения ячеек, не разделенных стеной + const parent = new Int32Array(numCols * numRows).map((_, i) => i); + const find = (i: number): number => { + if (parent[i] === i) return i; + parent[i] = find(parent[i]); + return parent[i]; + } + const union = (i: number, j: number) => { + const rootI = find(i); + const rootJ = find(j); + if (rootI !== rootJ) parent[rootJ] = rootI; + } + const getIdx = (c: number, r: number) => r * numCols + c; + + // Вертикальные границы (X) + for (let i = 0; i < numCols - 1; i++) { + const boundaryX = xSplits[i + 1]; + for (let j = 0; j < numRows; j++) { + const zMid = (zSplits[j] + zSplits[j + 1]) / 2; + // Проверяем наличие перегородки axis='x' + const isBlocked = part.internalPartitions.some(p => + p.axis === 'x' && + Math.abs(p.offset - boundaryX) < 0.001 && + (p.min ?? 0) <= zMid && (p.max ?? 1) >= zMid + ); + if (!isBlocked) union(getIdx(i, j), getIdx(i + 1, j)); + } + } + + // Горизонтальные границы (Z/Y) + for (let j = 0; j < numRows - 1; j++) { + const boundaryZ = zSplits[j + 1]; + for (let i = 0; i < numCols; i++) { + const xMid = (xSplits[i] + xSplits[i + 1]) / 2; + // Проверяем наличие перегородки axis='y' + const isBlocked = part.internalPartitions.some(p => + p.axis === 'y' && + Math.abs(p.offset - boundaryZ) < 0.001 && + (p.min ?? 0) <= xMid && (p.max ?? 1) >= xMid + ); + if (!isBlocked) union(getIdx(i, j), getIdx(i, j + 1)); + } + } + + // Агрегируем регионы + const regions: Record = {}; + for (let j = 0; j < numRows; j++) { + for (let i = 0; i < numCols; i++) { + const root = find(getIdx(i, j)); + if (!regions[root]) regions[root] = { minC: i, maxC: i, minR: j, maxR: j }; + else { + const r = regions[root]; + r.minC = Math.min(r.minC, i); + r.maxC = Math.max(r.maxC, i); + r.minR = Math.min(r.minR, j); + r.maxR = Math.max(r.maxR, j); + } + } + } + + return Object.values(regions).map((r, idx) => { + const fXStart = xSplits[r.minC]; + const fXEnd = xSplits[r.maxC + 1]; + const fZStart = zSplits[r.minR]; + const fZEnd = zSplits[r.maxR + 1]; + + const fracW = fXEnd - fXStart; + const fracD = fZEnd - fZStart; + + const dimX = Math.max(0, fracW * (part.width - thickness) - thickness); + const dimZ = Math.max(0, fracD * (part.depth - thickness) - thickness); + + const cx = -part.width / 2 + (fXStart + fXEnd) / 2 * part.width; + const cz = -part.depth / 2 + (fZStart + fZEnd) / 2 * part.depth; + + return { + key: `region-${idx}`, + pos: [cx, thickness + 0.2, cz] as [number, number, number], + text: `${dimX.toFixed(0)} x ${dimZ.toFixed(0)}` + }; + }); + }, [part, thickness]); + + return ( + { e.stopPropagation(); onClick(); }} + > + {/* Сама модель */} + + + + + {/* Текстовые метки размеров внутри каждой ячейки */} + {dimLabels.map(label => ( + + {label.text} + + ))} + + {/* Белая подсветка при выборе */} + {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..35a4fff 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,545 @@ const createConcaveFilletShape = (radius: number): THREE.Shape => { return shape; }; -export const createBinGeometry = ( - width: number, depth: number, height: number, thickness: number, radius: number = 0, partitions: Partition[] = [] +// New Helper: Create Perforated Plate (Vertical Wall) +const createPerforatedPlate = ( + width: number, + height: number, + thickness: number, + perf: PerforationConfig, + marginLeft: number = 0, + marginRight: number = 0, + exclusions: { start: number, end: number, yMax?: number }[] = [] ): THREE.BufferGeometry => { - const geometries: 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); - // ДНО И ВНЕШНИЕ СТЕНКИ - const floorShape = createRoundedRectShape(width, depth, radius); - const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false }); - floorGeo.rotateX(-Math.PI / 2); - geometries.push(floorGeo); + // Hole Generation + if (perf && perf.enabled && width > perf.size && height > perf.size) { + const { shape: shapeType, size, gap } = perf; + const step = size + gap; + // Margins: ensure holes don't cut into the solid edge zones + const startX = Math.max(gap, marginLeft + gap); + const startY = gap; + const endX = width - Math.max(gap, marginRight + gap); + const endY = height - gap; - 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); - } + // 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; - 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); + for (let x = startX + size / 2 + xOffset; x < endX; x += step) { + const hole = new THREE.Path(); + const r = size / 2; - // ВНУТРЕННИЕ ПЕРЕГОРОДКИ (СТРОГО ПО ДАННЫМ, БЕЗ SOLVER) - partitions.forEach(p => { - // Берем данные напрямую. Если в 2D нарисовано от 0.2 до 0.8, тут будет 0.2 до 0.8. - const pMin = p.min ?? 0; - const pMax = p.max ?? 1; + // Boundary check + if (x - r < marginLeft || x + r > width - marginRight || y - r < 0 || y + r > height) continue; - if (pMax - pMin < 0.01) return; + // Exclusion Zone Check + // Zone active if hole X is within [start, end] AND hole Y is below yMax (if specified). + // If y > yMax, the exclusion doesn't apply (it's above the intersecting wall). + // Note: y is measured from bottom (0) to top (height). + // yMax is the height of the intersecting partition. - const lengthRatio = pMax - pMin; - const midRatio = pMin + (lengthRatio / 2); + const inExclusion = exclusions.some(zone => { + if (x + r <= zone.start || x - r >= zone.end) return false; // X-axis check + if (zone.yMax !== undefined && y - r > zone.yMax) return false; // Y-axis check (hole above wall) + return true; + }); - 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); - } + if (inExclusion) continue; - 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); + 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++; + } + } - // СКРУГЛЕНИЯ (Fillets) - if (p.rounded && radius > 1) { - const filletR = Math.min(radius, 5); - const filletShape = createConcaveFilletShape(filletR); + 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; +}; - // Функция проверки высоты соседа (простая проверка на пересечение) - 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; - }; +// 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(); - const hStart = Math.min(p.height, getNeighborHeight(pMin)); - const hEnd = Math.min(p.height, getNeighborHeight(pMax)); + // Create a Ring Segment (Hollow Corner) as a single loop. + // Center at (0,0). + const innerRadius = Math.max(0.01, radius - thickness); // Ensure slightly > 0 to maintain shape integrity - 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); - }; + // 1. Start at Outer Start + shape.moveTo(radius, 0); - const t = thickness / 2; + // 2. Outer Arc (CCW) -> To (0, radius) + shape.absarc(0, 0, radius, 0, Math.PI / 2, false); - if (p.axis === 'x') { - const topY = (-innerDepth / 2) + (innerDepth * pMin); - const botY = (-innerDepth / 2) + (innerDepth * pMax); + // 3. Line to Inner End (0, innerRadius) + shape.lineTo(0, innerRadius); - 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); + // 4. Inner Arc (CW) -> To (innerRadius, 0) + shape.absarc(0, 0, innerRadius, Math.PI / 2, 0, true); - 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); - } - } - }); + // 5. Close loop + shape.lineTo(radius, 0); - const merged = mergeBufferGeometries(geometries); - if (merged) merged.computeVertexNormals(); - return merged || new THREE.BoxGeometry(1, 1, 1); + // Extrude + // curveSegments 32 for smoothness + const geo = new THREE.ExtrudeGeometry(shape, { depth: height, bevelEnabled: false, curveSegments: 32 }); + return geo; +}; + +export const createBinGeometry = ( + width: number, + depth: number, + height: number, + thickness: number, + radius: number = 0, + partitions: Partition[] = [], + perforation?: PerforationConfig +): THREE.BufferGeometry => { + const geometries: THREE.BufferGeometry[] = []; + + // ДНО (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); + + // 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); + + if (innerWidth > 0.1 && innerDepth > 0.1) { + const innerHole = createRoundedRectShape(innerWidth, innerDepth, innerRadius); + outerShape.holes.push(innerHole); + } + + 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); + + const straightW = width - 2 * effRadius; + const straightD = depth - 2 * effRadius; + + // 1. Corners (4 pcs) + if (effRadius > 0) { + const cornerGeoBase = createCornerProfile(effRadius, thickness, wallHeight); + + // 1. Stand Up: Extrusion Z -> Y. Shape moves to X(+)/Z(+). + cornerGeoBase.rotateX(-Math.PI / 2); + + // Base Corner Shape (after rotateX) is Q4 (+X, -Z). + // Rotation Logic: -90 degrees per Quadrant (Standard Three.js Y-Rot). + // Q4 (0) -> Q1 (-90) -> Q2 (-180/180) -> Q3 (-270/+90). + + const positions = [ + // Back Right (+X, +Z). Q1. + // Rot -90 (-PI/2). + { x: width / 2 - effRadius, z: depth / 2 - effRadius, rot: -Math.PI / 2 }, + + // Back Left (-X, +Z). Q2. + // Rot 180 (PI). + { x: -(width / 2 - effRadius), z: depth / 2 - effRadius, rot: Math.PI }, + + // Front Left (-X, -Z). Q3. + // Rot 90 (PI/2). (Equivalent to -270). + { x: -(width / 2 - effRadius), z: -(depth / 2 - effRadius), rot: Math.PI / 2 }, + + // Front Right (+X, -Z). Q4. + // Rot 0. + { x: width / 2 - effRadius, z: -(depth / 2 - effRadius), rot: 0 } + ]; + + positions.forEach(pos => { + const corner = cornerGeoBase.clone(); + corner.rotateY(pos.rot); + corner.translate(pos.x, 0, pos.z); // Start at Y=0 + corner.translate(0, thickness, 0); // Move on top of floor + geometries.push(corner); + }); + } + + // 2. Straight Walls (4 pcs) - Centered on edges + + // REWRITE EXCLUSION COLLECTION LOGIC TO BE WALL-SPECIFIC + const exclusionsBack: { start: number, end: number, yMax: number }[] = []; + const exclusionsFront: { start: number, end: number, yMax: number }[] = []; + const exclusionsLeft: { start: number, end: number, yMax: number }[] = []; + const exclusionsRight: { start: number, end: number, yMax: number }[] = []; + + // Inner Dimensions + const effectiveInnerW = width - 2 * thickness; + const effectiveInnerD = depth - 2 * thickness; + + partitions.forEach(p => { + const hEff = Math.max(0.1, p.height - thickness); // Exclude only up to partition height + // Ensure solid strip around partition by adding padding to exclusion zone + const padding = (perforation?.gap ?? 2) + 1; + + if (p.axis === 'x') { + // Runs Depth-wise (Y axis in Layout). + // Intersects Front and Back walls. + + const partGlobalX = (-effectiveInnerW / 2) + (effectiveInnerW * p.offset); + const sW = width - 2 * effRadius; + const localXOnWall = partGlobalX + sW / 2; + + // Check intersection with active straight wall area + padding + if (localXOnWall + thickness / 2 + padding > 0 && localXOnWall - thickness / 2 - padding < sW) { + const zone = { + start: localXOnWall - thickness / 2 - padding, + end: localXOnWall + thickness / 2 + padding, + yMax: hEff + }; + + if ((p.max ?? 1) > 0.99) exclusionsFront.push(zone); // Near + if ((p.min ?? 0) < 0.01) exclusionsBack.push(zone); // Far + } + + } else { // p.axis === 'y' + // Runs Width-wise (X axis in Layout). + // Intersects Left and Right walls. + + const partGlobalZ = (-effectiveInnerD / 2) + (effectiveInnerD * p.offset); + const sD = depth - 2 * effRadius; + const localXOnWall = partGlobalZ + sD / 2; + + if (localXOnWall + thickness / 2 + padding > 0 && localXOnWall - thickness / 2 - padding < sD) { + const zone = { + start: localXOnWall - thickness / 2 - padding, + end: localXOnWall + thickness / 2 + padding, + yMax: hEff + }; + + if ((p.min ?? 0) < 0.01) { + exclusionsLeft.push(zone); + } + if ((p.max ?? 1) > 0.99) { + exclusionsRight.push(zone); + } + } + } + }); + + // Front/Back + if (straightW > 0.1) { + // Wall 1 (at +Z). This is "Front" (Near). Contacts p.max. + // Uses exclusionsFront. + const wGeoFront = createPerforatedPlate(straightW, wallHeight, thickness, perforation, 0, 0, exclusionsFront); + wGeoFront.translate(-straightW / 2, 0, 0); + const w1 = wGeoFront.clone(); + w1.translate(0, thickness, depth / 2 - thickness); + geometries.push(w1); + + // Wall 2 (at -Z). This is "Back" (Far). Contacts p.min. + // Uses exclusionsBack. + // Needs checking mapping (Rot 180). + // p.offset increases X. Wall rotated 180 means X is inverted. + // So we map exclusionsBack. + const exclusionsBackMapped = exclusionsBack.map(e => ({ + start: straightW - e.end, + end: straightW - e.start, + yMax: e.yMax + })); + + const wGeoBack = createPerforatedPlate(straightW, wallHeight, thickness, perforation, 0, 0, exclusionsBackMapped); + wGeoBack.translate(-straightW / 2, 0, 0); + const w2 = wGeoBack.clone(); + w2.rotateY(Math.PI); + w2.translate(0, thickness, -(depth / 2 - thickness)); + geometries.push(w2); + } + + // Left/Right + if (straightD > 0.1) { + // Wall 3 (Right? +X). + // Plate is 0..straightD in X, 0..wallHeight in Y. Thickness along Z. + // We want it to be at X = width/2. + // It needs to be rotated -PI/2 around Y to align its X-axis with World Z-axis. + // After rotateY(-PI/2): Plate X (0..straightD) becomes World Z (0..straightD). + // Plate Z (thickness) becomes World -X (towards Left). + // So if we place it at X=width/2, it extrudes to width/2 - thickness. Correct for Right Wall. + const wGeoRight = createPerforatedPlate(straightD, wallHeight, thickness, perforation, 0, 0, exclusionsRight); + wGeoRight.translate(-straightD / 2, 0, 0); // Center X of plate + const w3 = wGeoRight.clone(); + w3.rotateY(-Math.PI / 2); // Rotate to align with Z-axis + w3.translate(width / 2, thickness, 0); // Position at X=width/2 + geometries.push(w3); + + // Wall 4 (Left? -X). + // This wall is also rotated PI/2. + // Plate Z (thickness) becomes World X (towards Right). + // We want it at X=-width/2. + // It will extrude to -width/2 + thickness. Correct for Left Wall. + const exclusionsLeftMapped = exclusionsLeft.map(e => ({ + start: straightD - e.end, + end: straightD - e.start, + yMax: e.yMax + })); + + const wGeoLeft = createPerforatedPlate(straightD, wallHeight, thickness, perforation, 0, 0, exclusionsLeftMapped); + wGeoLeft.translate(-straightD / 2, 0, 0); // Center X of plate + const w4 = wGeoLeft.clone(); + w4.rotateY(Math.PI / 2); + w4.translate(-width / 2, thickness, 0); // Position at X=-width/2 + geometries.push(w4); + } + } + + + // ВНУТРЕННИЕ ПЕРЕГОРОДКИ + 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); + + // FIX: Subtract thickness because partitions sit ON TOP of the floor + const effectiveHeight = Math.max(0.1, p.height - thickness); + + // --- 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 + + // Calculate Exclusions for Internal Partition + const partExclusions: { start: number, end: number, yMax?: number }[] = []; + // This partition is 'p' (Axis X, runs along Depth). + // Intersected by partitions 'n' (Axis Y, runs along Width). + // p global Y range: pY - pLen/2 to pY + pLen/2. + // n global Y pos: (-innerDepth/2) + (innerDepth * n.offset). + + partitions.forEach(n => { + if (n.axis !== 'y') return; // Only orthogonal partitions intersect + // Check if n intersects p + // Intersection Y location: + const nY = (-innerDepth / 2) + (innerDepth * n.offset); + + // Does nY (which is global Y of the intersecting partition) match p's position? + // p is Axis X, runs along DEPTH (Global Y). + // p is centered at pX (Width) and covers pLen in Depth (Y). + // Wait. p (Axis X) runs along Y? + // p.axis='x' -> "Runs along Y (Depth)". Correct. + // So p covers Y range [pY - pLen/2, pY + pLen/2]. + // n is Axis Y, runs along X (Width). + // n is centered at nY (Depth). + // So intersection happens if nY is within p's Y range. + + const pStartGlobal = pY - pLen / 2; + const pEndGlobal = pY + pLen / 2; + + // And check if n covers p's X location. + // n runs along X from nMin to nMax. + const nXStart = (-innerWidth / 2) + (innerWidth * (n.min ?? 0)); + const nXEnd = (-innerWidth / 2) + (innerWidth * (n.max ?? 1)); + const pXLoc = pX; + + if (nY >= pStartGlobal && nY <= pEndGlobal && pXLoc >= nXStart && pXLoc <= nXEnd) { + // Intersection confirmed. + // Calculate local coord on p's plate. + // p runs along Y. Plate local X maps to Global Y. + // local = global - pY + pLen/2. + const nHeight = Math.max(0.1, n.height - thickness); + const localX = nY - pY + pLen / 2; + partExclusions.push({ start: localX - thickness / 2, end: localX + thickness / 2, yMax: nHeight }); + } + }); + + // Add margins (solid ends) + const margin = thickness; + const plate = createPerforatedPlate(pLen, effectiveHeight, thickness, perforation!, margin, margin, partExclusions); + 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); + + // Calculate Exclusions + const partExclusions: { start: number, end: number, yMax?: number }[] = []; + // This partition is 'p' (Axis Y, runs along Width). + // Intersected by partitions 'n' (Axis X, runs along Depth). + + partitions.forEach(n => { + if (n.axis !== 'x') return; + const nX = (-innerWidth / 2) + (innerWidth * n.offset); + + const pStartGlobal = pX - pLen / 2; // X range of p + const pEndGlobal = pX + pLen / 2; + + const nYStart = (-innerDepth / 2) + (innerDepth * (n.min ?? 0)); + const nYEnd = (-innerDepth / 2) + (innerDepth * (n.max ?? 1)); + const pYLoc = pY; + + if (nX >= pStartGlobal && nX <= pEndGlobal && pYLoc >= nYStart && pYLoc <= nYEnd) { + // Intersection confirmed + // local = global - pX + pLen/2 + const nHeight = Math.max(0.1, n.height - thickness); // INTERSECTING PARTITION HEIGHT + const localX = nX - pX + pLen / 2; + partExclusions.push({ start: localX - thickness / 2, end: localX + thickness / 2, yMax: nHeight }); + } + }); + + const margin = thickness; + const plate = createPerforatedPlate(pLen, effectiveHeight, thickness, perforation!, margin, margin, partExclusions); + 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: effectiveHeight, 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) => { + const hEff = Math.max(0.1, h - thickness); + if (hEff <= 1) return; + const geo = new THREE.ExtrudeGeometry(filletShape, { depth: hEff, 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 +633,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 {