From a4773748a1d5ceaf3df405d04bfeab7254f5e0ed Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 6 Apr 2026 13:56:08 -0400 Subject: [PATCH] feat: add plate creation and upgrade-vs-new evaluation Co-Authored-By: Claude Sonnet 4.6 --- OpenNest.Engine/MultiPlateNester.cs | 72 +++++++++++++++++++ .../Engine/MultiPlateNesterTests.cs | 57 +++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/OpenNest.Engine/MultiPlateNester.cs b/OpenNest.Engine/MultiPlateNester.cs index c838a79..8c9a84e 100644 --- a/OpenNest.Engine/MultiPlateNester.cs +++ b/OpenNest.Engine/MultiPlateNester.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using OpenNest.Engine.Fill; using OpenNest.Geometry; +using OpenNest.Math; namespace OpenNest { @@ -92,5 +93,76 @@ namespace OpenNest return viable; } + + public struct UpgradeDecision + { + public bool ShouldUpgrade; + public double UpgradeCost; + public double NewPlateCost; + } + + public static Plate CreatePlate(Plate template, List options, Box minBounds) + { + var plate = new Plate(template.Size) + { + PartSpacing = template.PartSpacing, + Quadrant = template.Quadrant, + }; + plate.EdgeSpacing = new Spacing + { + Left = template.EdgeSpacing.Left, + Right = template.EdgeSpacing.Right, + Top = template.EdgeSpacing.Top, + Bottom = template.EdgeSpacing.Bottom, + }; + + if (options == null || options.Count == 0 || minBounds == null) + return plate; + + // Find smallest option that fits the part (by cost ascending). + var sorted = options.OrderBy(o => o.Cost).ToList(); + + foreach (var option in sorted) + { + var workW = option.Width - template.EdgeSpacing.Left - template.EdgeSpacing.Right; + var workL = option.Length - template.EdgeSpacing.Top - template.EdgeSpacing.Bottom; + + var fitsNormal = workW >= minBounds.Width - Tolerance.Epsilon + && workL >= minBounds.Length - Tolerance.Epsilon; + var fitsRotated = workW >= minBounds.Length - Tolerance.Epsilon + && workL >= minBounds.Width - Tolerance.Epsilon; + + if (fitsNormal || fitsRotated) + { + plate.Size = new Size(option.Width, option.Length); + return plate; + } + } + + // No option fits — use template size. + return plate; + } + + public static UpgradeDecision EvaluateUpgradeVsNew( + PlateOption currentSize, + PlateOption upgradeSize, + PlateOption newPlateSize, + double salvageRate, + double estimatedNewPlateUtilization) + { + var upgradeCost = upgradeSize.Cost - currentSize.Cost; + + var newPlateCost = newPlateSize.Cost; + var remnantFraction = 1.0 - estimatedNewPlateUtilization; + var salvageCredit = remnantFraction * newPlateSize.Cost * salvageRate; + var netNewCost = newPlateCost - salvageCredit; + + return new UpgradeDecision + { + ShouldUpgrade = upgradeCost < netNewCost, + UpgradeCost = upgradeCost, + NewPlateCost = netNewCost, + }; + } } } diff --git a/OpenNest.Tests/Engine/MultiPlateNesterTests.cs b/OpenNest.Tests/Engine/MultiPlateNesterTests.cs index c2c41a9..30cd185 100644 --- a/OpenNest.Tests/Engine/MultiPlateNesterTests.cs +++ b/OpenNest.Tests/Engine/MultiPlateNesterTests.cs @@ -146,4 +146,61 @@ public class MultiPlateNesterTests $"Zone {zone.Width:F1}x{zone.Length:F1} is not scrap — at least one dimension >= 12"); } } + + // --- Task 6: Plate Creation Helper --- + + [Fact] + public void CreatePlate_UsesTemplateWhenNoOptions() + { + var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 }; + template.EdgeSpacing = new Spacing { Left = 1, Right = 1, Top = 1, Bottom = 1 }; + + var plate = MultiPlateNester.CreatePlate(template, null, null); + + Assert.Equal(96, plate.Size.Width); + Assert.Equal(48, plate.Size.Length); + Assert.Equal(0.25, plate.PartSpacing); + Assert.Equal(1, plate.Quadrant); + } + + [Fact] + public void CreatePlate_PicksSmallestFittingOption() + { + var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 }; + template.EdgeSpacing = new Spacing { Left = 1, Right = 1, Top = 1, Bottom = 1 }; + + var options = new List + { + new() { Width = 48, Length = 96, Cost = 100 }, + new() { Width = 60, Length = 120, Cost = 200 }, + new() { Width = 72, Length = 144, Cost = 300 }, + }; + + // Part needs 50x50 work area — 48x96 (after edge spacing: 46x94) — 46 < 50, doesn't fit. + // 60x120 (58x118) does fit. + var minBounds = new Box(0, 0, 50, 50); + + var plate = MultiPlateNester.CreatePlate(template, options, minBounds); + + Assert.Equal(60, plate.Size.Width); + Assert.Equal(120, plate.Size.Length); + } + + [Fact] + public void EvaluateUpgrade_PrefersCheaperOption() + { + var currentOption = new PlateOption { Width = 48, Length = 96, Cost = 100 }; + var upgradeOption = new PlateOption { Width = 60, Length = 120, Cost = 160 }; + var newPlateOption = new PlateOption { Width = 48, Length = 96, Cost = 100 }; + + // Upgrade cost = 160 - 100 = 60 + // New plate cost with 50% utilization, 50% salvage: + // remnantFraction = 0.5, salvageCredit = 0.5 * 100 * 0.5 = 25 + // netNewCost = 100 - 25 = 75 + // Upgrade (60) < new plate (75), so upgrade wins + var decision = MultiPlateNester.EvaluateUpgradeVsNew( + currentOption, upgradeOption, newPlateOption, 0.5, 0.5); + + Assert.True(decision.ShouldUpgrade); + } }