From eafa0fab0154c36444a3aebee0e77d0d81838f28 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 21 Sep 2026 15:01:00 -0400 Subject: [PATCH] refactor(engine): extract plate fill orchestration --- .../Jobs/PlateFillerContractTests.cs | 83 +++++++ .../Fillers/PlateFillOrchestrator.cs | 215 ++++++++++++++++ .../Jobs/Placement/Fillers/PlateFillerBase.cs | 104 ++++++++ OpenNest.Engine/NestEngineBase.cs | 232 +----------------- 4 files changed, 414 insertions(+), 220 deletions(-) create mode 100644 OpenNest.Engine/Jobs/Placement/Fillers/PlateFillOrchestrator.cs create mode 100644 OpenNest.Engine/Jobs/Placement/Fillers/PlateFillerBase.cs diff --git a/OpenNest.Engine.Tests/Jobs/PlateFillerContractTests.cs b/OpenNest.Engine.Tests/Jobs/PlateFillerContractTests.cs index a93f944..37f4dfe 100644 --- a/OpenNest.Engine.Tests/Jobs/PlateFillerContractTests.cs +++ b/OpenNest.Engine.Tests/Jobs/PlateFillerContractTests.cs @@ -1,3 +1,5 @@ +using System.Threading; +using OpenNest.Engine.Fill; using OpenNest.Engine.Jobs.Placement.Fillers; using OpenNest.Geometry; @@ -63,6 +65,87 @@ public class PlateFillerContractTests Assert.Empty(progress.Reports); } + [Fact] + public void PlateFillOrchestrator_Nest_UsesThresholdIdentityAndCallerDelegates() + { + var plate = new Plate(new Size(100, 100)); + var fillDrawing = new Drawing("duplicate", TestDrawingFactory.Rectangle(10, 10)); + var packDrawing = new Drawing("duplicate", TestDrawingFactory.Rectangle(10, 10)); + var items = new List + { + new() { Drawing = fillDrawing, Quantity = 10 }, + new() { Drawing = packDrawing, Quantity = 1 }, + }; + var calls = new List(); + var progress = new CapturingProgress(); + var token = CancellationToken.None; + + var parts = PlateFillOrchestrator.Nest( + plate, + items, + new DefaultFillComparer(), + (item, workArea, receivedProgress, receivedToken) => + { + calls.Add("fill"); + Assert.Same(progress, receivedProgress); + Assert.Equal(token, receivedToken); + Assert.Same(fillDrawing, item.Drawing); + var placed = new List(); + for (var i = 0; i < item.Quantity; i++) + { + var part = new Part(item.Drawing); + part.Offset(new Vector(i * 10, 0)); + placed.Add(part); + } + return placed; + }, + (workArea, packItems, receivedProgress, receivedToken) => + { + calls.Add("pack"); + Assert.Same(progress, receivedProgress); + Assert.Equal(token, receivedToken); + var packItem = Assert.Single(packItems); + Assert.Same(packDrawing, packItem.Drawing); + return new List { new(packItem.Drawing, new Vector(0, 20)) }; + }, + progress, + token + ); + + Assert.Equal(new[] { "fill", "pack" }, calls); + Assert.Equal(11, parts.Count); + Assert.Equal(10, parts.Count(part => ReferenceEquals(part.BaseDrawing, fillDrawing))); + Assert.Single(parts.Where(part => ReferenceEquals(part.BaseDrawing, packDrawing))); + Assert.Equal(0, items[0].Quantity); + Assert.Equal(0, items[1].Quantity); + } + + [Fact] + public void PlateFillOrchestrator_Nest_DoesNotInvokeDelegatesAfterCancellation() + { + var plate = new Plate(new Size(100, 100)); + var item = new NestItem + { + Drawing = new Drawing("part", TestDrawingFactory.Rectangle(10, 10)), + Quantity = 10, + }; + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + var parts = PlateFillOrchestrator.Nest( + plate, + new List { item }, + new DefaultFillComparer(), + (_, _, _, _) => throw new Xunit.Sdk.XunitException("Fill must not run after cancellation"), + (_, _, _, _) => throw new Xunit.Sdk.XunitException("Pack must not run after cancellation"), + null, + cancellation.Token + ); + + Assert.Empty(parts); + Assert.Equal(10, item.Quantity); + } + private sealed class CapturingProgress : IProgress { public List Reports { get; } = new(); diff --git a/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillOrchestrator.cs b/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillOrchestrator.cs new file mode 100644 index 0000000..f85033d --- /dev/null +++ b/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillOrchestrator.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using OpenNest.Engine; +using OpenNest.Engine.BestFit; +using OpenNest.Engine.Fill; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Engine.Jobs.Placement.Fillers; + +internal static class PlateFillOrchestrator +{ + internal static List Nest( + Plate plate, + List items, + IFillComparer comparer, + Func, CancellationToken, List> fill, + Func, IProgress, CancellationToken, List> pack, + IProgress progress, + CancellationToken token + ) + { + if (items == null || items.Count == 0) + return new List(); + + var workArea = plate.WorkArea(); + var allParts = new List(); + + var plateArea = workArea.Width * workArea.Length; + + var fillItems = items + .Where(item => ShouldFill(item, plate, plateArea)) + .OrderBy(item => item.Priority) + .ThenByDescending(item => item.Drawing.Area) + .ToList(); + + var packItems = items.Where(item => !ShouldFill(item, plate, plateArea)).ToList(); + + if (fillItems.Count > 0) + { + var remnantFiller = new RemnantFiller(workArea, plate.PartSpacing); + + var fillParts = remnantFiller.FillItems( + fillItems, + (item, area) => fill(item, area, progress, token), + token, + progress + ); + if (fillParts.Count > 0) + { + allParts.AddRange(fillParts); + + DeductPlacedQuantities(fillItems, fillParts); + + var placedObstacles = fillParts + .Select(part => part.BoundingBox.Offset(plate.PartSpacing)) + .ToList(); + var finder = new RemnantFinder(workArea, placedObstacles); + var remnants = finder.FindRemnants(); + if (remnants.Count > 0) + workArea = remnants[0]; + else + workArea = new Box(0, 0, 0, 0); + } + } + + packItems = packItems.Where(item => item.Quantity > 0).ToList(); + var pairItems = packItems.Where(item => item.Quantity == 2).ToList(); + var regularPackItems = packItems.Where(item => item.Quantity != 2).ToList(); + + if ( + regularPackItems.Count > 0 + && workArea.Width > 0 + && workArea.Length > 0 + && !token.IsCancellationRequested + ) + { + var packParts = pack(workArea, regularPackItems, progress, token); + + if (packParts.Count > 0) + { + allParts.AddRange(packParts); + + DeductPlacedQuantities(regularPackItems, packParts); + } + } + + if (pairItems.Count > 0 && !token.IsCancellationRequested) + { + var placed = PlaceBestFitPairs(plate, comparer, pairItems, allParts, plate.WorkArea()); + allParts.AddRange(placed); + } + + Compactor.Settle(allParts, plate.WorkArea(), plate.PartSpacing); + + return allParts; + } + + private static void DeductPlacedQuantities(List items, List parts) + { + foreach (var item in items) + { + var placed = parts.Count(part => ReferenceEquals(part.BaseDrawing, item.Drawing)); + item.Quantity = System.Math.Max(0, item.Quantity - placed); + } + } + + private static List PlaceBestFitPairs( + Plate plate, + IFillComparer comparer, + List pairItems, + List existingParts, + Box fullWorkArea + ) + { + var result = new List(); + var obstacles = existingParts + .Select(part => part.BoundingBox.Offset(plate.PartSpacing)) + .ToList(); + var finder = new RemnantFinder(fullWorkArea, obstacles); + + foreach (var item in pairItems) + { + if (item.Quantity < 2) + continue; + + var bestFits = BestFitCache.GetOrCompute( + item.Drawing, + plate.Size.Length, + plate.Size.Width, + plate.PartSpacing + ); + + var canonicalDrawing = CanonicalFrame.AsCanonicalCopy(item.Drawing); + + List bestPlacement = null; + Box bestTarget = null; + + foreach (var fit in bestFits) + { + if (!fit.Keep) + continue; + + var parts = fit.BuildParts(canonicalDrawing); + var pairBbox = ((IEnumerable)parts).GetBoundingBox(); + var pairWidth = pairBbox.Width; + var pairLength = pairBbox.Length; + var minDimension = System.Math.Min(pairWidth, pairLength); + + var remnants = finder.FindRemnants(minDimension); + + foreach (var remnant in remnants) + { + if ( + pairWidth <= remnant.Width + Tolerance.Epsilon + && pairLength <= remnant.Length + Tolerance.Epsilon + ) + { + var offset = remnant.Location - pairBbox.Location; + foreach (var part in parts) + { + part.Offset(offset); + part.UpdateBounds(); + } + + if (bestPlacement == null || comparer.IsBetter(parts, bestPlacement, remnant)) + { + bestPlacement = parts; + bestTarget = remnant; + } + break; + } + } + } + + if (bestPlacement == null) + continue; + + bestPlacement = CanonicalFrame.RebindToOriginal(bestPlacement, item.Drawing); + + result.AddRange(bestPlacement); + item.Quantity = 0; + + var envelope = ((IEnumerable)bestPlacement).GetBoundingBox(); + finder.AddObstacle(envelope.Offset(plate.PartSpacing)); + + Debug.WriteLine( + $"[Nest] Placed best-fit pair for {item.Drawing.Name} " + + $"at ({bestTarget.X:F1},{bestTarget.Y:F1}), " + + $"size {envelope.Width:F1}x{envelope.Length:F1}" + ); + } + + return result; + } + + private static bool ShouldFill(NestItem item, Plate plate, double plateArea) + { + if (item.Quantity <= 1) + return false; + + var boundingBox = item.Drawing.Program.BoundingBox(); + var partArea = (boundingBox.Width + plate.PartSpacing) + * (boundingBox.Length + plate.PartSpacing); + if (partArea <= 0) + return false; + + var totalArea = partArea * item.Quantity; + + return totalArea >= plateArea * 0.1; + } +} diff --git a/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillerBase.cs b/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillerBase.cs new file mode 100644 index 0000000..16933f8 --- /dev/null +++ b/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillerBase.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using OpenNest.Engine; +using OpenNest.Engine.Fill; +using OpenNest.Engine.Strategies; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Jobs.Placement.Fillers; + +internal abstract class PlateFillerBase +{ + private IFillComparer comparer; + + protected PlateFillerBase(Plate plate) + { + Plate = plate; + } + + public Plate Plate { get; } + + public int PlateNumber { get; set; } + + public NestDirection NestDirection { get; set; } + + public NestPhase WinnerPhase { get; protected set; } + + public List PhaseResults { get; } = new(); + + public List AngleResults { get; } = new(); + + protected IFillComparer Comparer => comparer ??= CreateComparer(); + + protected virtual IFillComparer CreateComparer() => new DefaultFillComparer(); + + public virtual NestDirection? PreferredDirection => null; + + public virtual ShrinkAxis TrimAxis => ShrinkAxis.Width; + + public virtual List BuildAngles( + NestItem item, + ClassificationResult classification, + Box workArea + ) + { + return new List + { + classification.PrimaryAngle, + classification.PrimaryAngle + OpenNest.Math.Angle.HalfPI, + }; + } + + protected virtual void RecordProductiveAngles(List angleResults) { } + + protected FillPolicy BuildPolicy() => new(Comparer, PreferredDirection); + + public virtual List Fill( + NestItem item, + Box workArea, + IProgress progress, + CancellationToken token + ) + { + return new List(); + } + + public virtual List Fill( + List groupParts, + Box workArea, + IProgress progress, + CancellationToken token + ) + { + return new List(); + } + + public virtual List PackArea( + Box box, + List items, + IProgress progress, + CancellationToken token + ) + { + return new List(); + } + + public List Nest( + List items, + IProgress progress, + CancellationToken token + ) + { + return PlateFillOrchestrator.Nest( + Plate, + items, + Comparer, + (item, workArea, sink, cancellation) => Fill(item, workArea, sink, cancellation), + (workArea, packItems, sink, cancellation) => + PackArea(workArea, packItems, sink, cancellation), + progress, + token + ); + } +} diff --git a/OpenNest.Engine/NestEngineBase.cs b/OpenNest.Engine/NestEngineBase.cs index ef0c53f..090f3ea 100644 --- a/OpenNest.Engine/NestEngineBase.cs +++ b/OpenNest.Engine/NestEngineBase.cs @@ -97,7 +97,7 @@ namespace OpenNest.Engine return new List(); } - // --- Nest: multi-item strategy (virtual, side-effect-free) --- + // --- Nest: compatibility façade over shared single-plate orchestration --- public virtual List Nest( List items, @@ -105,99 +105,17 @@ namespace OpenNest.Engine CancellationToken token ) { - if (items == null || items.Count == 0) - return new List(); - - var workArea = Plate.WorkArea(); - var allParts = new List(); - - var plateArea = workArea.Width * workArea.Length; - - var fillItems = items - .Where(i => ShouldFill(i, plateArea)) - .OrderBy(i => i.Priority) - .ThenByDescending(i => i.Drawing.Area) - .ToList(); - - var packItems = items.Where(i => !ShouldFill(i, plateArea)).ToList(); - - // Phase 1: Fill multi-quantity drawings using RemnantFiller. - if (fillItems.Count > 0) - { - var remnantFiller = new RemnantFiller(workArea, Plate.PartSpacing); - - Func> fillFunc = (ni, b) => - FillExact(ni, b, progress, token); - - var fillParts = remnantFiller.FillItems(fillItems, fillFunc, token, progress); - - if (fillParts.Count > 0) - { - allParts.AddRange(fillParts); - - // Deduct placed quantities by drawing reference, not name. - foreach (var item in fillItems) - { - var placed = fillParts.Count(p => - ReferenceEquals(p.BaseDrawing, item.Drawing) - ); - item.Quantity = System.Math.Max(0, item.Quantity - placed); - } - - // Update workArea for pack phase - var placedObstacles = fillParts - .Select(p => p.BoundingBox.Offset(Plate.PartSpacing)) - .ToList(); - var finder = new RemnantFinder(workArea, placedObstacles); - var remnants = finder.FindRemnants(); - if (remnants.Count > 0) - workArea = remnants[0]; - else - workArea = new Box(0, 0, 0, 0); - } - } - - // Phase 2: Pack low-quantity items into remaining space. - // Separate qty=2 items — they'll be placed as best-fit pairs after packing. - packItems = packItems.Where(i => i.Quantity > 0).ToList(); - var pairItems = packItems.Where(i => i.Quantity == 2).ToList(); - var regularPackItems = packItems.Where(i => i.Quantity != 2).ToList(); - - if ( - regularPackItems.Count > 0 - && workArea.Width > 0 - && workArea.Length > 0 - && !token.IsCancellationRequested - ) - { - var packParts = PackArea(workArea, regularPackItems, progress, token); - - if (packParts.Count > 0) - { - allParts.AddRange(packParts); - - // Deduct placed quantities by drawing reference, not name. - foreach (var item in regularPackItems) - { - var placed = packParts.Count(p => - ReferenceEquals(p.BaseDrawing, item.Drawing) - ); - item.Quantity = System.Math.Max(0, item.Quantity - placed); - } - } - } - - // Phase 3: Place best-fit pairs for qty=2 items in remaining space. - if (pairItems.Count > 0 && !token.IsCancellationRequested) - { - var placed = PlaceBestFitPairs(pairItems, allParts, Plate.WorkArea()); - allParts.AddRange(placed); - } - - // Compact placed parts toward the origin to close gaps. - Compactor.Settle(allParts, Plate.WorkArea(), Plate.PartSpacing); - - return allParts; + return PlateFillOrchestrator.Nest( + Plate, + items, + Comparer, + (item, workArea, sink, cancellation) => + FillExact(item, workArea, sink, cancellation), + (workArea, packItems, sink, cancellation) => + PackArea(workArea, packItems, sink, cancellation), + progress, + token + ); } // --- FillExact (non-virtual, delegates to virtual Fill) --- @@ -340,131 +258,5 @@ namespace OpenNest.Engine return false; } - /// - /// Places best-fit pairs for qty=2 items into remnant spaces around - /// already-placed parts. Returns all placed pair parts. - /// - private List PlaceBestFitPairs( - List pairItems, - List existingParts, - Box fullWorkArea - ) - { - var result = new List(); - var obstacles = existingParts - .Select(p => p.BoundingBox.Offset(Plate.PartSpacing)) - .ToList(); - var finder = new RemnantFinder(fullWorkArea, obstacles); - - foreach (var item in pairItems) - { - if (item.Quantity < 2) - continue; - - var bestFits = BestFitCache.GetOrCompute( - item.Drawing, - Plate.Size.Length, - Plate.Size.Width, - Plate.PartSpacing - ); - - // BestFitCache stores pair coordinates in canonical frame. Build candidates - // from a canonical drawing copy so geometry and coords share a frame; rebind - // + un-rotate winning pair to the original drawing's frame before returning. - var canonicalDrawing = CanonicalFrame.AsCanonicalCopy(item.Drawing); - - List bestPlacement = null; - Box bestTarget = null; - - foreach (var fit in bestFits) - { - if (!fit.Keep) - continue; - - var parts = fit.BuildParts(canonicalDrawing); - var pairBbox = ((IEnumerable)parts).GetBoundingBox(); - var pairW = pairBbox.Width; - var pairL = pairBbox.Length; - var minDim = System.Math.Min(pairW, pairL); - - var remnants = finder.FindRemnants(minDim); - - foreach (var r in remnants) - { - if ( - pairW <= r.Width + Tolerance.Epsilon - && pairL <= r.Length + Tolerance.Epsilon - ) - { - var offset = r.Location - pairBbox.Location; - foreach (var p in parts) - { - p.Offset(offset); - p.UpdateBounds(); - } - - if (bestPlacement == null || IsBetterFill(parts, bestPlacement, r)) - { - bestPlacement = parts; - bestTarget = r; - } - break; - } - } - } - - if (bestPlacement == null) - continue; - - // Rebind to the original drawing and compose the canonical angle onto rotation so - // the final placed parts sit in the user's visible frame. - bestPlacement = RebindPairToOriginal(bestPlacement, item.Drawing); - - result.AddRange(bestPlacement); - item.Quantity = 0; - - var envelope = ((IEnumerable)bestPlacement).GetBoundingBox(); - finder.AddObstacle(envelope.Offset(Plate.PartSpacing)); - - Debug.WriteLine( - $"[Nest] Placed best-fit pair for {item.Drawing.Name} " - + $"at ({bestTarget.X:F1},{bestTarget.Y:F1}), " - + $"size {envelope.Width:F1}x{envelope.Length:F1}" - ); - } - - return result; - } - - /// - /// Rebinds each canonical-frame Part in the pair to the original Drawing at its current - /// world pose, then composes the canonical angle onto each via - /// CanonicalFrame.RebindToOriginal so the returned list is in the original drawing's - /// visible frame. Mirrors DefaultNestEngine.RebindAndUnCanonicalize. - /// - private static List RebindPairToOriginal(List parts, Drawing original) => - CanonicalFrame.RebindToOriginal(parts, original); - - /// - /// Determines whether a drawing should use grid-fill (true) or bin-pack (false). - /// Low-quantity items whose total area is a small fraction of the plate are - /// better off being packed alongside other parts rather than filling first. - /// - private bool ShouldFill(NestItem item, double plateArea) - { - if (item.Quantity <= 1) - return false; - - var bbox = item.Drawing.Program.BoundingBox(); - var partArea = (bbox.Width + Plate.PartSpacing) * (bbox.Length + Plate.PartSpacing); - if (partArea <= 0) - return false; - - var totalArea = partArea * item.Quantity; - - // If the total area of all copies is less than 10% of the plate, - // packing produces better results than grid-filling. - return totalArea >= plateArea * 0.1; - } } }