diff --git a/OpenNest.Engine/AngleCandidateBuilder.cs b/OpenNest.Engine/AngleCandidateBuilder.cs
new file mode 100644
index 0000000..8c09ce0
--- /dev/null
+++ b/OpenNest.Engine/AngleCandidateBuilder.cs
@@ -0,0 +1,97 @@
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using OpenNest.Engine.ML;
+using OpenNest.Geometry;
+using OpenNest.Math;
+
+namespace OpenNest
+{
+ ///
+ /// 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)
+ {
+ var angles = new List { bestRotation, bestRotation + Angle.HalfPI };
+
+ var testPart = new Part(item.Drawing);
+ if (!bestRotation.IsEqualTo(0))
+ testPart.Rotate(bestRotation);
+ testPart.UpdateBounds();
+
+ var partLongestSide = System.Math.Max(testPart.BoundingBox.Width, testPart.BoundingBox.Length);
+ var workAreaShortSide = System.Math.Min(workArea.Width, workArea.Length);
+ var needsSweep = workAreaShortSide < partLongestSide || ForceFullSweep;
+
+ if (needsSweep)
+ {
+ var step = Angle.ToRadians(5);
+ for (var a = 0.0; a < System.Math.PI; a += step)
+ {
+ if (!angles.Any(existing => existing.IsEqualTo(a)))
+ angles.Add(a);
+ }
+ }
+
+ if (!ForceFullSweep && angles.Count > 2)
+ {
+ var features = FeatureExtractor.Extract(item.Drawing);
+ if (features != null)
+ {
+ var predicted = AnglePredictor.PredictAngles(
+ features, workArea.Width, workArea.Length);
+
+ if (predicted != null)
+ {
+ var mlAngles = new List(predicted);
+
+ if (!mlAngles.Any(a => a.IsEqualTo(bestRotation)))
+ mlAngles.Add(bestRotation);
+ if (!mlAngles.Any(a => a.IsEqualTo(bestRotation + Angle.HalfPI)))
+ mlAngles.Add(bestRotation + Angle.HalfPI);
+
+ Debug.WriteLine($"[AngleCandidateBuilder] ML: {angles.Count} angles -> {mlAngles.Count} predicted");
+ angles = mlAngles;
+ }
+ }
+ }
+
+ if (knownGoodAngles.Count > 0 && !ForceFullSweep)
+ {
+ var pruned = new List { bestRotation, bestRotation + Angle.HalfPI };
+
+ foreach (var a in knownGoodAngles)
+ {
+ if (!pruned.Any(existing => existing.IsEqualTo(a)))
+ pruned.Add(a);
+ }
+
+ Debug.WriteLine($"[AngleCandidateBuilder] Pruned: {angles.Count} -> {pruned.Count} angles (known-good)");
+ return pruned;
+ }
+
+ return angles;
+ }
+
+ ///
+ /// Records angles that produced results. These are used to prune
+ /// subsequent Build() calls.
+ ///
+ public void RecordProductive(List angleResults)
+ {
+ foreach (var ar in angleResults)
+ {
+ if (ar.PartCount > 0)
+ knownGoodAngles.Add(Angle.ToRadians(ar.AngleDeg));
+ }
+ }
+ }
+}
diff --git a/OpenNest.Tests/AngleCandidateBuilderTests.cs b/OpenNest.Tests/AngleCandidateBuilderTests.cs
new file mode 100644
index 0000000..29d26bf
--- /dev/null
+++ b/OpenNest.Tests/AngleCandidateBuilderTests.cs
@@ -0,0 +1,83 @@
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests;
+
+public class AngleCandidateBuilderTests
+{
+ private static Drawing MakeRectDrawing(double w, double h)
+ {
+ var pgm = new OpenNest.CNC.Program();
+ pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
+ pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, 0)));
+ pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, h)));
+ pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, h)));
+ pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
+ return new Drawing("rect", pgm);
+ }
+
+ [Fact]
+ public void Build_ReturnsAtLeastTwoAngles()
+ {
+ var builder = new AngleCandidateBuilder();
+ var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
+ var workArea = new Box(0, 0, 100, 100);
+
+ var angles = builder.Build(item, 0, workArea);
+
+ Assert.True(angles.Count >= 2);
+ }
+
+ [Fact]
+ public void Build_NarrowWorkArea_ProducesMoreAngles()
+ {
+ var builder = new AngleCandidateBuilder();
+ var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
+ var wideArea = new Box(0, 0, 100, 100);
+ var narrowArea = new Box(0, 0, 100, 8); // narrower than part's longest side
+
+ var wideAngles = builder.Build(item, 0, wideArea);
+ var narrowAngles = builder.Build(item, 0, narrowArea);
+
+ Assert.True(narrowAngles.Count > wideAngles.Count,
+ $"Narrow ({narrowAngles.Count}) should have more angles than wide ({wideAngles.Count})");
+ }
+
+ [Fact]
+ public void ForceFullSweep_ProducesFullSweep()
+ {
+ var builder = new AngleCandidateBuilder { ForceFullSweep = true };
+ var item = new NestItem { Drawing = MakeRectDrawing(5, 5) };
+ var workArea = new Box(0, 0, 100, 100);
+
+ var angles = builder.Build(item, 0, workArea);
+
+ // Full sweep at 5deg steps = ~36 angles (0 to 175), plus base angles
+ Assert.True(angles.Count > 10);
+ }
+
+ [Fact]
+ public void RecordProductive_PrunesSubsequentBuilds()
+ {
+ var builder = new AngleCandidateBuilder { ForceFullSweep = true };
+ var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
+ var workArea = new Box(0, 0, 100, 8);
+
+ // First build — full sweep
+ var firstAngles = builder.Build(item, 0, workArea);
+
+ // Record some as productive
+ var productive = new List
+ {
+ new AngleResult { AngleDeg = 0, PartCount = 5 },
+ new AngleResult { AngleDeg = 45, PartCount = 3 },
+ };
+ builder.RecordProductive(productive);
+
+ // Second build — should be pruned to known-good + base angles
+ builder.ForceFullSweep = false;
+ var secondAngles = builder.Build(item, 0, workArea);
+
+ Assert.True(secondAngles.Count < firstAngles.Count,
+ $"Pruned ({secondAngles.Count}) should be fewer than full ({firstAngles.Count})");
+ }
+}