Compare commits

..

41 Commits

Author SHA1 Message Date
21d50e7c20 fix: Prevent shape change after material creation
Disable Shape dropdown on existing materials since changing shape would
require completely different dimension properties.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 00:19:01 -05:00
f723661696 feat: Convert part form to modal dialog and improve material ordering
- Replace inline part form with Bootstrap modal dialog for better UX
- Add SortOrder to material dropdown ordering in Parts and Stock tabs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 00:18:56 -05:00
c795c129e5 feat: Improve material ordering and default type in MCP tools
- Add SortOrder as secondary ordering key after Shape across all material
  queries (list_materials, search methods)
- Default material type to "Steel" when not specified in add_stock_with_offering

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 00:18:51 -05:00
30071469bc chore: Add Claude Code project configuration
Add .claude directory with settings and memory files for
consistent AI-assisted development.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:38:33 -05:00
c9a2583f26 feat: Add MCP inventory management tools
Add comprehensive MCP tools for inventory management:
- list_suppliers, add_supplier
- list_materials, add_material, search_materials
- list_stock_items, add_stock_item
- list_supplier_offerings, add_supplier_offering
- add_stock_with_offering (convenience method)

Features:
- Dimension-based material search with tolerance
- Auto-generate size strings from dimensions
- Parse size strings to typed dimensions
- Type/Grade support for material categorization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:38:27 -05:00
0e5b63c557 refactor: Update controllers for new Material model
MaterialsController:
- Update to use MaterialShape enum
- Add Type and Grade to imports
- Fix display name formatting

SeedController:
- Update seed data to use MaterialShape enum
- Add MaterialType assignments

CuttingTool:
- Add Notes property

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:38:21 -05:00
6388e003d3 feat: Update UI for Jobs and enhanced Materials
Navigation:
- Rename Projects to Jobs in NavMenu
- Add new icon for multi-material boxes

Home page:
- Update references from Projects to Jobs

Materials pages:
- Add Type and Grade columns to index
- Shape-specific dimension editing with typed inputs
- Error handling with detailed messages

Stock pages:
- Show Shape, Type, Grade, Size columns
- Display QuantityOnHand with badges

Shared components:
- LengthInput: Add nullable binding mode for optional dimensions
- LengthInput: Format on blur for better UX
- CutListReport: Update for Job model references

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:38:15 -05:00
c5da5dda98 feat: Update service layer for new data model
MaterialService:
- Include Dimensions in queries
- Add CreateWithDimensionsAsync for typed dimension creation
- Add UpdateWithDimensionsAsync with optional size regeneration
- Add dimension search methods by value with tolerance
- Sort by SortOrder for numeric ordering

StockItemService:
- Add stock transaction methods (AddStock, UseStock, AdjustStock)
- Add GetAverageCost and GetLastPurchasePrice for costing
- Add GetTransactionHistory for audit

CutListPackingService:
- Update to use JobPart instead of ProjectPart
- Support job-specific stock (JobStock) with priorities
- Fall back to all available stock when no job stock configured

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:38:06 -05:00
21cddb22c7 chore: Remove deprecated Project entities and pages
Remove files superseded by the Job model:
- Project, ProjectPart entities (replaced by Job, JobPart, JobStock)
- ProjectService (replaced by JobService)
- Projects UI pages (replaced by Jobs pages)
- MaterialStockLength entity (consolidated into StockItem)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:37:57 -05:00
3b036308c8 refactor: Update Material and StockItem entities
Material entity changes:
- Shape property now uses MaterialShape enum
- Add Type (MaterialType) and Grade properties
- Add SortOrder for numeric sorting
- Add Dimensions navigation property (1:1)
- Replace ProjectParts with JobParts collection

StockItem entity changes:
- Add QuantityOnHand for inventory tracking
- Add Notes field
- Add Transactions navigation property

DbContext updates:
- Configure MaterialDimensions TPH inheritance
- Add enum-to-string conversions for MaterialShape and MaterialType
- Configure shared column names for TPH properties
- Add indexes on primary dimension columns
- Update all entity relationships for Job model

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:37:51 -05:00
4f6d986dc9 feat: Add material dimensions with typed properties
Implement TPH inheritance for material dimensions:
- MaterialShape enum with display names and parsing
- MaterialType enum (Steel, Aluminum, Stainless, etc.)
- MaterialDimensions base class with derived types per shape
- Auto-generate size strings from typed dimensions
- SortOrder field for numeric dimension sorting

