Rename SawCut library to CutList.Core

Rename the core library project from SawCut to CutList.Core for consistent
branding across the solution. This includes:
- Rename project folder and .csproj file
- Update namespace from SawCut to CutList.Core
- Update all using statements and project references

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-28 12:31:30 -05:00
parent c612a40a46
commit f25e31698f
30 changed files with 36 additions and 36 deletions

75
CutList.Core/ArchUnits.cs Normal file
View File

@@ -0,0 +1,75 @@
using System;
using System.Text;
using System.Text.RegularExpressions;
namespace CutList.Core
{
public static class ArchUnits
{
public static double ParseToInches(string input)
{
if (string.IsNullOrWhiteSpace(input))
return 0;
var sb = new StringBuilder(input.Trim().ToLower());
// replace all units with their equivelant symbols
sb.Replace("ft", "'");
sb.Replace("feet", "'");
sb.Replace("foot", "'");
sb.Replace("inches", "\"");
sb.Replace("inch", "\"");
sb.Replace("in", "\"");
var regex = new Regex("^(?<Feet>\\d+\\.?\\d*\\s*')?\\s*(?<Inches>\\d+\\.?\\d*\\s*\")?$");
// input manipulation is done, put the value back
input = Fraction.ReplaceFractionsWithDecimals(sb.ToString());
var match2 = regex.Match(input);
if (!match2.Success)
throw new Exception("Input is not in a valid format.");
var feet = match2.Groups["Feet"];
var inches = match2.Groups["Inches"];
var totalInches = 0.0;
if (feet.Success)
{
var x = double.Parse(feet.Value.Remove(feet.Length - 1));
totalInches += x * 12;
}
if (inches.Success)
{
var x = double.Parse(inches.Value.Remove(inches.Length - 1));
totalInches += x;
}
return Math.Round(totalInches, 8);
}
public static double ParseToFeet(string input)
{
var inches = ParseToInches(input);
return Math.Round(inches / 12.0, 8);
}
public static string FormatFromInches(double totalInches)
{
var feet = Math.Floor(totalInches / 12.0);
var inches = FormatHelper.ConvertToMixedFraction(totalInches - (feet * 12.0));
if (feet > 0)
{
return $"{feet}' {inches}\"";
}
else
{
return $"{inches}\"";
}
}
}
}

View File

@@ -0,0 +1,112 @@
using System;
namespace CutList.Core
{
public static class BestCombination
{
public static BestComboResult FindFrom2(double length1, double length2, double stockLength)
{
var result = InitializeResult(length1, length2);
bool item1Fits = length1 <= stockLength;
bool item2Fits = length2 <= stockLength;
if (!item1Fits && !item2Fits)
return null;
if (!item1Fits)
return CalculateSingleItemResult(result, length2, stockLength, false);
if (!item2Fits)
return CalculateSingleItemResult(result, length1, stockLength, true);
return CalculateOptimalCombination(result, length1, length2, stockLength);
}
private static BestComboResult InitializeResult(double length1, double length2)
{
return new BestComboResult
{
Item1Length = length1,
Item2Length = length2
};
}
private static BestComboResult CalculateSingleItemResult(BestComboResult result, double length, double stockLength, bool isItem1)
{
if (isItem1)
{
result.Item1Count = (int)Math.Floor(stockLength / length);
result.Item2Count = 0;
}
else
{
result.Item1Count = 0;
result.Item2Count = (int)Math.Floor(stockLength / length);
}
return result;
}
private static BestComboResult CalculateOptimalCombination(BestComboResult result, double length1, double length2, double stockLength)
{
var maxCountLength1 = (int)Math.Floor(stockLength / length1);
result.Item1Count = maxCountLength1;
result.Item2Count = 0;
var remnant = stockLength - maxCountLength1 * length1;
for (int countLength1 = 0; countLength1 <= maxCountLength1; ++countLength1)
{
var remnant1 = stockLength - countLength1 * length1;
if (remnant1 >= length2)
remnant = UpdateResultForTwoItems(result, length2, remnant1, remnant, countLength1);
else
remnant = UpdateResultForOneItem(result, remnant1, remnant, countLength1);
if (remnant.IsEqualTo(0))
break;
}
return result;
}
private static double UpdateResultForTwoItems(BestComboResult result, double length2, double remnant1, double currentRemnant, int countLength1)
{
var countLength2 = (int)Math.Floor(remnant1 / length2);
var remnant2 = remnant1 - countLength2 * length2;
if (remnant2 < currentRemnant)
{
result.Item1Count = countLength1;
result.Item2Count = countLength2;
return remnant2;
}
return currentRemnant;
}
private static double UpdateResultForOneItem(BestComboResult result, double remnant1, double currentRemnant, int countLength1)
{
if (remnant1 < currentRemnant)
{
result.Item1Count = countLength1;
result.Item2Count = 0;
return remnant1;
}
return currentRemnant;
}
}
public class BestComboResult
{
public double Item1Length { get; set; }
public int Item1Count { get; set; }
public double Item1TotalLength => Item1Length * Item1Count;
public double Item2Length { get; set; }
public int Item2Count { get; set; }
public double Item2TotalLength => Item2Length * Item2Count;
}
}

