-
+
+ {/* Drawer Dimensions */}
+
+
+ Внутренние размеры ящика
+
+
+ updateDrawer('width', v)} />
+
+
+ updateDrawer('depth', v)} />
+
+
+ updateDrawer('height', v)} />
+
+
+
+ {/* Settings */}
+
+
+ Параметры печати
+
+
+ {/* Wall Thickness */}
+
+
{config.wallThickness.toFixed(1)} мм
-
-
+ />
+
3.2
+
+
- {/* Corner Radius (NEW) */}
-
-
+ {/* Corner Radius */}
+
+
{config.cornerRadius?.toFixed(0) || 0} мм
-
-
+ />
+
20
+
+
- {/* Printer Tolerance */}
-
-
+ {/* Printer Tolerance */}
+
+
{config.printerTolerance.toFixed(1)} мм
-
-
+ />
+
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 */}
-
-
-
- Управление
-
-
- - • ЛКМ: Вращение
- - • ПКМ: Перемещение
- - • Скролл: Масштаб
-
-
-
-
-
+
+ {/* 3D Viewer */}
+
+
+
+ Управление
+
+
+ - • ЛКМ: Вращение
+ - • ПКМ: Перемещение
+ - • Скролл: Масштаб
+
+
- {/* Sidebar List (Grid Layout) */}
-
-
-
- Детали ({parts.length})
-
-
-
+
+
-
-
- {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.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 {