diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..9dffe1b --- /dev/null +++ b/App.tsx @@ -0,0 +1,124 @@ +import React, { useState, useMemo } from 'react'; +import { AppConfig, LayoutSplits, GeneratedPart } from './types'; +// Убедись, что этот файл существует по этому пути +import { calculateParts } from './services/geometryGenerator'; +import { ConfigStep } from './components/ConfigStep'; +import { LayoutStep } from './components/LayoutStep'; +import { PreviewStep } from './components/PreviewStep'; +import { ChevronRight, ChevronLeft, Box } from 'lucide-react'; + +const App = () => { + const [step, setStep] = useState(1); + + // State + const [config, setConfig] = useState({ + drawer: { width: 300, depth: 400, height: 80 }, + wallThickness: 1.2, + printerTolerance: 0.5, + }); + + const [splits, setSplits] = useState({ + x: [], + y: [] + }); + + // Derived State: Parts (Мгновенный пересчет без API) + const parts: GeneratedPart[] = useMemo(() => { + return calculateParts(config, splits); + }, [config, splits]); + + return ( +
+ {/* Header */} +
+
+
+
+ +
+
+

PrintFit

+

Генератор без ИИ

+
+
+ + {/* Progress Stepper */} +
+ {[1, 2, 3].map((num) => ( + +
+ + {num} + + + {num === 1 ? 'Настройки' : num === 2 ? 'Макет' : 'Экспорт'} + +
+ {num < 3 &&
} + + ))} +
+
+
+ + {/* Main Content */} +
+ {step === 1 && ( +
+ +
+ )} + + {step === 2 && ( +
+ +
+ )} + + {step === 3 && ( +
+ +
+ )} +
+ + {/* Footer Navigation */} +
+
+ + +
+ {step === 2 && Ячеек: {parts.length}} +
+ + {step < 3 ? ( + + ) : ( + + )} +
+
+
+ ); +}; + +export default App; \ No newline at end of file diff --git a/README.md b/README.md index b3a9ce0..68dc502 100644 --- a/README.md +++ b/README.md @@ -1 +1,20 @@ -# BoxGenerator +
+GHBanner +
+ +# Run and deploy your AI Studio app + +This contains everything you need to run your app locally. + +View your app in AI Studio: https://ai.studio/apps/drive/1gUfO_tgl_CPfAGf1eXuSFyC0K7nUZFgu + +## Run Locally + +**Prerequisites:** Node.js + + +1. Install dependencies: + `npm install` +2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key +3. Run the app: + `npm run dev` diff --git a/components/ConfigStep.tsx b/components/ConfigStep.tsx new file mode 100644 index 0000000..088f867 --- /dev/null +++ b/components/ConfigStep.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { AppConfig } from '../types'; +import { Ruler, Box, Layers, Minimize2 } from 'lucide-react'; + +interface Props { + config: AppConfig; + onChange: (newConfig: AppConfig) => void; +} + +const InputGroup: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => ( +
+ +
{children}
+
+); + +const NumberInput = ({ + label, + value, + onChange, + max +}: { + label: string; + value: number; + onChange: (val: number) => void; + max?: number +}) => ( +
+ {label} + onChange(parseFloat(e.target.value) || 0)} + className="w-full bg-slate-800 border border-slate-700 rounded p-2 pl-8 text-white focus:ring-2 focus:ring-primary outline-none" + /> + мм +
+); + +export const ConfigStep: React.FC = ({ config, onChange }) => { + const updateDrawer = (key: keyof AppConfig['drawer'], val: number) => { + onChange({ ...config, drawer: { ...config.drawer, [key]: val } }); + }; + + return ( +
+

+ 1. Размеры +

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

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

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

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

+ +
+
+ + + {config.wallThickness.toFixed(1)} мм + +
+ +
+ 0.4 + onChange({...config, wallThickness: parseFloat(e.target.value)})} + className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-primary hover:accent-blue-400 transition-all" + /> + 3.2 +
+
+ +
+
+ + + {config.printerTolerance.toFixed(1)} мм + +
+ +
+ 0.0 + onChange({...config, printerTolerance: parseFloat(e.target.value)})} + className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-accent hover:accent-amber-400 transition-all" + /> + 2.0 +
+
+ +
+