79
CutList.Core/Bin.cs Normal file
View File

@@ -0,0 +1,79 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace CutList.Core
{
public class Bin
{
private readonly List<BinItem> _items;
public Bin(double length)
{
_items = new List<BinItem>();
Length = length;
}
public IReadOnlyList<BinItem> Items => _items;
public void AddItem(BinItem item)
{
_items.Add(item);
}
public void AddItems(IEnumerable<BinItem> items)
{
_items.AddRange(items);
}
public void RemoveItem(BinItem item)
{
_items.Remove(item);
}
public void SortItems(Comparison<BinItem> comparison)
{
_items.Sort(comparison);
}
public double Spacing { get; set; }
public double Length { get; set; }
public double UsedLength
{
get
{
var usedLength = Math.Round(Items.Sum(i => i.Length) + Spacing * Items.Count, 8);
if (usedLength > Length && (usedLength - Length) <= Spacing)
return Length;
return Math.Round(Items.Sum(i => i.Length) + Spacing * Items.Count, 8);
}
}
public double RemainingLength
{
get { return Math.Round(Length - UsedLength, 8); }
}
/// <summary>
/// Returns a ratio of UsedLength to TotalLength
/// 1.0 = 100% utilization
/// </summary>
public double Utilization
{
get { return UsedLength / Length; }
}
public override string ToString()
{
var totalLength = FormatHelper.ConvertToMixedFraction(Math.Round(Length, 4));
var remainingLength = FormatHelper.ConvertToMixedFraction(Math.Round(RemainingLength, 4));
var utilitation = Math.Round(Utilization * 100, 2);
return $"Length: {totalLength}, {remainingLength} remaining, {Items.Count} items, {utilitation}% utilization";
}
}
}

113
CutList.Core/BinComparer.cs Normal file
View File

