Compare commits

...

6 Commits

Author SHA1 Message Date
aj 6ce501da11 feat: smart strategy skipping, pack rotation, and dual-sort packing
- Skip ExtentsFillStrategy for rectangle/circle parts
- Skip PairsFillStrategy for circle parts
- PackBottomLeft now tries rotated orientation when items don't fit
- PackBottomLeft tries both area-descending and length-descending sort
  orders, keeping whichever places more parts (tighter bbox on tie)
- Add user constraint override tests for AngleCandidateBuilder

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 23:25:40 -04:00
aj 05037bc928 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>
2026-03-29 22:19:20 -04:00
aj f83df3a55a test: add PartClassifier unit tests for all shape types
Covers all 9 cases: pure rectangle, rounded rectangle, rect with notch,
circle, L-shape, triangle, serrated edge (perimeter ratio), tilted rect
(primary angle), and empty drawing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 22:12:40 -04:00
aj 84ad39414a feat: add PartClassifier with rectangle/circle/irregular detection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 22:09:33 -04:00
aj fdb4a2373a fix: simplify Shape.OffsetOutward winding normalization and sync designer
OffsetOutward now normalizes to CW winding before offsetting instead of
trial-and-error with bounding box comparison. CadConverterForm designer
regenerated with new entityView1 properties.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 21:01:46 -04:00
aj 3a0267c041 chore: add docs/ to gitignore and remove tracked superpowers docs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 21:01:20 -04:00
18 changed files with 602 additions and 1430 deletions
+3
View File
@@ -211,5 +211,8 @@ FakesAssemblies/
.superpowers/
docs/superpowers/
# Documentation (manuals, templates, etc.)
docs/
# Launch settings
**/Properties/launchSettings.json
+22 -27
View File
@@ -579,43 +579,38 @@ namespace OpenNest.Geometry
}
/// <summary>
/// Offsets the shape outward by the given distance, detecting winding direction
/// to choose the correct offset side. Falls back to the opposite side if the
/// bounding box shrinks (indicating the offset went inward).
/// Offsets the shape outward by the given distance.
/// Normalizes to CW winding before offsetting Left (which is outward for CW),
/// making the method independent of the original contour winding direction.
/// </summary>
public Shape OffsetOutward(double distance)
{
var poly = ToPolygon();
var side = poly.Vertices.Count >= 3 && poly.RotationDirection() == RotationType.CW
? OffsetSide.Left
: OffsetSide.Right;
var result = OffsetEntity(distance, side) as Shape;
if (poly == null || poly.Vertices.Count < 3
|| poly.RotationDirection() == RotationType.CW)
return OffsetEntity(distance, OffsetSide.Left) as Shape;
if (result == null)
return null;
// Shape is CCW — reverse to CW so Left offset goes outward.
var copy = new Shape();
UpdateBounds();
var originalBB = BoundingBox;
result.UpdateBounds();
var offsetBB = result.BoundingBox;
if (offsetBB.Width < originalBB.Width || offsetBB.Length < originalBB.Length)
for (var i = Entities.Count - 1; i >= 0; i--)
{
Trace.TraceWarning(
"Shape.OffsetOutward: offset shrank bounding box " +
$"(original={originalBB.Width:F3}x{originalBB.Length:F3}, " +
$"offset={offsetBB.Width:F3}x{offsetBB.Length:F3}). " +
"Retrying with opposite side.");
var opposite = side == OffsetSide.Left ? OffsetSide.Right : OffsetSide.Left;
var retry = OffsetEntity(distance, opposite) as Shape;
if (retry != null)
result = retry;
switch (Entities[i])
{
case Line l:
copy.Entities.Add(new Line(l.EndPoint, l.StartPoint) { Layer = l.Layer });
break;
case Arc a:
copy.Entities.Add(new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed) { Layer = a.Layer });
break;
case Circle c:
copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer });
break;
}
}
return result;
return copy.OffsetEntity(distance, OffsetSide.Left) as Shape;
}
/// <summary>
+8 -5
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
+56 -16
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)
+2 -2
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;
}
+2 -2
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) { }
+104
View File
@@ -0,0 +1,104 @@
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Engine
{
public enum PartType { Rectangle, Circle, Irregular }
public struct ClassificationResult
{
public PartType Type;
public double Rectangularity;
public double Circularity;
public double PerimeterRatio;
public double PrimaryAngle;
}
public static class PartClassifier
{
public const double RectangularityThreshold = 0.92;
public const double PerimeterRatioThreshold = 0.85;
public const double CircularityThreshold = 0.95;
public static ClassificationResult Classify(Drawing drawing)
{
var result = new ClassificationResult { Type = PartType.Irregular };
var entities = ConvertProgram.ToGeometry(drawing.Program)
.Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = ShapeBuilder.GetShapes(entities);
if (shapes.Count == 0)
return result;
// Find the largest shape (outer perimeter).
var perimeter = shapes[0];
var perimeterArea = perimeter.Area();
for (var i = 1; i < shapes.Count; i++)
{
var area = shapes[i].Area();
if (area > perimeterArea)
{
perimeter = shapes[i];
perimeterArea = area;
}
}
// Convert to polygon for hull/MBR computation.
var polygon = perimeter.ToPolygonWithTolerance(0.1);
if (polygon == null || polygon.Vertices.Count < 3)
return result;
// Compute convex hull.
var hull = ConvexHull.Compute(polygon.Vertices);
var hullArea = hull.Area();
// Compute MBR via rotating calipers.
var mbr = RotatingCalipers.MinimumBoundingRectangle(hull);
var mbrArea = mbr.Area;
var mbrPerimeter = 2 * (mbr.Width + mbr.Height);
// Store primary angle (negated to align MBR with axes, same as RotationAnalysis).
result.PrimaryAngle = -mbr.Angle;
// Drawing perimeter for circularity and perimeter ratio.
var drawingPerimeter = polygon.Perimeter();
// Circularity: 4*PI*area / perimeter^2. Circles ~ 1.0.
if (drawingPerimeter > Tolerance.Epsilon)
result.Circularity = 4 * System.Math.PI * perimeterArea / (drawingPerimeter * drawingPerimeter);
// Check circle first (rotationally invariant).
if (result.Circularity >= CircularityThreshold)
{
result.Type = PartType.Circle;
return result;
}
// Rectangularity: hull area / MBR area.
if (mbrArea > Tolerance.Epsilon)
result.Rectangularity = hullArea / mbrArea;
// Perimeter ratio: MBR perimeter / drawing perimeter.
if (drawingPerimeter > Tolerance.Epsilon)
result.PerimeterRatio = mbrPerimeter / drawingPerimeter;
// Rectangle: both metrics pass thresholds.
if (result.Rectangularity >= RectangularityThreshold
&& result.PerimeterRatio >= PerimeterRatioThreshold)
{
result.Type = PartType.Rectangle;
return result;
}
result.Type = PartType.Irregular;
return result;
}
}
}
@@ -1,4 +1,4 @@
using OpenNest.Geometry;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
using System.Linq;
@@ -7,33 +7,50 @@ namespace OpenNest.RectanglePacking
{
internal class PackBottomLeft : PackEngine
{
private List<Vector> points;
public PackBottomLeft(Bin bin)
: base(bin)
{
points = new List<Vector>();
}
public override void Pack(List<Item> items)
{
items = items.OrderBy(i => -i.Area()).ToList();
var byArea = items.Select(i => i.Clone() as Item).OrderByDescending(i => i.Area()).ToList();
var byLength = items.Select(i => i.Clone() as Item).OrderByDescending(i => System.Math.Max(i.Width, i.Length)).ToList();
points.Add(Bin.Location);
var resultA = PackWithOrder(byArea);
var resultB = PackWithOrder(byLength);
var winner = PickWinner(resultA, resultB);
Bin.Items.AddRange(winner);
}
private List<Item> PackWithOrder(List<Item> items)
{
var points = new List<Vector> { Bin.Location };
var placed = new List<Item>();
var skip = new List<int>();
for (int i = 0; i < items.Count; i++)
for (var i = 0; i < items.Count; i++)
{
var item = items[i];
if (skip.Contains(item.Id))
continue;
var pt = FindPointVertical(item);
var pt = FindPointVertical(item, points, placed);
// If it doesn't fit, try rotated.
if (pt == null)
{
item.Rotate();
pt = FindPointVertical(item, points, placed);
}
if (pt == null)
{
if (item.IsRotated)
item.Rotate();
skip.Add(item.Id);
continue;
}
@@ -44,23 +61,37 @@ namespace OpenNest.RectanglePacking
points.Add(new Vector(item.Left, item.Top));
points.Add(new Vector(item.Right, item.Bottom));
Bin.Items.Add(item);
placed.Add(item);
}
points.Clear();
return placed;
}
private Vector? FindPointVertical(Item item)
private static List<Item> PickWinner(List<Item> a, List<Item> b)
{
if (a.Count != b.Count)
return a.Count > b.Count ? a : b;
if (a.Count == 0)
return a;
var areaA = a.GetBoundingBox().Area();
var areaB = b.GetBoundingBox().Area();
return areaB < areaA ? b : a;
}
private Vector? FindPointVertical(Item item, List<Vector> points, List<Item> placed)
{
var pt = new Vector(double.MaxValue, double.MaxValue);
for (int i = 0; i < points.Count; i++)
for (var i = 0; i < points.Count; i++)
{
var point = points[i];
item.Location = point;
if (!IsValid(item))
if (!IsValid(item, placed))
continue;
if (point.X < pt.X)
@@ -75,12 +106,12 @@ namespace OpenNest.RectanglePacking
return null;
}
private bool IsValid(Item item)
private bool IsValid(Item item, List<Item> placed)
{
if (!Bin.Contains(item))
return false;
foreach (var it in Bin.Items)
foreach (var it in placed)
{
if (item.Intersects(it))
return false;
@@ -12,6 +12,8 @@ namespace OpenNest.Engine.Strategies
public List<Part> Fill(FillContext context)
{
if (context.PartType == PartType.Rectangle || context.PartType == PartType.Circle)
return null;
var filler = new FillExtents(context.WorkArea, context.Plate.PartSpacing);
var bestRotation = context.SharedState.TryGetValue("BestRotation", out var rot)
@@ -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>
@@ -20,6 +20,9 @@ namespace OpenNest.Engine.Strategies
if (active.Value)
return null;
if (context.PartType == PartType.Circle)
return null;
active.Value = true;
try
{
+2 -2
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;
}
+80 -7
View File
@@ -1,5 +1,7 @@
using OpenNest.Engine;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Tests;
@@ -16,6 +18,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 +28,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 +53,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 +67,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<AngleResult>
@@ -74,9 +79,77 @@ 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]);
}
[Fact]
public void Build_UserConstraints_OverrideRectangleClassification()
{
var builder = new AngleCandidateBuilder();
var item = new NestItem
{
Drawing = MakeRectDrawing(100, 50),
RotationStart = Angle.ToRadians(10),
RotationEnd = Angle.ToRadians(90),
StepAngle = Angle.ToRadians(10),
};
var classification = MakeClassification(0, PartType.Rectangle);
var workArea = new Box(0, 0, 1000, 500);
var angles = builder.Build(item, classification, workArea);
Assert.True(angles.Count > 2,
$"User constraints should override rect classification, got {angles.Count} angles");
}
[Fact]
public void Build_UserConstraints_StartingAtZero_AreRespected()
{
var builder = new AngleCandidateBuilder();
var item = new NestItem
{
Drawing = MakeRectDrawing(100, 50),
RotationStart = 0,
RotationEnd = System.Math.PI,
StepAngle = Angle.ToRadians(45),
};
var classification = MakeClassification(0, PartType.Rectangle);
var workArea = new Box(0, 0, 1000, 500);
var angles = builder.Build(item, classification, workArea);
// Start=0, End=PI is NOT "no constraints" — it's a real 0-180 range
Assert.True(angles.Count > 2,
$"0-to-PI constraint should produce multiple angles, got {angles.Count}");
}
}
+205
View File
@@ -0,0 +1,205 @@
using OpenNest.CNC;
using OpenNest.Engine;
using OpenNest.Geometry;
using OpenNest.Math;
using OpenNest.Shapes;
namespace OpenNest.Tests;
public class PartClassifierTests
{
// ── helpers ──────────────────────────────────────────────────────────────
private static Drawing MakeRectDrawing(double w, double h)
{
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(w, 0)));
pgm.Codes.Add(new LinearMove(new Vector(w, h)));
pgm.Codes.Add(new LinearMove(new Vector(0, h)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
return new Drawing("rect", pgm);
}
// ── tests ─────────────────────────────────────────────────────────────────
[Fact]
public void Classify_PureRectangle_ReturnsRectangle()
{
var drawing = MakeRectDrawing(100, 50);
var result = PartClassifier.Classify(drawing);
Assert.Equal(PartType.Rectangle, result.Type);
Assert.True(result.Rectangularity >= 0.99, $"Expected rectangularity>=0.99, got {result.Rectangularity:F4}");
Assert.True(result.PerimeterRatio >= 0.99, $"Expected perimeterRatio>=0.99, got {result.PerimeterRatio:F4}");
}
[Fact]
public void Classify_RoundedRectangle_ReturnsRectangle()
{
// Use the built-in shape builder so arc geometry is constructed correctly.
var shape = new RoundedRectangleShape { Length = 100, Width = 50, Radius = 5 };
var drawing = shape.GetDrawing();
var result = PartClassifier.Classify(drawing);
Assert.Equal(PartType.Rectangle, result.Type);
}
[Fact]
public void Classify_RectWithSmallNotches_ReturnsRectangle()
{
// 100x50 rectangle with a 5x2 notch cut into the bottom edge near the centre.
// The notch is small relative to the overall perimeter so both metrics still pass.
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
// Bottom edge left section -> notch -> bottom edge right section
pgm.Codes.Add(new LinearMove(new Vector(45, 0))); // along bottom to notch start
pgm.Codes.Add(new LinearMove(new Vector(45, 2))); // up into notch
pgm.Codes.Add(new LinearMove(new Vector(50, 2))); // across notch (5 wide)
pgm.Codes.Add(new LinearMove(new Vector(50, 0))); // back down
pgm.Codes.Add(new LinearMove(new Vector(100, 0))); // remainder of bottom edge
pgm.Codes.Add(new LinearMove(new Vector(100, 50))); // right edge
pgm.Codes.Add(new LinearMove(new Vector(0, 50))); // top edge
pgm.Codes.Add(new LinearMove(new Vector(0, 0))); // left edge back to start
var drawing = new Drawing("rect-notch", pgm);
var result = PartClassifier.Classify(drawing);
Assert.Equal(PartType.Rectangle, result.Type);
}
[Fact]
public void Classify_Circle_ReturnsCircle()
{
var shape = new CircleShape { Diameter = 50 };
var drawing = shape.GetDrawing();
var result = PartClassifier.Classify(drawing);
Assert.Equal(PartType.Circle, result.Type);
Assert.True(result.Circularity >= PartClassifier.CircularityThreshold,
$"Expected circularity>={PartClassifier.CircularityThreshold}, got {result.Circularity:F4}");
}
[Fact]
public void Classify_LShape_ReturnsIrregular()
{
// 100x80 L-shape: full rect minus a 50x40 block from the top-right corner.
// Outline (CCW): (0,0) → (100,0) → (100,40) → (50,40) → (50,80) → (0,80) → (0,0)
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(100, 0)));
pgm.Codes.Add(new LinearMove(new Vector(100, 40)));
pgm.Codes.Add(new LinearMove(new Vector(50, 40)));
pgm.Codes.Add(new LinearMove(new Vector(50, 80)));
pgm.Codes.Add(new LinearMove(new Vector(0, 80)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
var drawing = new Drawing("lshape", pgm);
var result = PartClassifier.Classify(drawing);
Assert.Equal(PartType.Irregular, result.Type);
}
[Fact]
public void Classify_Triangle_ReturnsIrregular()
{
// Right triangle: base 100, height 80.
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(100, 0)));
pgm.Codes.Add(new LinearMove(new Vector(0, 80)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
var drawing = new Drawing("triangle", pgm);
var result = PartClassifier.Classify(drawing);
Assert.Equal(PartType.Irregular, result.Type);
}
[Fact]
public void Classify_SerratedEdge_CaughtByPerimeterRatio()
{
// 100x30 rectangle with 20 teeth of depth 6 along the bottom edge.
// Each tooth is 5 wide, 6 deep → adds 12 units of extra perimeter per tooth.
// Total extra = 20 * 12 = 240 mm extra over a plain 100mm bottom edge.
// MBR perimeter ≈ 2*(100+30) = 260. Actual perimeter ≈ 260 - 100 + 100 + 240 = 500.
// PerimeterRatio ≈ 260/500 = 0.52 — well below the 0.85 threshold.
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
// Serrated bottom edge: 20 teeth, each 5 wide and 6 deep.
var toothCount = 20;
var toothWidth = 5.0;
var toothDepth = 6.0;
var w = toothCount * toothWidth; // = 100
var h = 30.0;
for (var i = 0; i < toothCount; i++)
{
var x0 = i * toothWidth;
pgm.Codes.Add(new LinearMove(new Vector(x0 + toothWidth / 2, -toothDepth)));
pgm.Codes.Add(new LinearMove(new Vector(x0 + toothWidth, 0)));
}
pgm.Codes.Add(new LinearMove(new Vector(w, h)));
pgm.Codes.Add(new LinearMove(new Vector(0, h)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
var drawing = new Drawing("serrated", pgm);
var result = PartClassifier.Classify(drawing);
Assert.Equal(PartType.Irregular, result.Type);
Assert.True(result.PerimeterRatio < PartClassifier.PerimeterRatioThreshold,
$"Expected perimeterRatio<{PartClassifier.PerimeterRatioThreshold}, got {result.PerimeterRatio:F4}");
}
[Fact]
public void Classify_PrimaryAngle_MatchesMbrAlignment()
{
// A rectangle rotated 30° around the origin — no edge is axis-aligned, so
// RotatingCalipers must find a non-zero MBR angle.
var tiltDeg = 30.0;
var tiltRad = Angle.ToRadians(tiltDeg);
var w = 80.0;
var h = 30.0;
var cos = System.Math.Cos(tiltRad);
var sin = System.Math.Sin(tiltRad);
// Rotate each corner of an 80×30 rectangle by 30°.
Vector Rot(double x, double y) => new Vector(x * cos - y * sin, x * sin + y * cos);
var p0 = Rot(0, 0);
var p1 = Rot(w, 0);
var p2 = Rot(w, h);
var p3 = Rot(0, h);
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new RapidMove(p0));
pgm.Codes.Add(new LinearMove(p1));
pgm.Codes.Add(new LinearMove(p2));
pgm.Codes.Add(new LinearMove(p3));
pgm.Codes.Add(new LinearMove(p0));
var drawing = new Drawing("tilted-rect", pgm);
var result = PartClassifier.Classify(drawing);
// The MBR must be tilted — primary angle should be non-zero.
Assert.True(System.Math.Abs(result.PrimaryAngle) > 0.01,
$"Expected non-zero primary angle for 30°-tilted rect, got {result.PrimaryAngle:F4} rad");
}
[Fact]
public void Classify_EmptyDrawing_ReturnsIrregularDefault()
{
var pgm = new OpenNest.CNC.Program();
var drawing = new Drawing("empty", pgm);
var result = PartClassifier.Classify(drawing);
Assert.Equal(PartType.Irregular, result.Type);
Assert.Equal(0.0, result.Rectangularity);
Assert.Equal(0.0, result.Circularity);
}
}
+5 -3
View File
@@ -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<string>();
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;
+60 -37
View File
@@ -49,41 +49,41 @@ namespace OpenNest.Forms
((System.ComponentModel.ISupportInitialize)numQuantity).BeginInit();
bottomPanel1.SuspendLayout();
SuspendLayout();
//
//
// mainSplit
//
//
mainSplit.Dock = System.Windows.Forms.DockStyle.Fill;
mainSplit.FixedPanel = System.Windows.Forms.FixedPanel.Panel1;
mainSplit.Location = new System.Drawing.Point(0, 0);
mainSplit.Name = "mainSplit";
//
//
// mainSplit.Panel1
//
//
mainSplit.Panel1.Controls.Add(sidebarSplit);
mainSplit.Panel1MinSize = 200;
//
//
// mainSplit.Panel2
//
//
mainSplit.Panel2.Controls.Add(entityView1);
mainSplit.Panel2.Controls.Add(detailBar);
mainSplit.Size = new System.Drawing.Size(1024, 670);
mainSplit.SplitterDistance = 260;
mainSplit.SplitterWidth = 5;
mainSplit.TabIndex = 2;
//
//
// sidebarSplit
//
//
sidebarSplit.Dock = System.Windows.Forms.DockStyle.Fill;
sidebarSplit.Location = new System.Drawing.Point(0, 0);
sidebarSplit.Name = "sidebarSplit";
sidebarSplit.Orientation = System.Windows.Forms.Orientation.Horizontal;
//
//
// sidebarSplit.Panel1
//
//
sidebarSplit.Panel1.Controls.Add(fileList);
//
//
// sidebarSplit.Panel2
//
//
sidebarSplit.Panel2.Controls.Add(filterPanel);
sidebarSplit.Size = new System.Drawing.Size(260, 670);
sidebarSplit.SplitterDistance = 300;
@@ -116,8 +116,15 @@ namespace OpenNest.Forms
entityView1.BackColor = System.Drawing.Color.FromArgb(33, 40, 48);
entityView1.Cursor = System.Windows.Forms.Cursors.Cross;
entityView1.Dock = System.Windows.Forms.DockStyle.Fill;
entityView1.IsPickingBendLine = false;
entityView1.Location = new System.Drawing.Point(0, 0);
entityView1.Name = "entityView1";
entityView1.OriginalEntities = null;
entityView1.ShowEntityLabels = false;
entityView1.SimplifierHighlight = null;
entityView1.SimplifierPreview = null;
entityView1.SimplifierToleranceLeft = null;
entityView1.SimplifierToleranceRight = null;
entityView1.Size = new System.Drawing.Size(759, 634);
entityView1.TabIndex = 0;
//
@@ -215,52 +222,68 @@ namespace OpenNest.Forms
//
btnSplit.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
btnSplit.Font = new System.Drawing.Font("Segoe UI", 9F);
btnSplit.Location = new System.Drawing.Point(291, 6);
btnSplit.Margin = new System.Windows.Forms.Padding(2, 0, 8, 0);
btnSplit.Location = new System.Drawing.Point(289, 6);
btnSplit.Margin = new System.Windows.Forms.Padding(0, 0, 10, 0);
btnSplit.Name = "btnSplit";
btnSplit.Size = new System.Drawing.Size(60, 24);
btnSplit.Size = new System.Drawing.Size(60, 27);
btnSplit.TabIndex = 6;
btnSplit.Text = "Split...";
//
//
// btnSimplify
//
//
btnSimplify.AutoSize = true;
btnSimplify.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
btnSimplify.Font = new System.Drawing.Font("Segoe UI", 9F);
btnSimplify.Location = new System.Drawing.Point(359, 6);
btnSimplify.Margin = new System.Windows.Forms.Padding(0, 0, 10, 0);
btnSimplify.Name = "btnSimplify";
btnSimplify.Size = new System.Drawing.Size(75, 27);
btnSimplify.TabIndex = 7;
btnSimplify.Text = "Simplify...";
btnSimplify.AutoSize = true;
btnSimplify.Margin = new System.Windows.Forms.Padding(4, 0, 0, 0);
btnSimplify.Click += new System.EventHandler(this.OnSimplifyClick);
//
btnSimplify.Click += OnSimplifyClick;
//
// btnExportDxf
//
//
btnExportDxf.AutoSize = true;
btnExportDxf.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
btnExportDxf.Font = new System.Drawing.Font("Segoe UI", 9F);
btnExportDxf.Location = new System.Drawing.Point(444, 6);
btnExportDxf.Margin = new System.Windows.Forms.Padding(0, 0, 10, 0);
btnExportDxf.Name = "btnExportDxf";
btnExportDxf.Size = new System.Drawing.Size(76, 27);
btnExportDxf.TabIndex = 8;
btnExportDxf.Text = "Export DXF";
btnExportDxf.AutoSize = true;
btnExportDxf.Margin = new System.Windows.Forms.Padding(4, 0, 0, 0);
btnExportDxf.Click += new System.EventHandler(this.OnExportDxfClick);
//
btnExportDxf.Click += OnExportDxfClick;
//
// chkShowOriginal
//
//
chkShowOriginal.AutoSize = true;
chkShowOriginal.Font = new System.Drawing.Font("Segoe UI", 9F);
chkShowOriginal.Text = "Original";
chkShowOriginal.Location = new System.Drawing.Point(536, 9);
chkShowOriginal.Margin = new System.Windows.Forms.Padding(6, 3, 0, 0);
chkShowOriginal.CheckedChanged += new System.EventHandler(this.OnShowOriginalChanged);
//
chkShowOriginal.Name = "chkShowOriginal";
chkShowOriginal.Size = new System.Drawing.Size(68, 19);
chkShowOriginal.TabIndex = 9;
chkShowOriginal.Text = "Original";
chkShowOriginal.CheckedChanged += OnShowOriginalChanged;
//
// chkLabels
//
//
chkLabels.AutoSize = true;
chkLabels.Font = new System.Drawing.Font("Segoe UI", 9F);
chkLabels.Text = "Labels";
chkLabels.Location = new System.Drawing.Point(610, 9);
chkLabels.Margin = new System.Windows.Forms.Padding(6, 3, 0, 0);
chkLabels.CheckedChanged += new System.EventHandler(this.OnLabelsChanged);
//
chkLabels.Name = "chkLabels";
chkLabels.Size = new System.Drawing.Size(59, 19);
chkLabels.TabIndex = 10;
chkLabels.Text = "Labels";
chkLabels.CheckedChanged += OnLabelsChanged;
//
// lblDetect
//
//
lblDetect.AutoSize = true;
lblDetect.Font = new System.Drawing.Font("Segoe UI", 9F);
lblDetect.Location = new System.Drawing.Point(361, 9);
lblDetect.Location = new System.Drawing.Point(671, 9);
lblDetect.Margin = new System.Windows.Forms.Padding(2, 3, 0, 0);
lblDetect.Name = "lblDetect";
lblDetect.Size = new System.Drawing.Size(42, 15);
@@ -271,7 +294,7 @@ namespace OpenNest.Forms
//
cboBendDetector.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
cboBendDetector.Font = new System.Drawing.Font("Segoe UI", 9F);
cboBendDetector.Location = new System.Drawing.Point(405, 6);
cboBendDetector.Location = new System.Drawing.Point(715, 6);
cboBendDetector.Margin = new System.Windows.Forms.Padding(2, 0, 0, 0);
cboBendDetector.Name = "cboBendDetector";
cboBendDetector.Size = new System.Drawing.Size(90, 23);
File diff suppressed because it is too large Load Diff
@@ -1,147 +0,0 @@
# Direct Arc Conversion for Spline and Ellipse Import
## Problem
During DXF import, splines and ellipses are converted to many small line segments (200 for ellipses, control-point polygons for splines), then optionally reconstructed back to arcs via GeometrySimplifier in the CAD converter. This is wasteful and lossy:
- **Ellipses** are sampled into 200 line segments, discarding the known parametric form.
- **Splines** connect control points with lines, which is geometrically incorrect for B-splines (control points don't lie on the curve).
- Reconstructing arcs from approximate line segments is less accurate than fitting arcs to the exact curve.
## Solution
Convert splines and ellipses directly to circular arcs (and lines where necessary) during import, using the exact curve geometry. No user review step — the import produces the best representation automatically.
## Design Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| When to convert | During import (automatic) | User didn't ask for 200 lines; produce best representation |
| Tolerance | 0.001" default | Tighter than simplifier's 0.004" because we have exact curves |
| Ellipse method | Analytical (curvature-based) | We have the exact parametric form |
| Spline method | Sample-then-fit | ACadSharp provides `PolygonalVertexes()` for accurate curve points |
| Fallback | Keep line segments where arcs can't fit | Handles rapid curvature changes in splines |
| Junction continuity | G1 (tangent) continuity | Normal-constrained arc centers prevent serrated edges |
## Architecture
Two new classes in `OpenNest.Core/Geometry/`:
### EllipseConverter
**Input:** Ellipse parameters — center, semi-major axis length, semi-minor axis length, rotation angle, start parameter, end parameter, tolerance.
**Output:** `List<Entity>` containing arcs that approximate the ellipse within tolerance.
**Algorithm — normal-constrained arc fitting:**
1. Compute an initial set of split parameters along the ellipse. Start with quadrant boundaries (points of maximum/minimum curvature) as natural split candidates.
2. For each pair of consecutive split points (t_start, t_end):
a. Compute the ellipse normal at both endpoints analytically.
b. Find the arc center at the intersection of the two normals. This guarantees the arc is tangent to the ellipse at both endpoints (G1 continuity).
c. Compute the arc radius from the center to either endpoint.
d. Sample several points on the ellipse between t_start and t_end, and measure the maximum radial deviation from the fitted arc.
e. If deviation exceeds tolerance, subdivide: insert a split point at the midpoint and retry both halves.
f. If deviation is within tolerance, emit the arc.
3. Continue until all segments are within tolerance.
**Ellipse analytical formulas (in local coordinates before rotation):**
- Point: `P(t) = (a cos t, b sin t)`
- Tangent: `T(t) = (-a sin t, b cos t)`
- Normal (inward): perpendicular to tangent, pointing toward center of curvature
- Curvature: `k(t) = ab / (a^2 sin^2 t + b^2 cos^2 t)^(3/2)`
After computing in local coordinates, rotate by the ellipse's major axis angle and translate to center.
**Arc count:** Depends on eccentricity and tolerance. A nearly-circular ellipse needs 1-2 arcs. A highly eccentric one (ratio < 0.3) may need 8-16. Tolerance drives this automatically via subdivision.
**Closed ellipse handling:** When the ellipse sweep is approximately 2pi, ensure the last arc's endpoint connects back to the first arc's start point. Tangent continuity wraps around.
### SplineConverter
**Input:** List of points evaluated on the spline curve (from ACadSharp's `PolygonalVertexes`), tolerance, and whether the spline is closed.
**Output:** `List<Entity>` containing arcs and lines that approximate the spline within tolerance.
**Algorithm — tangent-chained greedy arc fitting:**
1. Evaluate the spline at high density using `PolygonalVertexes(precision)` where precision comes from the existing `SplinePrecision` setting.
2. Walk the evaluated points from the start:
a. At the current segment start, compute the tangent direction from the first two points (or from the chained tangent of the previous arc).
b. Fit an arc constrained to be tangent at the start point:
- The arc center lies on the normal to the tangent at the start point.
- Use the perpendicular bisector of the chord from start to candidate end point, intersected with the start normal, to find the center.
c. Extend the arc forward point-by-point. At each extension, recompute the center (intersection of start normal and chord bisector to the new endpoint) and check that all intermediate points are within tolerance of the arc.
d. When adding the next point would exceed tolerance, finalize the arc with the last good endpoint.
e. Compute the tangent at the arc's end point (perpendicular to the radius at that point) and chain it to the next segment.
3. If fewer than 3 points remain in a run where no arc fits (curvature changes too rapidly), emit line segments instead.
4. For closed splines, chain the final arc's tangent back to constrain the first arc.
**This is essentially the same approach GeometrySimplifier uses** (tangent chaining via `chainedTangent`), but operating on densely-sampled curve points rather than pre-existing line segments.
### Changes to Existing Code
#### Extensions.cs
```
// Before: returns List<Line>
public static List<Geometry.Line> ToOpenNest(this Spline spline)
// After: returns List<Entity>
public static List<Entity> ToOpenNest(this Spline spline, int precision)
```
- Extracts ACadSharp spline data, calls `SplineConverter.Convert()`
- Now accepts `precision` parameter (was ignored before)
```
// Before: returns List<Line>
public static List<Geometry.Line> ToOpenNest(this Ellipse ellipse, int precision = 200)
// After: returns List<Entity>
public static List<Entity> ToOpenNest(this Ellipse ellipse, double tolerance = 0.001)
```
- Extracts ACadSharp ellipse parameters, calls `EllipseConverter.Convert()`
- Precision parameter replaced by tolerance (precision is no longer relevant)
Both methods preserve Layer, Color, and LineTypeName on the output entities.
#### DxfImporter.cs
Currently collects `List<Line>` and `List<Arc>` separately. The new converters return `List<Entity>` (mixed arcs and lines). Options:
- Sort returned entities into the existing `lines` and `arcs` lists by type, OR
- Switch to a single `List<Entity>` collection
The simpler change is to sort into existing lists so downstream code (GeometryOptimizer, ShapeBuilder) is unaffected.
### What Stays the Same
- **GeometrySimplifier** — still exists for user-triggered simplification of genuinely line-based geometry (e.g., DXF files that actually contain line segments, or polylines)
- **GeometryOptimizer** — still merges collinear lines and coradial arcs post-import. May merge adjacent arcs produced by the new converters if they happen to be coradial.
- **ShapeBuilder, ConvertGeometry, CNC pipeline** — unchanged, they already handle mixed Line/Arc entities
- **SplinePrecision setting** — still used for spline point evaluation density
## Testing
- **EllipseConverter unit tests:**
- Circle (ratio = 1.0) produces 1-2 arcs
- Moderate ellipse produces arcs within tolerance
- Highly eccentric ellipse produces more arcs, all within tolerance
- Partial ellipse (elliptical arc) works correctly
- Endpoint continuity: each arc's end matches the next arc's start
- Tangent continuity: no discontinuities at junctions
- Closed ellipse: last arc connects back to first
- **SplineConverter unit tests:**
- Circular arc spline produces a single arc
- S-curve spline produces arcs + lines where needed
- Straight-line spline produces a line (not degenerate arcs)
- Closed spline: endpoints connect
- Tangent chaining: smooth transitions between consecutive arcs
- **Integration test:**
- Import a DXF with splines and ellipses, verify the result contains arcs (not 200 lines)
- Compare bounding boxes to ensure geometry is preserved