Compare commits

...

35 Commits

Author SHA1 Message Date
aj 195e29da52 fix(ui): rotate grouped parts around shared center with dedup
Parts created by fill operations share a Program instance via
CloneAtOffset. RotateSelectedParts called Part.Rotate on each,
compounding Program.Rotate on the shared object (1x, 2x, 3x…)
and producing inconsistent bounding boxes. Fix tracks rotated
programs with a HashSet, rotates locations around the group center,
and anchors the bounding box corner to prevent drift.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:11:07 -04:00
aj 0b9a42e84c fix(engine): use smallest remaining part as minimum remnant size
Skip remnants that are too small to fit any remaining part, avoiding
wasted fill attempts. Recalculated each iteration as quantities deplete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:51:30 -04:00
aj 00ccf82196 fix(engine): apply shrink loop to remnant fills in StripNestEngine
Remainder items were being filled into the full remnant box without
compaction. Added ShrinkFill helper that fills then shrinks the box
horizontally and vertically while maintaining the same part count.
This matches the strip item's shrink behavior and produces tighter
layouts that leave more usable space for subsequent items.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:50:14 -04:00
aj a41a08c9af fix(engine): use local quantity tracking in StripNestEngine remnant loop
The iterative remnant fill was mutating shared NestItem.Quantity objects,
causing the second TryOrientation call (left) to see depleted quantities
from the first call (bottom). Use a local dictionary instead so both
orientations start with the full quantities.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:23:32 -04:00
aj 3d23943b69 fix(engine): use RemnantFinder for iterative remnant filling in StripNestEngine
Replace the single-pass guillotine split approach with RemnantFinder-based
iteration. After each fill, re-discover all free rectangles and try all
remaining items again until no more progress is made. This fills gaps that
were previously left empty after the initial strip + remainder layout.

Also change ActiveWorkArea border color from orange to red.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:11:28 -04:00
aj 51b482aefb test: add RemnantFinder edge cases and FillScore comparison tests
RemnantFinder: obstacle clipping, overlapping obstacles, iterative
workflow, grid pattern, no-overlap invariant, constructor/AddObstacles.
FillScore: count-vs-density ordering, operators, Compute edge cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:02:15 -04:00
aj 6419f6b8a2 feat: report ActiveWorkArea in NestProgress from ReportProgress
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 12:58:17 -04:00
aj 4911d05869 feat: wire ActiveWorkArea from NestProgress to PlateView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 12:57:51 -04:00
aj 2b4f7c4e80 feat: draw active work area as dashed orange rectangle on PlateView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 12:56:37 -04:00
aj 2c62f601ca feat: add ActiveWorkArea property to NestProgress
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 12:55:54 -04:00
aj 2bda7c9f0f refactor: remove StripNestResult.RemnantBox
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 12:54:19 -04:00
aj 9d99e3a003 refactor: update MCP tools to use RemnantFinder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 12:53:48 -04:00
aj b42348665f refactor: remove Plate.GetRemnants(), replaced by RemnantFinder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 12:53:29 -04:00
aj 4d30178752 refactor: replace ComputeRemainderWithin with RemnantFinder in Nest()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 12:51:25 -04:00
aj 2b578fa006 refactor: remove NestPhase.Remainder enum value and switch cases
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 12:50:48 -04:00
aj 78c625361e refactor: remove remainder phase from DefaultNestEngine
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 12:50:26 -04:00
aj dd3a2b0e9a refactor: remove UsableRemnantArea from NestProgress and UI 2026-03-16 12:47:57 -04:00
aj 9b21a0c6d7 fix: update FillWithPairs debug logging after FillScore simplification 2026-03-16 12:47:11 -04:00
aj 5b9e6c28e4 refactor: simplify FillScore to count + density, remove remnant tracking 2026-03-16 12:46:53 -04:00
aj ecdf571c71 feat: add RemnantFinder with edge projection algorithm 2026-03-16 12:45:35 -04:00
aj f5ab070453 test: add RemnantFinder tests (red) 2026-03-16 12:44:53 -04:00
aj 5873bff48b docs: add remnant finder implementation plan
16-task plan covering RemnantFinder class, FillScore simplification,
remainder phase removal, caller updates, and PlateView ActiveWorkArea
visualization.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 12:38:44 -04:00
aj 190f2a062f docs: add PlateView remnant visualization to remnant finder spec
Active remnant shown as dashed orange rectangle on PlateView during
iterative fill workflow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 12:26:23 -04:00
aj 384d53da47 docs: add remnant finder design spec
Extracts remnant detection from the nesting engine into a standalone
RemnantFinder class using edge projection algorithm, enabling an
iterative nest-area -> get-remnants workflow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 12:19:16 -04:00
aj 1b62f7af04 docs: revise lead-in UI spec with external/internal split and LayerType tagging
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 11:04:42 -04:00
aj 13264a2f8d Merge branch 'feature/nfp-bestfit' 2026-03-13 11:24:55 -04:00
aj 9df42d26de perf: replace linear sweep with iterative halving in RotationSlideStrategy
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:20:21 -04:00
aj 9daa768629 refactor: extract ComputeSlideDistance helpers in RotationSlideStrategy
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:19:36 -04:00
aj 3592a4ce59 docs: update bestfit spec to iterative halving approach
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:09:16 -04:00
aj e746afb57f docs: add coarse-then-refine bestfit sweep design spec
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:59:12 -04:00
aj 0c98b240c3 feat(engine): integrate NFP phase into Fill(groupParts) single-drawing path
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:23:39 -04:00
aj 56c9b17ff6 feat(engine): integrate NFP phase into FindBestFill (async overload)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:23:05 -04:00
aj c4d09f2466 feat(engine): integrate NFP phase into FindBestFill (sync overload)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:22:39 -04:00
aj bbc3466bc8 feat(engine): add FillNfpBestFit method for NFP-based single-drawing fill
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:22:07 -04:00
aj c18259a348 feat(engine): add Nfp to NestPhase enum
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:21:26 -04:00
19 changed files with 2118 additions and 491 deletions
-81
View File
@@ -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;
}
}
}
+2 -165
View File
@@ -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;
}
}
}
+3 -51
View File
@@ -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;
+8 -20
View File
@@ -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();
}
}
+3 -2
View File
@@ -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; }
}
}
+172
View File
@@ -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;
}
}
}
+120 -54
View File
@@ -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>
-1
View File
@@ -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; }
}
+1 -1
View File
@@ -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}:");
+2 -1
View File
@@ -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}";
+89
View File
@@ -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);
}
}
+272
View File
@@ -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);
}
}
+48 -8
View File
@@ -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()
+6
View File
@@ -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();
-2
View File
@@ -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.