@@ -0,0 +1,113 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace CutList.Core
{
/// <summary>
/// Compares bins to determine if they are identical (same items in same order).
/// </summary>
public class BinComparer : IEqualityComparer<Bin>
{
public bool Equals(Bin x, Bin y)
{
if (ReferenceEquals(x, y)) return true;
if (x == null || y == null) return false;
// Check basic properties
if (Math.Abs(x.Length - y.Length) > 0.0001) return false;
if (Math.Abs(x.Spacing - y.Spacing) > 0.0001) return false;
// Check item count
if (x.Items.Count != y.Items.Count) return false;
// Check each item in order
for (int i = 0; i < x.Items.Count; i++)
{
var itemX = x.Items[i];
var itemY = y.Items[i];
if (itemX.Name != itemY.Name) return false;
if (Math.Abs(itemX.Length - itemY.Length) > 0.0001) return false;
}
return true;
}
public int GetHashCode(Bin bin)
{
if (bin == null) return 0;
unchecked
{
int hash = 17;
hash = hash * 23 + bin.Length.GetHashCode();
hash = hash * 23 + bin.Spacing.GetHashCode();
hash = hash * 23 + bin.Items.Count.GetHashCode();
// Include first and last item in hash for better distribution
if (bin.Items.Count > 0)
{
var firstItem = bin.Items[0];
hash = hash * 23 + (firstItem.Name?.GetHashCode() ?? 0);
hash = hash * 23 + firstItem.Length.GetHashCode();
if (bin.Items.Count > 1)
{
var lastItem = bin.Items[bin.Items.Count - 1];
hash = hash * 23 + (lastItem.Name?.GetHashCode() ?? 0);
hash = hash * 23 + lastItem.Length.GetHashCode();
}
}
return hash;
}
}
}
/// <summary>
/// Helper methods for grouping bins.
/// </summary>
public static class BinGroupingHelper
{
/// <summary>
/// Groups identical bins together and returns a list of BinGroup objects.
/// </summary>
public static List<BinGroup> GroupIdenticalBins(List<Bin> bins)
{
if (bins == null || bins.Count == 0)
return new List<BinGroup>();
var comparer = new BinComparer();
var groups = new List<BinGroup>();
var processed = new HashSet<int>();
for (int i = 0; i < bins.Count; i++)
{
if (processed.Contains(i))
continue;
var currentBin = bins[i];
int count = 1;
// Find all identical bins
for (int j = i + 1; j < bins.Count; j++)
{
if (processed.Contains(j))
continue;
if (comparer.Equals(currentBin, bins[j]))
{
count++;
processed.Add(j);
}
}
processed.Add(i);
groups.Add(new BinGroup(currentBin, count));
}
return groups;
}
}
}

51
CutList.Core/BinGroup.cs Normal file
View File

@@ -0,0 +1,51 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace CutList.Core
{
/// <summary>
/// Represents a group of identical bins (bins with the same items in the same order).
/// Used for displaying consolidated results.
/// </summary>
public class BinGroup
{
public BinGroup(Bin representativeBin, int count)
{
if (representativeBin == null)
throw new ArgumentNullException(nameof(representativeBin));
if (count <= 0)
throw new ArgumentException("Count must be greater than zero", nameof(count));
RepresentativeBin = representativeBin;
Count = count;
}
/// <summary>
/// A representative bin from this group (all bins in the group are identical).
/// </summary>
public Bin RepresentativeBin { get; }
/// <summary>
/// The number of identical bins in this group.
/// </summary>
public int Count { get; }
// Properties that delegate to the representative bin for data binding
public double Spacing => RepresentativeBin.Spacing;
public double Length => RepresentativeBin.Length;
public double UsedLength => RepresentativeBin.UsedLength;
public double RemainingLength => RepresentativeBin.RemainingLength;
public double Utilization => RepresentativeBin.Utilization;
public IReadOnlyList<BinItem> Items => RepresentativeBin.Items;
public override string ToString()
{
if (Count == 1)
return RepresentativeBin.ToString();
return $"{RepresentativeBin} (x{Count})";
}
}
}

97
CutList.Core/BinItem.cs Normal file
View File

