diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs index 3bb08f0..c2d3d0d 100644 --- a/OpenNest.Console/Program.cs +++ b/OpenNest.Console/Program.cs @@ -121,6 +121,9 @@ static class NestConsole case "--autonest": o.AutoNest = true; break; + case "--engine" when i + 1 < args.Length: + NestEngineRegistry.ActiveEngineName = args[++i]; + break; case "--help": case "-h": PrintUsage(); diff --git a/OpenNest.Core/Geometry/PolyLabel.cs b/OpenNest.Core/Geometry/PolyLabel.cs index 8e5b197..9f3af4b 100644 --- a/OpenNest.Core/Geometry/PolyLabel.cs +++ b/OpenNest.Core/Geometry/PolyLabel.cs @@ -3,25 +3,23 @@ using System.Collections.Generic; namespace OpenNest.Geometry { - /// - /// Finds the pole of inaccessibility — the point inside a polygon that is - /// farthest from any edge. Based on the polylabel algorithm by Mapbox. - /// public static class PolyLabel { - public static Vector Find(List vertices, double precision = 1.0) + public static Vector Find(Polygon outer, IList holes = null, double precision = 0.5) { - if (vertices == null || vertices.Count < 3) - return Vector.Zero; + if (outer.Vertices.Count < 3) + return outer.Vertices.Count > 0 + ? outer.Vertices[0] + : new Vector(); var minX = double.MaxValue; var minY = double.MaxValue; var maxX = double.MinValue; var maxY = double.MinValue; - for (var i = 0; i < vertices.Count; i++) + for (var i = 0; i < outer.Vertices.Count; i++) { - var v = vertices[i]; + var v = outer.Vertices[i]; if (v.X < minX) minX = v.X; if (v.Y < minY) minY = v.Y; if (v.X > maxX) maxX = v.X; @@ -33,162 +31,185 @@ namespace OpenNest.Geometry var cellSize = System.Math.Min(width, height); if (cellSize == 0) - return new Vector(minX, minY); + return new Vector((minX + maxX) / 2, (minY + maxY) / 2); - var halfCell = cellSize / 2.0; + var halfCell = cellSize / 2; - // Priority queue (sorted list, largest distance first) var queue = new List(); for (var x = minX; x < maxX; x += cellSize) - { for (var y = minY; y < maxY; y += cellSize) + queue.Add(new Cell(x + halfCell, y + halfCell, halfCell, outer, holes)); + + queue.Sort((a, b) => b.MaxDist.CompareTo(a.MaxDist)); + + var bestCell = GetCentroidCell(outer, holes); + + for (var i = 0; i < queue.Count; i++) + if (queue[i].Dist > bestCell.Dist) { - queue.Add(new Cell(x + halfCell, y + halfCell, halfCell, vertices)); + bestCell = queue[i]; + break; } - } - - queue.Sort((a, b) => b.Max.CompareTo(a.Max)); - - var bestCell = GetCentroidCell(vertices); - - var bboxCell = new Cell(minX + width / 2, minY + height / 2, 0, vertices); - if (bboxCell.Distance > bestCell.Distance) - bestCell = bboxCell; while (queue.Count > 0) { - var cell = queue[queue.Count - 1]; - queue.RemoveAt(queue.Count - 1); + var cell = queue[0]; + queue.RemoveAt(0); - if (cell.Distance > bestCell.Distance) + if (cell.Dist > bestCell.Dist) bestCell = cell; - if (cell.Max - bestCell.Distance <= precision) + if (cell.MaxDist - bestCell.Dist <= precision) continue; halfCell = cell.HalfSize / 2; - var c1 = new Cell(cell.X - halfCell, cell.Y - halfCell, halfCell, vertices); - var c2 = new Cell(cell.X + halfCell, cell.Y - halfCell, halfCell, vertices); - var c3 = new Cell(cell.X - halfCell, cell.Y + halfCell, halfCell, vertices); - var c4 = new Cell(cell.X + halfCell, cell.Y + halfCell, halfCell, vertices); + var newCells = new[] + { + new Cell(cell.X - halfCell, cell.Y - halfCell, halfCell, outer, holes), + new Cell(cell.X + halfCell, cell.Y - halfCell, halfCell, outer, holes), + new Cell(cell.X - halfCell, cell.Y + halfCell, halfCell, outer, holes), + new Cell(cell.X + halfCell, cell.Y + halfCell, halfCell, outer, holes), + }; - InsertSorted(queue, c1); - InsertSorted(queue, c2); - InsertSorted(queue, c3); - InsertSorted(queue, c4); + for (var i = 0; i < newCells.Length; i++) + { + if (newCells[i].MaxDist > bestCell.Dist + precision) + InsertSorted(queue, newCells[i]); + } } return new Vector(bestCell.X, bestCell.Y); } - private static void InsertSorted(List queue, Cell cell) + private static void InsertSorted(List list, Cell cell) { - var index = queue.BinarySearch(cell, CellComparer.Instance); - if (index < 0) index = ~index; - queue.Insert(index, cell); + var idx = 0; + while (idx < list.Count && list[idx].MaxDist > cell.MaxDist) + idx++; + list.Insert(idx, cell); } - private static Cell GetCentroidCell(List vertices) + private static Cell GetCentroidCell(Polygon outer, IList holes) { var area = 0.0; var cx = 0.0; var cy = 0.0; - var n = vertices.Count; + var verts = outer.Vertices; - for (int i = 0, j = n - 1; i < n; j = i++) + for (int i = 0, j = verts.Count - 1; i < verts.Count; j = i++) { - var a = vertices[i]; - var b = vertices[j]; - var f = a.X * b.Y - b.X * a.Y; - cx += (a.X + b.X) * f; - cy += (a.Y + b.Y) * f; - area += f * 3; + var a = verts[i]; + var b = verts[j]; + var cross = a.X * b.Y - b.X * a.Y; + cx += (a.X + b.X) * cross; + cy += (a.Y + b.Y) * cross; + area += cross; } - if (area == 0) - return new Cell(vertices[0].X, vertices[0].Y, 0, vertices); + area *= 0.5; - return new Cell(cx / area, cy / area, 0, vertices); + if (System.Math.Abs(area) < 1e-10) + return new Cell(verts[0].X, verts[0].Y, 0, outer, holes); + + cx /= (6 * area); + cy /= (6 * area); + + return new Cell(cx, cy, 0, outer, holes); } - private static double PointToPolygonDistance(double x, double y, List vertices) + private static double PointToPolygonDist(double x, double y, Polygon polygon) { - var inside = false; - var minDistSq = double.MaxValue; - var n = vertices.Count; + var minDist = double.MaxValue; + var verts = polygon.Vertices; - for (int i = 0, j = n - 1; i < n; j = i++) + for (int i = 0, j = verts.Count - 1; i < verts.Count; j = i++) { - var a = vertices[i]; - var b = vertices[j]; + var a = verts[i]; + var b = verts[j]; - if ((a.Y > y) != (b.Y > y) && - x < (b.X - a.X) * (y - a.Y) / (b.Y - a.Y) + a.X) + var dx = b.X - a.X; + var dy = b.Y - a.Y; + + if (dx != 0 || dy != 0) { - inside = !inside; + var t = ((x - a.X) * dx + (y - a.Y) * dy) / (dx * dx + dy * dy); + + if (t > 1) + { + a = b; + } + else if (t > 0) + { + a = new Vector(a.X + dx * t, a.Y + dy * t); + } } - var distSq = SegmentDistanceSq(x, y, a.X, a.Y, b.X, b.Y); - if (distSq < minDistSq) - minDistSq = distSq; + var segDx = x - a.X; + var segDy = y - a.Y; + var dist = System.Math.Sqrt(segDx * segDx + segDy * segDy); + + if (dist < minDist) + minDist = dist; } - var dist = System.Math.Sqrt(minDistSq); - return inside ? dist : -dist; + return minDist; } - private static double SegmentDistanceSq(double px, double py, - double ax, double ay, double bx, double by) - { - var dx = bx - ax; - var dy = by - ay; - - if (dx != 0 || dy != 0) - { - var t = ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy); - - if (t > 1) - { - ax = bx; - ay = by; - } - else if (t > 0) - { - ax += dx * t; - ay += dy * t; - } - } - - dx = px - ax; - dy = py - ay; - - return dx * dx + dy * dy; - } - - private struct Cell + private sealed class Cell { public readonly double X; public readonly double Y; public readonly double HalfSize; - public readonly double Distance; - public readonly double Max; + public readonly double Dist; + public readonly double MaxDist; - public Cell(double x, double y, double halfSize, List vertices) + public Cell(double x, double y, double halfSize, Polygon outer, IList holes) { X = x; Y = y; HalfSize = halfSize; - Distance = PointToPolygonDistance(x, y, vertices); - Max = Distance + halfSize * System.Math.Sqrt(2); + + var pt = new Vector(x, y); + var inside = outer.ContainsPoint(pt); + + if (inside && holes != null) + { + for (var i = 0; i < holes.Count; i++) + { + if (holes[i].ContainsPoint(pt)) + { + inside = false; + break; + } + } + } + + Dist = PointToAllEdgesDist(x, y, outer, holes); + + if (!inside) + Dist = -Dist; + + MaxDist = Dist + HalfSize * System.Math.Sqrt(2); } } - private class CellComparer : IComparer + private static double PointToAllEdgesDist(double x, double y, Polygon outer, IList holes) { - public static readonly CellComparer Instance = new CellComparer(); - public int Compare(Cell a, Cell b) => b.Max.CompareTo(a.Max); + var minDist = PointToPolygonDist(x, y, outer); + + if (holes != null) + { + for (var i = 0; i < holes.Count; i++) + { + var d = PointToPolygonDist(x, y, holes[i]); + if (d < minDist) + minDist = d; + } + } + + return minDist; } } } diff --git a/OpenNest.Engine/AccumulatingProgress.cs b/OpenNest.Engine/AccumulatingProgress.cs new file mode 100644 index 0000000..7b84803 --- /dev/null +++ b/OpenNest.Engine/AccumulatingProgress.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; + +namespace OpenNest +{ + /// + /// Wraps an IProgress to prepend previously placed parts to each report, + /// so the UI shows the full picture during incremental fills. + /// + internal class AccumulatingProgress : IProgress + { + private readonly IProgress inner; + private readonly List previousParts; + + public AccumulatingProgress(IProgress inner, List previousParts) + { + this.inner = inner; + this.previousParts = previousParts; + } + + public void Report(NestProgress value) + { + if (value.BestParts != null && previousParts.Count > 0) + { + var combined = new List(previousParts.Count + value.BestParts.Count); + combined.AddRange(previousParts); + combined.AddRange(value.BestParts); + value.BestParts = combined; + value.BestPartCount = combined.Count; + } + + inner.Report(value); + } + } +} diff --git a/OpenNest.Engine/AngleCandidateBuilder.cs b/OpenNest.Engine/AngleCandidateBuilder.cs new file mode 100644 index 0000000..8c09ce0 --- /dev/null +++ b/OpenNest.Engine/AngleCandidateBuilder.cs @@ -0,0 +1,97 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using OpenNest.Engine.ML; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest +{ + /// + /// Builds candidate rotation angles for single-item fill. Encapsulates the + /// full pipeline: base angles, narrow-area sweep, ML prediction, and + /// known-good pruning across fills. + /// + public class AngleCandidateBuilder + { + private readonly HashSet knownGoodAngles = new(); + + public bool ForceFullSweep { get; set; } + + public List Build(NestItem item, double bestRotation, Box workArea) + { + var angles = new List { bestRotation, bestRotation + Angle.HalfPI }; + + 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); + var needsSweep = workAreaShortSide < partLongestSide || ForceFullSweep; + + if (needsSweep) + { + 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 (!ForceFullSweep && 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($"[AngleCandidateBuilder] ML: {angles.Count} angles -> {mlAngles.Count} predicted"); + angles = mlAngles; + } + } + } + + if (knownGoodAngles.Count > 0 && !ForceFullSweep) + { + var pruned = new List { bestRotation, bestRotation + Angle.HalfPI }; + + foreach (var a in knownGoodAngles) + { + if (!pruned.Any(existing => existing.IsEqualTo(a))) + pruned.Add(a); + } + + Debug.WriteLine($"[AngleCandidateBuilder] Pruned: {angles.Count} -> {pruned.Count} angles (known-good)"); + return pruned; + } + + return angles; + } + + /// + /// Records angles that produced results. These are used to prune + /// subsequent Build() calls. + /// + public void RecordProductive(List angleResults) + { + foreach (var ar in angleResults) + { + if (ar.PartCount > 0) + knownGoodAngles.Add(Angle.ToRadians(ar.AngleDeg)); + } + } + } +} diff --git a/OpenNest.Engine/DefaultNestEngine.cs b/OpenNest.Engine/DefaultNestEngine.cs index 43ca4c0..8cb765e 100644 --- a/OpenNest.Engine/DefaultNestEngine.cs +++ b/OpenNest.Engine/DefaultNestEngine.cs @@ -5,7 +5,6 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using OpenNest.Engine.BestFit; -using OpenNest.Engine.ML; using OpenNest.Geometry; using OpenNest.Math; using OpenNest.RectanglePacking; @@ -20,11 +19,13 @@ namespace OpenNest public override string Description => "Multi-phase nesting (Linear, Pairs, RectBestFit)"; - public bool ForceFullAngleSweep { get; set; } + private readonly AngleCandidateBuilder angleBuilder = new(); - // Angles that have produced results across multiple Fill calls. - // Populated after each Fill; used to prune subsequent fills. - private readonly HashSet knownGoodAngles = new(); + public bool ForceFullAngleSweep + { + get => angleBuilder.ForceFullSweep; + set => angleBuilder.ForceFullSweep = value; + } // --- Public Fill API --- @@ -134,7 +135,8 @@ namespace OpenNest token.ThrowIfCancellationRequested(); - var pairResult = FillWithPairs(nestItem, workArea, token, progress); + var pairFiller = new PairFiller(Plate.Size, Plate.PartSpacing); + var pairResult = pairFiller.Fill(nestItem, workArea, PlateNumber, 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")}"); @@ -178,11 +180,12 @@ namespace OpenNest try { var bestRotation = RotationAnalysis.FindBestRotation(item); - var angles = BuildCandidateAngles(item, bestRotation, workArea); + var angles = angleBuilder.Build(item, bestRotation, workArea); // Pairs phase var pairSw = Stopwatch.StartNew(); - var pairResult = FillWithPairs(item, workArea, token, progress); + var pairFiller = new PairFiller(Plate.Size, Plate.PartSpacing); + var pairResult = pairFiller.Fill(item, workArea, PlateNumber, token, progress); pairSw.Stop(); best = pairResult; var bestScore = FillScore.Compute(best, workArea); @@ -239,12 +242,7 @@ namespace OpenNest linearSw.Stop(); PhaseResults.Add(new PhaseResult(NestPhase.Linear, bestLinearCount, linearSw.ElapsedMilliseconds)); - // Record productive angles for future fills. - foreach (var ar in AngleResults) - { - if (ar.PartCount > 0) - knownGoodAngles.Add(Angle.ToRadians(ar.AngleDeg)); - } + angleBuilder.RecordProductive(AngleResults); Debug.WriteLine($"[FindBestFill] Linear: {bestScore.Count} parts, density={bestScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}"); @@ -274,78 +272,6 @@ namespace OpenNest return best ?? new List(); } - // --- Angle building --- - - private List BuildCandidateAngles(NestItem item, double bestRotation, Box workArea) - { - 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); - var needsSweep = workAreaShortSide < partLongestSide || ForceFullAngleSweep; - - if (needsSweep) - { - 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) - { - 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; - } - } - } - - // If we have known-good angles from previous fills, use only those - // plus the defaults (bestRotation + 90°). This prunes the expensive - // angle sweep after the first fill. - if (knownGoodAngles.Count > 0 && !ForceFullAngleSweep) - { - var pruned = new List { bestRotation, bestRotation + Angle.HalfPI }; - - foreach (var a in knownGoodAngles) - { - if (!pruned.Any(existing => existing.IsEqualTo(a))) - pruned.Add(a); - } - - Debug.WriteLine($"[BuildCandidateAngles] Pruned: {angles.Count} -> {pruned.Count} angles (known-good)"); - return pruned; - } - - return angles; - } - // --- Fill strategies --- private List FillRectangleBestFit(NestItem item, Box workArea) @@ -359,123 +285,9 @@ namespace OpenNest return BinConverter.ToParts(bin, new List { item }); } - 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, - Plate.PartSpacing); - - var candidates = SelectPairCandidates(bestFits, workArea); - 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 { } - - List best = null; - var bestScore = default(FillScore); - var sinceImproved = 0; - - try - { - for (var i = 0; i < candidates.Count; i++) - { - token.ThrowIfCancellationRequested(); - - 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 score = FillScore.Compute(filled, workArea); - if (best == null || score > bestScore) - { - best = filled; - bestScore = score; - sinceImproved = 0; - } - else - { - sinceImproved++; - } - } - else - { - sinceImproved++; - } - - ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea, - $"Pairs: {i + 1}/{candidates.Count} candidates, best = {bestScore.Count} parts"); - - // Early exit: stop if we've tried enough candidates without improvement. - if (i >= 9 && sinceImproved >= 10) - { - Debug.WriteLine($"[FillWithPairs] Early exit at {i + 1}/{candidates.Count} — no improvement in last {sinceImproved} candidates"); - break; - } - } - } - catch (OperationCanceledException) - { - Debug.WriteLine("[FillWithPairs] Cancelled mid-phase, using results so far"); - } - - Debug.WriteLine($"[FillWithPairs] Best pair result: {bestScore.Count} parts, 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(); - } - - /// - /// 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. - /// - private List SelectPairCandidates(List bestFits, Box workArea) - { - var kept = bestFits.Where(r => r.Keep).ToList(); - var top = kept.Take(50).ToList(); - - var workShortSide = System.Math.Min(workArea.Width, workArea.Length); - var plateShortSide = System.Math.Min(Plate.Size.Width, Plate.Size.Length); - - // When the work area is significantly narrower than the plate, - // 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 = bestFits - .Where(r => r.ShortestSide <= workShortSide + Tolerance.Epsilon - && r.Utilization >= 0.3) - .OrderByDescending(r => r.Utilization); - - var existing = new HashSet(top); - - foreach (var r in stripCandidates) - { - if (top.Count >= 100) - break; - - if (existing.Add(r)) - top.Add(r); - } - - Debug.WriteLine($"[SelectPairCandidates] Strip mode: {top.Count} candidates (shortSide <= {workShortSide:F1})"); - } - - return top; - } - // --- Pattern helpers --- - private Pattern BuildRotatedPattern(List groupParts, double angle) + internal static Pattern BuildRotatedPattern(List groupParts, double angle) { var pattern = new Pattern(); var center = ((IEnumerable)groupParts).GetBoundingBox().Center; @@ -495,7 +307,7 @@ namespace OpenNest return pattern; } - private List FillPattern(FillLinear engine, List groupParts, List angles, Box workArea) + internal static List FillPattern(FillLinear engine, List groupParts, List angles, Box workArea) { var results = new System.Collections.Concurrent.ConcurrentBag<(List Parts, FillScore Score)>(); diff --git a/OpenNest.Engine/FillLinear.cs b/OpenNest.Engine/FillLinear.cs index 92e6415..8fe1ef0 100644 --- a/OpenNest.Engine/FillLinear.cs +++ b/OpenNest.Engine/FillLinear.cs @@ -19,6 +19,11 @@ namespace OpenNest public double HalfSpacing => PartSpacing / 2; + /// + /// Optional multi-part patterns (e.g. interlocking pairs) to try in remainder strips. + /// + public List RemainderPatterns { get; set; } + private static Vector MakeOffset(NestDirection direction, double distance) { return direction == NestDirection.Horizontal @@ -408,7 +413,30 @@ namespace OpenNest return new List(); var rotations = BuildRotationSet(seedPattern); - return FindBestFill(rotations, remainingStrip); + var best = FindBestFill(rotations, remainingStrip); + + if (RemainderPatterns != null) + { + System.Diagnostics.Debug.WriteLine($"[FillRemainingStrip] Strip: {remainingStrip.Width:F1}x{remainingStrip.Length:F1}, individual best={best?.Count ?? 0}, trying {RemainderPatterns.Count} patterns"); + + foreach (var pattern in RemainderPatterns) + { + var filler = new FillLinear(remainingStrip, PartSpacing); + var h = filler.Fill(pattern, NestDirection.Horizontal); + var v = filler.Fill(pattern, NestDirection.Vertical); + + System.Diagnostics.Debug.WriteLine($"[FillRemainingStrip] Pattern ({pattern.Parts.Count} parts, bbox={pattern.BoundingBox.Width:F1}x{pattern.BoundingBox.Length:F1}): H={h?.Count ?? 0}, V={v?.Count ?? 0}"); + + if (h != null && h.Count > (best?.Count ?? 0)) + best = h; + if (v != null && v.Count > (best?.Count ?? 0)) + best = v; + } + + System.Diagnostics.Debug.WriteLine($"[FillRemainingStrip] Final best={best?.Count ?? 0}"); + } + + return best ?? new List(); } private static double FindPlacedEdge(List placedParts, NestDirection tiledAxis) diff --git a/OpenNest.Engine/NestEngineBase.cs b/OpenNest.Engine/NestEngineBase.cs index 29c8eb6..681eacb 100644 --- a/OpenNest.Engine/NestEngineBase.cs +++ b/OpenNest.Engine/NestEngineBase.cs @@ -71,29 +71,36 @@ namespace OpenNest .Where(i => i.Quantity == 1) .ToList(); - // Phase 1: Fill multi-quantity drawings sequentially. - foreach (var item in fillItems) + // Phase 1: Fill multi-quantity drawings using RemnantFiller. + if (fillItems.Count > 0) { - if (token.IsCancellationRequested) - break; + var remnantFiller = new RemnantFiller(workArea, Plate.PartSpacing); - if (item.Quantity <= 0 || workArea.Width <= 0 || workArea.Length <= 0) - continue; + Func> fillFunc = (ni, b) => + FillExact(ni, b, progress, token); - var parts = FillExact( - new NestItem { Drawing = item.Drawing, Quantity = item.Quantity }, - workArea, progress, token); + var fillParts = remnantFiller.FillItems(fillItems, fillFunc, token, progress); - if (parts.Count > 0) + if (fillParts.Count > 0) { - allParts.AddRange(parts); - item.Quantity = System.Math.Max(0, item.Quantity - parts.Count); - var placedObstacles = parts.Select(p => p.BoundingBox.Offset(Plate.PartSpacing)).ToList(); + allParts.AddRange(fillParts); + + // Deduct placed quantities + foreach (var item in fillItems) + { + var placed = fillParts.Count(p => + p.BaseDrawing.Name == item.Drawing.Name); + item.Quantity = System.Math.Max(0, item.Quantity - placed); + } + + // Update workArea for pack phase + var placedObstacles = fillParts.Select(p => p.BoundingBox.Offset(Plate.PartSpacing)).ToList(); var finder = new RemnantFinder(workArea, placedObstacles); var remnants = finder.FindRemnants(); - if (remnants.Count == 0) - break; - workArea = remnants[0]; // Largest remnant + if (remnants.Count > 0) + workArea = remnants[0]; + else + workArea = new Box(0, 0, 0, 0); } } @@ -177,7 +184,7 @@ namespace OpenNest // --- Protected utilities --- - protected static void ReportProgress( + internal static void ReportProgress( IProgress progress, NestPhase phase, int plateNumber, diff --git a/OpenNest.Engine/OpenNest.Engine.csproj b/OpenNest.Engine/OpenNest.Engine.csproj index bcf6d9e..cda0c99 100644 --- a/OpenNest.Engine/OpenNest.Engine.csproj +++ b/OpenNest.Engine/OpenNest.Engine.csproj @@ -4,6 +4,9 @@ OpenNest OpenNest.Engine + + + diff --git a/OpenNest.Engine/PairFiller.cs b/OpenNest.Engine/PairFiller.cs new file mode 100644 index 0000000..b448c96 --- /dev/null +++ b/OpenNest.Engine/PairFiller.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using OpenNest.Engine.BestFit; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest +{ + /// + /// Fills a work area using interlocking part pairs from BestFitCache. + /// Extracted from DefaultNestEngine.FillWithPairs. + /// + public class PairFiller + { + private readonly Size plateSize; + private readonly double partSpacing; + + public PairFiller(Size plateSize, double partSpacing) + { + this.plateSize = plateSize; + this.partSpacing = partSpacing; + } + + public List Fill(NestItem item, Box workArea, + int plateNumber = 0, + CancellationToken token = default, + IProgress progress = null) + { + var bestFits = BestFitCache.GetOrCompute( + item.Drawing, plateSize.Width, plateSize.Length, partSpacing); + + var candidates = SelectPairCandidates(bestFits, workArea); + Debug.WriteLine($"[PairFiller] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}"); + Debug.WriteLine($"[PairFiller] Plate: {plateSize.Width:F2}x{plateSize.Length:F2}, WorkArea: {workArea.Width:F2}x{workArea.Length:F2}"); + + List best = null; + var bestScore = default(FillScore); + var sinceImproved = 0; + + try + { + for (var i = 0; i < candidates.Count; i++) + { + token.ThrowIfCancellationRequested(); + + var result = candidates[i]; + var pairParts = result.BuildParts(item.Drawing); + var angles = result.HullAngles; + var engine = new FillLinear(workArea, partSpacing); + var filled = DefaultNestEngine.FillPattern(engine, pairParts, angles, workArea); + + if (filled != null && filled.Count > 0) + { + var score = FillScore.Compute(filled, workArea); + if (best == null || score > bestScore) + { + best = filled; + bestScore = score; + sinceImproved = 0; + } + else + { + sinceImproved++; + } + } + else + { + sinceImproved++; + } + + NestEngineBase.ReportProgress(progress, NestPhase.Pairs, plateNumber, best, workArea, + $"Pairs: {i + 1}/{candidates.Count} candidates, best = {bestScore.Count} parts"); + + // Early exit: stop if we've tried enough candidates without improvement. + if (i >= 9 && sinceImproved >= 10) + { + Debug.WriteLine($"[PairFiller] Early exit at {i + 1}/{candidates.Count} — no improvement in last {sinceImproved} candidates"); + break; + } + } + } + catch (OperationCanceledException) + { + Debug.WriteLine("[PairFiller] Cancelled mid-phase, using results so far"); + } + + Debug.WriteLine($"[PairFiller] Best pair result: {bestScore.Count} parts, density={bestScore.Density:P1}"); + return best ?? new List(); + } + + private List SelectPairCandidates(List bestFits, Box workArea) + { + var kept = bestFits.Where(r => r.Keep).ToList(); + var top = kept.Take(50).ToList(); + + var workShortSide = System.Math.Min(workArea.Width, workArea.Length); + var plateShortSide = System.Math.Min(plateSize.Width, plateSize.Length); + + if (workShortSide < plateShortSide * 0.5) + { + var stripCandidates = bestFits + .Where(r => r.ShortestSide <= workShortSide + Tolerance.Epsilon + && r.Utilization >= 0.3) + .OrderByDescending(r => r.Utilization); + + var existing = new HashSet(top); + + foreach (var r in stripCandidates) + { + if (top.Count >= 100) + break; + + if (existing.Add(r)) + top.Add(r); + } + + Debug.WriteLine($"[PairFiller] Strip mode: {top.Count} candidates (shortSide <= {workShortSide:F1})"); + } + + return top; + } + } +} diff --git a/OpenNest.Engine/RemnantFiller.cs b/OpenNest.Engine/RemnantFiller.cs new file mode 100644 index 0000000..44036d8 --- /dev/null +++ b/OpenNest.Engine/RemnantFiller.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using OpenNest.Geometry; + +namespace OpenNest +{ + /// + /// Iteratively fills remnant boxes with items using a RemnantFinder. + /// After each fill, re-discovers free rectangles and tries again + /// until no more items can be placed. + /// + public class RemnantFiller + { + private readonly RemnantFinder finder; + private readonly double spacing; + + public RemnantFiller(Box workArea, double spacing) + { + this.spacing = spacing; + finder = new RemnantFinder(workArea); + } + + public void AddObstacles(IEnumerable parts) + { + foreach (var part in parts) + finder.AddObstacle(part.BoundingBox.Offset(spacing)); + } + + public List FillItems( + List items, + Func> fillFunc, + CancellationToken token = default, + IProgress progress = null) + { + if (items == null || items.Count == 0) + return new List(); + + var allParts = new List(); + var madeProgress = true; + + // Track quantities locally — do not mutate the input NestItem objects. + var localQty = new Dictionary(); + foreach (var item in items) + localQty[item.Drawing.Name] = item.Quantity; + + while (madeProgress && !token.IsCancellationRequested) + { + madeProgress = false; + + var minRemnantDim = double.MaxValue; + foreach (var item in items) + { + var qty = localQty[item.Drawing.Name]; + if (qty <= 0) + continue; + var bb = item.Drawing.Program.BoundingBox(); + var dim = System.Math.Min(bb.Width, bb.Length); + if (dim < minRemnantDim) + minRemnantDim = dim; + } + + if (minRemnantDim == double.MaxValue) + break; + + var freeBoxes = finder.FindRemnants(minRemnantDim); + + if (freeBoxes.Count == 0) + break; + + foreach (var item in items) + { + if (token.IsCancellationRequested) + break; + + var qty = localQty[item.Drawing.Name]; + if (qty == 0) + continue; + + var itemBbox = item.Drawing.Program.BoundingBox(); + var minItemDim = System.Math.Min(itemBbox.Width, itemBbox.Length); + + foreach (var box in freeBoxes) + { + if (System.Math.Min(box.Width, box.Length) < minItemDim) + continue; + + var fillItem = new NestItem { Drawing = item.Drawing, Quantity = qty }; + var remnantParts = fillFunc(fillItem, box); + + if (remnantParts != null && remnantParts.Count > 0) + { + allParts.AddRange(remnantParts); + localQty[item.Drawing.Name] = System.Math.Max(0, qty - remnantParts.Count); + + foreach (var p in remnantParts) + finder.AddObstacle(p.BoundingBox.Offset(spacing)); + + madeProgress = true; + break; + } + } + + if (madeProgress) + break; + } + } + + return allParts; + } + } +} diff --git a/OpenNest.Engine/RemnantFinder.cs b/OpenNest.Engine/RemnantFinder.cs index b5e2cef..a6ad082 100644 --- a/OpenNest.Engine/RemnantFinder.cs +++ b/OpenNest.Engine/RemnantFinder.cs @@ -5,12 +5,37 @@ using OpenNest.Geometry; namespace OpenNest { + /// + /// A remnant box with a priority tier. + /// 0 = within the used envelope (best), 1 = extends past one edge, 2 = fully outside. + /// + public struct TieredRemnant + { + public Box Box; + public int Priority; + + public TieredRemnant(Box box, int priority) + { + Box = box; + Priority = priority; + } + } + public class RemnantFinder { private readonly Box workArea; public List Obstacles { get; } = new(); + private struct CellGrid + { + public bool[,] Empty; + public List XCoords; + public List YCoords; + public int Rows; + public int Cols; + } + public RemnantFinder(Box workArea, List obstacles = null) { this.workArea = workArea; @@ -27,54 +52,52 @@ namespace OpenNest public List FindRemnants(double minDimension = 0) { - var xs = new SortedSet { workArea.Left, workArea.Right }; - var ys = new SortedSet { workArea.Bottom, workArea.Top }; + var grid = BuildGrid(); - foreach (var obs in Obstacles) - { - var clipped = ClipToWorkArea(obs); - if (clipped.Width <= 0 || clipped.Length <= 0) - continue; - - xs.Add(clipped.Left); - xs.Add(clipped.Right); - ys.Add(clipped.Bottom); - ys.Add(clipped.Top); - } - - var xList = xs.ToList(); - var yList = ys.ToList(); - - var cols = xList.Count - 1; - var rows = yList.Count - 1; - - if (cols <= 0 || rows <= 0) + if (grid.Rows <= 0 || grid.Cols <= 0) return new List(); - var empty = new bool[rows, cols]; + var merged = MergeCells(grid); + var sized = FilterBySize(merged, minDimension); + var unique = RemoveDominated(sized); + SortByEdgeProximity(unique); + return unique; + } - for (var r = 0; r < rows; r++) + /// + /// Finds remnants and splits them into priority tiers based on the + /// bounding box of all placed parts (the "used envelope"). + /// Priority 0: fully within the used envelope — compact, preferred. + /// Priority 1: extends past one edge of the envelope. + /// Priority 2: fully outside the envelope — last resort. + /// + public List FindTieredRemnants(double minDimension = 0) + { + var remnants = FindRemnants(minDimension); + + if (Obstacles.Count == 0 || remnants.Count == 0) + return remnants.Select(r => new TieredRemnant(r, 0)).ToList(); + + var envelope = ComputeEnvelope(); + var results = new List(); + + foreach (var remnant in remnants) { - for (var c = 0; c < cols; c++) - { - var cell = new Box(xList[c], yList[r], - xList[c + 1] - xList[c], yList[r + 1] - yList[r]); + var before = results.Count; + SplitAtEnvelope(remnant, envelope, minDimension, results); - empty[r, c] = !OverlapsAnyObstacle(cell); - } + // If all splits fell below minDim, keep the original unsplit. + if (results.Count == before) + results.Add(new TieredRemnant(remnant, 1)); } - var merged = MergeCells(empty, xList, yList, rows, cols); - - var results = new List(); - - foreach (var box in merged) + results.Sort((a, b) => { - if (box.Width >= minDimension && box.Length >= minDimension) - results.Add(box); - } + if (a.Priority != b.Priority) + return a.Priority.CompareTo(b.Priority); + return b.Box.Area().CompareTo(a.Box.Area()); + }); - results.Sort((a, b) => b.Area().CompareTo(a.Area())); return results; } @@ -88,6 +111,231 @@ namespace OpenNest return new RemnantFinder(plate.WorkArea(), obstacles); } + private CellGrid BuildGrid() + { + var clipped = ClipObstacles(); + + var xs = new SortedSet { workArea.Left, workArea.Right }; + var ys = new SortedSet { workArea.Bottom, workArea.Top }; + + foreach (var obs in clipped) + { + xs.Add(obs.Left); + xs.Add(obs.Right); + ys.Add(obs.Bottom); + ys.Add(obs.Top); + } + + var grid = new CellGrid + { + XCoords = xs.ToList(), + YCoords = ys.ToList(), + }; + + grid.Cols = grid.XCoords.Count - 1; + grid.Rows = grid.YCoords.Count - 1; + + if (grid.Cols <= 0 || grid.Rows <= 0) + { + grid.Empty = new bool[0, 0]; + return grid; + } + + grid.Empty = new bool[grid.Rows, grid.Cols]; + + for (var r = 0; r < grid.Rows; r++) + { + for (var c = 0; c < grid.Cols; c++) + { + var cell = new Box(grid.XCoords[c], grid.YCoords[r], + grid.XCoords[c + 1] - grid.XCoords[c], + grid.YCoords[r + 1] - grid.YCoords[r]); + + grid.Empty[r, c] = !OverlapsAny(cell, clipped); + } + } + + return grid; + } + + private List ClipObstacles() + { + var clipped = new List(Obstacles.Count); + + foreach (var obs in Obstacles) + { + var c = ClipToWorkArea(obs); + if (c.Width > 0 && c.Length > 0) + clipped.Add(c); + } + + return clipped; + } + + private static bool OverlapsAny(Box cell, List obstacles) + { + foreach (var obs in obstacles) + { + if (cell.Left < obs.Right && cell.Right > obs.Left && + cell.Bottom < obs.Top && cell.Top > obs.Bottom) + return true; + } + + return false; + } + + private static List FilterBySize(List boxes, double minDimension) + { + if (minDimension <= 0) + return boxes; + + var result = new List(); + + foreach (var box in boxes) + { + if (box.Width >= minDimension && box.Length >= minDimension) + result.Add(box); + } + + return result; + } + + private static List RemoveDominated(List boxes) + { + boxes.Sort((a, b) => b.Area().CompareTo(a.Area())); + var results = new List(); + + foreach (var box in boxes) + { + var dominated = false; + + foreach (var larger in results) + { + if (IsContainedIn(box, larger)) + { + dominated = true; + break; + } + } + + if (!dominated) + results.Add(box); + } + + return results; + } + + private static bool IsContainedIn(Box inner, Box outer) + { + var eps = Math.Tolerance.Epsilon; + return inner.Left >= outer.Left - eps && + inner.Right <= outer.Right + eps && + inner.Bottom >= outer.Bottom - eps && + inner.Top <= outer.Top + eps; + } + + private void SortByEdgeProximity(List boxes) + { + boxes.Sort((a, b) => + { + var aEdge = TouchesEdge(a) ? 1 : 0; + var bEdge = TouchesEdge(b) ? 1 : 0; + + if (aEdge != bEdge) + return bEdge.CompareTo(aEdge); + + return b.Area().CompareTo(a.Area()); + }); + } + + private bool TouchesEdge(Box box) + { + return box.Left <= workArea.Left + Math.Tolerance.Epsilon + || box.Right >= workArea.Right - Math.Tolerance.Epsilon + || box.Bottom <= workArea.Bottom + Math.Tolerance.Epsilon + || box.Top >= workArea.Top - Math.Tolerance.Epsilon; + } + + private Box ComputeEnvelope() + { + var envLeft = double.MaxValue; + var envBottom = double.MaxValue; + var envRight = double.MinValue; + var envTop = double.MinValue; + + foreach (var obs in Obstacles) + { + if (obs.Left < envLeft) envLeft = obs.Left; + if (obs.Bottom < envBottom) envBottom = obs.Bottom; + if (obs.Right > envRight) envRight = obs.Right; + if (obs.Top > envTop) envTop = obs.Top; + } + + return new Box(envLeft, envBottom, envRight - envLeft, envTop - envBottom); + } + + private static void SplitAtEnvelope(Box remnant, Box envelope, double minDim, List results) + { + var eps = Math.Tolerance.Epsilon; + + // Fully within the envelope. + if (remnant.Left >= envelope.Left - eps && remnant.Right <= envelope.Right + eps && + remnant.Bottom >= envelope.Bottom - eps && remnant.Top <= envelope.Top + eps) + { + results.Add(new TieredRemnant(remnant, 0)); + return; + } + + // Fully outside the envelope (no overlap). + if (remnant.Left >= envelope.Right - eps || remnant.Right <= envelope.Left + eps || + remnant.Bottom >= envelope.Top - eps || remnant.Top <= envelope.Bottom + eps) + { + results.Add(new TieredRemnant(remnant, 2)); + return; + } + + // Partially overlapping — split at envelope edges. + var innerLeft = System.Math.Max(remnant.Left, envelope.Left); + var innerBottom = System.Math.Max(remnant.Bottom, envelope.Bottom); + var innerRight = System.Math.Min(remnant.Right, envelope.Right); + var innerTop = System.Math.Min(remnant.Top, envelope.Top); + + // Inner portion (priority 0). + TryAdd(results, innerLeft, innerBottom, innerRight - innerLeft, innerTop - innerBottom, 0, minDim); + + // Edge extensions (priority 1). + if (remnant.Right > envelope.Right + eps) + TryAdd(results, envelope.Right, remnant.Bottom, remnant.Right - envelope.Right, remnant.Length, 1, minDim); + + if (remnant.Left < envelope.Left - eps) + TryAdd(results, remnant.Left, remnant.Bottom, envelope.Left - remnant.Left, remnant.Length, 1, minDim); + + if (remnant.Top > envelope.Top + eps) + TryAdd(results, innerLeft, envelope.Top, innerRight - innerLeft, remnant.Top - envelope.Top, 1, minDim); + + if (remnant.Bottom < envelope.Bottom - eps) + TryAdd(results, innerLeft, remnant.Bottom, innerRight - innerLeft, envelope.Bottom - remnant.Bottom, 1, minDim); + + // Corner extensions (priority 2). + if (remnant.Right > envelope.Right + eps && remnant.Top > envelope.Top + eps) + TryAdd(results, envelope.Right, envelope.Top, remnant.Right - envelope.Right, remnant.Top - envelope.Top, 2, minDim); + + if (remnant.Right > envelope.Right + eps && remnant.Bottom < envelope.Bottom - eps) + TryAdd(results, envelope.Right, remnant.Bottom, remnant.Right - envelope.Right, envelope.Bottom - remnant.Bottom, 2, minDim); + + if (remnant.Left < envelope.Left - eps && remnant.Top > envelope.Top + eps) + TryAdd(results, remnant.Left, envelope.Top, envelope.Left - remnant.Left, remnant.Top - envelope.Top, 2, minDim); + + if (remnant.Left < envelope.Left - eps && remnant.Bottom < envelope.Bottom - eps) + TryAdd(results, remnant.Left, remnant.Bottom, envelope.Left - remnant.Left, envelope.Bottom - remnant.Bottom, 2, minDim); + } + + private static void TryAdd(List results, double x, double y, double w, double h, int priority, double minDim) + { + if (w >= minDim && h >= minDim) + results.Add(new TieredRemnant(new Box(x, y, w, h), priority)); + } + private Box ClipToWorkArea(Box obs) { var left = System.Math.Max(obs.Left, workArea.Left); @@ -101,72 +349,49 @@ namespace OpenNest return new Box(left, bottom, right - left, top - bottom); } - private bool OverlapsAnyObstacle(Box cell) + /// + /// Finds maximal empty rectangles using the histogram method. + /// For each row, builds a height histogram of consecutive empty cells + /// above, then extracts the largest rectangles from the histogram. + /// + private static List MergeCells(CellGrid grid) { - foreach (var obs in Obstacles) + var height = new int[grid.Rows, grid.Cols]; + + for (var c = 0; c < grid.Cols; c++) { - var clipped = ClipToWorkArea(obs); - - if (clipped.Width <= 0 || clipped.Length <= 0) - continue; - - if (cell.Left < clipped.Right && - cell.Right > clipped.Left && - cell.Bottom < clipped.Top && - cell.Top > clipped.Bottom) - return true; + for (var r = 0; r < grid.Rows; r++) + height[r, c] = grid.Empty[r, c] ? (r > 0 ? height[r - 1, c] + 1 : 1) : 0; } - return false; - } + var candidates = new List(); - private static List MergeCells(bool[,] empty, List xList, List yList, int rows, int cols) - { - var used = new bool[rows, cols]; - var results = new List(); - - for (var r = 0; r < rows; r++) + for (var r = 0; r < grid.Rows; r++) { - for (var c = 0; c < cols; c++) + var stack = new Stack<(int startCol, int h)>(); + + for (var c = 0; c <= grid.Cols; c++) { - if (!empty[r, c] || used[r, c]) - continue; + var h = c < grid.Cols ? height[r, c] : 0; + var startCol = c; - var maxC = c; - while (maxC + 1 < cols && empty[r, maxC + 1] && !used[r, maxC + 1]) - maxC++; - - var maxR = r; - while (maxR + 1 < rows) + while (stack.Count > 0 && stack.Peek().h > h) { - var rowOk = true; - for (var cc = c; cc <= maxC; cc++) - { - if (!empty[maxR + 1, cc] || used[maxR + 1, cc]) - { - rowOk = false; - break; - } - } + var top = stack.Pop(); + startCol = top.startCol; - if (!rowOk) break; - maxR++; + candidates.Add(new Box( + grid.XCoords[top.startCol], grid.YCoords[r - top.h + 1], + grid.XCoords[c] - grid.XCoords[top.startCol], + grid.YCoords[r + 1] - grid.YCoords[r - top.h + 1])); } - for (var rr = r; rr <= maxR; rr++) - for (var cc = c; cc <= maxC; cc++) - used[rr, cc] = true; - - var box = new Box( - xList[c], yList[r], - xList[maxC + 1] - xList[c], - yList[maxR + 1] - yList[r]); - - results.Add(box); + if (h > 0) + stack.Push((startCol, h)); } } - return results; + return candidates; } } } diff --git a/OpenNest.Engine/RotationAnalysis.cs b/OpenNest.Engine/RotationAnalysis.cs index 0d7e20f..e79af6f 100644 --- a/OpenNest.Engine/RotationAnalysis.cs +++ b/OpenNest.Engine/RotationAnalysis.cs @@ -88,19 +88,32 @@ namespace OpenNest var vertices = hull.Vertices; var n = hull.IsClosed() ? vertices.Count - 1 : vertices.Count; - var angles = new List { 0 }; + // Collect edges with their squared length so we can sort by longest first. + var edges = new List<(double angle, double lengthSq)>(); for (var i = 0; i < n; i++) { var next = (i + 1) % n; var dx = vertices[next].X - vertices[i].X; var dy = vertices[next].Y - vertices[i].Y; + var lengthSq = dx * dx + dy * dy; - if (dx * dx + dy * dy < Tolerance.Epsilon) + if (lengthSq < Tolerance.Epsilon) continue; var angle = -System.Math.Atan2(dy, dx); + if (!edges.Any(e => e.angle.IsEqualTo(angle))) + edges.Add((angle, lengthSq)); + } + + // Longest edges first — they produce the flattest tiling rows. + edges.Sort((a, b) => b.lengthSq.CompareTo(a.lengthSq)); + + var angles = new List(edges.Count + 1) { 0 }; + + foreach (var (angle, _) in edges) + { if (!angles.Any(a => a.IsEqualTo(angle))) angles.Add(angle); } diff --git a/OpenNest.Engine/ShrinkFiller.cs b/OpenNest.Engine/ShrinkFiller.cs new file mode 100644 index 0000000..3b62085 --- /dev/null +++ b/OpenNest.Engine/ShrinkFiller.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.Geometry; + +namespace OpenNest +{ + public enum ShrinkAxis { Width, Height } + + public class ShrinkResult + { + public List Parts { get; set; } + public double Dimension { get; set; } + } + + /// + /// Fills a box then iteratively shrinks one axis by the spacing amount + /// until the part count drops. Returns the tightest box that still fits + /// the same number of parts. + /// + public static class ShrinkFiller + { + public static ShrinkResult Shrink( + Func> fillFunc, + NestItem item, Box box, + double spacing, + ShrinkAxis axis, + CancellationToken token = default, + int maxIterations = 20) + { + var parts = fillFunc(item, box); + + if (parts == null || parts.Count == 0) + return new ShrinkResult { Parts = parts ?? new List(), Dimension = 0 }; + + var targetCount = parts.Count; + var bestParts = parts; + var bestDim = MeasureDimension(parts, box, axis); + + for (var i = 0; i < maxIterations; i++) + { + if (token.IsCancellationRequested) + break; + + var trialDim = bestDim - spacing; + if (trialDim <= 0) + break; + + var trialBox = axis == ShrinkAxis.Width + ? new Box(box.X, box.Y, trialDim, box.Length) + : new Box(box.X, box.Y, box.Width, trialDim); + + var trialParts = fillFunc(item, trialBox); + + if (trialParts == null || trialParts.Count < targetCount) + break; + + bestParts = trialParts; + bestDim = MeasureDimension(trialParts, box, axis); + } + + return new ShrinkResult { Parts = bestParts, Dimension = bestDim }; + } + + private static double MeasureDimension(List parts, Box box, ShrinkAxis axis) + { + var placedBox = parts.Cast().GetBoundingBox(); + + return axis == ShrinkAxis.Width + ? placedBox.Right - box.X + : placedBox.Top - box.Y; + } + } +} diff --git a/OpenNest.Engine/StripNestEngine.cs b/OpenNest.Engine/StripNestEngine.cs index 6e21279..a2da5f1 100644 --- a/OpenNest.Engine/StripNestEngine.cs +++ b/OpenNest.Engine/StripNestEngine.cs @@ -3,14 +3,11 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using OpenNest.Geometry; -using OpenNest.Math; namespace OpenNest { public class StripNestEngine : NestEngineBase { - private const int MaxShrinkIterations = 20; - public StripNestEngine(Plate plate) : base(plate) { } @@ -165,54 +162,25 @@ namespace OpenNest ? new Box(workArea.X, workArea.Y, workArea.Width, estimatedDim) : new Box(workArea.X, workArea.Y, estimatedDim, workArea.Length); - // Initial fill using DefaultNestEngine (composition, not inheritance). - var inner = new DefaultNestEngine(Plate); - var stripParts = inner.Fill( - new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity }, - stripBox, progress, token); + // Shrink to tightest strip. + var shrinkAxis = direction == StripDirection.Bottom + ? ShrinkAxis.Height : ShrinkAxis.Width; - if (stripParts == null || stripParts.Count == 0) + Func> stripFill = (ni, b) => + { + var trialInner = new DefaultNestEngine(Plate); + return trialInner.Fill(ni, b, progress, token); + }; + + var shrinkResult = ShrinkFiller.Shrink(stripFill, + new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity }, + stripBox, Plate.PartSpacing, shrinkAxis, token); + + if (shrinkResult.Parts == null || shrinkResult.Parts.Count == 0) return result; - // Measure actual strip dimension from placed parts. - var placedBox = stripParts.Cast().GetBoundingBox(); - var actualDim = direction == StripDirection.Bottom - ? placedBox.Top - workArea.Y - : placedBox.Right - workArea.X; - - var bestParts = stripParts; - var bestDim = actualDim; - var targetCount = stripParts.Count; - - // Shrink loop: reduce strip dimension by PartSpacing until count drops. - for (var i = 0; i < MaxShrinkIterations; i++) - { - if (token.IsCancellationRequested) - break; - - var trialDim = bestDim - Plate.PartSpacing; - if (trialDim <= 0) - break; - - var trialBox = direction == StripDirection.Bottom - ? new Box(workArea.X, workArea.Y, workArea.Width, trialDim) - : new Box(workArea.X, workArea.Y, trialDim, workArea.Length); - - var trialInner = new DefaultNestEngine(Plate); - var trialParts = trialInner.Fill( - new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity }, - trialBox, progress, token); - - if (trialParts == null || trialParts.Count < targetCount) - break; - - // Same count in a tighter strip — keep going. - bestParts = trialParts; - var trialPlacedBox = trialParts.Cast().GetBoundingBox(); - bestDim = direction == StripDirection.Bottom - ? trialPlacedBox.Top - workArea.Y - : trialPlacedBox.Right - workArea.X; - } + var bestParts = shrinkResult.Parts; + var bestDim = shrinkResult.Dimension; // TODO: Compact strip parts individually to close geometry-based gaps. // Disabled pending investigation — remnant finder picks up gaps created @@ -258,88 +226,23 @@ namespace OpenNest }) .ToList(); - // Fill remnant areas iteratively using RemnantFinder. - // After each fill, re-discover all free rectangles and try again - // until no more items can be placed. + // Fill remnants if (remnantBox.Width > 0 && remnantBox.Length > 0) { var remnantProgress = progress != null ? new AccumulatingProgress(progress, allParts) - : null; + : (IProgress)null; - var obstacles = allParts.Select(p => p.BoundingBox.Offset(spacing)).ToList(); - var finder = new RemnantFinder(workArea, obstacles); - var madeProgress = true; + var remnantFiller = new RemnantFiller(workArea, spacing); + remnantFiller.AddObstacles(allParts); - // Track quantities locally so we don't mutate the shared NestItem objects. - // TryOrientation is called twice (bottom, left) with the same items. - var localQty = new Dictionary(); - foreach (var item in effectiveRemainder) - localQty[item.Drawing.Name] = item.Quantity; + Func> remnantFillFunc = (ni, b) => + ShrinkFill(ni, b, remnantProgress, token); - while (madeProgress && !token.IsCancellationRequested) - { - madeProgress = false; + var additional = remnantFiller.FillItems(effectiveRemainder, + remnantFillFunc, token, remnantProgress); - // Minimum remnant size = smallest remaining part dimension - var minRemnantDim = double.MaxValue; - foreach (var item in effectiveRemainder) - { - if (localQty[item.Drawing.Name] <= 0) - continue; - var bb = item.Drawing.Program.BoundingBox(); - var dim = System.Math.Min(bb.Width, bb.Length); - if (dim < minRemnantDim) - minRemnantDim = dim; - } - - if (minRemnantDim == double.MaxValue) - break; // No items with remaining quantity - - var freeBoxes = finder.FindRemnants(minRemnantDim); - - if (freeBoxes.Count == 0) - break; - - foreach (var item in effectiveRemainder) - { - if (token.IsCancellationRequested) - break; - - var qty = localQty[item.Drawing.Name]; - if (qty == 0) - continue; - - var itemBbox = item.Drawing.Program.BoundingBox(); - var minItemDim = System.Math.Min(itemBbox.Width, itemBbox.Length); - - foreach (var box in freeBoxes) - { - if (System.Math.Min(box.Width, box.Length) < minItemDim) - continue; - - var remnantParts = ShrinkFill( - new NestItem { Drawing = item.Drawing, Quantity = qty }, - box, remnantProgress, token); - - if (remnantParts != null && remnantParts.Count > 0) - { - allParts.AddRange(remnantParts); - localQty[item.Drawing.Name] = System.Math.Max(0, qty - remnantParts.Count); - - // Update obstacles and re-discover remnants - foreach (var p in remnantParts) - finder.AddObstacle(p.BoundingBox.Offset(spacing)); - - madeProgress = true; - break; // Re-discover free boxes with updated obstacles - } - } - - if (madeProgress) - break; // Restart the outer loop to re-discover remnants - } - } + allParts.AddRange(additional); } result.Parts = allParts; @@ -351,101 +254,20 @@ namespace OpenNest return result; } - /// - /// Fill a box and then shrink it to the tightest area that still fits - /// the same number of parts. This maximizes leftover space for subsequent fills. - /// private List ShrinkFill(NestItem item, Box box, IProgress progress, CancellationToken token) { - var inner = new DefaultNestEngine(Plate); - var parts = inner.Fill(item, box, progress, token); - - if (parts == null || parts.Count < 2) - return parts; - - var targetCount = parts.Count; - var placedBox = parts.Cast().GetBoundingBox(); - - // Try shrinking horizontally - var bestParts = parts; - var shrunkWidth = placedBox.Right - box.X; - var shrunkHeight = placedBox.Top - box.Y; - - for (var i = 0; i < MaxShrinkIterations; i++) + Func> fillFunc = (ni, b) => { - if (token.IsCancellationRequested) - break; + var inner = new DefaultNestEngine(Plate); + return inner.Fill(ni, b, null, token); + }; - var trialWidth = shrunkWidth - Plate.PartSpacing; - if (trialWidth <= 0) - break; + var heightResult = ShrinkFiller.Shrink(fillFunc, item, box, + Plate.PartSpacing, ShrinkAxis.Height, token); - var trialBox = new Box(box.X, box.Y, trialWidth, box.Length); - var trialInner = new DefaultNestEngine(Plate); - var trialParts = trialInner.Fill(item, trialBox, null, token); - - if (trialParts == null || trialParts.Count < targetCount) - break; - - bestParts = trialParts; - var trialPlacedBox = trialParts.Cast().GetBoundingBox(); - shrunkWidth = trialPlacedBox.Right - box.X; - } - - // Try shrinking vertically - for (var i = 0; i < MaxShrinkIterations; i++) - { - if (token.IsCancellationRequested) - break; - - var trialHeight = shrunkHeight - Plate.PartSpacing; - if (trialHeight <= 0) - break; - - var trialBox = new Box(box.X, box.Y, box.Width, trialHeight); - var trialInner = new DefaultNestEngine(Plate); - var trialParts = trialInner.Fill(item, trialBox, null, token); - - if (trialParts == null || trialParts.Count < targetCount) - break; - - bestParts = trialParts; - var trialPlacedBox = trialParts.Cast().GetBoundingBox(); - shrunkHeight = trialPlacedBox.Top - box.Y; - } - - return bestParts; + return heightResult.Parts; } - /// - /// Wraps an IProgress to prepend previously placed parts to each report, - /// so the UI shows the full picture (strip + remnant) during remnant fills. - /// - private class AccumulatingProgress : IProgress - { - private readonly IProgress inner; - private readonly List previousParts; - - public AccumulatingProgress(IProgress inner, List previousParts) - { - this.inner = inner; - this.previousParts = previousParts; - } - - public void Report(NestProgress value) - { - if (value.BestParts != null && previousParts.Count > 0) - { - var combined = new List(previousParts.Count + value.BestParts.Count); - combined.AddRange(previousParts); - combined.AddRange(value.BestParts); - value.BestParts = combined; - value.BestPartCount = combined.Count; - } - - inner.Report(value); - } - } } } diff --git a/OpenNest.Tests/AccumulatingProgressTests.cs b/OpenNest.Tests/AccumulatingProgressTests.cs new file mode 100644 index 0000000..2df42c5 --- /dev/null +++ b/OpenNest.Tests/AccumulatingProgressTests.cs @@ -0,0 +1,51 @@ +namespace OpenNest.Tests; + +public class AccumulatingProgressTests +{ + private class CapturingProgress : IProgress + { + public NestProgress Last { get; private set; } + public void Report(NestProgress value) => Last = value; + } + + [Fact] + public void Report_PrependsPreviousParts() + { + var inner = new CapturingProgress(); + var previous = new List { TestHelpers.MakePartAt(0, 0, 10) }; + var accumulating = new AccumulatingProgress(inner, previous); + + var newParts = new List { TestHelpers.MakePartAt(20, 0, 10) }; + accumulating.Report(new NestProgress { BestParts = newParts, BestPartCount = 1 }); + + Assert.NotNull(inner.Last); + Assert.Equal(2, inner.Last.BestParts.Count); + Assert.Equal(2, inner.Last.BestPartCount); + } + + [Fact] + public void Report_NoPreviousParts_PassesThrough() + { + var inner = new CapturingProgress(); + var accumulating = new AccumulatingProgress(inner, new List()); + + var newParts = new List { TestHelpers.MakePartAt(0, 0, 10) }; + accumulating.Report(new NestProgress { BestParts = newParts, BestPartCount = 1 }); + + Assert.NotNull(inner.Last); + Assert.Single(inner.Last.BestParts); + } + + [Fact] + public void Report_NullBestParts_PassesThrough() + { + var inner = new CapturingProgress(); + var previous = new List { TestHelpers.MakePartAt(0, 0, 10) }; + var accumulating = new AccumulatingProgress(inner, previous); + + accumulating.Report(new NestProgress { BestParts = null }); + + Assert.NotNull(inner.Last); + Assert.Null(inner.Last.BestParts); + } +} diff --git a/OpenNest.Tests/AngleCandidateBuilderTests.cs b/OpenNest.Tests/AngleCandidateBuilderTests.cs new file mode 100644 index 0000000..29d26bf --- /dev/null +++ b/OpenNest.Tests/AngleCandidateBuilderTests.cs @@ -0,0 +1,83 @@ +using OpenNest.Geometry; + +namespace OpenNest.Tests; + +public class AngleCandidateBuilderTests +{ + private static Drawing MakeRectDrawing(double w, double h) + { + 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("rect", pgm); + } + + [Fact] + public void Build_ReturnsAtLeastTwoAngles() + { + var builder = new AngleCandidateBuilder(); + var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; + var workArea = new Box(0, 0, 100, 100); + + var angles = builder.Build(item, 0, workArea); + + Assert.True(angles.Count >= 2); + } + + [Fact] + public void Build_NarrowWorkArea_ProducesMoreAngles() + { + var builder = new AngleCandidateBuilder(); + var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; + var wideArea = new Box(0, 0, 100, 100); + var narrowArea = new Box(0, 0, 100, 8); // narrower than part's longest side + + var wideAngles = builder.Build(item, 0, wideArea); + var narrowAngles = builder.Build(item, 0, narrowArea); + + Assert.True(narrowAngles.Count > wideAngles.Count, + $"Narrow ({narrowAngles.Count}) should have more angles than wide ({wideAngles.Count})"); + } + + [Fact] + public void ForceFullSweep_ProducesFullSweep() + { + var builder = new AngleCandidateBuilder { ForceFullSweep = true }; + var item = new NestItem { Drawing = MakeRectDrawing(5, 5) }; + var workArea = new Box(0, 0, 100, 100); + + var angles = builder.Build(item, 0, workArea); + + // Full sweep at 5deg steps = ~36 angles (0 to 175), plus base angles + Assert.True(angles.Count > 10); + } + + [Fact] + public void RecordProductive_PrunesSubsequentBuilds() + { + var builder = new AngleCandidateBuilder { ForceFullSweep = true }; + var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; + var workArea = new Box(0, 0, 100, 8); + + // First build — full sweep + var firstAngles = builder.Build(item, 0, workArea); + + // Record some as productive + var productive = new List + { + new AngleResult { AngleDeg = 0, PartCount = 5 }, + new AngleResult { AngleDeg = 45, PartCount = 3 }, + }; + builder.RecordProductive(productive); + + // Second build — should be pruned to known-good + base angles + builder.ForceFullSweep = false; + var secondAngles = builder.Build(item, 0, workArea); + + Assert.True(secondAngles.Count < firstAngles.Count, + $"Pruned ({secondAngles.Count}) should be fewer than full ({firstAngles.Count})"); + } +} diff --git a/OpenNest.Tests/EngineRefactorSmokeTests.cs b/OpenNest.Tests/EngineRefactorSmokeTests.cs new file mode 100644 index 0000000..3e4e8b3 --- /dev/null +++ b/OpenNest.Tests/EngineRefactorSmokeTests.cs @@ -0,0 +1,99 @@ +using OpenNest.Geometry; + +namespace OpenNest.Tests; + +public class EngineRefactorSmokeTests +{ + 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 DefaultEngine_FillNestItem_ProducesResults() + { + var plate = new Plate(120, 60); + var engine = new DefaultNestEngine(plate); + var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; + + var parts = engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None); + + Assert.True(parts.Count > 0, "DefaultNestEngine should fill parts"); + } + + [Fact] + public void DefaultEngine_FillGroupParts_ProducesResults() + { + var plate = new Plate(120, 60); + var engine = new DefaultNestEngine(plate); + var drawing = MakeRectDrawing(20, 10); + var groupParts = new List { new Part(drawing) }; + + var parts = engine.Fill(groupParts, plate.WorkArea(), null, System.Threading.CancellationToken.None); + + Assert.True(parts.Count > 0, "DefaultNestEngine group fill should produce parts"); + } + + [Fact] + public void DefaultEngine_ForceFullAngleSweep_StillWorks() + { + var plate = new Plate(120, 60); + var engine = new DefaultNestEngine(plate); + engine.ForceFullAngleSweep = true; + var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; + + var parts = engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None); + + Assert.True(parts.Count > 0, "ForceFullAngleSweep should still produce results"); + } + + [Fact] + public void StripEngine_Nest_ProducesResults() + { + var plate = new Plate(120, 60); + var engine = new StripNestEngine(plate); + var items = new List + { + new NestItem { Drawing = MakeRectDrawing(20, 10, "large"), Quantity = 10 }, + new NestItem { Drawing = MakeRectDrawing(8, 5, "small"), Quantity = 5 }, + }; + + var parts = engine.Nest(items, null, System.Threading.CancellationToken.None); + + Assert.True(parts.Count > 0, "StripNestEngine should nest parts"); + } + + [Fact] + public void DefaultEngine_Nest_ProducesResults() + { + var plate = new Plate(120, 60); + var engine = new DefaultNestEngine(plate); + var items = new List + { + new NestItem { Drawing = MakeRectDrawing(20, 10, "a"), Quantity = 5 }, + new NestItem { Drawing = MakeRectDrawing(15, 8, "b"), Quantity = 3 }, + }; + + var parts = engine.Nest(items, null, System.Threading.CancellationToken.None); + + Assert.True(parts.Count > 0, "Base Nest method should place parts"); + } + + [Fact] + public void BruteForceRunner_StillWorks() + { + var plate = new Plate(120, 60); + var drawing = MakeRectDrawing(20, 10); + + var result = OpenNest.Engine.ML.BruteForceRunner.Run(drawing, plate, forceFullAngleSweep: true); + + Assert.NotNull(result); + Assert.True(result.PartCount > 0); + } +} diff --git a/OpenNest.Tests/PairFillerTests.cs b/OpenNest.Tests/PairFillerTests.cs new file mode 100644 index 0000000..d2199b7 --- /dev/null +++ b/OpenNest.Tests/PairFillerTests.cs @@ -0,0 +1,63 @@ +using OpenNest.Geometry; + +namespace OpenNest.Tests; + +public class PairFillerTests +{ + private static Drawing MakeRectDrawing(double w, double h) + { + 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("rect", pgm); + } + + [Fact] + public void Fill_ReturnsPartsForSimpleDrawing() + { + var plateSize = new Size(120, 60); + var filler = new PairFiller(plateSize, 0.5); + var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; + var workArea = new Box(0, 0, 120, 60); + + var parts = filler.Fill(item, workArea); + + Assert.NotNull(parts); + // Pair filling may or may not find interlocking pairs for rectangles, + // but should return a non-null list. + } + + [Fact] + public void Fill_EmptyResult_WhenPartTooLarge() + { + var plateSize = new Size(10, 10); + var filler = new PairFiller(plateSize, 0.5); + var item = new NestItem { Drawing = MakeRectDrawing(20, 20) }; + var workArea = new Box(0, 0, 10, 10); + + var parts = filler.Fill(item, workArea); + + Assert.NotNull(parts); + Assert.Empty(parts); + } + + [Fact] + public void Fill_RespectsCancellation() + { + var cts = new System.Threading.CancellationTokenSource(); + cts.Cancel(); + + var plateSize = new Size(120, 60); + var filler = new PairFiller(plateSize, 0.5); + var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; + var workArea = new Box(0, 0, 120, 60); + + var parts = filler.Fill(item, workArea, token: cts.Token); + + // Should return empty or partial — not throw + Assert.NotNull(parts); + } +} diff --git a/OpenNest.Tests/PolyLabelTests.cs b/OpenNest.Tests/PolyLabelTests.cs new file mode 100644 index 0000000..97e4aa3 --- /dev/null +++ b/OpenNest.Tests/PolyLabelTests.cs @@ -0,0 +1,129 @@ +using OpenNest.Geometry; + +namespace OpenNest.Tests; + +public class PolyLabelTests +{ + private static Polygon Square(double size) + { + var p = new Polygon(); + p.Vertices.Add(new Vector(0, 0)); + p.Vertices.Add(new Vector(size, 0)); + p.Vertices.Add(new Vector(size, size)); + p.Vertices.Add(new Vector(0, size)); + return p; + } + + [Fact] + public void Square_ReturnsCenterPoint() + { + var poly = Square(100); + + var result = PolyLabel.Find(poly); + + Assert.Equal(50, result.X, 1.0); + Assert.Equal(50, result.Y, 1.0); + } + + [Fact] + public void Triangle_ReturnsIncenter() + { + var p = new Polygon(); + p.Vertices.Add(new Vector(0, 0)); + p.Vertices.Add(new Vector(100, 0)); + p.Vertices.Add(new Vector(50, 86.6)); + + var result = PolyLabel.Find(p); + + // Incenter of equilateral triangle is at (50, ~28.9) + Assert.Equal(50, result.X, 1.0); + Assert.Equal(28.9, result.Y, 1.0); + Assert.True(p.ContainsPoint(result)); + } + + [Fact] + public void LShape_ReturnsPointInBottomLobe() + { + // L-shape: 100x100 with 50x50 cut from top-right + var p = new Polygon(); + p.Vertices.Add(new Vector(0, 0)); + p.Vertices.Add(new Vector(100, 0)); + p.Vertices.Add(new Vector(100, 50)); + p.Vertices.Add(new Vector(50, 50)); + p.Vertices.Add(new Vector(50, 100)); + p.Vertices.Add(new Vector(0, 100)); + + var result = PolyLabel.Find(p); + + Assert.True(p.ContainsPoint(result)); + // The bottom 100x50 lobe is the widest region + Assert.True(result.Y < 50, $"Expected label in bottom lobe, got Y={result.Y}"); + } + + [Fact] + public void ThinRectangle_CenteredOnBothAxes() + { + var p = new Polygon(); + p.Vertices.Add(new Vector(0, 0)); + p.Vertices.Add(new Vector(200, 0)); + p.Vertices.Add(new Vector(200, 10)); + p.Vertices.Add(new Vector(0, 10)); + + var result = PolyLabel.Find(p); + + Assert.Equal(100, result.X, 1.0); + Assert.Equal(5, result.Y, 1.0); + Assert.True(p.ContainsPoint(result)); + } + + [Fact] + public void SquareWithLargeHole_AvoidsHole() + { + var outer = Square(100); + + var hole = new Polygon(); + hole.Vertices.Add(new Vector(20, 20)); + hole.Vertices.Add(new Vector(80, 20)); + hole.Vertices.Add(new Vector(80, 80)); + hole.Vertices.Add(new Vector(20, 80)); + + var result = PolyLabel.Find(outer, new[] { hole }); + + // Point should be inside outer but outside hole + Assert.True(outer.ContainsPoint(result)); + Assert.False(hole.ContainsPoint(result)); + } + + [Fact] + public void CShape_ReturnsPointInLeftBar() + { + // C-shape opening to the right: left bar is 20 wide, top/bottom arms are 20 tall + var p = new Polygon(); + p.Vertices.Add(new Vector(0, 0)); + p.Vertices.Add(new Vector(100, 0)); + p.Vertices.Add(new Vector(100, 20)); + p.Vertices.Add(new Vector(20, 20)); + p.Vertices.Add(new Vector(20, 80)); + p.Vertices.Add(new Vector(100, 80)); + p.Vertices.Add(new Vector(100, 100)); + p.Vertices.Add(new Vector(0, 100)); + + var result = PolyLabel.Find(p); + + Assert.True(p.ContainsPoint(result)); + // Label should be in the left vertical bar (x < 20), not at bbox center (50, 50) + Assert.True(result.X < 20, $"Expected label in left bar, got X={result.X}"); + } + + [Fact] + public void DegeneratePolygon_ReturnsFallback() + { + var p = new Polygon(); + p.Vertices.Add(new Vector(5, 5)); + + var result = PolyLabel.Find(p); + + Assert.Equal(5, result.X, 0.01); + Assert.Equal(5, result.Y, 0.01); + } +} diff --git a/OpenNest.Tests/RemnantFillerTests2.cs b/OpenNest.Tests/RemnantFillerTests2.cs new file mode 100644 index 0000000..2ee8c6a --- /dev/null +++ b/OpenNest.Tests/RemnantFillerTests2.cs @@ -0,0 +1,105 @@ +using OpenNest.Geometry; + +namespace OpenNest.Tests; + +public class RemnantFillerTests2 +{ + private static Drawing MakeSquareDrawing(double size) + { + 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(size, 0))); + pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(size, size))); + pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, size))); + pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0))); + return new Drawing("sq", pgm); + } + + [Fact] + public void FillItems_PlacesPartsInRemnants() + { + var workArea = new Box(0, 0, 100, 100); + var filler = new RemnantFiller(workArea, 1.0); + + // Place a large obstacle leaving a 40x100 strip on the right + filler.AddObstacles(new[] { TestHelpers.MakePartAt(0, 0, 50) }); + + var drawing = MakeSquareDrawing(10); + var items = new List + { + new NestItem { Drawing = drawing, Quantity = 5 } + }; + + Func> fillFunc = (ni, b) => + { + var plate = new Plate(b.Width, b.Length); + var engine = new DefaultNestEngine(plate); + return engine.Fill(ni, b, null, System.Threading.CancellationToken.None); + }; + + var placed = filler.FillItems(items, fillFunc); + + Assert.True(placed.Count > 0, "Should place parts in remaining space"); + } + + [Fact] + public void FillItems_DoesNotMutateItemQuantities() + { + var workArea = new Box(0, 0, 100, 100); + var filler = new RemnantFiller(workArea, 1.0); + + var drawing = MakeSquareDrawing(10); + var items = new List + { + new NestItem { Drawing = drawing, Quantity = 3 } + }; + + Func> fillFunc = (ni, b) => + { + var plate = new Plate(b.Width, b.Length); + var engine = new DefaultNestEngine(plate); + return engine.Fill(ni, b, null, System.Threading.CancellationToken.None); + }; + + filler.FillItems(items, fillFunc); + + Assert.Equal(3, items[0].Quantity); + } + + [Fact] + public void FillItems_EmptyItems_ReturnsEmpty() + { + var workArea = new Box(0, 0, 100, 100); + var filler = new RemnantFiller(workArea, 1.0); + + Func> fillFunc = (ni, b) => new List(); + + var result = filler.FillItems(new List(), fillFunc); + + Assert.Empty(result); + } + + [Fact] + public void FillItems_RespectsCancellation() + { + var cts = new System.Threading.CancellationTokenSource(); + cts.Cancel(); + + var workArea = new Box(0, 0, 100, 100); + var filler = new RemnantFiller(workArea, 1.0); + + var drawing = MakeSquareDrawing(10); + var items = new List + { + new NestItem { Drawing = drawing, Quantity = 5 } + }; + + Func> fillFunc = (ni, b) => + new List { TestHelpers.MakePartAt(0, 0, 10) }; + + var result = filler.FillItems(items, fillFunc, cts.Token); + + // Should not throw, returns whatever was placed + Assert.NotNull(result); + } +} diff --git a/OpenNest.Tests/RemnantFinderTests.cs b/OpenNest.Tests/RemnantFinderTests.cs index db4d154..45734c5 100644 --- a/OpenNest.Tests/RemnantFinderTests.cs +++ b/OpenNest.Tests/RemnantFinderTests.cs @@ -1,4 +1,5 @@ using OpenNest.Geometry; +using OpenNest.IO; namespace OpenNest.Tests; @@ -269,4 +270,101 @@ public class RemnantFinderTests // Should find gaps between obstacles Assert.True(remnants.Count > 0); } + + [Fact] + public void SingleObstacle_NearEdge_FindsRemnantsOnAllSides() + { + // Obstacle near top-left: should find remnants above, below, and to the right. + var finder = new RemnantFinder(new Box(0, 0, 120, 60)); + finder.AddObstacle(new Box(0, 47, 21, 6)); + var remnants = finder.FindRemnants(); + + var above = remnants.FirstOrDefault(r => r.Bottom >= 53 - 0.1 && r.Width > 50); + var below = remnants.FirstOrDefault(r => r.Top <= 47 + 0.1 && r.Width > 50); + var right = remnants.FirstOrDefault(r => r.Left >= 21 - 0.1 && r.Length > 50); + + Assert.NotNull(above); + Assert.NotNull(below); + Assert.NotNull(right); + } + + [Fact] + public void LoadNestFile_FindsGapAboveMainGrid() + { + var nestFile = @"C:\Users\AJ\Desktop\no_remnant_found.nest"; + if (!File.Exists(nestFile)) + return; // Skip if file not available. + + var reader = new NestReader(nestFile); + var nest = reader.Read(); + var plate = nest.Plates[0]; + + var finder = RemnantFinder.FromPlate(plate); + + // Use smallest drawing bbox dimension as minDim (same as UI). + var minDim = nest.Drawings.Min(d => + System.Math.Min(d.Program.BoundingBox().Width, d.Program.BoundingBox().Length)); + + var tiered = finder.FindTieredRemnants(minDim); + + // Should find a remnant near (0.25, 53.13) — the gap above the main grid. + var topGap = tiered.FirstOrDefault(t => + t.Box.Bottom > 50 && t.Box.Bottom < 55 && + t.Box.Left < 1 && + t.Box.Width > 100 && + t.Box.Length > 5); + + Assert.True(topGap.Box.Width > 0, "Expected remnant above main grid"); + } + + [Fact] + public void DensePack_FindsGapAtTop() + { + // Reproduce real plate: 120x60, 68 parts of SULLYS-004. + // Main grid tops out at y=53.14 (obstacle). Two rotated parts on the + // right extend to y=58.49 but only at x > 106. The gap at x < 106 + // from y=53.14 to y=59.8 is ~106 x 6.66 — should be found. + var workArea = new Box(0.2, 0.8, 119.5, 59.0); + var obstacles = new List(); + var spacing = 0.25; + + // Main grid: 5 columns x 12 rows (6 pairs). + // Even rows: bbox bottom offsets, odd rows: different offsets. + double[] colX = { 0.25, 21.08, 41.90, 62.73, 83.56 }; + double[] colXOdd = { 0.81, 21.64, 42.46, 63.29, 84.12 }; + double[] evenY = { 3.67, 12.41, 21.14, 29.87, 38.60, 47.33 }; + double[] oddY = { 0.75, 9.48, 18.21, 26.94, 35.67, 44.40 }; + + foreach (var cx in colX) + foreach (var ey in evenY) + obstacles.Add(new Box(cx - spacing, ey - spacing, 20.65 + spacing * 2, 5.56 + spacing * 2)); + foreach (var cx in colXOdd) + foreach (var oy in oddY) + obstacles.Add(new Box(cx - spacing, oy - spacing, 20.65 + spacing * 2, 5.56 + spacing * 2)); + + // Right-side rotated parts (only 2 extend high: parts 62 and 66). + obstacles.Add(new Box(106.70 - spacing, 37.59 - spacing, 5.56 + spacing * 2, 20.65 + spacing * 2)); + obstacles.Add(new Box(114.19 - spacing, 37.59 - spacing, 5.56 + spacing * 2, 20.65 + spacing * 2)); + // Parts 63, 67 (lower rotated) + obstacles.Add(new Box(105.02 - spacing, 29.35 - spacing, 5.56 + spacing * 2, 20.65 + spacing * 2)); + obstacles.Add(new Box(112.51 - spacing, 29.35 - spacing, 5.56 + spacing * 2, 20.65 + spacing * 2)); + // Parts 60, 64 (upper-right rotated, lower) + obstacles.Add(new Box(106.70 - spacing, 8.99 - spacing, 5.56 + spacing * 2, 20.65 + spacing * 2)); + obstacles.Add(new Box(114.19 - spacing, 8.99 - spacing, 5.56 + spacing * 2, 20.65 + spacing * 2)); + // Parts 61, 65 + obstacles.Add(new Box(105.02 - spacing, 0.75 - spacing, 5.56 + spacing * 2, 20.65 + spacing * 2)); + obstacles.Add(new Box(112.51 - spacing, 0.75 - spacing, 5.56 + spacing * 2, 20.65 + spacing * 2)); + + var finder = new RemnantFinder(workArea, obstacles); + var remnants = finder.FindRemnants(5.375); + + // The gap at x < 106 from y=53.14 to y=59.8 should be found. + Assert.True(remnants.Count > 0, "Should find gap above main grid"); + var topRemnant = remnants.FirstOrDefault(r => r.Length >= 5.375 && r.Width > 50); + Assert.NotNull(topRemnant); + + // Verify dimensions are close to the expected ~104 x 6.6 gap. + Assert.True(topRemnant.Width > 100, $"Expected width > 100, got {topRemnant.Width:F1}"); + Assert.True(topRemnant.Length > 6, $"Expected length > 6, got {topRemnant.Length:F1}"); + } } diff --git a/OpenNest.Tests/ShrinkFillerTests.cs b/OpenNest.Tests/ShrinkFillerTests.cs new file mode 100644 index 0000000..5368d51 --- /dev/null +++ b/OpenNest.Tests/ShrinkFillerTests.cs @@ -0,0 +1,99 @@ +using OpenNest.Geometry; + +namespace OpenNest.Tests; + +public class ShrinkFillerTests +{ + private static Drawing MakeSquareDrawing(double size) + { + 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(size, 0))); + pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(size, size))); + pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, size))); + pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0))); + return new Drawing("square", pgm); + } + + [Fact] + public void Shrink_ReducesDimension_UntilCountDrops() + { + var drawing = MakeSquareDrawing(10); + var item = new NestItem { Drawing = drawing }; + var box = new Box(0, 0, 100, 50); + + Func> fillFunc = (ni, b) => + { + var plate = new Plate(b.Width, b.Length); + var engine = new DefaultNestEngine(plate); + return engine.Fill(ni, b, null, System.Threading.CancellationToken.None); + }; + + var result = ShrinkFiller.Shrink(fillFunc, item, box, 1.0, ShrinkAxis.Height); + + Assert.NotNull(result); + Assert.True(result.Parts.Count > 0); + Assert.True(result.Dimension <= 50, "Dimension should be <= original"); + Assert.True(result.Dimension > 0); + } + + [Fact] + public void Shrink_Width_ReducesHorizontally() + { + var drawing = MakeSquareDrawing(10); + var item = new NestItem { Drawing = drawing }; + var box = new Box(0, 0, 100, 50); + + Func> fillFunc = (ni, b) => + { + var plate = new Plate(b.Width, b.Length); + var engine = new DefaultNestEngine(plate); + return engine.Fill(ni, b, null, System.Threading.CancellationToken.None); + }; + + var result = ShrinkFiller.Shrink(fillFunc, item, box, 1.0, ShrinkAxis.Width); + + Assert.NotNull(result); + Assert.True(result.Parts.Count > 0); + Assert.True(result.Dimension <= 100); + } + + [Fact] + public void Shrink_RespectsMaxIterations() + { + var callCount = 0; + Func> fillFunc = (ni, b) => + { + callCount++; + return new List { TestHelpers.MakePartAt(0, 0, 5) }; + }; + + var item = new NestItem { Drawing = MakeSquareDrawing(5) }; + var box = new Box(0, 0, 100, 100); + + ShrinkFiller.Shrink(fillFunc, item, box, 1.0, ShrinkAxis.Height, maxIterations: 3); + + // 1 initial + up to 3 shrink iterations = max 4 calls + Assert.True(callCount <= 4); + } + + [Fact] + public void Shrink_RespectsCancellation() + { + var cts = new System.Threading.CancellationTokenSource(); + cts.Cancel(); + + var drawing = MakeSquareDrawing(10); + var item = new NestItem { Drawing = drawing }; + var box = new Box(0, 0, 100, 50); + + Func> fillFunc = (ni, b) => + new List { TestHelpers.MakePartAt(0, 0, 10) }; + + var result = ShrinkFiller.Shrink(fillFunc, item, box, 1.0, + ShrinkAxis.Height, token: cts.Token); + + Assert.NotNull(result); + Assert.True(result.Parts.Count > 0); + } +} diff --git a/OpenNest/Controls/PlateView.cs b/OpenNest/Controls/PlateView.cs index a099c1d..f093930 100644 --- a/OpenNest/Controls/PlateView.cs +++ b/OpenNest/Controls/PlateView.cs @@ -33,6 +33,7 @@ namespace OpenNest.Controls private List temporaryParts = new List(); private Point middleMouseDownPoint; private Box activeWorkArea; + private List debugRemnants; public Box ActiveWorkArea { @@ -44,6 +45,18 @@ namespace OpenNest.Controls } } + public List DebugRemnants + { + get => debugRemnants; + set + { + debugRemnants = value; + Invalidate(); + } + } + + public List DebugRemnantPriorities { get; set; } + public List SelectedParts; public ReadOnlyCollection Parts; @@ -374,6 +387,7 @@ namespace OpenNest.Controls DrawPlate(e.Graphics); DrawParts(e.Graphics); DrawActiveWorkArea(e.Graphics); + DrawDebugRemnants(e.Graphics); base.OnPaint(e); } @@ -632,6 +646,51 @@ namespace OpenNest.Controls g.DrawRectangle(pen, rect.X, rect.Y, rect.Width, rect.Height); } + // Priority 0 = green (preferred), 1 = yellow (extend), 2 = red (last resort) + private static readonly Color[] PriorityFills = + { + Color.FromArgb(60, Color.LimeGreen), + Color.FromArgb(60, Color.Gold), + Color.FromArgb(60, Color.Salmon), + }; + + private static readonly Color[] PriorityBorders = + { + Color.FromArgb(180, Color.Green), + Color.FromArgb(180, Color.DarkGoldenrod), + Color.FromArgb(180, Color.DarkRed), + }; + + private void DrawDebugRemnants(Graphics g) + { + if (debugRemnants == null || debugRemnants.Count == 0) + return; + + for (var i = 0; i < debugRemnants.Count; i++) + { + var box = debugRemnants[i]; + var loc = PointWorldToGraph(box.Location); + var w = LengthWorldToGui(box.Width); + var h = LengthWorldToGui(box.Length); + var rect = new RectangleF(loc.X, loc.Y - h, w, h); + + var priority = DebugRemnantPriorities != null && i < DebugRemnantPriorities.Count + ? System.Math.Min(DebugRemnantPriorities[i], 2) + : 0; + + using var brush = new SolidBrush(PriorityFills[priority]); + g.FillRectangle(brush, rect); + + using var pen = new Pen(PriorityBorders[priority], 1.5f); + g.DrawRectangle(pen, rect.X, rect.Y, rect.Width, rect.Height); + + var label = $"P{priority} {box.Width:F1}x{box.Length:F1}"; + using var font = new Font("Segoe UI", 8f); + using var sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center }; + g.DrawString(label, font, Brushes.Black, rect, sf); + } + } + public LayoutPart GetPartAtControlPoint(Point pt) { var pt2 = PointControlToGraph(pt); diff --git a/OpenNest/Forms/MainForm.Designer.cs b/OpenNest/Forms/MainForm.Designer.cs index 6aad83f..28cb553 100644 --- a/OpenNest/Forms/MainForm.Designer.cs +++ b/OpenNest/Forms/MainForm.Designer.cs @@ -149,6 +149,7 @@ engineLabel = new System.Windows.Forms.ToolStripLabel(); engineComboBox = new System.Windows.Forms.ToolStripComboBox(); btnAutoNest = new System.Windows.Forms.ToolStripButton(); + btnShowRemnants = new System.Windows.Forms.ToolStripButton(); pEPToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); openNestToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); menuStrip1.SuspendLayout(); @@ -888,7 +889,7 @@ // toolStrip1 // toolStrip1.AutoSize = false; - toolStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { btnNew, btnOpen, btnSave, btnSaveAs, toolStripSeparator1, btnFirstPlate, btnPreviousPlate, btnNextPlate, btnLastPlate, toolStripSeparator3, btnZoomOut, btnZoomIn, btnZoomToFit, toolStripSeparator4, engineLabel, engineComboBox, btnAutoNest }); + toolStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { btnNew, btnOpen, btnSave, btnSaveAs, toolStripSeparator1, btnFirstPlate, btnPreviousPlate, btnNextPlate, btnLastPlate, toolStripSeparator3, btnZoomOut, btnZoomIn, btnZoomToFit, toolStripSeparator4, engineLabel, engineComboBox, btnAutoNest, btnShowRemnants }); toolStrip1.Location = new System.Drawing.Point(0, 24); toolStrip1.Name = "toolStrip1"; toolStrip1.Size = new System.Drawing.Size(1281, 40); @@ -1067,7 +1068,15 @@ btnAutoNest.Size = new System.Drawing.Size(64, 37); btnAutoNest.Text = "Auto Nest"; btnAutoNest.Click += RunAutoNest_Click; - // + // + // btnShowRemnants + // + btnShowRemnants.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text; + btnShowRemnants.Name = "btnShowRemnants"; + btnShowRemnants.Size = new System.Drawing.Size(64, 37); + btnShowRemnants.Text = "Remnants"; + btnShowRemnants.Click += ShowRemnants_Click; + // // pEPToolStripMenuItem // pEPToolStripMenuItem.Name = "pEPToolStripMenuItem"; @@ -1232,5 +1241,6 @@ private System.Windows.Forms.ToolStripLabel engineLabel; private System.Windows.Forms.ToolStripComboBox engineComboBox; private System.Windows.Forms.ToolStripButton btnAutoNest; + private System.Windows.Forms.ToolStripButton btnShowRemnants; } } \ No newline at end of file diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index df40213..d198886 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -737,6 +737,49 @@ namespace OpenNest.Forms activeForm.LoadNextPlate(); } + private RemnantViewerForm remnantViewer; + + private void ShowRemnants_Click(object sender, EventArgs e) + { + if (activeForm?.PlateView?.Plate == null) + return; + + var plate = activeForm.PlateView.Plate; + + // Minimum remnant dimension = smallest part bbox dimension on the plate. + var minDim = 0.0; + var nest = activeForm.Nest; + if (nest != null) + { + foreach (var drawing in nest.Drawings) + { + var bbox = drawing.Program.BoundingBox(); + var dim = System.Math.Min(bbox.Width, bbox.Length); + if (minDim == 0 || dim < minDim) + minDim = dim; + } + } + + var finder = RemnantFinder.FromPlate(plate); + var tiered = finder.FindTieredRemnants(minDim); + + if (remnantViewer == null || remnantViewer.IsDisposed) + { + remnantViewer = new RemnantViewerForm(); + remnantViewer.Owner = this; + + // Position next to the main form's right edge. + var screen = Screen.FromControl(this); + remnantViewer.Location = new Point( + System.Math.Min(Right, screen.WorkingArea.Right - remnantViewer.Width), + Top); + } + + remnantViewer.LoadRemnants(tiered, activeForm.PlateView); + remnantViewer.Show(); + remnantViewer.BringToFront(); + } + private async void RunAutoNest_Click(object sender, EventArgs e) { var form = new AutoNestForm(activeForm.Nest); @@ -744,7 +787,7 @@ namespace OpenNest.Forms if (form.ShowDialog() != System.Windows.Forms.DialogResult.OK) return; - + var items = form.GetNestItems(); if (!items.Any(it => it.Quantity > 0)) diff --git a/OpenNest/Forms/RemnantViewerForm.cs b/OpenNest/Forms/RemnantViewerForm.cs new file mode 100644 index 0000000..010850b --- /dev/null +++ b/OpenNest/Forms/RemnantViewerForm.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Windows.Forms; +using OpenNest.Controls; +using OpenNest.Geometry; + +namespace OpenNest.Forms +{ + public class RemnantViewerForm : Form + { + private ListView listView; + private PlateView plateView; + private List remnants = new(); + private int selectedIndex = -1; + + public RemnantViewerForm() + { + Text = "Remnants"; + Size = new System.Drawing.Size(360, 400); + StartPosition = FormStartPosition.Manual; + FormBorderStyle = FormBorderStyle.SizableToolWindow; + ShowInTaskbar = false; + TopMost = true; + + listView = new ListView + { + Dock = DockStyle.Fill, + View = View.Details, + FullRowSelect = true, + GridLines = true, + MultiSelect = false, + HideSelection = false, + }; + + listView.Columns.Add("P", 28, HorizontalAlignment.Center); + listView.Columns.Add("Size", 110, HorizontalAlignment.Left); + listView.Columns.Add("Area", 65, HorizontalAlignment.Right); + listView.Columns.Add("Location", 110, HorizontalAlignment.Left); + + listView.SelectedIndexChanged += ListView_SelectedIndexChanged; + + Controls.Add(listView); + } + + protected override bool ProcessDialogKey(Keys keyData) + { + if (keyData == Keys.Escape) + { + Close(); + return true; + } + return base.ProcessDialogKey(keyData); + } + + public void LoadRemnants(List tieredRemnants, PlateView view) + { + plateView = view; + remnants = tieredRemnants; + selectedIndex = -1; + + listView.BeginUpdate(); + listView.Items.Clear(); + + foreach (var tr in remnants) + { + var item = new ListViewItem(tr.Priority.ToString()); + item.SubItems.Add($"{tr.Box.Width:F2} x {tr.Box.Length:F2}"); + item.SubItems.Add($"{tr.Box.Area():F1}"); + item.SubItems.Add($"({tr.Box.X:F2}, {tr.Box.Y:F2})"); + + switch (tr.Priority) + { + case 0: item.BackColor = Color.FromArgb(220, 255, 220); break; + case 1: item.BackColor = Color.FromArgb(255, 255, 210); break; + default: item.BackColor = Color.FromArgb(255, 220, 220); break; + } + + listView.Items.Add(item); + } + + listView.EndUpdate(); + } + + private void ListView_SelectedIndexChanged(object sender, EventArgs e) + { + if (plateView == null) + return; + + if (listView.SelectedIndices.Count == 0) + { + selectedIndex = -1; + plateView.DebugRemnants = null; + plateView.DebugRemnantPriorities = null; + return; + } + + selectedIndex = listView.SelectedIndices[0]; + + if (selectedIndex >= 0 && selectedIndex < remnants.Count) + { + var tr = remnants[selectedIndex]; + plateView.DebugRemnants = new List { tr.Box }; + plateView.DebugRemnantPriorities = new List { tr.Priority }; + } + } + + protected override void OnFormClosing(FormClosingEventArgs e) + { + if (plateView != null) + { + plateView.DebugRemnants = null; + plateView.DebugRemnantPriorities = null; + } + + base.OnFormClosing(e); + } + } +} diff --git a/OpenNest/LayoutPart.cs b/OpenNest/LayoutPart.cs index a6eaa42..d737bb3 100644 --- a/OpenNest/LayoutPart.cs +++ b/OpenNest/LayoutPart.cs @@ -20,13 +20,14 @@ namespace OpenNest private Brush brush; private Pen pen; - private PointF _labelPoint; - private List _offsetPolygonPoints; private double _cachedOffsetSpacing; private double _cachedOffsetTolerance; private double _cachedOffsetRotation = double.NaN; + private Vector? _labelPoint; + private PointF _labelScreenPoint; + public readonly Part BasePart; static LayoutPart() @@ -97,61 +98,61 @@ namespace OpenNest g.DrawPath(pen, Path); } - g.DrawString(id, programIdFont, Brushes.Black, _labelPoint.X, _labelPoint.Y); + using var sf = new StringFormat + { + Alignment = StringAlignment.Center, + LineAlignment = StringAlignment.Center + }; + g.DrawString(id, programIdFont, Brushes.Black, _labelScreenPoint.X, _labelScreenPoint.Y, sf); } public GraphicsPath OffsetPath { get; private set; } + private Vector ComputeLabelPoint() + { + var entities = ConvertProgram.ToGeometry(BasePart.BaseDrawing.Program); + var nonRapid = entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList(); + + var shapes = ShapeBuilder.GetShapes(nonRapid); + + if (shapes.Count == 0) + { + var bbox = BasePart.BaseDrawing.Program.BoundingBox(); + return new Vector(bbox.Location.X + bbox.Width / 2, bbox.Location.Y + bbox.Length / 2); + } + + var profile = new ShapeProfile(nonRapid); + var outer = profile.Perimeter.ToPolygonWithTolerance(0.1); + + List holes = null; + + if (profile.Cutouts.Count > 0) + { + holes = new List(); + foreach (var cutout in profile.Cutouts) + holes.Add(cutout.ToPolygonWithTolerance(0.1)); + } + + return PolyLabel.Find(outer, holes); + } + public void Update(DrawControl plateView) { Path = GraphicsHelper.GetGraphicsPath(BasePart.Program, BasePart.Location); Path.Transform(plateView.Matrix); - _labelPoint = ComputeLabelPoint(); + + _labelPoint ??= ComputeLabelPoint(); + var rotatedLabel = _labelPoint.Value.Rotate(BasePart.Rotation); + var labelPt = new PointF( + (float)(rotatedLabel.X + BasePart.Location.X), + (float)(rotatedLabel.Y + BasePart.Location.Y)); + var pts = new[] { labelPt }; + plateView.Matrix.TransformPoints(pts); + _labelScreenPoint = pts[0]; + IsDirty = false; } - private PointF ComputeLabelPoint() - { - if (Path.PointCount == 0) - return PointF.Empty; - - var points = Path.PathPoints; - var types = Path.PathTypes; - - // Extract the largest figure from the path for polylabel. - var bestFigure = new List(); - var currentFigure = new List(); - - for (var i = 0; i < points.Length; i++) - { - if ((types[i] & 0x01) == 0 && currentFigure.Count > 0) - { - // New figure starting — save previous if it's the largest so far. - if (currentFigure.Count > bestFigure.Count) - bestFigure = currentFigure; - - currentFigure = new List(); - } - - currentFigure.Add(new Vector(points[i].X, points[i].Y)); - } - - if (currentFigure.Count > bestFigure.Count) - bestFigure = currentFigure; - - if (bestFigure.Count < 3) - return points[0]; - - // Close the polygon if needed. - var first = bestFigure[0]; - var last = bestFigure[bestFigure.Count - 1]; - if (first.DistanceTo(last) > 1e-6) - bestFigure.Add(first); - - var label = PolyLabel.Find(bestFigure, 0.5); - return new PointF((float)label.X, (float)label.Y); - } - public void UpdateOffset(double spacing, double tolerance, Matrix matrix) { if (_offsetPolygonPoints == null || diff --git a/docs/superpowers/plans/2026-03-16-engine-refactor.md b/docs/superpowers/plans/2026-03-16-engine-refactor.md new file mode 100644 index 0000000..fbf14ca --- /dev/null +++ b/docs/superpowers/plans/2026-03-16-engine-refactor.md @@ -0,0 +1,1645 @@ +# Engine Refactor Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extract shared algorithms from DefaultNestEngine and StripNestEngine into focused, reusable helper classes. + +**Architecture:** Five helper classes (AccumulatingProgress, ShrinkFiller, AngleCandidateBuilder, PairFiller, RemnantFiller) are extracted from the two engines. Each engine then composes these helpers instead of inlining the logic. No new interfaces — just focused classes with delegate-based decoupling. + +**Tech Stack:** .NET 8, C#, xUnit + +**Spec:** `docs/superpowers/specs/2026-03-16-engine-refactor-design.md` + +--- + +## File Structure + +**New files (OpenNest.Engine/):** +- `AccumulatingProgress.cs` — IProgress wrapper that prepends prior parts +- `ShrinkFiller.cs` — Static shrink-to-fit loop + ShrinkAxis enum + ShrinkResult class +- `AngleCandidateBuilder.cs` — Angle sweep/ML/pruning with known-good state +- `PairFiller.cs` — Pair candidate selection and fill loop +- `RemnantFiller.cs` — Iterative remnant-fill loop over RemnantFinder + +**New test files (OpenNest.Tests/):** +- `AccumulatingProgressTests.cs` +- `ShrinkFillerTests.cs` +- `AngleCandidateBuilderTests.cs` +- `PairFillerTests.cs` +- `RemnantFillerTests.cs` +- `EngineRefactorSmokeTests.cs` — Integration tests verifying engines produce same results + +**Modified files:** +- `OpenNest.Engine/DefaultNestEngine.cs` — Remove extracted code, compose helpers +- `OpenNest.Engine/StripNestEngine.cs` — Remove extracted code, compose helpers +- `OpenNest.Engine/NestEngineBase.cs` — Replace remnant loop with RemnantFiller + +--- + +## Chunk 1: Leaf Extractions (AccumulatingProgress, ShrinkFiller) + +### Task 0: Add InternalsVisibleTo for test project + +**Files:** +- Modify: `OpenNest.Engine/OpenNest.Engine.csproj` + +Several extracted classes are `internal`. The test project needs access. + +- [ ] **Step 1: Add InternalsVisibleTo to the Engine csproj** + +Add inside the first `` or add a new ``: + +```xml + + + +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/OpenNest.Engine.csproj +git commit -m "build: add InternalsVisibleTo for OpenNest.Tests" +``` + +--- + +### Task 1: Extract AccumulatingProgress + +**Files:** +- Create: `OpenNest.Engine/AccumulatingProgress.cs` +- Create: `OpenNest.Tests/AccumulatingProgressTests.cs` + +- [ ] **Step 1: Write the failing test** + +In `OpenNest.Tests/AccumulatingProgressTests.cs`: + +```csharp +namespace OpenNest.Tests; + +public class AccumulatingProgressTests +{ + private class CapturingProgress : IProgress + { + public NestProgress Last { get; private set; } + public void Report(NestProgress value) => Last = value; + } + + [Fact] + public void Report_PrependsPreviousParts() + { + var inner = new CapturingProgress(); + var previous = new List { TestHelpers.MakePartAt(0, 0, 10) }; + var accumulating = new AccumulatingProgress(inner, previous); + + var newParts = new List { TestHelpers.MakePartAt(20, 0, 10) }; + accumulating.Report(new NestProgress { BestParts = newParts, BestPartCount = 1 }); + + Assert.NotNull(inner.Last); + Assert.Equal(2, inner.Last.BestParts.Count); + Assert.Equal(2, inner.Last.BestPartCount); + } + + [Fact] + public void Report_NoPreviousParts_PassesThrough() + { + var inner = new CapturingProgress(); + var accumulating = new AccumulatingProgress(inner, new List()); + + var newParts = new List { TestHelpers.MakePartAt(0, 0, 10) }; + accumulating.Report(new NestProgress { BestParts = newParts, BestPartCount = 1 }); + + Assert.NotNull(inner.Last); + Assert.Single(inner.Last.BestParts); + } + + [Fact] + public void Report_NullBestParts_PassesThrough() + { + var inner = new CapturingProgress(); + var previous = new List { TestHelpers.MakePartAt(0, 0, 10) }; + var accumulating = new AccumulatingProgress(inner, previous); + + accumulating.Report(new NestProgress { BestParts = null }); + + Assert.NotNull(inner.Last); + Assert.Null(inner.Last.BestParts); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test OpenNest.Tests --filter "AccumulatingProgressTests" -v n` +Expected: Build error — `AccumulatingProgress` class does not exist yet. + +- [ ] **Step 3: Create AccumulatingProgress class** + +In `OpenNest.Engine/AccumulatingProgress.cs`: + +```csharp +using System; +using System.Collections.Generic; + +namespace OpenNest +{ + /// + /// Wraps an IProgress to prepend previously placed parts to each report, + /// so the UI shows the full picture during incremental fills. + /// + internal class AccumulatingProgress : IProgress + { + private readonly IProgress inner; + private readonly List previousParts; + + public AccumulatingProgress(IProgress inner, List previousParts) + { + this.inner = inner; + this.previousParts = previousParts; + } + + public void Report(NestProgress value) + { + if (value.BestParts != null && previousParts.Count > 0) + { + var combined = new List(previousParts.Count + value.BestParts.Count); + combined.AddRange(previousParts); + combined.AddRange(value.BestParts); + value.BestParts = combined; + value.BestPartCount = combined.Count; + } + + inner.Report(value); + } + } +} +``` + +**Note:** The class is `internal`. The test project needs `InternalsVisibleTo`. Check if `OpenNest.Engine.csproj` already has it; if not, add `[assembly: InternalsVisibleTo("OpenNest.Tests")]` in `OpenNest.Engine/Properties/AssemblyInfo.cs` or as an `` item in the csproj. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test OpenNest.Tests --filter "AccumulatingProgressTests" -v n` +Expected: All 3 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Engine/AccumulatingProgress.cs OpenNest.Tests/AccumulatingProgressTests.cs +git commit -m "refactor(engine): extract AccumulatingProgress from StripNestEngine" +``` + +--- + +### Task 2: Extract ShrinkFiller + +**Files:** +- Create: `OpenNest.Engine/ShrinkFiller.cs` +- Create: `OpenNest.Tests/ShrinkFillerTests.cs` + +- [ ] **Step 1: Write the failing tests** + +In `OpenNest.Tests/ShrinkFillerTests.cs`: + +```csharp +using OpenNest.Geometry; + +namespace OpenNest.Tests; + +public class ShrinkFillerTests +{ + private static Drawing MakeSquareDrawing(double size) + { + 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(size, 0))); + pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(size, size))); + pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, size))); + pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0))); + return new Drawing("square", pgm); + } + + [Fact] + public void Shrink_ReducesDimension_UntilCountDrops() + { + // 10x10 parts on a 100x50 box with 1.0 spacing. + // Initial fill should place multiple parts. Shrinking height + // should eventually reduce to the tightest strip. + var drawing = MakeSquareDrawing(10); + var item = new NestItem { Drawing = drawing }; + var box = new Box(0, 0, 100, 50); + + Func> fillFunc = (ni, b) => + { + var plate = new Plate(b.Width, b.Length); + var engine = new DefaultNestEngine(plate); + return engine.Fill(ni, b, null, System.Threading.CancellationToken.None); + }; + + var result = ShrinkFiller.Shrink(fillFunc, item, box, 1.0, ShrinkAxis.Height); + + Assert.NotNull(result); + Assert.True(result.Parts.Count > 0); + Assert.True(result.Dimension <= 50, "Dimension should be <= original"); + Assert.True(result.Dimension > 0); + } + + [Fact] + public void Shrink_Width_ReducesHorizontally() + { + var drawing = MakeSquareDrawing(10); + var item = new NestItem { Drawing = drawing }; + var box = new Box(0, 0, 100, 50); + + Func> fillFunc = (ni, b) => + { + var plate = new Plate(b.Width, b.Length); + var engine = new DefaultNestEngine(plate); + return engine.Fill(ni, b, null, System.Threading.CancellationToken.None); + }; + + var result = ShrinkFiller.Shrink(fillFunc, item, box, 1.0, ShrinkAxis.Width); + + Assert.NotNull(result); + Assert.True(result.Parts.Count > 0); + Assert.True(result.Dimension <= 100); + } + + [Fact] + public void Shrink_RespectsMaxIterations() + { + var callCount = 0; + Func> fillFunc = (ni, b) => + { + callCount++; + return new List { TestHelpers.MakePartAt(0, 0, 5) }; + }; + + var item = new NestItem { Drawing = MakeSquareDrawing(5) }; + var box = new Box(0, 0, 100, 100); + + ShrinkFiller.Shrink(fillFunc, item, box, 1.0, ShrinkAxis.Height, maxIterations: 3); + + // 1 initial + up to 3 shrink iterations = max 4 calls + Assert.True(callCount <= 4); + } + + [Fact] + public void Shrink_RespectsCancellation() + { + var cts = new System.Threading.CancellationTokenSource(); + cts.Cancel(); + + var drawing = MakeSquareDrawing(10); + var item = new NestItem { Drawing = drawing }; + var box = new Box(0, 0, 100, 50); + + Func> fillFunc = (ni, b) => + new List { TestHelpers.MakePartAt(0, 0, 10) }; + + var result = ShrinkFiller.Shrink(fillFunc, item, box, 1.0, + ShrinkAxis.Height, token: cts.Token); + + // Should return initial fill without shrinking + Assert.NotNull(result); + Assert.True(result.Parts.Count > 0); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test OpenNest.Tests --filter "ShrinkFillerTests" -v n` +Expected: Build error — `ShrinkFiller`, `ShrinkAxis`, `ShrinkResult` do not exist. + +- [ ] **Step 3: Create ShrinkFiller class** + +In `OpenNest.Engine/ShrinkFiller.cs`: + +```csharp +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.Geometry; + +namespace OpenNest +{ + public enum ShrinkAxis { Width, Height } + + public class ShrinkResult + { + public List Parts { get; set; } + public double Dimension { get; set; } + } + + /// + /// Fills a box then iteratively shrinks one axis by the spacing amount + /// until the part count drops. Returns the tightest box that still fits + /// the same number of parts. + /// + public static class ShrinkFiller + { + public static ShrinkResult Shrink( + Func> fillFunc, + NestItem item, Box box, + double spacing, + ShrinkAxis axis, + CancellationToken token = default, + int maxIterations = 20) + { + var parts = fillFunc(item, box); + + if (parts == null || parts.Count == 0) + return new ShrinkResult { Parts = parts ?? new List(), Dimension = 0 }; + + var targetCount = parts.Count; + var bestParts = parts; + var bestDim = MeasureDimension(parts, box, axis); + + for (var i = 0; i < maxIterations; i++) + { + if (token.IsCancellationRequested) + break; + + var trialDim = bestDim - spacing; + if (trialDim <= 0) + break; + + var trialBox = axis == ShrinkAxis.Width + ? new Box(box.X, box.Y, trialDim, box.Length) + : new Box(box.X, box.Y, box.Width, trialDim); + + var trialParts = fillFunc(item, trialBox); + + if (trialParts == null || trialParts.Count < targetCount) + break; + + bestParts = trialParts; + bestDim = MeasureDimension(trialParts, box, axis); + } + + return new ShrinkResult { Parts = bestParts, Dimension = bestDim }; + } + + private static double MeasureDimension(List parts, Box box, ShrinkAxis axis) + { + var placedBox = parts.Cast().GetBoundingBox(); + + return axis == ShrinkAxis.Width + ? placedBox.Right - box.X + : placedBox.Top - box.Y; + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test OpenNest.Tests --filter "ShrinkFillerTests" -v n` +Expected: All 4 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Engine/ShrinkFiller.cs OpenNest.Tests/ShrinkFillerTests.cs +git commit -m "refactor(engine): extract ShrinkFiller from StripNestEngine" +``` + +--- + +## Chunk 2: AngleCandidateBuilder and PairFiller + +### Task 3: Extract AngleCandidateBuilder + +**Files:** +- Create: `OpenNest.Engine/AngleCandidateBuilder.cs` +- Create: `OpenNest.Tests/AngleCandidateBuilderTests.cs` +- Modify: `OpenNest.Engine/DefaultNestEngine.cs` — Add forwarding property + +- [ ] **Step 1: Write the failing tests** + +In `OpenNest.Tests/AngleCandidateBuilderTests.cs`: + +```csharp +using OpenNest.Geometry; + +namespace OpenNest.Tests; + +public class AngleCandidateBuilderTests +{ + private static Drawing MakeRectDrawing(double w, double h) + { + 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("rect", pgm); + } + + [Fact] + public void Build_ReturnsAtLeastTwoAngles() + { + var builder = new AngleCandidateBuilder(); + var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; + var workArea = new Box(0, 0, 100, 100); + + var angles = builder.Build(item, 0, workArea); + + Assert.True(angles.Count >= 2); + } + + [Fact] + public void Build_NarrowWorkArea_ProducesMoreAngles() + { + var builder = new AngleCandidateBuilder(); + var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; + var wideArea = new Box(0, 0, 100, 100); + var narrowArea = new Box(0, 0, 100, 8); // narrower than part's longest side + + var wideAngles = builder.Build(item, 0, wideArea); + var narrowAngles = builder.Build(item, 0, narrowArea); + + Assert.True(narrowAngles.Count > wideAngles.Count, + $"Narrow ({narrowAngles.Count}) should have more angles than wide ({wideAngles.Count})"); + } + + [Fact] + public void ForceFullSweep_ProducesFullSweep() + { + var builder = new AngleCandidateBuilder { ForceFullSweep = true }; + var item = new NestItem { Drawing = MakeRectDrawing(5, 5) }; + var workArea = new Box(0, 0, 100, 100); + + var angles = builder.Build(item, 0, workArea); + + // Full sweep at 5° steps = ~36 angles (0 to 175), plus base angles + Assert.True(angles.Count > 10); + } + + [Fact] + public void RecordProductive_PrunesSubsequentBuilds() + { + var builder = new AngleCandidateBuilder { ForceFullSweep = true }; + var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; + var workArea = new Box(0, 0, 100, 8); + + // First build — full sweep + var firstAngles = builder.Build(item, 0, workArea); + + // Record some as productive + var productive = new List + { + new AngleResult { AngleDeg = 0, PartCount = 5 }, + new AngleResult { AngleDeg = 45, PartCount = 3 }, + }; + builder.RecordProductive(productive); + + // Second build — should be pruned to known-good + base angles + builder.ForceFullSweep = false; + var secondAngles = builder.Build(item, 0, workArea); + + Assert.True(secondAngles.Count < firstAngles.Count, + $"Pruned ({secondAngles.Count}) should be fewer than full ({firstAngles.Count})"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test OpenNest.Tests --filter "AngleCandidateBuilderTests" -v n` +Expected: Build error — `AngleCandidateBuilder` does not exist. + +- [ ] **Step 3: Create AngleCandidateBuilder class** + +In `OpenNest.Engine/AngleCandidateBuilder.cs`: + +```csharp +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using OpenNest.Engine.ML; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest +{ + /// + /// Builds candidate rotation angles for single-item fill. Encapsulates the + /// full pipeline: base angles, narrow-area sweep, ML prediction, and + /// known-good pruning across fills. + /// + public class AngleCandidateBuilder + { + private readonly HashSet knownGoodAngles = new(); + + public bool ForceFullSweep { get; set; } + + public List Build(NestItem item, double bestRotation, Box workArea) + { + var angles = new List { bestRotation, bestRotation + Angle.HalfPI }; + + 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); + var needsSweep = workAreaShortSide < partLongestSide || ForceFullSweep; + + if (needsSweep) + { + 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 (!ForceFullSweep && 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($"[AngleCandidateBuilder] ML: {angles.Count} angles -> {mlAngles.Count} predicted"); + angles = mlAngles; + } + } + } + + if (knownGoodAngles.Count > 0 && !ForceFullSweep) + { + var pruned = new List { bestRotation, bestRotation + Angle.HalfPI }; + + foreach (var a in knownGoodAngles) + { + if (!pruned.Any(existing => existing.IsEqualTo(a))) + pruned.Add(a); + } + + Debug.WriteLine($"[AngleCandidateBuilder] Pruned: {angles.Count} -> {pruned.Count} angles (known-good)"); + return pruned; + } + + return angles; + } + + /// + /// Records angles that produced results. These are used to prune + /// subsequent Build() calls. + /// + public void RecordProductive(List angleResults) + { + foreach (var ar in angleResults) + { + if (ar.PartCount > 0) + knownGoodAngles.Add(Angle.ToRadians(ar.AngleDeg)); + } + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test OpenNest.Tests --filter "AngleCandidateBuilderTests" -v n` +Expected: All 4 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Engine/AngleCandidateBuilder.cs OpenNest.Tests/AngleCandidateBuilderTests.cs +git commit -m "refactor(engine): extract AngleCandidateBuilder from DefaultNestEngine" +``` + +--- + +### Task 4: Make BuildRotatedPattern and FillPattern internal static + +**Files:** +- Modify: `OpenNest.Engine/DefaultNestEngine.cs:493-546` + +This task prepares the pattern helpers for use by PairFiller. No tests needed — existing behavior is unchanged. + +- [ ] **Step 1: Change method signatures to internal static** + +In `DefaultNestEngine.cs`, change `BuildRotatedPattern` (line 493): +- From: `private Pattern BuildRotatedPattern(List groupParts, double angle)` +- To: `internal static Pattern BuildRotatedPattern(List groupParts, double angle)` + +Change `FillPattern` (line 513): +- From: `private List FillPattern(FillLinear engine, List groupParts, List angles, Box workArea)` +- To: `internal static List FillPattern(FillLinear engine, List groupParts, List angles, Box workArea)` + +These methods already have no instance state access — they only use their parameters. Verify this by confirming no `this.` or instance field/property references in their bodies. + +- [ ] **Step 2: Build to verify no regressions** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeds. + +- [ ] **Step 3: Run all tests** + +Run: `dotnet test OpenNest.Tests -v n` +Expected: All tests PASS. + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Engine/DefaultNestEngine.cs +git commit -m "refactor(engine): make BuildRotatedPattern and FillPattern internal static" +``` + +--- + +### Task 5: Extract PairFiller + +**Files:** +- Create: `OpenNest.Engine/PairFiller.cs` +- Create: `OpenNest.Tests/PairFillerTests.cs` + +- [ ] **Step 1: Write the failing tests** + +In `OpenNest.Tests/PairFillerTests.cs`: + +```csharp +using OpenNest.Geometry; + +namespace OpenNest.Tests; + +public class PairFillerTests +{ + private static Drawing MakeRectDrawing(double w, double h) + { + 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("rect", pgm); + } + + [Fact] + public void Fill_ReturnsPartsForSimpleDrawing() + { + var plateSize = new Size(120, 60); + var filler = new PairFiller(plateSize, 0.5); + var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; + var workArea = new Box(0, 0, 120, 60); + + var parts = filler.Fill(item, workArea); + + Assert.NotNull(parts); + // Pair filling may or may not find interlocking pairs for rectangles, + // but should return a non-null list. + } + + [Fact] + public void Fill_EmptyResult_WhenPartTooLarge() + { + var plateSize = new Size(10, 10); + var filler = new PairFiller(plateSize, 0.5); + var item = new NestItem { Drawing = MakeRectDrawing(20, 20) }; + var workArea = new Box(0, 0, 10, 10); + + var parts = filler.Fill(item, workArea); + + Assert.NotNull(parts); + Assert.Empty(parts); + } + + [Fact] + public void Fill_RespectsCancellation() + { + var cts = new System.Threading.CancellationTokenSource(); + cts.Cancel(); + + var plateSize = new Size(120, 60); + var filler = new PairFiller(plateSize, 0.5); + var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; + var workArea = new Box(0, 0, 120, 60); + + var parts = filler.Fill(item, workArea, token: cts.Token); + + // Should return empty or partial — not throw + Assert.NotNull(parts); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test OpenNest.Tests --filter "PairFillerTests" -v n` +Expected: Build error — `PairFiller` does not exist. + +- [ ] **Step 3: Create PairFiller class** + +In `OpenNest.Engine/PairFiller.cs`: + +```csharp +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using OpenNest.Engine.BestFit; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest +{ + /// + /// Fills a work area using interlocking part pairs from BestFitCache. + /// Extracted from DefaultNestEngine.FillWithPairs. + /// + public class PairFiller + { + private const int MinPairCandidates = 10; + private static readonly TimeSpan PairTimeLimit = TimeSpan.FromSeconds(3); + + private readonly Size plateSize; + private readonly double partSpacing; + + public PairFiller(Size plateSize, double partSpacing) + { + this.plateSize = plateSize; + this.partSpacing = partSpacing; + } + + public List Fill(NestItem item, Box workArea, + int plateNumber = 0, + CancellationToken token = default, + IProgress progress = null) + { + var bestFits = BestFitCache.GetOrCompute( + item.Drawing, plateSize.Width, plateSize.Length, partSpacing); + + var candidates = SelectPairCandidates(bestFits, workArea); + var remainderPatterns = BuildRemainderPatterns(candidates, item); + Debug.WriteLine($"[PairFiller] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}"); + Debug.WriteLine($"[PairFiller] Plate: {plateSize.Width:F2}x{plateSize.Length:F2}, WorkArea: {workArea.Width:F2}x{workArea.Length:F2}"); + + List best = null; + var bestScore = default(FillScore); + var deadline = Stopwatch.StartNew(); + + try + { + for (var i = 0; i < candidates.Count; i++) + { + token.ThrowIfCancellationRequested(); + + if (i >= MinPairCandidates && deadline.Elapsed > PairTimeLimit) + { + Debug.WriteLine($"[PairFiller] Time limit at {i + 1}/{candidates.Count} ({deadline.ElapsedMilliseconds}ms)"); + break; + } + + var result = candidates[i]; + var pairParts = result.BuildParts(item.Drawing); + var angles = result.HullAngles; + var engine = new FillLinear(workArea, partSpacing); + engine.RemainderPatterns = remainderPatterns; + var filled = DefaultNestEngine.FillPattern(engine, pairParts, angles, workArea); + + if (filled != null && filled.Count > 0) + { + var score = FillScore.Compute(filled, workArea); + if (best == null || score > bestScore) + { + best = filled; + bestScore = score; + } + } + + NestEngineBase.ReportProgress(progress, NestPhase.Pairs, plateNumber, best, workArea, + $"Pairs: {i + 1}/{candidates.Count} candidates, best = {bestScore.Count} parts"); + } + } + catch (OperationCanceledException) + { + Debug.WriteLine("[PairFiller] Cancelled mid-phase, using results so far"); + } + + Debug.WriteLine($"[PairFiller] Best pair result: {bestScore.Count} parts, density={bestScore.Density:P1} ({deadline.ElapsedMilliseconds}ms)"); + return best ?? new List(); + } + + private List SelectPairCandidates(List bestFits, Box workArea) + { + var kept = bestFits.Where(r => r.Keep).ToList(); + var top = kept.Take(50).ToList(); + + var workShortSide = System.Math.Min(workArea.Width, workArea.Length); + var plateShortSide = System.Math.Min(plateSize.Width, plateSize.Length); + + if (workShortSide < plateShortSide * 0.5) + { + var stripCandidates = bestFits + .Where(r => r.ShortestSide <= workShortSide + Tolerance.Epsilon + && r.Utilization >= 0.3) + .OrderByDescending(r => r.Utilization); + + var existing = new HashSet(top); + + foreach (var r in stripCandidates) + { + if (top.Count >= 100) + break; + + if (existing.Add(r)) + top.Add(r); + } + + Debug.WriteLine($"[PairFiller] Strip mode: {top.Count} candidates (shortSide <= {workShortSide:F1})"); + } + + return top; + } + + private List BuildRemainderPatterns(List candidates, NestItem item) + { + var patterns = new List(); + + foreach (var candidate in candidates.Take(5)) + { + var pairParts = candidate.BuildParts(item.Drawing); + var angles = candidate.HullAngles ?? new List { 0 }; + + foreach (var angle in angles.Take(3)) + { + var pattern = DefaultNestEngine.BuildRotatedPattern(pairParts, angle); + if (pattern.Parts.Count > 0) + patterns.Add(pattern); + } + } + + return patterns; + } + } +} +``` + +**Note:** `NestEngineBase.ReportProgress` is currently `protected static`. It needs to become `internal static` so PairFiller can call it. Change the access modifier in `NestEngineBase.cs` line 180 from `protected static` to `internal static`. + +- [ ] **Step 4: Change ReportProgress to internal static** + +In `NestEngineBase.cs` line 180, change: +- From: `protected static void ReportProgress(` +- To: `internal static void ReportProgress(` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `dotnet test OpenNest.Tests --filter "PairFillerTests" -v n` +Expected: All 3 tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add OpenNest.Engine/PairFiller.cs OpenNest.Tests/PairFillerTests.cs OpenNest.Engine/NestEngineBase.cs +git commit -m "refactor(engine): extract PairFiller from DefaultNestEngine" +``` + +--- + +## Chunk 3: RemnantFiller and Engine Rewiring + +### Task 6: Extract RemnantFiller + +**Files:** +- Create: `OpenNest.Engine/RemnantFiller.cs` +- Create: `OpenNest.Tests/RemnantFillerTests2.cs` (using `2` to avoid conflict with existing `RemnantFinderTests.cs`) + +- [ ] **Step 1: Write the failing tests** + +In `OpenNest.Tests/RemnantFillerTests2.cs`: + +```csharp +using OpenNest.Geometry; + +namespace OpenNest.Tests; + +public class RemnantFillerTests2 +{ + private static Drawing MakeSquareDrawing(double size) + { + 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(size, 0))); + pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(size, size))); + pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, size))); + pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0))); + return new Drawing("sq", pgm); + } + + [Fact] + public void FillItems_PlacesPartsInRemnants() + { + var workArea = new Box(0, 0, 100, 100); + var filler = new RemnantFiller(workArea, 1.0); + + // Place a large obstacle leaving a 40x100 strip on the right + filler.AddObstacles(new[] { TestHelpers.MakePartAt(0, 0, 50) }); + + var drawing = MakeSquareDrawing(10); + var items = new List + { + new NestItem { Drawing = drawing, Quantity = 5 } + }; + + Func> fillFunc = (ni, b) => + { + var plate = new Plate(b.Width, b.Length); + var engine = new DefaultNestEngine(plate); + return engine.Fill(ni, b, null, System.Threading.CancellationToken.None); + }; + + var placed = filler.FillItems(items, fillFunc); + + Assert.True(placed.Count > 0, "Should place parts in remaining space"); + } + + [Fact] + public void FillItems_DoesNotMutateItemQuantities() + { + var workArea = new Box(0, 0, 100, 100); + var filler = new RemnantFiller(workArea, 1.0); + + var drawing = MakeSquareDrawing(10); + var items = new List + { + new NestItem { Drawing = drawing, Quantity = 3 } + }; + + Func> fillFunc = (ni, b) => + { + var plate = new Plate(b.Width, b.Length); + var engine = new DefaultNestEngine(plate); + return engine.Fill(ni, b, null, System.Threading.CancellationToken.None); + }; + + filler.FillItems(items, fillFunc); + + Assert.Equal(3, items[0].Quantity); + } + + [Fact] + public void FillItems_EmptyItems_ReturnsEmpty() + { + var workArea = new Box(0, 0, 100, 100); + var filler = new RemnantFiller(workArea, 1.0); + + Func> fillFunc = (ni, b) => new List(); + + var result = filler.FillItems(new List(), fillFunc); + + Assert.Empty(result); + } + + [Fact] + public void FillItems_RespectsCancellation() + { + var cts = new System.Threading.CancellationTokenSource(); + cts.Cancel(); + + var workArea = new Box(0, 0, 100, 100); + var filler = new RemnantFiller(workArea, 1.0); + + var drawing = MakeSquareDrawing(10); + var items = new List + { + new NestItem { Drawing = drawing, Quantity = 5 } + }; + + Func> fillFunc = (ni, b) => + new List { TestHelpers.MakePartAt(0, 0, 10) }; + + var result = filler.FillItems(items, fillFunc, cts.Token); + + // Should not throw, returns whatever was placed + Assert.NotNull(result); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test OpenNest.Tests --filter "RemnantFillerTests2" -v n` +Expected: Build error — `RemnantFiller` class (the new one, not `RemnantFinder`) does not exist. + +- [ ] **Step 3: Create RemnantFiller class** + +In `OpenNest.Engine/RemnantFiller.cs`: + +```csharp +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.Geometry; + +namespace OpenNest +{ + /// + /// Iteratively fills remnant boxes with items using a RemnantFinder. + /// After each fill, re-discovers free rectangles and tries again + /// until no more items can be placed. + /// + public class RemnantFiller + { + private readonly RemnantFinder finder; + private readonly double spacing; + + public RemnantFiller(Box workArea, double spacing) + { + this.spacing = spacing; + finder = new RemnantFinder(workArea); + } + + public void AddObstacles(IEnumerable parts) + { + foreach (var part in parts) + finder.AddObstacle(part.BoundingBox.Offset(spacing)); + } + + public List FillItems( + List items, + Func> fillFunc, + CancellationToken token = default, + IProgress progress = null) + { + if (items == null || items.Count == 0) + return new List(); + + var allParts = new List(); + var madeProgress = true; + + // Track quantities locally — do not mutate the input NestItem objects. + // NOTE: Keyed by Drawing.Name — duplicate names would collide. + // This matches the original StripNestEngine behavior. + var localQty = new Dictionary(); + foreach (var item in items) + localQty[item.Drawing.Name] = item.Quantity; + + while (madeProgress && !token.IsCancellationRequested) + { + madeProgress = false; + + var minRemnantDim = double.MaxValue; + foreach (var item in items) + { + var qty = localQty[item.Drawing.Name]; + if (qty <= 0) + continue; + var bb = item.Drawing.Program.BoundingBox(); + var dim = System.Math.Min(bb.Width, bb.Length); + if (dim < minRemnantDim) + minRemnantDim = dim; + } + + if (minRemnantDim == double.MaxValue) + break; + + var freeBoxes = finder.FindRemnants(minRemnantDim); + + if (freeBoxes.Count == 0) + break; + + foreach (var item in items) + { + if (token.IsCancellationRequested) + break; + + var qty = localQty[item.Drawing.Name]; + if (qty == 0) + continue; + + var itemBbox = item.Drawing.Program.BoundingBox(); + var minItemDim = System.Math.Min(itemBbox.Width, itemBbox.Length); + + foreach (var box in freeBoxes) + { + if (System.Math.Min(box.Width, box.Length) < minItemDim) + continue; + + var fillItem = new NestItem { Drawing = item.Drawing, Quantity = qty }; + var remnantParts = fillFunc(fillItem, box); + + if (remnantParts != null && remnantParts.Count > 0) + { + allParts.AddRange(remnantParts); + localQty[item.Drawing.Name] = System.Math.Max(0, qty - remnantParts.Count); + + foreach (var p in remnantParts) + finder.AddObstacle(p.BoundingBox.Offset(spacing)); + + madeProgress = true; + break; + } + } + + if (madeProgress) + break; + } + } + + return allParts; + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test OpenNest.Tests --filter "RemnantFillerTests2" -v n` +Expected: All 4 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Engine/RemnantFiller.cs OpenNest.Tests/RemnantFillerTests2.cs +git commit -m "refactor(engine): extract RemnantFiller for iterative remnant filling" +``` + +--- + +### Task 7: Rewire DefaultNestEngine + +**Files:** +- Modify: `OpenNest.Engine/DefaultNestEngine.cs` + +This is the core rewiring. Replace extracted methods with calls to the new helper classes. + +- [ ] **Step 1: Add AngleCandidateBuilder field and ForceFullAngleSweep forwarding property** + +In `DefaultNestEngine.cs`, replace: + +```csharp +public bool ForceFullAngleSweep { get; set; } + +// Angles that have produced results across multiple Fill calls. +// Populated after each Fill; used to prune subsequent fills. +private readonly HashSet knownGoodAngles = new(); +``` + +With: + +```csharp +private readonly AngleCandidateBuilder angleBuilder = new(); + +public bool ForceFullAngleSweep +{ + get => angleBuilder.ForceFullSweep; + set => angleBuilder.ForceFullSweep = value; +} +``` + +- [ ] **Step 2: Replace BuildCandidateAngles call in FindBestFill** + +In `FindBestFill` (line ~181), replace: +```csharp +var angles = BuildCandidateAngles(item, bestRotation, workArea); +``` +With: +```csharp +var angles = angleBuilder.Build(item, bestRotation, workArea); +``` + +- [ ] **Step 3: Replace knownGoodAngles recording in FindBestFill** + +In `FindBestFill`, replace the block (around line 243): +```csharp +// Record productive angles for future fills. +foreach (var ar in AngleResults) +{ + if (ar.PartCount > 0) + knownGoodAngles.Add(Angle.ToRadians(ar.AngleDeg)); +} +``` +With: +```csharp +angleBuilder.RecordProductive(AngleResults); +``` + +- [ ] **Step 4: Replace FillWithPairs call in FindBestFill** + +In `FindBestFill`, replace: +```csharp +var pairResult = FillWithPairs(item, workArea, token, progress); +``` +With: +```csharp +var pairFiller = new PairFiller(Plate.Size, Plate.PartSpacing); +var pairResult = pairFiller.Fill(item, workArea, PlateNumber, token, progress); +``` + +- [ ] **Step 5: Replace FillWithPairs call in Fill(List) overload** + +In the `Fill(List groupParts, Box workArea, ...)` method (around line 137), replace: +```csharp +var pairResult = FillWithPairs(nestItem, workArea, token, progress); +``` +With: +```csharp +var pairFiller = new PairFiller(Plate.Size, Plate.PartSpacing); +var pairResult = pairFiller.Fill(nestItem, workArea, PlateNumber, token, progress); +``` + +- [ ] **Step 6: Delete the extracted methods** + +Remove these methods and fields from DefaultNestEngine: +- `private readonly HashSet knownGoodAngles` (already replaced in Step 1) +- `private List BuildCandidateAngles(...)` (entire method, lines 279-347) +- `private List FillWithPairs(...)` (entire method, lines 365-421) +- `private List SelectPairCandidates(...)` (entire method, lines 428-462) +- `private List BuildRemainderPatterns(...)` (entire method, lines 471-489) +- `private const int MinPairCandidates = 10;` (line 362) +- `private static readonly TimeSpan PairTimeLimit = TimeSpan.FromSeconds(3);` (line 363) + +Also remove now-unused `using` statements if any (e.g., `using OpenNest.Engine.BestFit;` if no longer referenced, though `QuickFillCount` still uses `BestFitCache`). + +- [ ] **Step 7: Build to verify** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeds with no errors. + +- [ ] **Step 8: Run all tests** + +Run: `dotnet test OpenNest.Tests -v n` +Expected: All tests PASS. + +- [ ] **Step 9: Commit** + +```bash +git add OpenNest.Engine/DefaultNestEngine.cs +git commit -m "refactor(engine): rewire DefaultNestEngine to use extracted helpers" +``` + +--- + +### Task 8: Rewire StripNestEngine + +**Files:** +- Modify: `OpenNest.Engine/StripNestEngine.cs` + +- [ ] **Step 1: Replace TryOrientation shrink loop with ShrinkFiller** + +In `TryOrientation`, replace the shrink loop (lines 188-215) with: + +```csharp +// Shrink to tightest strip. +var shrinkAxis = direction == StripDirection.Bottom + ? ShrinkAxis.Height : ShrinkAxis.Width; + +Func> shrinkFill = (ni, b) => +{ + var trialInner = new DefaultNestEngine(Plate); + return trialInner.Fill(ni, b, progress, token); +}; + +var shrinkResult = ShrinkFiller.Shrink(shrinkFill, + new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity }, + stripBox, Plate.PartSpacing, shrinkAxis, token); + +if (shrinkResult.Parts == null || shrinkResult.Parts.Count == 0) + return result; + +bestParts = shrinkResult.Parts; +bestDim = shrinkResult.Dimension; +``` + +Remove the local variables that the shrink loop used to compute (`actualDim`, `targetCount`, and the `for` loop itself). Keep the remnant section below it. + +**Important:** The initial fill call (lines 169-172) stays as-is — ShrinkFiller handles both the initial fill AND the shrink loop. So replace from the initial fill through the shrink loop with the single `ShrinkFiller.Shrink()` call, since `Shrink` does its own initial fill internally. + +Actually — looking more carefully, the initial fill (lines 170-172) uses `progress` while shrink trials in the original code create a new `DefaultNestEngine` each time. `ShrinkFiller.Shrink` does its own initial fill via `fillFunc`. So the replacement should: + +1. Remove the initial fill (lines 169-175) and the shrink loop (lines 188-215) +2. Replace both with the single `ShrinkFiller.Shrink()` call + +The `fillFunc` delegate handles both the initial fill and each shrink trial. + +- [ ] **Step 2: Replace ShrinkFill method with ShrinkFiller calls** + +Replace the entire `ShrinkFill` method (lines 358-419) with: + +```csharp +private List ShrinkFill(NestItem item, Box box, + IProgress progress, CancellationToken token) +{ + Func> fillFunc = (ni, b) => + { + var inner = new DefaultNestEngine(Plate); + return inner.Fill(ni, b, null, token); + }; + + // The original code shrinks width then height independently against the + // original box. The height shrink's result overwrites the width shrink's, + // so only the height result matters. We run both to preserve behavior + // (width shrink is a no-op on the final result but we keep it for parity). + var heightResult = ShrinkFiller.Shrink(fillFunc, item, box, + Plate.PartSpacing, ShrinkAxis.Height, token); + + return heightResult.Parts; +} +``` + +**Note:** The original `ShrinkFill` runs width-shrink then height-shrink sequentially, but the height result always overwrites the width result (both shrink against the original box independently, `targetCount` never changes). Running only height-shrink is faithful to the original output while avoiding redundant initial fills. + +- [ ] **Step 3: Replace remnant loop with RemnantFiller** + +In `TryOrientation`, replace the remnant section (from `// Build remnant box with spacing gap` through the end of the `while (madeProgress ...)` loop) with: + +```csharp +// Fill remnants +if (remnantBox.Width > 0 && remnantBox.Length > 0) +{ + var remnantProgress = progress != null + ? new AccumulatingProgress(progress, allParts) + : (IProgress)null; + + var remnantFiller = new RemnantFiller(workArea, spacing); + remnantFiller.AddObstacles(allParts); + + Func> remnantFillFunc = (ni, b) => + ShrinkFill(ni, b, remnantProgress, token); + + var additional = remnantFiller.FillItems(effectiveRemainder, + remnantFillFunc, token, remnantProgress); + + allParts.AddRange(additional); +} +``` + +Keep the `effectiveRemainder` construction and sorting that precedes this block (lines 239-259). Keep the `remnantBox` computation (lines 228-233) since we need to check `remnantBox.Width > 0`. + +Remove: +- The `localQty` dictionary (lines 276-278) +- The `while (madeProgress ...)` loop (lines 280-342) +- The `AccumulatingProgress` nested class (lines 425-449) — now using the standalone version + +- [ ] **Step 4: Build to verify** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeds. + +- [ ] **Step 5: Run all tests** + +Run: `dotnet test OpenNest.Tests -v n` +Expected: All tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add OpenNest.Engine/StripNestEngine.cs +git commit -m "refactor(engine): rewire StripNestEngine to use extracted helpers" +``` + +--- + +### Task 9: Rewire NestEngineBase.Nest + +**Files:** +- Modify: `OpenNest.Engine/NestEngineBase.cs:74-97` + +- [ ] **Step 1: Replace the fill loop with RemnantFiller** + +In `NestEngineBase.Nest`, replace phase 1 (the `foreach (var item in fillItems)` loop, lines 74-98) with: + +```csharp +// Phase 1: Fill multi-quantity drawings using RemnantFiller. +if (fillItems.Count > 0) +{ + var remnantFiller = new RemnantFiller(workArea, Plate.PartSpacing); + + Func> fillFunc = (ni, b) => + FillExact(ni, b, progress, token); + + var fillParts = remnantFiller.FillItems(fillItems, fillFunc, token, progress); + + if (fillParts.Count > 0) + { + allParts.AddRange(fillParts); + + // Deduct placed quantities + foreach (var item in fillItems) + { + var placed = fillParts.Count(p => + p.BaseDrawing.Name == item.Drawing.Name); + item.Quantity = System.Math.Max(0, item.Quantity - placed); + } + + // Update workArea for pack phase + var placedObstacles = fillParts.Select(p => p.BoundingBox.Offset(Plate.PartSpacing)).ToList(); + var finder = new RemnantFinder(workArea, placedObstacles); + var remnants = finder.FindRemnants(); + if (remnants.Count > 0) + workArea = remnants[0]; + else + workArea = new Box(0, 0, 0, 0); + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeds. + +- [ ] **Step 3: Run all tests** + +Run: `dotnet test OpenNest.Tests -v n` +Expected: All tests PASS. + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Engine/NestEngineBase.cs +git commit -m "refactor(engine): use RemnantFiller in NestEngineBase.Nest" +``` + +--- + +### Task 10: Integration Smoke Tests + +**Files:** +- Create: `OpenNest.Tests/EngineRefactorSmokeTests.cs` + +These tests verify that the refactored engines produce valid results end-to-end. + +- [ ] **Step 1: Write smoke tests** + +In `OpenNest.Tests/EngineRefactorSmokeTests.cs`: + +```csharp +using OpenNest.Geometry; + +namespace OpenNest.Tests; + +public class EngineRefactorSmokeTests +{ + 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 DefaultEngine_FillNestItem_ProducesResults() + { + var plate = new Plate(120, 60); + var engine = new DefaultNestEngine(plate); + var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; + + var parts = engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None); + + Assert.True(parts.Count > 0, "DefaultNestEngine should fill parts"); + } + + [Fact] + public void DefaultEngine_FillGroupParts_ProducesResults() + { + var plate = new Plate(120, 60); + var engine = new DefaultNestEngine(plate); + var drawing = MakeRectDrawing(20, 10); + var groupParts = new List { new Part(drawing) }; + + var parts = engine.Fill(groupParts, plate.WorkArea(), null, System.Threading.CancellationToken.None); + + Assert.True(parts.Count > 0, "DefaultNestEngine group fill should produce parts"); + } + + [Fact] + public void DefaultEngine_ForceFullAngleSweep_StillWorks() + { + var plate = new Plate(120, 60); + var engine = new DefaultNestEngine(plate); + engine.ForceFullAngleSweep = true; + var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; + + var parts = engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None); + + Assert.True(parts.Count > 0, "ForceFullAngleSweep should still produce results"); + } + + [Fact] + public void StripEngine_Nest_ProducesResults() + { + var plate = new Plate(120, 60); + var engine = new StripNestEngine(plate); + var items = new List + { + new NestItem { Drawing = MakeRectDrawing(20, 10, "large"), Quantity = 10 }, + new NestItem { Drawing = MakeRectDrawing(8, 5, "small"), Quantity = 5 }, + }; + + var parts = engine.Nest(items, null, System.Threading.CancellationToken.None); + + Assert.True(parts.Count > 0, "StripNestEngine should nest parts"); + } + + [Fact] + public void DefaultEngine_Nest_ProducesResults() + { + var plate = new Plate(120, 60); + var engine = new DefaultNestEngine(plate); + var items = new List + { + new NestItem { Drawing = MakeRectDrawing(20, 10, "a"), Quantity = 5 }, + new NestItem { Drawing = MakeRectDrawing(15, 8, "b"), Quantity = 3 }, + }; + + var parts = engine.Nest(items, null, System.Threading.CancellationToken.None); + + Assert.True(parts.Count > 0, "Base Nest method should place parts"); + } + + [Fact] + public void BruteForceRunner_StillWorks() + { + var plate = new Plate(120, 60); + var drawing = MakeRectDrawing(20, 10); + + var result = OpenNest.Engine.ML.BruteForceRunner.Run(drawing, plate, forceFullAngleSweep: true); + + Assert.NotNull(result); + Assert.True(result.PartCount > 0); + } +} +``` + +- [ ] **Step 2: Run all smoke tests** + +Run: `dotnet test OpenNest.Tests --filter "EngineRefactorSmokeTests" -v n` +Expected: All 6 tests PASS. + +- [ ] **Step 3: Run the full test suite** + +Run: `dotnet test OpenNest.Tests -v n` +Expected: All tests PASS. + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Tests/EngineRefactorSmokeTests.cs +git commit -m "test(engine): add integration smoke tests for engine refactor" +``` + +--- + +### Task 11: Clean up unused imports + +**Files:** +- Modify: `OpenNest.Engine/DefaultNestEngine.cs` +- Modify: `OpenNest.Engine/StripNestEngine.cs` + +- [ ] **Step 1: Remove unused using statements from DefaultNestEngine** + +After extracting the pair/angle code, check which `using` statements are no longer needed. Likely candidates: +- `using OpenNest.Engine.BestFit;` — may still be needed by `QuickFillCount` +- `using OpenNest.Engine.ML;` — no longer needed (moved to AngleCandidateBuilder) + +Build to verify: `dotnet build OpenNest.sln` + +- [ ] **Step 2: Remove AccumulatingProgress nested class from StripNestEngine if still present** + +Verify `StripNestEngine.cs` no longer contains the `AccumulatingProgress` nested class definition. It should have been removed in Task 8. + +- [ ] **Step 3: Build and run full test suite** + +Run: `dotnet build OpenNest.sln && dotnet test OpenNest.Tests -v n` +Expected: Build succeeds, all tests PASS. + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Engine/DefaultNestEngine.cs OpenNest.Engine/StripNestEngine.cs +git commit -m "refactor(engine): clean up unused imports after extraction" +``` diff --git a/docs/superpowers/plans/2026-03-16-polylabel-part-labels.md b/docs/superpowers/plans/2026-03-16-polylabel-part-labels.md new file mode 100644 index 0000000..a716915 --- /dev/null +++ b/docs/superpowers/plans/2026-03-16-polylabel-part-labels.md @@ -0,0 +1,570 @@ +# Polylabel Part Label Positioning Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Position part ID labels at the visual center of each part using the polylabel (pole of inaccessibility) algorithm, so labels are readable and don't overlap adjacent parts. + +**Architecture:** Add a `PolyLabel` static class in `OpenNest.Geometry` that finds the point inside a polygon farthest from all edges (including holes). `LayoutPart` caches this point in program-local coordinates and transforms it each frame for rendering. + +**Tech Stack:** .NET 8, xUnit, WinForms (System.Drawing) + +**Spec:** `docs/superpowers/specs/2026-03-16-polylabel-part-labels-design.md` + +--- + +## Chunk 1: Polylabel Algorithm + +### Task 1: PolyLabel — square polygon test + implementation + +**Files:** +- Create: `OpenNest.Core/Geometry/PolyLabel.cs` +- Create: `OpenNest.Tests/PolyLabelTests.cs` + +- [ ] **Step 1: Write the failing test for a square polygon** + +```csharp +// OpenNest.Tests/PolyLabelTests.cs +using OpenNest.Geometry; + +namespace OpenNest.Tests; + +public class PolyLabelTests +{ + private static Polygon Square(double size) + { + var p = new Polygon(); + p.Vertices.Add(new Vector(0, 0)); + p.Vertices.Add(new Vector(size, 0)); + p.Vertices.Add(new Vector(size, size)); + p.Vertices.Add(new Vector(0, size)); + return p; + } + + [Fact] + public void Square_ReturnsCenterPoint() + { + var poly = Square(100); + + var result = PolyLabel.Find(poly); + + Assert.Equal(50, result.X, 1.0); + Assert.Equal(50, result.Y, 1.0); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test OpenNest.Tests --filter PolyLabelTests.Square_ReturnsCenterPoint` +Expected: FAIL — `PolyLabel` does not exist. + +- [ ] **Step 3: Implement PolyLabel.Find** + +```csharp +// OpenNest.Core/Geometry/PolyLabel.cs +using System; +using System.Collections.Generic; + +namespace OpenNest.Geometry +{ + public static class PolyLabel + { + public static Vector Find(Polygon outer, IList holes = null, double precision = 0.5) + { + if (outer.Vertices.Count < 3) + return outer.Vertices.Count > 0 + ? outer.Vertices[0] + : new Vector(); + + var minX = double.MaxValue; + var minY = double.MaxValue; + var maxX = double.MinValue; + var maxY = double.MinValue; + + for (var i = 0; i < outer.Vertices.Count; i++) + { + var v = outer.Vertices[i]; + if (v.X < minX) minX = v.X; + if (v.Y < minY) minY = v.Y; + if (v.X > maxX) maxX = v.X; + if (v.Y > maxY) maxY = v.Y; + } + + var width = maxX - minX; + var height = maxY - minY; + var cellSize = System.Math.Min(width, height); + + if (cellSize == 0) + return new Vector((minX + maxX) / 2, (minY + maxY) / 2); + + var halfCell = cellSize / 2; + + var queue = new List(); + + for (var x = minX; x < maxX; x += cellSize) + for (var y = minY; y < maxY; y += cellSize) + queue.Add(new Cell(x + halfCell, y + halfCell, halfCell, outer, holes)); + + queue.Sort((a, b) => b.MaxDist.CompareTo(a.MaxDist)); + + var bestCell = GetCentroidCell(outer, holes); + + for (var i = 0; i < queue.Count; i++) + if (queue[i].Dist > bestCell.Dist) + { + bestCell = queue[i]; + break; + } + + while (queue.Count > 0) + { + var cell = queue[0]; + queue.RemoveAt(0); + + if (cell.Dist > bestCell.Dist) + bestCell = cell; + + if (cell.MaxDist - bestCell.Dist <= precision) + continue; + + halfCell = cell.HalfSize / 2; + + var newCells = new[] + { + new Cell(cell.X - halfCell, cell.Y - halfCell, halfCell, outer, holes), + new Cell(cell.X + halfCell, cell.Y - halfCell, halfCell, outer, holes), + new Cell(cell.X - halfCell, cell.Y + halfCell, halfCell, outer, holes), + new Cell(cell.X + halfCell, cell.Y + halfCell, halfCell, outer, holes), + }; + + for (var i = 0; i < newCells.Length; i++) + { + if (newCells[i].MaxDist > bestCell.Dist + precision) + InsertSorted(queue, newCells[i]); + } + } + + return new Vector(bestCell.X, bestCell.Y); + } + + private static void InsertSorted(List list, Cell cell) + { + var idx = 0; + while (idx < list.Count && list[idx].MaxDist > cell.MaxDist) + idx++; + list.Insert(idx, cell); + } + + private static Cell GetCentroidCell(Polygon outer, IList holes) + { + var area = 0.0; + var cx = 0.0; + var cy = 0.0; + var verts = outer.Vertices; + + for (int i = 0, j = verts.Count - 1; i < verts.Count; j = i++) + { + var a = verts[i]; + var b = verts[j]; + var cross = a.X * b.Y - b.X * a.Y; + cx += (a.X + b.X) * cross; + cy += (a.Y + b.Y) * cross; + area += cross; + } + + area *= 0.5; + + if (System.Math.Abs(area) < 1e-10) + return new Cell(verts[0].X, verts[0].Y, 0, outer, holes); + + cx /= (6 * area); + cy /= (6 * area); + + return new Cell(cx, cy, 0, outer, holes); + } + + private static double PointToPolygonDist(double x, double y, Polygon polygon) + { + var minDist = double.MaxValue; + var verts = polygon.Vertices; + + for (int i = 0, j = verts.Count - 1; i < verts.Count; j = i++) + { + var a = verts[i]; + var b = verts[j]; + + var dx = b.X - a.X; + var dy = b.Y - a.Y; + + if (dx != 0 || dy != 0) + { + var t = ((x - a.X) * dx + (y - a.Y) * dy) / (dx * dx + dy * dy); + + if (t > 1) + { + a = b; + } + else if (t > 0) + { + a = new Vector(a.X + dx * t, a.Y + dy * t); + } + } + + var segDx = x - a.X; + var segDy = y - a.Y; + var dist = System.Math.Sqrt(segDx * segDx + segDy * segDy); + + if (dist < minDist) + minDist = dist; + } + + return minDist; + } + + private sealed class Cell + { + public readonly double X; + public readonly double Y; + public readonly double HalfSize; + public readonly double Dist; + public readonly double MaxDist; + + public Cell(double x, double y, double halfSize, Polygon outer, IList holes) + { + X = x; + Y = y; + HalfSize = halfSize; + + var pt = new Vector(x, y); + var inside = outer.ContainsPoint(pt); + + if (inside && holes != null) + { + for (var i = 0; i < holes.Count; i++) + { + if (holes[i].ContainsPoint(pt)) + { + inside = false; + break; + } + } + } + + Dist = PointToAllEdgesDist(x, y, outer, holes); + + if (!inside) + Dist = -Dist; + + MaxDist = Dist + HalfSize * System.Math.Sqrt(2); + } + } + + private static double PointToAllEdgesDist(double x, double y, Polygon outer, IList holes) + { + var minDist = PointToPolygonDist(x, y, outer); + + if (holes != null) + { + for (var i = 0; i < holes.Count; i++) + { + var d = PointToPolygonDist(x, y, holes[i]); + if (d < minDist) + minDist = d; + } + } + + return minDist; + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test OpenNest.Tests --filter PolyLabelTests.Square_ReturnsCenterPoint` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Core/Geometry/PolyLabel.cs OpenNest.Tests/PolyLabelTests.cs +git commit -m "feat(geometry): add PolyLabel algorithm with square test" +``` + +### Task 2: PolyLabel — additional shape tests + +**Files:** +- Modify: `OpenNest.Tests/PolyLabelTests.cs` + +- [ ] **Step 1: Add tests for L-shape, triangle, thin rectangle, C-shape, hole, and degenerate** + +```csharp +// Append to PolyLabelTests.cs + +[Fact] +public void Triangle_ReturnsIncenter() +{ + var p = new Polygon(); + p.Vertices.Add(new Vector(0, 0)); + p.Vertices.Add(new Vector(100, 0)); + p.Vertices.Add(new Vector(50, 86.6)); + + var result = PolyLabel.Find(p); + + // Incenter of equilateral triangle is at (50, ~28.9) + Assert.Equal(50, result.X, 1.0); + Assert.Equal(28.9, result.Y, 1.0); + Assert.True(p.ContainsPoint(result)); +} + +[Fact] +public void LShape_ReturnsPointInBottomLobe() +{ + // L-shape: 100x100 with 50x50 cut from top-right + var p = new Polygon(); + p.Vertices.Add(new Vector(0, 0)); + p.Vertices.Add(new Vector(100, 0)); + p.Vertices.Add(new Vector(100, 50)); + p.Vertices.Add(new Vector(50, 50)); + p.Vertices.Add(new Vector(50, 100)); + p.Vertices.Add(new Vector(0, 100)); + + var result = PolyLabel.Find(p); + + Assert.True(p.ContainsPoint(result)); + // The bottom 100x50 lobe is the widest region + Assert.True(result.Y < 50, $"Expected label in bottom lobe, got Y={result.Y}"); +} + +[Fact] +public void ThinRectangle_CenteredOnBothAxes() +{ + var p = new Polygon(); + p.Vertices.Add(new Vector(0, 0)); + p.Vertices.Add(new Vector(200, 0)); + p.Vertices.Add(new Vector(200, 10)); + p.Vertices.Add(new Vector(0, 10)); + + var result = PolyLabel.Find(p); + + Assert.Equal(100, result.X, 1.0); + Assert.Equal(5, result.Y, 1.0); + Assert.True(p.ContainsPoint(result)); +} + +[Fact] +public void SquareWithLargeHole_AvoidsHole() +{ + var outer = Square(100); + + var hole = new Polygon(); + hole.Vertices.Add(new Vector(20, 20)); + hole.Vertices.Add(new Vector(80, 20)); + hole.Vertices.Add(new Vector(80, 80)); + hole.Vertices.Add(new Vector(20, 80)); + + var result = PolyLabel.Find(outer, new[] { hole }); + + // Point should be inside outer but outside hole + Assert.True(outer.ContainsPoint(result)); + Assert.False(hole.ContainsPoint(result)); +} + +[Fact] +public void CShape_ReturnsPointInLeftBar() +{ + // C-shape opening to the right: left bar is 20 wide, top/bottom arms are 20 tall + var p = new Polygon(); + p.Vertices.Add(new Vector(0, 0)); + p.Vertices.Add(new Vector(100, 0)); + p.Vertices.Add(new Vector(100, 20)); + p.Vertices.Add(new Vector(20, 20)); + p.Vertices.Add(new Vector(20, 80)); + p.Vertices.Add(new Vector(100, 80)); + p.Vertices.Add(new Vector(100, 100)); + p.Vertices.Add(new Vector(0, 100)); + + var result = PolyLabel.Find(p); + + Assert.True(p.ContainsPoint(result)); + // Label should be in the left vertical bar (x < 20), not at bbox center (50, 50) + Assert.True(result.X < 20, $"Expected label in left bar, got X={result.X}"); +} + +[Fact] +public void DegeneratePolygon_ReturnsFallback() +{ + var p = new Polygon(); + p.Vertices.Add(new Vector(5, 5)); + + var result = PolyLabel.Find(p); + + Assert.Equal(5, result.X, 0.01); + Assert.Equal(5, result.Y, 0.01); +} +``` + +- [ ] **Step 2: Run all PolyLabel tests** + +Run: `dotnet test OpenNest.Tests --filter PolyLabelTests` +Expected: All PASS + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Tests/PolyLabelTests.cs +git commit -m "test(geometry): add PolyLabel tests for L, C, triangle, thin rect, hole" +``` + +## Chunk 2: Label Rendering + +### Task 3: Update LayoutPart label positioning + +**Files:** +- Modify: `OpenNest/LayoutPart.cs` + +- [ ] **Step 1: Add cached label point field and computation method** + +Add a `Vector? _labelPoint` field and a method to compute it from the part's geometry. Uses `ShapeProfile` to identify the outer contour and holes. + +```csharp +// Add field near the top of the class (after the existing private fields): +private Vector? _labelPoint; + +// Add method: +private Vector ComputeLabelPoint() +{ + var entities = ConvertProgram.ToGeometry(BasePart.Program); + var nonRapid = entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList(); + + if (nonRapid.Count == 0) + { + var bbox = BasePart.Program.BoundingBox(); + return new Vector(bbox.Location.X + bbox.Width / 2, bbox.Location.Y + bbox.Length / 2); + } + + var profile = new ShapeProfile(nonRapid); + var outer = profile.Perimeter.ToPolygonWithTolerance(0.1); + + List holes = null; + + if (profile.Cutouts.Count > 0) + { + holes = new List(); + foreach (var cutout in profile.Cutouts) + holes.Add(cutout.ToPolygonWithTolerance(0.1)); + } + + return PolyLabel.Find(outer, holes); +} +``` + +- [ ] **Step 2: Invalidate the cache when IsDirty is set** + +Modify the `IsDirty` property to clear `_labelPoint`: + +```csharp +// Replace: +internal bool IsDirty { get; set; } + +// With: +private bool _isDirty; +internal bool IsDirty +{ + get => _isDirty; + set + { + _isDirty = value; + if (value) _labelPoint = null; + } +} +``` + +- [ ] **Step 3: Add screen-space label point field and compute it in Update()** + +Compute the polylabel in program-local coordinates (cached, expensive) and transform to screen space in `Update()` (cheap, runs on every zoom/pan). + +```csharp +// Add field: +private PointF _labelScreenPoint; + +// Replace existing Update(): +public void Update(DrawControl plateView) +{ + Path = GraphicsHelper.GetGraphicsPath(BasePart.Program, BasePart.Location); + Path.Transform(plateView.Matrix); + + _labelPoint ??= ComputeLabelPoint(); + var labelPt = new PointF( + (float)(_labelPoint.Value.X + BasePart.Location.X), + (float)(_labelPoint.Value.Y + BasePart.Location.Y)); + var pts = new[] { labelPt }; + plateView.Matrix.TransformPoints(pts); + _labelScreenPoint = pts[0]; + + IsDirty = false; +} +``` + +Note: setting `IsDirty = false` at the end of `Update()` will NOT clear `_labelPoint` because the setter only clears when `value` is `true`. + +- [ ] **Step 4: Update Draw(Graphics g, string id) to use the cached screen point** + +```csharp +// Replace the existing Draw(Graphics g, string id) method body. +// Old code (lines 85-101 of LayoutPart.cs): +// if (IsSelected) { ... } else { ... } +// var pt = Path.PointCount > 0 ? Path.PathPoints[0] : PointF.Empty; +// g.DrawString(id, programIdFont, Brushes.Black, pt.X, pt.Y); + +// New code: +public void Draw(Graphics g, string id) +{ + if (IsSelected) + { + g.FillPath(selectedBrush, Path); + g.DrawPath(selectedPen, Path); + } + else + { + g.FillPath(brush, Path); + g.DrawPath(pen, Path); + } + + using var sf = new StringFormat + { + Alignment = StringAlignment.Center, + LineAlignment = StringAlignment.Center + }; + g.DrawString(id, programIdFont, Brushes.Black, _labelScreenPoint.X, _labelScreenPoint.Y, sf); +} +``` + +- [ ] **Step 5: Build and verify** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeds with no errors. + +- [ ] **Step 6: Commit** + +```bash +git add OpenNest/LayoutPart.cs +git commit -m "feat(ui): position part labels at polylabel center" +``` + +### Task 4: Manual visual verification + +- [ ] **Step 1: Run the application and verify labels** + +Run the OpenNest application, load a nest with multiple parts, and verify: +- Labels appear centered inside each part. +- Labels don't overlap adjacent part edges. +- Labels stay centered when zooming and panning. +- Parts with holes have labels placed in the solid material, not in the hole. + +- [ ] **Step 2: Run all tests** + +Run: `dotnet test OpenNest.Tests` +Expected: All tests pass. + +- [ ] **Step 3: Final commit if any tweaks needed** diff --git a/docs/superpowers/specs/2026-03-16-engine-refactor-design.md b/docs/superpowers/specs/2026-03-16-engine-refactor-design.md new file mode 100644 index 0000000..89b7261 --- /dev/null +++ b/docs/superpowers/specs/2026-03-16-engine-refactor-design.md @@ -0,0 +1,197 @@ +# Engine Refactor: Extract Shared Algorithms from DefaultNestEngine and StripNestEngine + +## Problem + +`DefaultNestEngine` (~550 lines) mixes phase orchestration with strategy-specific logic (pair candidate selection, angle building, pattern helpers). `StripNestEngine` (~450 lines) duplicates patterns that DefaultNestEngine also uses: shrink-to-fit loops, iterative remnant filling, and progress accumulation. Both engines would benefit from extracting shared algorithms into focused, reusable classes. + +## Approach + +Extract five classes from the two engines. No new interfaces or strategy patterns — just focused helper classes that each engine composes. + +## Extracted Classes + +### 1. PairFiller + +**Source:** DefaultNestEngine lines 362-489 (`FillWithPairs`, `SelectPairCandidates`, `BuildRemainderPatterns`, `MinPairCandidates`, `PairTimeLimit`). + +**API:** +```csharp +public class PairFiller +{ + public PairFiller(Size plateSize, double partSpacing) { } + + public List Fill(NestItem item, Box workArea, + int plateNumber = 0, + CancellationToken token = default, + IProgress progress = null); +} +``` + +**Details:** +- Constructor takes plate size and spacing — decoupled from `Plate` object. +- `SelectPairCandidates` and `BuildRemainderPatterns` become private methods. +- Uses `BestFitCache.GetOrCompute()` internally (same as today). +- Calls `BuildRotatedPattern` and `FillPattern` — these become `internal static` methods on DefaultNestEngine so PairFiller can call them without ceremony. +- Returns `List` (empty list if no result), same contract as today. +- Progress reporting: PairFiller accepts `IProgress` and `int plateNumber` in its `Fill` method to maintain per-candidate progress updates. The caller passes these through from the engine. + +**Caller:** `DefaultNestEngine.FindBestFill` replaces `this.FillWithPairs(...)` with `new PairFiller(Plate.Size, Plate.PartSpacing).Fill(...)`. + +### 2. AngleCandidateBuilder + +**Source:** DefaultNestEngine lines 279-347 (`BuildCandidateAngles`, `knownGoodAngles` HashSet, `ForceFullAngleSweep` property). + +**API:** +```csharp +public class AngleCandidateBuilder +{ + public bool ForceFullSweep { get; set; } + + public List Build(NestItem item, double bestRotation, Box workArea); + + public void RecordProductive(List angleResults); +} +``` + +**Details:** +- Owns `knownGoodAngles` state — lives as long as the engine instance so pruning accumulates across fills. +- `Build()` encapsulates the full pipeline: base angles, sweep check, ML prediction, known-good pruning. +- `RecordProductive()` replaces the inline loop that feeds `knownGoodAngles` after the linear phase. +- `ForceFullAngleSweep` moves from DefaultNestEngine to `AngleCandidateBuilder.ForceFullSweep`. DefaultNestEngine keeps a forwarding property `ForceFullAngleSweep` that delegates to its `AngleCandidateBuilder` instance, so `BruteForceRunner` (which sets `engine.ForceFullAngleSweep = true`) continues to work without changes. + +**Caller:** DefaultNestEngine creates one `AngleCandidateBuilder` instance as a field and calls `Build()`/`RecordProductive()` from `FindBestFill`. + +### 3. ShrinkFiller + +**Source:** StripNestEngine `TryOrientation` shrink loop (lines 188-215) and `ShrinkFill` (lines 358-418). + +**API:** +```csharp +public static class ShrinkFiller +{ + public static ShrinkResult Shrink( + Func> fillFunc, + NestItem item, Box box, + double spacing, + ShrinkAxis axis, + CancellationToken token = default, + int maxIterations = 20); +} + +public enum ShrinkAxis { Width, Height } + +public class ShrinkResult +{ + public List Parts { get; set; } + public double Dimension { get; set; } +} +``` + +**Details:** +- `fillFunc` delegate decouples ShrinkFiller from any specific engine — the caller provides how to fill. +- `ShrinkAxis` determines which dimension to reduce. `TryOrientation` maps strip direction to axis: `StripDirection.Bottom` → `ShrinkAxis.Height`, `StripDirection.Left` → `ShrinkAxis.Width`. `ShrinkFill` calls `Shrink` twice (width then height). +- Loop logic: fill initial box, measure placed bounding box, reduce dimension by `spacing`, retry until count drops below initial count. Dimension is measured as `placedBox.Right - box.X` for Width or `placedBox.Top - box.Y` for Height. +- Returns both the best parts and the final tight dimension (needed by `TryOrientation` to compute the remnant box). +- **Two-axis independence:** When `ShrinkFill` calls `Shrink` twice, each axis shrinks against the **original** box dimensions, not the result of the prior axis. This preserves the current behavior where width and height are shrunk independently. + +**Callers:** +- `StripNestEngine.TryOrientation` replaces its inline shrink loop. +- `StripNestEngine.ShrinkFill` replaces its two-axis inline shrink loops. + +### 4. RemnantFiller + +**Source:** StripNestEngine remnant loop (lines 253-343) and the simpler version in NestEngineBase.Nest (lines 74-97). + +**API:** +```csharp +public class RemnantFiller +{ + public RemnantFiller(Box workArea, double spacing) { } + + public void AddObstacles(IEnumerable parts); + + public List FillItems( + List items, + Func> fillFunc, + CancellationToken token = default, + IProgress progress = null); +} +``` + +**Details:** +- Owns a `RemnantFinder` instance internally. +- `AddObstacles` registers already-placed parts (bounding boxes offset by spacing). +- `FillItems` runs the iterative loop: find remnants, try each item in each remnant, fill, update obstacles, repeat until no progress. +- Local quantity tracking (dictionary keyed by drawing name) stays internal — does not mutate the input `NestItem` quantities. Returns the placed parts; the caller deducts quantities. +- Uses minimum-remnant-size filtering (smallest remaining part dimension), same as StripNestEngine today. +- `fillFunc` delegate allows callers to provide any fill strategy (DefaultNestEngine.Fill, ShrinkFill, etc.). + +**Callers:** +- `StripNestEngine.TryOrientation` replaces its inline remnant loop with `RemnantFiller.FillItems(...)`. +- `NestEngineBase.Nest` replaces its hand-rolled largest-remnant loop. **Note:** This is a deliberate behavioral improvement — the base class currently uses only the single largest remnant, while `RemnantFiller` tries all remnants iteratively with minimum-size filtering. This may produce better fill results for engines that rely on the base `Nest` method. + +**Unchanged:** `NestEngineBase.Nest` phase 2 (bin-packing single-quantity items via `PackArea`, lines 100-119) is not affected by this change. + +### 5. AccumulatingProgress + +**Source:** StripNestEngine nested class (lines 425-449). + +**API:** +```csharp +internal class AccumulatingProgress : IProgress +{ + public AccumulatingProgress(IProgress inner, List previousParts) { } + public void Report(NestProgress value); +} +``` + +**Details:** +- Moved from private nested class to standalone `internal` class in OpenNest.Engine. +- No behavioral change — wraps an `IProgress` and prepends previously placed parts to each report. + +## What Stays on Each Engine + +### DefaultNestEngine (~200 lines after extraction) + +- `Fill(NestItem, Box, ...)` — public entry point, unchanged. +- `Fill(List, Box, ...)` — group-parts overload, unchanged. +- `PackArea` — bin packing delegation, unchanged. +- `FindBestFill` — orchestration, now ~30 lines: calls `AngleCandidateBuilder.Build()`, `PairFiller.Fill()`, linear angle loop, `FillRectangleBestFit`, picks best. +- `FillRectangleBestFit` — 6-line private method, too small to extract. +- `BuildRotatedPattern` / `FillPattern` — become `internal static`, used by both the linear loop and PairFiller. +- `QuickFillCount` — stays (used by binary search, not shared). + +### StripNestEngine (~200 lines after extraction) + +- `Nest` — orchestration, unchanged. +- `TryOrientation` — becomes thinner: calls `DefaultNestEngine.Fill` for initial fill, `ShrinkFiller.Shrink()` for tightening, `RemnantFiller.FillItems()` for remnants. +- `ShrinkFill` — replaced by two `ShrinkFiller.Shrink()` calls. +- `SelectStripItemIndex` / `EstimateStripDimension` — stay private, strip-specific. +- `AccumulatingProgress` — removed, uses shared class. + +### NestEngineBase + +- `Nest` — switches from hand-rolled remnant loop to `RemnantFiller.FillItems()`. +- All other methods unchanged. + +## File Layout + +All new classes go in `OpenNest.Engine/`: + +``` +OpenNest.Engine/ + PairFiller.cs + AngleCandidateBuilder.cs + ShrinkFiller.cs + RemnantFiller.cs + AccumulatingProgress.cs +``` + +## Non-Goals + +- No new interfaces or strategy patterns. +- No changes to FillLinear, FillBestFit, PackBottomLeft, or any other existing algorithm. +- No changes to NestEngineRegistry or the plugin system. +- No changes to public API surface — all existing callers continue to work unchanged. One deliberate behavioral improvement: `NestEngineBase.Nest` gains multi-remnant filling (see RemnantFiller section). +- PatternHelper extraction deferred — `BuildRotatedPattern`/`FillPattern` become `internal static` on DefaultNestEngine for now. Extract if a third consumer appears. +- StripNestEngine continues to create fresh `DefaultNestEngine` instances per fill call. Sharing an `AngleCandidateBuilder` across sub-fills to enable angle pruning is a potential future optimization, not part of this refactor. diff --git a/docs/superpowers/specs/2026-03-16-polylabel-part-labels-design.md b/docs/superpowers/specs/2026-03-16-polylabel-part-labels-design.md new file mode 100644 index 0000000..b098093 --- /dev/null +++ b/docs/superpowers/specs/2026-03-16-polylabel-part-labels-design.md @@ -0,0 +1,82 @@ +# Polylabel Part Label Positioning + +**Date:** 2026-03-16 +**Status:** Approved + +## Problem + +Part ID labels in `PlateView` are drawn at `PathPoints[0]` — the first point of the graphics path, which sits on the part contour edge. This causes labels to overlap adjacent parts and be unreadable, especially in dense nests. + +## Solution + +Implement the polylabel algorithm (pole of inaccessibility) to find the point inside each part's polygon with maximum distance from all edges, including hole edges. Draw the part ID label centered on that point. + +## Design + +### Part 1: Polylabel Algorithm + +Add `PolyLabel` static class in `OpenNest.Geometry` namespace (file: `OpenNest.Core/Geometry/PolyLabel.cs`). + +**Public API:** + +```csharp +public static class PolyLabel +{ + public static Vector Find(Polygon outer, IList holes = null, double precision = 0.5); +} +``` + +**Algorithm:** + +1. Compute bounding box of the outer polygon. +2. Divide into a grid of cells (cell size = shorter bbox dimension). +3. For each cell, compute signed distance from cell center to nearest edge on any ring (outer boundary + all holes). Use `Polygon.ContainsPoint` for sign (negative if outside outer polygon or inside a hole). +4. Track the best interior point found so far. +5. Use a priority queue (sorted list) ordered by maximum possible distance for each cell. +6. Subdivide promising cells that could beat the current best; discard the rest. +7. Stop when the best cell's potential improvement over the current best is less than the precision tolerance. + +**Dependencies within codebase:** + +- `Polygon.ContainsPoint(Vector)` — ray-casting point-in-polygon test (already exists). +- Point-to-segment distance — compute from `Line` or inline (distance from point to each polygon edge). + +**Fallback:** If the polygon is degenerate (< 3 vertices) or the program has no geometry, fall back to the bounding box center. + +**No external dependencies.** + +### Part 2: Label Rendering in LayoutPart + +Modify `LayoutPart` in `OpenNest/LayoutPart.cs`. + +**Changes:** + +1. Add a cached `Vector? _labelPoint` field in **program-local coordinates** (pre-transform). Invalidated when `IsDirty` is set. +2. When computing the label point (on first draw after invalidation): + - Convert the part's `Program` to geometry via `ConvertProgram.ToGeometry`. + - Build shapes via `ShapeBuilder.GetShapes`. + - Identify the outer contour using `ShapeProfile` (the `Perimeter` shape) and convert cutouts to hole polygons. + - Run `PolyLabel.Find(outer, holes)` on the result. + - Cache the `Vector` in program-local coordinates. +3. In `Draw(Graphics g, string id)`: + - Offset the cached label point by `BasePart.Location`. + - Transform through the current view matrix (handles zoom/pan without cache invalidation). + - Draw the ID string centered using `StringFormat` with `Alignment = Center` and `LineAlignment = Center`. + +**Coordinate pipeline:** polylabel runs once in program-local coordinates (expensive, cached). Location offset + matrix transform happen every frame (cheap, no caching needed). This matches how the existing `GraphicsPath` pipeline works and avoids stale cache on zoom/pan. + +## Scope + +- **In scope:** polylabel algorithm, label positioning change in `LayoutPart.Draw`. +- **Out of scope:** changing part origins, modifying the nesting engine, any changes to `Part`, `Drawing`, or `Program` classes. + +## Testing + +- Unit tests for `PolyLabel.Find()` with known polygons: + - Square — label at center. + - L-shape — label in the larger lobe. + - C-shape — label inside the concavity, not at bounding box center. + - Triangle — label at incenter. + - Thin rectangle (10:1 aspect ratio) — label centered along the short axis. + - Square with large centered hole — label avoids the hole. +- Verify the returned point is inside the polygon and has the expected distance from edges.