From 8076ddbd783896e45d5a01d30a77c005a5067e95 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 1 Nov 2026 12:07:06 -0500 Subject: [PATCH] feat(io): join arcs covering a full circle into a circle on import CAM exports often draw a hole as two arcs that overshoot the seam, plus a short chord back to the start. The Hole Table rejected these contours for sweeping more than 360 degrees, so drawings like the HT EX flange listed no round holes. CadImporter now replaces coradial arcs on one material layer that cover the whole circle with a Circle, and drops a chord lying wholly on the overlap. Arcs that leave a gap or overlap by more than MaxSeamOverlap stay as they are. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Core/Geometry/GeometryOptimizer.cs | 110 ++++++++++++ OpenNest.IO/CadImporter.cs | 4 + .../Geometry/JoinFullCirclesTests.cs | 168 ++++++++++++++++++ 3 files changed, 282 insertions(+) create mode 100644 OpenNest.Tests/Geometry/JoinFullCirclesTests.cs 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)); + } +}