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)); + } +}