From 3c59da17c26f5e319d2a2a5392d75498c72cb2ba Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 01:14:07 -0400 Subject: [PATCH] fix(engine): fix pair candidate filtering for narrow plates and strips The BestFitFilter's aspect ratio cap of 5.0 was rejecting valid pair candidates needed for narrow plates (e.g. 60x6.5, aspect 9.2) and remainder strips on normal plates. Three fixes: - BestFitFinder: derive MaxAspectRatio from the plate's own aspect ratio so narrow plates don't reject all elongated pairs - SelectPairCandidates: search the full unfiltered candidate list (not just Keep=true) in strip mode, so pairs rejected by aspect ratio for the main plate can still be used for narrow remainder strips - BestFitCache.Populate: skip caching empty result lists so stale pre-computed data from nest files doesn't prevent recomputation Also fixes console --size parsing to use LxW format matching Size.Parse convention, and includes prior engine refactoring (sequential fill loops, parallel FillPattern, pre-sorted edge arrays in RotationSlideStrategy). Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Console/Program.cs | 11 +- OpenNest.Core/Helper.cs | 2 +- OpenNest.Engine/BestFit/BestFitCache.cs | 3 + OpenNest.Engine/BestFit/BestFitFinder.cs | 5 +- .../BestFit/RotationSlideStrategy.cs | 78 ++++++- OpenNest.Engine/FillLinear.cs | 5 +- OpenNest.Engine/NestEngine.cs | 200 ++++++++---------- OpenNest/Forms/MainForm.cs | 8 +- 8 files changed, 185 insertions(+), 127 deletions(-) diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs index 53bb30f..49ac31d 100644 --- a/OpenNest.Console/Program.cs +++ b/OpenNest.Console/Program.cs @@ -98,8 +98,8 @@ static class NestConsole var parts = args[++i].Split('x'); if (parts.Length == 2) { - o.PlateWidth = double.Parse(parts[0]); - o.PlateHeight = double.Parse(parts[1]); + o.PlateHeight = double.Parse(parts[0]); + o.PlateWidth = double.Parse(parts[1]); } break; case "--check-overlaps": @@ -297,7 +297,8 @@ static class NestConsole static void PrintHeader(Nest nest, Plate plate, Drawing drawing, int existingCount, Options options) { Console.WriteLine($"Nest: {nest.Name}"); - Console.WriteLine($"Plate: {options.PlateIndex} ({plate.Size.Width:F1} x {plate.Size.Length:F1}), spacing={plate.PartSpacing:F2}"); + var wa = plate.WorkArea(); + Console.WriteLine($"Plate: {options.PlateIndex} ({plate.Size.Length:F1} x {plate.Size.Width:F1}), spacing={plate.PartSpacing:F2}, edge=({plate.EdgeSpacing.Left},{plate.EdgeSpacing.Bottom},{plate.EdgeSpacing.Right},{plate.EdgeSpacing.Top}), workArea={wa.Length:F1}x{wa.Width:F1}"); Console.WriteLine($"Drawing: {drawing.Name}"); Console.WriteLine(options.KeepParts ? $"Keeping {existingCount} existing parts" @@ -386,7 +387,7 @@ static class NestConsole Console.Error.WriteLine(); Console.Error.WriteLine("Modes:"); Console.Error.WriteLine(" Load nest and fill (existing behavior)"); - Console.Error.WriteLine(" --size WxH Import DXF, create plate, and fill"); + Console.Error.WriteLine(" --size LxW Import DXF, create plate, and fill"); Console.Error.WriteLine(" Load nest and add imported DXF drawings"); Console.Error.WriteLine(); Console.Error.WriteLine("Options:"); @@ -394,7 +395,7 @@ static class NestConsole Console.Error.WriteLine(" --plate Plate index to fill (default: 0)"); Console.Error.WriteLine(" --quantity Max parts to place (default: 0 = unlimited)"); Console.Error.WriteLine(" --spacing Override part spacing"); - Console.Error.WriteLine(" --size Override plate size (e.g. 120x60); required for DXF-only mode"); + Console.Error.WriteLine(" --size Override plate size (e.g. 120x60); required for DXF-only mode"); Console.Error.WriteLine(" --output Output nest file path (default: -result.zip)"); Console.Error.WriteLine(" --template Nest template for plate defaults (thickness, quadrant, material, spacing)"); Console.Error.WriteLine(" --autonest Use NFP-based mixed-part autonesting instead of linear fill"); diff --git a/OpenNest.Core/Helper.cs b/OpenNest.Core/Helper.cs index ecea400..5b746c1 100644 --- a/OpenNest.Core/Helper.cs +++ b/OpenNest.Core/Helper.cs @@ -1103,7 +1103,7 @@ namespace OpenNest return minDist; } - private static double OneWayDistance( + public static double OneWayDistance( Vector vertex, (Vector start, Vector end)[] edges, Vector edgeOffset, PushDirection direction) { diff --git a/OpenNest.Engine/BestFit/BestFitCache.cs b/OpenNest.Engine/BestFit/BestFitCache.cs index d7b1fc9..8a12faa 100644 --- a/OpenNest.Engine/BestFit/BestFitCache.cs +++ b/OpenNest.Engine/BestFit/BestFitCache.cs @@ -153,6 +153,9 @@ namespace OpenNest.Engine.BestFit public static void Populate(Drawing drawing, double plateWidth, double plateHeight, double spacing, List results) { + if (results == null || results.Count == 0) + return; + var key = new CacheKey(drawing, plateWidth, plateHeight, spacing); _cache.TryAdd(key, results); } diff --git a/OpenNest.Engine/BestFit/BestFitFinder.cs b/OpenNest.Engine/BestFit/BestFitFinder.cs index b456bf6..b86bc6a 100644 --- a/OpenNest.Engine/BestFit/BestFitFinder.cs +++ b/OpenNest.Engine/BestFit/BestFitFinder.cs @@ -20,10 +20,13 @@ namespace OpenNest.Engine.BestFit { _evaluator = evaluator ?? new PairEvaluator(); _slideComputer = slideComputer; + var plateAspect = System.Math.Max(maxPlateWidth, maxPlateHeight) / + System.Math.Max(System.Math.Min(maxPlateWidth, maxPlateHeight), 0.001); _filter = new BestFitFilter { MaxPlateWidth = maxPlateWidth, - MaxPlateHeight = maxPlateHeight + MaxPlateHeight = maxPlateHeight, + MaxAspectRatio = System.Math.Max(5.0, plateAspect) }; } diff --git a/OpenNest.Engine/BestFit/RotationSlideStrategy.cs b/OpenNest.Engine/BestFit/RotationSlideStrategy.cs index 7da14dd..395025f 100644 --- a/OpenNest.Engine/BestFit/RotationSlideStrategy.cs +++ b/OpenNest.Engine/BestFit/RotationSlideStrategy.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using OpenNest.Geometry; namespace OpenNest.Engine.BestFit @@ -147,11 +148,82 @@ namespace OpenNest.Engine.BestFit var results = new double[count]; - for (var i = 0; i < count; i++) + // Pre-calculate moving vertices in local space. + var movingVerticesLocal = new HashSet(); + for (var i = 0; i < part2TemplateLines.Count; i++) { - results[i] = Helper.DirectionalDistance( - part2TemplateLines, allDx[i], allDy[i], part1Lines, allDirs[i]); + movingVerticesLocal.Add(part2TemplateLines[i].StartPoint); + movingVerticesLocal.Add(part2TemplateLines[i].EndPoint); } + var movingVerticesArray = movingVerticesLocal.ToArray(); + + // Pre-calculate stationary vertices in local space. + var stationaryVerticesLocal = new HashSet(); + for (var i = 0; i < part1Lines.Count; i++) + { + stationaryVerticesLocal.Add(part1Lines[i].StartPoint); + stationaryVerticesLocal.Add(part1Lines[i].EndPoint); + } + var stationaryVerticesArray = stationaryVerticesLocal.ToArray(); + + // Pre-sort stationary and moving edges for all 4 directions. + var stationaryEdgesByDir = new Dictionary(); + var movingEdgesByDir = new Dictionary(); + + foreach (var dir in AllDirections) + { + var sEdges = new (Vector start, Vector end)[part1Lines.Count]; + for (var i = 0; i < part1Lines.Count; i++) + sEdges[i] = (part1Lines[i].StartPoint, part1Lines[i].EndPoint); + + if (dir == PushDirection.Left || dir == PushDirection.Right) + sEdges = sEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray(); + else + sEdges = sEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray(); + stationaryEdgesByDir[dir] = sEdges; + + var opposite = Helper.OppositeDirection(dir); + var mEdges = new (Vector start, Vector end)[part2TemplateLines.Count]; + for (var i = 0; i < part2TemplateLines.Count; i++) + mEdges[i] = (part2TemplateLines[i].StartPoint, part2TemplateLines[i].EndPoint); + + if (opposite == PushDirection.Left || opposite == PushDirection.Right) + mEdges = mEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray(); + else + mEdges = mEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray(); + movingEdgesByDir[dir] = mEdges; + } + + // Use Parallel.For for the heavy lifting. + System.Threading.Tasks.Parallel.For(0, count, i => + { + var dx = allDx[i]; + var dy = allDy[i]; + var dir = allDirs[i]; + var movingOffset = new Vector(dx, dy); + + var sEdges = stationaryEdgesByDir[dir]; + var mEdges = movingEdgesByDir[dir]; + var opposite = Helper.OppositeDirection(dir); + + var minDist = double.MaxValue; + + // Case 1: Moving vertices -> Stationary edges + foreach (var mv in movingVerticesArray) + { + var d = Helper.OneWayDistance(mv + movingOffset, sEdges, Vector.Zero, dir); + if (d < minDist) minDist = d; + } + + // Case 2: Stationary vertices -> Moving edges (translated) + foreach (var sv in stationaryVerticesArray) + { + var d = Helper.OneWayDistance(sv, mEdges, movingOffset, opposite); + if (d < minDist) minDist = d; + } + + results[i] = minDist; + }); return results; } diff --git a/OpenNest.Engine/FillLinear.cs b/OpenNest.Engine/FillLinear.cs index deb2deb..09c0ead 100644 --- a/OpenNest.Engine/FillLinear.cs +++ b/OpenNest.Engine/FillLinear.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Threading.Tasks; using OpenNest.Geometry; using OpenNest.Math; @@ -512,7 +513,7 @@ namespace OpenNest { var bag = new System.Collections.Concurrent.ConcurrentBag>(); - foreach (var entry in rotations) + Parallel.ForEach(rotations, entry => { var filler = new FillLinear(strip, PartSpacing); var h = filler.Fill(entry.drawing, entry.rotation, NestDirection.Horizontal); @@ -523,7 +524,7 @@ namespace OpenNest if (v != null && v.Count > 0) bag.Add(v); - } + }); List best = null; diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/NestEngine.cs index 271629a..7344925 100644 --- a/OpenNest.Engine/NestEngine.cs +++ b/OpenNest.Engine/NestEngine.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading; +using System.Threading.Tasks; using OpenNest.Engine.BestFit; using OpenNest.Engine.ML; using OpenNest.Geometry; @@ -215,54 +216,48 @@ namespace OpenNest // 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, - new System.Threading.Tasks.ParallelOptions { CancellationToken = token }, - 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); - - var angleDeg = Angle.ToDegrees(angle); - if (h != null && h.Count > 0) - { - linearBag.Add((FillScore.Compute(h, workArea), h)); - angleBag.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Horizontal, PartCount = h.Count }); - } - if (v != null && v.Count > 0) - { - linearBag.Add((FillScore.Compute(v, workArea), v)); - angleBag.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Vertical, PartCount = v.Count }); - } - - var done = Interlocked.Increment(ref anglesCompleted); - var bestCount = System.Math.Max(h?.Count ?? 0, v?.Count ?? 0); - progress?.Report(new NestProgress - { - Phase = NestPhase.Linear, - PlateNumber = PlateNumber, - Description = $"Linear: {done}/{angles.Count} angles, {angleDeg:F0}° = {bestCount} parts" - }); - }); - linearSw.Stop(); - AngleResults.AddRange(angleBag); - var bestLinearCount = 0; - foreach (var (score, parts) in linearBag) + + for (var ai = 0; ai < angles.Count; ai++) { - if (parts.Count > bestLinearCount) - bestLinearCount = parts.Count; - if (score > bestScore) + token.ThrowIfCancellationRequested(); + + var angle = angles[ai]; + 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); + + var angleDeg = Angle.ToDegrees(angle); + if (h != null && h.Count > 0) { - best = parts; - bestScore = score; - WinnerPhase = NestPhase.Linear; + var scoreH = FillScore.Compute(h, workArea); + AngleResults.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Horizontal, PartCount = h.Count }); + if (h.Count > bestLinearCount) bestLinearCount = h.Count; + if (scoreH > bestScore) + { + best = h; + bestScore = scoreH; + WinnerPhase = NestPhase.Linear; + } } + if (v != null && v.Count > 0) + { + var scoreV = FillScore.Compute(v, workArea); + AngleResults.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Vertical, PartCount = v.Count }); + if (v.Count > bestLinearCount) bestLinearCount = v.Count; + if (scoreV > bestScore) + { + best = v; + bestScore = scoreV; + WinnerPhase = NestPhase.Linear; + } + } + + ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea, + $"Linear: {ai + 1}/{angles.Count} angles, {angleDeg:F0}° best = {bestScore.Count} parts"); } + + linearSw.Stop(); PhaseResults.Add(new PhaseResult(NestPhase.Linear, bestLinearCount, linearSw.ElapsedMilliseconds)); Debug.WriteLine($"[FindBestFill] Linear: {bestScore.Count} parts, density={bestScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}"); @@ -369,56 +364,51 @@ namespace OpenNest Plate.PartSpacing); var candidates = SelectPairCandidates(bestFits, workArea); - Debug.WriteLine($"[FillWithPairs] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}"); + var diagMsg = $"[FillWithPairs] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}\n" + + $"[FillWithPairs] Plate: {Plate.Size.Width:F2}x{Plate.Size.Length:F2}, WorkArea: {workArea.Width:F2}x{workArea.Length:F2}"; + Debug.WriteLine(diagMsg); + try { System.IO.File.AppendAllText( + System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "nest-debug.log"), + $"{DateTime.Now:HH:mm:ss} {diagMsg}\n"); } catch { } - var resultBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List parts)>(); + List best = null; + var bestScore = default(FillScore); try { - var pairsCompleted = 0; - var pairsBestCount = 0; + for (var i = 0; i < candidates.Count; i++) + { + token.ThrowIfCancellationRequested(); - System.Threading.Tasks.Parallel.For(0, candidates.Count, - new System.Threading.Tasks.ParallelOptions { CancellationToken = token }, - 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) { - 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)); - - var done = Interlocked.Increment(ref pairsCompleted); - InterlockedMax(ref pairsBestCount, filled?.Count ?? 0); - progress?.Report(new NestProgress + var score = FillScore.Compute(filled, workArea); + if (best == null || score > bestScore) { - Phase = NestPhase.Pairs, - PlateNumber = PlateNumber, - Description = $"Pairs: {done}/{candidates.Count} candidates, best = {pairsBestCount} parts" - }); - }); + best = filled; + bestScore = score; + } + } + + ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea, + $"Pairs: {i + 1}/{candidates.Count} candidates, best = {bestScore.Count} parts"); + } } catch (OperationCanceledException) { Debug.WriteLine("[FillWithPairs] Cancelled mid-phase, using results so far"); } - 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}"); + try { System.IO.File.AppendAllText( + System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "nest-debug.log"), + $"{DateTime.Now:HH:mm:ss} [FillWithPairs] Best: {bestScore.Count} parts, density={bestScore.Density:P1}\n"); } catch { } return best ?? new List(); } @@ -436,11 +426,14 @@ namespace OpenNest var plateShortSide = System.Math.Min(Plate.Size.Width, Plate.Size.Length); // When the work area is significantly narrower than the plate, - // include all pairs that fit the narrow dimension. + // search ALL candidates (not just kept) for pairs that fit the + // narrow dimension. Pairs rejected by aspect ratio for the full + // plate may be exactly what's needed for a narrow remainder strip. if (workShortSide < plateShortSide * 0.5) { - var stripCandidates = kept - .Where(r => r.ShortestSide <= workShortSide + Tolerance.Epsilon); + var stripCandidates = bestFits + .Where(r => r.ShortestSide <= workShortSide + Tolerance.Epsilon + && r.Utilization >= 0.3); var existing = new HashSet(top); @@ -480,32 +473,33 @@ namespace OpenNest private List FillPattern(FillLinear engine, List groupParts, List angles, Box workArea) { - List best = null; - var bestScore = default(FillScore); + var results = new System.Collections.Concurrent.ConcurrentBag<(List Parts, FillScore Score)>(); - foreach (var angle in angles) + Parallel.ForEach(angles, angle => { var pattern = BuildRotatedPattern(groupParts, angle); if (pattern.Parts.Count == 0) - continue; + return; 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)) - { - best = h; - bestScore = scoreH; - } + if (h != null && h.Count > 0) + results.Add((h, FillScore.Compute(h, workArea))); var v = engine.Fill(pattern, NestDirection.Vertical); - var scoreV = v != null && v.Count > 0 ? FillScore.Compute(v, workArea) : default; + if (v != null && v.Count > 0) + results.Add((v, FillScore.Compute(v, workArea))); + }); - if (scoreV.Count > 0 && (best == null || scoreV > bestScore)) + List best = null; + var bestScore = default(FillScore); + + foreach (var res in results) + { + if (best == null || res.Score > bestScore) { - best = v; - bestScore = scoreV; + best = res.Parts; + bestScore = res.Score; } } @@ -766,17 +760,5 @@ namespace OpenNest } } - // --- Utilities --- - - private static void InterlockedMax(ref int location, int value) - { - int current; - do - { - current = location; - if (value <= current) return; - } while (Interlocked.CompareExchange(ref location, value, current) != current); - } - } } diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index 0ef8fac..4329cd1 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -862,9 +862,7 @@ namespace OpenNest.Forms var progress = new Progress(p => { progressForm.UpdateProgress(p); - - if (p.BestParts != null) - activeForm.PlateView.SetTemporaryParts(p.BestParts); + activeForm.PlateView.SetTemporaryParts(p.BestParts); }); progressForm.Show(this); @@ -924,9 +922,7 @@ namespace OpenNest.Forms var progress = new Progress(p => { progressForm.UpdateProgress(p); - - if (p.BestParts != null) - activeForm.PlateView.SetTemporaryParts(p.BestParts); + activeForm.PlateView.SetTemporaryParts(p.BestParts); }); Action> onComplete = parts =>