@@ -0,0 +1,97 @@
using System;
namespace CutList.Core
{
/// <summary>
/// Represents an item to be placed in a bin.
/// Enforces business rules for valid items.
/// </summary>
public class BinItem
{
private string _name;
private double _length;
public BinItem(string name, double length)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Item name cannot be empty", nameof(name));
if (length <= 0)
throw new ArgumentException("Item length must be greater than zero", nameof(length));
_name = name;
_length = length;
}
/// <summary>
/// Parameterless constructor for serialization only.
/// Use the parameterized constructor for creating valid instances.
/// </summary>
public BinItem()
{
}
public string Name
{
get => _name;
set
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Item name cannot be empty", nameof(value));
_name = value;
}
}
public double Length
{
get => _length;
set
{
if (value <= 0)
throw new ArgumentException("Item length must be greater than zero", nameof(value));
_length = value;
}
}
/// <summary>
/// Checks if this item can fit in the given available length.
/// </summary>
public bool CanFitIn(double availableLength)
{
return Length <= availableLength;
}
/// <summary>
/// Checks if this item can fit in the given available length with spacing.
/// </summary>
public bool CanFitInWithSpacing(double availableLength, double spacing)
{
return Length + spacing <= availableLength;
}
public override string ToString()
{
return $"{Name} ({Length}\")";
}
public override bool Equals(object? obj)
{
if (obj is BinItem other)
{
return Name == other.Name && Length.IsEqualTo(other.Length);
}
return false;
}
public override int GetHashCode()
{
unchecked
{
int hash = 17;
hash = hash * 23 + (Name?.GetHashCode() ?? 0);
hash = hash * 23 + Length.GetHashCode();
return hash;
}
}
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<!-- Disable auto-generation of assembly attributes to avoid conflicts with AssemblyInfo.cs -->
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,68 @@
using System;
namespace CutList.Core
{
/// <summary>
/// Provides formatting utilities for displaying measurements and values.
/// </summary>
public static class FormatHelper
{
/// <summary>
/// Converts a decimal measurement to a mixed fraction string representation.
/// </summary>
/// <param name="input">The decimal value to convert</param>
/// <param name="precision">The denominator precision (default 16 for 1/16")</param>
/// <returns>A string in the format "whole-numerator/denominator"</returns>
public static string ConvertToMixedFraction(decimal input, int precision = 16)
{
// Get the whole number part
int wholeNumber = (int)input;
// Get the fractional part
decimal fractionalPart = Math.Abs(input - wholeNumber);
if (fractionalPart == 0)
{
return wholeNumber.ToString();
}
// Convert the fractional part to a fraction
int numerator = (int)(fractionalPart * precision);
int denominator = precision;
// Simplify the fraction
int gcd = GetGreatestCommonDivisor(numerator, denominator);
numerator /= gcd;
denominator /= gcd;
// If rounding wiped out the fraction → return whole number only
if (numerator == 0)
{
return wholeNumber.ToString();
}
return $"{wholeNumber}-{numerator}/{denominator}";
}
/// <summary>
/// Converts a double measurement to a mixed fraction string representation.
/// </summary>
/// <param name="input">The double value to convert</param>
/// <returns>A string in the format "whole-numerator/denominator"</returns>
public static string ConvertToMixedFraction(double input)
{
return ConvertToMixedFraction((decimal)input);
}
private static int GetGreatestCommonDivisor(int a, int b)
{
while (b != 0)
{
int temp = b;
b = a % b;
a = temp;
}
return Math.Abs(a);
}
}
}

85
CutList.Core/Fraction.cs Normal file
View File

@@ -0,0 +1,85 @@
using System;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
namespace CutList.Core
{
public static class Fraction
{
public static readonly Regex FractionRegex = new Regex(@"((?<WholeNum>\d+)(\ |-))?(?<Fraction>\d+\/\d+)");
public static bool IsValid(string s)
{
return FractionRegex.IsMatch(s);
}
public static double Parse(string s)
{
var match = FractionRegex.Match(s);
if (!match.Success)
throw new Exception("Invalid format.");
var value = 0.0;
var wholeNumGroup = match.Groups["WholeNum"];
var fractionGroup = match.Groups["Fraction"];
if (wholeNumGroup.Success)
{
value = double.Parse(wholeNumGroup.Value);
}
if (fractionGroup.Success)
{
var parts = fractionGroup.Value.Split('/');
var numerator = double.Parse(parts[0]);
var denominator = double.Parse(parts[1]);
value += Math.Round(numerator / denominator, 8);
}
return value;
}
public static string ReplaceFractionsWithDecimals(string input)
{
var sb = new StringBuilder(input);
// find all matches and sort by descending index number to avoid
// changing all previous index numbers when the fraction is replaced
// with the decimal equivalent.
var fractionMatches = FractionRegex.Matches(sb.ToString())
.Cast<Match>()
.OrderByDescending(m => m.Index);
foreach (var fractionMatch in fractionMatches)
{
// convert the fraction to a decimal value
var decimalValue = Parse(fractionMatch.Value);
// remove the fraction and insert the decimal value in its place.
sb.Remove(fractionMatch.Index, fractionMatch.Length);
sb.Insert(fractionMatch.Index, decimalValue);
}
return sb.ToString();
}
public static bool TryParse(string s, out double fraction)
{
try
{
fraction = Parse(s);
}
catch
{
fraction = 0;
return false;
}
return true;
}
}
}