Each shape has specific dimension properties:
- RoundBar: Diameter
- RoundTube: OuterDiameter, Wall
- FlatBar: Width, Thickness
- SquareBar/Tube: Size, Wall
- RectangularTube: Width, Height, Wall
- Angle: Leg1, Leg2, Thickness
- Channel: Height, Flange, Web
- IBeam: Height, WeightPerFoot
- Pipe: NominalSize, Wall, Schedule

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:37:43 -05:00
254066c989 feat: Add stock transaction tracking system
- Add StockTransaction entity for audit trail
- Track received, used, adjusted, scrapped, and returned stock
- Include unit price tracking for cost analysis
- Link transactions to jobs and suppliers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:37:31 -05:00
ce14dd50cb refactor: Rename Project to Job with enhanced model
Rename the Project concept to Job for clarity:
- Add Job, JobPart, JobStock entities
- JobStock supports both inventory stock and custom lengths
- Add JobNumber field for job identification
- Add priority-based stock allocation for cut optimization
- Include Jobs UI pages (Index, Edit, Results)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:37:24 -05:00
dfc767320a fix: Improve architectural unit parsing and formatting
- Add fallback to parse plain decimal inches without unit symbols
- Fix fraction-only display to show "1/2" instead of "0-1/2"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:37:15 -05:00
5cc088ea6b feat: Add Stock Items UI and update Supplier offerings
- Add Stock Items index page listing all stock items
- Add Stock Items edit page with supplier offerings management
- Update Suppliers edit page to manage offerings (select from stock
  items instead of material+length)
- Add Stock Items navigation link to sidebar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:32:32 -05:00
6797d1e4fd feat: Update service layer for new stock model
- Add StockItemService for CRUD operations on stock items
- Update SupplierService to manage SupplierOfferings instead of
  SupplierStock (GetOfferingsForSupplierAsync, AddOfferingAsync, etc.)
- Update CutListPackingService to use StockItems for available lengths
- Register StockItemService in Program.cs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:32:25 -05:00
c4fc88f7d2 chore: Add migration for StockItem and SupplierOffering
Migration drops SupplierStocks table and creates StockItems and
SupplierOfferings tables with appropriate indexes and foreign keys.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:32:20 -05:00
9929d82768 refactor: Replace SupplierStock with StockItem/SupplierOffering model
- Remove SupplierStock entity
- Update Material navigation from SupplierStocks to StockItems
- Update Supplier navigation from Stocks to Offerings
- Update ApplicationDbContext with new DbSets and configurations
- Add unique constraints: StockItem(MaterialId, LengthInches) and
  SupplierOffering(SupplierId, StockItemId)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:32:14 -05:00
0ded77ce8b feat: Add StockItem and SupplierOffering entities
Introduce new data model that separates stock catalog (StockItem) from
supplier-specific pricing/catalog info (SupplierOffering). StockItem
represents a Material+Length combination, while SupplierOffering links
suppliers to stock items with part numbers, descriptions, and pricing.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:32:07 -05:00
8e73d630d5 chore: Change development port to 5009
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 23:57:13 -05:00
079f5b1085 style: Update sidebar color and optimize print layout
Changes sidebar gradient to neutral gray and adds compact print styles
for better paper usage when printing reports.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 23:57:02 -05:00
97fa90357b fix: Minor UI improvements to layout and LengthInput
Removes unnecessary top row link and adds default placeholder to
LengthInput component.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 23:56:47 -05:00
bf6c4764ed feat: Add custom print title JS function
Adds printWithTitle function to set document title before printing for
better file naming when saving as PDF.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 23:56:37 -05:00
ed911a13ba feat: Redesign Results page for multi-material output
Updates Results page to display packing results grouped by material,
showing in-stock vs. to-be-purchased breakdown with order summaries.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 23:56:28 -05:00
c99de55fe1 feat: Update Project pages for multi-material parts
Redesigns Project Edit with a tabbed interface and adds material
selection (shape -> size) when adding parts. Updates Index to show
customer instead of material.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 23:56:19 -05:00
8b16cbd79f feat: Add stock lengths management UI to Material Edit page
Extends the Material Edit page with a side panel to manage available
stock lengths, including quantity tracking and CRUD operations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 23:56:06 -05:00
cad5ab790a chore: Register API controllers in Program.cs
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 23:55:57 -05:00
f8020549fe feat: Add REST API controllers for materials
Adds MaterialsController with bulk import support and SeedController
for development data seeding.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 23:55:48 -05:00
66ed19a1ac feat: Implement multi-material packing with inventory awareness
Refactors CutListPackingService to:
- Pack parts grouped by material type
- Distinguish between in-stock and to-be-purchased bins
- Use material stock lengths for finite inventory
- Use supplier stock for unlimited purchase options

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 23:55:38 -05:00
051b866c6d refactor: Remove stock bin management from ProjectService
Removes project-level stock bin methods since stock is now derived from
material stock lengths. Updates queries to include Material on parts.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 23:55:28 -05:00
3d80adbfff feat: Add stock length management to MaterialService
Adds CRUD methods for managing material stock lengths, including
duplicate checking and quantity tracking.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 23:55:18 -05:00
b7b98d4338 chore: Update DbContext for new entity relationships
Updates ApplicationDbContext to configure MaterialStockLength and
revised ProjectPart relationships.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 23:55:07 -05:00
ced272d3e3 feat: Support multi-material project parts
Each project part now references its own material, allowing a single
project to use multiple material types. Removes ProjectStockBin entity
since stock is now derived from material stock lengths.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 23:54:59 -05:00
35b26e673e feat: Add Customer field to Project entity
Adds a customer name field to projects for better job tracking and
identification on reports.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 23:54:48 -05:00
cca569ae81 feat: Add MaterialStockLength entity for inventory tracking
Introduces a new entity to track available stock lengths per material,
enabling in-stock vs. purchase-needed distinction during optimization.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 23:54:23 -05:00
fa36d82285 feat: Add material shape dropdown and Save As button to MainForm
UI improvements:
- Add material shape dropdown (Round Tube, Square Tube, Angle, etc.)
- Add Save As toolbar button for saving to a new file
- Simplify toolbar button styling (show text with icons)
- Expose SelectedMaterialShape property for report generation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 21:56:42 -05:00
b0c9470bb7 feat: Add cut method and material shape to saved reports
Include cutting tool name and material shape in the text report output.
This provides better context when reviewing saved cut lists.

