From ed908b97063855b38ee317300b81886c02bd3f5f Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 21 Sep 2026 15:40:13 -0400 Subject: [PATCH] refactor(engine): extract strip plate filler --- .../Jobs/PlateFillerContractTests.cs | 118 ++++++++++++++ .../Jobs/Placement/Fillers/PlateFillerBase.cs | 2 +- .../Placement/Fillers/StripPlateFiller.cs | 135 ++++++++++++++++ OpenNest.Engine/StripNestEngine.cs | 148 +++++------------- 4 files changed, 296 insertions(+), 107 deletions(-) create mode 100644 OpenNest.Engine/Jobs/Placement/Fillers/StripPlateFiller.cs diff --git a/OpenNest.Engine.Tests/Jobs/PlateFillerContractTests.cs b/OpenNest.Engine.Tests/Jobs/PlateFillerContractTests.cs index c2e2831..00ba923 100644 --- a/OpenNest.Engine.Tests/Jobs/PlateFillerContractTests.cs +++ b/OpenNest.Engine.Tests/Jobs/PlateFillerContractTests.cs @@ -97,6 +97,74 @@ public class PlateFillerContractTests Assert.Equal(11, parts.Count); } + [Fact] + public void StripPlateFiller_Nest_MatchesCompatibilityFacade() + { + var directPlate = new Plate(new Size(30, 50)); + var facadePlate = new Plate(new Size(30, 50)); + var directItems = new List + { + new() + { + Drawing = new Drawing("rect-a", TestDrawingFactory.Rectangle(6, 4)), + Quantity = 5, + }, + new() + { + Drawing = new Drawing("rect-b", TestDrawingFactory.Rectangle(4, 3)), + Quantity = 4, + }, + new() + { + Drawing = new Drawing("rect-c", TestDrawingFactory.Rectangle(2, 7)), + Quantity = 3, + }, + }; + var facadeItems = new List + { + new() + { + Drawing = new Drawing("rect-a", TestDrawingFactory.Rectangle(6, 4)), + Quantity = 5, + }, + new() + { + Drawing = new Drawing("rect-b", TestDrawingFactory.Rectangle(4, 3)), + Quantity = 4, + }, + new() + { + Drawing = new Drawing("rect-c", TestDrawingFactory.Rectangle(2, 7)), + Quantity = 3, + }, + }; + var directFiller = new StripPlateFiller(directPlate) { PlateNumber = 7 }; + var facade = new StripNestEngine(facadePlate) { PlateNumber = 7 }; + + var directParts = directFiller.Nest(directItems, null, CancellationToken.None); + var facadeParts = facade.Nest(facadeItems, null, CancellationToken.None); + + AssertEquivalentLayouts(facadeParts, directParts); + Assert.Equal(facadeItems.Select(item => item.Quantity), directItems.Select(item => item.Quantity)); + } + + [Fact] + public void CompatibilityStripFacade_Nest_UsesOverriddenPackArea() + { + var plate = new Plate(new Size(100, 100)); + var drawing = new Drawing("pack", TestDrawingFactory.Rectangle(10, 10)); + var engine = new PackProbeStripNestEngine(plate, drawing); + + var parts = engine.Nest( + new List { new() { Drawing = drawing, Quantity = 1 } }, + null, + CancellationToken.None + ); + + Assert.Equal(1, engine.PackAreaCalls); + Assert.Single(parts); + } + [Fact] public void NestProgressReporter_Report_ClonesPartsAndPreservesReportFields() { @@ -302,6 +370,56 @@ public class PlateFillerContractTests } } + private sealed class PackProbeStripNestEngine : StripNestEngine + { + private readonly Drawing drawing; + + internal PackProbeStripNestEngine(Plate plate, Drawing drawing) + : base(plate) + { + this.drawing = drawing; + } + + internal int PackAreaCalls { get; private set; } + + public override List PackArea( + Box box, + List items, + IProgress progress, + CancellationToken token + ) + { + PackAreaCalls++; + Assert.Same(drawing, Assert.Single(items).Drawing); + return new List { new(drawing, new Vector(0, 0)) }; + } + } + + private static void AssertEquivalentLayouts(List expected, List actual) + { + var expectedParts = expected + .OrderBy(part => part.BaseDrawing.Name) + .ThenBy(part => part.Location.X) + .ThenBy(part => part.Location.Y) + .ThenBy(part => part.Rotation) + .ToList(); + var actualParts = actual + .OrderBy(part => part.BaseDrawing.Name) + .ThenBy(part => part.Location.X) + .ThenBy(part => part.Location.Y) + .ThenBy(part => part.Rotation) + .ToList(); + + Assert.Equal(expectedParts.Count, actualParts.Count); + for (var i = 0; i < expectedParts.Count; i++) + { + Assert.Equal(expectedParts[i].BaseDrawing.Name, actualParts[i].BaseDrawing.Name); + Assert.Equal(expectedParts[i].Location.X, actualParts[i].Location.X, 9); + Assert.Equal(expectedParts[i].Location.Y, actualParts[i].Location.Y, 9); + Assert.Equal(expectedParts[i].Rotation, actualParts[i].Rotation, 9); + } + } + private static PlateFillerBase CreateFiller(string strategy, Plate plate) => strategy switch { "Default" => new DefaultPlateFiller(plate), diff --git a/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillerBase.cs b/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillerBase.cs index 072f123..97604e5 100644 --- a/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillerBase.cs +++ b/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillerBase.cs @@ -173,7 +173,7 @@ internal abstract class PlateFillerBase return new List(); } - public List Nest( + public virtual List Nest( List items, IProgress progress, CancellationToken token diff --git a/OpenNest.Engine/Jobs/Placement/Fillers/StripPlateFiller.cs b/OpenNest.Engine/Jobs/Placement/Fillers/StripPlateFiller.cs new file mode 100644 index 0000000..2ea1aac --- /dev/null +++ b/OpenNest.Engine/Jobs/Placement/Fillers/StripPlateFiller.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.Engine; +using OpenNest.Engine.Fill; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Jobs.Placement.Fillers; + +internal class StripPlateFiller : PlateFillerBase +{ + internal StripPlateFiller(Plate plate) + : base(plate) { } + + public override List Fill( + NestItem item, + Box workArea, + IProgress progress, + CancellationToken token + ) + { + var inner = new DefaultPlateFiller(Plate); + return inner.Fill(item, workArea, progress, token); + } + + public override List Fill( + List groupParts, + Box workArea, + IProgress progress, + CancellationToken token + ) + { + var inner = new DefaultPlateFiller(Plate); + return inner.Fill(groupParts, workArea, progress, token); + } + + public override List PackArea( + Box box, + List items, + IProgress progress, + CancellationToken token + ) => PackAreaCore(box, items, progress, token); + + internal List PackAreaCore( + Box box, + List items, + IProgress progress, + CancellationToken token + ) + { + var inner = new DefaultPlateFiller(Plate); + return inner.PackArea(box, items, progress, token); + } + + public override List Nest( + List items, + IProgress progress, + CancellationToken token + ) + { + if (items == null || items.Count == 0) + return new List(); + + var workArea = Plate.WorkArea(); + var fillItems = items + .Where(item => item.Quantity != 1) + .OrderBy(item => item.Priority) + .ThenByDescending(item => item.Drawing.Area) + .ToList(); + var packItems = items.Where(item => item.Quantity == 1).ToList(); + var allParts = new List(); + + if (fillItems.Count > 0) + { + Func> heightFillFunc = (item, box) => + { + var inner = new RemnantPlateFiller(Plate, RemnantFillPolicy.Horizontal); + return inner.Fill(item, box, progress, token); + }; + Func> widthFillFunc = (item, box) => + { + var inner = new RemnantPlateFiller(Plate, RemnantFillPolicy.Vertical); + return inner.Fill(item, box, progress, token); + }; + + var shrinkResult = IterativeShrinkFiller.Fill( + fillItems, + workArea, + heightFillFunc, + Plate.PartSpacing, + token, + progress, + PlateNumber, + widthFillFunc + ); + + allParts.AddRange(shrinkResult.Parts); + Compactor.Settle(allParts, workArea, Plate.PartSpacing); + packItems.AddRange(shrinkResult.Leftovers); + } + + packItems = packItems.Where(item => item.Quantity > 0).ToList(); + if (packItems.Count > 0 && !token.IsCancellationRequested) + { + var packArea = workArea; + if (allParts.Count > 0) + { + var obstacles = allParts + .Select(part => part.BoundingBox.Offset(Plate.PartSpacing)) + .ToList(); + var finder = new RemnantFinder(workArea, obstacles); + var remnants = finder.FindRemnants(); + packArea = remnants.Count > 0 ? remnants[0] : new Box(0, 0, 0, 0); + } + + if (packArea.Width > 0 && packArea.Length > 0) + { + var packParts = PackArea(packArea, packItems, progress, token); + allParts.AddRange(packParts); + } + } + + foreach (var item in items) + { + if (item.Quantity <= 0) + continue; + + var placed = allParts.Count(part => ReferenceEquals(part.BaseDrawing, item.Drawing)); + item.Quantity = System.Math.Max(0, item.Quantity - placed); + } + + return allParts; + } +} diff --git a/OpenNest.Engine/StripNestEngine.cs b/OpenNest.Engine/StripNestEngine.cs index 5a86917..158bf27 100644 --- a/OpenNest.Engine/StripNestEngine.cs +++ b/OpenNest.Engine/StripNestEngine.cs @@ -1,14 +1,15 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Threading; -using OpenNest.Engine.Fill; +using OpenNest.Engine.Jobs.Placement.Fillers; using OpenNest.Geometry; namespace OpenNest.Engine { public class StripNestEngine : NestEngineBase { + private StripPlateFiller filler; + public StripNestEngine(Plate plate) : base(plate) { } @@ -17,9 +18,24 @@ namespace OpenNest.Engine public override string Description => "Iterative shrink-fill nesting for mixed-drawing layouts"; - /// - /// Single-item fill delegates to DefaultNestEngine. - /// + private StripPlateFiller Filler + { + get + { + if (filler == null || !ReferenceEquals(filler.Plate, Plate)) + filler = new LegacyStripPlateFiller(this, Plate); + return filler; + } + } + + private StripPlateFiller PrepareFiller() + { + var current = Filler; + current.PlateNumber = PlateNumber; + current.NestDirection = NestDirection; + return current; + } + public override List Fill( NestItem item, Box workArea, @@ -27,13 +43,10 @@ namespace OpenNest.Engine CancellationToken token ) { - var inner = new DefaultNestEngine(Plate); - return inner.Fill(item, workArea, progress, token); + var current = PrepareFiller(); + return current.Fill(item, workArea, progress, token); } - /// - /// Group-parts fill delegates to DefaultNestEngine. - /// public override List Fill( List groupParts, Box workArea, @@ -41,13 +54,10 @@ namespace OpenNest.Engine CancellationToken token ) { - var inner = new DefaultNestEngine(Plate); - return inner.Fill(groupParts, workArea, progress, token); + var current = PrepareFiller(); + return current.Fill(groupParts, workArea, progress, token); } - /// - /// Pack delegates to DefaultNestEngine. - /// public override List PackArea( Box box, List items, @@ -55,110 +65,36 @@ namespace OpenNest.Engine CancellationToken token ) { - var inner = new DefaultNestEngine(Plate); - return inner.PackArea(box, items, progress, token); + var current = PrepareFiller(); + return current.PackAreaCore(box, items, progress, token); } - /// - /// Multi-drawing iterative shrink-fill strategy. - /// Each multi-quantity drawing gets shrink-filled into the tightest - /// sub-region using dual-direction selection. Singles and leftovers - /// are packed at the end. - /// public override List Nest( List items, IProgress progress, CancellationToken token ) { - if (items == null || items.Count == 0) - return new List(); + var current = PrepareFiller(); + return current.Nest(items, progress, token); + } - var workArea = Plate.WorkArea(); + private sealed class LegacyStripPlateFiller : StripPlateFiller + { + private readonly StripNestEngine engine; - // Separate multi-quantity from singles. - var fillItems = items - .Where(i => i.Quantity != 1) - .OrderBy(i => i.Priority) - .ThenByDescending(i => i.Drawing.Area) - .ToList(); - - var packItems = items.Where(i => i.Quantity == 1).ToList(); - - var allParts = new List(); - - // Phase 1: Iterative shrink-fill for multi-quantity items. - if (fillItems.Count > 0) + internal LegacyStripPlateFiller(StripNestEngine engine, Plate plate) + : base(plate) { - // Use direction-specific engines: height shrink benefits from - // minimizing Y-extent, width shrink from minimizing X-extent. - Func> heightFillFunc = (ni, b) => - { - var inner = new HorizontalRemnantEngine(Plate); - return inner.Fill(ni, b, progress, token); - }; - - Func> widthFillFunc = (ni, b) => - { - var inner = new VerticalRemnantEngine(Plate); - return inner.Fill(ni, b, progress, token); - }; - - var shrinkResult = IterativeShrinkFiller.Fill( - fillItems, - workArea, - heightFillFunc, - Plate.PartSpacing, - token, - progress, - PlateNumber, - widthFillFunc - ); - - allParts.AddRange(shrinkResult.Parts); - - // Compact placed parts toward the origin to close gaps. - Compactor.Settle(allParts, workArea, Plate.PartSpacing); - - // Add unfilled items to pack list. - packItems.AddRange(shrinkResult.Leftovers); + this.engine = engine; } - // Phase 2: Pack singles + leftovers into remaining space. - packItems = packItems.Where(i => i.Quantity > 0).ToList(); - - if (packItems.Count > 0 && !token.IsCancellationRequested) - { - // Reconstruct remaining area from placed parts. - var packArea = workArea; - if (allParts.Count > 0) - { - var obstacles = allParts - .Select(p => p.BoundingBox.Offset(Plate.PartSpacing)) - .ToList(); - var finder = new RemnantFinder(workArea, obstacles); - var remnants = finder.FindRemnants(); - packArea = remnants.Count > 0 ? remnants[0] : new Box(0, 0, 0, 0); - } - - if (packArea.Width > 0 && packArea.Length > 0) - { - var packParts = PackArea(packArea, packItems, progress, token); - allParts.AddRange(packParts); - } - } - - // Deduct placed quantities from original items by drawing reference. - foreach (var item in items) - { - if (item.Quantity <= 0) - continue; - - var placed = allParts.Count(p => ReferenceEquals(p.BaseDrawing, item.Drawing)); - item.Quantity = System.Math.Max(0, item.Quantity - placed); - } - - return allParts; + public override List PackArea( + Box box, + List items, + IProgress progress, + CancellationToken token + ) => engine.PackArea(box, items, progress, token); } } }