From ae010212ac6f75620600adf50433e7453e28943a Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 22:51:57 -0400 Subject: [PATCH] refactor(engine): extract AutoNester and reorganize NestEngine Move NFP-based AutoNest logic (polygon extraction, rotation computation, simulated annealing) into dedicated AutoNester class. Consolidate duplicate FillWithPairs overloads, extract BuildCandidateAngles and BuildProgressSummary, reorganize NestEngine into logical sections. Update callers in Console, MCP tools, and MainForm to use AutoNester.Nest. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Console/Program.cs | 2 +- OpenNest.Engine/AutoNester.cs | 223 +++++++ OpenNest.Engine/NestEngine.cs | 969 ++++++++++------------------- OpenNest.Mcp/Tools/NestingTools.cs | 2 +- OpenNest/Forms/MainForm.cs | 10 +- 5 files changed, 553 insertions(+), 653 deletions(-) create mode 100644 OpenNest.Engine/AutoNester.cs diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs index c66deda..53bb30f 100644 --- a/OpenNest.Console/Program.cs +++ b/OpenNest.Console/Program.cs @@ -327,7 +327,7 @@ static class NestConsole Console.WriteLine($"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts"); - var nestParts = NestEngine.AutoNest(nestItems, plate); + var nestParts = AutoNester.Nest(nestItems, plate); plate.Parts.AddRange(nestParts); success = nestParts.Count > 0; } diff --git a/OpenNest.Engine/AutoNester.cs b/OpenNest.Engine/AutoNester.cs new file mode 100644 index 0000000..0140544 --- /dev/null +++ b/OpenNest.Engine/AutoNester.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using OpenNest.Converters; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest +{ + /// + /// Mixed-part geometry-aware nesting using NFP-based collision avoidance + /// and simulated annealing optimization. + /// + public static class AutoNester + { + public static List Nest(List items, Plate plate, + CancellationToken cancellation = default) + { + var workArea = plate.WorkArea(); + var halfSpacing = plate.PartSpacing / 2.0; + var nfpCache = new NfpCache(); + var candidateRotations = new Dictionary>(); + + // Extract perimeter polygons for each unique drawing. + foreach (var item in items) + { + var drawing = item.Drawing; + + if (candidateRotations.ContainsKey(drawing.Id)) + continue; + + var perimeterPolygon = ExtractPerimeterPolygon(drawing, halfSpacing); + + if (perimeterPolygon == null) + { + Debug.WriteLine($"[AutoNest] Skipping drawing '{drawing.Name}': no valid perimeter"); + continue; + } + + // Compute candidate rotations for this drawing. + var rotations = ComputeCandidateRotations(item, perimeterPolygon, workArea); + candidateRotations[drawing.Id] = rotations; + + // Register polygons at each candidate rotation. + foreach (var rotation in rotations) + { + var rotatedPolygon = RotatePolygon(perimeterPolygon, rotation); + nfpCache.RegisterPolygon(drawing.Id, rotation, rotatedPolygon); + } + } + + if (candidateRotations.Count == 0) + return new List(); + + // Pre-compute all NFPs. + nfpCache.PreComputeAll(); + + Debug.WriteLine($"[AutoNest] NFP cache: {nfpCache.Count} entries for {candidateRotations.Count} drawings"); + + // Run simulated annealing optimizer. + var optimizer = new SimulatedAnnealing(); + var result = optimizer.Optimize(items, workArea, nfpCache, candidateRotations, cancellation); + + if (result.Sequence == null || result.Sequence.Count == 0) + return new List(); + + // Final BLF placement with the best solution. + var blf = new BottomLeftFill(workArea, nfpCache); + var placedParts = blf.Fill(result.Sequence); + var parts = BottomLeftFill.ToNestParts(placedParts); + + Debug.WriteLine($"[AutoNest] Result: {parts.Count} parts placed, {result.Iterations} SA iterations"); + + return parts; + } + + /// + /// Extracts the perimeter polygon from a drawing, inflated by half-spacing. + /// + private static Polygon ExtractPerimeterPolygon(Drawing drawing, double halfSpacing) + { + var entities = ConvertProgram.ToGeometry(drawing.Program) + .Where(e => e.Layer != SpecialLayers.Rapid) + .ToList(); + + if (entities.Count == 0) + return null; + + var definedShape = new ShapeProfile(entities); + var perimeter = definedShape.Perimeter; + + if (perimeter == null) + return null; + + // Inflate by half-spacing if spacing is non-zero. + Shape inflated; + + if (halfSpacing > 0) + { + var offsetEntity = perimeter.OffsetEntity(halfSpacing, OffsetSide.Right); + inflated = offsetEntity as Shape ?? perimeter; + } + else + { + inflated = perimeter; + } + + // Convert to polygon with circumscribed arcs for tight nesting. + var polygon = inflated.ToPolygonWithTolerance(0.01, circumscribe: true); + + if (polygon.Vertices.Count < 3) + return null; + + // Normalize: move reference point to origin. + polygon.UpdateBounds(); + var bb = polygon.BoundingBox; + polygon.Offset(-bb.Left, -bb.Bottom); + + return polygon; + } + + /// + /// Computes candidate rotation angles for a drawing. + /// + private static List ComputeCandidateRotations(NestItem item, + Polygon perimeterPolygon, Box workArea) + { + var rotations = new List { 0 }; + + // Add hull-edge angles from the polygon itself. + var hullAngles = ComputeHullEdgeAngles(perimeterPolygon); + + foreach (var angle in hullAngles) + { + if (!rotations.Any(r => r.IsEqualTo(angle))) + rotations.Add(angle); + } + + // Add 90-degree rotation. + if (!rotations.Any(r => r.IsEqualTo(Angle.HalfPI))) + rotations.Add(Angle.HalfPI); + + // For narrow work areas, add sweep angles. + var partBounds = perimeterPolygon.BoundingBox; + var partLongest = System.Math.Max(partBounds.Width, partBounds.Length); + var workShort = System.Math.Min(workArea.Width, workArea.Length); + + if (workShort < partLongest) + { + var step = Angle.ToRadians(5); + + for (var a = 0.0; a < System.Math.PI; a += step) + { + if (!rotations.Any(r => r.IsEqualTo(a))) + rotations.Add(a); + } + } + + return rotations; + } + + /// + /// Computes convex hull edge angles from a polygon for candidate rotations. + /// + private static List ComputeHullEdgeAngles(Polygon polygon) + { + var angles = new List(); + + if (polygon.Vertices.Count < 3) + return angles; + + var hull = ConvexHull.Compute(polygon.Vertices); + var verts = hull.Vertices; + var n = hull.IsClosed() ? verts.Count - 1 : verts.Count; + + for (var i = 0; i < n; i++) + { + var next = (i + 1) % n; + var dx = verts[next].X - verts[i].X; + var dy = verts[next].Y - verts[i].Y; + + if (dx * dx + dy * dy < Tolerance.Epsilon) + continue; + + var angle = -System.Math.Atan2(dy, dx); + + if (!angles.Any(a => a.IsEqualTo(angle))) + angles.Add(angle); + } + + return angles; + } + + /// + /// Creates a rotated copy of a polygon around the origin. + /// + private static Polygon RotatePolygon(Polygon polygon, double angle) + { + if (angle.IsEqualTo(0)) + return polygon; + + var result = new Polygon(); + var cos = System.Math.Cos(angle); + var sin = System.Math.Sin(angle); + + foreach (var v in polygon.Vertices) + { + result.Vertices.Add(new Vector( + v.X * cos - v.Y * sin, + v.X * sin + v.Y * cos)); + } + + // Re-normalize to origin. + result.UpdateBounds(); + var bb = result.BoundingBox; + result.Offset(-bb.Left, -bb.Bottom); + + return result; + } + } +} diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/NestEngine.cs index 1eaf05c..271629a 100644 --- a/OpenNest.Engine/NestEngine.cs +++ b/OpenNest.Engine/NestEngine.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading; -using OpenNest.Converters; using OpenNest.Engine.BestFit; using OpenNest.Engine.ML; using OpenNest.Geometry; @@ -33,16 +32,13 @@ namespace OpenNest public List AngleResults { get; } = new(); + // --- Public Fill API --- + public bool Fill(NestItem item) { return Fill(item, Plate.WorkArea()); } - public bool Fill(List groupParts) - { - return Fill(groupParts, Plate.WorkArea()); - } - public bool Fill(NestItem item, Box workArea) { var parts = Fill(item, workArea, null, CancellationToken.None); @@ -75,7 +71,7 @@ namespace OpenNest best = improved; WinnerPhase = NestPhase.Remainder; PhaseResults.Add(new PhaseResult(NestPhase.Remainder, improved.Count, remainderSw.ElapsedMilliseconds)); - ReportProgress(progress, NestPhase.Remainder, PlateNumber, best, workArea); + ReportProgress(progress, NestPhase.Remainder, PlateNumber, best, workArea, BuildProgressSummary()); } if (best == null || best.Count == 0) @@ -87,187 +83,124 @@ namespace OpenNest return best; } - private List FindBestFill(NestItem item, Box workArea) + public bool Fill(List groupParts) { - var bestRotation = RotationAnalysis.FindBestRotation(item); - - var engine = new FillLinear(workArea, Plate.PartSpacing); - - // Build candidate rotation angles — always try the best rotation and +90°. - var angles = new List { bestRotation, bestRotation + Angle.HalfPI }; - - // When the work area is narrow relative to the part, sweep rotation - // angles so we can find one that fits the part into the tight strip. - var testPart = new Part(item.Drawing); - - if (!bestRotation.IsEqualTo(0)) - testPart.Rotate(bestRotation); - - testPart.UpdateBounds(); - - var partLongestSide = System.Math.Max(testPart.BoundingBox.Width, testPart.BoundingBox.Length); - var workAreaShortSide = System.Math.Min(workArea.Width, workArea.Length); - - if (workAreaShortSide < partLongestSide) - { - // Try every 5° from 0 to 175° to find rotations that fit. - var step = Angle.ToRadians(5); - - for (var a = 0.0; a < System.Math.PI; a += step) - { - if (!angles.Any(existing => existing.IsEqualTo(a))) - angles.Add(a); - } - } - - if (ForceFullAngleSweep) - { - var step = Angle.ToRadians(5); - for (var a = 0.0; a < System.Math.PI; a += step) - { - if (!angles.Any(existing => existing.IsEqualTo(a))) - angles.Add(a); - } - } - - // When the work area triggers a full sweep (and we're not forcing it for training), - // try ML angle prediction to reduce the sweep. - if (!ForceFullAngleSweep && angles.Count > 2) - { - var features = FeatureExtractor.Extract(item.Drawing); - if (features != null) - { - var predicted = AnglePredictor.PredictAngles( - features, workArea.Width, workArea.Length); - - if (predicted != null) - { - // Use predicted angles, but always keep bestRotation and bestRotation + 90. - var mlAngles = new List(predicted); - - if (!mlAngles.Any(a => a.IsEqualTo(bestRotation))) - mlAngles.Add(bestRotation); - if (!mlAngles.Any(a => a.IsEqualTo(bestRotation + Angle.HalfPI))) - mlAngles.Add(bestRotation + Angle.HalfPI); - - Debug.WriteLine($"[FindBestFill] ML: {angles.Count} angles -> {mlAngles.Count} predicted"); - angles = mlAngles; - } - } - } - - // Try pair-based approach first. - var pairResult = FillWithPairs(item, workArea); - var best = pairResult; - var bestScore = FillScore.Compute(best, workArea); - - Debug.WriteLine($"[FindBestFill] Pair: {bestScore.Count} parts"); - - // Try linear phase. - var linearBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List parts)>(); - - System.Threading.Tasks.Parallel.ForEach(angles, angle => - { - var localEngine = new FillLinear(workArea, Plate.PartSpacing); - var h = localEngine.Fill(item.Drawing, angle, NestDirection.Horizontal); - var v = localEngine.Fill(item.Drawing, angle, NestDirection.Vertical); - - if (h != null && h.Count > 0) - linearBag.Add((FillScore.Compute(h, workArea), h)); - - if (v != null && v.Count > 0) - linearBag.Add((FillScore.Compute(v, workArea), v)); - }); - - foreach (var (score, parts) in linearBag) - { - if (score > bestScore) - { - best = parts; - bestScore = score; - } - } - - Debug.WriteLine($"[FindBestFill] Linear: {bestScore.Count} parts, density={bestScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}"); - - // Try rectangle best-fit (mixes orientations to fill remnant strips). - var rectResult = FillRectangleBestFit(item, workArea); - var rectScore = rectResult != null ? FillScore.Compute(rectResult, workArea) : default; - - Debug.WriteLine($"[FindBestFill] RectBestFit: {rectScore.Count} parts"); - - if (rectScore > bestScore) - best = rectResult; - - return best; + return Fill(groupParts, Plate.WorkArea()); } - private List FindBestFill(NestItem item, Box workArea, + public bool Fill(List groupParts, Box workArea) + { + var parts = Fill(groupParts, workArea, null, CancellationToken.None); + + if (parts == null || parts.Count == 0) + return false; + + Plate.Parts.AddRange(parts); + return true; + } + + public List Fill(List groupParts, Box workArea, IProgress progress, CancellationToken token) + { + if (groupParts == null || groupParts.Count == 0) + return new List(); + + PhaseResults.Clear(); + var engine = new FillLinear(workArea, Plate.PartSpacing); + var angles = RotationAnalysis.FindHullEdgeAngles(groupParts); + var best = FillPattern(engine, groupParts, angles, workArea); + PhaseResults.Add(new PhaseResult(NestPhase.Linear, best?.Count ?? 0, 0)); + + Debug.WriteLine($"[Fill(groupParts,Box)] Linear: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Length:F1}"); + + ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea, BuildProgressSummary()); + + if (groupParts.Count == 1) + { + try + { + token.ThrowIfCancellationRequested(); + + var nestItem = new NestItem { Drawing = groupParts[0].BaseDrawing }; + var rectResult = FillRectangleBestFit(nestItem, workArea); + PhaseResults.Add(new PhaseResult(NestPhase.RectBestFit, rectResult?.Count ?? 0, 0)); + + Debug.WriteLine($"[Fill(groupParts,Box)] RectBestFit: {rectResult?.Count ?? 0} parts"); + + if (IsBetterFill(rectResult, best, workArea)) + { + best = rectResult; + ReportProgress(progress, NestPhase.RectBestFit, PlateNumber, best, workArea, BuildProgressSummary()); + } + + token.ThrowIfCancellationRequested(); + + var pairResult = FillWithPairs(nestItem, workArea, token, progress); + PhaseResults.Add(new PhaseResult(NestPhase.Pairs, pairResult.Count, 0)); + + Debug.WriteLine($"[Fill(groupParts,Box)] Pair: {pairResult.Count} parts | Winner: {(IsBetterFill(pairResult, best, workArea) ? "Pair" : "Linear")}"); + + if (IsBetterFill(pairResult, best, workArea)) + { + best = pairResult; + ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea, BuildProgressSummary()); + } + + // Try improving by filling the remainder strip separately. + var improved = TryRemainderImprovement(nestItem, workArea, best); + + if (IsBetterFill(improved, best, workArea)) + { + Debug.WriteLine($"[Fill(groupParts,Box)] Remainder improvement: {improved.Count} parts (was {best?.Count ?? 0})"); + best = improved; + PhaseResults.Add(new PhaseResult(NestPhase.Remainder, improved.Count, 0)); + ReportProgress(progress, NestPhase.Remainder, PlateNumber, best, workArea, BuildProgressSummary()); + } + } + catch (OperationCanceledException) + { + Debug.WriteLine("[Fill(groupParts,Box)] Cancelled, returning current best"); + } + } + + return best ?? new List(); + } + + // --- Pack API --- + + public bool Pack(List items) + { + var workArea = Plate.WorkArea(); + return PackArea(workArea, items); + } + + public bool PackArea(Box box, List items) + { + var binItems = BinConverter.ToItems(items, Plate.PartSpacing, Plate.Area()); + var bin = BinConverter.CreateBin(box, Plate.PartSpacing); + + var engine = new PackBottomLeft(bin); + engine.Pack(binItems); + + var parts = BinConverter.ToParts(bin, items); + Plate.Parts.AddRange(parts); + + return parts.Count > 0; + } + + // --- FindBestFill: core orchestration --- + + private List FindBestFill(NestItem item, Box workArea, + IProgress progress = null, CancellationToken token = default) { List best = null; try { var bestRotation = RotationAnalysis.FindBestRotation(item); - var engine = new FillLinear(workArea, Plate.PartSpacing); - var angles = new List { bestRotation, bestRotation + Angle.HalfPI }; + var angles = BuildCandidateAngles(item, bestRotation, workArea); - var testPart = new Part(item.Drawing); - if (!bestRotation.IsEqualTo(0)) - testPart.Rotate(bestRotation); - testPart.UpdateBounds(); - - var partLongestSide = System.Math.Max(testPart.BoundingBox.Width, testPart.BoundingBox.Length); - var workAreaShortSide = System.Math.Min(workArea.Width, workArea.Length); - - if (workAreaShortSide < partLongestSide) - { - var step = Angle.ToRadians(5); - for (var a = 0.0; a < System.Math.PI; a += step) - { - if (!angles.Any(existing => existing.IsEqualTo(a))) - angles.Add(a); - } - } - - if (ForceFullAngleSweep) - { - var step = Angle.ToRadians(5); - for (var a = 0.0; a < System.Math.PI; a += step) - { - if (!angles.Any(existing => existing.IsEqualTo(a))) - angles.Add(a); - } - } - - // When the work area triggers a full sweep (and we're not forcing it for training), - // try ML angle prediction to reduce the sweep. - if (!ForceFullAngleSweep && angles.Count > 2) - { - var features = FeatureExtractor.Extract(item.Drawing); - if (features != null) - { - var predicted = AnglePredictor.PredictAngles( - features, workArea.Width, workArea.Length); - - if (predicted != null) - { - // Use predicted angles, but always keep bestRotation and bestRotation + 90. - var mlAngles = new List(predicted); - - if (!mlAngles.Any(a => a.IsEqualTo(bestRotation))) - mlAngles.Add(bestRotation); - if (!mlAngles.Any(a => a.IsEqualTo(bestRotation + Angle.HalfPI))) - mlAngles.Add(bestRotation + Angle.HalfPI); - - Debug.WriteLine($"[FindBestFill] ML: {angles.Count} angles -> {mlAngles.Count} predicted"); - angles = mlAngles; - } - } - } - - // Pairs phase first + // Pairs phase var pairSw = Stopwatch.StartNew(); var pairResult = FillWithPairs(item, workArea, token, progress); pairSw.Stop(); @@ -277,14 +210,13 @@ namespace OpenNest PhaseResults.Add(new PhaseResult(NestPhase.Pairs, pairResult.Count, pairSw.ElapsedMilliseconds)); Debug.WriteLine($"[FindBestFill] Pair: {bestScore.Count} parts"); - ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea); + ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea, BuildProgressSummary()); token.ThrowIfCancellationRequested(); // Linear phase var linearSw = Stopwatch.StartNew(); var linearBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List parts)>(); var angleBag = new System.Collections.Concurrent.ConcurrentBag(); - var anglesCompleted = 0; System.Threading.Tasks.Parallel.ForEach(angles, @@ -335,7 +267,7 @@ namespace OpenNest Debug.WriteLine($"[FindBestFill] Linear: {bestScore.Count} parts, density={bestScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}"); - ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea); + ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea, BuildProgressSummary()); token.ThrowIfCancellationRequested(); // RectBestFit phase @@ -350,7 +282,7 @@ namespace OpenNest { best = rectResult; WinnerPhase = NestPhase.RectBestFit; - ReportProgress(progress, NestPhase.RectBestFit, PlateNumber, best, workArea); + ReportProgress(progress, NestPhase.RectBestFit, PlateNumber, best, workArea, BuildProgressSummary()); } } catch (OperationCanceledException) @@ -361,98 +293,62 @@ namespace OpenNest return best ?? new List(); } - public bool Fill(List groupParts, Box workArea) + // --- Angle building --- + + private List BuildCandidateAngles(NestItem item, double bestRotation, Box workArea) { - var parts = Fill(groupParts, workArea, null, CancellationToken.None); + var angles = new List { bestRotation, bestRotation + Angle.HalfPI }; - if (parts == null || parts.Count == 0) - return false; + // When the work area is narrow relative to the part, sweep rotation + // angles so we can find one that fits the part into the tight strip. + var testPart = new Part(item.Drawing); + if (!bestRotation.IsEqualTo(0)) + testPart.Rotate(bestRotation); + testPart.UpdateBounds(); - Plate.Parts.AddRange(parts); - return true; - } + var partLongestSide = System.Math.Max(testPart.BoundingBox.Width, testPart.BoundingBox.Length); + var workAreaShortSide = System.Math.Min(workArea.Width, workArea.Length); + var needsSweep = workAreaShortSide < partLongestSide || ForceFullAngleSweep; - public List Fill(List groupParts, Box workArea, - IProgress progress, CancellationToken token) - { - if (groupParts == null || groupParts.Count == 0) - return new List(); - - var engine = new FillLinear(workArea, Plate.PartSpacing); - var angles = RotationAnalysis.FindHullEdgeAngles(groupParts); - var best = FillPattern(engine, groupParts, angles, workArea); - - Debug.WriteLine($"[Fill(groupParts,Box)] Linear: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Length:F1}"); - - ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea); - - if (groupParts.Count == 1) + if (needsSweep) { - try + var step = Angle.ToRadians(5); + for (var a = 0.0; a < System.Math.PI; a += step) { - token.ThrowIfCancellationRequested(); - - var nestItem = new NestItem { Drawing = groupParts[0].BaseDrawing }; - var rectResult = FillRectangleBestFit(nestItem, workArea); - - Debug.WriteLine($"[Fill(groupParts,Box)] RectBestFit: {rectResult?.Count ?? 0} parts"); - - if (IsBetterFill(rectResult, best, workArea)) - { - best = rectResult; - ReportProgress(progress, NestPhase.RectBestFit, PlateNumber, best, workArea); - } - - token.ThrowIfCancellationRequested(); - - var pairResult = FillWithPairs(nestItem, workArea, token); - - Debug.WriteLine($"[Fill(groupParts,Box)] Pair: {pairResult.Count} parts | Winner: {(IsBetterFill(pairResult, best, workArea) ? "Pair" : "Linear")}"); - - if (IsBetterFill(pairResult, best, workArea)) - { - best = pairResult; - ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea); - } - - // Try improving by filling the remainder strip separately. - var improved = TryRemainderImprovement(nestItem, workArea, best); - - if (IsBetterFill(improved, best, workArea)) - { - Debug.WriteLine($"[Fill(groupParts,Box)] Remainder improvement: {improved.Count} parts (was {best?.Count ?? 0})"); - best = improved; - ReportProgress(progress, NestPhase.Remainder, PlateNumber, best, workArea); - } - } - catch (OperationCanceledException) - { - Debug.WriteLine("[Fill(groupParts,Box)] Cancelled, returning current best"); + if (!angles.Any(existing => existing.IsEqualTo(a))) + angles.Add(a); } } - return best ?? new List(); + // When the work area triggers a full sweep (and we're not forcing it for training), + // try ML angle prediction to reduce the sweep. + if (!ForceFullAngleSweep && angles.Count > 2) + { + var features = FeatureExtractor.Extract(item.Drawing); + if (features != null) + { + var predicted = AnglePredictor.PredictAngles( + features, workArea.Width, workArea.Length); + + if (predicted != null) + { + var mlAngles = new List(predicted); + + if (!mlAngles.Any(a => a.IsEqualTo(bestRotation))) + mlAngles.Add(bestRotation); + if (!mlAngles.Any(a => a.IsEqualTo(bestRotation + Angle.HalfPI))) + mlAngles.Add(bestRotation + Angle.HalfPI); + + Debug.WriteLine($"[BuildCandidateAngles] ML: {angles.Count} angles -> {mlAngles.Count} predicted"); + angles = mlAngles; + } + } + } + + return angles; } - public bool Pack(List items) - { - var workArea = Plate.WorkArea(); - return PackArea(workArea, items); - } - - public bool PackArea(Box box, List items) - { - var binItems = BinConverter.ToItems(items, Plate.PartSpacing, Plate.Area()); - var bin = BinConverter.CreateBin(box, Plate.PartSpacing); - - var engine = new PackBottomLeft(bin); - engine.Pack(binItems); - - var parts = BinConverter.ToParts(bin, items); - Plate.Parts.AddRange(parts); - - return parts.Count > 0; - } + // --- Fill strategies --- private List FillRectangleBestFit(NestItem item, Box workArea) { @@ -465,46 +361,8 @@ namespace OpenNest return BinConverter.ToParts(bin, new List { item }); } - private List FillWithPairs(NestItem item, Box workArea) - { - var bestFits = BestFitCache.GetOrCompute( - item.Drawing, Plate.Size.Width, Plate.Size.Length, - Plate.PartSpacing); - - var candidates = SelectPairCandidates(bestFits, workArea); - Debug.WriteLine($"[FillWithPairs] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}"); - - var resultBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List parts)>(); - - System.Threading.Tasks.Parallel.For(0, candidates.Count, i => - { - var result = candidates[i]; - var pairParts = result.BuildParts(item.Drawing); - var angles = result.HullAngles; - var engine = new FillLinear(workArea, Plate.PartSpacing); - var filled = FillPattern(engine, pairParts, angles, workArea); - - if (filled != null && filled.Count > 0) - resultBag.Add((FillScore.Compute(filled, workArea), filled)); - }); - - List best = null; - var bestScore = default(FillScore); - - foreach (var (score, parts) in resultBag) - { - if (best == null || score > bestScore) - { - best = parts; - bestScore = score; - } - } - - Debug.WriteLine($"[FillWithPairs] Best pair result: {bestScore.Count} parts, remnant={bestScore.UsableRemnantArea:F1}, density={bestScore.Density:P1}"); - return best ?? new List(); - } - - private List FillWithPairs(NestItem item, Box workArea, CancellationToken token, IProgress progress = null) + private List FillWithPairs(NestItem item, Box workArea, + CancellationToken token = default, IProgress progress = null) { var bestFits = BestFitCache.GetOrCompute( item.Drawing, Plate.Size.Width, Plate.Size.Length, @@ -567,8 +425,7 @@ namespace OpenNest /// /// Selects pair candidates to try for the given work area. Always includes /// the top 50 by area. For narrow work areas, also includes all pairs whose - /// shortest side fits the strip width — these are candidates that can only - /// be evaluated by actually tiling them into the narrow space. + /// shortest side fits the strip width. /// private List SelectPairCandidates(List bestFits, Box workArea) { @@ -599,104 +456,82 @@ namespace OpenNest return top; } - private bool HasOverlaps(List parts, double spacing) + // --- Pattern helpers --- + + private Pattern BuildRotatedPattern(List groupParts, double angle) { - if (parts == null || parts.Count <= 1) - return false; + var pattern = new Pattern(); + var center = ((IEnumerable)groupParts).GetBoundingBox().Center; - for (var i = 0; i < parts.Count; i++) + foreach (var part in groupParts) { - var box1 = parts[i].BoundingBox; + var clone = (Part)part.Clone(); + clone.UpdateBounds(); - for (var j = i + 1; j < parts.Count; j++) + if (!angle.IsEqualTo(0)) + clone.Rotate(angle, center); + + pattern.Parts.Add(clone); + } + + pattern.UpdateBounds(); + return pattern; + } + + private List FillPattern(FillLinear engine, List groupParts, List angles, Box workArea) + { + List best = null; + var bestScore = default(FillScore); + + foreach (var angle in angles) + { + var pattern = BuildRotatedPattern(groupParts, angle); + + if (pattern.Parts.Count == 0) + continue; + + var h = engine.Fill(pattern, NestDirection.Horizontal); + var scoreH = h != null && h.Count > 0 ? FillScore.Compute(h, workArea) : default; + + if (scoreH.Count > 0 && (best == null || scoreH > bestScore)) { - var box2 = parts[j].BoundingBox; + best = h; + bestScore = scoreH; + } - // Fast bounding box rejection — if boxes don't overlap, - // the parts can't intersect. Eliminates nearly all pairs - // in grid layouts. - if (box1.Right < box2.Left || box2.Right < box1.Left || - box1.Top < box2.Bottom || box2.Top < box1.Bottom) - continue; + var v = engine.Fill(pattern, NestDirection.Vertical); + var scoreV = v != null && v.Count > 0 ? FillScore.Compute(v, workArea) : default; - List pts; - - if (parts[i].Intersects(parts[j], out pts)) - { - var b1 = parts[i].BoundingBox; - var b2 = parts[j].BoundingBox; - Debug.WriteLine($"[HasOverlaps] Overlap: part[{i}] ({parts[i].BaseDrawing?.Name}) @ ({b1.Left:F2},{b1.Bottom:F2})-({b1.Right:F2},{b1.Top:F2}) rot={parts[i].Rotation:F2}" + - $" vs part[{j}] ({parts[j].BaseDrawing?.Name}) @ ({b2.Left:F2},{b2.Bottom:F2})-({b2.Right:F2},{b2.Top:F2}) rot={parts[j].Rotation:F2}" + - $" intersections={pts?.Count ?? 0}"); - return true; - } + if (scoreV.Count > 0 && (best == null || scoreV > bestScore)) + { + best = v; + bestScore = scoreV; } } - return false; + return best; } - private bool IsBetterFill(List candidate, List current, Box workArea) + // --- Remainder improvement --- + + private List TryRemainderImprovement(NestItem item, Box workArea, List currentBest) { - if (candidate == null || candidate.Count == 0) - return false; + if (currentBest == null || currentBest.Count < 3) + return null; - if (current == null || current.Count == 0) - return true; + List best = null; - return FillScore.Compute(candidate, workArea) > FillScore.Compute(current, workArea); - } + var hResult = TryStripRefill(item, workArea, currentBest, horizontal: true); - private bool IsBetterValidFill(List candidate, List current, Box workArea) - { - if (candidate != null && candidate.Count > 0 && HasOverlaps(candidate, Plate.PartSpacing)) - { - Debug.WriteLine($"[IsBetterValidFill] REJECTED {candidate.Count} parts due to overlaps (current best: {current?.Count ?? 0})"); - return false; - } + if (IsBetterFill(hResult, best, workArea)) + best = hResult; - return IsBetterFill(candidate, current, workArea); - } + var vResult = TryStripRefill(item, workArea, currentBest, horizontal: false); - /// - /// Groups parts into positional clusters along the given axis. - /// Parts whose center positions are separated by more than half - /// the part dimension start a new cluster. - /// - private static List> ClusterParts(List parts, bool horizontal) - { - var sorted = horizontal - ? parts.OrderBy(p => p.BoundingBox.Center.X).ToList() - : parts.OrderBy(p => p.BoundingBox.Center.Y).ToList(); + if (IsBetterFill(vResult, best, workArea)) + best = vResult; - var refDim = horizontal - ? sorted.Max(p => p.BoundingBox.Width) - : sorted.Max(p => p.BoundingBox.Length); - var gapThreshold = refDim * 0.5; - - var clusters = new List>(); - var current = new List { sorted[0] }; - - for (var i = 1; i < sorted.Count; i++) - { - var prevCenter = horizontal - ? sorted[i - 1].BoundingBox.Center.X - : sorted[i - 1].BoundingBox.Center.Y; - var currCenter = horizontal - ? sorted[i].BoundingBox.Center.X - : sorted[i].BoundingBox.Center.Y; - - if (currCenter - prevCenter > gapThreshold) - { - clusters.Add(current); - current = new List(); - } - - current.Add(sorted[i]); - } - - clusters.Add(current); - return clusters; + return best; } private List TryStripRefill(NestItem item, Box workArea, List parts, bool horizontal) @@ -771,86 +606,115 @@ namespace OpenNest return combined; } - private List TryRemainderImprovement(NestItem item, Box workArea, List currentBest) + /// + /// Groups parts into positional clusters along the given axis. + /// Parts whose center positions are separated by more than half + /// the part dimension start a new cluster. + /// + private static List> ClusterParts(List parts, bool horizontal) { - if (currentBest == null || currentBest.Count < 3) - return null; + var sorted = horizontal + ? parts.OrderBy(p => p.BoundingBox.Center.X).ToList() + : parts.OrderBy(p => p.BoundingBox.Center.Y).ToList(); - List best = null; + var refDim = horizontal + ? sorted.Max(p => p.BoundingBox.Width) + : sorted.Max(p => p.BoundingBox.Length); + var gapThreshold = refDim * 0.5; - var hResult = TryStripRefill(item, workArea, currentBest, horizontal: true); + var clusters = new List>(); + var current = new List { sorted[0] }; - if (IsBetterFill(hResult, best, workArea)) - best = hResult; - - var vResult = TryStripRefill(item, workArea, currentBest, horizontal: false); - - if (IsBetterFill(vResult, best, workArea)) - best = vResult; - - return best; - } - - private Pattern BuildRotatedPattern(List groupParts, double angle) - { - var pattern = new Pattern(); - var center = ((IEnumerable)groupParts).GetBoundingBox().Center; - - foreach (var part in groupParts) + for (var i = 1; i < sorted.Count; i++) { - var clone = (Part)part.Clone(); - clone.UpdateBounds(); + var prevCenter = horizontal + ? sorted[i - 1].BoundingBox.Center.X + : sorted[i - 1].BoundingBox.Center.Y; + var currCenter = horizontal + ? sorted[i].BoundingBox.Center.X + : sorted[i].BoundingBox.Center.Y; - if (!angle.IsEqualTo(0)) - clone.Rotate(angle, center); - - pattern.Parts.Add(clone); - } - - pattern.UpdateBounds(); - return pattern; - } - - private List FillPattern(FillLinear engine, List groupParts, List angles, Box workArea) - { - List best = null; - var bestScore = default(FillScore); - - foreach (var angle in angles) - { - var pattern = BuildRotatedPattern(groupParts, angle); - - if (pattern.Parts.Count == 0) - continue; - - var h = engine.Fill(pattern, NestDirection.Horizontal); - var scoreH = h != null && h.Count > 0 ? FillScore.Compute(h, workArea) : default; - - if (scoreH.Count > 0 && (best == null || scoreH > bestScore)) + if (currCenter - prevCenter > gapThreshold) { - best = h; - bestScore = scoreH; + clusters.Add(current); + current = new List(); } - var v = engine.Fill(pattern, NestDirection.Vertical); - var scoreV = v != null && v.Count > 0 ? FillScore.Compute(v, workArea) : default; + current.Add(sorted[i]); + } - if (scoreV.Count > 0 && (best == null || scoreV > bestScore)) + clusters.Add(current); + return clusters; + } + + // --- Scoring / comparison --- + + private bool IsBetterFill(List candidate, List current, Box workArea) + { + if (candidate == null || candidate.Count == 0) + return false; + + if (current == null || current.Count == 0) + return true; + + return FillScore.Compute(candidate, workArea) > FillScore.Compute(current, workArea); + } + + private bool IsBetterValidFill(List candidate, List current, Box workArea) + { + if (candidate != null && candidate.Count > 0 && HasOverlaps(candidate, Plate.PartSpacing)) + { + Debug.WriteLine($"[IsBetterValidFill] REJECTED {candidate.Count} parts due to overlaps (current best: {current?.Count ?? 0})"); + return false; + } + + return IsBetterFill(candidate, current, workArea); + } + + private bool HasOverlaps(List parts, double spacing) + { + if (parts == null || parts.Count <= 1) + return false; + + for (var i = 0; i < parts.Count; i++) + { + var box1 = parts[i].BoundingBox; + + for (var j = i + 1; j < parts.Count; j++) { - best = v; - bestScore = scoreV; + var box2 = parts[j].BoundingBox; + + // Fast bounding box rejection. + if (box1.Right < box2.Left || box2.Right < box1.Left || + box1.Top < box2.Bottom || box2.Top < box1.Bottom) + continue; + + List pts; + + if (parts[i].Intersects(parts[j], out pts)) + { + var b1 = parts[i].BoundingBox; + var b2 = parts[j].BoundingBox; + Debug.WriteLine($"[HasOverlaps] Overlap: part[{i}] ({parts[i].BaseDrawing?.Name}) @ ({b1.Left:F2},{b1.Bottom:F2})-({b1.Right:F2},{b1.Top:F2}) rot={parts[i].Rotation:F2}" + + $" vs part[{j}] ({parts[j].BaseDrawing?.Name}) @ ({b2.Left:F2},{b2.Bottom:F2})-({b2.Right:F2},{b2.Top:F2}) rot={parts[j].Rotation:F2}" + + $" intersections={pts?.Count ?? 0}"); + return true; + } } } - return best; + return false; } + // --- Progress reporting --- + private static void ReportProgress( IProgress progress, NestPhase phase, int plateNumber, List best, - Box workArea) + Box workArea, + string description) { if (progress == null || best == null || best.Count == 0) return; @@ -873,227 +737,36 @@ namespace OpenNest BestDensity = score.Density, UsableRemnantArea = workArea.Area() - totalPartArea, BestParts = clonedParts, - Description = null + Description = description }); } - /// - /// Mixed-part geometry-aware nesting using NFP-based collision avoidance - /// and simulated annealing optimization. - /// - public List AutoNest(List items, CancellationToken cancellation = default) + private string BuildProgressSummary() { - return AutoNest(items, Plate, cancellation); - } - - /// - /// Mixed-part geometry-aware nesting using NFP-based collision avoidance - /// and simulated annealing optimization. - /// - public static List AutoNest(List items, Plate plate, - CancellationToken cancellation = default) - { - var workArea = plate.WorkArea(); - var halfSpacing = plate.PartSpacing / 2.0; - var nfpCache = new NfpCache(); - var candidateRotations = new Dictionary>(); - - // Extract perimeter polygons for each unique drawing. - foreach (var item in items) - { - var drawing = item.Drawing; - - if (candidateRotations.ContainsKey(drawing.Id)) - continue; - - var perimeterPolygon = ExtractPerimeterPolygon(drawing, halfSpacing); - - if (perimeterPolygon == null) - { - Debug.WriteLine($"[AutoNest] Skipping drawing '{drawing.Name}': no valid perimeter"); - continue; - } - - // Compute candidate rotations for this drawing. - var rotations = ComputeCandidateRotations(item, perimeterPolygon, workArea); - candidateRotations[drawing.Id] = rotations; - - // Register polygons at each candidate rotation. - foreach (var rotation in rotations) - { - var rotatedPolygon = RotatePolygon(perimeterPolygon, rotation); - nfpCache.RegisterPolygon(drawing.Id, rotation, rotatedPolygon); - } - } - - if (candidateRotations.Count == 0) - return new List(); - - // Pre-compute all NFPs. - nfpCache.PreComputeAll(); - - Debug.WriteLine($"[AutoNest] NFP cache: {nfpCache.Count} entries for {candidateRotations.Count} drawings"); - - // Run simulated annealing optimizer. - var optimizer = new SimulatedAnnealing(); - var result = optimizer.Optimize(items, workArea, nfpCache, candidateRotations, cancellation); - - if (result.Sequence == null || result.Sequence.Count == 0) - return new List(); - - // Final BLF placement with the best solution. - var blf = new BottomLeftFill(workArea, nfpCache); - var placedParts = blf.Fill(result.Sequence); - var parts = BottomLeftFill.ToNestParts(placedParts); - - Debug.WriteLine($"[AutoNest] Result: {parts.Count} parts placed, {result.Iterations} SA iterations"); - - return parts; - } - - /// - /// Extracts the perimeter polygon from a drawing, inflated by half-spacing. - /// - private static Polygon ExtractPerimeterPolygon(Drawing drawing, double halfSpacing) - { - var entities = ConvertProgram.ToGeometry(drawing.Program) - .Where(e => e.Layer != SpecialLayers.Rapid) - .ToList(); - - if (entities.Count == 0) + if (PhaseResults.Count == 0) return null; - var definedShape = new ShapeProfile(entities); - var perimeter = definedShape.Perimeter; + var parts = new List(PhaseResults.Count); - if (perimeter == null) - return null; + foreach (var r in PhaseResults) + parts.Add($"{FormatPhaseName(r.Phase)}: {r.PartCount}"); - // Inflate by half-spacing if spacing is non-zero. - Shape inflated; - - if (halfSpacing > 0) - { - var offsetEntity = perimeter.OffsetEntity(halfSpacing, OffsetSide.Right); - inflated = offsetEntity as Shape ?? perimeter; - } - else - { - inflated = perimeter; - } - - // Convert to polygon with circumscribed arcs for tight nesting. - var polygon = inflated.ToPolygonWithTolerance(0.01, circumscribe: true); - - if (polygon.Vertices.Count < 3) - return null; - - // Normalize: move reference point to origin. - polygon.UpdateBounds(); - var bb = polygon.BoundingBox; - polygon.Offset(-bb.Left, -bb.Bottom); - - return polygon; + return string.Join(" | ", parts); } - /// - /// Computes candidate rotation angles for a drawing. - /// - private static List ComputeCandidateRotations(NestItem item, - Polygon perimeterPolygon, Box workArea) + private static string FormatPhaseName(NestPhase phase) { - var rotations = new List { 0 }; - - // Add hull-edge angles from the polygon itself. - var hullAngles = ComputeHullEdgeAngles(perimeterPolygon); - - foreach (var angle in hullAngles) + switch (phase) { - if (!rotations.Any(r => r.IsEqualTo(angle))) - rotations.Add(angle); + case NestPhase.Pairs: return "Pairs"; + case NestPhase.Linear: return "Linear"; + case NestPhase.RectBestFit: return "BestFit"; + case NestPhase.Remainder: return "Remainder"; + default: return phase.ToString(); } - - // Add 90-degree rotation. - if (!rotations.Any(r => r.IsEqualTo(Angle.HalfPI))) - rotations.Add(Angle.HalfPI); - - // For narrow work areas, add sweep angles. - var partBounds = perimeterPolygon.BoundingBox; - var partLongest = System.Math.Max(partBounds.Width, partBounds.Length); - var workShort = System.Math.Min(workArea.Width, workArea.Length); - - if (workShort < partLongest) - { - var step = Angle.ToRadians(5); - - for (var a = 0.0; a < System.Math.PI; a += step) - { - if (!rotations.Any(r => r.IsEqualTo(a))) - rotations.Add(a); - } - } - - return rotations; } - /// - /// Computes convex hull edge angles from a polygon for candidate rotations. - /// - private static List ComputeHullEdgeAngles(Polygon polygon) - { - var angles = new List(); - - if (polygon.Vertices.Count < 3) - return angles; - - var hull = ConvexHull.Compute(polygon.Vertices); - var verts = hull.Vertices; - var n = hull.IsClosed() ? verts.Count - 1 : verts.Count; - - for (var i = 0; i < n; i++) - { - var next = (i + 1) % n; - var dx = verts[next].X - verts[i].X; - var dy = verts[next].Y - verts[i].Y; - - if (dx * dx + dy * dy < Tolerance.Epsilon) - continue; - - var angle = -System.Math.Atan2(dy, dx); - - if (!angles.Any(a => a.IsEqualTo(angle))) - angles.Add(angle); - } - - return angles; - } - - /// - /// Creates a rotated copy of a polygon around the origin. - /// - private static Polygon RotatePolygon(Polygon polygon, double angle) - { - if (angle.IsEqualTo(0)) - return polygon; - - var result = new Polygon(); - var cos = System.Math.Cos(angle); - var sin = System.Math.Sin(angle); - - foreach (var v in polygon.Vertices) - { - result.Vertices.Add(new Vector( - v.X * cos - v.Y * sin, - v.X * sin + v.Y * cos)); - } - - // Re-normalize to origin. - result.UpdateBounds(); - var bb = result.BoundingBox; - result.Offset(-bb.Left, -bb.Bottom); - - return result; - } + // --- Utilities --- private static void InterlockedMax(ref int location, int value) { diff --git a/OpenNest.Mcp/Tools/NestingTools.cs b/OpenNest.Mcp/Tools/NestingTools.cs index cdd96ce..99d8aef 100644 --- a/OpenNest.Mcp/Tools/NestingTools.cs +++ b/OpenNest.Mcp/Tools/NestingTools.cs @@ -233,7 +233,7 @@ namespace OpenNest.Mcp.Tools items.Add(new NestItem { Drawing = drawing, Quantity = qtys[i] }); } - var parts = NestEngine.AutoNest(items, plate); + var parts = AutoNester.Nest(items, plate); plate.Parts.AddRange(parts); var sb = new StringBuilder(); diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index 6f526e0..0ef8fac 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -762,7 +762,7 @@ namespace OpenNest.Forms activeForm.LoadLastPlate(); var parts = await Task.Run(() => - NestEngine.AutoNest(remaining, plate, token)); + AutoNester.Nest(remaining, plate, token)); if (parts.Count == 0) break; @@ -862,7 +862,9 @@ namespace OpenNest.Forms var progress = new Progress(p => { progressForm.UpdateProgress(p); - activeForm.PlateView.SetTemporaryParts(p.BestParts); + + if (p.BestParts != null) + activeForm.PlateView.SetTemporaryParts(p.BestParts); }); progressForm.Show(this); @@ -922,7 +924,9 @@ namespace OpenNest.Forms var progress = new Progress(p => { progressForm.UpdateProgress(p); - activeForm.PlateView.SetTemporaryParts(p.BestParts); + + if (p.BestParts != null) + activeForm.PlateView.SetTemporaryParts(p.BestParts); }); Action> onComplete = parts =>