From ff8a9869d1fc314f72a29f6863efd5a0cec3f909 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 25 Nov 2025 17:49:16 +0000
Subject: [PATCH 1/3] Initial plan
From 8956587090b6bb7a688222f8284e76c21d992d23 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 25 Nov 2025 18:04:57 +0000
Subject: [PATCH 2/3] Add export stackable prints feature with links to model
repositories
Co-authored-by: ntindle <8845353+ntindle@users.noreply.github.com>
---
src/components/Features.tsx | 2 +-
src/components/GridfinityCalculator.tsx | 15 +
.../GridfinityCalculator/ExportPanel.tsx | 232 ++++++++++++
src/services/exportService.test.ts | 240 ++++++++++++
src/services/exportService.ts | 357 ++++++++++++++++++
5 files changed, 845 insertions(+), 1 deletion(-)
create mode 100644 src/components/GridfinityCalculator/ExportPanel.tsx
create mode 100644 src/services/exportService.test.ts
create mode 100644 src/services/exportService.ts
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..b4a880b 100644
--- a/src/components/GridfinityCalculator.tsx
+++ b/src/components/GridfinityCalculator.tsx
@@ -4,12 +4,14 @@ 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";
import { useGridfinityCalculation } from "@/hooks/useGridfinityCalculation";
import { useLegacyMigration } from "@/hooks/useLegacyMigration";
import { saveUserSettings, loadUserSettings } from "@/lib/utils";
+import { calculateGrids } from "@/utils/gridfinityUtils";
const GridfinityCalculator: React.FC = () => {
// Migrate legacy data first
@@ -129,6 +131,19 @@ 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 && (
+
+ )}
+
+ );
+};
+
+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..70b8431
--- /dev/null
+++ b/src/services/exportService.ts
@@ -0,0 +1,357 @@
+/**
+ * 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
+};
+
+/**
+ * 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');
+
+ // Calculate recommended stacking
+ const recommendedStacks = Math.ceil(unitMath.divide(totalQuantity, maxStack));
+ const itemsPerStack = unitMath.min(maxStack, totalQuantity);
+ const leftoverItems = unitMath.mod(totalQuantity, maxStack);
+
+ prints.push({
+ id: `baseplate-${size}`,
+ type: 'baseplate',
+ width,
+ height,
+ quantity: totalQuantity,
+ maxStack,
+ recommendedStacks,
+ itemsPerStack: leftoverItems > 0 && recommendedStacks > 1
+ ? maxStack
+ : itemsPerStack,
+ leftoverItems,
+ 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 recommendedStacks = Math.ceil(unitMath.divide(totalQuantity, maxStack));
+ const itemsPerStack = unitMath.min(maxStack, totalQuantity);
+ const leftoverItems = unitMath.mod(totalQuantity, maxStack);
+
+ prints.push({
+ id: `half-size-${size}`,
+ type: 'half-size',
+ width,
+ height,
+ quantity: totalQuantity,
+ maxStack,
+ recommendedStacks,
+ itemsPerStack: leftoverItems > 0 && recommendedStacks > 1
+ ? maxStack
+ : itemsPerStack,
+ leftoverItems,
+ modelLinks: generateModelLinks('half-size', width, height),
+ });
+ });
+
+ // Process spacers
+ Object.entries(result.spacers).forEach(([size, count]) => {
+ // Parse size like "25.62mm x 252mm"
+ const match = size.match(/^([\d.]+)mm x ([\d.]+)mm$/);
+ 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 recommendedStacks = Math.ceil(unitMath.divide(totalQuantity, maxStack));
+ const itemsPerStack = unitMath.min(maxStack, totalQuantity);
+ const leftoverItems = unitMath.mod(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,
+ recommendedStacks,
+ itemsPerStack: leftoverItems > 0 && recommendedStacks > 1
+ ? maxStack
+ : itemsPerStack,
+ leftoverItems,
+ 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);
+ }
+ }
+};
From d2bd802e359662bd0e7f66afa1626dfd1ed4733d Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 25 Nov 2025 18:11:51 +0000
Subject: [PATCH 3/3] Refactor export service to reduce code duplication
Co-authored-by: ntindle <8845353+ntindle@users.noreply.github.com>
---
src/components/GridfinityCalculator.tsx | 14 ++---
src/services/exportService.ts | 76 +++++++++++++++----------
2 files changed, 52 insertions(+), 38 deletions(-)
diff --git a/src/components/GridfinityCalculator.tsx b/src/components/GridfinityCalculator.tsx
index b4a880b..60ea61b 100644
--- a/src/components/GridfinityCalculator.tsx
+++ b/src/components/GridfinityCalculator.tsx
@@ -11,7 +11,6 @@ import { useSettings } from "@/contexts/SettingsContext";
import { useGridfinityCalculation } from "@/hooks/useGridfinityCalculation";
import { useLegacyMigration } from "@/hooks/useLegacyMigration";
import { saveUserSettings, loadUserSettings } from "@/lib/utils";
-import { calculateGrids } from "@/utils/gridfinityUtils";
const GridfinityCalculator: React.FC = () => {
// Migrate legacy data first
@@ -133,13 +132,12 @@ const GridfinityCalculator: React.FC = () => {
)}
{settings.drawerSize && (
diff --git a/src/services/exportService.ts b/src/services/exportService.ts
index 70b8431..d3bb9aa 100644
--- a/src/services/exportService.ts
+++ b/src/services/exportService.ts
@@ -71,6 +71,44 @@ const COMPONENT_HEIGHTS: Record = {
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
*/
@@ -162,11 +200,7 @@ export const generateExportData = (
const totalQuantity = unitMath.multiply(count, numDrawers);
const maxStack = calculateMaxStack(printerSize.z, 'baseplate');
-
- // Calculate recommended stacking
- const recommendedStacks = Math.ceil(unitMath.divide(totalQuantity, maxStack));
- const itemsPerStack = unitMath.min(maxStack, totalQuantity);
- const leftoverItems = unitMath.mod(totalQuantity, maxStack);
+ const metrics = calculateStackingMetrics(totalQuantity, maxStack);
prints.push({
id: `baseplate-${size}`,
@@ -175,11 +209,7 @@ export const generateExportData = (
height,
quantity: totalQuantity,
maxStack,
- recommendedStacks,
- itemsPerStack: leftoverItems > 0 && recommendedStacks > 1
- ? maxStack
- : itemsPerStack,
- leftoverItems,
+ ...metrics,
modelLinks: generateModelLinks('baseplate', width, height),
});
});
@@ -192,10 +222,7 @@ export const generateExportData = (
const totalQuantity = unitMath.multiply(count, numDrawers);
const maxStack = calculateMaxStack(printerSize.z, 'half-size');
-
- const recommendedStacks = Math.ceil(unitMath.divide(totalQuantity, maxStack));
- const itemsPerStack = unitMath.min(maxStack, totalQuantity);
- const leftoverItems = unitMath.mod(totalQuantity, maxStack);
+ const metrics = calculateStackingMetrics(totalQuantity, maxStack);
prints.push({
id: `half-size-${size}`,
@@ -204,19 +231,15 @@ export const generateExportData = (
height,
quantity: totalQuantity,
maxStack,
- recommendedStacks,
- itemsPerStack: leftoverItems > 0 && recommendedStacks > 1
- ? maxStack
- : itemsPerStack,
- leftoverItems,
+ ...metrics,
modelLinks: generateModelLinks('half-size', width, height),
});
});
// Process spacers
Object.entries(result.spacers).forEach(([size, count]) => {
- // Parse size like "25.62mm x 252mm"
- const match = size.match(/^([\d.]+)mm x ([\d.]+)mm$/);
+ // 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]);
@@ -224,10 +247,7 @@ export const generateExportData = (
const totalQuantity = unitMath.multiply(count, numDrawers);
const maxStack = calculateMaxStack(printerSize.z, 'spacer');
-
- const recommendedStacks = Math.ceil(unitMath.divide(totalQuantity, maxStack));
- const itemsPerStack = unitMath.min(maxStack, totalQuantity);
- const leftoverItems = unitMath.mod(totalQuantity, maxStack);
+ const metrics = calculateStackingMetrics(totalQuantity, maxStack);
prints.push({
id: `spacer-${size}`,
@@ -236,11 +256,7 @@ export const generateExportData = (
height: unitMath.round(unitMath.divide(heightMm, FULL_GRID_SIZE), 2),
quantity: totalQuantity,
maxStack,
- recommendedStacks,
- itemsPerStack: leftoverItems > 0 && recommendedStacks > 1
- ? maxStack
- : itemsPerStack,
- leftoverItems,
+ ...metrics,
modelLinks: generateModelLinks('spacer', widthMm, heightMm),
});
});