Changes:
- BinFileSaver: Add CutMethod and MaterialShape properties
- ResultsForm: Pass cut method and material to file saver
- IMainView: Extend ShowResults with additional parameters
- MainFormPresenter: Use document name for save filename if available

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 21:56:33 -05:00
9868df162d feat: Add CutList.Web Blazor Server application
Add a new web-based frontend for cut list optimization using:
- Blazor Server with .NET 8
- Entity Framework Core with MSSQL LocalDB
- Full CRUD for Materials, Suppliers, Projects, and Cutting Tools
- Supplier stock length management for quick project setup
- Integration with CutList.Core for bin packing optimization
- Print-friendly HTML reports with efficiency statistics

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 21:56:21 -05:00
6db8ab21f4 feat: Improve document management with Save/Save As and keyboard shortcuts
- Track file path after save/load so Save doesn't prompt again
- Add Save As (Ctrl+Shift+S) to always prompt for location
- Update window title to show current filename
- Generate incremental default filenames (CutList_1.json, etc.)
- Add keyboard shortcuts: Ctrl+S (Save), Ctrl+O (Open), Ctrl+N (New)
- Enter key in items grid moves to Length column on next row

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 16:20:59 -05:00
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
6e8469be4b refactor: Extract duplicate code in MainForm and CutListTools
Consolidate duplicate logic to reduce code smells identified by Roslyn Bridge:
- Extract FlushPendingEdits() helper from Save() and Run() methods
- Simplify ClearData() to delegate to LoadDocumentData()
- Extract ConvertParts(), ConvertStockBins(), RunPackingAlgorithm() helpers
- Move DTO classes to separate Models.cs file

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 13:18:23 -05:00
210 changed files with 22434 additions and 783 deletions

BIN
.claude/mcp/Azure.Core.dll Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

BIN
.claude/mcp/CutList.Mcp.dll Normal file

Binary file not shown.

BIN
.claude/mcp/CutList.Mcp.exe Normal file

Binary file not shown.

View File

@@ -0,0 +1,20 @@
{
"runtimeOptions": {
"tfm": "net8.0",
"frameworks": [
{
"name": "Microsoft.NETCore.App",
"version": "8.0.0"
},
{
"name": "Microsoft.AspNetCore.App",
"version": "8.0.0"
}
],
"configProperties": {
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false,
"System.Reflection.NullabilityInfoContext.IsSupported": true,
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
}
}
}

BIN
.claude/mcp/CutList.Web.dll Normal file

Binary file not shown.

BIN
.claude/mcp/CutList.Web.exe Normal file

Binary file not shown.

View File

@@ -0,0 +1,21 @@
{
"runtimeOptions": {
"tfm": "net8.0",
"frameworks": [
{
"name": "Microsoft.NETCore.App",
"version": "8.0.0"
},
{
"name": "Microsoft.AspNetCore.App",
"version": "8.0.0"
}
],
"configProperties": {
"System.GC.Server": true,
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false,
"System.Reflection.NullabilityInfoContext.IsSupported": true,
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
}
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=CutListDb;Trusted_Connection=True;MultipleActiveResultSets=true"
}
}

