diff --git a/OpenNest.Engine.Tests/Jobs/PlateFillerContractTests.cs b/OpenNest.Engine.Tests/Jobs/PlateFillerContractTests.cs index 37f4dfe..c2e2831 100644 --- a/OpenNest.Engine.Tests/Jobs/PlateFillerContractTests.cs +++ b/OpenNest.Engine.Tests/Jobs/PlateFillerContractTests.cs @@ -7,6 +7,96 @@ namespace OpenNest.Engine.Tests.Jobs; public class PlateFillerContractTests { + [Theory] + [InlineData("Default")] + [InlineData("Vertical Remnant")] + [InlineData("Horizontal Remnant")] + public void StandardPlateFiller_Fill_MatchesCompatibilityFacade(string strategy) + { + var directPlate = new Plate(new Size(30, 50)); + var facadePlate = new Plate(new Size(30, 50)); + var directDrawing = new Drawing("part", TestDrawingFactory.Rectangle(6, 4)); + var facadeDrawing = new Drawing("part", TestDrawingFactory.Rectangle(6, 4)); + var directFiller = CreateFiller(strategy, directPlate); + var facade = CreateFacade(strategy, facadePlate); + + var directParts = directFiller.Fill( + new NestItem { Drawing = directDrawing, Quantity = 4 }, + directPlate.WorkArea(), + null, + CancellationToken.None + ); + var facadeParts = facade.Fill( + new NestItem { Drawing = facadeDrawing, Quantity = 4 }, + facadePlate.WorkArea(), + null, + CancellationToken.None + ); + + Assert.Equal(facade.WinnerPhase, directFiller.WinnerPhase); + Assert.Equal( + facade.PhaseResults.Select(result => (result.Phase, result.PartCount)), + directFiller.PhaseResults.Select(result => (result.Phase, result.PartCount)) + ); + Assert.Equal( + facade.AngleResults.Select(result => (result.AngleDeg, result.Direction, result.PartCount)), + directFiller.AngleResults.Select(result => (result.AngleDeg, result.Direction, result.PartCount)) + ); + Assert.Equal(facadeParts.Count, directParts.Count); + for (var i = 0; i < directParts.Count; i++) + { + Assert.Same(directDrawing, directParts[i].BaseDrawing); + Assert.Same(facadeDrawing, facadeParts[i].BaseDrawing); + Assert.Equal(facadeParts[i].Location.X, directParts[i].Location.X, 9); + Assert.Equal(facadeParts[i].Location.Y, directParts[i].Location.Y, 9); + Assert.Equal(facadeParts[i].Rotation, directParts[i].Rotation, 9); + } + } + + [Fact] + public void CompatibilityDefaultFacade_UsesOverriddenAngleSelection() + { + var plate = new Plate(new Size(30, 50)); + var engine = new AngleProbeDefaultNestEngine(plate); + + var parts = engine.Fill( + new NestItem + { + Drawing = new Drawing("part", TestDrawingFactory.Rectangle(6, 4)), + Quantity = 4, + }, + plate.WorkArea(), + null, + CancellationToken.None + ); + + Assert.NotEmpty(parts); + Assert.True(engine.BuildAnglesCalled); + } + + [Fact] + public void CompatibilityDefaultFacade_Nest_UsesOverriddenFillAndPackArea() + { + var plate = new Plate(new Size(100, 100)); + var fillDrawing = new Drawing("fill", TestDrawingFactory.Rectangle(10, 10)); + var packDrawing = new Drawing("pack", TestDrawingFactory.Rectangle(10, 10)); + var engine = new FillAndPackProbeDefaultNestEngine(plate, fillDrawing, packDrawing); + + var parts = engine.Nest( + new List + { + new() { Drawing = fillDrawing, Quantity = 10 }, + new() { Drawing = packDrawing, Quantity = 1 }, + }, + null, + CancellationToken.None + ); + + Assert.Equal(1, engine.FillCalls); + Assert.Equal(1, engine.PackAreaCalls); + Assert.Equal(11, parts.Count); + } + [Fact] public void NestProgressReporter_Report_ClonesPartsAndPreservesReportFields() { @@ -146,6 +236,88 @@ public class PlateFillerContractTests Assert.Equal(10, item.Quantity); } + private sealed class FillAndPackProbeDefaultNestEngine : DefaultNestEngine + { + private readonly Drawing fillDrawing; + private readonly Drawing packDrawing; + + internal FillAndPackProbeDefaultNestEngine( + Plate plate, + Drawing fillDrawing, + Drawing packDrawing + ) + : base(plate) + { + this.fillDrawing = fillDrawing; + this.packDrawing = packDrawing; + } + + internal int FillCalls { get; private set; } + + internal int PackAreaCalls { get; private set; } + + public override List Fill( + NestItem item, + Box workArea, + IProgress progress, + CancellationToken token + ) + { + FillCalls++; + Assert.Same(fillDrawing, item.Drawing); + var parts = new List(); + for (var i = 0; i < item.Quantity; i++) + parts.Add(new Part(fillDrawing, new Vector(i * 10, 0))); + return parts; + } + + public override List PackArea( + Box box, + List items, + IProgress progress, + CancellationToken token + ) + { + PackAreaCalls++; + Assert.Same(packDrawing, Assert.Single(items).Drawing); + return new List { new(packDrawing, new Vector(0, 20)) }; + } + } + + private sealed class AngleProbeDefaultNestEngine : DefaultNestEngine + { + internal AngleProbeDefaultNestEngine(Plate plate) + : base(plate) { } + + internal bool BuildAnglesCalled { get; private set; } + + public override List BuildAngles( + NestItem item, + ClassificationResult classification, + Box workArea + ) + { + BuildAnglesCalled = true; + return base.BuildAngles(item, classification, workArea); + } + } + + private static PlateFillerBase CreateFiller(string strategy, Plate plate) => strategy switch + { + "Default" => new DefaultPlateFiller(plate), + "Vertical Remnant" => new RemnantPlateFiller(plate, RemnantFillPolicy.Vertical), + "Horizontal Remnant" => new RemnantPlateFiller(plate, RemnantFillPolicy.Horizontal), + _ => throw new ArgumentOutOfRangeException(nameof(strategy)), + }; + + private static NestEngineBase CreateFacade(string strategy, Plate plate) => strategy switch + { + "Default" => new DefaultNestEngine(plate), + "Vertical Remnant" => new VerticalRemnantEngine(plate), + "Horizontal Remnant" => new HorizontalRemnantEngine(plate), + _ => throw new ArgumentOutOfRangeException(nameof(strategy)), + }; + private sealed class CapturingProgress : IProgress { public List Reports { get; } = new(); diff --git a/OpenNest.Engine/DefaultNestEngine.cs b/OpenNest.Engine/DefaultNestEngine.cs index 8a2388c..eca7f35 100644 --- a/OpenNest.Engine/DefaultNestEngine.cs +++ b/OpenNest.Engine/DefaultNestEngine.cs @@ -1,20 +1,18 @@ 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.Engine.Jobs.Placement.Fillers; using OpenNest.Engine.Strategies; using OpenNest.Geometry; -using OpenNest.Math; -using OpenNest.Engine.RectanglePacking; namespace OpenNest.Engine { public class DefaultNestEngine : NestEngineBase { + private DefaultPlateFiller filler; + private bool forceFullAngleSweep; + public DefaultNestEngine(Plate plate) : base(plate) { } @@ -23,29 +21,70 @@ namespace OpenNest.Engine public override string Description => "Multi-phase nesting (Linear, Pairs, RectBestFit, Extents)"; - private readonly AngleCandidateBuilder angleBuilder = new(); + public override NestPhase WinnerPhase + { + get => Filler.WinnerPhase; + protected set => Filler.SetWinnerPhase(value); + } + + public override List PhaseResults => Filler.PhaseResults; + + public override List AngleResults => Filler.AngleResults; public bool ForceFullAngleSweep { - get => angleBuilder.ForceFullSweep; - set => angleBuilder.ForceFullSweep = value; + get => forceFullAngleSweep; + set + { + forceFullAngleSweep = value; + if (filler != null) + filler.ForceFullAngleSweep = value; + } } + private DefaultPlateFiller Filler + { + get + { + if (filler == null || !ReferenceEquals(filler.Plate, Plate)) + { + filler = CreateFiller(Plate); + filler.ForceFullAngleSweep = forceFullAngleSweep; + } + return filler; + } + } + + private DefaultPlateFiller PrepareFiller() + { + var current = Filler; + current.PlateNumber = PlateNumber; + current.NestDirection = NestDirection; + return current; + } + + internal virtual DefaultPlateFiller CreateFiller(Plate plate) => + new LegacyDefaultPlateFiller(this, plate); + + internal DefaultPlateFiller CreateRemnantFiller(Plate plate, RemnantFillPolicy policy) => + new LegacyRemnantPlateFiller(this, plate, policy); + + protected override IFillComparer CreateComparer() => Filler.CreateComparerCore(); + + public override NestDirection? PreferredDirection => null; + + public override ShrinkAxis TrimAxis => ShrinkAxis.Width; + public override List BuildAngles( NestItem item, ClassificationResult classification, Box workArea - ) - { - return angleBuilder.Build(item, classification, workArea); - } + ) => Filler.BuildAnglesCore(item, classification, workArea); - protected override void RecordProductiveAngles(List angleResults) - { - angleBuilder.RecordProductive(angleResults); - } + protected override void RecordProductiveAngles(List angleResults) => + Filler.RecordProductiveAnglesCore(angleResults); - // --- Public Fill API --- + protected virtual void RunPipeline(FillContext context) => Filler.RunPipelineCore(context); public override List Fill( NestItem item, @@ -54,305 +93,8 @@ namespace OpenNest.Engine CancellationToken token ) { - PhaseResults.Clear(); - AngleResults.Clear(); - - // Replace the item's Drawing with a canonical copy for the duration of this fill. - // All internal methods see canonical geometry; this wrapper un-canonicalizes the final result. - var originalDrawing = item.Drawing; - var canonicalItem = new NestItem - { - Drawing = CanonicalFrame.AsCanonicalCopy(item.Drawing), - Quantity = item.Quantity, - Priority = item.Priority, - RotationStart = item.RotationStart, - RotationEnd = item.RotationEnd, - StepAngle = item.StepAngle, - }; - - // Fast path for qty 1-2. - if (canonicalItem.Quantity > 0 && canonicalItem.Quantity <= 2) - { - var fast = TryFillSmallQuantity(canonicalItem, workArea); - if (fast != null && fast.Count >= canonicalItem.Quantity) - { - Debug.WriteLine( - $"[Fill] Fast path: placed {fast.Count} parts for qty={canonicalItem.Quantity}" - ); - WinnerPhase = NestPhase.Pairs; - fast = RebindAndUnCanonicalize(fast, originalDrawing); - ReportProgress( - progress, - new ProgressReport - { - Phase = WinnerPhase, - PlateNumber = PlateNumber, - Parts = fast, - WorkArea = workArea, - Description = $"Fast path: {fast.Count} parts", - IsOverallBest = true, - } - ); - return fast; - } - } - - var effectiveWorkArea = workArea; - if (canonicalItem.Quantity > 0) - { - effectiveWorkArea = ShrinkWorkArea(canonicalItem, workArea, Plate.PartSpacing); - if (effectiveWorkArea != workArea) - Debug.WriteLine( - $"[Fill] Low-qty shrink: {canonicalItem.Quantity} requested, " - + $"from {workArea.Width:F1}x{workArea.Length:F1} " - + $"to {effectiveWorkArea.Width:F1}x{effectiveWorkArea.Length:F1}" - ); - } - - var best = RunFillPipeline(canonicalItem, effectiveWorkArea, progress, token); - - if ( - canonicalItem.Quantity > 0 - && best.Count < canonicalItem.Quantity - && effectiveWorkArea != workArea - ) - { - Debug.WriteLine( - $"[Fill] Low-qty fallback: got {best.Count}, need {canonicalItem.Quantity}, retrying full area" - ); - PhaseResults.Clear(); - AngleResults.Clear(); - best = RunFillPipeline(canonicalItem, workArea, progress, token); - } - - if (canonicalItem.Quantity > 0 && best.Count > canonicalItem.Quantity) - best = ShrinkFiller.TrimToCount(best, canonicalItem.Quantity, TrimAxis); - - best = RebindAndUnCanonicalize(best, originalDrawing); - - ReportProgress( - progress, - new ProgressReport - { - Phase = WinnerPhase, - PlateNumber = PlateNumber, - Parts = best, - WorkArea = workArea, - Description = BuildProgressSummary(), - IsOverallBest = true, - } - ); - - return best; - } - - /// - /// Single exit point for canonical -> source frame conversion. Rebinds every Part to the - /// original Drawing (so consumers see the user's drawing identity, not the transient canonical copy) - /// and composes the canonical angle onto each Part's rotation via CanonicalFrame.RebindToOriginal. - /// - private static List RebindAndUnCanonicalize(List parts, Drawing original) => - CanonicalFrame.RebindToOriginal(parts, original); - - /// - /// Fast path for qty 1-2: place a single part or a best-fit pair - /// without running the full strategy pipeline. - /// - private List TryFillSmallQuantity(NestItem item, Box workArea) - { - if (item.Quantity == 1) - return TryPlaceSingle(item.Drawing, workArea); - - if (item.Quantity == 2) - return TryPlaceBestFitPair(item.Drawing, workArea); - - return null; - } - - private static List TryPlaceSingle(Drawing drawing, Box workArea) - { - var part = Part.CreateAtOrigin(drawing); - if ( - part.BoundingBox.Width > workArea.Width + Tolerance.Epsilon - || part.BoundingBox.Length > workArea.Length + Tolerance.Epsilon - ) - return null; - - part.Offset(workArea.Location - part.BoundingBox.Location); - return new List { part }; - } - - private List TryPlaceBestFitPair(Drawing drawing, Box workArea) - { - var bestFits = BestFitCache.GetOrCompute( - drawing, - Plate.Size.Length, - Plate.Size.Width, - Plate.PartSpacing - ); - - // Build pair candidates with a canonical drawing so their geometry matches - // the coordinate frame of the cached fit results. - var canonicalDrawing = CanonicalFrame.AsCanonicalCopy(drawing); - - List bestPlacement = null; - - foreach (var fit in bestFits) - { - if (!fit.Keep) - continue; - - // Skip pairs that can't possibly fit the work area in either orientation. - if ( - fit.ShortestSide - > System.Math.Min(workArea.Width, workArea.Length) + Tolerance.Epsilon - ) - continue; - if ( - fit.LongestSide - > System.Math.Max(workArea.Width, workArea.Length) + Tolerance.Epsilon - ) - continue; - - var landscape = fit.BuildParts(canonicalDrawing); - var portrait = RotatePair90(landscape); - - var lFits = TryOffsetToWorkArea(landscape, workArea); - var pFits = TryOffsetToWorkArea(portrait, workArea); - - // Pick the better orientation for this pair. - List candidate = null; - if (lFits && pFits) - candidate = IsBetterFill(portrait, landscape, workArea) ? portrait : landscape; - else if (lFits) - candidate = landscape; - else if (pFits) - candidate = portrait; - - if (candidate == null) - continue; - - if (bestPlacement == null || IsBetterFill(candidate, bestPlacement, workArea)) - bestPlacement = candidate; - } - - // Parts are returned in canonical frame, bound to the canonical drawing. - // The outer Fill wrapper (Task 7) rebinds to `drawing` and composes sourceAngle onto rotation. - return bestPlacement; - } - - private static List RotatePair90(List parts) - { - var rotated = new List(parts.Count); - foreach (var p in parts) - rotated.Add((Part)p.Clone()); - - var bbox = ((IEnumerable)rotated).GetBoundingBox(); - var center = bbox.Center; - - foreach (var p in rotated) - p.Rotate(-Angle.HalfPI, center); - - var newBbox = ((IEnumerable)rotated).GetBoundingBox(); - var offset = new Vector(-newBbox.Left, -newBbox.Bottom); - foreach (var p in rotated) - { - p.Offset(offset); - p.UpdateBounds(); - } - - return rotated; - } - - private static bool TryOffsetToWorkArea(List parts, Box workArea) - { - var bbox = ((IEnumerable)parts).GetBoundingBox(); - if ( - bbox.Width > workArea.Width + Tolerance.Epsilon - || bbox.Length > workArea.Length + Tolerance.Epsilon - ) - return false; - - var offset = workArea.Location - bbox.Location; - foreach (var p in parts) - { - p.Offset(offset); - p.UpdateBounds(); - } - return true; - } - - /// - /// Shrinks the work area in both dimensions proportionally when the - /// requested quantity is much less than the plate capacity. - /// - private static Box ShrinkWorkArea(NestItem item, Box workArea, double spacing) - { - var bbox = item.Drawing.Program.BoundingBox(); - if (bbox.Width <= 0 || bbox.Length <= 0) - return workArea; - - var bin = new Bin { Size = new Size(workArea.Width, workArea.Length) }; - var packItem = new Item - { - Size = new Size(bbox.Width + spacing, bbox.Length + spacing), - }; - var packer = new FillBestFit(bin); - packer.Fill(packItem); - var fullCount = bin.Items.Count; - - if (fullCount <= 0 || fullCount <= item.Quantity) - return workArea; - - // Scale both dimensions by sqrt(ratio) so the area shrinks - // proportionally. 2x margin gives strategies room to optimize. - var ratio = (double)item.Quantity / fullCount; - var scale = System.Math.Sqrt(ratio) * 2.0; - - var newWidth = workArea.Width * scale; - var newLength = workArea.Length * scale; - - // Ensure at least one part fits. - var minWidth = bbox.Width + spacing * 2; - var minLength = bbox.Length + spacing * 2; - newWidth = System.Math.Max(newWidth, minWidth); - newLength = System.Math.Max(newLength, minLength); - - // Clamp to original dimensions. - newWidth = System.Math.Min(newWidth, workArea.Width); - newLength = System.Math.Min(newLength, workArea.Length); - - if (newWidth >= workArea.Width && newLength >= workArea.Length) - return workArea; - - return new Box(workArea.X, workArea.Y, newLength, newWidth); - } - - private List RunFillPipeline( - NestItem item, - Box workArea, - IProgress progress, - CancellationToken token - ) - { - var context = new FillContext - { - Item = item, - WorkArea = workArea, - Plate = Plate, - PlateNumber = PlateNumber, - Token = token, - Progress = progress, - Policy = BuildPolicy(), - MaxQuantity = item.Quantity, - }; - - RunPipeline(context); - - AngleResults.AddRange(context.AngleResults); - WinnerPhase = context.WinnerPhase; - - return context.CurrentBest ?? new List(); + var current = PrepareFiller(); + return current.Fill(item, workArea, progress, token); } public override List Fill( @@ -362,45 +104,10 @@ namespace OpenNest.Engine CancellationToken token ) { - if (groupParts == null || groupParts.Count == 0) - return new List(); - - // Single part: delegate to the strategy pipeline. - if (groupParts.Count == 1) - { - var nestItem = new NestItem { Drawing = groupParts[0].BaseDrawing }; - return Fill(nestItem, workArea, progress, token); - } - - // Multi-part group: linear pattern fill only. - PhaseResults.Clear(); - var engine = new FillLinear(workArea, Plate.PartSpacing) { Label = "GroupPattern" }; - var angles = RotationAnalysis.FindHullEdgeAngles(groupParts); - var best = FillHelpers.FillPattern(engine, groupParts, angles, workArea, Comparer); - PhaseResults.Add(new PhaseResult(NestPhase.Linear, best?.Count ?? 0, 0)); - - Debug.WriteLine( - $"[Fill(groupParts,Box)] Linear pattern: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Length:F1}" - ); - - ReportProgress( - progress, - new ProgressReport - { - Phase = NestPhase.Linear, - PlateNumber = PlateNumber, - Parts = best, - WorkArea = workArea, - Description = BuildProgressSummary(), - IsOverallBest = true, - } - ); - - return best ?? new List(); + var current = PrepareFiller(); + return current.Fill(groupParts, workArea, progress, token); } - // --- Pack API --- - public override List PackArea( Box box, List items, @@ -408,87 +115,77 @@ namespace OpenNest.Engine CancellationToken token ) { - var binItems = BinConverter.ToItems(items, Plate.PartSpacing, Plate.Area()); - var bin = BinConverter.CreateBin(box, Plate.PartSpacing); - - var engine = new PackBottomLeft(bin); - engine.Pack(binItems); - - return BinConverter.ToParts(bin, items); + var current = PrepareFiller(); + return current.PackArea(box, items, progress, token); } - // --- RunPipeline: strategy-based orchestration --- - - protected virtual void RunPipeline(FillContext context) + public override List Nest( + List items, + IProgress progress, + CancellationToken token + ) { - var classification = PartClassifier.Classify(context.Item.Drawing); - context.PartType = classification.Type; - context.SharedState["BestRotation"] = classification.PrimaryAngle; - context.SharedState["Classification"] = classification; + return base.Nest(items, progress, token); + } - var angles = BuildAngles(context.Item, classification, context.WorkArea); - context.SharedState["AngleCandidates"] = angles; + private sealed class LegacyDefaultPlateFiller : DefaultPlateFiller + { + private readonly DefaultNestEngine engine; - try + internal LegacyDefaultPlateFiller(DefaultNestEngine engine, Plate plate) + : base(plate) { - foreach (var strategy in FillStrategyRegistry.Strategies) - { - context.Token.ThrowIfCancellationRequested(); - context.ActivePhase = strategy.Phase; - - var sw = Stopwatch.StartNew(); - var result = strategy.Fill(context); - sw.Stop(); - - var phaseResult = new PhaseResult( - strategy.Phase, - result?.Count ?? 0, - sw.ElapsedMilliseconds - ); - context.PhaseResults.Add(phaseResult); - - // Keep engine's PhaseResults in sync so BuildProgressSummary() works - // during progress reporting. - PhaseResults.Add(phaseResult); - - // FillContext.ReportProgress updates CurrentBest during the - // strategy's angle sweep. This catches strategies that return a - // result without reporting it (e.g. RectBestFit). - var improved = context.Policy.Comparer.IsBetter( - result, - context.CurrentBest, - context.WorkArea - ); - if (improved) - { - context.CurrentBest = result; - context.CurrentBestScore = FillScore.Compute(result, context.WorkArea); - context.WinnerPhase = strategy.Phase; - } - - if (improved && context.CurrentBest != null && context.CurrentBest.Count > 0) - { - ReportProgress( - context.Progress, - new ProgressReport - { - Phase = context.WinnerPhase, - PlateNumber = PlateNumber, - Parts = context.CurrentBest, - WorkArea = context.WorkArea, - Description = BuildProgressSummary(), - IsOverallBest = true, - } - ); - } - } - } - catch (OperationCanceledException) - { - Debug.WriteLine("[RunPipeline] Cancelled, returning current best"); + this.engine = engine; } - RecordProductiveAngles(context.AngleResults); + protected override IFillComparer CreateComparer() => engine.CreateComparer(); + + public override NestDirection? PreferredDirection => engine.PreferredDirection; + + public override ShrinkAxis TrimAxis => engine.TrimAxis; + + public override List BuildAngles( + NestItem item, + ClassificationResult classification, + Box workArea + ) => engine.BuildAngles(item, classification, workArea); + + protected override void RecordProductiveAngles(List angleResults) => + engine.RecordProductiveAngles(angleResults); + + protected override void RunPipeline(FillContext context) => engine.RunPipeline(context); + } + + private sealed class LegacyRemnantPlateFiller : RemnantPlateFiller + { + private readonly DefaultNestEngine engine; + + internal LegacyRemnantPlateFiller( + DefaultNestEngine engine, + Plate plate, + RemnantFillPolicy policy + ) + : base(plate, policy) + { + this.engine = engine; + } + + protected override IFillComparer CreateComparer() => engine.CreateComparer(); + + public override NestDirection? PreferredDirection => engine.PreferredDirection; + + public override ShrinkAxis TrimAxis => engine.TrimAxis; + + public override List BuildAngles( + NestItem item, + ClassificationResult classification, + Box workArea + ) => engine.BuildAngles(item, classification, workArea); + + protected override void RecordProductiveAngles(List angleResults) => + engine.RecordProductiveAngles(angleResults); + + protected override void RunPipeline(FillContext context) => engine.RunPipeline(context); } } } diff --git a/OpenNest.Engine/HorizontalRemnantEngine.cs b/OpenNest.Engine/HorizontalRemnantEngine.cs index 4b8c1ca..0f0fd9a 100644 --- a/OpenNest.Engine/HorizontalRemnantEngine.cs +++ b/OpenNest.Engine/HorizontalRemnantEngine.cs @@ -1,16 +1,13 @@ -using System; using System.Collections.Generic; -using OpenNest.Engine; using OpenNest.Engine.Fill; +using OpenNest.Engine.Jobs.Placement.Fillers; +using OpenNest.Engine.Strategies; using OpenNest.Geometry; -using OpenNest.Math; namespace OpenNest.Engine { /// /// Optimizes for the largest top-side horizontal drop. - /// Scores by count first, then minimizes Y-extent. - /// Prefers vertical nest direction and angles that keep parts narrow in Y. /// public class HorizontalRemnantEngine : DefaultNestEngine { @@ -21,33 +18,21 @@ namespace OpenNest.Engine public override string Description => "Optimizes for largest top-side horizontal drop"; - protected override IFillComparer CreateComparer() => new HorizontalRemnantComparer(); + protected override IFillComparer CreateComparer() => + RemnantFillPolicy.Horizontal.CreateComparer(); - public override NestDirection? PreferredDirection => NestDirection.Vertical; + public override NestDirection? PreferredDirection => + RemnantFillPolicy.Horizontal.PreferredDirection; - public override ShrinkAxis TrimAxis => ShrinkAxis.Length; + public override ShrinkAxis TrimAxis => RemnantFillPolicy.Horizontal.TrimAxis; public override List BuildAngles( NestItem item, ClassificationResult classification, Box workArea - ) - { - var baseAngles = new List - { - classification.PrimaryAngle, - classification.PrimaryAngle + Angle.HalfPI, - }; - baseAngles.Sort((a, b) => RotatedHeight(item, a).CompareTo(RotatedHeight(item, b))); - return baseAngles; - } + ) => RemnantFillPolicy.Horizontal.BuildAngles(item, classification); - private static double RotatedHeight(NestItem item, double angle) - { - var bb = item.Drawing.Program.BoundingBox(); - var cos = System.Math.Abs(System.Math.Cos(angle)); - var sin = System.Math.Abs(System.Math.Sin(angle)); - return bb.Width * cos + bb.Length * sin; - } + internal override DefaultPlateFiller CreateFiller(Plate plate) => + CreateRemnantFiller(plate, RemnantFillPolicy.Horizontal); } } diff --git a/OpenNest.Engine/Jobs/Placement/Fillers/DefaultPlateFiller.cs b/OpenNest.Engine/Jobs/Placement/Fillers/DefaultPlateFiller.cs new file mode 100644 index 0000000..24a2ef5 --- /dev/null +++ b/OpenNest.Engine/Jobs/Placement/Fillers/DefaultPlateFiller.cs @@ -0,0 +1,493 @@ +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.Engine.RectanglePacking; +using OpenNest.Engine.Strategies; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Engine.Jobs.Placement.Fillers; + +internal class DefaultPlateFiller : PlateFillerBase +{ + private readonly AngleCandidateBuilder angleBuilder = new(); + + internal DefaultPlateFiller(Plate plate) + : base(plate) { } + + protected override IFillComparer CreateComparer() => CreateComparerCore(); + + internal IFillComparer CreateComparerCore() => new DefaultFillComparer(); + + internal bool ForceFullAngleSweep + { + get => angleBuilder.ForceFullSweep; + set => angleBuilder.ForceFullSweep = value; + } + + public override List BuildAngles( + NestItem item, + ClassificationResult classification, + Box workArea + ) => BuildAnglesCore(item, classification, workArea); + + internal List BuildAnglesCore( + NestItem item, + ClassificationResult classification, + Box workArea + ) => angleBuilder.Build(item, classification, workArea); + + protected override void RecordProductiveAngles(List angleResults) => + RecordProductiveAnglesCore(angleResults); + + internal void RecordProductiveAnglesCore(List angleResults) + { + angleBuilder.RecordProductive(angleResults); + } + + public override List Fill( + NestItem item, + Box workArea, + IProgress progress, + CancellationToken token + ) + { + PhaseResults.Clear(); + AngleResults.Clear(); + + // Replace the item's Drawing with a canonical copy for the duration of this fill. + // All internal methods see canonical geometry; this wrapper un-canonicalizes the final result. + var originalDrawing = item.Drawing; + var canonicalItem = new NestItem + { + Drawing = CanonicalFrame.AsCanonicalCopy(item.Drawing), + Quantity = item.Quantity, + Priority = item.Priority, + RotationStart = item.RotationStart, + RotationEnd = item.RotationEnd, + StepAngle = item.StepAngle, + }; + + // Fast path for qty 1-2. + if (canonicalItem.Quantity > 0 && canonicalItem.Quantity <= 2) + { + var fast = TryFillSmallQuantity(canonicalItem, workArea); + if (fast != null && fast.Count >= canonicalItem.Quantity) + { + Debug.WriteLine( + $"[Fill] Fast path: placed {fast.Count} parts for qty={canonicalItem.Quantity}" + ); + WinnerPhase = NestPhase.Pairs; + fast = RebindAndUnCanonicalize(fast, originalDrawing); + NestProgressReporter.Report( + progress, + new ProgressReport + { + Phase = WinnerPhase, + PlateNumber = PlateNumber, + Parts = fast, + WorkArea = workArea, + Description = $"Fast path: {fast.Count} parts", + IsOverallBest = true, + } + ); + return fast; + } + } + + var effectiveWorkArea = workArea; + if (canonicalItem.Quantity > 0) + { + effectiveWorkArea = ShrinkWorkArea(canonicalItem, workArea, Plate.PartSpacing); + if (effectiveWorkArea != workArea) + Debug.WriteLine( + $"[Fill] Low-qty shrink: {canonicalItem.Quantity} requested, " + + $"from {workArea.Width:F1}x{workArea.Length:F1} " + + $"to {effectiveWorkArea.Width:F1}x{effectiveWorkArea.Length:F1}" + ); + } + + var best = RunFillPipeline(canonicalItem, effectiveWorkArea, progress, token); + + if ( + canonicalItem.Quantity > 0 + && best.Count < canonicalItem.Quantity + && effectiveWorkArea != workArea + ) + { + Debug.WriteLine( + $"[Fill] Low-qty fallback: got {best.Count}, need {canonicalItem.Quantity}, retrying full area" + ); + PhaseResults.Clear(); + AngleResults.Clear(); + best = RunFillPipeline(canonicalItem, workArea, progress, token); + } + + if (canonicalItem.Quantity > 0 && best.Count > canonicalItem.Quantity) + best = ShrinkFiller.TrimToCount(best, canonicalItem.Quantity, TrimAxis); + + best = RebindAndUnCanonicalize(best, originalDrawing); + + NestProgressReporter.Report( + progress, + new ProgressReport + { + Phase = WinnerPhase, + PlateNumber = PlateNumber, + Parts = best, + WorkArea = workArea, + Description = BuildProgressSummary(), + IsOverallBest = true, + } + ); + + return best; + } + + /// + /// Single exit point for canonical -> source frame conversion. Rebinds every Part to the + /// original Drawing (so consumers see the user's drawing identity, not the transient canonical copy) + /// and composes the canonical angle onto each Part's rotation via CanonicalFrame.RebindToOriginal. + /// + private static List RebindAndUnCanonicalize(List parts, Drawing original) => + CanonicalFrame.RebindToOriginal(parts, original); + + /// + /// Fast path for qty 1-2: place a single part or a best-fit pair + /// without running the full strategy pipeline. + /// + private List TryFillSmallQuantity(NestItem item, Box workArea) + { + if (item.Quantity == 1) + return TryPlaceSingle(item.Drawing, workArea); + + if (item.Quantity == 2) + return TryPlaceBestFitPair(item.Drawing, workArea); + + return null; + } + + private static List TryPlaceSingle(Drawing drawing, Box workArea) + { + var part = Part.CreateAtOrigin(drawing); + if ( + part.BoundingBox.Width > workArea.Width + Tolerance.Epsilon + || part.BoundingBox.Length > workArea.Length + Tolerance.Epsilon + ) + return null; + + part.Offset(workArea.Location - part.BoundingBox.Location); + return new List { part }; + } + + private List TryPlaceBestFitPair(Drawing drawing, Box workArea) + { + var bestFits = BestFitCache.GetOrCompute( + drawing, + Plate.Size.Length, + Plate.Size.Width, + Plate.PartSpacing + ); + + // Build pair candidates with a canonical drawing so their geometry matches + // the coordinate frame of the cached fit results. + var canonicalDrawing = CanonicalFrame.AsCanonicalCopy(drawing); + + List bestPlacement = null; + + foreach (var fit in bestFits) + { + if (!fit.Keep) + continue; + + // Skip pairs that can't possibly fit the work area in either orientation. + if ( + fit.ShortestSide + > System.Math.Min(workArea.Width, workArea.Length) + Tolerance.Epsilon + ) + continue; + if ( + fit.LongestSide + > System.Math.Max(workArea.Width, workArea.Length) + Tolerance.Epsilon + ) + continue; + + var landscape = fit.BuildParts(canonicalDrawing); + var portrait = RotatePair90(landscape); + + var lFits = TryOffsetToWorkArea(landscape, workArea); + var pFits = TryOffsetToWorkArea(portrait, workArea); + + // Pick the better orientation for this pair. + List candidate = null; + if (lFits && pFits) + candidate = IsBetterFill(portrait, landscape, workArea) ? portrait : landscape; + else if (lFits) + candidate = landscape; + else if (pFits) + candidate = portrait; + + if (candidate == null) + continue; + + if (bestPlacement == null || IsBetterFill(candidate, bestPlacement, workArea)) + bestPlacement = candidate; + } + + // Parts are returned in canonical frame, bound to the canonical drawing. + // The outer Fill wrapper rebinds to `drawing` and composes sourceAngle onto rotation. + return bestPlacement; + } + + private static List RotatePair90(List parts) + { + var rotated = new List(parts.Count); + foreach (var part in parts) + rotated.Add((Part)part.Clone()); + + var bbox = ((IEnumerable)rotated).GetBoundingBox(); + var center = bbox.Center; + + foreach (var part in rotated) + part.Rotate(-Angle.HalfPI, center); + + var newBbox = ((IEnumerable)rotated).GetBoundingBox(); + var offset = new Vector(-newBbox.Left, -newBbox.Bottom); + foreach (var part in rotated) + { + part.Offset(offset); + part.UpdateBounds(); + } + + return rotated; + } + + private static bool TryOffsetToWorkArea(List parts, Box workArea) + { + var bbox = ((IEnumerable)parts).GetBoundingBox(); + if ( + bbox.Width > workArea.Width + Tolerance.Epsilon + || bbox.Length > workArea.Length + Tolerance.Epsilon + ) + return false; + + var offset = workArea.Location - bbox.Location; + foreach (var part in parts) + { + part.Offset(offset); + part.UpdateBounds(); + } + return true; + } + + /// + /// Shrinks the work area in both dimensions proportionally when the + /// requested quantity is much less than the plate capacity. + /// + private static Box ShrinkWorkArea(NestItem item, Box workArea, double spacing) + { + var bbox = item.Drawing.Program.BoundingBox(); + if (bbox.Width <= 0 || bbox.Length <= 0) + return workArea; + + var bin = new Bin { Size = new Size(workArea.Width, workArea.Length) }; + var packItem = new Item + { + Size = new Size(bbox.Width + spacing, bbox.Length + spacing), + }; + var packer = new FillBestFit(bin); + packer.Fill(packItem); + var fullCount = bin.Items.Count; + + if (fullCount <= 0 || fullCount <= item.Quantity) + return workArea; + + // Scale both dimensions by sqrt(ratio) so the area shrinks + // proportionally. 2x margin gives strategies room to optimize. + var ratio = (double)item.Quantity / fullCount; + var scale = System.Math.Sqrt(ratio) * 2.0; + + var newWidth = workArea.Width * scale; + var newLength = workArea.Length * scale; + + // Ensure at least one part fits. + var minWidth = bbox.Width + spacing * 2; + var minLength = bbox.Length + spacing * 2; + newWidth = System.Math.Max(newWidth, minWidth); + newLength = System.Math.Max(newLength, minLength); + + // Clamp to original dimensions. + newWidth = System.Math.Min(newWidth, workArea.Width); + newLength = System.Math.Min(newLength, workArea.Length); + + if (newWidth >= workArea.Width && newLength >= workArea.Length) + return workArea; + + return new Box(workArea.X, workArea.Y, newLength, newWidth); + } + + private List RunFillPipeline( + NestItem item, + Box workArea, + IProgress progress, + CancellationToken token + ) + { + var context = new FillContext + { + Item = item, + WorkArea = workArea, + Plate = Plate, + PlateNumber = PlateNumber, + Token = token, + Progress = progress, + Policy = BuildPolicy(), + MaxQuantity = item.Quantity, + }; + RunPipeline(context); + + AngleResults.AddRange(context.AngleResults); + WinnerPhase = context.WinnerPhase; + + return context.CurrentBest ?? new List(); + } + + public override List Fill( + List groupParts, + Box workArea, + IProgress progress, + CancellationToken token + ) + { + if (groupParts == null || groupParts.Count == 0) + return new List(); + + // Single part: delegate to the strategy pipeline. + if (groupParts.Count == 1) + { + var nestItem = new NestItem { Drawing = groupParts[0].BaseDrawing }; + return Fill(nestItem, workArea, progress, token); + } + + // Multi-part group: linear pattern fill only. + PhaseResults.Clear(); + var engine = new FillLinear(workArea, Plate.PartSpacing) { Label = "GroupPattern" }; + var angles = RotationAnalysis.FindHullEdgeAngles(groupParts); + var best = FillHelpers.FillPattern(engine, groupParts, angles, workArea, Comparer); + PhaseResults.Add(new PhaseResult(NestPhase.Linear, best?.Count ?? 0, 0)); + + Debug.WriteLine( + $"[Fill(groupParts,Box)] Linear pattern: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Length:F1}" + ); + + NestProgressReporter.Report( + progress, + new ProgressReport + { + Phase = NestPhase.Linear, + PlateNumber = PlateNumber, + Parts = best, + WorkArea = workArea, + Description = BuildProgressSummary(), + IsOverallBest = true, + } + ); + + return best ?? new List(); + } + + public override List PackArea( + Box box, + List items, + IProgress progress, + CancellationToken token + ) + { + var binItems = BinConverter.ToItems(items, Plate.PartSpacing, Plate.Area()); + var bin = BinConverter.CreateBin(box, Plate.PartSpacing); + + var engine = new PackBottomLeft(bin); + engine.Pack(binItems); + + return BinConverter.ToParts(bin, items); + } + + protected virtual void RunPipeline(FillContext context) => RunPipelineCore(context); + + internal void RunPipelineCore(FillContext context) + { + var classification = PartClassifier.Classify(context.Item.Drawing); + context.PartType = classification.Type; + context.SharedState["BestRotation"] = classification.PrimaryAngle; + context.SharedState["Classification"] = classification; + + var angles = BuildAngles(context.Item, classification, context.WorkArea); + context.SharedState["AngleCandidates"] = angles; + + try + { + foreach (var strategy in FillStrategyRegistry.Strategies) + { + context.Token.ThrowIfCancellationRequested(); + context.ActivePhase = strategy.Phase; + + var stopwatch = Stopwatch.StartNew(); + var result = strategy.Fill(context); + stopwatch.Stop(); + + var phaseResult = new PhaseResult( + strategy.Phase, + result?.Count ?? 0, + stopwatch.ElapsedMilliseconds + ); + context.PhaseResults.Add(phaseResult); + + // Keep filler PhaseResults in sync so BuildProgressSummary() works + // during progress reporting. + PhaseResults.Add(phaseResult); + + // FillContext.ReportProgress updates CurrentBest during the + // strategy's angle sweep. This catches strategies that return a + // result without reporting it (e.g. RectBestFit). + var improved = context.Policy.Comparer.IsBetter( + result, + context.CurrentBest, + context.WorkArea + ); + if (improved) + { + context.CurrentBest = result; + context.CurrentBestScore = FillScore.Compute(result, context.WorkArea); + context.WinnerPhase = strategy.Phase; + } + + if (improved && context.CurrentBest != null && context.CurrentBest.Count > 0) + { + NestProgressReporter.Report( + context.Progress, + new ProgressReport + { + Phase = context.WinnerPhase, + PlateNumber = PlateNumber, + Parts = context.CurrentBest, + WorkArea = context.WorkArea, + Description = BuildProgressSummary(), + IsOverallBest = true, + } + ); + } + } + } + catch (OperationCanceledException) + { + Debug.WriteLine("[RunPipeline] Cancelled, returning current best"); + } + + RecordProductiveAngles(context.AngleResults); + } +} diff --git a/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillerBase.cs b/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillerBase.cs index 16933f8..072f123 100644 --- a/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillerBase.cs +++ b/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillerBase.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Threading; using OpenNest.Engine; using OpenNest.Engine.Fill; using OpenNest.Engine.Strategies; using OpenNest.Geometry; +using OpenNest.Math; namespace OpenNest.Engine.Jobs.Placement.Fillers; @@ -25,6 +27,11 @@ internal abstract class PlateFillerBase public NestPhase WinnerPhase { get; protected set; } + internal void SetWinnerPhase(NestPhase winnerPhase) + { + WinnerPhase = winnerPhase; + } + public List PhaseResults { get; } = new(); public List AngleResults { get; } = new(); @@ -54,6 +61,88 @@ internal abstract class PlateFillerBase protected FillPolicy BuildPolicy() => new(Comparer, PreferredDirection); + internal IFillComparer FillComparer => Comparer; + + protected string BuildProgressSummary() => BuildProgressSummary(PhaseResults); + + internal static string BuildProgressSummary(IReadOnlyList phaseResults) + { + if (phaseResults.Count == 0) + return null; + + var parts = new List(phaseResults.Count); + foreach (var result in phaseResults) + parts.Add($"{result.Phase.ShortName()}: {result.PartCount}"); + + return string.Join(" | ", parts); + } + + protected bool IsBetterFill(List candidate, List current, Box workArea) => + IsBetterFill(Comparer, candidate, current, workArea); + + internal static bool IsBetterFill( + IFillComparer comparer, + List candidate, + List current, + Box workArea + ) => comparer.IsBetter(candidate, current, workArea); + + protected bool IsBetterValidFill(List candidate, List current, Box workArea) + { + if ( + candidate != null + && candidate.Count > 0 + && HasOverlaps(candidate, Plate.PartSpacing) + ) + { + Debug.WriteLine( + $"[IsBetterValidFill] REJECTED {candidate.Count} parts due to overlaps (current best: {current?.Count ?? 0})" + ); + return false; + } + + return IsBetterFill(candidate, current, workArea); + } + + internal static bool HasOverlaps(List parts, double spacing) + { + if (parts == null || parts.Count <= 1) + return false; + + for (var i = 0; i < parts.Count; i++) + { + var box1 = parts[i].BoundingBox; + + for (var j = i + 1; j < parts.Count; j++) + { + var box2 = parts[j].BoundingBox; + + var overlapX = System.Math.Min(box1.Right, box2.Right) + - System.Math.Max(box1.Left, box2.Left); + var overlapY = System.Math.Min(box1.Top, box2.Top) + - System.Math.Max(box1.Bottom, box2.Bottom); + + if (overlapX <= Tolerance.Epsilon || overlapY <= Tolerance.Epsilon) + continue; + + List points; + if (parts[i].Intersects(parts[j], out points)) + { + var first = parts[i].BoundingBox; + var second = parts[j].BoundingBox; + Debug.WriteLine( + $"[HasOverlaps] Overlap: part[{i}] ({parts[i].BaseDrawing?.Name}) @ ({first.Left:F2},{first.Bottom:F2})-({first.Right:F2},{first.Top:F2}) rot={parts[i].Rotation:F2}" + + $" vs part[{j}] ({parts[j].BaseDrawing?.Name}) @ ({second.Left:F2},{second.Bottom:F2})-({second.Right:F2},{second.Top:F2}) rot={parts[j].Rotation:F2}" + + $" intersections={points?.Count ?? 0}" + ); + return true; + } + } + } + + return false; + } + public virtual List Fill( NestItem item, Box workArea, diff --git a/OpenNest.Engine/Jobs/Placement/Fillers/RemnantPlateFiller.cs b/OpenNest.Engine/Jobs/Placement/Fillers/RemnantPlateFiller.cs new file mode 100644 index 0000000..f34b13c --- /dev/null +++ b/OpenNest.Engine/Jobs/Placement/Fillers/RemnantPlateFiller.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using OpenNest.Engine; +using OpenNest.Engine.Fill; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Engine.Jobs.Placement.Fillers; + +internal class RemnantPlateFiller : DefaultPlateFiller +{ + private readonly RemnantFillPolicy policy; + + internal RemnantPlateFiller(Plate plate, RemnantFillPolicy policy) + : base(plate) + { + this.policy = policy; + } + + protected override IFillComparer CreateComparer() => policy.CreateComparer(); + + public override NestDirection? PreferredDirection => policy.PreferredDirection; + + public override ShrinkAxis TrimAxis => policy.TrimAxis; + + public override List BuildAngles( + NestItem item, + ClassificationResult classification, + Box workArea + ) => policy.BuildAngles(item, classification); +} + +internal sealed class RemnantFillPolicy +{ + private readonly Func comparerFactory; + private readonly Func angleExtent; + + private RemnantFillPolicy( + Func comparerFactory, + NestDirection preferredDirection, + ShrinkAxis trimAxis, + Func angleExtent + ) + { + this.comparerFactory = comparerFactory; + PreferredDirection = preferredDirection; + TrimAxis = trimAxis; + this.angleExtent = angleExtent; + } + + internal static RemnantFillPolicy Vertical { get; } = new( + () => new VerticalRemnantComparer(), + NestDirection.Horizontal, + ShrinkAxis.Width, + RotatedWidth + ); + + internal static RemnantFillPolicy Horizontal { get; } = new( + () => new HorizontalRemnantComparer(), + NestDirection.Vertical, + ShrinkAxis.Length, + RotatedHeight + ); + + internal NestDirection PreferredDirection { get; } + + internal ShrinkAxis TrimAxis { get; } + + internal IFillComparer CreateComparer() => comparerFactory(); + + internal List BuildAngles(NestItem item, ClassificationResult classification) + { + var baseAngles = new List + { + classification.PrimaryAngle, + classification.PrimaryAngle + Angle.HalfPI, + }; + baseAngles.Sort((left, right) => angleExtent(item, left).CompareTo(angleExtent(item, right))); + return baseAngles; + } + + private static double RotatedWidth(NestItem item, double angle) + { + var boundingBox = item.Drawing.Program.BoundingBox(); + var cos = System.Math.Abs(System.Math.Cos(angle)); + var sin = System.Math.Abs(System.Math.Sin(angle)); + return boundingBox.Length * cos + boundingBox.Width * sin; + } + + private static double RotatedHeight(NestItem item, double angle) + { + var boundingBox = item.Drawing.Program.BoundingBox(); + var cos = System.Math.Abs(System.Math.Cos(angle)); + var sin = System.Math.Abs(System.Math.Sin(angle)); + return boundingBox.Width * cos + boundingBox.Length * sin; + } +} diff --git a/OpenNest.Engine/NestEngineBase.cs b/OpenNest.Engine/NestEngineBase.cs index 090f3ea..1ca33d5 100644 --- a/OpenNest.Engine/NestEngineBase.cs +++ b/OpenNest.Engine/NestEngineBase.cs @@ -26,11 +26,14 @@ namespace OpenNest.Engine public NestDirection NestDirection { get; set; } - public NestPhase WinnerPhase { get; protected set; } + private readonly List phaseResults = new(); + private readonly List angleResults = new(); - public List PhaseResults { get; } = new(); + public virtual NestPhase WinnerPhase { get; protected set; } - public List AngleResults { get; } = new(); + public virtual List PhaseResults => phaseResults; + + public virtual List AngleResults => angleResults; public abstract string Name { get; } @@ -183,21 +186,11 @@ namespace OpenNest.Engine NestProgressReporter.Report(progress, report); } - protected string BuildProgressSummary() - { - if (PhaseResults.Count == 0) - return null; - - var parts = new List(PhaseResults.Count); - - foreach (var r in PhaseResults) - parts.Add($"{r.Phase.ShortName()}: {r.PartCount}"); - - return string.Join(" | ", parts); - } + protected string BuildProgressSummary() => + PlateFillerBase.BuildProgressSummary(PhaseResults); protected bool IsBetterFill(List candidate, List current, Box workArea) => - Comparer.IsBetter(candidate, current, workArea); + PlateFillerBase.IsBetterFill(Comparer, candidate, current, workArea); protected bool IsBetterValidFill(List candidate, List current, Box workArea) { @@ -216,47 +209,8 @@ namespace OpenNest.Engine return IsBetterFill(candidate, current, workArea); } - protected static bool HasOverlaps(List parts, double spacing) - { - if (parts == null || parts.Count <= 1) - return false; - - for (var i = 0; i < parts.Count; i++) - { - var box1 = parts[i].BoundingBox; - - for (var j = i + 1; j < parts.Count; j++) - { - var box2 = parts[j].BoundingBox; - - var overlapX = - System.Math.Min(box1.Right, box2.Right) - - System.Math.Max(box1.Left, box2.Left); - var overlapY = - System.Math.Min(box1.Top, box2.Top) - - System.Math.Max(box1.Bottom, box2.Bottom); - - if (overlapX <= Tolerance.Epsilon || overlapY <= Tolerance.Epsilon) - continue; - - List pts; - - if (parts[i].Intersects(parts[j], out pts)) - { - var b1 = parts[i].BoundingBox; - var b2 = parts[j].BoundingBox; - Debug.WriteLine( - $"[HasOverlaps] Overlap: part[{i}] ({parts[i].BaseDrawing?.Name}) @ ({b1.Left:F2},{b1.Bottom:F2})-({b1.Right:F2},{b1.Top:F2}) rot={parts[i].Rotation:F2}" - + $" vs part[{j}] ({parts[j].BaseDrawing?.Name}) @ ({b2.Left:F2},{b2.Bottom:F2})-({b2.Right:F2},{b2.Top:F2}) rot={parts[j].Rotation:F2}" - + $" intersections={pts?.Count ?? 0}" - ); - return true; - } - } - } - - return false; - } + protected static bool HasOverlaps(List parts, double spacing) => + PlateFillerBase.HasOverlaps(parts, spacing); } } diff --git a/OpenNest.Engine/VerticalRemnantEngine.cs b/OpenNest.Engine/VerticalRemnantEngine.cs index 8d419ad..a8869d6 100644 --- a/OpenNest.Engine/VerticalRemnantEngine.cs +++ b/OpenNest.Engine/VerticalRemnantEngine.cs @@ -1,16 +1,13 @@ -using System; using System.Collections.Generic; -using OpenNest.Engine; using OpenNest.Engine.Fill; +using OpenNest.Engine.Jobs.Placement.Fillers; +using OpenNest.Engine.Strategies; using OpenNest.Geometry; -using OpenNest.Math; namespace OpenNest.Engine { /// /// Optimizes for the largest right-side vertical drop. - /// Scores by count first, then minimizes X-extent. - /// Prefers horizontal nest direction and angles that keep parts narrow in X. /// public class VerticalRemnantEngine : DefaultNestEngine { @@ -21,31 +18,17 @@ namespace OpenNest.Engine public override string Description => "Optimizes for largest right-side vertical drop"; - protected override IFillComparer CreateComparer() => new VerticalRemnantComparer(); + protected override IFillComparer CreateComparer() => RemnantFillPolicy.Vertical.CreateComparer(); - public override NestDirection? PreferredDirection => NestDirection.Horizontal; + public override NestDirection? PreferredDirection => RemnantFillPolicy.Vertical.PreferredDirection; public override List BuildAngles( NestItem item, ClassificationResult classification, Box workArea - ) - { - var baseAngles = new List - { - classification.PrimaryAngle, - classification.PrimaryAngle + Angle.HalfPI, - }; - baseAngles.Sort((a, b) => RotatedWidth(item, a).CompareTo(RotatedWidth(item, b))); - return baseAngles; - } + ) => RemnantFillPolicy.Vertical.BuildAngles(item, classification); - private static double RotatedWidth(NestItem item, double angle) - { - var bb = item.Drawing.Program.BoundingBox(); - var cos = System.Math.Abs(System.Math.Cos(angle)); - var sin = System.Math.Abs(System.Math.Sin(angle)); - return bb.Length * cos + bb.Width * sin; - } + internal override DefaultPlateFiller CreateFiller(Plate plate) => + CreateRemnantFiller(plate, RemnantFillPolicy.Vertical); } }