From a7f27480e9c12077054a47eec5f233fcc56e10b0 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 16 Mar 2026 22:29:52 -0400 Subject: [PATCH] refactor(engine): extract AngleCandidateBuilder from DefaultNestEngine Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/AngleCandidateBuilder.cs | 97 ++++++++++++++++++++ OpenNest.Tests/AngleCandidateBuilderTests.cs | 83 +++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 OpenNest.Engine/AngleCandidateBuilder.cs create mode 100644 OpenNest.Tests/AngleCandidateBuilderTests.cs 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})"); + } +}