From a04586f7df74ad883a20b67af64caf1af8e27c54 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 20 Mar 2026 14:43:18 -0400 Subject: [PATCH] feat: add AutoNester.Optimize post-pass and NfpNestEngine Add Optimize method that re-places parts using NFP-based BLF, keeping the result only if it improves density without losing parts. Fix perimeter inflation to use correct offset side. Add NfpNestEngine that wraps AutoNester for the registry. Register NFP engine. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/NestEngineRegistry.cs | 4 + OpenNest.Engine/Nfp/AutoNester.cs | 127 +++++++++++++++++++++++++- OpenNest.Engine/NfpNestEngine.cs | 68 ++++++++++++++ 3 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 OpenNest.Engine/NfpNestEngine.cs diff --git a/OpenNest.Engine/NestEngineRegistry.cs b/OpenNest.Engine/NestEngineRegistry.cs index 82973ea..2a7e9da 100644 --- a/OpenNest.Engine/NestEngineRegistry.cs +++ b/OpenNest.Engine/NestEngineRegistry.cs @@ -20,6 +20,10 @@ namespace OpenNest Register("Strip", "Strip-based nesting for mixed-drawing layouts", plate => new StripNestEngine(plate)); + + Register("NFP", + "NFP-based mixed-part nesting with simulated annealing", + plate => new NfpNestEngine(plate)); } public static IReadOnlyList AvailableEngines => engines; diff --git a/OpenNest.Engine/Nfp/AutoNester.cs b/OpenNest.Engine/Nfp/AutoNester.cs index 55d1d38..a439c5a 100644 --- a/OpenNest.Engine/Nfp/AutoNester.cs +++ b/OpenNest.Engine/Nfp/AutoNester.cs @@ -1,8 +1,10 @@ using OpenNest.Converters; using OpenNest.Geometry; using OpenNest.Math; +using System; using System.Collections.Generic; using System.Diagnostics; +using System.IO; using System.Linq; using System.Threading; @@ -15,6 +17,7 @@ namespace OpenNest.Engine.Nfp public static class AutoNester { public static List Nest(List items, Plate plate, + IProgress progress = null, CancellationToken cancellation = default) { var workArea = plate.WorkArea(); @@ -60,7 +63,7 @@ namespace OpenNest.Engine.Nfp // Run simulated annealing optimizer. var optimizer = new SimulatedAnnealing(); - var result = optimizer.Optimize(items, workArea, nfpCache, candidateRotations, cancellation); + var result = optimizer.Optimize(items, workArea, nfpCache, candidateRotations, progress, cancellation); if (result.Sequence == null || result.Sequence.Count == 0) return new List(); @@ -72,9 +75,129 @@ namespace OpenNest.Engine.Nfp Debug.WriteLine($"[AutoNest] Result: {parts.Count} parts placed, {result.Iterations} SA iterations"); + NestEngineBase.ReportProgress(progress, NestPhase.Nfp, 0, parts, workArea, + $"NFP: {parts.Count} parts, {result.Iterations} iterations", isOverallBest: true); + return parts; } + /// + /// Re-places already-positioned parts using NFP-based BLF. + /// Returns the tighter layout if BLF improves density without losing parts. + /// + public static List Optimize(List parts, Plate plate) + { + return Optimize(parts, plate.WorkArea(), plate.PartSpacing); + } + + /// + /// Re-places already-positioned parts using NFP-based BLF within the given work area. + /// Returns the tighter layout if BLF improves density without losing parts. + /// + public static List Optimize(List parts, Box workArea, double partSpacing) + { + if (parts == null || parts.Count < 2) + return parts; + + var halfSpacing = partSpacing / 2.0; + var nfpCache = new NfpCache(); + var registeredRotations = new HashSet<(int id, double rotation)>(); + + // Extract polygons for each unique drawing+rotation used by the placed parts. + foreach (var part in parts) + { + var drawing = part.BaseDrawing; + var rotation = part.Rotation; + var key = (drawing.Id, rotation); + + if (registeredRotations.Contains(key)) + continue; + + var perimeterPolygon = ExtractPerimeterPolygon(drawing, halfSpacing); + + if (perimeterPolygon == null) + continue; + + var rotatedPolygon = RotatePolygon(perimeterPolygon, rotation); + nfpCache.RegisterPolygon(drawing.Id, rotation, rotatedPolygon); + registeredRotations.Add(key); + } + + if (registeredRotations.Count == 0) + return parts; + + nfpCache.PreComputeAll(); + + // Build BLF sequence sorted by area descending (largest first packs best). + var sequence = parts + .OrderByDescending(p => p.BaseDrawing.Area) + .Select(p => new SequenceEntry(p.BaseDrawing.Id, p.Rotation, p.BaseDrawing)) + .ToList(); + + var blf = new BottomLeftFill(workArea, nfpCache); + var placed = blf.Fill(sequence); + var optimized = BottomLeftFill.ToNestParts(placed); + + // Only use the NFP result if it kept all parts and improved density. + if (optimized.Count < parts.Count) + { + Debug.WriteLine($"[AutoNest.Optimize] Rejected: placed {optimized.Count}/{parts.Count} parts"); + return parts; + } + + // Reject if any part landed outside the work area. + if (!AllPartsInBounds(optimized, workArea)) + { + Debug.WriteLine("[AutoNest.Optimize] Rejected: parts outside work area"); + return parts; + } + + var originalScore = Fill.FillScore.Compute(parts, workArea); + var optimizedScore = Fill.FillScore.Compute(optimized, workArea); + + if (optimizedScore > originalScore) + { + Debug.WriteLine($"[AutoNest.Optimize] Improved: density {originalScore.Density:P1} -> {optimizedScore.Density:P1}"); + return optimized; + } + + Debug.WriteLine($"[AutoNest.Optimize] No improvement: {originalScore.Density:P1} >= {optimizedScore.Density:P1}"); + return parts; + } + + private static bool AllPartsInBounds(List parts, Box workArea) + { + var logPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "nest-debug.log"); + + var allInBounds = true; + + // Append to the log that BLF already started + using var log = new StreamWriter(logPath, true); + log.WriteLine($"\n[Bounds] workArea: X={workArea.X} Y={workArea.Y} W={workArea.Width} H={workArea.Length} Right={workArea.Right} Top={workArea.Top}"); + + foreach (var part in parts) + { + var bb = part.BoundingBox; + var outLeft = bb.Left < workArea.X - Tolerance.Epsilon; + var outBottom = bb.Bottom < workArea.Y - Tolerance.Epsilon; + var outRight = bb.Right > workArea.Right + Tolerance.Epsilon; + var outTop = bb.Top > workArea.Top + Tolerance.Epsilon; + var oob = outLeft || outBottom || outRight || outTop; + + if (oob) + { + log.WriteLine($"[Bounds] OOB DrawingId={part.BaseDrawing.Id} \"{part.BaseDrawing.Name}\" loc=({part.Location.X:F4},{part.Location.Y:F4}) rot={part.Rotation:F3} bb=({bb.Left:F4},{bb.Bottom:F4})-({bb.Right:F4},{bb.Top:F4}) violations: {(outLeft ? "LEFT " : "")}{(outBottom ? "BOTTOM " : "")}{(outRight ? "RIGHT " : "")}{(outTop ? "TOP " : "")}"); + allInBounds = false; + } + } + + if (allInBounds) + log.WriteLine($"[Bounds] All {parts.Count} parts in bounds."); + + return allInBounds; + } + /// /// Extracts the perimeter polygon from a drawing, inflated by half-spacing. /// @@ -98,7 +221,7 @@ namespace OpenNest.Engine.Nfp if (halfSpacing > 0) { - var offsetEntity = perimeter.OffsetEntity(halfSpacing, OffsetSide.Right); + var offsetEntity = perimeter.OffsetEntity(halfSpacing, OffsetSide.Left); inflated = offsetEntity as Shape ?? perimeter; } else diff --git a/OpenNest.Engine/NfpNestEngine.cs b/OpenNest.Engine/NfpNestEngine.cs new file mode 100644 index 0000000..9617820 --- /dev/null +++ b/OpenNest.Engine/NfpNestEngine.cs @@ -0,0 +1,68 @@ +using OpenNest.Engine.Fill; +using OpenNest.Engine.Nfp; +using OpenNest.Geometry; +using System; +using System.Collections.Generic; +using System.Threading; + +namespace OpenNest +{ + public class NfpNestEngine : NestEngineBase + { + public NfpNestEngine(Plate plate) : base(plate) + { + } + + public override string Name => "NFP"; + + public override string Description => "NFP-based mixed-part nesting with simulated annealing"; + + public override List Fill(NestItem item, Box workArea, + IProgress progress, CancellationToken token) + { + var inner = new DefaultNestEngine(Plate); + return inner.Fill(item, workArea, progress, token); + } + + public override List Fill(List groupParts, Box workArea, + IProgress progress, CancellationToken token) + { + var inner = new DefaultNestEngine(Plate); + return inner.Fill(groupParts, workArea, progress, token); + } + + public override List PackArea(Box box, List items, + IProgress progress, CancellationToken token) + { + var inner = new DefaultNestEngine(Plate); + return inner.PackArea(box, items, progress, token); + } + + public override List Nest(List items, + IProgress progress, CancellationToken token) + { + if (items == null || items.Count == 0) + return new List(); + + var parts = AutoNester.Nest(items, Plate, progress, token); + + // Compact placed parts toward the origin to close gaps. + Compactor.Settle(parts, Plate.WorkArea(), Plate.PartSpacing); + + // NFP optimization pass — re-place parts using geometry-aware BLF. + parts = AutoNester.Optimize(parts, Plate); + + // Deduct placed quantities from original items. + foreach (var item in items) + { + if (item.Quantity <= 0) + continue; + + var placed = parts.FindAll(p => p.BaseDrawing.Name == item.Drawing.Name).Count; + item.Quantity = System.Math.Max(0, item.Quantity - placed); + } + + return parts; + } + } +}