125
CutList.Core/MultiBin.cs Normal file
View File

@@ -0,0 +1,125 @@
using System;
namespace CutList.Core
{
/// <summary>
/// Represents a type of bin with quantity and priority.
/// Enforces business rules for valid bin configurations.
/// </summary>
public class MultiBin
{
private int _quantity;
private double _length;
private int _priority;
public MultiBin(double length, int quantity = 1, int priority = 25)
{
if (length <= 0)
throw new ArgumentException("Bin length must be greater than zero", nameof(length));
if (quantity < -1 || quantity == 0)
throw new ArgumentException("Quantity must be positive or -1 for unlimited", nameof(quantity));
_length = length;
_quantity = quantity;
_priority = priority;
}
/// <summary>
/// Parameterless constructor for serialization only.
/// Use the parameterized constructor for creating valid instances.
/// </summary>
public MultiBin()
{
_quantity = 1;
_priority = 25;
}
/// <summary>
/// Quantity of bins available. Use -1 for unlimited bins.
/// </summary>
public int Quantity
{
get => _quantity;
set
{
if (value < -1 || value == 0)
throw new ArgumentException("Quantity must be positive or -1 for unlimited", nameof(value));
_quantity = value;
}
}
public double Length
{
get => _length;
set
{
if (value <= 0)
throw new ArgumentException("Bin length must be greater than zero", nameof(value));
_length = value;
}
}
/// <summary>
/// Lower value priority will be used first. Default is 25.
/// </summary>
public int Priority
{
get => _priority;
set => _priority = value;
}
/// <summary>
/// Checks if this bin type has unlimited quantity.
/// </summary>
public bool IsUnlimited => Quantity == -1;
/// <summary>
/// Checks if an item of the given length can fit in this bin.
/// </summary>
public bool CanFitItem(double itemLength)
{
return itemLength <= Length && itemLength > 0;
}
/// <summary>
/// Calculates the waste for a single bin if the given length is used.
/// </summary>
public double CalculateWaste(double usedLength)
{
if (usedLength < 0 || usedLength > Length)
throw new ArgumentException("Used length must be between 0 and bin length", nameof(usedLength));
return Length - usedLength;
}
public override string ToString()
{
var quantityStr = IsUnlimited ? "Unlimited" : Quantity.ToString();
return $"Bin {Length}\" (Qty: {quantityStr}, Priority: {Priority})";
}
public override bool Equals(object? obj)
{
if (obj is MultiBin other)
{
return Quantity == other.Quantity &&
Length.IsEqualTo(other.Length) &&
Priority == other.Priority;
}
return false;
}
public override int GetHashCode()
{
unchecked
{
int hash = 17;
hash = hash * 23 + Quantity.GetHashCode();
hash = hash * 23 + Length.GetHashCode();
hash = hash * 23 + Priority.GetHashCode();
return hash;
}
}
}
}

View File

