import * as THREE from 'three'; import { STLExporter, mergeBufferGeometries } from 'three-stdlib'; 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 xPoints = [0, ...[...safeX].sort((a, b) => a - b), 1]; const yPoints = [0, ...[...safeY].sort((a, b) => a - b), 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; const rawX = xPoints[i] * config.drawer.width; const rawY = yPoints[j] * config.drawer.depth; const internalPartitions = safeParts[`${i}-${j}`] || []; 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; }; // --- ГЕОМЕТРИЯ --- 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); 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 => { const shape = new THREE.Shape(); shape.moveTo(0, 0); shape.lineTo(radius, 0); shape.absarc(radius, radius, radius, -Math.PI / 2, -Math.PI, true); shape.lineTo(0, 0); 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[] = [], perforation?: PerforationConfig ): THREE.BufferGeometry => { const geometries: THREE.BufferGeometry[] = []; // ДНО (Floor) - Always same const floorShape = createRoundedRectShape(width, depth, radius); const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false }); floorGeo.rotateX(-Math.PI / 2); // Lay flat geometries.push(floorGeo); // WALLS if (!perforation || !perforation.enabled) { // --- ORIGINAL LOGIC (Optimized for Solid Walls) --- const outerShape = createRoundedRectShape(width, depth, radius); const innerRadius = Math.max(0.1, radius - thickness); const innerWidth = width - (2 * thickness); const innerDepth = depth - (2 * thickness); if (innerWidth > 0.1 && innerDepth > 0.1) { const innerHole = createRoundedRectShape(innerWidth, innerDepth, innerRadius); outerShape.holes.push(innerHole); } const wallHeight = height - thickness; const wallGeo = new THREE.ExtrudeGeometry(outerShape, { depth: wallHeight, bevelEnabled: false }); wallGeo.rotateX(-Math.PI / 2); wallGeo.translate(0, thickness, 0); geometries.push(wallGeo); } else { // --- PERFORATED LOGIC (Split Walls) --- const wallHeight = height - thickness; // Clamp radius to at least thickness for valid corners in this mode const effRadius = Math.max(radius, thickness); const straightW = width - 2 * effRadius; const straightD = depth - 2 * effRadius; // 1. Corners (4 pcs) if (effRadius > 0) { const cornerGeoBase = createCornerProfile(effRadius, thickness, wallHeight); // 1. Stand Up: Extrusion Z -> Y. Shape moves to X(+)/Z(+). cornerGeoBase.rotateX(-Math.PI / 2); 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. ]; positions.forEach(pos => { const c = cornerGeoBase.clone(); c.rotateY(pos.rot); c.translate(pos.x, thickness, pos.z); geometries.push(c); }); } // 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); // 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); // 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 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); } // 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); 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(); w4.rotateY(Math.PI / 2); w4.translate(-(width / 2), thickness, 0); geometries.push(w4); } } // ВНУТРЕННИЕ ПЕРЕГОРОДКИ const innerWidth = width - (2 * thickness); const innerDepth = depth - (2 * thickness); // Approximate usable space logic partitions.forEach(p => { const pMin = p.min ?? 0; const pMax = p.max ?? 1; if (pMax - pMin < 0.01) return; const lengthRatio = pMax - pMin; const midRatio = pMin + (lengthRatio / 2); // --- 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 => { const exporter = new STLExporter(); const result = exporter.parse(mesh, { binary: true }); if (result instanceof DataView) return new Uint8Array(result.buffer, result.byteOffset, result.byteLength); return result as string; }; export const exportSTL = (mesh: THREE.Object3D, filename: string) => { const result = generateSTL(mesh); const blob = new Blob([result as any], { type: 'application/octet-stream' }); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = filename; link.click(); };