diff --git a/OpenNest.Engine/DefaultNestEngine.cs b/OpenNest.Engine/DefaultNestEngine.cs index c9463f0..9912ea2 100644 --- a/OpenNest.Engine/DefaultNestEngine.cs +++ b/OpenNest.Engine/DefaultNestEngine.cs @@ -1,3 +1,4 @@ +using OpenNest.Engine; using OpenNest.Engine.Fill; using OpenNest.Engine.Strategies; using OpenNest.Geometry; @@ -26,9 +27,9 @@ namespace OpenNest set => angleBuilder.ForceFullSweep = value; } - public override List BuildAngles(NestItem item, double bestRotation, Box workArea) + public override List BuildAngles(NestItem item, ClassificationResult classification, Box workArea) { - return angleBuilder.Build(item, bestRotation, workArea); + return angleBuilder.Build(item, classification, workArea); } protected override void RecordProductiveAngles(List angleResults) @@ -132,10 +133,12 @@ namespace OpenNest protected virtual void RunPipeline(FillContext context) { - var bestRotation = RotationAnalysis.FindBestRotation(context.Item); - context.SharedState["BestRotation"] = bestRotation; + 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, bestRotation, context.WorkArea); + var angles = BuildAngles(context.Item, classification, context.WorkArea); context.SharedState["AngleCandidates"] = angles; try diff --git a/OpenNest.Engine/Fill/AngleCandidateBuilder.cs b/OpenNest.Engine/Fill/AngleCandidateBuilder.cs index 9f2a1ec..738ad54 100644 --- a/OpenNest.Engine/Fill/AngleCandidateBuilder.cs +++ b/OpenNest.Engine/Fill/AngleCandidateBuilder.cs @@ -7,31 +7,68 @@ using System.Linq; namespace OpenNest.Engine.Fill { - /// - /// Builds candidate rotation angles for single-item fill. Encapsulates the - /// full pipeline: base angles, narrow-area sweep, ML prediction, and - /// known-good pruning across fills. - /// public class AngleCandidateBuilder { private readonly HashSet knownGoodAngles = new(); public bool ForceFullSweep { get; set; } - public List Build(NestItem item, double bestRotation, Box workArea) + public List Build(NestItem item, ClassificationResult classification, Box workArea) { - var baseAngles = new[] { bestRotation, bestRotation + Angle.HalfPI }; + // User constraints always take precedence over classification. + if (HasExplicitConstraints(item)) + return BuildFromConstraints(item); + + switch (classification.Type) + { + case PartType.Circle: + return new List { 0 }; + + case PartType.Rectangle: + return new List { classification.PrimaryAngle, classification.PrimaryAngle + Angle.HalfPI }; + + default: + return BuildIrregularAngles(item, classification.PrimaryAngle, workArea); + } + } + + private static bool HasExplicitConstraints(NestItem item) + { + // Default NestConstraints: Start=0, End=0. Both zero = no constraints. + return !(item.RotationStart.IsEqualTo(0) && item.RotationEnd.IsEqualTo(0)); + } + + private static List BuildFromConstraints(NestItem item) + { + var angles = new List(); + var step = item.StepAngle > Tolerance.Epsilon ? item.StepAngle : Angle.ToRadians(5); + + for (var a = item.RotationStart; a <= item.RotationEnd + Tolerance.Epsilon; a += step) + { + if (!ContainsAngle(angles, a)) + angles.Add(a); + } + + if (angles.Count == 0) + angles.Add(item.RotationStart); + + return angles; + } + + private List BuildIrregularAngles(NestItem item, double primaryAngle, Box workArea) + { + var baseAngles = new[] { primaryAngle, primaryAngle + Angle.HalfPI }; if (knownGoodAngles.Count > 0 && !ForceFullSweep) return BuildPrunedList(baseAngles); var angles = new List(baseAngles); - if (ForceFullSweep) - AddSweepAngles(angles); + // Full 5-degree sweep for irregular parts. + AddSweepAngles(angles); - if (!ForceFullSweep && angles.Count > 2) - angles = ApplyMlPrediction(item, workArea, baseAngles, angles); + // ML prediction complements the sweep when available. + angles = ApplyMlPrediction(item, workArea, baseAngles, angles); return angles; } @@ -64,7 +101,14 @@ namespace OpenNest.Engine.Fill mlAngles.Add(b); } - Debug.WriteLine($"[AngleCandidateBuilder] ML: {fallback.Count} angles -> {mlAngles.Count} predicted"); + // Merge ML angles into the existing sweep so both contribute. + foreach (var a in fallback) + { + if (!ContainsAngle(mlAngles, a)) + mlAngles.Add(a); + } + + Debug.WriteLine($"[AngleCandidateBuilder] ML: {fallback.Count} sweep + {predicted.Count} predicted = {mlAngles.Count} total"); return mlAngles; } @@ -86,10 +130,6 @@ namespace OpenNest.Engine.Fill return angles.Any(existing => existing.IsEqualTo(angle)); } - /// - /// Records angles that produced results. These are used to prune - /// subsequent Build() calls. - /// public void RecordProductive(List angleResults) { foreach (var ar in angleResults) diff --git a/OpenNest.Engine/HorizontalRemnantEngine.cs b/OpenNest.Engine/HorizontalRemnantEngine.cs index 6218a9a..06e7e13 100644 --- a/OpenNest.Engine/HorizontalRemnantEngine.cs +++ b/OpenNest.Engine/HorizontalRemnantEngine.cs @@ -26,9 +26,9 @@ namespace OpenNest public override ShrinkAxis TrimAxis => ShrinkAxis.Length; - public override List BuildAngles(NestItem item, double bestRotation, Box workArea) + public override List BuildAngles(NestItem item, ClassificationResult classification, Box workArea) { - var baseAngles = new List { bestRotation, bestRotation + Angle.HalfPI }; + var baseAngles = new List { classification.PrimaryAngle, classification.PrimaryAngle + Angle.HalfPI }; baseAngles.Sort((a, b) => RotatedHeight(item, a).CompareTo(RotatedHeight(item, b))); return baseAngles; } diff --git a/OpenNest.Engine/NestEngineBase.cs b/OpenNest.Engine/NestEngineBase.cs index f9a5f9e..9fb0921 100644 --- a/OpenNest.Engine/NestEngineBase.cs +++ b/OpenNest.Engine/NestEngineBase.cs @@ -46,9 +46,9 @@ namespace OpenNest public virtual ShrinkAxis TrimAxis => ShrinkAxis.Width; - public virtual List BuildAngles(NestItem item, double bestRotation, Box workArea) + public virtual List BuildAngles(NestItem item, ClassificationResult classification, Box workArea) { - return new List { bestRotation, bestRotation + OpenNest.Math.Angle.HalfPI }; + return new List { classification.PrimaryAngle, classification.PrimaryAngle + OpenNest.Math.Angle.HalfPI }; } protected virtual void RecordProductiveAngles(List angleResults) { } diff --git a/OpenNest.Engine/PartClassifier.cs b/OpenNest.Engine/PartClassifier.cs index da8e137..f53f4ab 100644 --- a/OpenNest.Engine/PartClassifier.cs +++ b/OpenNest.Engine/PartClassifier.cs @@ -25,7 +25,7 @@ namespace OpenNest.Engine public static ClassificationResult Classify(Drawing drawing) { - var result = new ClassificationResult(); + var result = new ClassificationResult { Type = PartType.Irregular }; var entities = ConvertProgram.ToGeometry(drawing.Program) .Where(e => e.Layer != SpecialLayers.Rapid); diff --git a/OpenNest.Engine/Strategies/FillContext.cs b/OpenNest.Engine/Strategies/FillContext.cs index 166894a..8f43f76 100644 --- a/OpenNest.Engine/Strategies/FillContext.cs +++ b/OpenNest.Engine/Strategies/FillContext.cs @@ -1,3 +1,4 @@ +using OpenNest.Engine; using OpenNest.Engine.Fill; using OpenNest.Geometry; using System; @@ -15,6 +16,7 @@ namespace OpenNest.Engine.Strategies public CancellationToken Token { get; init; } public IProgress Progress { get; init; } public FillPolicy Policy { get; init; } + public PartType PartType { get; set; } public List CurrentBest { get; set; } /// For progress reporting only; comparisons use Policy.Comparer. diff --git a/OpenNest.Engine/VerticalRemnantEngine.cs b/OpenNest.Engine/VerticalRemnantEngine.cs index 1be136b..b7f6791 100644 --- a/OpenNest.Engine/VerticalRemnantEngine.cs +++ b/OpenNest.Engine/VerticalRemnantEngine.cs @@ -24,9 +24,9 @@ namespace OpenNest public override NestDirection? PreferredDirection => NestDirection.Horizontal; - public override List BuildAngles(NestItem item, double bestRotation, Box workArea) + public override List BuildAngles(NestItem item, ClassificationResult classification, Box workArea) { - var baseAngles = new List { bestRotation, bestRotation + Angle.HalfPI }; + var baseAngles = new List { classification.PrimaryAngle, classification.PrimaryAngle + Angle.HalfPI }; baseAngles.Sort((a, b) => RotatedWidth(item, a).CompareTo(RotatedWidth(item, b))); return baseAngles; } diff --git a/OpenNest.Tests/AngleCandidateBuilderTests.cs b/OpenNest.Tests/AngleCandidateBuilderTests.cs index fa892a3..ba9acc1 100644 --- a/OpenNest.Tests/AngleCandidateBuilderTests.cs +++ b/OpenNest.Tests/AngleCandidateBuilderTests.cs @@ -1,3 +1,4 @@ +using OpenNest.Engine; using OpenNest.Engine.Fill; using OpenNest.Geometry; @@ -16,6 +17,9 @@ public class AngleCandidateBuilderTests return new Drawing("rect", pgm); } + private static ClassificationResult MakeClassification(double primaryAngle = 0, PartType type = PartType.Irregular) + => new ClassificationResult { PrimaryAngle = primaryAngle, Type = type }; + [Fact] public void Build_ReturnsAtLeastTwoAngles() { @@ -23,21 +27,21 @@ public class AngleCandidateBuilderTests var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; var workArea = new Box(0, 0, 100, 100); - var angles = builder.Build(item, 0, workArea); + var angles = builder.Build(item, MakeClassification(), workArea); Assert.True(angles.Count >= 2); } [Fact] - public void Build_NarrowWorkArea_UsesBaseAnglesOnly() + public void Build_RectangleType_NarrowWorkArea_UsesBaseAnglesOnly() { var builder = new AngleCandidateBuilder(); var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; var narrowArea = new Box(0, 0, 100, 8); // narrower than part's longest side - var angles = builder.Build(item, 0, narrowArea); + var angles = builder.Build(item, MakeClassification(0, PartType.Rectangle), narrowArea); - // Without ForceFullSweep, narrow areas use only base angles (0° and 90°) + // Rectangle classification always returns exactly 2 angles regardless of work area Assert.Equal(2, angles.Count); } @@ -48,7 +52,7 @@ public class AngleCandidateBuilderTests var item = new NestItem { Drawing = MakeRectDrawing(5, 5) }; var workArea = new Box(0, 0, 100, 100); - var angles = builder.Build(item, 0, workArea); + var angles = builder.Build(item, MakeClassification(), workArea); // Full sweep at 5deg steps = ~36 angles (0 to 175), plus base angles Assert.True(angles.Count > 10); @@ -62,7 +66,7 @@ public class AngleCandidateBuilderTests var workArea = new Box(0, 0, 100, 8); // First build — full sweep - var firstAngles = builder.Build(item, 0, workArea); + var firstAngles = builder.Build(item, MakeClassification(), workArea); // Record some as productive var productive = new List @@ -74,9 +78,36 @@ public class AngleCandidateBuilderTests // Second build — should be pruned to known-good + base angles builder.ForceFullSweep = false; - var secondAngles = builder.Build(item, 0, workArea); + var secondAngles = builder.Build(item, MakeClassification(), workArea); Assert.True(secondAngles.Count < firstAngles.Count, $"Pruned ({secondAngles.Count}) should be fewer than full ({firstAngles.Count})"); } + + [Fact] + public void Build_RectanglePart_ReturnsTwoAngles() + { + var builder = new AngleCandidateBuilder(); + var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; + var workArea = new Box(0, 0, 100, 100); + var classification = MakeClassification(0, PartType.Rectangle); + + var angles = builder.Build(item, classification, workArea); + + Assert.Equal(2, angles.Count); + } + + [Fact] + public void Build_CirclePart_ReturnsOneAngle() + { + var builder = new AngleCandidateBuilder(); + var item = new NestItem { Drawing = MakeRectDrawing(10, 10) }; + var workArea = new Box(0, 0, 100, 100); + var classification = MakeClassification(0, PartType.Circle); + + var angles = builder.Build(item, classification, workArea); + + Assert.Single(angles); + Assert.Equal(0, angles[0]); + } } diff --git a/OpenNest.Tests/PartClassifierTests.cs b/OpenNest.Tests/PartClassifierTests.cs index c0229f4..1c288ac 100644 --- a/OpenNest.Tests/PartClassifierTests.cs +++ b/OpenNest.Tests/PartClassifierTests.cs @@ -198,11 +198,7 @@ public class PartClassifierTests var result = PartClassifier.Classify(drawing); - // No shapes → early return with default struct (Type = Rectangle = 0, but - // the implementation returns early before setting Type, so default is Rectangle (0). - // Verify that no exception is thrown and we get the zero-value struct back. - // Per implementation: returns default(ClassificationResult) which has Type=Rectangle. - Assert.Equal(default(PartType), result.Type); + Assert.Equal(PartType.Irregular, result.Type); Assert.Equal(0.0, result.Rectangularity); Assert.Equal(0.0, result.Circularity); } diff --git a/OpenNest.Tests/StrategyOverlapTests.cs b/OpenNest.Tests/StrategyOverlapTests.cs index 98536d9..e47f124 100644 --- a/OpenNest.Tests/StrategyOverlapTests.cs +++ b/OpenNest.Tests/StrategyOverlapTests.cs @@ -1,4 +1,5 @@ using OpenNest.Converters; +using OpenNest.Engine; using OpenNest.Engine.Fill; using OpenNest.Engine.Strategies; using OpenNest.Geometry; @@ -33,7 +34,7 @@ public class StrategyOverlapTests var strategies = FillStrategyRegistry.Strategies.ToList(); var item = new NestItem { Drawing = drawing }; - var bestRotation = RotationAnalysis.FindBestRotation(item); + var classification = PartClassifier.Classify(drawing); var failures = new List(); foreach (var strategy in strategies) @@ -50,9 +51,10 @@ public class StrategyOverlapTests Token = System.Threading.CancellationToken.None, Policy = policy, }; - context.SharedState["BestRotation"] = bestRotation; + context.SharedState["BestRotation"] = classification.PrimaryAngle; + context.SharedState["Classification"] = classification; context.SharedState["AngleCandidates"] = new AngleCandidateBuilder().Build( - item, bestRotation, context.WorkArea); + item, classification, context.WorkArea); var parts = strategy.Fill(context); var count = parts?.Count ?? 0;