diff --git a/src/components/Features.tsx b/src/components/Features.tsx index f81cfe2..f0d4a74 100644 --- a/src/components/Features.tsx +++ b/src/components/Features.tsx @@ -39,7 +39,7 @@ const Features = () => {
  • - Export designs for 3D printing (Coming Soon!) + Export stackable print lists for 3D printing
  • diff --git a/src/components/GridfinityCalculator.tsx b/src/components/GridfinityCalculator.tsx index 289cfc0..60ea61b 100644 --- a/src/components/GridfinityCalculator.tsx +++ b/src/components/GridfinityCalculator.tsx @@ -4,6 +4,7 @@ import DrawerDimensions from "./GridfinityCalculator/DrawerDimensions"; import PrinterSettings from "./GridfinityCalculator/PrinterSettings"; import BinOptions from "./GridfinityCalculator/BinOptions"; import DrawerOptions from "./GridfinityCalculator/DrawerOptions"; +import ExportPanel from "./GridfinityCalculator/ExportPanel"; import GridfinityResults from "./GridfinityResults"; import GridfinityVisualPreview from "./GridfinityVisualPreview"; import { useSettings } from "@/contexts/SettingsContext"; @@ -129,6 +130,18 @@ const GridfinityCalculator: React.FC = () => { useMm={settings.useMm} /> )} + {settings.drawerSize && ( + + )} )} diff --git a/src/components/GridfinityCalculator/ExportPanel.tsx b/src/components/GridfinityCalculator/ExportPanel.tsx new file mode 100644 index 0000000..2fc0842 --- /dev/null +++ b/src/components/GridfinityCalculator/ExportPanel.tsx @@ -0,0 +1,232 @@ +import React, { useState, useMemo } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from '@/components/ui/tooltip'; +import { + Download, + Copy, + ExternalLink, + Layers, + CheckCircle, + ChevronDown, + ChevronUp +} from 'lucide-react'; +import { + generateExportData, + generateTextSummary, + copyToClipboard, + type ExportData, + type StackablePrint +} from '@/services/exportService'; +import type { GridfinityResult, PrinterSize } from '@/types'; + +interface ExportPanelProps { + result: GridfinityResult; + printerSize: PrinterSize; + numDrawers: number; +} + +const ExportPanel: React.FC = ({ + result, + printerSize, + numDrawers +}) => { + const [copied, setCopied] = useState(false); + const [expanded, setExpanded] = useState(false); + + const exportData = useMemo(() => { + return generateExportData(result, printerSize, numDrawers); + }, [result, printerSize, numDrawers]); + + const handleCopyToClipboard = async () => { + const text = generateTextSummary(exportData); + const success = await copyToClipboard(text); + if (success) { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const handleDownloadText = () => { + const text = generateTextSummary(exportData); + const blob = new Blob([text], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'gridfinity-print-list.txt'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + if (exportData.prints.length === 0) { + return null; + } + + return ( + + +
    +
    + +

    Export Stackable Prints

    +
    +
    + + + + + + +

    Copy print list to clipboard

    +
    +
    +
    + + + + + + + +

    Download as text file

    +
    +
    +
    +
    +
    + +

    + {exportData.summary} +

    + + + + {expanded && ( +
    + {exportData.prints.map((print) => ( + + ))} +
    + )} +
    +
    + ); +}; + +interface PrintCardProps { + print: StackablePrint; +} + +const PrintCard: React.FC = ({ print }) => { + const typeLabel = print.type === 'baseplate' + ? 'Baseplate' + : print.type === 'half-size' + ? 'Half-size Bin' + : 'Spacer'; + + const typeColor = print.type === 'baseplate' + ? 'bg-blue-100 text-blue-800 border-blue-200' + : print.type === 'half-size' + ? 'bg-green-100 text-green-800 border-green-200' + : 'bg-orange-100 text-orange-800 border-orange-200'; + + return ( +
    +
    +
    + {typeLabel} + {print.width}x{print.height} +
    +
    +
    + {print.quantity} needed +
    +
    + Max {print.maxStack} per stack +
    +
    +
    + +
    + + {print.leftoverItems > 0 && print.recommendedStacks > 1 ? ( + + Print {print.recommendedStacks - 1} stack(s) of{' '} + {print.itemsPerStack}, then{' '} + 1 stack of {print.leftoverItems} + + ) : ( + + Print {print.recommendedStacks} stack(s) of{' '} + {print.itemsPerStack} + + )} +
    + + {print.modelLinks.length > 0 && ( +
    + {print.modelLinks.map((link) => ( + + + {link.platform} + + ))} +
    + )} +
    + ); +}; + +export default ExportPanel; diff --git a/src/services/exportService.test.ts b/src/services/exportService.test.ts new file mode 100644 index 0000000..4506fc6 --- /dev/null +++ b/src/services/exportService.test.ts @@ -0,0 +1,240 @@ +import { describe, it, expect } from 'vitest'; +import { + generateExportData, + generateTextSummary, + type ExportData +} from '@/services/exportService'; +import type { GridfinityResult, PrinterSize } from '@/types'; + +describe('exportService', () => { + describe('generateExportData', () => { + const defaultPrinterSize: PrinterSize = { + x: 256, + y: 256, + z: 256, + }; + + it('should generate export data for baseplates', () => { + const result: GridfinityResult = { + baseplates: { '6x6': 2, '3x3': 1 }, + spacers: {}, + halfSizeBins: {}, + layout: [], + }; + + const exportData = generateExportData(result, defaultPrinterSize, 1); + + expect(exportData.prints).toHaveLength(2); + expect(exportData.prints[0].type).toBe('baseplate'); + expect(exportData.prints[0].quantity).toBe(2); + expect(exportData.prints[1].quantity).toBe(1); + }); + + it('should multiply quantities by number of drawers', () => { + const result: GridfinityResult = { + baseplates: { '6x6': 2 }, + spacers: {}, + halfSizeBins: {}, + layout: [], + }; + + const exportData = generateExportData(result, defaultPrinterSize, 3); + + expect(exportData.prints[0].quantity).toBe(6); + }); + + it('should calculate max stack based on printer Z height', () => { + const result: GridfinityResult = { + baseplates: { '6x6': 1 }, + spacers: {}, + halfSizeBins: {}, + layout: [], + }; + + // Standard Z height should allow many stacks + const exportData = generateExportData(result, defaultPrinterSize, 1); + expect(exportData.prints[0].maxStack).toBeGreaterThan(10); + + // Small Z height should limit stacks + const smallPrinter: PrinterSize = { x: 256, y: 256, z: 50 }; + const smallExportData = generateExportData(result, smallPrinter, 1); + expect(smallExportData.prints[0].maxStack).toBeLessThan(exportData.prints[0].maxStack); + }); + + it('should generate model links for baseplates', () => { + const result: GridfinityResult = { + baseplates: { '6x6': 1 }, + spacers: {}, + halfSizeBins: {}, + layout: [], + }; + + const exportData = generateExportData(result, defaultPrinterSize, 1); + + expect(exportData.prints[0].modelLinks).toHaveLength(3); + expect(exportData.prints[0].modelLinks.map(l => l.platform)).toContain('MakerWorld'); + expect(exportData.prints[0].modelLinks.map(l => l.platform)).toContain('Printables'); + expect(exportData.prints[0].modelLinks.map(l => l.platform)).toContain('Thangs'); + }); + + it('should generate export data for half-size bins', () => { + const result: GridfinityResult = { + baseplates: {}, + spacers: {}, + halfSizeBins: { '1x1': 4, '2x2': 2 }, + layout: [], + }; + + const exportData = generateExportData(result, defaultPrinterSize, 1); + + expect(exportData.prints).toHaveLength(2); + expect(exportData.prints.every(p => p.type === 'half-size')).toBe(true); + }); + + it('should generate export data for spacers', () => { + const result: GridfinityResult = { + baseplates: {}, + spacers: { '25.62mm x 252mm': 1, '42mm x 41.16mm': 2 }, + halfSizeBins: {}, + layout: [], + }; + + const exportData = generateExportData(result, defaultPrinterSize, 1); + + expect(exportData.prints).toHaveLength(2); + expect(exportData.prints.every(p => p.type === 'spacer')).toBe(true); + }); + + it('should calculate recommended stacks correctly', () => { + const result: GridfinityResult = { + baseplates: { '6x6': 25 }, + spacers: {}, + halfSizeBins: {}, + layout: [], + }; + + const exportData = generateExportData(result, defaultPrinterSize, 1); + const print = exportData.prints[0]; + + // With 25 items and max stack of 20, should recommend 2 stacks + expect(print.recommendedStacks).toBe(2); + expect(print.leftoverItems).toBe(5); + }); + + it('should calculate total print jobs', () => { + const result: GridfinityResult = { + baseplates: { '6x6': 2, '3x3': 1 }, + spacers: { '25.62mm x 252mm': 1 }, + halfSizeBins: {}, + layout: [], + }; + + const exportData = generateExportData(result, defaultPrinterSize, 1); + + expect(exportData.totalPrintJobs).toBeGreaterThan(0); + }); + + it('should generate summary text', () => { + const result: GridfinityResult = { + baseplates: { '6x6': 2 }, + spacers: {}, + halfSizeBins: {}, + layout: [], + }; + + const exportData = generateExportData(result, defaultPrinterSize, 1); + + expect(exportData.summary).toContain('Print'); + expect(exportData.summary).toContain('items'); + }); + + it('should return empty prints array for empty result', () => { + const result: GridfinityResult = { + baseplates: {}, + spacers: {}, + halfSizeBins: {}, + layout: [], + }; + + const exportData = generateExportData(result, defaultPrinterSize, 1); + + expect(exportData.prints).toHaveLength(0); + expect(exportData.totalPrintJobs).toBe(0); + }); + }); + + describe('generateTextSummary', () => { + it('should generate readable text summary', () => { + const exportData: ExportData = { + prints: [ + { + id: 'baseplate-6x6', + type: 'baseplate', + width: 6, + height: 6, + quantity: 2, + maxStack: 20, + recommendedStacks: 1, + itemsPerStack: 2, + leftoverItems: 0, + modelLinks: [ + { platform: 'MakerWorld', url: 'https://example.com', description: 'Test' }, + ], + }, + ], + stackingSavingsPercent: 50, + totalPrintJobs: 1, + summary: 'Print 2 total items in 1 print job using stacking.', + }; + + const text = generateTextSummary(exportData); + + expect(text).toContain('Gridfinity Layout Print List'); + expect(text).toContain('Baseplate 6x6'); + expect(text).toContain('Total needed: 2'); + expect(text).toContain('Max per stack: 20'); + expect(text).toContain('MakerWorld'); + }); + + it('should handle leftover items in summary', () => { + const exportData: ExportData = { + prints: [ + { + id: 'baseplate-6x6', + type: 'baseplate', + width: 6, + height: 6, + quantity: 25, + maxStack: 20, + recommendedStacks: 2, + itemsPerStack: 20, + leftoverItems: 5, + modelLinks: [], + }, + ], + stackingSavingsPercent: 92, + totalPrintJobs: 2, + summary: 'Print 25 total items in 2 print jobs using stacking.', + }; + + const text = generateTextSummary(exportData); + + expect(text).toContain('stack(s) of 20'); + expect(text).toContain('stack of 5'); + }); + + it('should handle empty prints', () => { + const exportData: ExportData = { + prints: [], + stackingSavingsPercent: 0, + totalPrintJobs: 0, + summary: 'No items to print.', + }; + + const text = generateTextSummary(exportData); + + expect(text).toContain('Gridfinity Layout Print List'); + expect(text).toContain('No items to print.'); + }); + }); +}); diff --git a/src/services/exportService.ts b/src/services/exportService.ts new file mode 100644 index 0000000..d3bb9aa --- /dev/null +++ b/src/services/exportService.ts @@ -0,0 +1,373 @@ +/** + * Export Service for Gridfinity Space Optimizer + * + * Provides functionality for generating stackable print information + * and links to Gridfinity model repositories. + */ + +import type { PrinterSize, GridfinityResult } from '@/types'; +import { unitMath } from '@/services/unitMath'; +import { FULL_GRID_SIZE } from '@/utils/gridfinityUtils'; + +/** + * Information about a stackable print + */ +export interface StackablePrint { + /** Unique identifier for this print type (e.g., "6x6 baseplate") */ + id: string; + /** Type of item */ + type: 'baseplate' | 'half-size' | 'spacer'; + /** Width in grid units */ + width: number; + /** Height in grid units */ + height: number; + /** Total quantity needed */ + quantity: number; + /** Maximum number that can be stacked based on printer Z height */ + maxStack: number; + /** Recommended number of stacks to print */ + recommendedStacks: number; + /** Items per recommended stack */ + itemsPerStack: number; + /** Leftover items after full stacks */ + leftoverItems: number; + /** Links to model repositories */ + modelLinks: ModelLink[]; +} + +/** + * Link to a model on a repository + */ +export interface ModelLink { + /** Platform name */ + platform: 'MakerWorld' | 'Printables' | 'Thingiverse' | 'Thangs'; + /** URL to the model */ + url: string; + /** Description of the model */ + description: string; +} + +/** + * Export data for the entire layout + */ +export interface ExportData { + /** All stackable prints organized by type */ + prints: StackablePrint[]; + /** Total print time savings estimate (percentage) */ + stackingSavingsPercent: number; + /** Number of separate print jobs required */ + totalPrintJobs: number; + /** Summary text */ + summary: string; +} + +/** + * Estimated height of different Gridfinity components in mm + * These are approximate heights for standard Gridfinity items + */ +const COMPONENT_HEIGHTS: Record = { + baseplate: 5.4, // Standard baseplate height + 'half-size': 5.4, // Half-size bin baseplate height + spacer: 3.0, // Spacer is typically thinner +}; + +/** + * Regex pattern for parsing spacer size strings like "25.62mm x 252mm" + */ +const SPACER_SIZE_PATTERN = /^([\d.]+)mm x ([\d.]+)mm$/; + +/** + * Calculate the optimal items per stack based on quantity and max stack + * @param totalQuantity Total items needed + * @param maxStack Maximum items that can be stacked + * @param leftoverItems Items remaining after full stacks + * @param recommendedStacks Total number of stacks needed + * @returns The number of items to put in each full stack + */ +const calculateItemsPerStack = ( + totalQuantity: number, + maxStack: number, + leftoverItems: number, + recommendedStacks: number +): number => { + const baseItemsPerStack = unitMath.min(maxStack, totalQuantity); + // If there are leftover items and multiple stacks, use max for full stacks + return leftoverItems > 0 && recommendedStacks > 1 ? maxStack : baseItemsPerStack; +}; + +/** + * Calculate stacking metrics for a given quantity and max stack size + */ +const calculateStackingMetrics = ( + totalQuantity: number, + maxStack: number +): { recommendedStacks: number; itemsPerStack: number; leftoverItems: number } => { + const recommendedStacks = Math.ceil(unitMath.divide(totalQuantity, maxStack)); + const leftoverItems = unitMath.mod(totalQuantity, maxStack); + const itemsPerStack = calculateItemsPerStack(totalQuantity, maxStack, leftoverItems, recommendedStacks); + + return { recommendedStacks, itemsPerStack, leftoverItems }; +}; + +/** + * Generate model repository links for a given baseplate size + */ +const generateModelLinks = ( + type: 'baseplate' | 'half-size' | 'spacer', + width: number, + height: number +): ModelLink[] => { + const links: ModelLink[] = []; + + if (type === 'baseplate') { + // Standard Gridfinity baseplates - link to common repositories + links.push({ + platform: 'MakerWorld', + url: `https://makerworld.com/en/search/models?keyword=gridfinity%20baseplate%20${width}x${height}`, + description: `Search for ${width}x${height} Gridfinity baseplate on MakerWorld`, + }); + links.push({ + platform: 'Printables', + url: `https://www.printables.com/search/models?q=gridfinity%20baseplate%20${width}x${height}`, + description: `Search for ${width}x${height} Gridfinity baseplate on Printables`, + }); + links.push({ + platform: 'Thangs', + url: `https://thangs.com/search/gridfinity%20baseplate%20${width}x${height}`, + description: `Search for ${width}x${height} Gridfinity baseplate on Thangs`, + }); + } else if (type === 'half-size') { + // Half-size Gridfinity bins + links.push({ + platform: 'MakerWorld', + url: `https://makerworld.com/en/search/models?keyword=gridfinity%20half%20size%20${width}x${height}`, + description: `Search for ${width}x${height} half-size Gridfinity bins on MakerWorld`, + }); + links.push({ + platform: 'Printables', + url: `https://www.printables.com/search/models?q=gridfinity%20half%20size%20${width}x${height}`, + description: `Search for ${width}x${height} half-size Gridfinity bins on Printables`, + }); + } else { + // Spacers - these are typically custom, so provide general search + links.push({ + platform: 'Printables', + url: 'https://www.printables.com/search/models?q=gridfinity%20spacer', + description: 'Search for Gridfinity spacers on Printables', + }); + } + + return links; +}; + +/** + * Calculate the maximum number of items that can be stacked + * based on printer Z height and component height + */ +const calculateMaxStack = ( + printerZ: number, + componentType: 'baseplate' | 'half-size' | 'spacer' +): number => { + const itemHeight = COMPONENT_HEIGHTS[componentType] || 5.4; + + // Leave some margin for adhesion and safety (5mm) + const usableZ = unitMath.subtract(printerZ, 5); + + // Calculate max items that fit by height + const maxByHeight = unitMath.floor(unitMath.divide(usableZ, itemHeight)); + + // Practical limit - don't stack too many to avoid print failures + const practicalLimit = 20; + + return Math.min(maxByHeight, practicalLimit); +}; + +/** + * Generate export data for stackable prints + */ +export const generateExportData = ( + result: GridfinityResult, + printerSize: PrinterSize, + numDrawers: number +): ExportData => { + const prints: StackablePrint[] = []; + + // Process baseplates + Object.entries(result.baseplates).forEach(([size, count]) => { + const [widthStr, heightStr] = size.split('x'); + const width = parseFloat(widthStr); + const height = parseFloat(heightStr); + const totalQuantity = unitMath.multiply(count, numDrawers); + + const maxStack = calculateMaxStack(printerSize.z, 'baseplate'); + const metrics = calculateStackingMetrics(totalQuantity, maxStack); + + prints.push({ + id: `baseplate-${size}`, + type: 'baseplate', + width, + height, + quantity: totalQuantity, + maxStack, + ...metrics, + modelLinks: generateModelLinks('baseplate', width, height), + }); + }); + + // Process half-size bins + Object.entries(result.halfSizeBins).forEach(([size, count]) => { + const [widthStr, heightStr] = size.split('x'); + const width = parseFloat(widthStr); + const height = parseFloat(heightStr); + const totalQuantity = unitMath.multiply(count, numDrawers); + + const maxStack = calculateMaxStack(printerSize.z, 'half-size'); + const metrics = calculateStackingMetrics(totalQuantity, maxStack); + + prints.push({ + id: `half-size-${size}`, + type: 'half-size', + width, + height, + quantity: totalQuantity, + maxStack, + ...metrics, + modelLinks: generateModelLinks('half-size', width, height), + }); + }); + + // Process spacers + Object.entries(result.spacers).forEach(([size, count]) => { + // Parse size using the documented pattern (e.g., "25.62mm x 252mm") + const match = size.match(SPACER_SIZE_PATTERN); + if (!match) return; + + const widthMm = parseFloat(match[1]); + const heightMm = parseFloat(match[2]); + const totalQuantity = unitMath.multiply(count, numDrawers); + + const maxStack = calculateMaxStack(printerSize.z, 'spacer'); + const metrics = calculateStackingMetrics(totalQuantity, maxStack); + + prints.push({ + id: `spacer-${size}`, + type: 'spacer', + width: unitMath.round(unitMath.divide(widthMm, FULL_GRID_SIZE), 2), + height: unitMath.round(unitMath.divide(heightMm, FULL_GRID_SIZE), 2), + quantity: totalQuantity, + maxStack, + ...metrics, + modelLinks: generateModelLinks('spacer', widthMm, heightMm), + }); + }); + + // Calculate total print jobs and stacking savings + const totalItems = prints.reduce((acc, p) => unitMath.add(acc, p.quantity), 0); + const totalPrintJobs = prints.reduce((acc, p) => unitMath.add(acc, p.recommendedStacks), 0); + + // Estimate savings: if we print items individually vs stacked + // Stacking saves on bed adhesion/first layer time and reduces job management + const stackingSavingsPercent = totalItems > 0 + ? unitMath.round(unitMath.multiply(unitMath.divide(unitMath.subtract(totalItems, totalPrintJobs), totalItems), 100), 1) + : 0; + + const summary = generateSummary(prints, totalPrintJobs, stackingSavingsPercent); + + return { + prints, + stackingSavingsPercent, + totalPrintJobs, + summary, + }; +}; + +/** + * Generate a human-readable summary of the export data + */ +const generateSummary = ( + prints: StackablePrint[], + totalPrintJobs: number, + savingsPercent: number +): string => { + if (prints.length === 0) { + return 'No items to print.'; + } + + const totalItems = prints.reduce((acc, p) => unitMath.add(acc, p.quantity), 0); + + let summary = `Print ${totalItems} total items in ${totalPrintJobs} print job${totalPrintJobs !== 1 ? 's' : ''} using stacking. `; + + if (savingsPercent > 0) { + summary += `This saves approximately ${savingsPercent}% compared to printing each item individually.`; + } + + return summary; +}; + +/** + * Generate a shareable text summary of the print list + */ +export const generateTextSummary = (exportData: ExportData): string => { + const lines: string[] = [ + '=== Gridfinity Layout Print List ===', + '', + exportData.summary, + '', + '--- Print Jobs (Stacked) ---', + '', + ]; + + exportData.prints.forEach((print) => { + const typeLabel = print.type === 'baseplate' ? 'Baseplate' + : print.type === 'half-size' ? 'Half-size Bin' + : 'Spacer'; + + lines.push(`${typeLabel} ${print.width}x${print.height}:`); + lines.push(` Total needed: ${print.quantity}`); + lines.push(` Max per stack: ${print.maxStack}`); + + if (print.leftoverItems > 0 && print.recommendedStacks > 1) { + lines.push(` Print ${print.recommendedStacks - 1} stack(s) of ${print.itemsPerStack}, then 1 stack of ${print.leftoverItems}`); + } else { + lines.push(` Print ${print.recommendedStacks} stack(s) of ${print.itemsPerStack}`); + } + + if (print.modelLinks.length > 0) { + lines.push(' Find models at:'); + print.modelLinks.forEach((link) => { + lines.push(` - ${link.platform}: ${link.url}`); + }); + } + lines.push(''); + }); + + lines.push('=== End of Print List ==='); + + return lines.join('\n'); +}; + +/** + * Copy text to clipboard + */ +export const copyToClipboard = async (text: string): Promise => { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Fallback for older browsers + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + try { + document.execCommand('copy'); + return true; + } catch { + return false; + } finally { + document.body.removeChild(textarea); + } + } +};