@@ -0,0 +1,253 @@
using CutList.Core.Nesting;
using CutList.Core;
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
namespace CutList.Core.Nesting
{
public class AdvancedFitEngine : IEngine
{
public AdvancedFitEngine()
{
Bins = new List<Bin>();
}
public double StockLength { get; set; }
public double Spacing { get; set; }
public int MaxBinCount { get; set; } = int.MaxValue;
private List<BinItem> Items { get; set; }
private List<Bin> Bins { get; set; }
public Result Pack(List<BinItem> items)
{
if (StockLength <= 0)
throw new Exception("Stock length must be greater than 0");
Items = items.OrderByDescending(i => i.Length).ToList();
var result = new Result();
var itemsTooLarge = Items.Where(i => i.Length > StockLength).ToList();
result.AddItemsNotUsed(itemsTooLarge);
Items.RemoveAll(item => itemsTooLarge.Contains(item));
CreateBins();
var finalItemsTooLarge = Items.Where(i => i.Length > StockLength).ToList();
result.AddItemsNotUsed(finalItemsTooLarge);
result.AddBins(Bins);
foreach (var bin in result.Bins)
{
foreach (var item in bin.Items)
{
Items.Remove(item);
}
}
result.AddItemsNotUsed(Items);
return result;
}
private void CreateBins()
{
while (Items.Count > 0 && CanAddMoreBins())
{
var bin = new Bin(StockLength)
{
Spacing = Spacing
};
FillBin(bin);
while (TryImprovePacking(bin))
{
}
bin.SortItems((a, b) =>
{
int comparison = b.Length.CompareTo(a.Length);
return comparison != 0 ? comparison : a.Length.CompareTo(b.Length);
});
Bins.Add(bin);
CreateDuplicateBins(bin);
}
Bins = Bins
.OrderByDescending(b => b.Utilization)
.ThenBy(b => b.Items.Count)
.ToList();
}
private bool CanAddMoreBins()
{
if (MaxBinCount == -1)
return true;
if (Bins.Count < MaxBinCount)
return true;
return false;
}
private void FillBin(Bin bin)
{
for (int i = 0; i < Items.Count; i++)
{
if (bin.RemainingLength >= Items[i].Length)
{
bin.AddItem(Items[i]);
Items.RemoveAt(i);
i--;
}
}
}
private void CreateDuplicateBins(Bin originalBin)
{
// Count how many times the bin can be duplicated
int duplicateCount = GetDuplicateCount(originalBin);
for (int i = 0; i < duplicateCount; i++)
{
if (!CanAddMoreBins())
break;
var newBin = new Bin(originalBin.Length)
{
Spacing = Spacing
};
foreach (var item in originalBin.Items)
{
var newItem = Items.FirstOrDefault(a => a.Length == item.Length);
newBin.AddItem(newItem);
Items.Remove(newItem);
}
Bins.Add(newBin);
}
}
private int GetDuplicateCount(Bin bin)
{
int count = int.MaxValue;
foreach (var item in bin.Items.GroupBy(i => i.Length))
{
int availableCount = Items.Count(i => i.Length == item.Key);
count = Math.Min(count, availableCount / item.Count());
}
return count;
}
private bool TryImprovePacking(Bin bin)
{
if (bin.Items.Count == 0)
return false;
if (Items.Count < 2)
return false;
var lengthGroups = GroupItemsByLength(bin.Items);
var shortestLengthItemAvailable = Items.Min(i => i.Length);
foreach (var group in lengthGroups)
{
var minRemainingLength = bin.RemainingLength;
var firstItem = group.Items.FirstOrDefault();
bin.RemoveItem(firstItem);
for (int i = 0; i < Items.Count; i++)
{
var item1 = Items[i];
if (Items[i].Length > bin.RemainingLength)
continue;
var bin2 = new Bin(bin.RemainingLength);
bin2.Spacing = bin.Spacing;
bin2.AddItem(item1);
for (int j = i + 1; j < Items.Count; j++)
{
if (bin2.RemainingLength < shortestLengthItemAvailable)
break;
var item2 = Items[j];
if (item2.Length > bin2.RemainingLength)
continue;
bin2.AddItem(item2);
}
if (bin2.RemainingLength < minRemainingLength)
{
Items.Add(firstItem);
bin.AddItems(bin2.Items);
foreach (var item in bin2.Items)
{
Items.Remove(item);
}
// improvement made
return true;
}
}
bin.AddItem(firstItem);
}
return false;
}
private List<LengthGroup> GroupItemsByLength(IEnumerable<BinItem> items)
{
var groups = new List<LengthGroup>();
var groupMap = new Dictionary<double, LengthGroup>();
foreach (var item in items)
{
if (!groupMap.TryGetValue(item.Length, out var group))
{
group = new LengthGroup
{
Length = item.Length,
Items = new List<BinItem>()
};
groupMap[item.Length] = group;
groups.Add(group);
}
group.Items.Add(item);
}
groups.Sort((a, b) => b.Length.CompareTo(a.Length));
if (groups.Count > 0)
{
groups.RemoveAt(0); // Remove the largest length group
}
return groups;
}
}
internal class LengthGroup
{
public double Length { get; set; }
public List<BinItem> Items { get; set; }
}
}