View File

@@ -0,0 +1,28 @@
{
"permissions": {
"allow": [
"Skill(roslyn-bridge)",
"Bash(dotnet build:*)",
"SlashCommand(/rb)",
"mcp__roslyn-bridge__get_projects",
"Bash(dotnet tool install:*)",
"Bash(dotnet ilspy:*)",
"Bash(dotnet add package:*)",
"Bash(git -C /c/Users/AJ/Desktop/Projects/CutList add CutList.Core/BinComparer.cs CutList.Core/BinItem.cs CutList.Core/MultiBin.cs CutList/Tool.cs)",
"Bash(git -C /c/Users/AJ/Desktop/Projects/CutList commit --amend --no-edit)",
"mcp__roslyn-bridge__get_code_smells",
"mcp__roslyn-bridge__get_duplicates",
"mcp__roslyn-bridge__get_code_smell_summary",
"mcp__cutlist__create_cutlist",
"Bash(dotnet run:*)",
"mcp__roslyn-bridge__get_files",
"mcp__roslyn-bridge__refresh_workspace",
"mcp__roslyn-bridge__get_diagnostics",
"Bash(dotnet ef database update:*)",
"mcp__roslyn-bridge__search_symbol",
"Bash(dotnet ef migrations add:*)"
],
"deny": [],
"ask": []
}
}

View File

@@ -11,6 +11,16 @@ namespace CutList.Core
public bool OpenFileAfterSave { get; set; } public bool OpenFileAfterSave { get; set; }
/// <summary>
/// The cutting method/tool used (e.g., "Miter Saw", "Table Saw").
/// </summary>
public string? CutMethod { get; set; }
/// <summary>
/// The material shape (e.g., "Round Tube", "Square Tube", "Flat Bar").
/// </summary>
public string? MaterialShape { get; set; }
public BinFileSaver(IEnumerable<Bin> bins) public BinFileSaver(IEnumerable<Bin> bins)
{ {
_bins = bins ?? throw new ArgumentNullException(nameof(bins)); _bins = bins ?? throw new ArgumentNullException(nameof(bins));
@@ -74,10 +84,24 @@ namespace CutList.Core
var totalItems = _bins.Sum(b => b.Items.Count); var totalItems = _bins.Sum(b => b.Items.Count);
writer.WriteLine("CUT LIST"); writer.WriteLine("CUT LIST");
writer.WriteLine($"Date: {DateTime.Now:g}"); WriteSeparator(writer, '=');
writer.WriteLine($"Total stock bars needed: {totalBars}");
writer.WriteLine($"Total pieces to cut: {totalItems}");
writer.WriteLine(); writer.WriteLine();
WriteAlignedLine(writer, "Date", DateTime.Now.ToString("g"));
if (!string.IsNullOrEmpty(CutMethod))
WriteAlignedLine(writer, "Cut Method", CutMethod);
if (!string.IsNullOrEmpty(MaterialShape))
WriteAlignedLine(writer, "Material", MaterialShape);
WriteAlignedLine(writer, "Stock Bars Needed", totalBars.ToString());
WriteAlignedLine(writer, "Total Pieces", totalItems.ToString());
writer.WriteLine();
}
private static void WriteAlignedLine(TextWriter writer, string label, string value, int labelWidth = 20)
{
writer.WriteLine($" {label.PadRight(labelWidth)} {value}");
} }
private void WriteBinSummary(TextWriter writer, Bin bin, int id) private void WriteBinSummary(TextWriter writer, Bin bin, int id)
@@ -85,8 +109,9 @@ namespace CutList.Core
var stockLength = FormatHelper.ConvertToMixedFraction(bin.Length); var stockLength = FormatHelper.ConvertToMixedFraction(bin.Length);
var dropLength = FormatHelper.ConvertToMixedFraction(bin.RemainingLength); var dropLength = FormatHelper.ConvertToMixedFraction(bin.RemainingLength);
writer.WriteLine(new string('─', 50)); WriteSeparator(writer);
writer.WriteLine($"BAR #{id} - Start with {stockLength}\" stock");
writer.WriteLine($"BAR #{id} - Length: {stockLength}\"");
writer.WriteLine(); writer.WriteLine();
writer.WriteLine(" Cut these pieces:"); writer.WriteLine(" Cut these pieces:");
@@ -97,6 +122,11 @@ namespace CutList.Core
writer.WriteLine(); writer.WriteLine();
} }
private static void WriteSeparator(TextWriter writer, char c = '─', int length = 50)
{
writer.WriteLine(new string(c, length));
}
private void WriteBinItems(TextWriter writer, Bin bin) private void WriteBinItems(TextWriter writer, Bin bin)
{ {
var groups = bin.Items var groups = bin.Items
@@ -128,14 +158,14 @@ namespace CutList.Core
string fmt(double v) => FormatHelper.ConvertToMixedFraction(v); string fmt(double v) => FormatHelper.ConvertToMixedFraction(v);
writer.WriteLine(new string('═', 50)); WriteSeparator(writer, '=');
writer.WriteLine("SUMMARY"); writer.WriteLine("SUMMARY");
writer.WriteLine($" Stock bars needed: {totalBars}"); writer.WriteLine();
writer.WriteLine($" Total pieces to cut: {totalItems}"); WriteAlignedLine(writer, "Stock Bars Needed", totalBars.ToString());
writer.WriteLine($" Total material used: {fmt(totalStock)}\""); WriteAlignedLine(writer, "Total Pieces", totalItems.ToString());
writer.WriteLine($" Total drop/waste: {fmt(totalDrop)}\""); WriteAlignedLine(writer, "Total Material Used", $"{fmt(totalStock)}\"");
writer.WriteLine(new string('═', 50)); WriteAlignedLine(writer, "Total Drop/Waste", $"{fmt(totalDrop)}\"");
WriteSeparator(writer, '=');
} }
} }
} }

