From 531b5dc0b15c80fbbf29092d1b34ecadece2640a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Mon, 12 Jan 2026 17:32:41 +0300 Subject: [PATCH 1/5] Add perforation --- src/App.tsx | 86 ++--- src/components/ConfigStep.tsx | 343 +++++++++++++----- src/components/PreviewStep.tsx | 567 +++++++++++++++--------------- src/services/geometryGenerator.ts | 553 +++++++++++++++++++++-------- src/types.ts | 10 + 5 files changed, 995 insertions(+), 564 deletions(-) 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 { From aaff0d06df7bd153770f478ba5f5ac2198ce5cc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Mon, 12 Jan 2026 21:26:38 +0300 Subject: [PATCH 2/5] Fix height --- src/services/geometryGenerator.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index 702cdcc..a0a29af 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -318,6 +318,9 @@ export const createBinGeometry = ( 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; @@ -334,7 +337,7 @@ export const createBinGeometry = ( pY = (-innerDepth / 2) + (innerDepth * midRatio); // Center of partition // Create Plate (Length, Height) - const plate = createPerforatedPlate(pLen, p.height, thickness, perforation!); + const plate = createPerforatedPlate(pLen, effectiveHeight, thickness, perforation!); plate.translate(-pLen / 2, 0, 0); // Center X // Rotate to align with Depth (along Z) @@ -352,7 +355,7 @@ export const createBinGeometry = ( pX = (-innerWidth / 2) + (innerWidth * midRatio); pY = (-innerDepth / 2) + (innerDepth * p.offset); - const plate = createPerforatedPlate(pLen, p.height, thickness, perforation!); + const plate = createPerforatedPlate(pLen, effectiveHeight, thickness, perforation!); plate.translate(-pLen / 2, 0, 0); // Center X // Already aligned with X. Thickness along Z. @@ -381,7 +384,7 @@ export const createBinGeometry = ( } const partShape = createRoundedRectShape(pWidth, pDepth, 0.1); - const partGeo = new THREE.ExtrudeGeometry(partShape, { depth: p.height, bevelEnabled: false }); + const partGeo = new THREE.ExtrudeGeometry(partShape, { depth: effectiveHeight, bevelEnabled: false }); partGeo.rotateX(-Math.PI / 2); partGeo.translate(pX, thickness, pY); geometries.push(partGeo); @@ -409,8 +412,9 @@ export const createBinGeometry = ( 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 }); + 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); From 34543d07c548eea09223b57558253344ab18581c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Mon, 12 Jan 2026 23:21:07 +0300 Subject: [PATCH 3/5] Fix --- src/services/geometryGenerator.ts | 357 +++++++++++++++++++++++------- 1 file changed, 283 insertions(+), 74 deletions(-) diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index a0a29af..afafb14 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -84,7 +84,15 @@ const createConcaveFilletShape = (radius: number): THREE.Shape => { }; // New Helper: Create Perforated Plate (Vertical Wall) -const createPerforatedPlate = (width: number, height: number, thickness: number, perf: PerforationConfig): THREE.BufferGeometry => { +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 shape = new THREE.Shape(); shape.moveTo(0, 0); shape.lineTo(width, 0); @@ -96,9 +104,10 @@ const createPerforatedPlate = (width: number, height: number, thickness: number, 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 + // 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; // Rows @@ -111,8 +120,22 @@ const createPerforatedPlate = (width: number, height: number, thickness: number, 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; + // Boundary check + if (x - r < marginLeft || x + r > width - marginRight || y - r < 0 || y + r > height) continue; + + // 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 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; + }); + + if (inExclusion) continue; if (shapeType === 'circle') { hole.absarc(x, y, r, 0, Math.PI * 2, true); @@ -143,7 +166,7 @@ const createPerforatedPlate = (width: number, height: number, thickness: number, } const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }); - // Extruded along Z. Wall is flat on XY. + // Extruded along Z. Wall is flat on XY. // We want "thickness" to be Z depth. return geo; }; @@ -152,23 +175,29 @@ const createPerforatedPlate = (width: number, height: number, thickness: number, 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) + + // 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 + + // 1. Start at Outer Start + shape.moveTo(radius, 0); + + // 2. Outer Arc (CCW) -> To (0, radius) 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); + + // 3. Line to Inner End (0, innerRadius) + shape.lineTo(0, innerRadius); + + // 4. Inner Arc (CW) -> To (innerRadius, 0) shape.absarc(0, 0, innerRadius, Math.PI / 2, 0, true); - // Close + + // 5. Close loop 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. + // Extrude + // curveSegments 32 for smoothness + const geo = new THREE.ExtrudeGeometry(shape, { depth: height, bevelEnabled: false, curveSegments: 32 }); return geo; }; @@ -223,83 +252,193 @@ export const createBinGeometry = ( // 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 = [ - { 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. + // 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 c = cornerGeoBase.clone(); - c.rotateY(pos.rot); - c.translate(pos.x, thickness, pos.z); - geometries.push(c); + 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 + + if (p.axis === 'x') { + // Runs Depth-wise (Y axis in Layout). + // Intersects Front and Back walls (which are Width-wise plates). + + // X pos on Front/Back walls: + // Partition X global: (-effectiveInnerW / 2) + (effectiveInnerW * p.offset) + // Wall plate local X: 0 to straightW. + // Wall Global X range: [-straightW/2, straightW/2]. + // Map partition global X to wall local X: + // localX = partGlobalX - (-straightW/2) = partGlobalX + straightW/2. + + // Note: straightW = width - 2*effRadius. + // effectiveInnerW = width - 2*thickness. + // These coordinate spaces are slightly different if radius > thickness. + // We should use the ACTUAL spatial intersection. + + const partGlobalX = (-effectiveInnerW / 2) + (effectiveInnerW * p.offset); + const sW = width - 2 * effRadius; + const localXOnWall = partGlobalX + sW / 2; + + // Check if partition intersects the wall's X range + if (localXOnWall + thickness / 2 > 0 && localXOnWall - thickness / 2 < sW) { + // Check if it touches Back (min approx 0) + // Mapping check: + // If p.min (0) is at -Depth/2. And p.max (1) is at +Depth/2. + // Wall 1 is at +Depth/2. So Wall 1 contacts p.max. + // Wall 2 is at -Depth/2. So Wall 2 contacts p.min. + + // Let's store EXCLUSIONS per contact point. + // p.min touches Wall 2. So add to 'exclusionsWall2'. + // p.max touches Wall 1. So add to 'exclusionsWall1'. + + // I used names 'exclusionsBack' and 'exclusionsFront'. + // Let's adhere to names: + // 'Back' typically means +Z (User side) or -Z? + // In 3D graphics: Camera at +Z looking -Z. + // Objects at +Z are "Front" (Near). Objects at -Z are "Background" (Far). + // So Wall 1 (+Z) is Front. Wall 2 (-Z) is Back. + + // 'p.min' (0) -> -Z -> Back/Far Wall (Wall 2). + // 'p.max' (1) -> +Z -> Front/Near Wall (Wall 1). + + // So: + // p.min contacts Wall 2 (Back). + // p.max contacts Wall 1 (Front). + + // Array 'exclusionsBack' should store contacts for Wall 2. + // Array 'exclusionsFront' should store contacts for Wall 1. + + if ((p.min ?? 0) < 0.01) { + exclusionsBack.push({ start: localXOnWall - thickness / 2, end: localXOnWall + thickness / 2, yMax: hEff }); + } + // Check if it touches Front (max approx 1) + if ((p.max ?? 1) > 0.99) { + exclusionsFront.push({ start: localXOnWall - thickness / 2, end: localXOnWall + thickness / 2, yMax: hEff }); + } + } + + } else { // p.axis === 'y' + // Runs Width-wise (X axis in Layout). + // Intersects Left and Right walls (Length-wise plates). + + const partGlobalZ = (-effectiveInnerD / 2) + (effectiveInnerD * p.offset); + const sD = depth - 2 * effRadius; + const localXOnWall = partGlobalZ + sD / 2; + + // Check if partition intersects the wall's X range (which maps to depth) + if (localXOnWall + thickness / 2 > 0 && localXOnWall - thickness / 2 < sD) { + // Check if it touches Left (min approx 0) + if ((p.min ?? 0) < 0.01) { + exclusionsLeft.push({ start: localXOnWall - thickness / 2, end: localXOnWall + thickness / 2, yMax: hEff }); + } + // Check if it touches Right (max approx 1) + if ((p.max ?? 1) > 0.99) { + exclusionsRight.push({ start: localXOnWall - thickness / 2, end: localXOnWall + thickness / 2, yMax: hEff }); + } + } + } + }); + // 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); - - // 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(); + // 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 (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. + // 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 w2 = wGeo.clone(); - // Rotate to face out? + const wGeoBack = createPerforatedPlate(straightW, wallHeight, thickness, perforation, 0, 0, exclusionsBackMapped); + wGeoBack.translate(-straightW / 2, 0, 0); + const w2 = wGeoBack.clone(); 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); } // Left/Right if (straightD > 0.1) { - const dGeo = createPerforatedPlate(straightD, wallHeight, thickness, perforation); - dGeo.translate(-straightD / 2, 0, 0); - // 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); + // 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). - // 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(); + // 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); + w4.translate(-width / 2, thickness, 0); // Position at X=-width/2 geometries.push(w4); } } @@ -336,8 +475,52 @@ export const createBinGeometry = ( pX = (-innerWidth / 2) + (innerWidth * p.offset); pY = (-innerDepth / 2) + (innerDepth * midRatio); // Center of partition - // Create Plate (Length, Height) - const plate = createPerforatedPlate(pLen, effectiveHeight, thickness, perforation!); + // 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) @@ -355,7 +538,33 @@ export const createBinGeometry = ( pX = (-innerWidth / 2) + (innerWidth * midRatio); pY = (-innerDepth / 2) + (innerDepth * p.offset); - const plate = createPerforatedPlate(pLen, effectiveHeight, thickness, perforation!); + // 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. From 01dbf52fc745b3b84ac9b108060c5c7baa8e67fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Mon, 12 Jan 2026 23:53:21 +0300 Subject: [PATCH 4/5] Fix --- src/App.tsx | 6 +-- src/services/geometryGenerator.ts | 77 +++++++++---------------------- 2 files changed, 25 insertions(+), 58 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 7070898..9c6279f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,12 +29,12 @@ 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: false, + enabled: true, shape: 'honeycomb', size: 8, gap: 2 diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index afafb14..35a4fff 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -297,82 +297,49 @@ export const createBinGeometry = ( 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 (which are Width-wise plates). - - // X pos on Front/Back walls: - // Partition X global: (-effectiveInnerW / 2) + (effectiveInnerW * p.offset) - // Wall plate local X: 0 to straightW. - // Wall Global X range: [-straightW/2, straightW/2]. - // Map partition global X to wall local X: - // localX = partGlobalX - (-straightW/2) = partGlobalX + straightW/2. - - // Note: straightW = width - 2*effRadius. - // effectiveInnerW = width - 2*thickness. - // These coordinate spaces are slightly different if radius > thickness. - // We should use the ACTUAL spatial intersection. + // Intersects Front and Back walls. const partGlobalX = (-effectiveInnerW / 2) + (effectiveInnerW * p.offset); const sW = width - 2 * effRadius; const localXOnWall = partGlobalX + sW / 2; - // Check if partition intersects the wall's X range - if (localXOnWall + thickness / 2 > 0 && localXOnWall - thickness / 2 < sW) { - // Check if it touches Back (min approx 0) - // Mapping check: - // If p.min (0) is at -Depth/2. And p.max (1) is at +Depth/2. - // Wall 1 is at +Depth/2. So Wall 1 contacts p.max. - // Wall 2 is at -Depth/2. So Wall 2 contacts p.min. + // 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 + }; - // Let's store EXCLUSIONS per contact point. - // p.min touches Wall 2. So add to 'exclusionsWall2'. - // p.max touches Wall 1. So add to 'exclusionsWall1'. - - // I used names 'exclusionsBack' and 'exclusionsFront'. - // Let's adhere to names: - // 'Back' typically means +Z (User side) or -Z? - // In 3D graphics: Camera at +Z looking -Z. - // Objects at +Z are "Front" (Near). Objects at -Z are "Background" (Far). - // So Wall 1 (+Z) is Front. Wall 2 (-Z) is Back. - - // 'p.min' (0) -> -Z -> Back/Far Wall (Wall 2). - // 'p.max' (1) -> +Z -> Front/Near Wall (Wall 1). - - // So: - // p.min contacts Wall 2 (Back). - // p.max contacts Wall 1 (Front). - - // Array 'exclusionsBack' should store contacts for Wall 2. - // Array 'exclusionsFront' should store contacts for Wall 1. - - if ((p.min ?? 0) < 0.01) { - exclusionsBack.push({ start: localXOnWall - thickness / 2, end: localXOnWall + thickness / 2, yMax: hEff }); - } - // Check if it touches Front (max approx 1) - if ((p.max ?? 1) > 0.99) { - exclusionsFront.push({ start: localXOnWall - thickness / 2, end: localXOnWall + thickness / 2, 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 (Length-wise plates). + // Intersects Left and Right walls. const partGlobalZ = (-effectiveInnerD / 2) + (effectiveInnerD * p.offset); const sD = depth - 2 * effRadius; const localXOnWall = partGlobalZ + sD / 2; - // Check if partition intersects the wall's X range (which maps to depth) - if (localXOnWall + thickness / 2 > 0 && localXOnWall - thickness / 2 < sD) { - // Check if it touches Left (min approx 0) + 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({ start: localXOnWall - thickness / 2, end: localXOnWall + thickness / 2, yMax: hEff }); + exclusionsLeft.push(zone); } - // Check if it touches Right (max approx 1) if ((p.max ?? 1) > 0.99) { - exclusionsRight.push({ start: localXOnWall - thickness / 2, end: localXOnWall + thickness / 2, yMax: hEff }); + exclusionsRight.push(zone); } } } From 21a41ccf05e94f52836176965c455d709901638b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Tue, 13 Jan 2026 01:10:04 +0300 Subject: [PATCH 5/5] Add sizes --- src/components/PreviewStep.tsx | 128 +++++++++++++++++++++++++++++++-- 1 file changed, 124 insertions(+), 4 deletions(-) diff --git a/src/components/PreviewStep.tsx b/src/components/PreviewStep.tsx index 3baa028..bff0b81 100644 --- a/src/components/PreviewStep.tsx +++ b/src/components/PreviewStep.tsx @@ -1,6 +1,6 @@ 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, PerforationConfig } from '../types'; @@ -52,18 +52,138 @@ const BinMesh: React.FC = ({ part, thickness, cornerRadius, perfor 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(); }} + > {/* Сама модель */} - { e.stopPropagation(); onClick(); }}> + + {/* Текстовые метки размеров внутри каждой ячейки */} + {dimLabels.map(label => ( + + {label.text} + + ))} + {/* Белая подсветка при выборе */} {isSelected && (