View File

@@ -0,0 +1,111 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
namespace CutList.Core.Nesting
{
public class BestFitEngine : IEngine
{
public double StockLength { get; set; }
public double Spacing { get; set; }
public int MaxBinCount { get; set; } = int.MaxValue;
private List<BinItem> Items { get; set; }
public Result Pack(List<BinItem> items)
{
if (StockLength <= 0)
throw new Exception("Stock length must be greater than 0");
Items = items.OrderByDescending(i => i.Length).ToList();
var result = new Result();
var itemsTooLarge = Items.Where(i => i.Length > StockLength).ToList();
result.AddItemsNotUsed(itemsTooLarge);
foreach (var item in itemsTooLarge)
{
Items.Remove(item);
}
var bins = GetBins();
result.AddBins(bins);
foreach (var bin in bins)
{
foreach (var item in bin.Items)
{
Items.Remove(item);
}
}
result.AddItemsNotUsed(Items);
return result;
}
private List<Bin> GetBins()
{
var bins = new List<Bin>();
foreach (var item in Items)
{
Bin best_bin;
if (!FindBin(bins.ToArray(), item.Length, out best_bin))
{
if (item.Length > StockLength)
continue;
if (bins.Count < MaxBinCount)
{
best_bin = CreateBin();
bins.Add(best_bin);
}
}
if (best_bin != null)
best_bin.AddItem(item);
}
return bins
.OrderByDescending(b => b.Utilization)
.ThenBy(b => b.Items.Count)
.ToList();
}
private Bin CreateBin()
{
var length = StockLength;
return new Bin(length)
{
Spacing = Spacing
};
}
private static bool FindBin(IEnumerable<Bin> bins, double length, out Bin found)
{
found = null;
foreach (var bin in bins)
{
if (bin.RemainingLength < length)
continue;
if (found == null)
found = bin;
if (bin.RemainingLength < found.RemainingLength)
found = bin;
}
return (found != null);
}
}
}

View File

@@ -0,0 +1,19 @@
namespace CutList.Core.Nesting
{
/// <summary>
/// Default implementation of IEngineFactory that creates AdvancedFitEngine instances.
/// Can be extended to support different engine types based on configuration.
/// </summary>
public class EngineFactory : IEngineFactory
{
public IEngine CreateEngine(double stockLength, double spacing, int maxBinCount)
{
return new AdvancedFitEngine
{
StockLength = stockLength,
Spacing = spacing,
MaxBinCount = maxBinCount
};
}
}
}

View File

@@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace CutList.Core.Nesting
{
public interface IEngine
{
Result Pack(List<BinItem> items);
}
}

View File

