feat: wire PartClassifier into engine and update angle selection

Replace RotationAnalysis.FindBestRotation with PartClassifier.Classify in
RunPipeline, propagate ClassificationResult through BuildAngles signatures and
FillContext.PartType, and rewrite AngleCandidateBuilder to dispatch on part type
(Circle=1 angle, Rectangle=2, Irregular=full sweep).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 22:19:20 -04:00
parent f83df3a55a
commit 05037bc928
10 changed files with 117 additions and 43 deletions

View File

@@ -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<double> BuildAngles(NestItem item, double bestRotation, Box workArea)
public override List<double> BuildAngles(NestItem item, ClassificationResult classification, Box workArea)
{
return angleBuilder.Build(item, bestRotation, workArea);
return angleBuilder.Build(item, classification, workArea);
}
protected override void RecordProductiveAngles(List<AngleResult> 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

View File

@@ -7,31 +7,68 @@ using System.Linq;
namespace OpenNest.Engine.Fill
{
/// <summary>
/// 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.
/// </summary>
public class AngleCandidateBuilder
{
private readonly HashSet<double> knownGoodAngles = new();
public bool ForceFullSweep { get; set; }
public List<double> Build(NestItem item, double bestRotation, Box workArea)
public List<double> 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<double> { 0 };
case PartType.Rectangle:
return new List<double> { 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<double> BuildFromConstraints(NestItem item)
{
var angles = new List<double>();
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<double> 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<double>(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));
}
/// <summary>
/// Records angles that produced results. These are used to prune
/// subsequent Build() calls.
/// </summary>
public void RecordProductive(List<AngleResult> angleResults)
{
foreach (var ar in angleResults)

View File

@@ -26,9 +26,9 @@ namespace OpenNest
public override ShrinkAxis TrimAxis => ShrinkAxis.Length;
public override List<double> BuildAngles(NestItem item, double bestRotation, Box workArea)
public override List<double> BuildAngles(NestItem item, ClassificationResult classification, Box workArea)
{
var baseAngles = new List<double> { bestRotation, bestRotation + Angle.HalfPI };
var baseAngles = new List<double> { classification.PrimaryAngle, classification.PrimaryAngle + Angle.HalfPI };
baseAngles.Sort((a, b) => RotatedHeight(item, a).CompareTo(RotatedHeight(item, b)));
return baseAngles;
}

View File

@@ -46,9 +46,9 @@ namespace OpenNest
public virtual ShrinkAxis TrimAxis => ShrinkAxis.Width;
public virtual List<double> BuildAngles(NestItem item, double bestRotation, Box workArea)
public virtual List<double> BuildAngles(NestItem item, ClassificationResult classification, Box workArea)
{
return new List<double> { bestRotation, bestRotation + OpenNest.Math.Angle.HalfPI };
return new List<double> { classification.PrimaryAngle, classification.PrimaryAngle + OpenNest.Math.Angle.HalfPI };
}
protected virtual void RecordProductiveAngles(List<AngleResult> angleResults) { }

View File

@@ -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);

View File

@@ -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<NestProgress> Progress { get; init; }
public FillPolicy Policy { get; init; }
public PartType PartType { get; set; }
public List<Part> CurrentBest { get; set; }
/// <summary>For progress reporting only; comparisons use Policy.Comparer.</summary>

View File

@@ -24,9 +24,9 @@ namespace OpenNest
public override NestDirection? PreferredDirection => NestDirection.Horizontal;
public override List<double> BuildAngles(NestItem item, double bestRotation, Box workArea)
public override List<double> BuildAngles(NestItem item, ClassificationResult classification, Box workArea)
{
var baseAngles = new List<double> { bestRotation, bestRotation + Angle.HalfPI };
var baseAngles = new List<double> { classification.PrimaryAngle, classification.PrimaryAngle + Angle.HalfPI };
baseAngles.Sort((a, b) => RotatedWidth(item, a).CompareTo(RotatedWidth(item, b)));
return baseAngles;
}