fix: prevent RemnantFiller interleaving and PairFiller recursion

RemnantFiller: add placed parts as a single envelope obstacle instead
of individual bounding boxes to prevent the next drawing from filling
into inter-row gaps. Remove the topmost bounding-box part to create a
clean rectangular boundary.

PairsFillStrategy: guard against recursive invocation — remnant fills
within PairFiller create a new engine that runs the full pipeline,
which would invoke PairsFillStrategy again causing deep recursion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-22 21:11:47 -04:00
parent 31a9e6dbad
commit ebb18d9b49
2 changed files with 69 additions and 9 deletions
+45 -2
View File
@@ -102,11 +102,21 @@ namespace OpenNest.Engine.Fill
if (placed == null) if (placed == null)
continue; continue;
// Remove the topmost bounding box part to create a clean
// rectangular obstacle boundary. Without this, gaps between
// individual bounding boxes cause the next drawing to fill
// into inter-row spaces, producing an interleaved layout.
if (placed.Count > 1)
RemoveTopmostPart(placed);
allParts.AddRange(placed); allParts.AddRange(placed);
localQty[item.Drawing.Name] = System.Math.Max(0, qty - placed.Count); localQty[item.Drawing.Name] = System.Math.Max(0, qty - placed.Count);
foreach (var p in placed) // Add the envelope of all placed parts as a single obstacle
finder.AddObstacle(p.BoundingBox.Offset(spacing)); // rather than individual bounding boxes, preventing the
// remnant finder from seeing inter-part gaps.
var envelope = ComputeEnvelope(placed, spacing);
finder.AddObstacle(envelope);
return true; return true;
} }
@@ -114,6 +124,39 @@ namespace OpenNest.Engine.Fill
return false; return false;
} }
private static void RemoveTopmostPart(List<Part> parts)
{
var topIdx = 0;
for (var i = 1; i < parts.Count; i++)
{
if (parts[i].BoundingBox.Top > parts[topIdx].BoundingBox.Top)
topIdx = i;
}
parts.RemoveAt(topIdx);
}
private static Box ComputeEnvelope(List<Part> parts, double spacing)
{
var left = double.MaxValue;
var bottom = double.MaxValue;
var right = double.MinValue;
var top = double.MinValue;
foreach (var p in parts)
{
var bb = p.BoundingBox;
if (bb.Left < left) left = bb.Left;
if (bb.Bottom < bottom) bottom = bb.Bottom;
if (bb.Right > right) right = bb.Right;
if (bb.Top > top) top = bb.Top;
}
return new Box(left - spacing, bottom - spacing,
right - left + spacing * 2, top - bottom + spacing * 2);
}
private static List<Part> TryFillInRemnants( private static List<Part> TryFillInRemnants(
NestItem item, NestItem item,
int qty, int qty,
@@ -1,25 +1,42 @@
using OpenNest.Engine.Fill; using OpenNest.Engine.Fill;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading;
namespace OpenNest.Engine.Strategies namespace OpenNest.Engine.Strategies
{ {
public class PairsFillStrategy : IFillStrategy public class PairsFillStrategy : IFillStrategy
{ {
private static readonly AsyncLocal<bool> active = new();
public string Name => "Pairs"; public string Name => "Pairs";
public NestPhase Phase => NestPhase.Pairs; public NestPhase Phase => NestPhase.Pairs;
public int Order => 100; public int Order => 100;
public List<Part> Fill(FillContext context) public List<Part> Fill(FillContext context)
{ {
var comparer = context.Policy?.Comparer; // Prevent recursive PairFiller — remnant fills within PairFiller
var dedup = GridDedup.GetOrCreate(context.SharedState); // create a new engine that runs the full pipeline, which would
var filler = new PairFiller(context.Plate, comparer, dedup); // invoke PairsFillStrategy again, causing deep recursion.
var result = filler.Fill(context.Item, context.WorkArea, if (active.Value)
context.PlateNumber, context.Token, context.Progress); return null;
context.SharedState["BestFits"] = result.BestFits; active.Value = true;
try
{
var comparer = context.Policy?.Comparer;
var dedup = GridDedup.GetOrCreate(context.SharedState);
var filler = new PairFiller(context.Plate, comparer, dedup);
var result = filler.Fill(context.Item, context.WorkArea,
context.PlateNumber, context.Token, context.Progress);
return result.Parts; context.SharedState["BestFits"] = result.BestFits;
return result.Parts;
}
finally
{
active.Value = false;
}
} }
} }
} }