Files
CutList/CutList.Mcp/CutListTools.cs
AJ Isaacs b19ecf3610 refactor: Redesign nesting engines with pipeline pattern and add exhaustive search
- Rename Result to PackResult to avoid confusion with Result<T>
- Add PackingRequest as immutable configuration replacing mutable engine state
- Add PackingStrategy enum (AdvancedFit, BestFit, Exhaustive)
- Implement pipeline pattern for composable packing steps
- Rewrite AdvancedFitEngine as stateless using pipeline
- Rewrite BestFitEngine as stateless
- Add ExhaustiveFitEngine with symmetry breaking for optimal solutions
  - Tries all bin assignments to find minimum bins
  - Falls back to AdvancedFit for >20 items
  - Configurable threshold via constructor
- Update IEngine/IEngineFactory interfaces for new pattern
- Add strategy parameter to MCP tools

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 15:16:40 -05:00

291 lines
12 KiB
C#

using System.ComponentModel;
using CutList.Core;
using CutList.Core.Formatting;
using CutList.Core.Nesting;
using ModelContextProtocol.Server;
namespace CutList.Mcp;
/// <summary>
/// MCP tools for cut list optimization.
/// </summary>
[McpServerToolType]
public static class CutListTools
{
/// <summary>
/// Creates an optimized cut list by packing parts into stock bins using a first-fit decreasing algorithm.
/// Returns the optimal arrangement of cuts to minimize waste.
/// </summary>
/// <param name="parts">List of parts to cut. Each part has a name, length (in inches or architectural format like 12' 6"), and quantity.</param>
/// <param name="stockBins">List of available stock material. Each has a length, quantity (-1 for unlimited), and priority (lower = used first).</param>
/// <param name="kerf">Blade/cutting width in inches. Default is 0.125 (1/8"). This accounts for material lost to the cut itself.</param>
/// <returns>Optimized cut list showing which parts go in which bins, utilization percentages, and any parts that couldn't fit.</returns>
[McpServerTool(Name = "create_cutlist"), Description("Creates an optimized cut list by packing parts into stock bins to minimize waste.")]
public static CutListResult CreateCutList(
[Description("Parts to cut. Each object needs: name (string), length (string like \"12'\" or \"36\\\"\" or \"12.5\"), quantity (int)")]
PartInput[] parts,
[Description("Stock bins available. Each needs: length (string), quantity (int, use -1 for unlimited), priority (int, lower = used first, default 25)")]
StockBinInput[] stockBins,
[Description("Blade kerf/width in inches (default 0.125)")]
double kerf = 0.125,
[Description("Packing strategy: 'advanced' (default), 'bestfit', or 'exhaustive' (optimal but slow, max 15 items)")]
string strategy = "advanced")
{
try
{
var (binItems, partsError) = ConvertParts(parts);
if (partsError != null)
return new CutListResult { Success = false, Error = partsError };
var (multiBins, binsError) = ConvertStockBins(stockBins);
if (binsError != null)
return new CutListResult { Success = false, Error = binsError };
var packResult = RunPackingAlgorithm(binItems!, multiBins!, kerf, ParseStrategy(strategy));
// Convert results
var resultBins = new List<ResultBin>();
foreach (var bin in packResult.Bins)
{
var resultBin = new ResultBin
{
Length = FormatLength(bin.Length),
LengthInches = bin.Length,
UsedLength = FormatLength(bin.UsedLength),
UsedLengthInches = bin.UsedLength,
RemainingLength = FormatLength(bin.RemainingLength),
RemainingLengthInches = bin.RemainingLength,
Utilization = Math.Round(bin.Utilization * 100, 2),
Items = bin.Items.Select(item => new ResultItem
{
Name = item.Name,
Length = FormatLength(item.Length),
LengthInches = item.Length
}).ToList()
};
resultBins.Add(resultBin);
}
var unusedItems = packResult.ItemsNotUsed.Select(item => new ResultItem
{
Name = item.Name,
Length = FormatLength(item.Length),
LengthInches = item.Length
}).ToList();
// Calculate summary statistics
double totalStockUsed = packResult.Bins.Sum(b => b.Length);
double totalMaterialUsed = packResult.Bins.Sum(b => b.UsedLength);
double totalWaste = packResult.Bins.Sum(b => b.RemainingLength);
double overallUtilization = totalStockUsed > 0 ? (totalMaterialUsed / totalStockUsed) * 100 : 0;
return new CutListResult
{
Success = true,
Bins = resultBins,
UnusedItems = unusedItems,
Summary = new CutListSummary
{
TotalBinsUsed = packResult.Bins.Count,
TotalPartsPlaced = binItems!.Count - packResult.ItemsNotUsed.Count,
TotalPartsNotPlaced = packResult.ItemsNotUsed.Count,
TotalStockLength = FormatLength(totalStockUsed),
TotalStockLengthInches = totalStockUsed,
TotalWaste = FormatLength(totalWaste),
TotalWasteInches = totalWaste,
OverallUtilization = Math.Round(overallUtilization, 2)
}
};
}
catch (Exception ex)
{
return new CutListResult
{
Success = false,
Error = ex.Message
};
}
}
/// <summary>
/// Parses a length string in architectural format (feet/inches/fractions) to inches.
/// </summary>
/// <param name="input">Length string like "12'", "6\"", "12' 6\"", "12.5", "6 1/2\"", etc.</param>
/// <returns>The length in inches, or an error message if parsing fails.</returns>
[McpServerTool(Name = "parse_length"), Description("Parses an architectural length string (feet/inches/fractions) to decimal inches.")]
public static ParseLengthResult ParseLengthString(
[Description("Length string like \"12'\", \"6\\\"\", \"12' 6\\\"\", \"12.5\", \"6 1/2\\\"\"")]
string input)
{
try
{
double inches = ParseLength(input);
return new ParseLengthResult
{
Success = true,
Inches = inches,
Formatted = FormatLength(inches)
};
}
catch (Exception ex)
{
return new ParseLengthResult
{
Success = false,
Error = ex.Message
};
}
}
/// <summary>
/// Formats a length in inches to a human-readable string with feet and fractional inches.
/// </summary>
/// <param name="inches">Length in inches.</param>
/// <returns>Formatted string like "12' 6 1/2\"".</returns>
[McpServerTool(Name = "format_length"), Description("Formats a length in inches to feet and fractional inches.")]
public static FormatLengthResult FormatLengthString(
[Description("Length in inches")]
double inches)
{
try
{
return new FormatLengthResult
{
Success = true,
Formatted = FormatLength(inches),
Inches = inches
};
}
catch (Exception ex)
{
return new FormatLengthResult
{
Success = false,
Error = ex.Message
};
}
}
/// <summary>
/// Creates an optimized cut list and saves a formatted text report to a file.
/// </summary>
[McpServerTool(Name = "create_cutlist_report"), Description("Creates an optimized cut list and saves a formatted printable text report to a file. Returns the file path.")]
public static CutListReportResult CreateCutListReport(
[Description("Parts to cut. Each object needs: name (string), length (string like \"12'\" or \"36\\\"\" or \"12.5\"), quantity (int)")]
PartInput[] parts,
[Description("Stock bins available. Each needs: length (string), quantity (int, use -1 for unlimited), priority (int, lower = used first, default 25)")]
StockBinInput[] stockBins,
[Description("Blade kerf/width in inches (default 0.125)")]
double kerf = 0.125,
[Description("File path to save the report. If not provided, saves to a temp file.")]
string? filePath = null,
[Description("Packing strategy: 'advanced' (default), 'bestfit', or 'exhaustive' (optimal but slow, max 15 items)")]
string strategy = "advanced")
{
try
{
var (binItems, partsError) = ConvertParts(parts);
if (partsError != null)
return new CutListReportResult { Success = false, Error = partsError };
var (multiBins, binsError) = ConvertStockBins(stockBins);
if (binsError != null)
return new CutListReportResult { Success = false, Error = binsError };
var packResult = RunPackingAlgorithm(binItems!, multiBins!, kerf, ParseStrategy(strategy));
// Determine file path
var outputPath = string.IsNullOrWhiteSpace(filePath)
? Path.Combine(Path.GetTempPath(), $"cutlist_{DateTime.Now:yyyyMMdd_HHmmss}.txt")
: filePath;
// Save using BinFileSaver
var saver = new BinFileSaver(packResult.Bins);
saver.SaveBinsToFile(outputPath);
return new CutListReportResult
{
Success = true,
FilePath = Path.GetFullPath(outputPath),
TotalBins = packResult.Bins.Count,
TotalParts = binItems!.Count - packResult.ItemsNotUsed.Count,
PartsNotPlaced = packResult.ItemsNotUsed.Count
};
}
catch (Exception ex)
{
return new CutListReportResult
{
Success = false,
Error = ex.Message
};
}
}
private static (List<BinItem>? Items, string? Error) ConvertParts(PartInput[] parts)
{
var binItems = new List<BinItem>();
foreach (var part in parts)
{
double length = ParseLength(part.Length);
if (length <= 0)
return (null, $"Invalid part length: {part.Length} for part '{part.Name}'");
for (int i = 0; i < part.Quantity; i++)
binItems.Add(new BinItem(part.Name, length));
}
return (binItems, null);
}
private static (List<MultiBin>? Bins, string? Error) ConvertStockBins(StockBinInput[] stockBins)
{
var multiBins = new List<MultiBin>();
foreach (var bin in stockBins)
{
double length = ParseLength(bin.Length);
if (length <= 0)
return (null, $"Invalid bin length: {bin.Length}");
multiBins.Add(new MultiBin(length, bin.Quantity, bin.Priority));
}
return (multiBins, null);
}
private static PackResult RunPackingAlgorithm(List<BinItem> items, List<MultiBin> bins, double kerf, PackingStrategy packingStrategy = PackingStrategy.AdvancedFit)
{
var engine = new MultiBinEngine();
engine.SetBins(bins);
engine.Spacing = kerf;
engine.Strategy = packingStrategy;
return engine.Pack(items);
}
private static PackingStrategy ParseStrategy(string strategy)
{
return strategy?.ToLowerInvariant() switch
{
"bestfit" or "best" => PackingStrategy.BestFit,
"exhaustive" or "optimal" => PackingStrategy.Exhaustive,
_ => PackingStrategy.AdvancedFit
};
}
private static double ParseLength(string input)
{
if (string.IsNullOrWhiteSpace(input))
return 0;
// Try parsing as a plain number first
if (double.TryParse(input.Trim(), out double plainNumber))
return plainNumber;
// Try architectural format
return ArchUnits.ParseToInches(input);
}
private static string FormatLength(double inches)
{
return ArchUnits.FormatFromInches(inches);
}
}