From 32db0863847ae9d3b79168e08a88311db4a19121 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 5 Oct 2026 09:03:49 -0400 Subject: [PATCH] perf(irregular): cap surplus rows in private stripe fills --- OpenNest.Engine/Fill/FillLinear.cs | 31 +++++- OpenNest.Engine/Fill/StripeFiller.cs | 44 ++++++-- .../Placement/Fillers/DefaultPlateFiller.cs | 9 +- .../Jobs/Placement/PrivatePlateFill.cs | 6 +- OpenNest.Engine/Strategies/FillContext.cs | 4 + .../Engine/EngineRefactorSmokeTests.cs | 21 ++++ .../Fill/FillLinearMaxPartsTests.cs | 103 ++++++++++++++++++ .../Strategies/StripeFillerTests.cs | 31 ++++++ docs/performance/fill-verification.md | 27 +++++ 9 files changed, 258 insertions(+), 18 deletions(-) create mode 100644 OpenNest.Tests/Fill/FillLinearMaxPartsTests.cs 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