diff --git a/OpenNest.Engine/Fill/FillLinear.cs b/OpenNest.Engine/Fill/FillLinear.cs
index 8a19366..dbc248f 100644
--- a/OpenNest.Engine/Fill/FillLinear.cs
+++ b/OpenNest.Engine/Fill/FillLinear.cs
@@ -53,6 +53,13 @@ namespace OpenNest.Engine.Fill
///
public string Label { get; set; }
+ ///
+ /// When positive, grid fills stop adding perpendicular rows once they hold at least
+ /// this many parts; a first row that already holds them is returned on its own. The
+ /// first row is always complete. Zero fills the whole work area.
+ ///
+ public int MaxParts { get; init; }
+
private static Vector MakeOffset(NestDirection direction, double distance)
{
return direction == NestDirection.Horizontal
@@ -195,7 +202,12 @@ namespace OpenNest.Engine.Fill
/// patterns, also adds individual parts from the next incomplete copy
/// that still fit within the work area.
///
- private List TilePattern(Pattern basePattern, NestDirection direction, OffsetPerimeterCache cache)
+ private List TilePattern(
+ Pattern basePattern,
+ NestDirection direction,
+ OffsetPerimeterCache cache,
+ int maxParts = 0
+ )
{
var copyDistance = FindPatternCopyDistance(basePattern, direction, cache);
@@ -211,7 +223,10 @@ namespace OpenNest.Engine.Fill
var count = 1;
- while (true)
+ // maxParts counts the base pattern too: stop once the tiling holds that many.
+ bool Full() => maxParts > 0 && basePattern.Parts.Count + result.Count >= maxParts;
+
+ while (!Full())
{
var nextPos = start + copyDistance * count;
@@ -230,7 +245,7 @@ namespace OpenNest.Engine.Fill
// next copy that didn't fit as a whole. This handles cases where
// e.g. a 2-part pair only partially fits — one part may still be
// within the work area even though the full pattern exceeds it.
- if (basePattern.Parts.Count > 1)
+ if (basePattern.Parts.Count > 1 && !Full())
{
var offset = MakeOffset(direction, copyDistance * count);
@@ -383,7 +398,7 @@ namespace OpenNest.Engine.Fill
// If primary tiling didn't produce copies, just tile along perpendicular
if (row.Count <= pattern.Parts.Count)
{
- row.AddRange(TilePattern(pattern, perpAxis, cache));
+ row.AddRange(TilePattern(pattern, perpAxis, cache, MaxParts));
if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a2, out var b2))
{
@@ -395,13 +410,19 @@ namespace OpenNest.Engine.Fill
return row;
}
+ // A capped fill whose first row already holds enough parts needs no more rows.
+ // Step 2's check could only switch perpendicular tiling to the bbox fallback; it
+ // never repairs the row itself, so returning the row loses no protection.
+ if (MaxParts > 0 && row.Count >= MaxParts)
+ return row;
+
// Step 2: Build row pattern and tile along perpendicular axis
var rowPattern = new Pattern();
rowPattern.Parts.AddRange(row);
rowPattern.UpdateBounds();
var gridResult = new List(rowPattern.Parts);
- gridResult.AddRange(TilePattern(rowPattern, perpAxis, cache));
+ gridResult.AddRange(TilePattern(rowPattern, perpAxis, cache, MaxParts));
// Only the unchanged row is covered by Step 1's clean verdict: skip Step 2
// only when the perpendicular tiling appended zero parts, so gridResult
diff --git a/OpenNest.Engine/Fill/StripeFiller.cs b/OpenNest.Engine/Fill/StripeFiller.cs
index 6fa3300..25e6f31 100644
--- a/OpenNest.Engine/Fill/StripeFiller.cs
+++ b/OpenNest.Engine/Fill/StripeFiller.cs
@@ -68,14 +68,16 @@ public class StripeFiller
sheetSpan,
spacing,
axis,
- _context.Token
+ _context.Token,
+ _context.MaxQuantity
);
var shrinkResult = ConvergeStripeAngleShrink(
pairParts,
sheetSpan,
spacing,
axis,
- _context.Token
+ _context.Token,
+ _context.MaxQuantity
);
foreach (var (angle, waste, count) in new[] { expandResult, shrinkResult })
@@ -127,7 +129,11 @@ public class StripeFiller
if (!_dedup.TryAdd(rotatedPattern.BoundingBox, workArea, primaryAxis))
return null;
- var stripeEngine = new FillLinear(stripeBox, spacing) { Label = "Stripe" };
+ var stripeEngine = new FillLinear(stripeBox, spacing)
+ {
+ Label = "Stripe",
+ MaxParts = _context.MaxQuantity,
+ };
var stripeParts = stripeEngine.Fill(rotatedPattern, primaryAxis);
if (stripeParts == null || stripeParts.Count == 0)
@@ -144,7 +150,11 @@ public class StripeFiller
stripePattern.Parts.AddRange(stripeParts);
stripePattern.UpdateBounds();
- var gridEngine = new FillLinear(workArea, spacing) { Label = "Stripe-Grid" };
+ var gridEngine = new FillLinear(workArea, spacing)
+ {
+ Label = "Stripe-Grid",
+ MaxParts = _context.MaxQuantity,
+ };
var gridParts = gridEngine.Fill(stripePattern, perpAxis);
if (gridParts == null || gridParts.Count == 0)
@@ -179,6 +189,10 @@ public class StripeFiller
var allParts = new List(gridParts);
+ // A capped fill that already holds the quantity needs no remnant parts.
+ if (_context.MaxQuantity > 0 && gridParts.Count >= _context.MaxQuantity)
+ return allParts;
+
var remnantParts = FillRemnant(gridParts, primaryAxis);
if (remnantParts != null)
{
@@ -351,18 +365,20 @@ public class StripeFiller
///
/// Iteratively finds the rotation angle where N copies of the pattern
/// span the given dimension with minimal waste by expanding pair width.
- /// Returns (angle, waste, pairCount).
+ /// Returns (angle, waste, pairCount). A positive caps
+ /// the estimate rows like .
///
public static (double Angle, double Waste, int Count) ConvergeStripeAngle(
List patternParts,
double sheetSpan,
double spacing,
NestDirection axis,
- CancellationToken token = default
+ CancellationToken token = default,
+ int maxParts = 0
)
{
var startAngle = OrientShortSideAlong(patternParts, axis);
- return ConvergeFromAngle(patternParts, startAngle, sheetSpan, spacing, axis, token);
+ return ConvergeFromAngle(patternParts, startAngle, sheetSpan, spacing, axis, token, maxParts);
}
///
@@ -374,7 +390,8 @@ public class StripeFiller
double sheetSpan,
double spacing,
NestDirection axis,
- CancellationToken token = default
+ CancellationToken token = default,
+ int maxParts = 0
)
{
var baseAngle = OrientShortSideAlong(patternParts, axis);
@@ -392,7 +409,7 @@ public class StripeFiller
return (0, double.MaxValue, 0);
var startAngle = FindAngleForTargetSpan(patternParts, targetSpan, axis);
- return ConvergeFromAngle(patternParts, startAngle, sheetSpan, spacing, axis, token);
+ return ConvergeFromAngle(patternParts, startAngle, sheetSpan, spacing, axis, token, maxParts);
}
private static (double Angle, double Waste, int Count) ConvergeFromAngle(
@@ -401,7 +418,8 @@ public class StripeFiller
double sheetSpan,
double spacing,
NestDirection axis,
- CancellationToken token
+ CancellationToken token,
+ int maxParts
)
{
var bestWaste = double.MaxValue;
@@ -428,7 +446,11 @@ public class StripeFiller
axis == NestDirection.Horizontal
? new Box(0, 0, sheetSpan, perpDim)
: new Box(0, 0, perpDim, sheetSpan);
- var engine = new FillLinear(stripeBox, spacing) { Label = "Stripe-EstimateRow" };
+ var engine = new FillLinear(stripeBox, spacing)
+ {
+ Label = "Stripe-EstimateRow",
+ MaxParts = maxParts,
+ };
var filled = engine.Fill(rotated, axis);
var n = filled?.Count ?? 0;
diff --git a/OpenNest.Engine/Jobs/Placement/Fillers/DefaultPlateFiller.cs b/OpenNest.Engine/Jobs/Placement/Fillers/DefaultPlateFiller.cs
index 1171c56..96f819a 100644
--- a/OpenNest.Engine/Jobs/Placement/Fillers/DefaultPlateFiller.cs
+++ b/OpenNest.Engine/Jobs/Placement/Fillers/DefaultPlateFiller.cs
@@ -28,6 +28,13 @@ internal class DefaultPlateFiller : PlateFillerBase
set => angleBuilder.ForceFullSweep = value;
}
+ ///
+ /// When true, a positive item quantity lets supporting strategies stop adding rows once a
+ /// candidate holds the quantity. Other strategies still fill the work area; the winner is
+ /// trimmed to the quantity. Off by default, so ordinary fills are unchanged.
+ ///
+ internal bool StopAtQuantity { get; init; }
+
public override List BuildAngles(
NestItem item,
ClassificationResult classification,
@@ -338,7 +345,7 @@ internal class DefaultPlateFiller : PlateFillerBase
Token = token,
Progress = progress,
Policy = BuildPolicy(),
- MaxQuantity = item.Quantity,
+ MaxQuantity = StopAtQuantity ? System.Math.Max(0, item.Quantity) : 0,
};
RunPipeline(context);
diff --git a/OpenNest.Engine/Jobs/Placement/PrivatePlateFill.cs b/OpenNest.Engine/Jobs/Placement/PrivatePlateFill.cs
index 71ca769..bee8133 100644
--- a/OpenNest.Engine/Jobs/Placement/PrivatePlateFill.cs
+++ b/OpenNest.Engine/Jobs/Placement/PrivatePlateFill.cs
@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Jobs.Adapters;
+using OpenNest.Engine.Jobs.Placement.Fillers;
using OpenNest.Geometry;
namespace OpenNest.Engine.Jobs.Placement;
@@ -29,7 +30,10 @@ internal static class PrivatePlateFill
.ThenBy(f => f.Candidate.Part2Offset.Y).ThenBy(f => f.OptimalRotation).ToArray();
fits.Clear();
fits.AddRange(sorted);
- return PlateFillService.FillItem("Default", plate, new NestItem
+ // A positive quantity lets supporting strategies avoid surplus rows. Zero still
+ // fills the whole area (the cutout lattice caller requires an uncapped lattice).
+ var filler = new DefaultPlateFiller(plate) { StopAtQuantity = true };
+ return filler.Fill(new NestItem
{
Drawing = drawing,
Quantity = quantity,
diff --git a/OpenNest.Engine/Strategies/FillContext.cs b/OpenNest.Engine/Strategies/FillContext.cs
index 8f18351..558db3a 100644
--- a/OpenNest.Engine/Strategies/FillContext.cs
+++ b/OpenNest.Engine/Strategies/FillContext.cs
@@ -17,6 +17,10 @@ namespace OpenNest.Engine.Strategies
public CancellationToken Token { get; init; }
public IProgress Progress { get; init; }
public FillPolicy Policy { get; init; } = new FillPolicy(new DefaultFillComparer());
+ ///
+ /// When positive, strategies may stop placing parts once a candidate holds this
+ /// many (see ). Zero fills the whole work area.
+ ///
public int MaxQuantity { get; init; }
public PartType PartType { get; set; }
diff --git a/OpenNest.Tests/Engine/EngineRefactorSmokeTests.cs b/OpenNest.Tests/Engine/EngineRefactorSmokeTests.cs
index 54f5dda..4ee8a82 100644
--- a/OpenNest.Tests/Engine/EngineRefactorSmokeTests.cs
+++ b/OpenNest.Tests/Engine/EngineRefactorSmokeTests.cs
@@ -70,6 +70,27 @@ public class EngineRefactorSmokeTests
Assert.True(parts.Count > 0, "ForceFullAngleSweep should still produce results");
}
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void DefaultPlateFiller_StopAtQuantity_StillDeliversExactlyTheQuantity(bool stopAtQuantity)
+ {
+ var plate = new Plate(60, 120) { PartSpacing = 0.5 };
+ var filler = new DefaultPlateFiller(plate) { StopAtQuantity = stopAtQuantity };
+ var item = new NestItem { Drawing = MakeRectDrawing(7, 3), Quantity = 23 };
+
+ var parts = filler.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
+
+ Assert.Equal(23, parts.Count);
+ plate.Parts.AddRange(parts);
+ Assert.False(plate.HasOverlappingParts(out _), "Fill overlaps");
+ var area = plate.WorkArea();
+ Assert.All(parts, p => Assert.True(
+ p.BoundingBox.Left >= area.Left - 1e-6 && p.BoundingBox.Right <= area.Right + 1e-6
+ && p.BoundingBox.Bottom >= area.Bottom - 1e-6 && p.BoundingBox.Top <= area.Top + 1e-6,
+ "Part outside the work area"));
+ }
+
[Fact]
public void StripPlateFiller_Nest_ProducesResults()
{
diff --git a/OpenNest.Tests/Fill/FillLinearMaxPartsTests.cs b/OpenNest.Tests/Fill/FillLinearMaxPartsTests.cs
new file mode 100644
index 0000000..2cc99f8
--- /dev/null
+++ b/OpenNest.Tests/Fill/FillLinearMaxPartsTests.cs
@@ -0,0 +1,103 @@
+using System.Collections.Generic;
+using System.Linq;
+using OpenNest.Engine;
+using OpenNest.Engine.Fill;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.Fill;
+
+public class FillLinearMaxPartsTests
+{
+ // 10 x 5 rectangles at 0.5 spacing in a 100 x 100 area: 9 per row, 18 rows.
+ private static readonly Box Area = new(0, 0, 100, 100);
+ private const double Spacing = 0.5;
+
+ private static Drawing Rectangle()
+ {
+ var pgm = new OpenNest.CNC.Program();
+ pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
+ pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(10, 0)));
+ pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(10, 5)));
+ pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 5)));
+ pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
+ return new Drawing("rect", pgm);
+ }
+
+ private static List<(double X, double Y)> Poses(List parts) =>
+ parts.Select(p => (p.Location.X, p.Location.Y)).OrderBy(p => p.Y).ThenBy(p => p.X).ToList();
+
+ [Fact]
+ public void Uncapped_FillsTheWholeArea()
+ {
+ var parts = new FillLinear(Area, Spacing).Fill(Rectangle(), 0, NestDirection.Horizontal);
+
+ Assert.Equal(9 * 18, parts.Count);
+ }
+
+ [Fact]
+ public void Cap_StopsAddingRowsOnceReached_AndKeepsTheFullGridPrefix()
+ {
+ var drawing = Rectangle();
+ var full = new FillLinear(Area, Spacing).Fill(drawing, 0, NestDirection.Horizontal);
+ var capped = new FillLinear(Area, Spacing) { MaxParts = 20 }.Fill(drawing, 0, NestDirection.Horizontal);
+
+ // Whole rows only: 20 parts need three rows of nine.
+ Assert.Equal(27, capped.Count);
+ var rows = Poses(full).Select(p => p.Y).Distinct().OrderBy(y => y).Take(3).ToHashSet();
+ Assert.Equal(Poses(full).Where(p => rows.Contains(p.Y)).ToList(), Poses(capped));
+ }
+
+ [Fact]
+ public void Cap_SmallerThanTheFirstRow_ReturnsTheCompleteFirstRow()
+ {
+ var capped = new FillLinear(Area, Spacing) { MaxParts = 5 }.Fill(Rectangle(), 0, NestDirection.Horizontal);
+
+ Assert.Equal(9, capped.Count);
+ Assert.Single(capped.Select(p => p.Location.Y).Distinct());
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void NonpositiveCap_MatchesTheUncappedFill(int maxParts)
+ {
+ var drawing = Rectangle();
+ var full = new FillLinear(Area, Spacing).Fill(drawing, 0, NestDirection.Horizontal);
+ var capped = new FillLinear(Area, Spacing) { MaxParts = maxParts }.Fill(drawing, 0, NestDirection.Horizontal);
+
+ Assert.Equal(Poses(full), Poses(capped));
+ }
+
+ [Theory]
+ [InlineData(NestDirection.Horizontal, 10, 100)]
+ [InlineData(NestDirection.Vertical, 100, 5)]
+ public void Cap_PerpendicularOnlyFill_StopsAtQuantity(NestDirection direction, double length, double width)
+ {
+ var area = new Box(0, 0, length, width);
+ var parts = new FillLinear(area, Spacing) { MaxParts = 3 }.Fill(Rectangle(), 0, direction);
+
+ Assert.Equal(3, parts.Count);
+ var plate = new Plate(width, length) { PartSpacing = Spacing };
+ plate.Parts.AddRange(parts);
+ Assert.False(plate.HasOverlappingParts(out _));
+ Assert.All(parts, part => Assert.True(part.BoundingBox.Right <= area.Right
+ && part.BoundingBox.Top <= area.Top));
+ }
+
+ [Fact]
+ public void Cap_DoesNotPlaceAPartThatCannotFit()
+ {
+ Assert.Empty(new FillLinear(new Box(0, 0, 4, 4), Spacing) { MaxParts = 1 }
+ .Fill(Rectangle(), 0, NestDirection.Horizontal));
+ }
+
+ [Fact]
+ public void Cap_LargerThanTheAreaHolds_MatchesTheUncappedFill()
+ {
+ var drawing = Rectangle();
+ var full = new FillLinear(Area, Spacing).Fill(drawing, 0, NestDirection.Horizontal);
+ var capped = new FillLinear(Area, Spacing) { MaxParts = 1000 }.Fill(drawing, 0, NestDirection.Horizontal);
+
+ Assert.Equal(Poses(full), Poses(capped));
+ }
+}
diff --git a/OpenNest.Tests/Strategies/StripeFillerTests.cs b/OpenNest.Tests/Strategies/StripeFillerTests.cs
index 7350e42..9b686a0 100644
--- a/OpenNest.Tests/Strategies/StripeFillerTests.cs
+++ b/OpenNest.Tests/Strategies/StripeFillerTests.cs
@@ -227,6 +227,37 @@ public class StripeFillerTests
}
}
+ private static List StripeFill(int maxQuantity, NestDirection axis)
+ {
+ var plate = new Plate(60, 120) { PartSpacing = 0.5 };
+ var drawing = MakeRectDrawing(20, 10);
+ var context = new OpenNest.Engine.Strategies.FillContext
+ {
+ Item = new NestItem { Drawing = drawing, Quantity = maxQuantity },
+ WorkArea = new Box(0, 0, 120, 60),
+ Plate = plate,
+ Token = System.Threading.CancellationToken.None,
+ MaxQuantity = maxQuantity,
+ };
+ context.SharedState["BestFits"] = MakeSideBySideBestFits(drawing, 0.5);
+ return new StripeFiller(context, axis).Fill();
+ }
+
+ [Theory]
+ [InlineData(NestDirection.Horizontal)]
+ [InlineData(NestDirection.Vertical)]
+ public void Fill_WithMaxQuantity_StopsOnceTheQuantityIsHeld(NestDirection axis)
+ {
+ var full = StripeFill(0, axis);
+ var capped = StripeFill(6, axis);
+
+ Assert.True(full.Count > 6, $"Control fill should exceed the cap, got {full.Count}");
+ Assert.InRange(capped.Count, 6, full.Count - 1);
+ var plate = new Plate(60, 120) { PartSpacing = 0.5 };
+ plate.Parts.AddRange(capped);
+ Assert.False(plate.HasOverlappingParts(out _), "Capped stripe fill overlaps");
+ }
+
[Fact]
public void Fill_ReturnsEmpty_WhenNoBestFits()
{
diff --git a/docs/performance/fill-verification.md b/docs/performance/fill-verification.md
index 5fb9a34..3a3afc6 100644
--- a/docs/performance/fill-verification.md
+++ b/docs/performance/fill-verification.md
@@ -11,6 +11,33 @@ Only the exact value `1` enables these tests; otherwise they skip. In PowerShell
The category covers comparer, group-pattern, rotated-pattern, extents-column, feature-extraction, no-model angle, and FillLinear offset-geometry workloads; individual filters match benchmark method names in `FillPerformanceTests.cs`. Overlap checks are measured separately by `OverlapCheck_ReportsPolygonPairsAndGridChecks` in `OpenNest.Tests/Fill/OverlapCheckPerformanceTests.cs` (same category). Keep harness, inputs, warmups and batches identical before/after; exclude setup/assertions from timing. Comparer/extents allocations are synchronous and current-thread only; parallel group fills omit allocation totals. No timing CI gates or whole-job speedup claims. Keep raw results and run-specific reports outside source control; this guide documents the reusable verification procedure.
+### Quantity-limited private fills
+
+Irregular's private block proposals opt into quantity-limited Row/Column stripe fills.
+`FillLinear.MaxParts` is a soft row limit: the first primary row stays complete, and
+perpendicular copies stop after reaching the requested count. The final fill still
+trims its winner to the exact quantity. Existing overlap fallbacks and block spacing
+certification remain active; fallback tiling can exceed the soft limit. Nonpositive
+limits remain uncapped. Ordinary Default fills and the zero-quantity cutout lattice
+keep their full-area behavior.
+
+This can change which private block candidate wins; it is not a promise of identical
+Irregular layouts. Verify fulfillment, bounds, spacing and rotation policy through
+the full pipeline, rather than treating a faster cancelled run as acceptance.
+
+```sh
+dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release \
+ --filter 'FullyQualifiedName~FillLinearMaxPartsTests|FullyQualifiedName~StripeFillerTests|FullyQualifiedName~EngineRefactorSmokeTests'
+dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj -c Release \
+ --filter 'FullyQualifiedName~IrregularBlockTests|FullyQualifiedName~CutoutLatticeFillTests'
+python3 scripts/check-synthetic-nests.py
+```
+
+For a supplied slow job, retain a bounded before/after trace, rebuilt probe and input
+hashes, and a completion run with all demand placed and zero independent validation
+violations. Bound cancellation and the outer process watchdog separately. Do not
+extend a known unchanged timeout just to obtain a baseline duration.
+
### FillLinear unchanged-row validation baseline
```bash