diff --git a/OpenNest.Core/PlateOptimizerResult.cs b/OpenNest.Core/PlateOptimizerResult.cs deleted file mode 100644 index eb793ce..0000000 --- a/OpenNest.Core/PlateOptimizerResult.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Collections.Generic; - -namespace OpenNest -{ - public class PlateOptimizerResult - { - public List Parts { get; set; } = new(); - public PlateOption ChosenSize { get; set; } - public double NetCost { get; set; } - public double Utilization { get; set; } - } -} diff --git a/OpenNest.Engine/Jobs/Placement/PlateFillService.cs b/OpenNest.Engine/Jobs/Placement/PlateFillService.cs index 3be45c2..e7d482e 100644 --- a/OpenNest.Engine/Jobs/Placement/PlateFillService.cs +++ b/OpenNest.Engine/Jobs/Placement/PlateFillService.cs @@ -122,11 +122,8 @@ public static class PlateFillService ); } - /// - /// Builds the filler for an optional strategy (null/empty = Default). Internal so the - /// engine-side multi-plate orchestrators share one resolution/rejection contract. - /// - internal static PlateFillerBase CreateFiller(string strategy, Plate plate) + /// Builds the filler for an optional strategy (null/empty = Default). + private static PlateFillerBase CreateFiller(string strategy, Plate plate) { ArgumentNullException.ThrowIfNull(plate); return ResolveStrategy(strategy) switch @@ -142,8 +139,8 @@ public static class PlateFillService private static PlateFillerBase RequireFiller(string strategy, Plate plate) { ArgumentNullException.ThrowIfNull(strategy); - // An explicit empty string is an unknown strategy, not the orchestrator's - // null-means-Default defaulting; only the orchestrator boundary may default. + // An explicit empty string is an unknown strategy; only ResolveStrategy(string), + // used for caller selections, maps null/empty to Default. ResolveStrategy(strategy, allowEmpty: false); return CreateFiller(strategy, plate); } diff --git a/OpenNest.Engine/MultiPlateNester.cs b/OpenNest.Engine/MultiPlateNester.cs deleted file mode 100644 index 7114275..0000000 --- a/OpenNest.Engine/MultiPlateNester.cs +++ /dev/null @@ -1,718 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using OpenNest.Engine.Fill; -using OpenNest.Engine.Jobs.Placement; -using OpenNest.Engine.Jobs.Placement.Fillers; -using OpenNest.Geometry; -using OpenNest.Math; - -namespace OpenNest.Engine -{ - public enum PartClass - { - Large, - Medium, - Small, - } - - public class MultiPlateNester - { - private readonly Plate _template; - private readonly List _plateOptions; - private readonly List _sortedOptions; - private readonly double _salvageRate; - private readonly double _minRemnantSize; - private readonly List _platePool; - private readonly IProgress _progress; - private readonly CancellationToken _token; - private readonly MultiPlateNestOptions _options; - private readonly string _strategy; - - private bool HasPlateOptions => _plateOptions != null && _plateOptions.Count > 0; - - private MultiPlateNester( - MultiPlateNestOptions options, - List existingPlates, - IProgress progress, - CancellationToken token - ) - { - _options = options; - _template = options.Template; - _plateOptions = options.PlateOptions; - _sortedOptions = options.PlateOptions?.OrderBy(o => o.Cost).ToList(); - _salvageRate = options.SalvageRate; - _minRemnantSize = options.MinRemnantSize; - _platePool = InitializePlatePool(existingPlates); - _progress = progress; - _token = token; - _strategy = PlateFillService.ResolveStrategy(options.Strategy); - } - - private PlateFillerBase CreateFiller(Plate plate) => - PlateFillService.CreateFiller(_strategy, plate); - - // --- Static Utility Methods --- - - public static bool FitsBounds(Box container, Box part) - { - var fitsNormal = - container.Width >= part.Width - Tolerance.Epsilon - && container.Length >= part.Length - Tolerance.Epsilon; - var fitsRotated = - container.Width >= part.Length - Tolerance.Epsilon - && container.Length >= part.Width - Tolerance.Epsilon; - return fitsNormal || fitsRotated; - } - - public static List SortItems(List items, PartSortOrder sortOrder) - { - var withBounds = items - .Select(i => (Item: i, Bounds: i.Drawing.Program.BoundingBox())) - .ToList(); - - switch (sortOrder) - { - case PartSortOrder.BoundingBoxArea: - return withBounds - .OrderByDescending(x => x.Bounds.Width * x.Bounds.Length) - .Select(x => x.Item) - .ToList(); - - case PartSortOrder.Size: - return withBounds - .OrderByDescending(x => System.Math.Max(x.Bounds.Width, x.Bounds.Length)) - .Select(x => x.Item) - .ToList(); - - default: - return items.ToList(); - } - } - - public static PartClass Classify(Box partBounds, Box workArea) - { - var halfWidth = workArea.Width / 2.0; - var halfLength = workArea.Length / 2.0; - - if (partBounds.Width > halfWidth || partBounds.Length > halfLength) - return PartClass.Large; - - var workAreaArea = workArea.Width * workArea.Length; - var partArea = partBounds.Width * partBounds.Length; - - if (partArea > workAreaArea / 9.0) - return PartClass.Medium; - - return PartClass.Small; - } - - public static bool IsScrapRemnant(Box remnant, double minRemnantSize) - { - return remnant.Width < minRemnantSize && remnant.Length < minRemnantSize; - } - - public static List FindRemnants(Plate plate, double minRemnantSize, bool scrapOnly) - { - var remnants = RemnantFinder.FromPlate(plate).FindRemnants(); - return remnants.Where(r => IsScrapRemnant(r, minRemnantSize) == scrapOnly).ToList(); - } - - 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; - - var sorted = options.OrderBy(o => o.Cost).ToList(); - - foreach (var option in sorted) - { - if (FitsBounds(OptionWorkArea(option, template), minBounds)) - { - plate.Size = new Size(option.Width, option.Length); - return plate; - } - } - - 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, - }; - } - - // --- Main Entry Point --- - - public static MultiPlateResult Nest( - List items, - MultiPlateNestOptions options, - List existingPlates = null, - IProgress progress = null, - CancellationToken token = default - ) - { - var nester = new MultiPlateNester(options, existingPlates, progress, token); - return nester.Run(items, options.SortOrder, options.AllowPlateCreation); - } - - // --- Private Helpers --- - - private static Box OptionWorkArea(PlateOption option, Plate template) - { - var w = option.Width - template.EdgeSpacing.Left - template.EdgeSpacing.Right; - var h = option.Length - template.EdgeSpacing.Top - template.EdgeSpacing.Bottom; - return new Box(0, 0, w, h); - } - - private static double ScoreZone(Box zone, Box partBounds) - { - if (!FitsBounds(zone, partBounds)) - return -1; - - var cols = (int)(zone.Width / partBounds.Width); - var rows = (int)(zone.Length / partBounds.Length); - var colsR = (int)(zone.Width / partBounds.Length); - var rowsR = (int)(zone.Length / partBounds.Width); - var estimatedCount = System.Math.Max(cols * rows, colsR * rowsR); - - var utilization = (estimatedCount * partBounds.Width * partBounds.Length) / zone.Area(); - - var zoneAspect = zone.Width / zone.Length; - var partAspect = partBounds.Width / partBounds.Length; - var aspectMatch = - System.Math.Min(zoneAspect, partAspect) / System.Math.Max(zoneAspect, partAspect); - - return utilization * 0.7 + aspectMatch * 0.3; - } - - private static void DecrementQuantity(NestItem item, int placed) - { - item.Quantity = System.Math.Max(0, item.Quantity - placed); - } - - private int FillAndPlace(PlateResult pr, Box zone, NestItem item) - { - var engine = CreateFiller(pr.Plate); - var clonedItem = CloneItem(item); - var parts = engine.Fill(clonedItem, zone, _progress, _token); - - if (parts.Count > 0) - { - pr.AddParts(parts); - DecrementQuantity(item, parts.Count); - } - - return parts.Count; - } - - private PlateResult CreateNewPlateResult(Plate plate) - { - var pr = new PlateResult { Plate = plate, IsNew = true }; - - if (HasPlateOptions) - { - pr.ChosenSize = _plateOptions.FirstOrDefault(o => - o.Width.IsEqualTo(plate.Size.Width) && o.Length.IsEqualTo(plate.Size.Length) - ); - } - - return pr; - } - - private static NestItem CloneItem(NestItem item) - { - return new NestItem - { - Drawing = item.Drawing, - Priority = item.Priority, - Quantity = item.Quantity, - StepAngle = item.StepAngle, - RotationStart = item.RotationStart, - RotationEnd = item.RotationEnd, - }; - } - - private static List InitializePlatePool(List existingPlates) - { - var pool = new List(); - - if (existingPlates != null) - { - foreach (var plate in existingPlates) - pool.Add(new PlateResult { Plate = plate, IsNew = false }); - } - - return pool; - } - - private bool TryWithUpgradedSize( - PlateResult pr, - PlateOption upgradeOption, - Func, bool> tryFill - ) - { - var oldSize = pr.Plate.Size; - var oldChosenSize = pr.ChosenSize; - - pr.Plate.Size = new Size(upgradeOption.Width, upgradeOption.Length); - pr.ChosenSize = upgradeOption; - - var remnants = RemnantFinder.FromPlate(pr.Plate).FindRemnants(); - - if (remnants.Count > 0 && tryFill(remnants)) - return true; - - pr.Plate.Size = oldSize; - pr.ChosenSize = oldChosenSize; - return false; - } - - private PlateOption FindSmallestFittingOption(Box partBounds) - { - return _sortedOptions?.FirstOrDefault(o => - FitsBounds(OptionWorkArea(o, _template), partBounds) - ); - } - - // --- Orchestration --- - - private MultiPlateResult Run( - List items, - PartSortOrder sortOrder, - bool allowPlateCreation - ) - { - var result = new MultiPlateResult(); - - if (items == null || items.Count == 0) - return result; - - var sorted = SortItems(items.Where(i => i.Quantity > 0).ToList(), sortOrder); - - foreach (var item in sorted) - { - if (_token.IsCancellationRequested || item.Quantity <= 0) - continue; - - var bb = item.Drawing.Program.BoundingBox(); - - TryPlaceOnExistingPlates(item, bb); - - var templateClass = Classify(bb, _template.WorkArea()); - - if (item.Quantity > 0 && allowPlateCreation && templateClass != PartClass.Small) - { - PlaceOnNewPlates(item, bb); - - if (item.Quantity > 0 && HasPlateOptions) - TryUpgradeOrNewPlate(item, bb); - } - } - - var leftovers = sorted.Where(i => i.Quantity > 0).ToList(); - - if (leftovers.Count > 0 && allowPlateCreation && !_token.IsCancellationRequested) - { - PackIntoExistingRemnants(leftovers); - CreateSharedPlates(leftovers); - } - - if (HasPlateOptions && !_token.IsCancellationRequested) - TryConsolidateTailPlates(); - - foreach (var item in sorted.Where(i => i.Quantity > 0)) - result.UnplacedItems.Add(item); - - result.Plates.AddRange(_platePool.Where(p => p.Parts.Count > 0 || p.IsNew)); - return result; - } - - private void PackIntoExistingRemnants(List leftovers) - { - foreach (var pr in _platePool) - { - if (_token.IsCancellationRequested) - break; - - var anyPlaced = true; - while (anyPlaced && !_token.IsCancellationRequested) - { - anyPlaced = false; - - var remaining = leftovers.Where(i => i.Quantity > 0).ToList(); - if (remaining.Count == 0) - break; - - var remnants = RemnantFinder.FromPlate(pr.Plate).FindRemnants(); - if (remnants.Count == 0) - break; - - var engine = CreateFiller(pr.Plate); - - foreach (var remnant in remnants) - { - remaining = leftovers.Where(i => i.Quantity > 0).ToList(); - if (remaining.Count == 0) - break; - - var cloned = remaining.Select(CloneItem).ToList(); - var parts = engine.PackArea(remnant, cloned, _progress, _token); - - if (parts.Count > 0) - { - pr.AddParts(parts); - anyPlaced = true; - - foreach (var item in remaining) - { - var placed = parts.Count(p => p.BaseDrawing == item.Drawing); - DecrementQuantity(item, placed); - } - } - } - } - } - } - - private void CreateSharedPlates(List leftovers) - { - leftovers.RemoveAll(i => i.Quantity <= 0); - - while (leftovers.Count > 0 && !_token.IsCancellationRequested) - { - var plate = CreatePlate(_template, _plateOptions, null); - var pr = CreateNewPlateResult(plate); - var placedAny = false; - - foreach (var item in leftovers) - { - if (item.Quantity <= 0 || _token.IsCancellationRequested) - continue; - - var remnants = !placedAny - ? new List { plate.WorkArea() } - : RemnantFinder.FromPlate(plate).FindRemnants(); - - if (remnants.Count == 0) - break; - - var engine = CreateFiller(plate); - - foreach (var remnant in remnants) - { - if (item.Quantity <= 0) - break; - - var clonedItem = CloneItem(item); - var parts = engine.Fill(clonedItem, remnant, _progress, _token); - - if (parts.Count > 0) - { - pr.AddParts(parts); - DecrementQuantity(item, parts.Count); - placedAny = true; - } - } - } - - if (!placedAny) - break; - - _platePool.Add(pr); - leftovers.RemoveAll(i => i.Quantity <= 0); - } - } - - private bool TryPlaceOnExistingPlates(NestItem item, Box partBounds) - { - var anyPlaced = false; - var remnantCache = new Dictionary>(); - PlateResult lastModified = null; - - while (item.Quantity > 0 && !_token.IsCancellationRequested) - { - PlateResult bestPlate = null; - Box bestZone = null; - var bestScore = double.MinValue; - - foreach (var pr in _platePool) - { - if (_token.IsCancellationRequested) - break; - - if (pr == lastModified || !remnantCache.ContainsKey(pr)) - { - var workArea = pr.Plate.WorkArea(); - var classification = Classify(partBounds, workArea); - - remnantCache[pr] = - classification == PartClass.Small - ? FindRemnants(pr.Plate, _minRemnantSize, scrapOnly: true) - : FindRemnants(pr.Plate, _minRemnantSize, scrapOnly: false); - } - - foreach (var zone in remnantCache[pr]) - { - var score = ScoreZone(zone, partBounds); - if (score > bestScore) - { - bestPlate = pr; - bestZone = zone; - bestScore = score; - } - } - } - - if (bestPlate == null || bestZone == null) - break; - - if (FillAndPlace(bestPlate, bestZone, item) == 0) - break; - - lastModified = bestPlate; - anyPlaced = true; - } - - return anyPlaced; - } - - private bool PlaceOnNewPlates(NestItem item, Box partBounds) - { - var anyPlaced = false; - - while (item.Quantity > 0 && !_token.IsCancellationRequested) - { - var plate = CreatePlate(_template, _plateOptions, partBounds); - var workArea = plate.WorkArea(); - - if (!FitsBounds(workArea, partBounds)) - break; - - var pr = CreateNewPlateResult(plate); - - if (FillAndPlace(pr, workArea, item) == 0) - break; - - _platePool.Add(pr); - anyPlaced = true; - } - - return anyPlaced; - } - - private bool TryUpgradeOrNewPlate(NestItem item, Box partBounds) - { - if (!HasPlateOptions) - return false; - - foreach (var pr in _platePool.Where(p => p.IsNew && p.ChosenSize != null)) - { - var currentOption = pr.ChosenSize; - var currentIdx = _sortedOptions.FindIndex(o => - o.Width.IsEqualTo(currentOption.Width) - && o.Length.IsEqualTo(currentOption.Length) - ); - - if (currentIdx < 0 || currentIdx >= _sortedOptions.Count - 1) - continue; - - for (var i = currentIdx + 1; i < _sortedOptions.Count; i++) - { - var upgradeOption = _sortedOptions[i]; - - if ( - upgradeOption.Width < currentOption.Width - Tolerance.Epsilon - || upgradeOption.Length < currentOption.Length - Tolerance.Epsilon - ) - continue; - - var smallestNew = FindSmallestFittingOption(partBounds); - - if (smallestNew == null) - continue; - - var utilEst = pr.Plate.Utilization(); - var decision = EvaluateUpgradeVsNew( - currentOption, - upgradeOption, - smallestNew, - _salvageRate, - utilEst - ); - - if (decision.ShouldUpgrade) - { - var placed = TryWithUpgradedSize( - pr, - upgradeOption, - remnants => - { - foreach (var remnant in remnants) - { - if (FillAndPlace(pr, remnant, item) > 0) - return true; - } - return false; - } - ); - - if (placed) - return true; - } - } - } - - return false; - } - - private void TryConsolidateTailPlates() - { - var consolidated = true; - while (consolidated) - { - consolidated = false; - - var activePlates = _platePool.Where(p => p.Parts.Count > 0 && p.IsNew).ToList(); - if (activePlates.Count < 2) - return; - - var donors = activePlates.OrderBy(p => p.Plate.Utilization()).ToList(); - - foreach (var donor in donors) - { - if (donor.Parts.Count == 0) - continue; - - var donorParts = donor.Parts.ToList(); - var absorbed = false; - - foreach (var target in activePlates) - { - if (target == donor || target.ChosenSize == null || target.Parts.Count == 0) - continue; - - var currentOption = target.ChosenSize; - - foreach ( - var upgradeOption in _sortedOptions.Where(o => - o.Width >= currentOption.Width - Tolerance.Epsilon - && o.Length >= currentOption.Length - Tolerance.Epsilon - && ( - o.Width > currentOption.Width + Tolerance.Epsilon - || o.Length > currentOption.Length + Tolerance.Epsilon - ) - ) - ) - { - absorbed = TryWithUpgradedSize( - target, - upgradeOption, - remnants => - { - var engine = CreateFiller(target.Plate); - var tempItems = donorParts - .GroupBy(p => p.BaseDrawing) - .Select(g => new NestItem - { - Drawing = g.Key, - Quantity = g.Count(), - }) - .ToList(); - - var totalPlaced = new List(); - foreach (var remnant in remnants) - { - var placed = engine.PackArea( - remnant, - tempItems, - _progress, - _token - ); - totalPlaced.AddRange(placed); - - foreach (var ti in tempItems) - { - var count = placed.Count(p => - p.BaseDrawing == ti.Drawing - ); - ti.Quantity = System.Math.Max(0, ti.Quantity - count); - } - - if (tempItems.All(ti => ti.Quantity <= 0)) - break; - } - - if (totalPlaced.Count >= donorParts.Count) - { - target.AddParts(totalPlaced); - - foreach (var p in donorParts) - donor.Plate.Parts.Remove(p); - donor.Parts.Clear(); - _platePool.Remove(donor); - return true; - } - - return false; - } - ); - - if (absorbed) - break; - } - - if (absorbed) - break; - } - - if (absorbed) - { - consolidated = true; - break; - } - } - } - } - } -} diff --git a/OpenNest.Engine/MultiPlateResult.cs b/OpenNest.Engine/MultiPlateResult.cs deleted file mode 100644 index 59ccb6e..0000000 --- a/OpenNest.Engine/MultiPlateResult.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System.Collections.Generic; - -namespace OpenNest.Engine -{ - public class MultiPlateNestOptions - { - public Plate Template { get; set; } - public List PlateOptions { get; set; } - public double SalvageRate { get; set; } = 0.5; - public PartSortOrder SortOrder { get; set; } = PartSortOrder.BoundingBoxArea; - public double MinRemnantSize { get; set; } = 12.0; - public bool AllowPlateCreation { get; set; } = true; - - /// - /// Explicit placement strategy for every single-plate fill/pack this run performs - /// ("Default", "Strip", "Vertical Remnant", "Horizontal Remnant"). Null or empty means - /// "Default"; unknown names are rejected up front. This replaces reading the - /// process-global engine registry. - /// - public string Strategy { get; set; } - } - - public class MultiPlateResult - { - public List Plates { get; set; } = new(); - public List UnplacedItems { get; set; } = new(); - } - - public class PlateResult - { - public Plate Plate { get; set; } - public List Parts { get; set; } = new(); - public PlateOption ChosenSize { get; set; } - public bool IsNew { get; set; } - - public void AddParts(IList parts) - { - Plate.Parts.AddRange(parts); - Parts.AddRange(parts); - } - } -} diff --git a/OpenNest.Engine/PartSortOrder.cs b/OpenNest.Engine/PartSortOrder.cs deleted file mode 100644 index b0e0581..0000000 --- a/OpenNest.Engine/PartSortOrder.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace OpenNest.Engine -{ - public enum PartSortOrder - { - BoundingBoxArea, - Size, - } -} diff --git a/OpenNest.Engine/PlateOptimizer.cs b/OpenNest.Engine/PlateOptimizer.cs deleted file mode 100644 index 47347c2..0000000 --- a/OpenNest.Engine/PlateOptimizer.cs +++ /dev/null @@ -1,235 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Threading; -using OpenNest.Engine; -using OpenNest.Engine.BestFit; -using OpenNest.Engine.Jobs.Placement; -using OpenNest.Geometry; -using OpenNest.Math; - -namespace OpenNest.Engine -{ - public static class PlateOptimizer - { - public static PlateOptimizerResult Optimize( - List items, - List plateOptions, - double salvageRate, - Plate templatePlate, - IProgress progress = null, - CancellationToken token = default, - string strategy = null - ) - { - if ( - items == null - || items.Count == 0 - || plateOptions == null - || plateOptions.Count == 0 - ) - return null; - - // Explicit strategy at the top-level boundary (null/empty = Default, unknown = throw); - // the size search never consults the process-global engine registry. - var resolvedStrategy = PlateFillService.ResolveStrategy(strategy); - - // Find the minimum dimension needed to fit the largest part, - // skipping items that are too large for every plate option. - var minPartWidth = 0.0; - var minPartLength = 0.0; - foreach (var item in items) - { - if (item.Quantity <= 0) - continue; - var bb = item.Drawing.Program.BoundingBox(); - var shortSide = System.Math.Min(bb.Width, bb.Length); - var longSide = System.Math.Max(bb.Width, bb.Length); - - if ( - !plateOptions.Any(o => - FitsPart(o, shortSide, longSide, templatePlate.EdgeSpacing) - ) - ) - { - Debug.WriteLine( - $"[PlateOptimizer] Skipping oversized item '{item.Drawing.Name}' " - + $"({shortSide:F1}x{longSide:F1}) — does not fit any plate option" - ); - continue; - } - - if (shortSide > minPartWidth) - minPartWidth = shortSide; - if (longSide > minPartLength) - minPartLength = longSide; - } - - // Sort candidates by cost ascending — try cheapest first. - var candidates = plateOptions - .Where(o => FitsPart(o, minPartWidth, minPartLength, templatePlate.EdgeSpacing)) - .OrderBy(o => o.Cost) - .ToList(); - - if (candidates.Count == 0) - return null; - - // Pre-compute best fits for all candidate plate sizes at once. - // This runs the expensive GPU evaluation once on the largest plate - // and filters the results for each smaller size. - var plateSizes = candidates.Select(o => (Width: o.Length, Height: o.Width)).ToList(); - - foreach (var item in items) - { - if (item.Quantity <= 0) - continue; - BestFitCache.ComputeForSizes(item.Drawing, templatePlate.PartSpacing, plateSizes); - } - - PlateOptimizerResult best = null; - - foreach (var option in candidates) - { - if (token.IsCancellationRequested) - break; - - var result = TryPlateSize( - option, - items, - salvageRate, - templatePlate, - progress, - token, - resolvedStrategy - ); - if (result == null) - continue; - - if (IsBetter(result, best)) - best = result; - - // Early exit: when all items fit, larger plates can only have - // worse utilization and higher cost. With salvage < 100%, the - // remnant credit never offsets the extra plate cost, so skip. - if (salvageRate < 1.0) - { - var allPlaced = items.All(i => - i.Quantity <= 0 - || result.Parts.Count(p => p.BaseDrawing.Name == i.Drawing.Name) - >= i.Quantity - ); - if (allPlaced) - { - Debug.WriteLine( - $"[PlateOptimizer] Early exit: {option.Width}x{option.Length} placed all items" - ); - break; - } - } - } - - return best; - } - - private static bool FitsPart( - PlateOption option, - double minWidth, - double minLength, - Spacing edgeSpacing - ) - { - var workW = option.Width - edgeSpacing.Left - edgeSpacing.Right; - var workL = option.Length - edgeSpacing.Top - edgeSpacing.Bottom; - - // Part fits in either orientation. - var fitsNormal = - workW >= minWidth - Tolerance.Epsilon && workL >= minLength - Tolerance.Epsilon; - var fitsRotated = - workW >= minLength - Tolerance.Epsilon && workL >= minWidth - Tolerance.Epsilon; - return fitsNormal || fitsRotated; - } - - private static PlateOptimizerResult TryPlateSize( - PlateOption option, - List items, - double salvageRate, - Plate templatePlate, - IProgress progress, - CancellationToken token, - string strategy - ) - { - // Create a temporary plate with candidate size + settings from template. - var tempPlate = new Plate(option.Width, option.Length) - { - PartSpacing = templatePlate.PartSpacing, - EdgeSpacing = new Spacing - { - Left = templatePlate.EdgeSpacing.Left, - Right = templatePlate.EdgeSpacing.Right, - Top = templatePlate.EdgeSpacing.Top, - Bottom = templatePlate.EdgeSpacing.Bottom, - }, - }; - - // Clone items so the dry run doesn't mutate originals. - var clonedItems = items - .Select(i => new NestItem - { - Drawing = i.Drawing, // share Drawing reference for BestFitCache compatibility - Priority = i.Priority, - Quantity = i.Quantity, - StepAngle = i.StepAngle, - RotationStart = i.RotationStart, - RotationEnd = i.RotationEnd, - }) - .ToList(); - - var parts = PlateFillService.Nest(strategy, tempPlate, clonedItems, progress, token); - - if (parts == null || parts.Count == 0) - return null; - - var workArea = tempPlate.WorkArea(); - var plateArea = workArea.Width * workArea.Length; - var partsArea = 0.0; - foreach (var part in parts) - partsArea += part.BoundingBox.Area(); - - var remnantArea = plateArea - partsArea; - var costPerSqUnit = option.Cost / option.Area; - var netCost = option.Cost - (remnantArea * costPerSqUnit * salvageRate); - - Debug.WriteLine( - $"[PlateOptimizer] {option.Width}x{option.Length} ${option.Cost}: " - + $"{parts.Count} parts, util={partsArea / plateArea:P1}, net=${netCost:F2}" - ); - - return new PlateOptimizerResult - { - Parts = parts, - ChosenSize = option, - NetCost = netCost, - Utilization = plateArea > 0 ? partsArea / plateArea : 0, - }; - } - - private static bool IsBetter(PlateOptimizerResult candidate, PlateOptimizerResult current) - { - if (current == null) - return true; - - // 1. More parts placed is always better. - if (candidate.Parts.Count != current.Parts.Count) - return candidate.Parts.Count > current.Parts.Count; - - // 2. Lower net cost. - if (!candidate.NetCost.IsEqualTo(current.NetCost)) - return candidate.NetCost < current.NetCost; - - // 3. Higher utilization (tighter density) as tiebreak. - return candidate.Utilization > current.Utilization; - } - } -} diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs index 40e3772..2c60753 100644 --- a/OpenNest.IO/NestReader.cs +++ b/OpenNest.IO/NestReader.cs @@ -364,7 +364,7 @@ namespace OpenNest.IO pd.EdgeSpacing.Top ); - // Plate optimizer settings + // Stock options: sheet sizes and salvage rate nest.SalvageRate = dto.SalvageRate; if (dto.PlateOptions != null) { diff --git a/OpenNest.Tests/Engine/ExplicitPlacementStrategyTests.cs b/OpenNest.Tests/Engine/ExplicitPlacementStrategyTests.cs index 7f8959d..1f435bf 100644 --- a/OpenNest.Tests/Engine/ExplicitPlacementStrategyTests.cs +++ b/OpenNest.Tests/Engine/ExplicitPlacementStrategyTests.cs @@ -6,8 +6,8 @@ using OpenNest.Geometry; namespace OpenNest.Tests.Engine; /// -/// Phase 3.2: MultiPlateNester and PlateOptimizer take an explicit placement strategy at their -/// top-level call boundary instead of consulting process-global selection state. +/// Callers name the placement strategy explicitly at the +/// boundary instead of consulting process-global selection state. /// public class ExplicitPlacementStrategyTests { @@ -22,106 +22,29 @@ public class ExplicitPlacementStrategyTests return new Drawing(name, pgm); } - private static NestItem MakeItem(string name, double w, double h, int qty) => - new() { Drawing = MakeRectDrawing(w, h, name), Quantity = qty }; - - private static MultiPlateNestOptions MakeOptions() + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ResolveStrategy_UnsetMeansDefault(string strategy) { - var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 }; - template.EdgeSpacing = new Spacing(); - return new MultiPlateNestOptions { Template = template }; + Assert.Equal("Default", PlateFillService.ResolveStrategy(strategy)); } - [Fact] - public void MultiPlateNester_UnsetStrategy_MatchesExplicitDefault() + [Theory] + [InlineData("strip", "Strip")] + [InlineData("VERTICAL REMNANT", "Vertical Remnant")] + public void ResolveStrategy_ReturnsCanonicalBuiltInName(string strategy, string expected) { - var unsetOptions = MakeOptions(); - var explicitOptions = MakeOptions(); - explicitOptions.Strategy = "Default"; - Assert.Null(unsetOptions.Strategy); - - var unsetResult = MultiPlateNester.Nest( - new List { MakeItem("a", 20, 10, 3), MakeItem("b", 12, 8, 4) }, - unsetOptions - ); - var explicitResult = MultiPlateNester.Nest( - new List { MakeItem("a", 20, 10, 3), MakeItem("b", 12, 8, 4) }, - explicitOptions - ); - - Assert.NotEmpty(unsetResult.Plates); - Assert.Equal( - unsetResult.Plates.Sum(p => p.Parts.Count), - explicitResult.Plates.Sum(p => p.Parts.Count) - ); + Assert.Equal(expected, PlateFillService.ResolveStrategy(strategy)); } [Theory] [InlineData("Mystery Engine")] [InlineData("StockLadder")] - public void MultiPlateNester_RejectsUnknownStrategy(string strategy) + public void ResolveStrategy_RejectsUnknownStrategy(string strategy) { - var options = MakeOptions(); - options.Strategy = strategy; - - Assert.Throws(() => - MultiPlateNester.Nest(new List { MakeItem("a", 20, 10, 1) }, options) - ); - } - - [Fact] - public void MultiPlateNester_HonorsExplicitStripStrategy() - { - var options = MakeOptions(); - options.Strategy = "Strip"; - - var result = MultiPlateNester.Nest( - new List { MakeItem("a", 20, 10, 3), MakeItem("b", 12, 8, 4) }, - options - ); - - Assert.NotEmpty(result.Plates); - Assert.Equal(0, result.UnplacedItems.Count); - } - - [Fact] - public void PlateOptimizer_UnsetStrategy_MatchesExplicitDefault() - { - var options = new List - { - new() { Width = 20, Length = 20, Cost = 100 }, - new() { Width = 40, Length = 40, Cost = 400 }, - }; - var templatePlate = new Plate(40, 40) { PartSpacing = 0 }; - var items = new List { MakeItem("rect", 10, 10, 1) }; - - var defaulted = PlateOptimizer.Optimize(items, options, 0.0, templatePlate); - var explicitDefault = PlateOptimizer.Optimize( - items, - options, - 0.0, - templatePlate, - strategy: "Default" - ); - - Assert.NotNull(defaulted); - Assert.NotNull(explicitDefault); - Assert.Equal(defaulted.ChosenSize.Width, explicitDefault.ChosenSize.Width); - Assert.Equal(defaulted.Parts.Count, explicitDefault.Parts.Count); - } - - [Theory] - [InlineData("Mystery Engine")] - [InlineData("StockLadder")] - public void PlateOptimizer_RejectsUnknownStrategy(string strategy) - { - var options = new List { new() { Width = 20, Length = 20, Cost = 100 } }; - var templatePlate = new Plate(40, 40) { PartSpacing = 0 }; - var items = new List { MakeItem("rect", 10, 10, 1) }; - - Assert.Throws(() => - PlateOptimizer.Optimize(items, options, 0.0, templatePlate, strategy: strategy) - ); + Assert.Throws(() => PlateFillService.ResolveStrategy(strategy)); } [Fact] diff --git a/OpenNest.Tests/Engine/MultiPlateNesterTests.cs b/OpenNest.Tests/Engine/MultiPlateNesterTests.cs deleted file mode 100644 index 720abd5..0000000 --- a/OpenNest.Tests/Engine/MultiPlateNesterTests.cs +++ /dev/null @@ -1,511 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using OpenNest.Geometry; -using OpenNest.IO; -using Xunit; -using Xunit.Abstractions; -using OpenNest.Engine; - -namespace OpenNest.Tests.Engine; - -public class MultiPlateNesterTests -{ - private readonly ITestOutputHelper _output; - - public MultiPlateNesterTests(ITestOutputHelper output) - { - _output = output; - } - - private static Drawing MakeDrawing(string name, double width, double length) - { - var program = new OpenNest.CNC.Program(); - program.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0))); - program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(width, 0))); - program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(width, length))); - program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, length))); - program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0))); - var drawing = new Drawing(name, program); - drawing.UpdateArea(); - return drawing; - } - - private static NestItem MakeItem(string name, double width, double length, int qty = 1) - { - return new NestItem { Drawing = MakeDrawing(name, width, length), Quantity = qty }; - } - - [Fact] - public void SortByBoundingBoxArea_OrdersLargestFirst() - { - var items = new List - { - MakeItem("small", 10, 10), - MakeItem("large", 40, 60), - MakeItem("medium", 20, 30), - }; - - var sorted = MultiPlateNester.SortItems(items, PartSortOrder.BoundingBoxArea); - - Assert.Equal("large", sorted[0].Drawing.Name); - Assert.Equal("medium", sorted[1].Drawing.Name); - Assert.Equal("small", sorted[2].Drawing.Name); - } - - [Fact] - public void SortBySize_OrdersByLongestDimension() - { - var items = new List - { - MakeItem("short-wide", 50, 20), // longest = 50 - MakeItem("tall-narrow", 10, 80), // longest = 80 - MakeItem("square", 30, 30), // longest = 30 - }; - - var sorted = MultiPlateNester.SortItems(items, PartSortOrder.Size); - - Assert.Equal("tall-narrow", sorted[0].Drawing.Name); - Assert.Equal("short-wide", sorted[1].Drawing.Name); - Assert.Equal("square", sorted[2].Drawing.Name); - } - - // --- Task 4: Part Classification --- - - [Fact] - public void Classify_LargePart_WhenWidthExceedsHalfWorkArea() - { - var workArea = new Box(0, 0, 96, 48); - var bb = new Box(0, 0, 50, 20); // width 50 > half of 96 = 48 - var result = MultiPlateNester.Classify(bb, workArea); - Assert.Equal(PartClass.Large, result); - } - - [Fact] - public void Classify_LargePart_WhenLengthExceedsHalfWorkArea() - { - var workArea = new Box(0, 0, 96, 48); - var bb = new Box(0, 0, 20, 30); // length 30 > half of 48 = 24 - var result = MultiPlateNester.Classify(bb, workArea); - Assert.Equal(PartClass.Large, result); - } - - [Fact] - public void Classify_MediumPart_NotLargeButAreaAboveThreshold() - { - var workArea = new Box(0, 0, 96, 48); - // workArea = 4608, 1/9 = 512. bb = 40*15 = 600 > 512 - // 40 < 48 (half of 96), 15 < 24 (half of 48) — not Large - var bb = new Box(0, 0, 40, 15); - var result = MultiPlateNester.Classify(bb, workArea); - Assert.Equal(PartClass.Medium, result); - } - - [Fact] - public void Classify_SmallPart() - { - var workArea = new Box(0, 0, 96, 48); - // workArea = 4608, 1/9 = 512. bb = 10*10 = 100 < 512 - var bb = new Box(0, 0, 10, 10); - var result = MultiPlateNester.Classify(bb, workArea); - Assert.Equal(PartClass.Small, result); - } - - // --- Task 5: Scrap Zone Identification --- - - [Fact] - public void IsScrapRemnant_BothDimensionsBelowThreshold_ReturnsTrue() - { - var remnant = new Box(0, 0, 10, 8); - Assert.True(MultiPlateNester.IsScrapRemnant(remnant, 12.0)); - } - - [Fact] - public void IsScrapRemnant_OneDimensionAboveThreshold_ReturnsFalse() - { - // 11 x 120 — narrow but long, should be preserved - var remnant = new Box(0, 0, 11, 120); - Assert.False(MultiPlateNester.IsScrapRemnant(remnant, 12.0)); - } - - [Fact] - public void IsScrapRemnant_BothDimensionsAboveThreshold_ReturnsFalse() - { - var remnant = new Box(0, 0, 20, 30); - Assert.False(MultiPlateNester.IsScrapRemnant(remnant, 12.0)); - } - - [Fact] - public void FindRemnants_ScrapOnly_ReturnsOnlyScrapRemnants() - { - // 96x48 plate with a 70x40 part placed at origin - var plate = new Plate(96, 48) { PartSpacing = 0.25 }; - var drawing = MakeDrawing("big", 70, 40); - var part = new Part(drawing); - plate.Parts.Add(part); - - var scrap = MultiPlateNester.FindRemnants(plate, 12.0, scrapOnly: true); - - // All returned zones should have both dims < 12 - foreach (var zone in scrap) - { - Assert.True( - zone.Width < 12.0 && zone.Length < 12.0, - $"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); - } - - // --- Task 7: Main Orchestration --- - - [Fact] - public void Nest_LargePartsGetOwnPlates() - { - var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 }; - template.EdgeSpacing = new Spacing(); - - var items = new List { MakeItem("big1", 80, 40, 1), MakeItem("big2", 70, 35, 1) }; - - var options = new MultiPlateNestOptions { Template = template }; - - var result = MultiPlateNester.Nest(items, options); - - // Each large part should be on its own plate. - Assert.True( - result.Plates.Count >= 2, - $"Expected at least 2 plates, got {result.Plates.Count}" - ); - } - - [Fact] - public void Nest_SmallPartsConsolidateOntoSharedPlates() - { - // Small parts should be packed together on shared plates rather than - // each drawing getting its own plate. The consolidation pass fills - // small parts into remaining space on existing plates. - var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 }; - template.EdgeSpacing = new Spacing(); - - var items = new List - { - MakeItem("big", 80, 40, 1), - MakeItem("tinyA", 5, 5, 3), - MakeItem("tinyB", 4, 4, 3), - }; - - var options = new MultiPlateNestOptions { Template = template }; - - var result = MultiPlateNester.Nest(items, options); - - // Both small drawing types should share space — not each on their own plate. - // With consolidation, they pack into remaining space alongside the big part. - Assert.True( - result.Plates.Count <= 2, - $"Expected at most 2 plates (small parts consolidated), got {result.Plates.Count}" - ); - Assert.Equal(0, result.UnplacedItems.Count); - } - - [Fact] - public void Nest_RespectsAllowPlateCreation() - { - var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 }; - template.EdgeSpacing = new Spacing(); - - var items = new List { MakeItem("big1", 80, 40, 1), MakeItem("big2", 70, 35, 1) }; - - var options = new MultiPlateNestOptions { Template = template, AllowPlateCreation = false }; - - var result = MultiPlateNester.Nest(items, options); - - // No existing plates and no plate creation — nothing can be placed. - Assert.Empty(result.Plates); - Assert.Equal(2, result.UnplacedItems.Count); - } - - [Fact] - public void Nest_UsesExistingPlates() - { - var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 }; - template.EdgeSpacing = new Spacing(); - - var existingPlate = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 }; - existingPlate.EdgeSpacing = new Spacing(); - - // Use a part small enough to be classified as Medium on a 96x48 plate. - // Plate WorkArea: Width=96, Length=48. Half: 48, 24. - // Part 24x22: Length=24 (not > 24), Width=22 (not > 48) — not Large. - // Area = 528 > 4608/9 = 512 — Medium. - var items = new List { MakeItem("medium", 24, 22, 1) }; - - var options = new MultiPlateNestOptions { Template = template }; - - var result = MultiPlateNester.Nest( - items, - options, - existingPlates: new List { existingPlate } - ); - - // Part should be placed on the existing plate, not a new one. - Assert.Single(result.Plates); - Assert.False(result.Plates[0].IsNew); - } - - [Fact] - public void Nest_RealNestFile_PartFirst() - { - var nestPath = @"C:\Users\aisaacs\Desktop\4526 A14 - 0.188 AISI 304.nest"; - if (!File.Exists(nestPath)) - { - _output.WriteLine("SKIP: nest file not found"); - return; - } - - var nest = new NestReader(nestPath).Read(); - var template = nest.PlateDefaults.CreateNew(); - - _output.WriteLine( - $"Plate: {template.Size.Width}x{template.Size.Length}, " - + $"spacing={template.PartSpacing}, edge=({template.EdgeSpacing.Left},{template.EdgeSpacing.Bottom},{template.EdgeSpacing.Right},{template.EdgeSpacing.Top})" - ); - - var wa = template.WorkArea(); - _output.WriteLine($"Work area: {wa.Width:F1}x{wa.Length:F1}"); - _output.WriteLine( - $"Classification thresholds: Large if dim > {wa.Width / 2:F1} or {wa.Length / 2:F1}, " - + $"Medium if area > {wa.Width * wa.Length / 9:F0}" - ); - _output.WriteLine("---"); - - var items = new List(); - foreach (var d in nest.Drawings) - { - var qty = d.Quantity.Required > 0 ? d.Quantity.Required : d.Quantity.Remaining; - if (qty <= 0) - qty = 1; - - var bb = d.Program.BoundingBox(); - var classification = MultiPlateNester.Classify(bb, wa); - - _output.WriteLine( - $" {d.Name, -25} {bb.Width:F1}x{bb.Length:F1} (area={bb.Width * bb.Length:F0}) qty={qty} class={classification}" - ); - - items.Add( - new NestItem - { - Drawing = d, - Quantity = qty, - StepAngle = d.Constraints.StepAngle, - RotationStart = d.Constraints.StartAngle, - RotationEnd = d.Constraints.EndAngle, - } - ); - } - - _output.WriteLine("---"); - _output.WriteLine($"Total: {items.Count} drawings, {items.Sum(i => i.Quantity)} parts"); - - var plateOptions = new List - { - new() - { - Width = 48, - Length = 96, - Cost = 0, - }, - new() - { - Width = 48, - Length = 120, - Cost = 0, - }, - new() - { - Width = 48, - Length = 144, - Cost = 0, - }, - new() - { - Width = 60, - Length = 96, - Cost = 0, - }, - new() - { - Width = 60, - Length = 120, - Cost = 0, - }, - new() - { - Width = 60, - Length = 144, - Cost = 0, - }, - new() - { - Width = 72, - Length = 96, - Cost = 0, - }, - new() - { - Width = 72, - Length = 120, - Cost = 0, - }, - new() - { - Width = 72, - Length = 144, - Cost = 0, - }, - }; - - _output.WriteLine( - $"Plate options: {string.Join(", ", plateOptions.Select(o => $"{o.Width}x{o.Length}"))}" - ); - _output.WriteLine(""); - - var options = new MultiPlateNestOptions - { - Template = template, - PlateOptions = plateOptions, - }; - - var result = MultiPlateNester.Nest(items, options); - - _output.WriteLine($"=== RESULTS: {result.Plates.Count} plates ==="); - - for (var i = 0; i < result.Plates.Count; i++) - { - var pr = result.Plates[i]; - var groups = pr - .Parts.GroupBy(p => p.BaseDrawing.Name) - .Select(g => $"{g.Key} x{g.Count()}") - .ToList(); - _output.WriteLine( - $" Plate {i + 1} ({pr.Plate.Size.Width}x{pr.Plate.Size.Length}): " - + $"{pr.Parts.Count} parts, util={pr.Plate.Utilization():P1} [{string.Join(", ", groups)}]" - ); - } - - if (result.UnplacedItems.Count > 0) - { - _output.WriteLine( - $" Unplaced: {string.Join(", ", result.UnplacedItems.Select(i => $"{i.Drawing.Name} x{i.Quantity}"))}" - ); - } - - _output.WriteLine($"\nTotal parts placed: {result.Plates.Sum(p => p.Parts.Count)}"); - _output.WriteLine($"Total plates used: {result.Plates.Count}"); - } -} diff --git a/OpenNest.Tests/Engine/PlateOptimizerTests.cs b/OpenNest.Tests/Engine/PlateOptimizerTests.cs deleted file mode 100644 index 425f6fc..0000000 --- a/OpenNest.Tests/Engine/PlateOptimizerTests.cs +++ /dev/null @@ -1,173 +0,0 @@ -using OpenNest.Geometry; -using OpenNest.Engine; - -namespace OpenNest.Tests.Engine; - -public class PlateOptimizerTests -{ - private static Drawing MakeRectDrawing(double w, double h, string name = "rect") - { - var pgm = new OpenNest.CNC.Program(); - pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0))); - pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, 0))); - pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, h))); - pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, h))); - pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0))); - return new Drawing(name, pgm); - } - - [Fact] - public void PicksCheapestPlateThatFitsParts() - { - var options = new List - { - new() - { - Width = 20, - Length = 20, - Cost = 100, - }, - new() - { - Width = 40, - Length = 40, - Cost = 400, - }, - }; - - var templatePlate = new Plate(40, 40) { PartSpacing = 0 }; - var items = new List - { - new() { Drawing = MakeRectDrawing(10, 10), Quantity = 1 }, - }; - - var result = PlateOptimizer.Optimize(items, options, 0.0, templatePlate); - - Assert.NotNull(result); - Assert.Equal(20, result.ChosenSize.Width); - Assert.True(result.Parts.Count >= 1); - } - - [Fact] - public void PrefersMorePartsOverCheaperPlate() - { - var options = new List - { - new() - { - Width = 12, - Length = 12, - Cost = 50, - }, - new() - { - Width = 24, - Length = 12, - Cost = 100, - }, - }; - - var templatePlate = new Plate(24, 12) { PartSpacing = 0 }; - var items = new List - { - new() { Drawing = MakeRectDrawing(10, 10), Quantity = 2 }, - }; - - var result = PlateOptimizer.Optimize(items, options, 0.0, templatePlate); - - Assert.NotNull(result); - Assert.Equal(24, result.ChosenSize.Width); - Assert.Equal(2, result.Parts.Count); - } - - [Fact] - public void SalvageRateReducesNetCost() - { - // Small: 20x20=400sqin, cost $400. Part=10x10=100sqin. Remnant=300. - // Net = 400 - 300*(400/400)*1.0 = 400-300 = 100 - // Large: 40x40=1600sqin, cost $800. Part=10x10=100sqin. Remnant=1500. - // Net = 800 - 1500*(800/1600)*1.0 = 800-750 = 50 - var options = new List - { - new() - { - Width = 20, - Length = 20, - Cost = 400, - }, - new() - { - Width = 40, - Length = 40, - Cost = 800, - }, - }; - - var templatePlate = new Plate(40, 40) { PartSpacing = 0 }; - templatePlate.EdgeSpacing = new Spacing(); - var items = new List - { - new() { Drawing = MakeRectDrawing(10, 10), Quantity = 1 }, - }; - - var result = PlateOptimizer.Optimize(items, options, 1.0, templatePlate); - - Assert.NotNull(result); - Assert.Equal(40, result.ChosenSize.Width); - } - - [Fact] - public void SkipsPlatesThatAreTooSmall() - { - var options = new List - { - new() - { - Width = 20, - Length = 20, - Cost = 100, - }, - new() - { - Width = 40, - Length = 40, - Cost = 400, - }, - }; - - var templatePlate = new Plate(40, 40) { PartSpacing = 0 }; - var items = new List - { - new() { Drawing = MakeRectDrawing(30, 30), Quantity = 1 }, - }; - - var result = PlateOptimizer.Optimize(items, options, 0.0, templatePlate); - - Assert.NotNull(result); - Assert.Equal(40, result.ChosenSize.Width); - } - - [Fact] - public void ReturnsNullWhenNoPlatesFit() - { - var options = new List - { - new() - { - Width = 10, - Length = 10, - Cost = 50, - }, - }; - - var templatePlate = new Plate(10, 10) { PartSpacing = 0 }; - var items = new List - { - new() { Drawing = MakeRectDrawing(20, 20), Quantity = 1 }, - }; - - var result = PlateOptimizer.Optimize(items, options, 0.0, templatePlate); - - Assert.Null(result); - } -} diff --git a/docs/automatic-nesting.md b/docs/automatic-nesting.md index 1018c82..8cc8014 100644 --- a/docs/automatic-nesting.md +++ b/docs/automatic-nesting.md @@ -38,6 +38,8 @@ Callers must hold drawings and target state stable from snapshot through attachm Interactive fills are outside this contract and still use `PlateFillService`. The benchmark invokes the same independent validator. +The pre-pipeline multi-plate orchestrator and plate-size optimizer have been removed from `OpenNest.Engine`; this is a source and binary break for external code that called them. Whole-job callers use `NestPipeline.Run` (or implement `INestingEngine` as a plug-in); multiple stock sizes and salvage credit are expressed as job stock and `NestJobOptions`. + ## Console and MCP Console `--autonest` uses the selected jobs engine against one physical sheet. The selected plate's old parts are replaced only after acceptance; other plates are unchanged. Default demand is still one of each drawing unless `--quantity` is supplied. A partially fulfilled, valid result may be saved; a successful placement is not a claim that all demand was met.