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:
@@ -1,3 +1,4 @@
|
|||||||
|
using OpenNest.Engine;
|
||||||
using OpenNest.Engine.Fill;
|
using OpenNest.Engine.Fill;
|
||||||
using OpenNest.Engine.Strategies;
|
using OpenNest.Engine.Strategies;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
@@ -26,9 +27,9 @@ namespace OpenNest
|
|||||||
set => angleBuilder.ForceFullSweep = value;
|
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)
|
protected override void RecordProductiveAngles(List<AngleResult> angleResults)
|
||||||
@@ -132,10 +133,12 @@ namespace OpenNest
|
|||||||
|
|
||||||
protected virtual void RunPipeline(FillContext context)
|
protected virtual void RunPipeline(FillContext context)
|
||||||
{
|
{
|
||||||
var bestRotation = RotationAnalysis.FindBestRotation(context.Item);
|
var classification = PartClassifier.Classify(context.Item.Drawing);
|
||||||
context.SharedState["BestRotation"] = bestRotation;
|
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;
|
context.SharedState["AngleCandidates"] = angles;
|
||||||
|
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -7,31 +7,68 @@ using System.Linq;
|
|||||||
|
|
||||||
namespace OpenNest.Engine.Fill
|
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
|
public class AngleCandidateBuilder
|
||||||
{
|
{
|
||||||
private readonly HashSet<double> knownGoodAngles = new();
|
private readonly HashSet<double> knownGoodAngles = new();
|
||||||
|
|
||||||
public bool ForceFullSweep { get; set; }
|
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)
|
if (knownGoodAngles.Count > 0 && !ForceFullSweep)
|
||||||
return BuildPrunedList(baseAngles);
|
return BuildPrunedList(baseAngles);
|
||||||
|
|
||||||
var angles = new List<double>(baseAngles);
|
var angles = new List<double>(baseAngles);
|
||||||
|
|
||||||
if (ForceFullSweep)
|
// Full 5-degree sweep for irregular parts.
|
||||||
AddSweepAngles(angles);
|
AddSweepAngles(angles);
|
||||||
|
|
||||||
if (!ForceFullSweep && angles.Count > 2)
|
// ML prediction complements the sweep when available.
|
||||||
angles = ApplyMlPrediction(item, workArea, baseAngles, angles);
|
angles = ApplyMlPrediction(item, workArea, baseAngles, angles);
|
||||||
|
|
||||||
return angles;
|
return angles;
|
||||||
}
|
}
|
||||||
@@ -64,7 +101,14 @@ namespace OpenNest.Engine.Fill
|
|||||||
mlAngles.Add(b);
|
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;
|
return mlAngles;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,10 +130,6 @@ namespace OpenNest.Engine.Fill
|
|||||||
return angles.Any(existing => existing.IsEqualTo(angle));
|
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)
|
public void RecordProductive(List<AngleResult> angleResults)
|
||||||
{
|
{
|
||||||
foreach (var ar in angleResults)
|
foreach (var ar in angleResults)
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ namespace OpenNest
|
|||||||
|
|
||||||
public override ShrinkAxis TrimAxis => ShrinkAxis.Length;
|
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)));
|
baseAngles.Sort((a, b) => RotatedHeight(item, a).CompareTo(RotatedHeight(item, b)));
|
||||||
return baseAngles;
|
return baseAngles;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ namespace OpenNest
|
|||||||
|
|
||||||
public virtual ShrinkAxis TrimAxis => ShrinkAxis.Width;
|
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) { }
|
protected virtual void RecordProductiveAngles(List<AngleResult> angleResults) { }
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ namespace OpenNest.Engine
|
|||||||
|
|
||||||
public static ClassificationResult Classify(Drawing drawing)
|
public static ClassificationResult Classify(Drawing drawing)
|
||||||
{
|
{
|
||||||
var result = new ClassificationResult();
|
var result = new ClassificationResult { Type = PartType.Irregular };
|
||||||
|
|
||||||
var entities = ConvertProgram.ToGeometry(drawing.Program)
|
var entities = ConvertProgram.ToGeometry(drawing.Program)
|
||||||
.Where(e => e.Layer != SpecialLayers.Rapid);
|
.Where(e => e.Layer != SpecialLayers.Rapid);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using OpenNest.Engine;
|
||||||
using OpenNest.Engine.Fill;
|
using OpenNest.Engine.Fill;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using System;
|
using System;
|
||||||
@@ -15,6 +16,7 @@ namespace OpenNest.Engine.Strategies
|
|||||||
public CancellationToken Token { get; init; }
|
public CancellationToken Token { get; init; }
|
||||||
public IProgress<NestProgress> Progress { get; init; }
|
public IProgress<NestProgress> Progress { get; init; }
|
||||||
public FillPolicy Policy { get; init; }
|
public FillPolicy Policy { get; init; }
|
||||||
|
public PartType PartType { get; set; }
|
||||||
|
|
||||||
public List<Part> CurrentBest { get; set; }
|
public List<Part> CurrentBest { get; set; }
|
||||||
/// <summary>For progress reporting only; comparisons use Policy.Comparer.</summary>
|
/// <summary>For progress reporting only; comparisons use Policy.Comparer.</summary>
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ namespace OpenNest
|
|||||||
|
|
||||||
public override NestDirection? PreferredDirection => NestDirection.Horizontal;
|
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)));
|
baseAngles.Sort((a, b) => RotatedWidth(item, a).CompareTo(RotatedWidth(item, b)));
|
||||||
return baseAngles;
|
return baseAngles;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using OpenNest.Engine;
|
||||||
using OpenNest.Engine.Fill;
|
using OpenNest.Engine.Fill;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
@@ -16,6 +17,9 @@ public class AngleCandidateBuilderTests
|
|||||||
return new Drawing("rect", pgm);
|
return new Drawing("rect", pgm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ClassificationResult MakeClassification(double primaryAngle = 0, PartType type = PartType.Irregular)
|
||||||
|
=> new ClassificationResult { PrimaryAngle = primaryAngle, Type = type };
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Build_ReturnsAtLeastTwoAngles()
|
public void Build_ReturnsAtLeastTwoAngles()
|
||||||
{
|
{
|
||||||
@@ -23,21 +27,21 @@ public class AngleCandidateBuilderTests
|
|||||||
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||||
var workArea = new Box(0, 0, 100, 100);
|
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);
|
Assert.True(angles.Count >= 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Build_NarrowWorkArea_UsesBaseAnglesOnly()
|
public void Build_RectangleType_NarrowWorkArea_UsesBaseAnglesOnly()
|
||||||
{
|
{
|
||||||
var builder = new AngleCandidateBuilder();
|
var builder = new AngleCandidateBuilder();
|
||||||
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||||
var narrowArea = new Box(0, 0, 100, 8); // narrower than part's longest side
|
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);
|
Assert.Equal(2, angles.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +52,7 @@ public class AngleCandidateBuilderTests
|
|||||||
var item = new NestItem { Drawing = MakeRectDrawing(5, 5) };
|
var item = new NestItem { Drawing = MakeRectDrawing(5, 5) };
|
||||||
var workArea = new Box(0, 0, 100, 100);
|
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
|
// Full sweep at 5deg steps = ~36 angles (0 to 175), plus base angles
|
||||||
Assert.True(angles.Count > 10);
|
Assert.True(angles.Count > 10);
|
||||||
@@ -62,7 +66,7 @@ public class AngleCandidateBuilderTests
|
|||||||
var workArea = new Box(0, 0, 100, 8);
|
var workArea = new Box(0, 0, 100, 8);
|
||||||
|
|
||||||
// First build — full sweep
|
// First build — full sweep
|
||||||
var firstAngles = builder.Build(item, 0, workArea);
|
var firstAngles = builder.Build(item, MakeClassification(), workArea);
|
||||||
|
|
||||||
// Record some as productive
|
// Record some as productive
|
||||||
var productive = new List<AngleResult>
|
var productive = new List<AngleResult>
|
||||||
@@ -74,9 +78,36 @@ public class AngleCandidateBuilderTests
|
|||||||
|
|
||||||
// Second build — should be pruned to known-good + base angles
|
// Second build — should be pruned to known-good + base angles
|
||||||
builder.ForceFullSweep = false;
|
builder.ForceFullSweep = false;
|
||||||
var secondAngles = builder.Build(item, 0, workArea);
|
var secondAngles = builder.Build(item, MakeClassification(), workArea);
|
||||||
|
|
||||||
Assert.True(secondAngles.Count < firstAngles.Count,
|
Assert.True(secondAngles.Count < firstAngles.Count,
|
||||||
$"Pruned ({secondAngles.Count}) should be fewer than full ({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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,11 +198,7 @@ public class PartClassifierTests
|
|||||||
|
|
||||||
var result = PartClassifier.Classify(drawing);
|
var result = PartClassifier.Classify(drawing);
|
||||||
|
|
||||||
// No shapes → early return with default struct (Type = Rectangle = 0, but
|
Assert.Equal(PartType.Irregular, result.Type);
|
||||||
// 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(0.0, result.Rectangularity);
|
Assert.Equal(0.0, result.Rectangularity);
|
||||||
Assert.Equal(0.0, result.Circularity);
|
Assert.Equal(0.0, result.Circularity);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using OpenNest.Converters;
|
using OpenNest.Converters;
|
||||||
|
using OpenNest.Engine;
|
||||||
using OpenNest.Engine.Fill;
|
using OpenNest.Engine.Fill;
|
||||||
using OpenNest.Engine.Strategies;
|
using OpenNest.Engine.Strategies;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
@@ -33,7 +34,7 @@ public class StrategyOverlapTests
|
|||||||
|
|
||||||
var strategies = FillStrategyRegistry.Strategies.ToList();
|
var strategies = FillStrategyRegistry.Strategies.ToList();
|
||||||
var item = new NestItem { Drawing = drawing };
|
var item = new NestItem { Drawing = drawing };
|
||||||
var bestRotation = RotationAnalysis.FindBestRotation(item);
|
var classification = PartClassifier.Classify(drawing);
|
||||||
var failures = new List<string>();
|
var failures = new List<string>();
|
||||||
|
|
||||||
foreach (var strategy in strategies)
|
foreach (var strategy in strategies)
|
||||||
@@ -50,9 +51,10 @@ public class StrategyOverlapTests
|
|||||||
Token = System.Threading.CancellationToken.None,
|
Token = System.Threading.CancellationToken.None,
|
||||||
Policy = policy,
|
Policy = policy,
|
||||||
};
|
};
|
||||||
context.SharedState["BestRotation"] = bestRotation;
|
context.SharedState["BestRotation"] = classification.PrimaryAngle;
|
||||||
|
context.SharedState["Classification"] = classification;
|
||||||
context.SharedState["AngleCandidates"] = new AngleCandidateBuilder().Build(
|
context.SharedState["AngleCandidates"] = new AngleCandidateBuilder().Build(
|
||||||
item, bestRotation, context.WorkArea);
|
item, classification, context.WorkArea);
|
||||||
|
|
||||||
var parts = strategy.Fill(context);
|
var parts = strategy.Fill(context);
|
||||||
var count = parts?.Count ?? 0;
|
var count = parts?.Count ?? 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user