+ Зазор уменьшает размер каждой ячейки, чтобы они легко вставлялись в ящик и друг в друга. Рекомендуется 0.5 мм. +

+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/components/LayoutStep.tsx b/components/LayoutStep.tsx new file mode 100644 index 0000000..a8861eb --- /dev/null +++ b/components/LayoutStep.tsx @@ -0,0 +1,372 @@ +import React, { useRef, useState, useMemo } from 'react'; +import { AppConfig, LayoutSplits } from '../types'; +import { Grid, MousePointer2, Trash2, RotateCcw } from 'lucide-react'; + +interface Props { + config: AppConfig; + splits: LayoutSplits; + onChange: (splits: LayoutSplits) => void; +} + +type Axis = 'x' | 'y'; + +export const LayoutStep: React.FC = ({ config, splits, onChange }) => { + const svgRef = useRef(null); + + // State for interaction + const [phantomAxis, setPhantomAxis] = useState(null); // Proposed new line axis + const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); // Normalized 0-1 + const [hoveredSplit, setHoveredSplit] = useState<{ axis: Axis; index: number } | null>(null); + const [dragging, setDragging] = useState<{ axis: Axis; index: number } | null>(null); + + // Constants + const SNAP_THRESHOLD = 0.02; // Reduced threshold slightly for better precision on large grid + const viewBoxW = 1000; + const aspectRatio = config.drawer.depth / config.drawer.width; + const viewBoxH = viewBoxW * aspectRatio; + + // Helpers to calculate cell dimensions + // IMPORTANT: Dependencies must be correct. dragging updates splits, which updates sortedX/Y + const sortedX = useMemo(() => [0, ...splits.x, 1].sort((a, b) => a - b), [splits.x]); + const sortedY = useMemo(() => [0, ...splits.y, 1].sort((a, b) => a - b), [splits.y]); + + // Handlers + const handleMouseMove = (e: React.MouseEvent) => { + if (!svgRef.current) return; + const rect = svgRef.current.getBoundingClientRect(); + const nx = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const ny = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); + + setMousePos({ x: nx, y: ny }); + + if (dragging) { + // Move logic + // IMPORTANT: Create NEW array references so useMemo in this component and parent components detect changes + const newSplits = { + x: [...splits.x], + y: [...splits.y] + }; + + const val = dragging.axis === 'x' ? nx : ny; + newSplits[dragging.axis][dragging.index] = val; + + onChange(newSplits); + return; + } + + // Hover logic: Check if near existing lines + let foundSplit = null; + + // Check X splits (Vertical lines) + splits.x.forEach((val, idx) => { + if (Math.abs(nx - val) < SNAP_THRESHOLD) foundSplit = { axis: 'x' as Axis, index: idx }; + }); + + // Check Y splits (Horizontal lines) + if (!foundSplit) { + splits.y.forEach((val, idx) => { + if (Math.abs(ny - val) < SNAP_THRESHOLD) foundSplit = { axis: 'y' as Axis, index: idx }; + }); + } + + setHoveredSplit(foundSplit); + + // Auto-detect axis for NEW lines if not hovering existing + // AND if not too close to other lines + if (!foundSplit) { + // Check proximity to ALL lines to prevent creating duplicates + const closeToX = splits.x.some(val => Math.abs(nx - val) < SNAP_THRESHOLD); + const closeToY = splits.y.some(val => Math.abs(ny - val) < SNAP_THRESHOLD); + + // Also check edges (0 and 1) + const closeToEdgeX = nx < SNAP_THRESHOLD || nx > (1 - SNAP_THRESHOLD); + const closeToEdgeY = ny < SNAP_THRESHOLD || ny > (1 - SNAP_THRESHOLD); + + if (closeToX || closeToEdgeX) { + // Too close to X line or edge, don't allow vertical split here + // But might allow horizontal? + // Actually if we are close to an X line, we probably want to select it, which is handled by foundSplit. + // If foundSplit is null but closeToX is true, it means we are just outside threshold? + // Let's simplified: if too close to any parallel line, disable creation. + } + + const distLeft = nx; + const distRight = 1 - nx; + const distTop = ny; + const distBottom = 1 - ny; + + const minXDist = Math.min(distLeft, distRight); + const minYDist = Math.min(distTop, distBottom); + + // Determine potential axis + let potentialAxis: Axis = minXDist < minYDist ? 'y' : 'x'; + + // Validate proximity for that axis + let valid = true; + if (potentialAxis === 'x') { + if (closeToX || closeToEdgeX) valid = false; + } else { + if (closeToY || closeToEdgeY) valid = false; + } + + if (valid) { + setPhantomAxis(potentialAxis); + } else { + setPhantomAxis(null); + } + } else { + setPhantomAxis(null); + } + }; + + const handleMouseDown = (e: React.MouseEvent) => { + if (hoveredSplit) { + // Start Dragging + if (e.button === 0) { // Left click + setDragging(hoveredSplit); + } else if (e.button === 2) { // Right click + removeSplit(hoveredSplit.axis, hoveredSplit.index); + } + } else if (phantomAxis) { + // Create New Split + const val = phantomAxis === 'x' ? mousePos.x : mousePos.y; + const newSplits = { ...splits }; + newSplits[phantomAxis] = [...newSplits[phantomAxis], val]; + onChange(newSplits); + // Immediately start dragging the new line for fine-tuning + setDragging({ axis: phantomAxis, index: newSplits[phantomAxis].length - 1 }); + } + }; + + const handleMouseUp = () => { + setDragging(null); + }; + + const removeSplit = (axis: Axis, index: number) => { + const newSplits = { ...splits }; + newSplits[axis] = newSplits[axis].filter((_, i) => i !== index); + onChange(newSplits); + setHoveredSplit(null); + setDragging(null); + }; + + return ( +
+
+

+ 2. Редактор макета +

+ +
+ +
+
+ + {/* Instruction Box */} +
+
+ Инструкция +
+
    +
  • Клик у края: Новая линия
  • +
  • Перетаскивание: Изменить размер
  • +
  • Двойной клик/ПКМ: Удалить
  • +
+
+ + {/* Rulers */} +
+ 0 + {config.drawer.width} мм +
+ +
+
+ 0 + {config.drawer.depth} мм +
+ + {/* Main Interactive SVG */} +
+ e.preventDefault()} + > + + + + + + + + + + + + {/* --- Cell Dimensions Labels --- */} + {sortedX.slice(0, -1).map((x1, i) => { + const x2 = sortedX[i + 1]; + return sortedY.slice(0, -1).map((y1, j) => { + const y2 = sortedY[j + 1]; + const width = (x2 - x1) * config.drawer.width; + const depth = (y2 - y1) * config.drawer.depth; + const centerX = ((x1 + x2) / 2) * viewBoxW; + const centerY = ((y1 + y2) / 2) * viewBoxH; + + const cellWidthSVG = (x2 - x1) * viewBoxW; + const cellHeightSVG = (y2 - y1) * viewBoxH; + + // Dynamic Font Sizing Logic + // Max font size: 36px + // Must fit in height: 60% of height max + // Must fit in width: approx 25% of width max (assuming ~5 chars) + let fontSize = Math.min(36, cellHeightSVG * 0.6); + fontSize = Math.min(fontSize, cellWidthSVG * 0.25); + + // Don't show text if calculated font size is too small to be readable + if (fontSize < 10) return null; + + return ( + + {width.toFixed(0)} × {depth.toFixed(0)} + + ); + }); + })} + + {/* --- Existing X Lines (Vertical) --- */} + {splits.x.map((x, i) => { + const isHovered = hoveredSplit?.axis === 'x' && hoveredSplit.index === i; + const isDragging = dragging?.axis === 'x' && dragging.index === i; + const color = isHovered || isDragging ? '#f59e0b' : '#64748b'; + const width = isHovered || isDragging ? 8 : 4; + + return ( + removeSplit('x', i)}> + {/* Invisible wide hit area */} + + {/* Visible Line */} + + {/* Delete Button if hovered */} + {(isHovered || isDragging) && ( + { e.stopPropagation(); removeSplit('x', i); }}> + + + + )} + + ); + })} + + {/* --- Existing Y Lines (Horizontal) --- */} + {splits.y.map((y, i) => { + const isHovered = hoveredSplit?.axis === 'y' && hoveredSplit.index === i; + const isDragging = dragging?.axis === 'y' && dragging.index === i; + const color = isHovered || isDragging ? '#f59e0b' : '#64748b'; + const width = isHovered || isDragging ? 8 : 4; + + return ( + removeSplit('y', i)}> + {/* Invisible wide hit area */} + + + {(isHovered || isDragging) && ( + { e.stopPropagation(); removeSplit('y', i); }}> + + + + )} + + ); + })} + + {/* --- Phantom Line (Preview) --- */} + {!hoveredSplit && !dragging && phantomAxis === 'x' && ( + + + + + + + )} + {!hoveredSplit && !dragging && phantomAxis === 'y' && ( + + + + + + + )} + +
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/components/PreviewStep.tsx b/components/PreviewStep.tsx new file mode 100644 index 0000000..8a91087 --- /dev/null +++ b/components/PreviewStep.tsx @@ -0,0 +1,259 @@ +import React, { useMemo, Suspense, useEffect, useRef, useState } from 'react'; +import { Canvas } from '@react-three/fiber'; +import { OrbitControls, Center, Environment } from '@react-three/drei'; +import * as THREE from 'three'; +import JSZip from 'jszip'; +import { AppConfig, GeneratedPart } from '../types'; +import { createBinGeometry, exportSTL, generateSTL } from '../services/geometryGenerator'; +import { Download, Package, Info, Loader2 } from 'lucide-react'; + +// --- 3D Helper Components --- + +// Каркас ящика (только ребра, без диагоналей) +const DrawerFrame = ({ config }: { config: AppConfig }) => { + const { width, depth, height } = config.drawer; + const offset = 0.5; // Небольшой отступ наружу + + return ( + + + + + + + ) +} + +// --- Bin Component --- + +interface BinMeshProps { + part: GeneratedPart; + thickness: number; + isSelected: boolean; + onClick: () => void; +} + +const BinMesh: React.FC = ({ part, thickness, isSelected, onClick }) => { + // REMOVED ARTIFICIAL GAP: The part dimensions now include the printer tolerance physically. + // The gap will be visible naturally because part.width is smaller than grid size. + + // Генерируем реальную геометрию, как для STL + const geometry = useMemo(() => { + return createBinGeometry(part.width, part.depth, part.height, thickness); + }, [part, thickness]); + + return ( + + {/* Основной меш ячейки */} + { e.stopPropagation(); onClick(); }} + > + + + + {/* Подсветка выделения (Bounding Box) */} + {isSelected && ( + + + + + )} + + ); +}; + +interface Props { + parts: GeneratedPart[]; + config: AppConfig; +} + +export const PreviewStep: React.FC = ({ parts, config }) => { + const [selectedId, setSelectedId] = useState(null); + const [isZipping, setIsZipping] = useState(false); + const itemRefs = useRef<{ [key: string]: HTMLDivElement | null }>({}); + + // Автопрокрутка к выбранному элементу + 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); + const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial()); + exportSTL(mesh, `${part.name.replace(/\s+/g, '_')}.stl`); + }; + + const handleDownloadAll = async () => { + if (isZipping) return; + setIsZipping(true); + + try { + console.log("Starting ZIP generation..."); + // Ensure JSZip is available + if (typeof JSZip === 'undefined' && !JSZip) { + throw new Error("Библиотека JSZip не загружена."); + } + + const zip = new JSZip(); + + // Генерация STL для каждой части и добавление в архив + parts.forEach(part => { + const geometry = createBinGeometry(part.width, part.depth, part.height, config.wallThickness); + const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial()); + const stlData = generateSTL(mesh); + // stlData is Uint8Array or string here, which is supported + zip.file(`${part.name.replace(/\s+/g, '_')}.stl`, stlData); + }); + + console.log("Files added to ZIP. Generating blob..."); + + // Генерация самого ZIP файла + const content = await zip.generateAsync({ type: "blob" }); + + console.log("ZIP blob generated. Size:", content.size); + + // Скачивание + 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) { + console.error("Failed to create zip archive", e); + alert(`Ошибка при создании архива: ${e.message || 'Неизвестная ошибка'}`); + } finally { + setIsZipping(false); + } + }; + + return ( +
+ {/* 3D Viewer */} +
+
+
+ Управление +
+
    +
  • • ЛКМ: Вращение
  • +
  • • ПКМ: Перемещение
  • +
  • • Скролл: Масштаб
  • +
  • • Клик по детали для выбора
  • +
+
+ + + + + + + + + + +
+ + + {parts.map(part => ( + setSelectedId(part.id)} + /> + ))} + +
+ + +
+
+
+ + {/* Sidebar List */} +
+
+

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

+ +
+ +
+ {parts.map(part => ( +
{ itemRefs.current[part.id] = el }} + className={`p-4 rounded-lg border transition-all cursor-pointer group ${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)} + > +
+ {part.name} +
+
+
+
+ Ширина + {part.width.toFixed(1)} +
+
+ Глубина + {part.depth.toFixed(1)} +
+
+ Высота + {part.height.toFixed(1)} +
+
+ +
+ ))} +
+
+
+ ); +}; \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..960e673 --- /dev/null +++ b/index.html @@ -0,0 +1,33 @@ + + + + + + PrintFit - Генератор Органайзеров + + + + + +
+ + + \ No newline at end of file diff --git a/index.tsx b/index.tsx new file mode 100644 index 0000000..6ca5361 --- /dev/null +++ b/index.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +const rootElement = document.getElementById('root'); +if (!rootElement) { + throw new Error("Could not find root element to mount to"); +} + +const root = ReactDOM.createRoot(rootElement); +root.render( + + + +); \ No newline at end of file diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..586f993 --- /dev/null +++ b/metadata.json @@ -0,0 +1,5 @@ +{ + "name": "PrintFit - Генератор органайзеров", + "description": "Параметрический инструмент для создания и нарезки органайзеров для 3D-печати. Настройте размеры, создайте макет и скачайте STL файлы.", + "requestFramePermissions": [] +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..62e354b --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "printfit-organizer", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.3", + "react-dom": "^19.2.3", + "three": "^0.182.0", + "three-stdlib": "^2.36.1", + "lucide-react": "^0.562.0", + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.4.2", + "jszip": "3.10.1", + "uuid": "^9.0.1" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@types/node": "^22.14.0", + "@types/uuid": "^9.0.8", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.8.2", + "vite": "^6.2.0" + } +} \ No newline at end of file diff --git a/services/geometryGenerator.ts b/services/geometryGenerator.ts new file mode 100644 index 0000000..b55cf78 --- /dev/null +++ b/services/geometryGenerator.ts @@ -0,0 +1,137 @@ +import * as THREE from 'three'; +import { STLExporter, mergeBufferGeometries } from 'three-stdlib'; +import { AppConfig, LayoutSplits, GeneratedPart } from '../types'; + +/** + * Calculates the final list of bins based on layout. + * Removes printer bed constraints logic. + */ +export const calculateParts = ( + config: AppConfig, + splits: LayoutSplits +): GeneratedPart[] => { + const parts: GeneratedPart[] = []; + + // 1. Sort splits to create segments + // CRITICAL FIX: Create a copy of the array before sorting to avoid mutating React state + const xPoints = [0, ...[...splits.x].sort((a, b) => a - b), 1]; + const yPoints = [0, ...[...splits.y].sort((a, b) => a - b), 1]; + + let partCounter = 1; + + // 2. Iterate through the grid defined by the user + for (let i = 0; i < xPoints.length - 1; i++) { + for (let j = 0; j < yPoints.length - 1; j++) { + + const segmentX = xPoints[i] * config.drawer.width; + const segmentY = yPoints[j] * config.drawer.depth; + const segmentW = (xPoints[i + 1] - xPoints[i]) * config.drawer.width; + const segmentD = (yPoints[j + 1] - yPoints[j]) * config.drawer.depth; + + // Apply Printer Tolerance (Gap) + // We subtract the tolerance from the width/depth and shift the position + // so the gap is evenly distributed around the part center. + const realWidth = segmentW - config.printerTolerance; + const realDepth = segmentD - config.printerTolerance; + const realX = segmentX + (config.printerTolerance / 2); + const realY = segmentY + (config.printerTolerance / 2); + + // Filter out extremely small parts (likely errors/double lines or consumed by tolerance) + if (realWidth < 1 || realDepth < 1) { + continue; + } + + 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%)` + }); + partCounter++; + } + } + + return parts; +}; + +/** + * Generates a Three.js Geometry for a hollow bin. + * The floor is at y=0..thickness. + * The walls sit on top of the floor, y=thickness..height. + */ +export const createBinGeometry = ( + width: number, + depth: number, + height: number, + thickness: number +): THREE.BufferGeometry => { + const geometries: THREE.BufferGeometry[] = []; + + // 1. Floor (Base) + // Positioned at the bottom (0 to thickness) + const floorGeo = new THREE.BoxGeometry(width, thickness, depth); + floorGeo.translate(0, thickness / 2, 0); + geometries.push(floorGeo); + + // Wall dimensions + // Walls sit ON TOP of the floor to avoid overlap/z-fighting + const wallHeight = height - thickness; + const wallCenterY = thickness + (wallHeight / 2); + + if (wallHeight > 0) { + // 2. Left & Right Walls (Full depth) + const wallLRGeo = new THREE.BoxGeometry(thickness, wallHeight, depth); + + const leftWall = wallLRGeo.clone(); + leftWall.translate(-(width/2) + (thickness/2), wallCenterY, 0); + geometries.push(leftWall); + + const rightWall = wallLRGeo.clone(); + rightWall.translate((width/2) - (thickness/2), wallCenterY, 0); + geometries.push(rightWall); + + // 3. Front & Back Walls (Width minus LR thickness to fit between) + // Width is reduced by 2*thickness + const wallFBWidth = Math.max(0, width - (2 * thickness)); + const wallFBGeo = new THREE.BoxGeometry(wallFBWidth, wallHeight, thickness); + + const frontWall = wallFBGeo.clone(); + frontWall.translate(0, wallCenterY, (depth/2) - (thickness/2)); + geometries.push(frontWall); + + const backWall = wallFBGeo.clone(); + backWall.translate(0, wallCenterY, -(depth/2) + (thickness/2)); + geometries.push(backWall); + } + + // Merge geometries into a single mesh for clean STL export + const merged = mergeBufferGeometries(geometries); + return merged || new THREE.BoxGeometry(1, 1, 1); +}; + +// Generates STL binary data without triggering download +export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => { + const exporter = new STLExporter(); + const result = exporter.parse(mesh, { binary: true }); + + // JSZip requires Uint8Array or ArrayBuffer, not DataView + if (result instanceof DataView) { + return new Uint8Array(result.buffer, result.byteOffset, result.byteLength); + } + + return result as string; +}; + +// Triggers download immediately +export const exportSTL = (mesh: THREE.Object3D, filename: string) => { + const result = generateSTL(mesh); + const blob = new Blob([result], { type: 'application/octet-stream' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = filename; + link.click(); +}; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c6eed5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2022", + "experimentalDecorators": true, + "useDefineForClassFields": false, + "module": "ESNext", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "types": [ + "node" + ], + "moduleResolution": "bundler", + "isolatedModules": true, + "moduleDetection": "force", + "allowJs": true, + "jsx": "react-jsx", + "paths": { + "@/*": [ + "./*" + ] + }, + "allowImportingTsExtensions": true, + "noEmit": true + } +} \ No newline at end of file diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..5a43f7a --- /dev/null +++ b/types.ts @@ -0,0 +1,29 @@ +export interface Dimensions { + width: number; + depth: number; + height: number; +} + +export interface AppConfig { + drawer: Dimensions; + wallThickness: number; + printerTolerance: number; // Gap between parts for fit +} + +// Represents a vertical or horizontal divider position (0 to 1) +export interface LayoutSplits { + x: number[]; // Vertical dividers (along width) + y: number[]; // Horizontal dividers (along depth) +} + +// A single generated bin part +export interface GeneratedPart { + id: string; + name: string; + width: number; + depth: number; + height: number; + x: number; // Position in drawer + y: number; // Position in drawer + color: string; +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..caee3fc --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,16 @@ +import path from 'path'; +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + server: { + port: 3000, + host: '0.0.0.0', + }, + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + } + } +}); \ No newline at end of file