Compare commits
35 Commits
48d4220199
...
195e29da52
| Author | SHA1 | Date | |
|---|---|---|---|
| 195e29da52 | |||
| 0b9a42e84c | |||
| 00ccf82196 | |||
| a41a08c9af | |||
| 3d23943b69 | |||
| 51b482aefb | |||
| 6419f6b8a2 | |||
| 4911d05869 | |||
| 2b4f7c4e80 | |||
| 2c62f601ca | |||
| 2bda7c9f0f | |||
| 9d99e3a003 | |||
| b42348665f | |||
| 4d30178752 | |||
| 2b578fa006 | |||
| 78c625361e | |||
| dd3a2b0e9a | |||
| 9b21a0c6d7 | |||
| 5b9e6c28e4 | |||
| ecdf571c71 | |||
| f5ab070453 | |||
| 5873bff48b | |||
| 190f2a062f | |||
| 384d53da47 | |||
| 1b62f7af04 | |||
| 13264a2f8d | |||
| 9df42d26de | |||
| 9daa768629 | |||
| 3592a4ce59 | |||
| e746afb57f | |||
| 0c98b240c3 | |||
| 56c9b17ff6 | |||
| c4d09f2466 | |||
| bbc3466bc8 | |||
| c18259a348 |
@@ -474,86 +474,5 @@ namespace OpenNest
|
||||
return pts.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds rectangular remnant (empty) regions on the plate.
|
||||
/// Returns strips along edges that are clear of parts.
|
||||
/// </summary>
|
||||
public List<Box> GetRemnants()
|
||||
{
|
||||
var work = WorkArea();
|
||||
var results = new List<Box>();
|
||||
|
||||
if (Parts.Count == 0)
|
||||
{
|
||||
results.Add(work);
|
||||
return results;
|
||||
}
|
||||
|
||||
var obstacles = new List<Box>();
|
||||
foreach (var part in Parts)
|
||||
obstacles.Add(part.BoundingBox.Offset(PartSpacing));
|
||||
|
||||
// Right strip: from the rightmost part edge to the work area right edge
|
||||
var maxRight = double.MinValue;
|
||||
foreach (var box in obstacles)
|
||||
{
|
||||
if (box.Right > maxRight)
|
||||
maxRight = box.Right;
|
||||
}
|
||||
|
||||
if (maxRight < work.Right)
|
||||
{
|
||||
var strip = new Box(maxRight, work.Bottom, work.Right - maxRight, work.Length);
|
||||
if (strip.Area() > 1.0)
|
||||
results.Add(strip);
|
||||
}
|
||||
|
||||
// Top strip: from the topmost part edge to the work area top edge
|
||||
var maxTop = double.MinValue;
|
||||
foreach (var box in obstacles)
|
||||
{
|
||||
if (box.Top > maxTop)
|
||||
maxTop = box.Top;
|
||||
}
|
||||
|
||||
if (maxTop < work.Top)
|
||||
{
|
||||
var strip = new Box(work.Left, maxTop, work.Width, work.Top - maxTop);
|
||||
if (strip.Area() > 1.0)
|
||||
results.Add(strip);
|
||||
}
|
||||
|
||||
// Bottom strip: from work area bottom to the lowest part edge
|
||||
var minBottom = double.MaxValue;
|
||||
foreach (var box in obstacles)
|
||||
{
|
||||
if (box.Bottom < minBottom)
|
||||
minBottom = box.Bottom;
|
||||
}
|
||||
|
||||
if (minBottom > work.Bottom)
|
||||
{
|
||||
var strip = new Box(work.Left, work.Bottom, work.Width, minBottom - work.Bottom);
|
||||
if (strip.Area() > 1.0)
|
||||
results.Add(strip);
|
||||
}
|
||||
|
||||
// Left strip: from work area left to the leftmost part edge
|
||||
var minLeft = double.MaxValue;
|
||||
foreach (var box in obstacles)
|
||||
{
|
||||
if (box.Left < minLeft)
|
||||
minLeft = box.Left;
|
||||
}
|
||||
|
||||
if (minLeft > work.Left)
|
||||
{
|
||||
var strip = new Box(work.Left, work.Bottom, minLeft - work.Left, work.Length);
|
||||
if (strip.Area() > 1.0)
|
||||
results.Add(strip);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace OpenNest
|
||||
|
||||
public override string Name => "Default";
|
||||
|
||||
public override string Description => "Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)";
|
||||
public override string Description => "Multi-phase nesting (Linear, Pairs, RectBestFit)";
|
||||
|
||||
public bool ForceFullAngleSweep { get; set; }
|
||||
|
||||
@@ -35,23 +35,6 @@ namespace OpenNest
|
||||
AngleResults.Clear();
|
||||
var best = FindBestFill(item, workArea, progress, token);
|
||||
|
||||
if (!token.IsCancellationRequested)
|
||||
{
|
||||
// Try improving by filling the remainder strip separately.
|
||||
var remainderSw = Stopwatch.StartNew();
|
||||
var improved = TryRemainderImprovement(item, workArea, best);
|
||||
remainderSw.Stop();
|
||||
|
||||
if (IsBetterFill(improved, best, workArea))
|
||||
{
|
||||
Debug.WriteLine($"[Fill] Remainder improvement: {improved.Count} parts (was {best?.Count ?? 0})");
|
||||
best = improved;
|
||||
WinnerPhase = NestPhase.Remainder;
|
||||
PhaseResults.Add(new PhaseResult(NestPhase.Remainder, improved.Count, remainderSw.ElapsedMilliseconds));
|
||||
ReportProgress(progress, NestPhase.Remainder, PlateNumber, best, workArea, BuildProgressSummary());
|
||||
}
|
||||
}
|
||||
|
||||
if (best == null || best.Count == 0)
|
||||
return new List<Part>();
|
||||
|
||||
@@ -161,17 +144,6 @@ namespace OpenNest
|
||||
best = pairResult;
|
||||
ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea, BuildProgressSummary());
|
||||
}
|
||||
|
||||
// Try improving by filling the remainder strip separately.
|
||||
var improved = TryRemainderImprovement(nestItem, workArea, best);
|
||||
|
||||
if (IsBetterFill(improved, best, workArea))
|
||||
{
|
||||
Debug.WriteLine($"[Fill(groupParts,Box)] Remainder improvement: {improved.Count} parts (was {best?.Count ?? 0})");
|
||||
best = improved;
|
||||
PhaseResults.Add(new PhaseResult(NestPhase.Remainder, improved.Count, 0));
|
||||
ReportProgress(progress, NestPhase.Remainder, PlateNumber, best, workArea, BuildProgressSummary());
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -453,7 +425,7 @@ namespace OpenNest
|
||||
Debug.WriteLine("[FillWithPairs] Cancelled mid-phase, using results so far");
|
||||
}
|
||||
|
||||
Debug.WriteLine($"[FillWithPairs] Best pair result: {bestScore.Count} parts, remnant={bestScore.UsableRemnantArea:F1}, density={bestScore.Density:P1}");
|
||||
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 { }
|
||||
@@ -558,140 +530,5 @@ namespace OpenNest
|
||||
return best;
|
||||
}
|
||||
|
||||
// --- Remainder improvement ---
|
||||
|
||||
private List<Part> TryRemainderImprovement(NestItem item, Box workArea, List<Part> currentBest)
|
||||
{
|
||||
if (currentBest == null || currentBest.Count < 3)
|
||||
return null;
|
||||
|
||||
List<Part> best = null;
|
||||
|
||||
var hResult = TryStripRefill(item, workArea, currentBest, horizontal: true);
|
||||
|
||||
if (IsBetterFill(hResult, best, workArea))
|
||||
best = hResult;
|
||||
|
||||
var vResult = TryStripRefill(item, workArea, currentBest, horizontal: false);
|
||||
|
||||
if (IsBetterFill(vResult, best, workArea))
|
||||
best = vResult;
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private List<Part> TryStripRefill(NestItem item, Box workArea, List<Part> parts, bool horizontal)
|
||||
{
|
||||
if (parts == null || parts.Count < 3)
|
||||
return null;
|
||||
|
||||
var clusters = ClusterParts(parts, horizontal);
|
||||
|
||||
if (clusters.Count < 2)
|
||||
return null;
|
||||
|
||||
// Determine the mode (most common) cluster count, excluding the last cluster.
|
||||
var mainClusters = clusters.Take(clusters.Count - 1).ToList();
|
||||
var modeCount = mainClusters
|
||||
.GroupBy(c => c.Count)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.First()
|
||||
.Key;
|
||||
|
||||
var lastCluster = clusters[clusters.Count - 1];
|
||||
|
||||
// Only attempt refill if the last cluster is smaller than the mode.
|
||||
if (lastCluster.Count >= modeCount)
|
||||
return null;
|
||||
|
||||
Debug.WriteLine($"[TryStripRefill] {(horizontal ? "H" : "V")} clusters: {clusters.Count}, mode: {modeCount}, last: {lastCluster.Count}");
|
||||
|
||||
// Build the main parts list (everything except the last cluster).
|
||||
var mainParts = clusters.Take(clusters.Count - 1).SelectMany(c => c).ToList();
|
||||
var mainBox = ((IEnumerable<IBoundable>)mainParts).GetBoundingBox();
|
||||
|
||||
// Compute the strip box from the main grid edge to the work area edge.
|
||||
Box stripBox;
|
||||
|
||||
if (horizontal)
|
||||
{
|
||||
var stripLeft = mainBox.Right + Plate.PartSpacing;
|
||||
var stripWidth = workArea.Right - stripLeft;
|
||||
|
||||
if (stripWidth <= 0)
|
||||
return null;
|
||||
|
||||
stripBox = new Box(stripLeft, workArea.Y, stripWidth, workArea.Length);
|
||||
}
|
||||
else
|
||||
{
|
||||
var stripBottom = mainBox.Top + Plate.PartSpacing;
|
||||
var stripHeight = workArea.Top - stripBottom;
|
||||
|
||||
if (stripHeight <= 0)
|
||||
return null;
|
||||
|
||||
stripBox = new Box(workArea.X, stripBottom, workArea.Width, stripHeight);
|
||||
}
|
||||
|
||||
Debug.WriteLine($"[TryStripRefill] Strip: {stripBox.Width:F1}x{stripBox.Length:F1} at ({stripBox.X:F1},{stripBox.Y:F1})");
|
||||
|
||||
var stripParts = FindBestFill(item, stripBox);
|
||||
|
||||
if (stripParts == null || stripParts.Count <= lastCluster.Count)
|
||||
{
|
||||
Debug.WriteLine($"[TryStripRefill] No improvement: strip={stripParts?.Count ?? 0} vs oddball={lastCluster.Count}");
|
||||
return null;
|
||||
}
|
||||
|
||||
Debug.WriteLine($"[TryStripRefill] Improvement: strip={stripParts.Count} vs oddball={lastCluster.Count}");
|
||||
|
||||
var combined = new List<Part>(mainParts.Count + stripParts.Count);
|
||||
combined.AddRange(mainParts);
|
||||
combined.AddRange(stripParts);
|
||||
return combined;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Groups parts into positional clusters along the given axis.
|
||||
/// Parts whose center positions are separated by more than half
|
||||
/// the part dimension start a new cluster.
|
||||
/// </summary>
|
||||
private static List<List<Part>> ClusterParts(List<Part> parts, bool horizontal)
|
||||
{
|
||||
var sorted = horizontal
|
||||
? parts.OrderBy(p => p.BoundingBox.Center.X).ToList()
|
||||
: parts.OrderBy(p => p.BoundingBox.Center.Y).ToList();
|
||||
|
||||
var refDim = horizontal
|
||||
? sorted.Max(p => p.BoundingBox.Width)
|
||||
: sorted.Max(p => p.BoundingBox.Length);
|
||||
var gapThreshold = refDim * 0.5;
|
||||
|
||||
var clusters = new List<List<Part>>();
|
||||
var current = new List<Part> { sorted[0] };
|
||||
|
||||
for (var i = 1; i < sorted.Count; i++)
|
||||
{
|
||||
var prevCenter = horizontal
|
||||
? sorted[i - 1].BoundingBox.Center.X
|
||||
: sorted[i - 1].BoundingBox.Center.Y;
|
||||
var currCenter = horizontal
|
||||
? sorted[i].BoundingBox.Center.X
|
||||
: sorted[i].BoundingBox.Center.Y;
|
||||
|
||||
if (currCenter - prevCenter > gapThreshold)
|
||||
{
|
||||
clusters.Add(current);
|
||||
current = new List<Part>();
|
||||
}
|
||||
|
||||
current.Add(sorted[i]);
|
||||
}
|
||||
|
||||
clusters.Add(current);
|
||||
return clusters;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,20 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public readonly struct FillScore : System.IComparable<FillScore>
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimum short-side dimension for a remnant to be considered usable.
|
||||
/// </summary>
|
||||
public const double MinRemnantDimension = 12.0;
|
||||
|
||||
public int Count { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Area of the largest remnant whose short side >= MinRemnantDimension.
|
||||
/// Zero if no usable remnant exists.
|
||||
/// </summary>
|
||||
public double UsableRemnantArea { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Total part area / bounding box area of all placed parts.
|
||||
/// </summary>
|
||||
public double Density { get; }
|
||||
|
||||
public FillScore(int count, double usableRemnantArea, double density)
|
||||
public FillScore(int count, double density)
|
||||
{
|
||||
Count = count;
|
||||
UsableRemnantArea = usableRemnantArea;
|
||||
Density = density;
|
||||
}
|
||||
|
||||
@@ -60,50 +46,16 @@ namespace OpenNest
|
||||
var bboxArea = (maxX - minX) * (maxY - minY);
|
||||
var density = bboxArea > 0 ? totalPartArea / bboxArea : 0;
|
||||
|
||||
var usableRemnantArea = ComputeUsableRemnantArea(maxX, maxY, workArea);
|
||||
|
||||
return new FillScore(parts.Count, usableRemnantArea, density);
|
||||
}
|
||||
|
||||
private static double ComputeUsableRemnantArea(double maxRight, double maxTop, Box workArea)
|
||||
{
|
||||
var largest = 0.0;
|
||||
|
||||
// Right strip
|
||||
if (maxRight < workArea.Right)
|
||||
{
|
||||
var width = workArea.Right - maxRight;
|
||||
var height = workArea.Length;
|
||||
|
||||
if (System.Math.Min(width, height) >= MinRemnantDimension)
|
||||
largest = System.Math.Max(largest, width * height);
|
||||
}
|
||||
|
||||
// Top strip
|
||||
if (maxTop < workArea.Top)
|
||||
{
|
||||
var width = workArea.Width;
|
||||
var height = workArea.Top - maxTop;
|
||||
|
||||
if (System.Math.Min(width, height) >= MinRemnantDimension)
|
||||
largest = System.Math.Max(largest, width * height);
|
||||
}
|
||||
|
||||
return largest;
|
||||
return new FillScore(parts.Count, density);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lexicographic comparison: count, then usable remnant area, then density.
|
||||
/// Lexicographic comparison: count, then density.
|
||||
/// </summary>
|
||||
public int CompareTo(FillScore other)
|
||||
{
|
||||
var c = Count.CompareTo(other.Count);
|
||||
|
||||
if (c != 0)
|
||||
return c;
|
||||
|
||||
c = UsableRemnantArea.CompareTo(other.UsableRemnantArea);
|
||||
|
||||
if (c != 0)
|
||||
return c;
|
||||
|
||||
|
||||
@@ -88,8 +88,12 @@ namespace OpenNest
|
||||
{
|
||||
allParts.AddRange(parts);
|
||||
item.Quantity = System.Math.Max(0, item.Quantity - parts.Count);
|
||||
var placedBox = parts.Cast<IBoundable>().GetBoundingBox();
|
||||
workArea = ComputeRemainderWithin(workArea, placedBox, Plate.PartSpacing);
|
||||
var placedObstacles = parts.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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,21 +121,6 @@ namespace OpenNest
|
||||
return allParts;
|
||||
}
|
||||
|
||||
protected static Box ComputeRemainderWithin(Box workArea, Box usedBox, double spacing)
|
||||
{
|
||||
var hWidth = workArea.Right - usedBox.Right - spacing;
|
||||
var hStrip = hWidth > 0
|
||||
? new Box(usedBox.Right + spacing, workArea.Y, hWidth, workArea.Length)
|
||||
: Box.Empty;
|
||||
|
||||
var vHeight = workArea.Top - usedBox.Top - spacing;
|
||||
var vStrip = vHeight > 0
|
||||
? new Box(workArea.X, usedBox.Top + spacing, workArea.Width, vHeight)
|
||||
: Box.Empty;
|
||||
|
||||
return hStrip.Area() >= vStrip.Area() ? hStrip : vStrip;
|
||||
}
|
||||
|
||||
// --- FillExact (non-virtual, delegates to virtual Fill) ---
|
||||
|
||||
public List<Part> FillExact(NestItem item, Box workArea,
|
||||
@@ -229,9 +218,9 @@ namespace OpenNest
|
||||
NestedWidth = bounds.Width,
|
||||
NestedLength = bounds.Length,
|
||||
NestedArea = totalPartArea,
|
||||
UsableRemnantArea = workArea.Area() - totalPartArea,
|
||||
BestParts = clonedParts,
|
||||
Description = description
|
||||
Description = description,
|
||||
ActiveWorkArea = workArea,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -311,7 +300,6 @@ namespace OpenNest
|
||||
case NestPhase.Pairs: return "Pairs";
|
||||
case NestPhase.Linear: return "Linear";
|
||||
case NestPhase.RectBestFit: return "BestFit";
|
||||
case NestPhase.Remainder: return "Remainder";
|
||||
default: return phase.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
@@ -7,7 +8,7 @@ namespace OpenNest
|
||||
Linear,
|
||||
RectBestFit,
|
||||
Pairs,
|
||||
Remainder
|
||||
Nfp
|
||||
}
|
||||
|
||||
public class PhaseResult
|
||||
@@ -40,8 +41,8 @@ namespace OpenNest
|
||||
public double NestedWidth { get; set; }
|
||||
public double NestedLength { get; set; }
|
||||
public double NestedArea { get; set; }
|
||||
public double UsableRemnantArea { get; set; }
|
||||
public List<Part> BestParts { get; set; }
|
||||
public string Description { get; set; }
|
||||
public Box ActiveWorkArea { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class RemnantFinder
|
||||
{
|
||||
private readonly Box workArea;
|
||||
|
||||
public List<Box> Obstacles { get; } = new();
|
||||
|
||||
public RemnantFinder(Box workArea, List<Box> obstacles = null)
|
||||
{
|
||||
this.workArea = workArea;
|
||||
|
||||
if (obstacles != null)
|
||||
Obstacles.AddRange(obstacles);
|
||||
}
|
||||
|
||||
public void AddObstacle(Box obstacle) => Obstacles.Add(obstacle);
|
||||
|
||||
public void AddObstacles(IEnumerable<Box> obstacles) => Obstacles.AddRange(obstacles);
|
||||
|
||||
public void ClearObstacles() => Obstacles.Clear();
|
||||
|
||||
public List<Box> FindRemnants(double minDimension = 0)
|
||||
{
|
||||
var xs = new SortedSet<double> { workArea.Left, workArea.Right };
|
||||
var ys = new SortedSet<double> { workArea.Bottom, workArea.Top };
|
||||
|
||||
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)
|
||||
return new List<Box>();
|
||||
|
||||
var empty = new bool[rows, cols];
|
||||
|
||||
for (var r = 0; r < rows; r++)
|
||||
{
|
||||
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]);
|
||||
|
||||
empty[r, c] = !OverlapsAnyObstacle(cell);
|
||||
}
|
||||
}
|
||||
|
||||
var merged = MergeCells(empty, xList, yList, rows, cols);
|
||||
|
||||
var results = new List<Box>();
|
||||
|
||||
foreach (var box in merged)
|
||||
{
|
||||
if (box.Width >= minDimension && box.Length >= minDimension)
|
||||
results.Add(box);
|
||||
}
|
||||
|
||||
results.Sort((a, b) => b.Area().CompareTo(a.Area()));
|
||||
return results;
|
||||
}
|
||||
|
||||
public static RemnantFinder FromPlate(Plate plate)
|
||||
{
|
||||
var obstacles = new List<Box>(plate.Parts.Count);
|
||||
|
||||
foreach (var part in plate.Parts)
|
||||
obstacles.Add(part.BoundingBox.Offset(plate.PartSpacing));
|
||||
|
||||
return new RemnantFinder(plate.WorkArea(), obstacles);
|
||||
}
|
||||
|
||||
private Box ClipToWorkArea(Box obs)
|
||||
{
|
||||
var left = System.Math.Max(obs.Left, workArea.Left);
|
||||
var bottom = System.Math.Max(obs.Bottom, workArea.Bottom);
|
||||
var right = System.Math.Min(obs.Right, workArea.Right);
|
||||
var top = System.Math.Min(obs.Top, workArea.Top);
|
||||
|
||||
if (right <= left || top <= bottom)
|
||||
return Box.Empty;
|
||||
|
||||
return new Box(left, bottom, right - left, top - bottom);
|
||||
}
|
||||
|
||||
private bool OverlapsAnyObstacle(Box cell)
|
||||
{
|
||||
foreach (var obs in Obstacles)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<Box> MergeCells(bool[,] empty, List<double> xList, List<double> yList, int rows, int cols)
|
||||
{
|
||||
var used = new bool[rows, cols];
|
||||
var results = new List<Box>();
|
||||
|
||||
for (var r = 0; r < rows; r++)
|
||||
{
|
||||
for (var c = 0; c < cols; c++)
|
||||
{
|
||||
if (!empty[r, c] || used[r, c])
|
||||
continue;
|
||||
|
||||
var maxC = c;
|
||||
while (maxC + 1 < cols && empty[r, maxC + 1] && !used[r, maxC + 1])
|
||||
maxC++;
|
||||
|
||||
var maxR = r;
|
||||
while (maxR + 1 < rows)
|
||||
{
|
||||
var rowOk = true;
|
||||
for (var cc = c; cc <= maxC; cc++)
|
||||
{
|
||||
if (!empty[maxR + 1, cc] || used[maxR + 1, cc])
|
||||
{
|
||||
rowOk = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!rowOk) break;
|
||||
maxR++;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,48 +248,86 @@ namespace OpenNest
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Fill remnant with remainder items using free-rectangle tracking.
|
||||
// After each fill, the consumed box is split into two non-overlapping
|
||||
// sub-rectangles (guillotine cut) so no usable area is lost.
|
||||
// Fill remnant areas iteratively using RemnantFinder.
|
||||
// After each fill, re-discover all free rectangles and try again
|
||||
// until no more items can be placed.
|
||||
if (remnantBox.Width > 0 && remnantBox.Length > 0)
|
||||
{
|
||||
var freeBoxes = new List<Box> { remnantBox };
|
||||
var remnantProgress = progress != null
|
||||
? new AccumulatingProgress(progress, allParts)
|
||||
: null;
|
||||
|
||||
var obstacles = allParts.Select(p => p.BoundingBox.Offset(spacing)).ToList();
|
||||
var finder = new RemnantFinder(workArea, obstacles);
|
||||
var madeProgress = true;
|
||||
|
||||
// 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<string, int>();
|
||||
foreach (var item in effectiveRemainder)
|
||||
localQty[item.Drawing.Name] = item.Quantity;
|
||||
|
||||
while (madeProgress && !token.IsCancellationRequested)
|
||||
{
|
||||
if (token.IsCancellationRequested || freeBoxes.Count == 0)
|
||||
madeProgress = false;
|
||||
|
||||
// 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;
|
||||
|
||||
var itemBbox = item.Drawing.Program.BoundingBox();
|
||||
var minItemDim = System.Math.Min(itemBbox.Width, itemBbox.Length);
|
||||
|
||||
// Try free boxes from largest to smallest.
|
||||
freeBoxes.Sort((a, b) => b.Area().CompareTo(a.Area()));
|
||||
|
||||
for (var i = 0; i < freeBoxes.Count; i++)
|
||||
foreach (var item in effectiveRemainder)
|
||||
{
|
||||
var box = freeBoxes[i];
|
||||
if (token.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
if (System.Math.Min(box.Width, box.Length) < minItemDim)
|
||||
var qty = localQty[item.Drawing.Name];
|
||||
if (qty == 0)
|
||||
continue;
|
||||
|
||||
var remnantInner = new DefaultNestEngine(Plate);
|
||||
var remnantParts = remnantInner.Fill(
|
||||
new NestItem { Drawing = item.Drawing, Quantity = item.Quantity },
|
||||
box, remnantProgress, token);
|
||||
var itemBbox = item.Drawing.Program.BoundingBox();
|
||||
var minItemDim = System.Math.Min(itemBbox.Width, itemBbox.Length);
|
||||
|
||||
if (remnantParts != null && remnantParts.Count > 0)
|
||||
foreach (var box in freeBoxes)
|
||||
{
|
||||
allParts.AddRange(remnantParts);
|
||||
freeBoxes.RemoveAt(i);
|
||||
if (System.Math.Min(box.Width, box.Length) < minItemDim)
|
||||
continue;
|
||||
|
||||
var usedBox = remnantParts.Cast<IBoundable>().GetBoundingBox();
|
||||
SplitFreeBox(box, usedBox, spacing, freeBoxes);
|
||||
break;
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,48 +336,76 @@ namespace OpenNest
|
||||
result.StripBox = direction == StripDirection.Bottom
|
||||
? new Box(workArea.X, workArea.Y, workArea.Width, bestDim)
|
||||
: new Box(workArea.X, workArea.Y, bestDim, workArea.Length);
|
||||
result.RemnantBox = remnantBox;
|
||||
result.Score = FillScore.Compute(allParts, workArea);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void SplitFreeBox(Box parent, Box used, double spacing, List<Box> freeBoxes)
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private List<Part> ShrinkFill(NestItem item, Box box,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
var hWidth = parent.Right - used.Right - spacing;
|
||||
var vHeight = parent.Top - used.Top - spacing;
|
||||
var inner = new DefaultNestEngine(Plate);
|
||||
var parts = inner.Fill(item, box, progress, token);
|
||||
|
||||
if (hWidth > spacing && vHeight > spacing)
|
||||
{
|
||||
// Guillotine split: give the overlapping corner to the larger strip.
|
||||
var hFullArea = hWidth * parent.Length;
|
||||
var vFullArea = parent.Width * vHeight;
|
||||
if (parts == null || parts.Count < 2)
|
||||
return parts;
|
||||
|
||||
if (hFullArea >= vFullArea)
|
||||
{
|
||||
// hStrip gets full height; vStrip truncated to left of split line.
|
||||
freeBoxes.Add(new Box(used.Right + spacing, parent.Y, hWidth, parent.Length));
|
||||
var vWidth = used.Right + spacing - parent.X;
|
||||
if (vWidth > spacing)
|
||||
freeBoxes.Add(new Box(parent.X, used.Top + spacing, vWidth, vHeight));
|
||||
}
|
||||
else
|
||||
{
|
||||
// vStrip gets full width; hStrip truncated below split line.
|
||||
freeBoxes.Add(new Box(parent.X, used.Top + spacing, parent.Width, vHeight));
|
||||
var hHeight = used.Top + spacing - parent.Y;
|
||||
if (hHeight > spacing)
|
||||
freeBoxes.Add(new Box(used.Right + spacing, parent.Y, hWidth, hHeight));
|
||||
}
|
||||
}
|
||||
else if (hWidth > spacing)
|
||||
var targetCount = parts.Count;
|
||||
var placedBox = parts.Cast<IBoundable>().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++)
|
||||
{
|
||||
freeBoxes.Add(new Box(used.Right + spacing, parent.Y, hWidth, parent.Length));
|
||||
if (token.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
var trialWidth = shrunkWidth - Plate.PartSpacing;
|
||||
if (trialWidth <= 0)
|
||||
break;
|
||||
|
||||
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<IBoundable>().GetBoundingBox();
|
||||
shrunkWidth = trialPlacedBox.Right - box.X;
|
||||
}
|
||||
else if (vHeight > spacing)
|
||||
|
||||
// Try shrinking vertically
|
||||
for (var i = 0; i < MaxShrinkIterations; i++)
|
||||
{
|
||||
freeBoxes.Add(new Box(parent.X, used.Top + spacing, parent.Width, vHeight));
|
||||
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<IBoundable>().GetBoundingBox();
|
||||
shrunkHeight = trialPlacedBox.Top - box.Y;
|
||||
}
|
||||
|
||||
return bestParts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace OpenNest
|
||||
{
|
||||
public List<Part> Parts { get; set; } = new();
|
||||
public Box StripBox { get; set; }
|
||||
public Box RemnantBox { get; set; }
|
||||
public FillScore Score { get; set; }
|
||||
public StripDirection Direction { get; set; }
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ namespace OpenNest.Mcp.Tools
|
||||
return $"Error: plate {plateIndex} not found";
|
||||
|
||||
var work = plate.WorkArea();
|
||||
var remnants = plate.GetRemnants();
|
||||
var remnants = RemnantFinder.FromPlate(plate).FindRemnants();
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"Plate {plateIndex}:");
|
||||
|
||||
@@ -102,7 +102,8 @@ namespace OpenNest.Mcp.Tools
|
||||
if (drawing == null)
|
||||
return $"Error: drawing '{drawingName}' not found";
|
||||
|
||||
var remnants = plate.GetRemnants();
|
||||
var finder = RemnantFinder.FromPlate(plate);
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
if (remnants.Count == 0)
|
||||
return $"No remnant areas found on plate {plateIndex}";
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class FillScoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void HigherCount_WinsOverLowerCount()
|
||||
{
|
||||
var a = new FillScore(10, 0.5);
|
||||
var b = new FillScore(5, 0.9);
|
||||
|
||||
Assert.True(a > b);
|
||||
Assert.False(b > a);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameCount_HigherDensityWins()
|
||||
{
|
||||
var a = new FillScore(10, 0.8);
|
||||
var b = new FillScore(10, 0.5);
|
||||
|
||||
Assert.True(a > b);
|
||||
Assert.False(b > a);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EqualScores_AreNotGreaterOrLess()
|
||||
{
|
||||
var a = new FillScore(10, 0.5);
|
||||
var b = new FillScore(10, 0.5);
|
||||
|
||||
Assert.False(a > b);
|
||||
Assert.False(a < b);
|
||||
Assert.True(a >= b);
|
||||
Assert.True(a <= b);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_IsZero()
|
||||
{
|
||||
var score = default(FillScore);
|
||||
|
||||
Assert.Equal(0, score.Count);
|
||||
Assert.Equal(0, score.Density);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compute_NullParts_ReturnsDefault()
|
||||
{
|
||||
var score = FillScore.Compute(null, new Geometry.Box(0, 0, 100, 100));
|
||||
|
||||
Assert.Equal(0, score.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compute_EmptyParts_ReturnsDefault()
|
||||
{
|
||||
var score = FillScore.Compute(new System.Collections.Generic.List<Part>(), new Geometry.Box(0, 0, 100, 100));
|
||||
|
||||
Assert.Equal(0, score.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compute_WithParts_ReturnsCorrectCount()
|
||||
{
|
||||
var parts = new System.Collections.Generic.List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 10),
|
||||
TestHelpers.MakePartAt(20, 0, 10),
|
||||
TestHelpers.MakePartAt(40, 0, 10)
|
||||
};
|
||||
var score = FillScore.Compute(parts, new Geometry.Box(0, 0, 100, 100));
|
||||
|
||||
Assert.Equal(3, score.Count);
|
||||
Assert.True(score.Density > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareTo_IsConsistentWithOperators()
|
||||
{
|
||||
var a = new FillScore(10, 0.8);
|
||||
var b = new FillScore(5, 0.9);
|
||||
|
||||
Assert.True(a.CompareTo(b) > 0);
|
||||
Assert.True(a > b);
|
||||
Assert.True(b < a);
|
||||
Assert.True(a >= b);
|
||||
Assert.True(b <= a);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class RemnantFinderTests
|
||||
{
|
||||
[Fact]
|
||||
public void EmptyPlate_ReturnsWholeWorkArea()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
Assert.Single(remnants);
|
||||
Assert.Equal(100 * 100, remnants[0].Area(), 0.1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleObstacle_InCorner_FindsLShapedRemnants()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
finder.AddObstacle(new Box(0, 0, 40, 40));
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
Assert.True(remnants.Count >= 2);
|
||||
var largest = remnants[0];
|
||||
Assert.Equal(60 * 100, largest.Area(), 0.1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleObstacle_InCenter_FindsFourRemnants()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
finder.AddObstacle(new Box(30, 30, 40, 40));
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
Assert.True(remnants.Count >= 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MinDimension_FiltersSmallRemnants()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
finder.AddObstacle(new Box(0, 0, 95, 100));
|
||||
var all = finder.FindRemnants(0);
|
||||
var filtered = finder.FindRemnants(10);
|
||||
|
||||
Assert.True(all.Count > filtered.Count);
|
||||
foreach (var r in filtered)
|
||||
{
|
||||
Assert.True(r.Width >= 10);
|
||||
Assert.True(r.Length >= 10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResultsSortedByAreaDescending()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
finder.AddObstacle(new Box(0, 0, 50, 50));
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
for (var i = 1; i < remnants.Count; i++)
|
||||
Assert.True(remnants[i - 1].Area() >= remnants[i].Area());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddObstacle_UpdatesResults()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
var before = finder.FindRemnants();
|
||||
Assert.Single(before);
|
||||
|
||||
finder.AddObstacle(new Box(0, 0, 50, 50));
|
||||
var after = finder.FindRemnants();
|
||||
Assert.True(after.Count > 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearObstacles_ResetsToFullWorkArea()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
finder.AddObstacle(new Box(0, 0, 50, 50));
|
||||
finder.ClearObstacles();
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
Assert.Single(remnants);
|
||||
Assert.Equal(100 * 100, remnants[0].Area(), 0.1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullyCovered_ReturnsEmpty()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
finder.AddObstacle(new Box(0, 0, 100, 100));
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
Assert.Empty(remnants);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleObstacles_FindsGapBetween()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
finder.AddObstacle(new Box(0, 0, 40, 100));
|
||||
finder.AddObstacle(new Box(60, 0, 40, 100));
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
var gap = remnants.FirstOrDefault(r =>
|
||||
r.Width >= 19.9 && r.Width <= 20.1 &&
|
||||
r.Length >= 99.9);
|
||||
Assert.NotNull(gap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromPlate_CreatesFinderWithPartsAsObstacles()
|
||||
{
|
||||
var plate = TestHelpers.MakePlate(60, 120,
|
||||
TestHelpers.MakePartAt(0, 0, 20));
|
||||
var finder = RemnantFinder.FromPlate(plate);
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
Assert.True(remnants.Count >= 1);
|
||||
Assert.True(remnants[0].Area() < plate.WorkArea().Area());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObstacleOutsideWorkArea_IsIgnored()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
finder.AddObstacle(new Box(200, 200, 50, 50));
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
Assert.Single(remnants);
|
||||
Assert.Equal(100 * 100, remnants[0].Area(), 0.1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObstaclePartiallyOutsideWorkArea_IsClipped()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
// Obstacle extends 20 units past the right edge
|
||||
finder.AddObstacle(new Box(80, 0, 40, 100));
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
// Should find the 80x100 strip on the left
|
||||
var left = remnants.FirstOrDefault(r =>
|
||||
r.Width >= 79.9 && r.Width <= 80.1 &&
|
||||
r.Length >= 99.9);
|
||||
Assert.NotNull(left);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OverlappingObstacles_HandledCorrectly()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
finder.AddObstacle(new Box(0, 0, 60, 60));
|
||||
finder.AddObstacle(new Box(40, 40, 60, 60)); // overlaps first
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
// No remnant should overlap either obstacle
|
||||
foreach (var r in remnants)
|
||||
{
|
||||
Assert.False(
|
||||
r.Left < 60 && r.Right > 0 && r.Bottom < 60 && r.Top > 0
|
||||
&& r.Left < 100 && r.Right > 40 && r.Bottom < 100 && r.Top > 40,
|
||||
"Remnant should not overlap both obstacles simultaneously in their shared region");
|
||||
}
|
||||
|
||||
// Total remnant area + obstacle coverage should not exceed work area
|
||||
var totalRemnantArea = remnants.Sum(r => r.Area());
|
||||
Assert.True(totalRemnantArea < 100 * 100);
|
||||
Assert.True(totalRemnantArea > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructorWithObstaclesList()
|
||||
{
|
||||
var obstacles = new List<Box>
|
||||
{
|
||||
new Box(0, 0, 40, 100),
|
||||
new Box(60, 0, 40, 100)
|
||||
};
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100), obstacles);
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
var gap = remnants.FirstOrDefault(r =>
|
||||
r.Width >= 19.9 && r.Width <= 20.1);
|
||||
Assert.NotNull(gap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddObstacles_Plural_AddsMultiple()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
finder.AddObstacles(new[]
|
||||
{
|
||||
new Box(0, 0, 40, 100),
|
||||
new Box(60, 0, 40, 100)
|
||||
});
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
var gap = remnants.FirstOrDefault(r =>
|
||||
r.Width >= 19.9 && r.Width <= 20.1);
|
||||
Assert.NotNull(gap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeWorkflow_AddObstacleThenRequery()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
|
||||
// First fill: obstacle in bottom-left
|
||||
finder.AddObstacle(new Box(0, 0, 50, 50));
|
||||
var pass1 = finder.FindRemnants();
|
||||
Assert.True(pass1.Count >= 2);
|
||||
|
||||
// Simulate filling the largest remnant by adding another obstacle
|
||||
var largest = pass1[0];
|
||||
finder.AddObstacle(new Box(largest.X, largest.Y, largest.Width / 2, largest.Length));
|
||||
var pass2 = finder.FindRemnants();
|
||||
|
||||
// Should have more, smaller remnants now
|
||||
var pass2TotalArea = pass2.Sum(r => r.Area());
|
||||
var pass1TotalArea = pass1.Sum(r => r.Area());
|
||||
Assert.True(pass2TotalArea < pass1TotalArea);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoRemnantOverlapsObstacle()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
finder.AddObstacle(new Box(20, 20, 30, 30));
|
||||
finder.AddObstacle(new Box(60, 10, 25, 80));
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
foreach (var r in remnants)
|
||||
{
|
||||
// Check no remnant overlaps obstacle 1
|
||||
var overlaps1 = r.Left < 50 && r.Right > 20 && r.Bottom < 50 && r.Top > 20;
|
||||
Assert.False(overlaps1, $"Remnant ({r.X},{r.Y} {r.Width}x{r.Length}) overlaps obstacle 1");
|
||||
|
||||
// Check no remnant overlaps obstacle 2
|
||||
var overlaps2 = r.Left < 85 && r.Right > 60 && r.Bottom < 90 && r.Top > 10;
|
||||
Assert.False(overlaps2, $"Remnant ({r.X},{r.Y} {r.Width}x{r.Length}) overlaps obstacle 2");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManyObstacles_GridPattern()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
|
||||
// Place a 5x5 grid of 10x10 obstacles with 10-unit gaps
|
||||
for (var row = 0; row < 5; row++)
|
||||
for (var col = 0; col < 5; col++)
|
||||
finder.AddObstacle(new Box(col * 20, row * 20, 10, 10));
|
||||
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
// All remnants should be within the work area
|
||||
foreach (var r in remnants)
|
||||
{
|
||||
Assert.True(r.Left >= 0);
|
||||
Assert.True(r.Bottom >= 0);
|
||||
Assert.True(r.Right <= 100);
|
||||
Assert.True(r.Top <= 100);
|
||||
}
|
||||
|
||||
// Should find gaps between obstacles
|
||||
Assert.True(remnants.Count > 0);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,17 @@ namespace OpenNest.Controls
|
||||
private List<LayoutPart> parts;
|
||||
private List<LayoutPart> temporaryParts = new List<LayoutPart>();
|
||||
private Point middleMouseDownPoint;
|
||||
private Box activeWorkArea;
|
||||
|
||||
public Box ActiveWorkArea
|
||||
{
|
||||
get => activeWorkArea;
|
||||
set
|
||||
{
|
||||
activeWorkArea = value;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public List<LayoutPart> SelectedParts;
|
||||
public ReadOnlyCollection<LayoutPart> Parts;
|
||||
@@ -362,6 +373,7 @@ namespace OpenNest.Controls
|
||||
|
||||
DrawPlate(e.Graphics);
|
||||
DrawParts(e.Graphics);
|
||||
DrawActiveWorkArea(e.Graphics);
|
||||
|
||||
base.OnPaint(e);
|
||||
}
|
||||
@@ -600,6 +612,26 @@ namespace OpenNest.Controls
|
||||
g.DrawRectangle(ColorScheme.BoundingBoxPen, rect.X, rect.Y - rect.Height, rect.Width, rect.Height);
|
||||
}
|
||||
|
||||
private void DrawActiveWorkArea(Graphics g)
|
||||
{
|
||||
if (activeWorkArea == null)
|
||||
return;
|
||||
|
||||
var rect = new RectangleF
|
||||
{
|
||||
Location = PointWorldToGraph(activeWorkArea.Location),
|
||||
Width = LengthWorldToGui(activeWorkArea.Width),
|
||||
Height = LengthWorldToGui(activeWorkArea.Length)
|
||||
};
|
||||
rect.Y -= rect.Height;
|
||||
|
||||
using var pen = new Pen(Color.Red, 1.5f)
|
||||
{
|
||||
DashStyle = DashStyle.Dash
|
||||
};
|
||||
g.DrawRectangle(pen, rect.X, rect.Y, rect.Width, rect.Height);
|
||||
}
|
||||
|
||||
public LayoutPart GetPartAtControlPoint(Point pt)
|
||||
{
|
||||
var pt2 = PointControlToGraph(pt);
|
||||
@@ -827,6 +859,7 @@ namespace OpenNest.Controls
|
||||
{
|
||||
progressForm.UpdateProgress(p);
|
||||
SetTemporaryParts(p.BestParts);
|
||||
ActiveWorkArea = p.ActiveWorkArea;
|
||||
});
|
||||
|
||||
progressForm.Show(FindForm());
|
||||
@@ -856,6 +889,7 @@ namespace OpenNest.Controls
|
||||
}
|
||||
finally
|
||||
{
|
||||
ActiveWorkArea = null;
|
||||
progressForm.Close();
|
||||
cts.Dispose();
|
||||
}
|
||||
@@ -960,22 +994,28 @@ namespace OpenNest.Controls
|
||||
|
||||
public void RotateSelectedParts(double angle)
|
||||
{
|
||||
var pt1 = SelectedParts.Select(p => p.BasePart).ToList().GetBoundingBox().Location;
|
||||
var parts = SelectedParts.Select(p => p.BasePart).ToList();
|
||||
var bounds = parts.GetBoundingBox();
|
||||
var center = bounds.Center;
|
||||
var anchor = bounds.Location;
|
||||
var rotatedPrograms = new HashSet<Program>();
|
||||
|
||||
for (int i = 0; i < SelectedParts.Count; ++i)
|
||||
{
|
||||
var part = SelectedParts[i];
|
||||
part.Rotate(angle);
|
||||
var basePart = part.BasePart;
|
||||
|
||||
if (rotatedPrograms.Add(basePart.Program))
|
||||
basePart.Program.Rotate(angle);
|
||||
|
||||
part.Location = part.Location.Rotate(angle, center);
|
||||
basePart.UpdateBounds();
|
||||
}
|
||||
|
||||
var pt2 = SelectedParts.Select(p => p.BasePart).ToList().GetBoundingBox().Location;
|
||||
var diff = pt1 - pt2;
|
||||
var diff = anchor - parts.GetBoundingBox().Location;
|
||||
|
||||
for (int i = 0; i < SelectedParts.Count; ++i)
|
||||
{
|
||||
var part = SelectedParts[i];
|
||||
part.Offset(diff);
|
||||
}
|
||||
SelectedParts[i].Offset(diff);
|
||||
}
|
||||
|
||||
protected override void UpdateMatrix()
|
||||
|
||||
@@ -759,6 +759,7 @@ namespace OpenNest.Forms
|
||||
{
|
||||
progressForm.UpdateProgress(p);
|
||||
activeForm.PlateView.SetTemporaryParts(p.BestParts);
|
||||
activeForm.PlateView.ActiveWorkArea = p.ActiveWorkArea;
|
||||
});
|
||||
|
||||
progressForm.Show(this);
|
||||
@@ -817,6 +818,7 @@ namespace OpenNest.Forms
|
||||
}
|
||||
finally
|
||||
{
|
||||
activeForm.PlateView.ActiveWorkArea = null;
|
||||
progressForm.Close();
|
||||
SetNestingLockout(false);
|
||||
nestingCts.Dispose();
|
||||
@@ -894,6 +896,7 @@ namespace OpenNest.Forms
|
||||
{
|
||||
progressForm.UpdateProgress(p);
|
||||
activeForm.PlateView.SetTemporaryParts(p.BestParts);
|
||||
activeForm.PlateView.ActiveWorkArea = p.ActiveWorkArea;
|
||||
});
|
||||
|
||||
progressForm.Show(this);
|
||||
@@ -923,6 +926,7 @@ namespace OpenNest.Forms
|
||||
}
|
||||
finally
|
||||
{
|
||||
activeForm.PlateView.ActiveWorkArea = null;
|
||||
progressForm.Close();
|
||||
SetNestingLockout(false);
|
||||
nestingCts.Dispose();
|
||||
@@ -954,6 +958,7 @@ namespace OpenNest.Forms
|
||||
{
|
||||
progressForm.UpdateProgress(p);
|
||||
activeForm.PlateView.SetTemporaryParts(p.BestParts);
|
||||
activeForm.PlateView.ActiveWorkArea = p.ActiveWorkArea;
|
||||
});
|
||||
|
||||
Action<List<Part>> onComplete = parts =>
|
||||
@@ -963,6 +968,7 @@ namespace OpenNest.Forms
|
||||
else
|
||||
activeForm.PlateView.ClearTemporaryParts();
|
||||
|
||||
activeForm.PlateView.ActiveWorkArea = null;
|
||||
progressForm.Close();
|
||||
SetNestingLockout(false);
|
||||
nestingCts.Dispose();
|
||||
|
||||
@@ -37,7 +37,6 @@ namespace OpenNest.Forms
|
||||
partsValue.Text = progress.BestPartCount.ToString();
|
||||
densityValue.Text = progress.BestDensity.ToString("P1");
|
||||
nestedAreaValue.Text = $"{progress.NestedWidth:F1} x {progress.NestedLength:F1} ({progress.NestedArea:F1} sq in)";
|
||||
remnantValue.Text = $"{progress.UsableRemnantArea:F1} sq in";
|
||||
|
||||
if (!string.IsNullOrEmpty(progress.Description))
|
||||
descriptionValue.Text = progress.Description;
|
||||
@@ -97,7 +96,6 @@ namespace OpenNest.Forms
|
||||
case NestPhase.Linear: return "Trying rotations...";
|
||||
case NestPhase.RectBestFit: return "Trying best fit...";
|
||||
case NestPhase.Pairs: return "Trying pairs...";
|
||||
case NestPhase.Remainder: return "Filling remainder...";
|
||||
default: return phase.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,998 @@
|
||||
# Remnant Finder 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 remnant detection from the nesting engine into a standalone `RemnantFinder` class that finds all rectangular empty regions via edge projection, and visualize the active work area on the plate view.
|
||||
|
||||
**Architecture:** `RemnantFinder` is a mutable class in `OpenNest.Engine` that takes a work area + obstacle boxes and uses edge projection to find empty rectangles. The remainder phase is removed from `DefaultNestEngine`, making `Fill()` single-pass. `FillScore` drops remnant tracking. `PlateView` gains a dashed orange rectangle overlay for the active work area. `NestProgress` carries `ActiveWorkArea` so callers can show which region is currently being filled.
|
||||
|
||||
**Tech Stack:** .NET 8, C#, xUnit, WinForms (GDI+)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-16-remnant-finder-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: RemnantFinder Core
|
||||
|
||||
### Task 1: RemnantFinder — failing tests
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Tests/RemnantFinderTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for RemnantFinder**
|
||||
|
||||
```csharp
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class RemnantFinderTests
|
||||
{
|
||||
[Fact]
|
||||
public void EmptyPlate_ReturnsWholeWorkArea()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
Assert.Single(remnants);
|
||||
Assert.Equal(100 * 100, remnants[0].Area(), 0.1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleObstacle_InCorner_FindsLShapedRemnants()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
finder.AddObstacle(new Box(0, 0, 40, 40));
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
// Should find at least the right strip (60x100) and top strip (40x60)
|
||||
Assert.True(remnants.Count >= 2);
|
||||
|
||||
// Largest remnant should be the right strip
|
||||
var largest = remnants[0];
|
||||
Assert.Equal(60 * 100, largest.Area(), 0.1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleObstacle_InCenter_FindsFourRemnants()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
finder.AddObstacle(new Box(30, 30, 40, 40));
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
// Should find remnants on all four sides
|
||||
Assert.True(remnants.Count >= 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MinDimension_FiltersSmallRemnants()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
// Obstacle leaves a 5-wide strip on the right
|
||||
finder.AddObstacle(new Box(0, 0, 95, 100));
|
||||
var all = finder.FindRemnants(0);
|
||||
var filtered = finder.FindRemnants(10);
|
||||
|
||||
Assert.True(all.Count > filtered.Count);
|
||||
foreach (var r in filtered)
|
||||
{
|
||||
Assert.True(r.Width >= 10);
|
||||
Assert.True(r.Length >= 10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResultsSortedByAreaDescending()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
finder.AddObstacle(new Box(0, 0, 50, 50));
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
for (var i = 1; i < remnants.Count; i++)
|
||||
Assert.True(remnants[i - 1].Area() >= remnants[i].Area());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddObstacle_UpdatesResults()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
var before = finder.FindRemnants();
|
||||
Assert.Single(before);
|
||||
|
||||
finder.AddObstacle(new Box(0, 0, 50, 50));
|
||||
var after = finder.FindRemnants();
|
||||
Assert.True(after.Count > 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearObstacles_ResetsToFullWorkArea()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
finder.AddObstacle(new Box(0, 0, 50, 50));
|
||||
finder.ClearObstacles();
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
Assert.Single(remnants);
|
||||
Assert.Equal(100 * 100, remnants[0].Area(), 0.1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullyCovered_ReturnsEmpty()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
finder.AddObstacle(new Box(0, 0, 100, 100));
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
Assert.Empty(remnants);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleObstacles_FindsGapBetween()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
// Two obstacles with a 20-wide gap in the middle
|
||||
finder.AddObstacle(new Box(0, 0, 40, 100));
|
||||
finder.AddObstacle(new Box(60, 0, 40, 100));
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
// Should find the 20x100 gap between the two obstacles
|
||||
var gap = remnants.FirstOrDefault(r =>
|
||||
r.Width >= 19.9 && r.Width <= 20.1 &&
|
||||
r.Length >= 99.9);
|
||||
Assert.NotNull(gap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromPlate_CreatesFinderWithPartsAsObstacles()
|
||||
{
|
||||
var plate = TestHelpers.MakePlate(60, 120,
|
||||
TestHelpers.MakePartAt(0, 0, 20));
|
||||
var finder = RemnantFinder.FromPlate(plate);
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
// Should have remnants around the 20x20 part
|
||||
Assert.True(remnants.Count >= 1);
|
||||
// Largest remnant area should be less than full plate work area
|
||||
Assert.True(remnants[0].Area() < plate.WorkArea().Area());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~RemnantFinderTests" -v minimal`
|
||||
Expected: FAIL — `RemnantFinder` class does not exist
|
||||
|
||||
- [ ] **Step 3: Commit failing tests**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Tests/RemnantFinderTests.cs
|
||||
git commit -m "test: add RemnantFinder tests (red)"
|
||||
```
|
||||
|
||||
### Task 2: RemnantFinder — implementation
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/RemnantFinder.cs`
|
||||
|
||||
- [ ] **Step 1: Implement RemnantFinder**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class RemnantFinder
|
||||
{
|
||||
private readonly Box workArea;
|
||||
|
||||
public List<Box> Obstacles { get; } = new();
|
||||
|
||||
public RemnantFinder(Box workArea, List<Box> obstacles = null)
|
||||
{
|
||||
this.workArea = workArea;
|
||||
|
||||
if (obstacles != null)
|
||||
Obstacles.AddRange(obstacles);
|
||||
}
|
||||
|
||||
public void AddObstacle(Box obstacle) => Obstacles.Add(obstacle);
|
||||
|
||||
public void AddObstacles(IEnumerable<Box> obstacles) => Obstacles.AddRange(obstacles);
|
||||
|
||||
public void ClearObstacles() => Obstacles.Clear();
|
||||
|
||||
public List<Box> FindRemnants(double minDimension = 0)
|
||||
{
|
||||
// Step 1-2: Collect unique X and Y coordinates
|
||||
var xs = new SortedSet<double> { workArea.Left, workArea.Right };
|
||||
var ys = new SortedSet<double> { workArea.Bottom, workArea.Top };
|
||||
|
||||
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();
|
||||
|
||||
// Step 3-4: Build grid cells and mark empty ones
|
||||
var cols = xList.Count - 1;
|
||||
var rows = yList.Count - 1;
|
||||
|
||||
if (cols <= 0 || rows <= 0)
|
||||
return new List<Box>();
|
||||
|
||||
var empty = new bool[rows, cols];
|
||||
|
||||
for (var r = 0; r < rows; r++)
|
||||
{
|
||||
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]);
|
||||
|
||||
empty[r, c] = !OverlapsAnyObstacle(cell);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Merge adjacent empty cells into larger rectangles
|
||||
var merged = MergeCells(empty, xList, yList, rows, cols);
|
||||
|
||||
// Step 6: Filter by minDimension
|
||||
var results = new List<Box>();
|
||||
|
||||
foreach (var box in merged)
|
||||
{
|
||||
if (box.Width >= minDimension && box.Length >= minDimension)
|
||||
results.Add(box);
|
||||
}
|
||||
|
||||
// Step 7: Sort by area descending
|
||||
results.Sort((a, b) => b.Area().CompareTo(a.Area()));
|
||||
return results;
|
||||
}
|
||||
|
||||
public static RemnantFinder FromPlate(Plate plate)
|
||||
{
|
||||
var obstacles = new List<Box>(plate.Parts.Count);
|
||||
|
||||
foreach (var part in plate.Parts)
|
||||
obstacles.Add(part.BoundingBox.Offset(plate.PartSpacing));
|
||||
|
||||
return new RemnantFinder(plate.WorkArea(), obstacles);
|
||||
}
|
||||
|
||||
private Box ClipToWorkArea(Box obs)
|
||||
{
|
||||
var left = System.Math.Max(obs.Left, workArea.Left);
|
||||
var bottom = System.Math.Max(obs.Bottom, workArea.Bottom);
|
||||
var right = System.Math.Min(obs.Right, workArea.Right);
|
||||
var top = System.Math.Min(obs.Top, workArea.Top);
|
||||
|
||||
if (right <= left || top <= bottom)
|
||||
return Box.Empty;
|
||||
|
||||
return new Box(left, bottom, right - left, top - bottom);
|
||||
}
|
||||
|
||||
private bool OverlapsAnyObstacle(Box cell)
|
||||
{
|
||||
foreach (var obs in Obstacles)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<Box> MergeCells(bool[,] empty, List<double> xList, List<double> yList, int rows, int cols)
|
||||
{
|
||||
var used = new bool[rows, cols];
|
||||
var results = new List<Box>();
|
||||
|
||||
for (var r = 0; r < rows; r++)
|
||||
{
|
||||
for (var c = 0; c < cols; c++)
|
||||
{
|
||||
if (!empty[r, c] || used[r, c])
|
||||
continue;
|
||||
|
||||
// Expand right as far as possible
|
||||
var maxC = c;
|
||||
while (maxC + 1 < cols && empty[r, maxC + 1] && !used[r, maxC + 1])
|
||||
maxC++;
|
||||
|
||||
// Expand down as far as possible
|
||||
var maxR = r;
|
||||
while (maxR + 1 < rows)
|
||||
{
|
||||
var rowOk = true;
|
||||
for (var cc = c; cc <= maxC; cc++)
|
||||
{
|
||||
if (!empty[maxR + 1, cc] || used[maxR + 1, cc])
|
||||
{
|
||||
rowOk = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!rowOk) break;
|
||||
maxR++;
|
||||
}
|
||||
|
||||
// Mark cells as used
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they pass**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~RemnantFinderTests" -v minimal`
|
||||
Expected: All PASS
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/RemnantFinder.cs
|
||||
git commit -m "feat: add RemnantFinder with edge projection algorithm"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: FillScore Simplification and Remnant Cleanup
|
||||
|
||||
### Task 3: Simplify FillScore — remove remnant tracking
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/FillScore.cs`
|
||||
|
||||
- [ ] **Step 1: Remove remnant-related members from FillScore**
|
||||
|
||||
Remove `MinRemnantDimension`, `UsableRemnantArea`, `ComputeUsableRemnantArea()`. Simplify constructor and `Compute()`. Update `CompareTo` to compare count then density (no remnant area).
|
||||
|
||||
New `FillScore.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public readonly struct FillScore : System.IComparable<FillScore>
|
||||
{
|
||||
public int Count { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Total part area / bounding box area of all placed parts.
|
||||
/// </summary>
|
||||
public double Density { get; }
|
||||
|
||||
public FillScore(int count, double density)
|
||||
{
|
||||
Count = count;
|
||||
Density = density;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a fill score from placed parts and the work area they were placed in.
|
||||
/// </summary>
|
||||
public static FillScore Compute(List<Part> parts, Box workArea)
|
||||
{
|
||||
if (parts == null || parts.Count == 0)
|
||||
return default;
|
||||
|
||||
var totalPartArea = 0.0;
|
||||
var minX = double.MaxValue;
|
||||
var minY = double.MaxValue;
|
||||
var maxX = double.MinValue;
|
||||
var maxY = double.MinValue;
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
totalPartArea += part.BaseDrawing.Area;
|
||||
var bb = part.BoundingBox;
|
||||
|
||||
if (bb.Left < minX) minX = bb.Left;
|
||||
if (bb.Bottom < minY) minY = bb.Bottom;
|
||||
if (bb.Right > maxX) maxX = bb.Right;
|
||||
if (bb.Top > maxY) maxY = bb.Top;
|
||||
}
|
||||
|
||||
var bboxArea = (maxX - minX) * (maxY - minY);
|
||||
var density = bboxArea > 0 ? totalPartArea / bboxArea : 0;
|
||||
|
||||
return new FillScore(parts.Count, density);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lexicographic comparison: count, then density.
|
||||
/// </summary>
|
||||
public int CompareTo(FillScore other)
|
||||
{
|
||||
var c = Count.CompareTo(other.Count);
|
||||
|
||||
if (c != 0)
|
||||
return c;
|
||||
|
||||
return Density.CompareTo(other.Density);
|
||||
}
|
||||
|
||||
public static bool operator >(FillScore a, FillScore b) => a.CompareTo(b) > 0;
|
||||
public static bool operator <(FillScore a, FillScore b) => a.CompareTo(b) < 0;
|
||||
public static bool operator >=(FillScore a, FillScore b) => a.CompareTo(b) >= 0;
|
||||
public static bool operator <=(FillScore a, FillScore b) => a.CompareTo(b) <= 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit (build will not pass yet — remaining UsableRemnantArea references fixed in Tasks 4-5)**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/FillScore.cs
|
||||
git commit -m "refactor: simplify FillScore to count + density, remove remnant tracking"
|
||||
```
|
||||
|
||||
### Task 4: Update DefaultNestEngine debug logging
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/DefaultNestEngine.cs:456-459`
|
||||
|
||||
- [ ] **Step 1: Update FillWithPairs debug log**
|
||||
|
||||
At line 456, change:
|
||||
```csharp
|
||||
Debug.WriteLine($"[FillWithPairs] Best pair result: {bestScore.Count} parts, remnant={bestScore.UsableRemnantArea:F1}, density={bestScore.Density:P1}");
|
||||
```
|
||||
to:
|
||||
```csharp
|
||||
Debug.WriteLine($"[FillWithPairs] Best pair result: {bestScore.Count} parts, density={bestScore.Density:P1}");
|
||||
```
|
||||
|
||||
Also update the file-based debug log at lines 457-459 — change `bestScore.UsableRemnantArea` references similarly. If the file log references `UsableRemnantArea`, remove that interpolation.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/DefaultNestEngine.cs
|
||||
git commit -m "fix: update FillWithPairs debug logging after FillScore simplification"
|
||||
```
|
||||
|
||||
### Task 5: Remove NestProgress.UsableRemnantArea and UI references
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestProgress.cs:44`
|
||||
- Modify: `OpenNest.Engine/NestEngineBase.cs:232`
|
||||
- Modify: `OpenNest\Forms\NestProgressForm.cs:40`
|
||||
|
||||
- [ ] **Step 1: Remove UsableRemnantArea from NestProgress**
|
||||
|
||||
In `NestProgress.cs`, remove line 44:
|
||||
```csharp
|
||||
public double UsableRemnantArea { get; set; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove UsableRemnantArea from ReportProgress**
|
||||
|
||||
In `NestEngineBase.cs` at line 232, remove:
|
||||
```csharp
|
||||
UsableRemnantArea = workArea.Area() - totalPartArea,
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove remnant display from NestProgressForm**
|
||||
|
||||
In `NestProgressForm.cs` at line 40, remove:
|
||||
```csharp
|
||||
remnantValue.Text = $"{progress.UsableRemnantArea:F1} sq in";
|
||||
```
|
||||
|
||||
Also remove the `remnantValue` label and its corresponding "Remnant:" label from the form's Designer file (or set them to display something else if desired). If simpler, just remove the line that sets the text — the label will remain but show its default empty text.
|
||||
|
||||
- [ ] **Step 4: Build to verify all UsableRemnantArea references are resolved**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds — all `UsableRemnantArea` references are now removed
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestProgress.cs OpenNest.Engine/NestEngineBase.cs OpenNest/Forms/NestProgressForm.cs
|
||||
git commit -m "refactor: remove UsableRemnantArea from NestProgress and UI"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Remove Remainder Phase from Engine
|
||||
|
||||
### Task 6: Remove remainder phase from DefaultNestEngine
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/DefaultNestEngine.cs`
|
||||
|
||||
- [ ] **Step 1: Remove TryRemainderImprovement calls from Fill() overrides**
|
||||
|
||||
In the first `Fill()` override (line 31), remove lines 40-53 (the remainder improvement block after `FindBestFill`):
|
||||
```csharp
|
||||
// Remove this entire block:
|
||||
if (!token.IsCancellationRequested)
|
||||
{
|
||||
var remainderSw = Stopwatch.StartNew();
|
||||
var improved = TryRemainderImprovement(item, workArea, best);
|
||||
// ... through to the closing brace
|
||||
}
|
||||
```
|
||||
|
||||
In the second `Fill()` override (line 118), remove lines 165-174 (the remainder improvement block inside the `if (groupParts.Count == 1)` block):
|
||||
```csharp
|
||||
// Remove this entire block:
|
||||
var improved = TryRemainderImprovement(nestItem, workArea, best);
|
||||
if (IsBetterFill(improved, best, workArea))
|
||||
{
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove TryRemainderImprovement, TryStripRefill, ClusterParts methods**
|
||||
|
||||
Remove the three private methods (lines 563-694):
|
||||
- `TryRemainderImprovement`
|
||||
- `TryStripRefill`
|
||||
- `ClusterParts`
|
||||
|
||||
- [ ] **Step 3: Update Description property**
|
||||
|
||||
Change:
|
||||
```csharp
|
||||
public override string Description => "Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)";
|
||||
```
|
||||
to:
|
||||
```csharp
|
||||
public override string Description => "Multi-phase nesting (Linear, Pairs, RectBestFit)";
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build and run tests**
|
||||
|
||||
Run: `dotnet build OpenNest.sln && dotnet test OpenNest.Tests -v minimal`
|
||||
Expected: Build succeeds, tests pass
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/DefaultNestEngine.cs
|
||||
git commit -m "refactor: remove remainder phase from DefaultNestEngine"
|
||||
```
|
||||
|
||||
### Task 7: Remove NestPhase.Remainder and cleanup
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestProgress.cs:11`
|
||||
- Modify: `OpenNest.Engine/NestEngineBase.cs:314`
|
||||
- Modify: `OpenNest\Forms\NestProgressForm.cs:100`
|
||||
|
||||
- [ ] **Step 1: Remove Remainder from NestPhase enum**
|
||||
|
||||
In `NestProgress.cs`, remove `Remainder` from the enum.
|
||||
|
||||
- [ ] **Step 2: Remove Remainder case from FormatPhaseName**
|
||||
|
||||
In `NestEngineBase.cs`, remove:
|
||||
```csharp
|
||||
case NestPhase.Remainder: return "Remainder";
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove Remainder case from FormatPhase**
|
||||
|
||||
In `NestProgressForm.cs`, remove:
|
||||
```csharp
|
||||
case NestPhase.Remainder: return "Filling remainder...";
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestProgress.cs OpenNest.Engine/NestEngineBase.cs OpenNest/Forms/NestProgressForm.cs
|
||||
git commit -m "refactor: remove NestPhase.Remainder enum value and switch cases"
|
||||
```
|
||||
|
||||
### Task 8: Remove ComputeRemainderWithin and update Nest()
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestEngineBase.cs:92,120-133`
|
||||
|
||||
- [ ] **Step 1: Replace ComputeRemainderWithin usage in Nest()**
|
||||
|
||||
At line 91-92, change:
|
||||
```csharp
|
||||
var placedBox = parts.Cast<IBoundable>().GetBoundingBox();
|
||||
workArea = ComputeRemainderWithin(workArea, placedBox, Plate.PartSpacing);
|
||||
```
|
||||
to:
|
||||
```csharp
|
||||
var placedObstacles = parts.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
|
||||
```
|
||||
|
||||
Note: This is a behavioral improvement — the old code used a single merged bounding box and picked one strip. The new code finds per-part obstacles and discovers all gaps, using the largest. This may produce different (better) results for non-rectangular layouts.
|
||||
|
||||
- [ ] **Step 2: Remove ComputeRemainderWithin method**
|
||||
|
||||
Delete lines 120-133 (the `ComputeRemainderWithin` static method).
|
||||
|
||||
- [ ] **Step 3: Build and run tests**
|
||||
|
||||
Run: `dotnet build OpenNest.sln && dotnet test OpenNest.Tests -v minimal`
|
||||
Expected: Build succeeds, tests pass
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestEngineBase.cs
|
||||
git commit -m "refactor: replace ComputeRemainderWithin with RemnantFinder in Nest()"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4: Remove Old Remnant Code and Update Callers
|
||||
|
||||
### Task 9: Remove Plate.GetRemnants()
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Core/Plate.cs:477-557`
|
||||
|
||||
- [ ] **Step 1: Remove GetRemnants method**
|
||||
|
||||
Delete the `GetRemnants()` method (lines 477-557, the XML doc comment through the closing brace).
|
||||
|
||||
- [ ] **Step 2: Build to check for remaining references**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Errors in `NestingTools.cs` and `InspectionTools.cs` (fixed in next task)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Core/Plate.cs
|
||||
git commit -m "refactor: remove Plate.GetRemnants(), replaced by RemnantFinder"
|
||||
```
|
||||
|
||||
### Task 10: Update MCP callers
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Mcp/Tools/NestingTools.cs:105`
|
||||
- Modify: `OpenNest.Mcp/Tools/InspectionTools.cs:31`
|
||||
|
||||
- [ ] **Step 1: Update NestingTools.FillRemnants**
|
||||
|
||||
At line 105, change:
|
||||
```csharp
|
||||
var remnants = plate.GetRemnants();
|
||||
```
|
||||
to:
|
||||
```csharp
|
||||
var finder = RemnantFinder.FromPlate(plate);
|
||||
var remnants = finder.FindRemnants();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update InspectionTools.GetPlateInfo**
|
||||
|
||||
At line 31, change:
|
||||
```csharp
|
||||
var remnants = plate.GetRemnants();
|
||||
```
|
||||
to:
|
||||
```csharp
|
||||
var remnants = RemnantFinder.FromPlate(plate).FindRemnants();
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Mcp/Tools/NestingTools.cs OpenNest.Mcp/Tools/InspectionTools.cs
|
||||
git commit -m "refactor: update MCP tools to use RemnantFinder"
|
||||
```
|
||||
|
||||
### Task 11: Remove StripNestResult.RemnantBox
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/StripNestResult.cs:10`
|
||||
- Modify: `OpenNest.Engine/StripNestEngine.cs:301`
|
||||
|
||||
- [ ] **Step 1: Remove RemnantBox property from StripNestResult**
|
||||
|
||||
In `StripNestResult.cs`, remove line 10:
|
||||
```csharp
|
||||
public Box RemnantBox { get; set; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove RemnantBox assignment in StripNestEngine**
|
||||
|
||||
In `StripNestEngine.cs` at line 301, remove:
|
||||
```csharp
|
||||
result.RemnantBox = remnantBox;
|
||||
```
|
||||
|
||||
Also check if the local `remnantBox` variable is now unused — if so, remove its declaration and computation too.
|
||||
|
||||
- [ ] **Step 3: Build and run tests**
|
||||
|
||||
Run: `dotnet build OpenNest.sln && dotnet test OpenNest.Tests -v minimal`
|
||||
Expected: Build succeeds, all tests pass
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/StripNestResult.cs OpenNest.Engine/StripNestEngine.cs
|
||||
git commit -m "refactor: remove StripNestResult.RemnantBox"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 5: PlateView Active Work Area Visualization
|
||||
|
||||
### Task 12: Add ActiveWorkArea to NestProgress
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestProgress.cs`
|
||||
|
||||
- [ ] **Step 1: Add ActiveWorkArea property to NestProgress**
|
||||
|
||||
`Box` is a reference type (class), so use `Box` directly (not `Box?`):
|
||||
|
||||
```csharp
|
||||
public Box ActiveWorkArea { get; set; }
|
||||
```
|
||||
|
||||
`NestProgress.cs` already has `using OpenNest.Geometry;` via the `Box` usage in existing properties. If not, add it.
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine`
|
||||
Expected: Build succeeds
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestProgress.cs
|
||||
git commit -m "feat: add ActiveWorkArea property to NestProgress"
|
||||
```
|
||||
|
||||
### Task 13: Draw active work area on PlateView
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest\Controls\PlateView.cs`
|
||||
|
||||
- [ ] **Step 1: Add ActiveWorkArea property**
|
||||
|
||||
Add a field and property to `PlateView`:
|
||||
```csharp
|
||||
private Box activeWorkArea;
|
||||
|
||||
public Box ActiveWorkArea
|
||||
{
|
||||
get => activeWorkArea;
|
||||
set
|
||||
{
|
||||
activeWorkArea = value;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add DrawActiveWorkArea method**
|
||||
|
||||
Add a private method to draw the dashed orange rectangle, using the same coordinate transform pattern as `DrawBox` (line 591-601):
|
||||
|
||||
```csharp
|
||||
private void DrawActiveWorkArea(Graphics g)
|
||||
{
|
||||
if (activeWorkArea == null)
|
||||
return;
|
||||
|
||||
var rect = new RectangleF
|
||||
{
|
||||
Location = PointWorldToGraph(activeWorkArea.Location),
|
||||
Width = LengthWorldToGui(activeWorkArea.Width),
|
||||
Height = LengthWorldToGui(activeWorkArea.Length)
|
||||
};
|
||||
rect.Y -= rect.Height;
|
||||
|
||||
using var pen = new Pen(Color.Orange, 2f)
|
||||
{
|
||||
DashStyle = DashStyle.Dash
|
||||
};
|
||||
g.DrawRectangle(pen, rect.X, rect.Y, rect.Width, rect.Height);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Call DrawActiveWorkArea in OnPaint**
|
||||
|
||||
In `OnPaint` (line 363-364), add the call after `DrawParts`:
|
||||
```csharp
|
||||
DrawPlate(e.Graphics);
|
||||
DrawParts(e.Graphics);
|
||||
DrawActiveWorkArea(e.Graphics);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest/Controls/PlateView.cs
|
||||
git commit -m "feat: draw active work area as dashed orange rectangle on PlateView"
|
||||
```
|
||||
|
||||
### Task 14: Wire ActiveWorkArea through progress callbacks
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest\Controls\PlateView.cs:828-829`
|
||||
- Modify: `OpenNest\Forms\MainForm.cs:760-761,895-896,955-956`
|
||||
|
||||
The `PlateView` and `MainForm` both have progress callbacks that already set `SetTemporaryParts`. Add `ActiveWorkArea` alongside those.
|
||||
|
||||
- [ ] **Step 1: Update PlateView.FillWithProgress callback**
|
||||
|
||||
At `PlateView.cs` line 828-829, the callback currently does:
|
||||
```csharp
|
||||
progressForm.UpdateProgress(p);
|
||||
SetTemporaryParts(p.BestParts);
|
||||
```
|
||||
|
||||
Add after `SetTemporaryParts`:
|
||||
```csharp
|
||||
ActiveWorkArea = p.ActiveWorkArea;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update MainForm progress callbacks**
|
||||
|
||||
There are three progress callback sites in `MainForm.cs`. At each one, after the `SetTemporaryParts` call, add:
|
||||
|
||||
At line 761 (after `activeForm.PlateView.SetTemporaryParts(p.BestParts);`):
|
||||
```csharp
|
||||
activeForm.PlateView.ActiveWorkArea = p.ActiveWorkArea;
|
||||
```
|
||||
|
||||
At line 896 (after `activeForm.PlateView.SetTemporaryParts(p.BestParts);`):
|
||||
```csharp
|
||||
activeForm.PlateView.ActiveWorkArea = p.ActiveWorkArea;
|
||||
```
|
||||
|
||||
At line 956 (after `activeForm.PlateView.SetTemporaryParts(p.BestParts);`):
|
||||
```csharp
|
||||
activeForm.PlateView.ActiveWorkArea = p.ActiveWorkArea;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Clear ActiveWorkArea when nesting completes**
|
||||
|
||||
In each nesting method's completion/cleanup path, clear the work area overlay. In `PlateView.cs` after the fill task completes (near `progressForm.ShowCompleted()`), add:
|
||||
```csharp
|
||||
ActiveWorkArea = null;
|
||||
```
|
||||
|
||||
Similarly in each `MainForm` nesting method's completion path:
|
||||
```csharp
|
||||
activeForm.PlateView.ActiveWorkArea = null;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest/Controls/PlateView.cs OpenNest/Forms/MainForm.cs
|
||||
git commit -m "feat: wire ActiveWorkArea from NestProgress to PlateView"
|
||||
```
|
||||
|
||||
### Task 15: Set ActiveWorkArea in Nest() method
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestEngineBase.cs` (the `Nest()` method updated in Task 8)
|
||||
|
||||
- [ ] **Step 1: Report ActiveWorkArea in Nest() progress**
|
||||
|
||||
In the `Nest()` method, after picking the largest remnant as the next work area (Task 8's change), set `ActiveWorkArea` on the progress report. Find the `ReportProgress` call inside or near the fill loop and ensure the progress object carries the current `workArea`.
|
||||
|
||||
The simplest approach: pass the work area through `ReportProgress`. In `NestEngineBase.ReportProgress` (the static helper), add `ActiveWorkArea = workArea` to the `NestProgress` initializer:
|
||||
|
||||
In `ReportProgress`, add to the `new NestProgress { ... }` block:
|
||||
```csharp
|
||||
ActiveWorkArea = workArea,
|
||||
```
|
||||
|
||||
This ensures every progress report includes the current work area being filled.
|
||||
|
||||
- [ ] **Step 2: Build and run tests**
|
||||
|
||||
Run: `dotnet build OpenNest.sln && dotnet test OpenNest.Tests -v minimal`
|
||||
Expected: Build succeeds, all tests pass
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestEngineBase.cs
|
||||
git commit -m "feat: report ActiveWorkArea in NestProgress from ReportProgress"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 6: Final Verification
|
||||
|
||||
### Task 16: Full build and test
|
||||
|
||||
**Files:** None (verification only)
|
||||
|
||||
- [ ] **Step 1: Run full build**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: 0 errors, 0 warnings related to remnant code
|
||||
|
||||
- [ ] **Step 2: Run all tests**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests -v minimal`
|
||||
Expected: All tests pass including new `RemnantFinderTests`
|
||||
|
||||
- [ ] **Step 3: Verify no stale references**
|
||||
|
||||
Run: `grep -rn "GetRemnants\|ComputeRemainderWithin\|TryRemainderImprovement\|MinRemnantDimension\|UsableRemnantArea" --include="*.cs" .`
|
||||
Expected: No matches in source files (only in docs/specs/plans)
|
||||
|
||||
- [ ] **Step 4: Final commit if any fixups needed**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: final cleanup after remnant finder extraction"
|
||||
```
|
||||
@@ -0,0 +1,92 @@
|
||||
# Iterative Halving Sweep in RotationSlideStrategy
|
||||
|
||||
## Problem
|
||||
|
||||
`RotationSlideStrategy.GenerateCandidatesForAxis` sweeps the full perpendicular range at `stepSize` (default 0.25"), calling `Helper.DirectionalDistance` at every step. Profiling shows `DirectionalDistance` accounts for 62% of CPU during best-fit computation. For parts with large bounding boxes, this produces hundreds of steps per direction, making the Pairs phase take 2.5+ minutes.
|
||||
|
||||
## Solution
|
||||
|
||||
Replace the single fine sweep with an iterative halving search inside `GenerateCandidatesForAxis`. Starting at a coarse step size (16× the fine step), each iteration identifies the best offset regions by slide distance, then halves the step and re-sweeps only within narrow windows around those regions. This converges to the optimal offsets in ~85 `DirectionalDistance` calls vs ~160 for a full fine sweep.
|
||||
|
||||
## Design
|
||||
|
||||
### Modified method: `GenerateCandidatesForAxis`
|
||||
|
||||
Located in `OpenNest.Engine/BestFit/RotationSlideStrategy.cs`. The public `GenerateCandidates` method and all other code remain unchanged.
|
||||
|
||||
**Current flow:**
|
||||
1. Sweep `alignedStart` to `perpMax` at `stepSize`
|
||||
2. At each offset: clone part2, position, compute offset lines, call `DirectionalDistance`, build `PairCandidate`
|
||||
|
||||
**New flow:**
|
||||
|
||||
**Constants (local to the method):**
|
||||
- `CoarseMultiplier = 16` — initial step is `stepSize * 16`
|
||||
- `MaxRegions = 5` — top-N regions to keep per iteration
|
||||
|
||||
**Algorithm:**
|
||||
|
||||
1. Compute `currentStep = stepSize * CoarseMultiplier`
|
||||
2. Set the initial sweep range to `[alignedStart, perpMax]` where `alignedStart = Math.Ceiling(perpMin / currentStep) * currentStep`
|
||||
3. **Iteration loop** — while `currentStep > stepSize`:
|
||||
a. Sweep all active regions at `currentStep`, collecting `(offset, slideDist)` tuples:
|
||||
- For each offset in each region: clone part2, position, compute offset lines, call `DirectionalDistance`
|
||||
- Skip if `slideDist >= double.MaxValue || slideDist < 0`
|
||||
b. Select top `MaxRegions` hits by `slideDist` ascending (tightest fit first), deduplicating any hits within `currentStep` of an already-selected hit
|
||||
c. Build new regions: for each selected hit, the new region is `[offset - currentStep, offset + currentStep]`, clamped to `[perpMin, perpMax]`
|
||||
d. Halve: `currentStep /= 2`
|
||||
e. Align each region's start to a multiple of `currentStep`
|
||||
4. **Final pass** — sweep all active regions at `stepSize`, generating full `PairCandidate` objects (same logic as current code: clone part2, position, compute offset lines, `DirectionalDistance`, build candidate)
|
||||
|
||||
**Iteration trace for a 20" range with `stepSize = 0.25`:**
|
||||
|
||||
| Pass | Step | Regions | Samples per region | Total samples |
|
||||
|------|------|---------|--------------------|---------------|
|
||||
| 1 | 4.0 | 1 (full range) | ~5 | ~5 |
|
||||
| 2 | 2.0 | up to 5 | ~4 | ~20 |
|
||||
| 3 | 1.0 | up to 5 | ~4 | ~20 |
|
||||
| 4 | 0.5 | up to 5 | ~4 | ~20 |
|
||||
| 5 (final) | 0.25 | up to 5 | ~4 | ~20 (generates candidates) |
|
||||
| **Total** | | | | **~85** vs **~160 current** |
|
||||
|
||||
**Alignment:** Each pass aligns its sweep start to a multiple of `currentStep`. Since `currentStep` is always a power-of-two multiple of `stepSize`, offset=0 is always a sample point when it falls within a region. This preserves perfect grid arrangements for rectangular parts.
|
||||
|
||||
**Region deduplication:** When selecting top hits, any hit whose offset is within `currentStep` of a previously selected hit is skipped. This prevents overlapping refinement windows from wasting samples on the same area.
|
||||
|
||||
### Integration points
|
||||
|
||||
The changes are entirely within the private method `GenerateCandidatesForAxis`. The method signature, parameters, and return type (`List<PairCandidate>`) are unchanged. The only behavioral difference is that it generates fewer candidates overall (only from the promising regions), but those candidates cover the same quality range because the iterative search converges on the best offsets.
|
||||
|
||||
### Performance
|
||||
|
||||
- Current: ~160 `DirectionalDistance` calls per direction (20" range / 0.25 step)
|
||||
- Iterative halving: ~85 calls (5 + 20 + 20 + 20 + 20)
|
||||
- ~47% reduction in `DirectionalDistance` calls per direction
|
||||
- Coarse passes are cheaper per-call since they only store `(offset, slideDist)` tuples rather than building full `PairCandidate` objects
|
||||
- Total across 4 directions × N angles: proportional reduction throughout
|
||||
- For larger parts (40"+ range), the savings are even greater since the coarse pass covers the range in very few samples
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `OpenNest.Engine/BestFit/RotationSlideStrategy.cs` | Replace single sweep in `GenerateCandidatesForAxis` with iterative halving sweep |
|
||||
|
||||
## What Doesn't Change
|
||||
|
||||
- `RotationSlideStrategy.GenerateCandidates` — unchanged, calls `GenerateCandidatesForAxis` as before
|
||||
- `BestFitFinder` — unchanged, calls `strategy.GenerateCandidates` as before
|
||||
- `BestFitCache` — unchanged
|
||||
- `PairEvaluator` / `IPairEvaluator` — unchanged
|
||||
- `PairCandidate`, `BestFitResult`, `BestFitFilter` — unchanged
|
||||
- `Helper.DirectionalDistance`, `Helper.GetOffsetPartLines` — reused as-is
|
||||
- `NestEngine.FillWithPairs` — unchanged caller
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- **Part smaller than initial coarseStep:** The first pass produces very few samples (possibly 1-2), but each subsequent halving still narrows correctly. For tiny parts, the total range may be smaller than `coarseStep`, so the algorithm effectively skips to finer passes quickly.
|
||||
- **Refinement regions overlap after halving:** Deduplication at each iteration prevents selecting nearby hits. Even if two regions share a boundary after halving, at worst one offset is evaluated twice — negligible cost.
|
||||
- **No valid hits at any pass:** If all offsets at a given step produce invalid slide distances, the hit list is empty, no regions are generated, and subsequent passes produce no candidates. This matches current behavior for parts that can't pair in the given direction.
|
||||
- **Sweep region extends past bounds:** All regions are clamped to `[perpMin, perpMax]` at each iteration.
|
||||
- **Only one valid region found:** The algorithm works correctly with 1 region — it just refines a single window instead of 5. This is common for simple rectangular parts where there's one clear best offset.
|
||||
- **stepSize is not a power of two:** The halving produces steps like 4.0 → 2.0 → 1.0 → 0.5 → 0.25 regardless of whether `stepSize` is a power of two. The loop condition `currentStep > stepSize` terminates correctly because `currentStep` will eventually equal `stepSize` after enough halvings (since `CoarseMultiplier` is a power of 2).
|
||||
@@ -1,73 +1,51 @@
|
||||
# Lead-In Assignment UI Design
|
||||
# Lead-In Assignment UI Design (Revised)
|
||||
|
||||
## Overview
|
||||
|
||||
Add a dialog and menu item for assigning lead-ins to parts on a plate. The dialog provides two parameter sets — tabbed (V lead-in/out) and standard (straight lead-in with overtravel) — and applies them per-part based on a new `Part.IsTabbed` flag. The `ContourCuttingStrategy` auto-detects corner vs mid-entity pierce points to determine lead-out behavior.
|
||||
Add a dialog and menu item for assigning lead-ins to parts on a plate. The dialog provides separate parameter sets for external (perimeter) and internal (cutout/hole) contours. Lead-in/lead-out moves are tagged with the existing `LayerType.Leadin`/`LayerType.Leadout` enum on each code, making them distinguishable from normal cut code and easy to strip and re-apply.
|
||||
|
||||
This is the "manual override" path — when the user assigns lead-ins via this dialog, each part gets `HasManualLeadIns = true` so the automated `PlateProcessor` pipeline skips it.
|
||||
## Design Principles
|
||||
|
||||
## Model Changes
|
||||
|
||||
### Part (OpenNest.Core)
|
||||
|
||||
Add two properties:
|
||||
|
||||
```csharp
|
||||
public bool IsTabbed { get; set; }
|
||||
```
|
||||
|
||||
Indicates the part uses tabbed lead-in parameters (V lead-in/out). Defaults to `false` — all parts use standard parameters until a tab assignment UI is built.
|
||||
|
||||
```csharp
|
||||
public void ApplyLeadIns(Program processedProgram)
|
||||
{
|
||||
Program = processedProgram;
|
||||
HasManualLeadIns = true;
|
||||
}
|
||||
```
|
||||
|
||||
Atomically sets the processed program and marks lead-ins as manually assigned. The original drawing program is preserved on `Part.BaseDrawing.Program`. This is the intentional "manual" path — `PlateProcessor` (the automated path) stores results non-destructively in `ProcessedPart.ProcessedProgram` and does not call this method.
|
||||
|
||||
### PlateHelper (OpenNest.Engine)
|
||||
|
||||
Change `PlateHelper` from `internal static` to `public static` so the UI project can access `GetExitPoint`.
|
||||
- **LayerType tagging.** Every lead-in move gets `Layer = LayerType.Leadin`, every lead-out move gets `Layer = LayerType.Leadout`. Normal contour cuts keep `Layer = LayerType.Cut` (the default). This uses the existing `LayerType` enum and `LinearMove.Layer`/`ArcMove.Layer` properties — no new enums or flags.
|
||||
- **Always rebuild from base.** `ContourCuttingStrategy.Apply` converts the input program to geometry via `Program.ToGeometry()` and `ShapeProfile`. These do NOT filter by layer — all entities (including lead-in/out codes if present) would be processed. Therefore, the strategy must always receive a clean program (cut codes only). The flow always clones from `Part.BaseDrawing.Program` and re-rotates before applying.
|
||||
- **Non-destructive.** `Part.BaseDrawing.Program` is never modified. The strategy builds a fresh `Program` with lead-ins baked in. `Part.HasManualLeadIns` (existing property) is set to `true` when lead-ins are assigned, so the automated `PlateProcessor` pipeline skips these parts.
|
||||
|
||||
## Lead-In Dialog (`LeadInForm`)
|
||||
|
||||
A WinForms dialog in `OpenNest/Forms/LeadInForm.cs` with two groups of numeric inputs.
|
||||
A WinForms dialog in `OpenNest/Forms/LeadInForm.cs` with two parameter groups, one checkbox, and OK/Cancel buttons.
|
||||
|
||||
### Tabbed Group (V lead-in/lead-out)
|
||||
- Lead-in angle (degrees) — default 60
|
||||
- Lead-in length (inches) — default 0.15
|
||||
- Lead-out angle (degrees) — default 60
|
||||
- Lead-out length (inches) — default 0.08
|
||||
|
||||
These form a V shape at the pierce point where the breaking point lands on the part edge, leaving a less noticeable tab spot.
|
||||
|
||||
### Standard Group
|
||||
### External Group (Perimeter)
|
||||
- Lead-in angle (degrees) — default 90
|
||||
- Lead-in length (inches) — default 0.125
|
||||
- Overtravel distance (inches) — default 0.03
|
||||
- Overtravel (inches) — default 0.03
|
||||
|
||||
The lead-out behavior for standard parts depends on pierce point location (auto-detected by `ContourCuttingStrategy`):
|
||||
- **Corner pierce:** straight `LineLeadOut` extending past the corner for the overtravel distance
|
||||
- **Mid-entity pierce:** handled at the `ContourCuttingStrategy` level (not via `LeadOut.Generate`) — the strategy appends overcut moves that follow the contour path for the overtravel distance after the shape's closing segment
|
||||
### Internal Group (Cutouts & Holes)
|
||||
- Lead-in angle (degrees) — default 90
|
||||
- Lead-in length (inches) — default 0.125
|
||||
- Overtravel (inches) — default 0.03
|
||||
|
||||
### Update Existing Checkbox
|
||||
- **"Update existing lead-ins"** — checked by default
|
||||
- When checked: strip all existing lead-in/lead-out codes from every part before re-applying
|
||||
- When unchecked: only process parts that have no `LayerType.Leadin` codes in their program
|
||||
|
||||
### Dialog Result
|
||||
|
||||
```csharp
|
||||
public class LeadInSettings
|
||||
{
|
||||
// Tabbed parameters (V lead-in/out)
|
||||
public double TabbedLeadInAngle { get; set; } = 60;
|
||||
public double TabbedLeadInLength { get; set; } = 0.15;
|
||||
public double TabbedLeadOutAngle { get; set; } = 60;
|
||||
public double TabbedLeadOutLength { get; set; } = 0.08;
|
||||
// External (perimeter) parameters
|
||||
public double ExternalLeadInAngle { get; set; } = 90;
|
||||
public double ExternalLeadInLength { get; set; } = 0.125;
|
||||
public double ExternalOvertravel { get; set; } = 0.03;
|
||||
|
||||
// Standard parameters
|
||||
public double StandardLeadInAngle { get; set; } = 90;
|
||||
public double StandardLeadInLength { get; set; } = 0.125;
|
||||
public double StandardOvertravel { get; set; } = 0.03;
|
||||
// Internal (cutout/hole) parameters
|
||||
public double InternalLeadInAngle { get; set; } = 90;
|
||||
public double InternalLeadInLength { get; set; } = 0.125;
|
||||
public double InternalOvertravel { get; set; } = 0.03;
|
||||
|
||||
// Behavior
|
||||
public bool UpdateExisting { get; set; } = true;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -75,31 +53,47 @@ Note: `LineLeadIn.ApproachAngle` and `LineLeadOut.ApproachAngle` store degrees (
|
||||
|
||||
## LeadInSettings to CuttingParameters Mapping
|
||||
|
||||
The caller builds two `CuttingParameters` instances up front — one for tabbed parts, one for standard — rather than swapping parameters per iteration:
|
||||
The caller builds one `CuttingParameters` instance with separate external and internal settings. ArcCircle shares the internal settings:
|
||||
|
||||
**Tabbed:**
|
||||
```
|
||||
ExternalLeadIn = new LineLeadIn { ApproachAngle = settings.TabbedLeadInAngle, Length = settings.TabbedLeadInLength }
|
||||
ExternalLeadOut = new LineLeadOut { ApproachAngle = settings.TabbedLeadOutAngle, Length = settings.TabbedLeadOutLength }
|
||||
InternalLeadIn = (same)
|
||||
InternalLeadOut = (same)
|
||||
ArcCircleLeadIn = (same)
|
||||
ArcCircleLeadOut = (same)
|
||||
ExternalLeadIn = new LineLeadIn { ApproachAngle = settings.ExternalLeadInAngle, Length = settings.ExternalLeadInLength }
|
||||
ExternalLeadOut = new LineLeadOut { Length = settings.ExternalOvertravel }
|
||||
InternalLeadIn = new LineLeadIn { ApproachAngle = settings.InternalLeadInAngle, Length = settings.InternalLeadInLength }
|
||||
InternalLeadOut = new LineLeadOut { Length = settings.InternalOvertravel }
|
||||
ArcCircleLeadIn = (same as Internal)
|
||||
ArcCircleLeadOut = (same as Internal)
|
||||
```
|
||||
|
||||
**Standard:**
|
||||
```
|
||||
ExternalLeadIn = new LineLeadIn { ApproachAngle = settings.StandardLeadInAngle, Length = settings.StandardLeadInLength }
|
||||
ExternalLeadOut = new LineLeadOut { Length = settings.StandardOvertravel }
|
||||
InternalLeadIn = (same)
|
||||
InternalLeadOut = (same)
|
||||
ArcCircleLeadIn = (same)
|
||||
ArcCircleLeadOut = (same)
|
||||
## Detecting Existing Lead-Ins
|
||||
|
||||
Check whether a part's program contains lead-in codes by inspecting `LayerType`:
|
||||
|
||||
```csharp
|
||||
bool HasLeadIns(Program program)
|
||||
{
|
||||
foreach (var code in program.Codes)
|
||||
{
|
||||
if (code is LinearMove lm && lm.Layer == LayerType.Leadin)
|
||||
return true;
|
||||
if (code is ArcMove am && am.Layer == LayerType.Leadin)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
For standard parts, the `LineLeadOut` handles the corner case. The mid-entity contour-follow case is handled at the `ContourCuttingStrategy` level (see below).
|
||||
## Preparing a Clean Program
|
||||
|
||||
All three contour types (external, internal, arc/circle) get the same settings for this iteration.
|
||||
**Important:** `Program.ToGeometry()` and `ShapeProfile` process ALL entities regardless of layer. They do NOT filter out lead-in/lead-out codes. If the strategy receives a program that already has lead-in codes baked in, those codes would be converted to geometry entities and corrupt the perimeter/cutout detection.
|
||||
|
||||
Therefore, the flow always starts from a clean base:
|
||||
|
||||
```csharp
|
||||
var cleanProgram = part.BaseDrawing.Program.Clone() as Program;
|
||||
cleanProgram.Rotate(part.Rotation);
|
||||
```
|
||||
|
||||
This produces a program with only the original cut geometry at the part's current rotation angle, safe to feed into `ContourCuttingStrategy.Apply`.
|
||||
|
||||
## Menu Integration
|
||||
|
||||
@@ -112,74 +106,139 @@ Click handler in `MainForm` delegates to `EditNestForm.AssignLeadIns()`.
|
||||
```
|
||||
1. Open LeadInForm dialog
|
||||
2. If user clicks OK:
|
||||
a. Get LeadInSettings from dialog
|
||||
b. Build two ContourCuttingStrategy instances:
|
||||
- tabbedStrategy with tabbed CuttingParameters
|
||||
- standardStrategy with standard CuttingParameters
|
||||
a. Get LeadInSettings from dialog (includes UpdateExisting flag)
|
||||
b. Build one ContourCuttingStrategy with CuttingParameters from settings
|
||||
c. Get exit point: PlateHelper.GetExitPoint(plate) [now public]
|
||||
d. Set currentPoint = exitPoint
|
||||
e. For each part on the current plate (in list order):
|
||||
- Skip if part.HasManualLeadIns is true
|
||||
e. For each part on the current plate (in sequence order):
|
||||
- If !updateExisting and part already has lead-in codes → skip
|
||||
- Build clean program: clone BaseDrawing.Program, rotate to part.Rotation
|
||||
- Compute localApproach = currentPoint - part.Location
|
||||
- Pick strategy = part.IsTabbed ? tabbedStrategy : standardStrategy
|
||||
- Call strategy.Apply(part.Program, localApproach) → CuttingResult
|
||||
- Call strategy.Apply(cleanProgram, localApproach) → CuttingResult
|
||||
- Call part.ApplyLeadIns(cutResult.Program)
|
||||
(this sets Program AND HasManualLeadIns = true atomically)
|
||||
(this sets Program, HasManualLeadIns = true, and recalculates bounds)
|
||||
- Update currentPoint = cutResult.LastCutPoint + part.Location
|
||||
f. Invalidate PlateView to show updated geometry
|
||||
```
|
||||
|
||||
Note: `ContourCuttingStrategy.Apply` builds a new `Program` from scratch — it reads `part.Program` but does not modify it. The returned `CuttingResult.Program` is a fresh instance with lead-ins baked in.
|
||||
Note: The clean program is always rebuilt from `BaseDrawing.Program` — never from the current `Part.Program` — because `Program.ToGeometry()` and `ShapeProfile` do not filter by layer and would be corrupted by existing lead-in codes.
|
||||
|
||||
Note: Setting `Part.Program` requires a public method since the setter is `private`. See Model Changes below.
|
||||
|
||||
## Model Changes
|
||||
|
||||
### Part (OpenNest.Core)
|
||||
|
||||
Add a method to apply lead-ins and mark the part:
|
||||
|
||||
```csharp
|
||||
public void ApplyLeadIns(Program processedProgram)
|
||||
{
|
||||
Program = processedProgram;
|
||||
HasManualLeadIns = true;
|
||||
UpdateBounds();
|
||||
}
|
||||
```
|
||||
|
||||
This atomically sets the processed program, marks `HasManualLeadIns = true` (so `PlateProcessor` skips this part), and recalculates bounds. The private setter on `Program` stays private — `ApplyLeadIns` is the public API.
|
||||
|
||||
### PlateHelper (OpenNest.Engine)
|
||||
|
||||
Change `PlateHelper` from `internal static` to `public static` so the UI project can access `GetExitPoint`.
|
||||
|
||||
## ContourCuttingStrategy Changes
|
||||
|
||||
### Corner vs Mid-Entity Auto-Detection
|
||||
### LayerType Tagging
|
||||
|
||||
When generating the lead-out for standard (non-tabbed) parts, the strategy detects whether the pierce point landed on a corner or mid-entity. Detection uses the `out Entity` from `ClosestPointTo` with type-specific endpoint checks:
|
||||
When emitting lead-in moves, stamp each code with `Layer = LayerType.Leadin`. When emitting lead-out moves, stamp with `Layer = LayerType.Leadout`. This applies to all move types (`LinearMove`, `ArcMove`) generated by `LeadIn.Generate()` and `LeadOut.Generate()`.
|
||||
|
||||
The `LeadIn.Generate()` and `LeadOut.Generate()` methods return `List<ICode>`. After calling them, the strategy sets the `Layer` property on each returned code:
|
||||
|
||||
```csharp
|
||||
bool isCorner;
|
||||
if (entity is Line line)
|
||||
isCorner = closestPt.DistanceTo(line.StartPoint) < Tolerance.Epsilon
|
||||
var leadInCodes = leadIn.Generate(piercePoint, normal, winding);
|
||||
foreach (var code in leadInCodes)
|
||||
{
|
||||
if (code is LinearMove lm) lm.Layer = LayerType.Leadin;
|
||||
else if (code is ArcMove am) am.Layer = LayerType.Leadin;
|
||||
}
|
||||
result.Codes.AddRange(leadInCodes);
|
||||
```
|
||||
|
||||
Same pattern for lead-out codes with `LayerType.Leadout`.
|
||||
|
||||
### Corner vs Mid-Entity Auto-Detection
|
||||
|
||||
When generating the lead-out, the strategy detects whether the pierce point landed on a corner or mid-entity. Detection uses the `out Entity` from `ClosestPointTo` with type-specific endpoint checks:
|
||||
|
||||
```csharp
|
||||
private static bool IsCornerPierce(Vector closestPt, Entity entity)
|
||||
{
|
||||
if (entity is Line line)
|
||||
return closestPt.DistanceTo(line.StartPoint) < Tolerance.Epsilon
|
||||
|| closestPt.DistanceTo(line.EndPoint) < Tolerance.Epsilon;
|
||||
else if (entity is Arc arc)
|
||||
isCorner = closestPt.DistanceTo(arc.StartPoint()) < Tolerance.Epsilon
|
||||
if (entity is Arc arc)
|
||||
return closestPt.DistanceTo(arc.StartPoint()) < Tolerance.Epsilon
|
||||
|| closestPt.DistanceTo(arc.EndPoint()) < Tolerance.Epsilon;
|
||||
else
|
||||
isCorner = false;
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
Note: `Entity` has no polymorphic `StartPoint`/`EndPoint` — `Line` has properties, `Arc` has methods, `Circle` has neither.
|
||||
|
||||
### Corner Lead-Out
|
||||
|
||||
Delegates to `LeadOut.Generate()` as normal — `LineLeadOut` extends past the corner along the contour normal.
|
||||
Delegates to `LeadOut.Generate()` as normal — `LineLeadOut` extends past the corner along the contour normal. Moves are tagged `LayerType.Leadout`.
|
||||
|
||||
### Mid-Entity Lead-Out (Contour-Follow Overtravel)
|
||||
|
||||
Handled at the `ContourCuttingStrategy` level, NOT via `LeadOut.Generate()` (which lacks access to the contour shape). After the reindexed shape's moves are emitted, the strategy appends additional moves that retrace the beginning of the contour for the overtravel distance. This is done by:
|
||||
Handled at the `ContourCuttingStrategy` level, NOT via `LeadOut.Generate()` (which lacks access to the contour shape). The overtravel distance is read from the selected `LeadOut` for the current contour type — `SelectLeadOut(contourType)`. Since external and internal have separate `LineLeadOut` instances in `CuttingParameters`, the overtravel distance automatically varies by contour type.
|
||||
|
||||
```csharp
|
||||
var leadOut = SelectLeadOut(contourType);
|
||||
if (IsCornerPierce(closestPt, entity))
|
||||
{
|
||||
// Corner: delegate to LeadOut.Generate() as normal
|
||||
var codes = leadOut.Generate(closestPt, normal, winding);
|
||||
// tag as LayerType.Leadout
|
||||
}
|
||||
else if (leadOut is LineLeadOut lineLeadOut && lineLeadOut.Length > 0)
|
||||
{
|
||||
// Mid-entity: retrace the start of the contour for overtravel distance
|
||||
var codes = GenerateOvertravelMoves(reindexed, lineLeadOut.Length);
|
||||
// tag as LayerType.Leadout
|
||||
}
|
||||
```
|
||||
|
||||
The contour-follow retraces the beginning of the reindexed shape:
|
||||
|
||||
1. Walking the reindexed shape's entities from the start
|
||||
2. Accumulating distance until `overtravel` is reached
|
||||
2. Accumulating distance until overtravel is reached
|
||||
3. Emitting `LinearMove`/`ArcMove` codes for those segments (splitting the last segment if needed)
|
||||
4. Tagging all emitted moves as `LayerType.Leadout`
|
||||
|
||||
This produces a clean overcut that ensures the contour fully closes.
|
||||
|
||||
### Tabbed Lead-Out
|
||||
### Lead-out behavior summary
|
||||
|
||||
For tabbed parts, the lead-out is always a `LineLeadOut` at the specified angle and length, regardless of corner/mid-entity. This creates the V shape.
|
||||
| Contour Type | Pierce Location | Lead-Out Behavior |
|
||||
|---|---|---|
|
||||
| External | Corner | `LineLeadOut.Generate()` — extends past corner |
|
||||
| External | Mid-entity | Contour-follow overtravel moves |
|
||||
| Internal | Corner | `LineLeadOut.Generate()` — extends past corner |
|
||||
| Internal | Mid-entity | Contour-follow overtravel moves |
|
||||
| ArcCircle | N/A (always mid-entity) | Contour-follow overtravel moves |
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
OpenNest.Core/
|
||||
├── Part.cs # add IsTabbed, ApplyLeadIns
|
||||
├── Part.cs # add ApplyLeadIns method
|
||||
└── CNC/CuttingStrategy/
|
||||
└── ContourCuttingStrategy.cs # corner vs mid-entity lead-out detection
|
||||
└── ContourCuttingStrategy.cs # LayerType tagging, Overtravel, corner detection
|
||||
|
||||
OpenNest.Engine/
|
||||
└── Sequencing/
|
||||
└── PlateHelper.cs # change internal → public
|
||||
└── PlateHelper.cs # change internal → public
|
||||
|
||||
OpenNest/
|
||||
├── Forms/
|
||||
@@ -191,16 +250,11 @@ OpenNest/
|
||||
└── LeadInSettings.cs # settings DTO
|
||||
```
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- `Part.IsTabbed` defaults to `false` with no UI to set it yet. All parts use standard parameters until a tab assignment UI is built. The tabbed code path is present but exercised only programmatically or via MCP tools for now.
|
||||
- `IsTabbed` is not yet serialized through the nest file format (NestWriter/NestReader). Will need serialization support when the tab assignment UI is added.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Per-contour-type lead-in configuration (deferred to database/datagrid UI)
|
||||
- Lead-in visualization in PlateView (separate enhancement)
|
||||
- Tabbed (V lead-in/out) parameters and `Part.IsTabbed` — deferred until tab assignment UI
|
||||
- Slug destruct for internal cutouts
|
||||
- Lead-in visualization colors in PlateView (separate enhancement)
|
||||
- Database storage of lead-in presets by material/thickness
|
||||
- Tab assignment UI (setting `Part.IsTabbed`)
|
||||
- MicrotabLeadOut integration
|
||||
- Nest file serialization of `IsTabbed`
|
||||
- Nest file serialization changes
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# Remnant Finder Design
|
||||
|
||||
## Problem
|
||||
|
||||
Remnant detection is currently scattered across four places in the codebase, all using simple edge-strip heuristics that miss interior gaps and produce unreliable results:
|
||||
|
||||
- `Plate.GetRemnants()` — finds strips along plate edges from global min/max of part bounding boxes
|
||||
- `DefaultNestEngine.TryRemainderImprovement()` / `TryStripRefill()` / `ClusterParts()` — clusters parts into rows/columns and refills the last incomplete cluster
|
||||
- `FillScore.ComputeUsableRemnantArea()` — estimates remnant area from rightmost/topmost part edges for fill scoring
|
||||
- `NestEngineBase.ComputeRemainderWithin()` — picks the larger of one horizontal or vertical strip
|
||||
|
||||
These approaches only find single edge strips and cannot discover multiple or interior empty regions.
|
||||
|
||||
## Solution
|
||||
|
||||
A standalone `RemnantFinder` class in `OpenNest.Engine` that uses edge projection to find all rectangular empty regions in a work area given a set of obstacle bounding boxes. This decouples remnant detection from the nesting engine and enables an iterative workflow:
|
||||
|
||||
1. Fill an area
|
||||
2. Get all remnants
|
||||
3. Pick a remnant, fill it
|
||||
4. Get all remnants again (repeat)
|
||||
|
||||
## API
|
||||
|
||||
### `RemnantFinder` — `OpenNest.Engine`
|
||||
|
||||
```csharp
|
||||
public class RemnantFinder
|
||||
{
|
||||
// Constructor
|
||||
public RemnantFinder(Box workArea, List<Box> obstacles = null);
|
||||
|
||||
// Mutable obstacle management
|
||||
public List<Box> Obstacles { get; }
|
||||
public void AddObstacle(Box obstacle);
|
||||
public void AddObstacles(IEnumerable<Box> obstacles);
|
||||
public void ClearObstacles();
|
||||
|
||||
// Core method
|
||||
public List<Box> FindRemnants(double minDimension = 0);
|
||||
|
||||
// Convenience factory
|
||||
public static RemnantFinder FromPlate(Plate plate);
|
||||
}
|
||||
```
|
||||
|
||||
### `FindRemnants` Algorithm (Edge Projection)
|
||||
|
||||
1. Collect all unique X coordinates from obstacle left/right edges + work area left/right.
|
||||
2. Collect all unique Y coordinates from obstacle bottom/top edges + work area bottom/top.
|
||||
3. Form candidate rectangles from every adjacent `(x[i], x[i+1])` x `(y[j], y[j+1])` cell in the grid.
|
||||
4. Filter out any candidate that overlaps any obstacle.
|
||||
5. Merge adjacent empty cells into larger rectangles — greedy row-first merge: scan cells left-to-right within each row and merge horizontally where cells share the same Y span, then merge vertically where resulting rectangles share the same X span and are adjacent in Y. This produces "good enough" large rectangles without requiring maximal rectangle decomposition.
|
||||
6. Filter by `minDimension` — both width and height must be >= the threshold.
|
||||
7. Return sorted by area descending.
|
||||
|
||||
### `FromPlate` Factory
|
||||
|
||||
Extracts `plate.WorkArea()` as the work area and each part's bounding box offset by `plate.PartSpacing` as obstacles.
|
||||
|
||||
## Scoping
|
||||
|
||||
The `RemnantFinder` operates on whatever work area it's given. When used within the strip nester or sub-region fills, pass the sub-region's work area and only the parts placed within it — not the full plate. This prevents remnants from spanning into unrelated layout regions.
|
||||
|
||||
## Thread Safety
|
||||
|
||||
`RemnantFinder` is not thread-safe. Each thread/task should use its own instance. The `FromPlate` factory creates a snapshot of obstacles at construction time, so concurrent reads of the plate during construction should be avoided.
|
||||
|
||||
## Removals
|
||||
|
||||
### `DefaultNestEngine`
|
||||
|
||||
Remove the entire remainder phase:
|
||||
- `TryRemainderImprovement()`
|
||||
- `TryStripRefill()`
|
||||
- `ClusterParts()`
|
||||
- `NestPhase.Remainder` reporting in both `Fill()` overrides
|
||||
|
||||
The engine's `Fill()` becomes single-pass. Iterative remnant filling is the caller's responsibility.
|
||||
|
||||
### `NestPhase.Remainder`
|
||||
|
||||
Remove the `Remainder` value from the `NestPhase` enum. Clean up corresponding switch cases in:
|
||||
- `NestEngineBase.FormatPhaseName()`
|
||||
- `NestProgressForm.FormatPhase()`
|
||||
|
||||
### `Plate`
|
||||
|
||||
Remove `GetRemnants()` — fully replaced by `RemnantFinder.FromPlate(plate)`.
|
||||
|
||||
### `FillScore`
|
||||
|
||||
Remove remnant-related members:
|
||||
- `MinRemnantDimension` constant
|
||||
- `UsableRemnantArea` property
|
||||
- `ComputeUsableRemnantArea()` method
|
||||
- Remnant area from the `CompareTo` ordering
|
||||
|
||||
Constructor simplifies from `FillScore(int count, double usableRemnantArea, double density)` to `FillScore(int count, double density)`. The `Compute` factory method drops the `ComputeUsableRemnantArea` call accordingly.
|
||||
|
||||
### `NestProgress`
|
||||
|
||||
Remove `UsableRemnantArea` property. Update `NestEngineBase.ReportProgress()` to stop computing/setting it. Update `NestProgressForm` to stop displaying it.
|
||||
|
||||
### `NestEngineBase`
|
||||
|
||||
Replace `ComputeRemainderWithin()` with `RemnantFinder` in the `Nest()` method. The current `Nest()` fills an item, then calls `ComputeRemainderWithin` to get a single remainder box for the next item. Updated behavior: after filling, create a `RemnantFinder` with the current work area and all placed parts, call `FindRemnants()`, and use the largest remnant as the next work area. If no remnants exist, the fill loop stops.
|
||||
|
||||
### `StripNestResult`
|
||||
|
||||
Remove `RemnantBox` property. The `StripNestEngine.TryOrientation` assignment to `result.RemnantBox` is removed — the value was stored but never read externally. The `StripNestResult` class itself is retained (it still carries `Parts`, `StripBox`, `Score`, `Direction`).
|
||||
|
||||
## Caller Updates
|
||||
|
||||
### `NestingTools` (MCP)
|
||||
|
||||
`fill_remnants` switches from `plate.GetRemnants()` to:
|
||||
```csharp
|
||||
var finder = RemnantFinder.FromPlate(plate);
|
||||
var remnants = finder.FindRemnants(minDimension);
|
||||
```
|
||||
|
||||
### `InspectionTools` (MCP)
|
||||
|
||||
`get_plate_info` switches from `plate.GetRemnants()` to `RemnantFinder.FromPlate(plate).FindRemnants()`.
|
||||
|
||||
### Debug Logging
|
||||
|
||||
`DefaultNestEngine.FillWithPairs()` logs `bestScore.UsableRemnantArea` — update to log only count and density after the `FillScore` simplification.
|
||||
|
||||
### UI / Console callers
|
||||
|
||||
Any caller that previously relied on `TryRemainderImprovement` getting called automatically inside `Fill()` will need to implement the iterative loop: fill -> find remnants -> fill remnant -> repeat.
|
||||
|
||||
## PlateView Work Area Visualization
|
||||
|
||||
When an area is being filled (during the iterative workflow), the `PlateView` control displays the active work area's outline as a dashed orange rectangle. The outline persists while that area is being filled and disappears when the fill completes.
|
||||
|
||||
**Implementation:** Add a `Box ActiveWorkArea` property to `PlateView` (`Box` is a reference type, so `null` means no overlay). When set, the paint handler draws a dashed rectangle at that location. The `NestProgress` class gets a new `Box ActiveWorkArea` property so the progress pipeline carries the current work area from the engine to the UI. The existing progress callbacks in `PlateView.FillWithProgress` and `MainForm` set `PlateView.ActiveWorkArea` from the progress object, alongside the existing `SetTemporaryParts` calls. `NestEngineBase.ReportProgress` populates `ActiveWorkArea` from its `workArea` parameter.
|
||||
|
||||
## Future
|
||||
|
||||
The edge projection algorithm is embarrassingly parallel — each candidate rectangle's overlap check is independent. This makes it a natural fit for GPU acceleration via `OpenNest.Gpu` in the future.
|
||||
Reference in New Issue
Block a user