@@ -0,0 +1,18 @@
namespace CutList.Core.Nesting
{
/// <summary>
/// Factory interface for creating bin packing engines.
/// Allows for dependency injection and testing without hard-coded engine types.
/// </summary>
public interface IEngineFactory
{
/// <summary>
/// Creates a configured engine instance for bin packing.
/// </summary>
/// <param name="stockLength">The length of stock bins</param>
/// <param name="spacing">The spacing/kerf between items</param>
/// <param name="maxBinCount">Maximum number of bins to create</param>
/// <returns>A configured IEngine instance</returns>
IEngine CreateEngine(double stockLength, double spacing, int maxBinCount);
}
}

View File

@@ -0,0 +1,68 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace CutList.Core.Nesting
{
public class MultiBinEngine : IEngine
{
private readonly IEngineFactory _engineFactory;
public MultiBinEngine() : this(new EngineFactory())
{
}
public MultiBinEngine(IEngineFactory engineFactory)
{
_engineFactory = engineFactory ?? throw new ArgumentNullException(nameof(engineFactory));
_bins = new List<MultiBin>();
}
private readonly List<MultiBin> _bins;
/// <summary>
/// Gets the read-only collection of bins.
/// Use SetBins() to configure bins for packing.
/// </summary>
public IReadOnlyList<MultiBin> Bins => _bins.AsReadOnly();
/// <summary>
/// Sets the bins to use for packing.
/// </summary>
public void SetBins(IEnumerable<MultiBin> bins)
{
_bins.Clear();
if (bins != null)
{
_bins.AddRange(bins);
}
}
public double Spacing { get; set; }
public Result Pack(List<BinItem> items)
{
var bins = _bins
.Where(b => b.Length > 0)
.OrderBy(b => b.Priority)
.ThenBy(b => b.Length)
.ToList();
var result = new Result();
var remainingItems = new List<BinItem>(items);
foreach (var bin in bins)
{
var engine = _engineFactory.CreateEngine(bin.Length, Spacing, bin.Quantity);
var r = engine.Pack(remainingItems);
result.AddBins(r.Bins);
remainingItems = r.ItemsNotUsed.ToList();
}
result.AddItemsNotUsed(remainingItems);
return result;
}
}
}

View File

@@ -0,0 +1,40 @@
using System.Collections.Generic;
namespace CutList.Core.Nesting
{
public class Result
{
private readonly List<BinItem> _itemsNotUsed;
private readonly List<Bin> _bins;
public Result()
{
_itemsNotUsed = new List<BinItem>();
_bins = new List<Bin>();
}
public IReadOnlyList<BinItem> ItemsNotUsed => _itemsNotUsed;
public IReadOnlyList<Bin> Bins => _bins;
public void AddItemNotUsed(BinItem item)
{
_itemsNotUsed.Add(item);
}
public void AddItemsNotUsed(IEnumerable<BinItem> items)
{
_itemsNotUsed.AddRange(items);
}
public void AddBin(Bin bin)
{
_bins.Add(bin);
}
public void AddBins(IEnumerable<Bin> bins)
{
_bins.AddRange(bins);
}
}
}

View File

@@ -0,0 +1,35 @@
using System.Reflection;
using System.Runtime.InteropServices;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("CutList.Core")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("CutList.Core")]
[assembly: AssemblyCopyright("Copyright © 2021")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
// Setting ComVisible to false makes the types in this assembly not visible
// to COM components. If you need to access a type in this assembly from
// COM, set the ComVisible attribute to true on that type.
[assembly: ComVisible(false)]
// The following GUID is for the ID of the typelib if this project is exposed to COM
[assembly: Guid("3d873ff0-6930-4bce-a5a9-da5c20354dee")]
// Version information for an assembly consists of the following four values:
//
// Major Version
// Minor Version
// Build Number
// Revision
//
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]

14
CutList.Core/Tolerance.cs Normal file
View File

@@ -0,0 +1,14 @@
using System;
namespace CutList.Core
{
public static class Tolerance
{
public const double Epsilon = 0.00001;
public static bool IsEqualTo(this double a, double b, double tolerance = Epsilon)
{
return Math.Abs(b - a) <= tolerance;
}
}
}