View File

@@ -28,7 +28,17 @@ namespace CutList.Core.Formatting
var match2 = regex.Match(input); var match2 = regex.Match(input);
if (!match2.Success) if (!match2.Success)
{
// If no unit symbols, try to parse as plain inches (e.g., "0.5" or "1/2" converted to "0.5")
if (!input.Contains("'") && !input.Contains("\""))
{
if (double.TryParse(input.Trim(), out var plainInches))
{
return Math.Round(plainInches, 8);
}
}
throw new Exception("Input is not in a valid format."); throw new Exception("Input is not in a valid format.");
}
var feet = match2.Groups["Feet"]; var feet = match2.Groups["Feet"];
var inches = match2.Groups["Inches"]; var inches = match2.Groups["Inches"];

View File

@@ -39,6 +39,12 @@ namespace CutList.Core.Formatting
return wholeNumber.ToString(); return wholeNumber.ToString();
} }
// If whole number is 0, just show the fraction
if (wholeNumber == 0)
{
return $"{numerator}/{denominator}";
}
return $"{wholeNumber}-{numerator}/{denominator}"; return $"{wholeNumber}-{numerator}/{denominator}";
} }

View File

@@ -1,248 +1,33 @@
using System.Data; using CutList.Core.Nesting.Pipeline;
namespace CutList.Core.Nesting namespace CutList.Core.Nesting
{ {
/// <summary>
/// Advanced bin packing engine using First-Fit Decreasing with optimization.
/// This is a stateless engine that uses a composable pipeline of steps.
/// </summary>
public class AdvancedFitEngine : IEngine public class AdvancedFitEngine : IEngine
{ {
private readonly PackingPipeline _pipeline;
public AdvancedFitEngine() public AdvancedFitEngine()
{ {
Bins = new List<Bin>(); _pipeline = new PackingPipeline()
.AddStep(new FilterOversizedItemsStep())
.AddStep(new SortItemsDescendingStep())
.AddStep(new FirstFitDecreasingStep())
.AddStep(new OptimizationStep())
.AddStep(new SortBinItemsStep())
.AddStep(new DuplicateBinsStep())
.AddStep(new SortBinsByUtilizationStep());
} }
public double StockLength { get; set; } /// <summary>
/// Packs items into bins using the FFD algorithm with optimization passes.
public double Spacing { get; set; } /// </summary>
public PackResult Pack(PackingRequest request)
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) return _pipeline.Execute(request);
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

@@ -1,92 +1,70 @@
using System.Data;
namespace CutList.Core.Nesting namespace CutList.Core.Nesting
{ {
/// <summary>
/// Best-Fit Decreasing bin packing engine.
/// Places each item in the bin with the least remaining space that can still fit it.
/// This is a stateless engine - all state is passed via PackingRequest.
/// </summary>
public class BestFitEngine : IEngine public class BestFitEngine : IEngine
{ {
public double StockLength { get; set; } /// <summary>
/// Packs items into bins using the Best-Fit Decreasing algorithm.
public double Spacing { get; set; } /// </summary>
public PackResult Pack(PackingRequest request)
public int MaxBinCount { get; set; } = int.MaxValue;
private List<BinItem> Items { get; set; }
public Result Pack(List<BinItem> items)
{ {
if (StockLength <= 0) var result = new PackResult();
throw new Exception("Stock length must be greater than 0"); var items = request.Items.OrderByDescending(i => i.Length).ToList();
var bins = new List<Bin>();
Items = items.OrderByDescending(i => i.Length).ToList(); // Filter oversized items
var oversizedItems = items.Where(i => i.Length > request.StockLength).ToList();
var result = new Result(); foreach (var item in oversizedItems)
var itemsTooLarge = Items.Where(i => i.Length > StockLength).ToList();
result.AddItemsNotUsed(itemsTooLarge);
foreach (var item in itemsTooLarge)
{ {
Items.Remove(item); items.Remove(item);
result.AddItemNotUsed(item);
} }
var bins = GetBins(); // Pack remaining items using best-fit
result.AddBins(bins); foreach (var item in items)
foreach (var bin in bins)
{ {
foreach (var item in bin.Items) if (!TryFindBestBin(bins, item.Length, out var bestBin))
{ {
Items.Remove(item); if (bins.Count < request.MaxBinCount)
{
bestBin = CreateBin(request);
bins.Add(bestBin);
}
}
if (bestBin != null)
{
bestBin.AddItem(item);
}
else
{
result.AddItemNotUsed(item);
} }
} }
result.AddItemsNotUsed(Items); // Sort bins by utilization
var sortedBins = bins
.OrderByDescending(b => b.Utilization)
.ThenBy(b => b.Items.Count);
result.AddBins(sortedBins);
return result; return result;
} }
private List<Bin> GetBins() private static Bin CreateBin(PackingRequest request)
{ {
var bins = new List<Bin>(); return new Bin(request.StockLength)
foreach (var item in Items)
{ {
Bin best_bin; Spacing = request.Spacing
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) private static bool TryFindBestBin(IEnumerable<Bin> bins, double length, out Bin? found)
{ {
found = null; found = null;
@@ -95,14 +73,13 @@ namespace CutList.Core.Nesting
if (bin.RemainingLength < length) if (bin.RemainingLength < length)
continue; continue;
if (found == null) if (found == null || bin.RemainingLength < found.RemainingLength)
found = bin; {
if (bin.RemainingLength < found.RemainingLength)
found = bin; found = bin;
}
} }
return (found != null); return found != null;
} }
} }
} }

View File

@@ -1,18 +1,19 @@
namespace CutList.Core.Nesting namespace CutList.Core.Nesting
{ {
/// <summary> /// <summary>
/// Default implementation of IEngineFactory that creates AdvancedFitEngine instances. /// Default implementation of IEngineFactory that creates packing engines
/// Can be extended to support different engine types based on configuration. /// based on the specified strategy.
/// </summary> /// </summary>
public class EngineFactory : IEngineFactory public class EngineFactory : IEngineFactory
{ {
public IEngine CreateEngine(double stockLength, double spacing, int maxBinCount) public IEngine CreateEngine(PackingStrategy strategy = PackingStrategy.AdvancedFit)
{ {
return new AdvancedFitEngine return strategy switch
{ {
StockLength = stockLength, PackingStrategy.AdvancedFit => new AdvancedFitEngine(),
Spacing = spacing, PackingStrategy.BestFit => new BestFitEngine(),
MaxBinCount = maxBinCount PackingStrategy.Exhaustive => new ExhaustiveFitEngine(),
_ => throw new ArgumentOutOfRangeException(nameof(strategy), strategy, "Unknown packing strategy")
}; };
} }
} }

View File

@@ -0,0 +1,198 @@
namespace CutList.Core.Nesting
{
/// <summary>
/// Exhaustive bin packing engine that tries all possible combinations
/// to find the optimal solution. Falls back to AdvancedFitEngine for
/// item counts exceeding the threshold due to exponential complexity.
/// </summary>
public class ExhaustiveFitEngine : IEngine
{
/// <summary>
/// Default maximum number of items before falling back to AdvancedFitEngine.
/// Testing showed 20 items is safe (~100ms worst case), while 21+ can take seconds.
/// </summary>
public const int DefaultMaxItems = 20;
private readonly IEngine _fallbackEngine;
private readonly int _maxItems;
public ExhaustiveFitEngine() : this(DefaultMaxItems)
{
}
/// <summary>
/// Creates an exhaustive engine with a custom item threshold for testing.
/// </summary>
/// <param name="maxItems">Maximum items before falling back. Use int.MaxValue to disable fallback.</param>
public ExhaustiveFitEngine(int maxItems)
{
_maxItems = maxItems;
_fallbackEngine = new AdvancedFitEngine();
}
public PackResult Pack(PackingRequest request)
{
// Filter oversized items first
var validItems = new List<BinItem>();
var oversizedItems = new List<BinItem>();
foreach (var item in request.Items)
{
if (item.Length > request.StockLength)
oversizedItems.Add(item);
else
validItems.Add(item);
}
// Fall back to AdvancedFit for large item counts
if (validItems.Count > _maxItems)
{
var fallbackResult = _fallbackEngine.Pack(request);
return fallbackResult;
}
// Sort items descending for better pruning
var sortedItems = validItems.OrderByDescending(i => i.Length).ToList();
// Find optimal solution using exhaustive search
var bestSolution = new SearchState
{
Bins = new List<List<BinItem>>(),
BinCount = int.MaxValue
};
var currentState = new SearchState
{
Bins = new List<List<BinItem>>(),
BinCount = 0
};
Search(sortedItems, 0, currentState, bestSolution, request);
// Build result from best solution
var result = new PackResult();
result.AddItemsNotUsed(oversizedItems);
foreach (var binItems in bestSolution.Bins)
{
var bin = new Bin(request.StockLength) { Spacing = request.Spacing };
foreach (var item in binItems.OrderByDescending(i => i.Length))
{
bin.AddItem(item);
}
result.AddBin(bin);
}
// Sort bins by utilization
var sortedBins = result.Bins
.OrderByDescending(b => b.Utilization)
.ThenBy(b => b.Items.Count)
.ToList();
var finalResult = new PackResult();
finalResult.AddItemsNotUsed(oversizedItems);
finalResult.AddBins(sortedBins);
return finalResult;
}
private void Search(
List<BinItem> items,
int itemIndex,
SearchState current,
SearchState best,
PackingRequest request)
{
// All items placed - check if this is better
if (itemIndex >= items.Count)
{
if (current.BinCount < best.BinCount ||
(current.BinCount == best.BinCount && GetTotalWaste(current, request) < GetTotalWaste(best, request)))
{
best.BinCount = current.BinCount;
best.Bins = current.Bins.Select(b => b.ToList()).ToList();
}
return;
}
// Pruning: if we already have more bins than best, stop
if (current.BinCount >= best.BinCount)
return;
// Respect max bin count
if (current.BinCount >= request.MaxBinCount)
return;
var item = items[itemIndex];
// Symmetry breaking: if this item has the same length as the previous item,
// only place it in bins with index >= where previous item went.
// This avoids redundant exploration of equivalent permutations.
int minBinIndex = 0;
if (itemIndex > 0 && items[itemIndex - 1].Length == item.Length)
{
minBinIndex = current.LastBinIndexUsed;
}
// Try placing in each existing bin (respecting symmetry constraint)
for (int i = minBinIndex; i < current.Bins.Count; i++)
{
var binUsed = GetBinUsedLength(current.Bins[i], request.Spacing);
var remaining = request.StockLength - binUsed;
// Item fits if adding it (with spacing) stays within tolerance
// Bin class allows going over by up to spacing amount
if (item.Length <= remaining)
{
// Place item in this bin
current.Bins[i].Add(item);
var prevBinIndex = current.LastBinIndexUsed;
current.LastBinIndexUsed = i;
Search(items, itemIndex + 1, current, best, request);
current.LastBinIndexUsed = prevBinIndex;
current.Bins[i].RemoveAt(current.Bins[i].Count - 1);
}
}
// Try placing in a new bin (if allowed)
if (current.BinCount < request.MaxBinCount && current.BinCount < best.BinCount)
{
int newBinIndex = current.Bins.Count;
current.Bins.Add(new List<BinItem> { item });
current.BinCount++;
var prevBinIndex = current.LastBinIndexUsed;
current.LastBinIndexUsed = newBinIndex;
Search(items, itemIndex + 1, current, best, request);
current.LastBinIndexUsed = prevBinIndex;
current.Bins.RemoveAt(current.Bins.Count - 1);
current.BinCount--;
}
}
private double GetBinUsedLength(List<BinItem> binItems, double spacing)
{
if (binItems.Count == 0)
return 0;
return binItems.Sum(i => i.Length) + binItems.Count * spacing;
}
private double GetTotalWaste(SearchState state, PackingRequest request)
{
double totalWaste = 0;
foreach (var bin in state.Bins)
{
var used = GetBinUsedLength(bin, request.Spacing);
totalWaste += request.StockLength - used;
}
return totalWaste;
}
private class SearchState
{
public List<List<BinItem>> Bins { get; set; } = new();
public int BinCount { get; set; }
public int LastBinIndexUsed { get; set; }
}
}
}

View File

@@ -1,7 +1,16 @@
namespace CutList.Core.Nesting namespace CutList.Core.Nesting
{ {
/// <summary>
/// Interface for bin packing engines.
/// Engines are stateless - all configuration is passed via PackingRequest.
/// </summary>
public interface IEngine public interface IEngine
{ {
Result Pack(List<BinItem> items); /// <summary>
/// Packs items into bins according to the request configuration.
/// </summary>
/// <param name="request">The packing configuration and items.</param>
/// <returns>The packing result with bins and unused items.</returns>
PackResult Pack(PackingRequest request);
} }
} }

View File

@@ -7,12 +7,10 @@ namespace CutList.Core.Nesting
public interface IEngineFactory public interface IEngineFactory
{ {
/// <summary> /// <summary>
/// Creates a configured engine instance for bin packing. /// Creates an engine instance for the specified packing strategy.
/// </summary> /// </summary>
/// <param name="stockLength">The length of stock bins</param> /// <param name="strategy">The packing strategy to use.</param>
/// <param name="spacing">The spacing/kerf between items</param> /// <returns>A configured IEngine instance.</returns>
/// <param name="maxBinCount">Maximum number of bins to create</param> IEngine CreateEngine(PackingStrategy strategy = PackingStrategy.AdvancedFit);
/// <returns>A configured IEngine instance</returns>
IEngine CreateEngine(double stockLength, double spacing, int maxBinCount);
} }
} }

View File

@@ -1,8 +1,13 @@
namespace CutList.Core.Nesting namespace CutList.Core.Nesting
{ {
public class MultiBinEngine : IEngine /// <summary>
/// Engine that coordinates packing across multiple bin types with different sizes.
/// Uses priority ordering to determine which bin types to fill first.
/// </summary>
public class MultiBinEngine
{ {
private readonly IEngineFactory _engineFactory; private readonly IEngineFactory _engineFactory;
private readonly List<MultiBin> _bins;
public MultiBinEngine() : this(new EngineFactory()) public MultiBinEngine() : this(new EngineFactory())
{ {
@@ -14,16 +19,14 @@
_bins = new List<MultiBin>(); _bins = new List<MultiBin>();
} }
private readonly List<MultiBin> _bins;
/// <summary> /// <summary>
/// Gets the read-only collection of bins. /// Gets the read-only collection of bin types.
/// Use SetBins() to configure bins for packing. /// Use SetBins() to configure bins for packing.
/// </summary> /// </summary>
public IReadOnlyList<MultiBin> Bins => _bins.AsReadOnly(); public IReadOnlyList<MultiBin> Bins => _bins.AsReadOnly();
/// <summary> /// <summary>
/// Sets the bins to use for packing. /// Sets the bin types to use for packing.
/// </summary> /// </summary>
public void SetBins(IEnumerable<MultiBin> bins) public void SetBins(IEnumerable<MultiBin> bins)
{ {
@@ -34,26 +37,48 @@
} }
} }
/// <summary>
/// The spacing/kerf between items.
/// </summary>
public double Spacing { get; set; } public double Spacing { get; set; }
public Result Pack(List<BinItem> items) /// <summary>
/// The packing strategy to use for each bin type.
/// </summary>
public PackingStrategy Strategy { get; set; } = PackingStrategy.AdvancedFit;
/// <summary>
/// Packs items across all configured bin types.
/// </summary>
public PackResult Pack(List<BinItem> items)
{ {
var bins = _bins var sortedBinTypes = _bins
.Where(b => b.Length > 0) .Where(b => b.Length > 0)
.OrderBy(b => b.Priority) .OrderBy(b => b.Priority)
.ThenBy(b => b.Length) .ThenBy(b => b.Length)
.ToList(); .ToList();
var result = new Result(); var result = new PackResult();
var remainingItems = new List<BinItem>(items); var remainingItems = new List<BinItem>(items);
foreach (var bin in bins) var engine = _engineFactory.CreateEngine(Strategy);
{
var engine = _engineFactory.CreateEngine(bin.Length, Spacing, bin.Quantity);
var r = engine.Pack(remainingItems);
result.AddBins(r.Bins); foreach (var binType in sortedBinTypes)
remainingItems = r.ItemsNotUsed.ToList(); {
if (remainingItems.Count == 0)
break;
var request = new PackingRequest(
items: remainingItems,
stockLength: binType.Length,
spacing: Spacing,
maxBinCount: binType.Quantity
);
var packResult = engine.Pack(request);
result.AddBins(packResult.Bins);
remainingItems = packResult.ItemsNotUsed.ToList();
} }
result.AddItemsNotUsed(remainingItems); result.AddItemsNotUsed(remainingItems);
@@ -61,4 +86,4 @@
return result; return result;
} }
} }
} }

Some files were not shown because too many files have changed in this diff Show More