perf(irregular): cap surplus rows in private stripe fills

This commit is contained in:
aj committed 2026-10-05 09:03:49 -04:00
1 parent 5f67a51bf1
commit 32db086384
9 files changed
+258 -18

No files matched your search

+26 -5
View File
@@ -53,6 +53,13 @@ namespace OpenNest.Engine.Fill
/// </summary>
public string Label { get; set; }
/// <summary>
/// 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.
/// </summary>
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.
/// </summary>
private List<Part> TilePattern(Pattern basePattern, NestDirection direction, OffsetPerimeterCache cache)
private List<Part> 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<Part>(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
+33 -11
View File
@@ -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<Part>(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
/// <summary>
/// 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 <paramref name="maxParts"/> caps
/// the estimate rows like <see cref="FillLinear.MaxParts"/>.
/// </summary>
public static (double Angle, double Waste, int Count) ConvergeStripeAngle(
List<Part> 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);
}
/// <summary>
@@ -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;
@@ -28,6 +28,13 @@ internal class DefaultPlateFiller : PlateFillerBase
set => angleBuilder.ForceFullSweep = value;
}
/// <summary>
/// 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.
/// </summary>
internal bool StopAtQuantity { get; init; }
public override List<double> 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);
@@ -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,
@@ -17,6 +17,10 @@ namespace OpenNest.Engine.Strategies
public CancellationToken Token { get; init; }
public IProgress<NestProgress> Progress { get; init; }
public FillPolicy Policy { get; init; } = new FillPolicy(new DefaultFillComparer());
/// <summary>
/// When positive, strategies may stop placing parts once a candidate holds this
/// many (see <see cref="FillLinear.MaxParts"/>). Zero fills the whole work area.
/// </summary>
public int MaxQuantity { get; init; }
public PartType PartType { get; set; }
@@ -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()
{
@@ -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<Part> 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));
}
}
@@ -227,6 +227,37 @@ public class StripeFillerTests
}
}
private static List<Part> 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()
{
+27
View File
@@ -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