Compare commits

...

2 Commits

Author SHA1 Message Date
aj 93a8981d0a feat: add Disable/Enable API to FillStrategyRegistry
Adds methods to permanently disable/enable strategies by name. Disabled
strategies remain registered but are excluded from the default pipeline.
SetEnabled (used for remnant fills) takes precedence over the disabled
set, so explicit overrides still work.

Pipeline test now checks against active strategy count dynamically.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:19:27 -04:00
aj 00e7866506 feat: add remnant filling to PairFiller for better part density
PairFiller previously only filled the main grid with pair patterns,
leaving narrow waste strips unfilled. Row/Column strategies filled
their remnants, winning on count despite worse base grids.

Now PairFiller evaluates grid+remnant together for each angle/direction
combination, picking the best total. Uses a two-phase approach: fast
grid evaluation first, then remnant filling only for grids within
striking distance of the current best. Remnant results are cached
via FillResultCache.

Constructor now takes Plate (needed to create remnant engine).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:19:19 -04:00
5 changed files with 181 additions and 19 deletions
+138 -7
View File
@@ -28,14 +28,16 @@ namespace OpenNest.Engine.Fill
private const int EarlyExitMinTried = 10; private const int EarlyExitMinTried = 10;
private const int EarlyExitStaleLimit = 10; private const int EarlyExitStaleLimit = 10;
private readonly Plate plate;
private readonly Size plateSize; private readonly Size plateSize;
private readonly double partSpacing; private readonly double partSpacing;
private readonly IFillComparer comparer; private readonly IFillComparer comparer;
public PairFiller(Size plateSize, double partSpacing, IFillComparer comparer = null) public PairFiller(Plate plate, IFillComparer comparer = null)
{ {
this.plateSize = plateSize; this.plate = plate;
this.partSpacing = partSpacing; this.plateSize = plate.Size;
this.partSpacing = plate.PartSpacing;
this.comparer = comparer ?? new DefaultFillComparer(); this.comparer = comparer ?? new DefaultFillComparer();
} }
@@ -73,7 +75,7 @@ namespace OpenNest.Engine.Fill
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var filled = EvaluateCandidate(candidates[i], drawing, effectiveWorkArea); var filled = EvaluateCandidate(candidates[i], drawing, effectiveWorkArea, token);
if (comparer.IsBetter(filled, best, effectiveWorkArea)) if (comparer.IsBetter(filled, best, effectiveWorkArea))
{ {
@@ -142,12 +144,141 @@ namespace OpenNest.Engine.Fill
System.Math.Min(newTop - workArea.Y, workArea.Length)); System.Math.Min(newTop - workArea.Y, workArea.Length));
} }
private List<Part> EvaluateCandidate(BestFitResult candidate, Drawing drawing, Box workArea) private List<Part> EvaluateCandidate(BestFitResult candidate, Drawing drawing,
Box workArea, CancellationToken token)
{ {
var pairParts = candidate.BuildParts(drawing); var pairParts = candidate.BuildParts(drawing);
var engine = new FillLinear(workArea, partSpacing);
var angles = BuildTilingAngles(candidate); var angles = BuildTilingAngles(candidate);
return FillHelpers.FillPattern(engine, pairParts, angles, workArea, comparer);
// Phase 1: evaluate all grids (fast)
var grids = new List<(List<Part> Parts, NestDirection Dir)>();
foreach (var angle in angles)
{
token.ThrowIfCancellationRequested();
var pattern = FillHelpers.BuildRotatedPattern(pairParts, angle);
if (pattern.Parts.Count == 0)
continue;
var engine = new FillLinear(workArea, partSpacing);
foreach (var dir in new[] { NestDirection.Horizontal, NestDirection.Vertical })
{
var gridParts = engine.Fill(pattern, dir);
if (gridParts != null && gridParts.Count > 0)
grids.Add((gridParts, dir));
}
}
if (grids.Count == 0)
return null;
// Sort by count descending so we try the best grids first
grids.Sort((a, b) => b.Parts.Count.CompareTo(a.Parts.Count));
// Phase 2: try remnant for each grid, skip if grid is too far behind
List<Part> best = null;
var maxRemnantEstimate = EstimateMaxRemnantParts(drawing, workArea);
foreach (var (gridParts, dir) in grids)
{
token.ThrowIfCancellationRequested();
// If this grid + max possible remnant can't beat current best, skip
if (best != null && gridParts.Count + maxRemnantEstimate <= best.Count)
break; // sorted descending, so remaining are even smaller
var remnantParts = FillRemnant(gridParts, drawing, workArea, token);
List<Part> total;
if (remnantParts != null && remnantParts.Count > 0)
{
total = new List<Part>(gridParts.Count + remnantParts.Count);
total.AddRange(gridParts);
total.AddRange(remnantParts);
}
else
{
total = gridParts;
}
if (comparer.IsBetter(total, best, workArea))
best = total;
}
return best;
}
private static int EstimateMaxRemnantParts(Drawing drawing, Box workArea)
{
var partBox = drawing.Program.BoundingBox();
var partArea = System.Math.Max(partBox.Width * partBox.Length, 1);
var remnantArea = workArea.Area() * 0.3; // remnant is at most ~30% of work area
return (int)(remnantArea / partArea) + 1;
}
private List<Part> FillRemnant(List<Part> gridParts, Drawing drawing,
Box workArea, CancellationToken token)
{
var gridBox = ((IEnumerable<IBoundable>)gridParts).GetBoundingBox();
var partBox = drawing.Program.BoundingBox();
var minDim = System.Math.Min(partBox.Width, partBox.Length) + 2 * partSpacing;
List<Part> bestRemnant = null;
// Try top remnant (full width, above grid)
var topY = gridBox.Top + partSpacing;
var topLength = workArea.Top - topY;
if (topLength >= minDim)
{
var topBox = new Box(workArea.X, topY, workArea.Width, topLength);
var parts = FillRemnantBox(drawing, topBox, token);
if (parts != null && parts.Count > (bestRemnant?.Count ?? 0))
bestRemnant = parts;
}
// Try right remnant (full height, right of grid)
var rightX = gridBox.Right + partSpacing;
var rightWidth = workArea.Right - rightX;
if (rightWidth >= minDim)
{
var rightBox = new Box(rightX, workArea.Y, rightWidth, workArea.Length);
var parts = FillRemnantBox(drawing, rightBox, token);
if (parts != null && parts.Count > (bestRemnant?.Count ?? 0))
bestRemnant = parts;
}
return bestRemnant;
}
private List<Part> FillRemnantBox(Drawing drawing, Box remnantBox, CancellationToken token)
{
var cachedResult = FillResultCache.Get(drawing, remnantBox, partSpacing);
if (cachedResult != null)
{
Debug.WriteLine($"[PairFiller] Remnant CACHE HIT: {cachedResult.Count} parts");
return cachedResult;
}
FillStrategyRegistry.SetEnabled("Pairs", "RectBestFit", "Extents", "Linear");
try
{
var remnantEngine = NestEngineRegistry.Create(plate);
var item = new NestItem { Drawing = drawing };
var parts = remnantEngine.Fill(item, remnantBox, null, token);
Debug.WriteLine($"[PairFiller] Remnant: {parts?.Count ?? 0} parts in " +
$"{remnantBox.Width:F2}x{remnantBox.Length:F2}");
if (parts != null && parts.Count > 0)
{
FillResultCache.Store(drawing, remnantBox, partSpacing, parts);
return parts;
}
return null;
}
finally
{
FillStrategyRegistry.SetEnabled(null);
}
} }
private static List<double> BuildTilingAngles(BestFitResult candidate) private static List<double> BuildTilingAngles(BestFitResult candidate)
@@ -12,6 +12,7 @@ namespace OpenNest.Engine.Strategies
private static readonly List<IFillStrategy> strategies = new(); private static readonly List<IFillStrategy> strategies = new();
private static List<IFillStrategy> sorted; private static List<IFillStrategy> sorted;
private static HashSet<string> enabledFilter; private static HashSet<string> enabledFilter;
private static readonly HashSet<string> disabled = new(StringComparer.OrdinalIgnoreCase);
static FillStrategyRegistry() static FillStrategyRegistry()
{ {
@@ -19,9 +20,36 @@ namespace OpenNest.Engine.Strategies
} }
public static IReadOnlyList<IFillStrategy> Strategies => public static IReadOnlyList<IFillStrategy> Strategies =>
sorted ??= (enabledFilter != null sorted ??= FilterStrategies();
? strategies.Where(s => enabledFilter.Contains(s.Name)).OrderBy(s => s.Order).ToList()
: strategies.OrderBy(s => s.Order).ToList()); private static List<IFillStrategy> FilterStrategies()
{
var source = enabledFilter != null
? strategies.Where(s => enabledFilter.Contains(s.Name))
: strategies.Where(s => !disabled.Contains(s.Name));
return source.OrderBy(s => s.Order).ToList();
}
/// <summary>
/// Permanently disables strategies by name. They remain registered
/// but are excluded from the default pipeline.
/// </summary>
public static void Disable(params string[] names)
{
foreach (var name in names)
disabled.Add(name);
sorted = null;
}
/// <summary>
/// Re-enables a previously disabled strategy.
/// </summary>
public static void Enable(params string[] names)
{
foreach (var name in names)
disabled.Remove(name);
sorted = null;
}
/// <summary> /// <summary>
/// Restricts the active strategies to only those whose names are listed. /// Restricts the active strategies to only those whose names are listed.
@@ -12,7 +12,7 @@ namespace OpenNest.Engine.Strategies
public List<Part> Fill(FillContext context) public List<Part> Fill(FillContext context)
{ {
var comparer = context.Policy?.Comparer; var comparer = context.Policy?.Comparer;
var filler = new PairFiller(context.Plate.Size, context.Plate.PartSpacing, comparer); var filler = new PairFiller(context.Plate, comparer);
var result = filler.Fill(context.Item, context.WorkArea, var result = filler.Fill(context.Item, context.WorkArea,
context.PlateNumber, context.Token, context.Progress); context.PlateNumber, context.Token, context.Progress);
+8 -6
View File
@@ -16,11 +16,15 @@ public class PairFillerTests
return new Drawing("rect", pgm); return new Drawing("rect", pgm);
} }
private static Plate MakePlate(double width, double length, double spacing = 0.5)
{
return new Plate { Size = new Size(width, length), PartSpacing = spacing };
}
[Fact] [Fact]
public void Fill_ReturnsPartsForSimpleDrawing() public void Fill_ReturnsPartsForSimpleDrawing()
{ {
var plateSize = new Size(120, 60); var filler = new PairFiller(MakePlate(120, 60));
var filler = new PairFiller(plateSize, 0.5);
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
var workArea = new Box(0, 0, 120, 60); var workArea = new Box(0, 0, 120, 60);
@@ -33,8 +37,7 @@ public class PairFillerTests
[Fact] [Fact]
public void Fill_EmptyResult_WhenPartTooLarge() public void Fill_EmptyResult_WhenPartTooLarge()
{ {
var plateSize = new Size(10, 10); var filler = new PairFiller(MakePlate(10, 10));
var filler = new PairFiller(plateSize, 0.5);
var item = new NestItem { Drawing = MakeRectDrawing(20, 20) }; var item = new NestItem { Drawing = MakeRectDrawing(20, 20) };
var workArea = new Box(0, 0, 10, 10); var workArea = new Box(0, 0, 10, 10);
@@ -50,8 +53,7 @@ public class PairFillerTests
var cts = new System.Threading.CancellationTokenSource(); var cts = new System.Threading.CancellationTokenSource();
cts.Cancel(); cts.Cancel();
var plateSize = new Size(120, 60); var filler = new PairFiller(MakePlate(120, 60));
var filler = new PairFiller(plateSize, 0.5);
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
var workArea = new Box(0, 0, 120, 60); var workArea = new Box(0, 0, 120, 60);
@@ -1,3 +1,4 @@
using OpenNest.Engine.Strategies;
using OpenNest.Geometry; using OpenNest.Geometry;
namespace OpenNest.Tests.Strategies; namespace OpenNest.Tests.Strategies;
@@ -24,8 +25,8 @@ public class FillPipelineTests
engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None); engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
Assert.True(engine.PhaseResults.Count >= 6, Assert.True(engine.PhaseResults.Count >= FillStrategyRegistry.Strategies.Count,
$"Expected phase results from all strategies, got {engine.PhaseResults.Count}"); $"Expected phase results from all active strategies, got {engine.PhaseResults.Count}");
} }
[Fact] [Fact]