diff --git a/OpenNest.Core/Geometry/GeometryOptimizer.cs b/OpenNest.Core/Geometry/GeometryOptimizer.cs
index e9ed0ef5..e90953c2 100644
--- a/OpenNest.Core/Geometry/GeometryOptimizer.cs
+++ b/OpenNest.Core/Geometry/GeometryOptimizer.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Threading.Tasks;
using OpenNest.Math;
@@ -57,6 +58,115 @@ namespace OpenNest.Geometry
}
}
+ ///
+ /// Replaces arcs of one center, radius and layer that together cover the whole circle with
+ /// a . CAM exports often overshoot the seam of a hole and return along
+ /// a short chord; such a line, lying wholly on the overlap, goes with the arcs. Arcs that
+ /// leave a gap, or overlap by more than , are left alone.
+ ///
+ public static void JoinFullCircles(List entities)
+ {
+ var groups = new List>();
+ foreach (var arc in entities.OfType())
+ {
+ if (!SpecialLayers.IsMaterial(arc.Layer))
+ continue;
+
+ var group = groups.Find(g => IsSameCircle(g[0], arc));
+ if (group == null)
+ groups.Add(new List { arc });
+ else
+ group.Add(arc);
+ }
+
+ foreach (var group in groups)
+ {
+ var longest = group.OrderByDescending(a => a.SweepAngle()).First();
+ var tolerance = Tolerance.ChainTolerance / longest.Radius;
+ var spans = group.Select(ToSpan).OrderBy(s => s.Start).ToList();
+ var overlap = spans.Sum(s => s.Sweep) - Angle.TwoPI;
+
+ if (group.Count < 2 || overlap > MaxSeamOverlap || !CoversCircle(spans, tolerance))
+ continue;
+
+ var chords = entities
+ .OfType()
+ .Where(line => IsSeamChord(line, longest, spans, tolerance))
+ .ToList();
+
+ var index = entities.FindIndex(e => e is Arc arc && group.Contains(arc));
+ entities.Insert(
+ index,
+ new Circle(longest.Center, longest.Radius)
+ {
+ Layer = longest.Layer,
+ Color = longest.Color,
+ }
+ );
+ entities.RemoveAll(e => e is Arc arc && group.Contains(arc));
+ entities.RemoveAll(e => e is Line line && chords.Contains(line));
+ }
+ }
+
+ /// The most the arcs of a joined circle may overlap, in radians: 30 degrees.
+ public const double MaxSeamOverlap = System.Math.PI / 6;
+
+ private static bool IsSameCircle(Arc a, Arc b) =>
+ a.Layer?.Name == b.Layer?.Name
+ && a.Center.DistanceTo(b.Center) <= Tolerance.ChainTolerance
+ && System.Math.Abs(a.Radius - b.Radius) <= Tolerance.ChainTolerance;
+
+ private static (double Start, double Sweep) ToSpan(Arc arc) =>
+ (Angle.NormalizeRad(arc.IsReversed ? arc.EndAngle : arc.StartAngle), arc.SweepAngle());
+
+ // Walks the spans from angle zero, first crediting whatever part of them wraps past it.
+ private static bool CoversCircle(List<(double Start, double Sweep)> spans, double tolerance)
+ {
+ var reach = spans.Max(s => s.Start + s.Sweep - Angle.TwoPI);
+ foreach (var (start, sweep) in spans)
+ {
+ if (start > reach + tolerance)
+ return false;
+ reach = System.Math.Max(reach, start + sweep);
+ }
+
+ return reach >= Angle.TwoPI - tolerance;
+ }
+
+ private static bool IsSeamChord(
+ Line line,
+ Arc circle,
+ List<(double Start, double Sweep)> spans,
+ double tolerance
+ )
+ {
+ if (line.Layer?.Name != circle.Layer?.Name)
+ return false;
+
+ var center = circle.Center;
+ bool OnCircle(Vector point) =>
+ System.Math.Abs(point.DistanceTo(center) - circle.Radius) <= Tolerance.ChainTolerance;
+ bool OnOverlap(Vector point)
+ {
+ var angle = System.Math.Atan2(point.Y - center.Y, point.X - center.X);
+ return spans.Count(s =>
+ {
+ var offset = Angle.NormalizeRad(angle - s.Start);
+ return offset <= s.Sweep + tolerance || offset >= Angle.TwoPI - tolerance;
+ }) >= 2;
+ }
+
+ var middle = new Vector(
+ (line.StartPoint.X + line.EndPoint.X) / 2,
+ (line.StartPoint.Y + line.EndPoint.Y) / 2
+ );
+ return OnCircle(line.StartPoint)
+ && OnCircle(line.EndPoint)
+ && OnOverlap(line.StartPoint)
+ && OnOverlap(line.EndPoint)
+ && OnOverlap(middle);
+ }
+
private delegate bool TryJoin(T a, T b, out T joined);
private static void MergePass(
diff --git a/OpenNest.IO/CadImporter.cs b/OpenNest.IO/CadImporter.cs
index 6e97c1a9..8f5a4e96 100644
--- a/OpenNest.IO/CadImporter.cs
+++ b/OpenNest.IO/CadImporter.cs
@@ -40,8 +40,12 @@ namespace OpenNest.IO
: dxf.Entities.Where(e => !IsRepairMark(e)).ToList();
RemoveDuplicateArcs(cleanup);
RemoveZeroSweepArcs(cleanup);
+ GeometryOptimizer.JoinFullCircles(cleanup);
if (options.BendRepair != null)
+ {
dxf.Entities.RemoveAll(e => !IsRepairMark(e) && !cleanup.Contains(e));
+ dxf.Entities.AddRange(cleanup.Where(e => !dxf.Entities.Contains(e)).ToList());
+ }
var bends = new List();
if (options.DetectBends && dxf.Document != null)
diff --git a/OpenNest.Tests/Geometry/JoinFullCirclesTests.cs b/OpenNest.Tests/Geometry/JoinFullCirclesTests.cs
new file mode 100644
index 00000000..8dad3c2e
--- /dev/null
+++ b/OpenNest.Tests/Geometry/JoinFullCirclesTests.cs
@@ -0,0 +1,168 @@
+using OpenNest.Dimensions;
+using OpenNest.Geometry;
+using OpenNest.Math;
+
+namespace OpenNest.Tests.Geometry;
+
+public class JoinFullCirclesTests
+{
+ private static readonly Layer Cut = new("0") { IsVisible = true };
+
+ private static Arc ArcDeg(double x, double y, double radius, double start, double end, Layer layer = null) =>
+ new(x, y, radius, Angle.ToRadians(start), Angle.ToRadians(end)) { Layer = layer ?? Cut };
+
+ private static Vector OnCircle(double x, double y, double radius, double degrees)
+ {
+ var angle = Angle.ToRadians(degrees);
+ return new Vector(x + radius * System.Math.Cos(angle), y + radius * System.Math.Sin(angle));
+ }
+
+ // Two arcs overshooting the seam by the given degrees, then a chord back to the start.
+ private static List OvershotHole(double x, double y, double radius, double seam, double overshoot) =>
+ [
+ ArcDeg(x, y, radius, seam, seam + 180),
+ ArcDeg(x + 8e-8, y - 5e-8, radius + 8e-8, seam + 180, seam + overshoot),
+ new Line(OnCircle(x, y, radius, seam + overshoot), OnCircle(x, y, radius, seam)) { Layer = Cut },
+ ];
+
+ [Theory]
+ [InlineData(210, 5.5)]
+ [InlineData(120, 1.14592)]
+ [InlineData(300, 5.5)]
+ [InlineData(0, 5.5)]
+ public void Joins_OvershotArcs_AndDropsTheirChord(double seam, double overshoot)
+ {
+ var entities = OvershotHole(2, 3, 0.3125, seam, overshoot);
+
+ GeometryOptimizer.JoinFullCircles(entities);
+
+ var circle = Assert.IsType(Assert.Single(entities));
+ Assert.Equal(2, circle.Center.X, 5);
+ Assert.Equal(3, circle.Center.Y, 5);
+ Assert.Equal(0.3125, circle.Radius, 5);
+ Assert.Same(Cut, circle.Layer);
+ }
+
+ [Fact]
+ public void Joins_OverlappingArcs_WithNoChord()
+ {
+ var entities = new List { ArcDeg(0, 0, 1, 210, 30), ArcDeg(0, 0, 1, 30, 215.5) };
+
+ GeometryOptimizer.JoinFullCircles(entities);
+
+ Assert.IsType(Assert.Single(entities));
+ }
+
+ [Fact]
+ public void Joins_ArcsThatMeetEndToEnd_InAnyOrderAndDirection()
+ {
+ var reversed = ArcDeg(0, 0, 1, 120, 240);
+ reversed.Reverse();
+ var entities = new List { ArcDeg(0, 0, 1, 240, 0), reversed, ArcDeg(0, 0, 1, 0, 120) };
+
+ GeometryOptimizer.JoinFullCircles(entities);
+
+ Assert.IsType(Assert.Single(entities));
+ }
+
+ [Fact]
+ public void Joins_WhenAWrappingArcBridgesTheFirstGap()
+ {
+ var entities = new List
+ {
+ ArcDeg(0, 0, 1, 10, 30),
+ ArcDeg(0, 0, 1, 40, 300),
+ ArcDeg(0, 0, 1, 300, 40),
+ };
+
+ GeometryOptimizer.JoinFullCircles(entities);
+
+ Assert.IsType(Assert.Single(entities));
+ }
+
+ [Fact]
+ public void LeavesArcs_ThatDoNotCloseTheCircle()
+ {
+ var entities = new List { ArcDeg(0, 0, 1, 0, 170), ArcDeg(0, 0, 1, 180, 350) };
+
+ GeometryOptimizer.JoinFullCircles(entities);
+
+ Assert.Equal(2, entities.Count);
+ Assert.All(entities, e => Assert.IsType(e));
+ }
+
+ [Fact]
+ public void LeavesDuplicateArcs_WhoseSweepsOnlySumPastACircle()
+ {
+ var entities = new List { ArcDeg(0, 0, 1, 0, 200), ArcDeg(0, 0, 1, 0, 200) };
+
+ GeometryOptimizer.JoinFullCircles(entities);
+
+ Assert.Equal(2, entities.Count);
+ }
+
+ [Fact]
+ public void LeavesArcs_OverlappingPastTheSeamLimit()
+ {
+ var entities = new List { ArcDeg(0, 0, 1, 0, 200), ArcDeg(0, 0, 1, 170, 40) };
+
+ GeometryOptimizer.JoinFullCircles(entities);
+
+ Assert.Equal(2, entities.Count);
+ }
+
+ [Fact]
+ public void LeavesArcs_OnDifferentLayers_OrNotOfMaterial()
+ {
+ var other = new Layer("OTHER") { IsVisible = true };
+ var split = new List { ArcDeg(0, 0, 1, 0, 180), ArcDeg(0, 0, 1, 180, 0, other) };
+ var marks = new List
+ {
+ ArcDeg(0, 0, 1, 0, 180, SpecialLayers.Scribe),
+ ArcDeg(0, 0, 1, 180, 0, SpecialLayers.Scribe),
+ };
+
+ GeometryOptimizer.JoinFullCircles(split);
+ GeometryOptimizer.JoinFullCircles(marks);
+
+ Assert.Equal(2, split.Count);
+ Assert.Equal(2, marks.Count);
+ }
+
+ [Fact]
+ public void KeepsLines_OffTheOverlap()
+ {
+ var elsewhere = new Line(OnCircle(0, 0, 1, 80), OnCircle(0, 0, 1, 100)) { Layer = Cut };
+ var outside = new Line(OnCircle(0, 0, 1, 210), new Vector(-5, -5)) { Layer = Cut };
+ var entities = OvershotHole(0, 0, 1, 210, 5.5);
+ entities.AddRange([elsewhere, outside]);
+
+ GeometryOptimizer.JoinFullCircles(entities);
+
+ Assert.Equal(3, entities.Count);
+ Assert.Contains(elsewhere, entities);
+ Assert.Contains(outside, entities);
+ Assert.Single(entities.OfType());
+ }
+
+ [Fact]
+ public void HoleTable_ListsTheJoinedHoles()
+ {
+ var entities = new List { new Circle(0, 0, 5.5) { Layer = Cut } };
+ entities.AddRange(OvershotHole(0, 0, 1.5, 120, 1.14592));
+ for (var i = 0; i < 8; i++)
+ {
+ var center = OnCircle(0, 0, 4.75, i * 45);
+ entities.AddRange(OvershotHole(center.X, center.Y, 0.3125, 30 + i * 45, 5.5));
+ }
+
+ Assert.Empty(HoleTable.Build(entities));
+
+ GeometryOptimizer.JoinFullCircles(entities);
+
+ var table = HoleTable.Build(entities);
+ Assert.Equal(2, table.Count);
+ Assert.Equal((0.625, 8), (table[0].Diameter, table[0].Quantity));
+ Assert.Equal((3.0, 1), (table[1].Diameter, table[1].Quantity));
+ }
+}