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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-11-01 12:07:06 -05:00
1 parent 1d310b373e
commit 8076ddbd78
3 files changed
+282

No files matched your search

+110
View File
@@ -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
}
}
/// <summary>
/// Replaces arcs of one center, radius and layer that together cover the whole circle with
/// a <see cref="Circle"/>. 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 <see cref="MaxSeamOverlap"/>, are left alone.
/// </summary>
public static void JoinFullCircles(List<Entity> entities)
{
var groups = new List<List<Arc>>();
foreach (var arc in entities.OfType<Arc>())
{
if (!SpecialLayers.IsMaterial(arc.Layer))
continue;
var group = groups.Find(g => IsSameCircle(g[0], arc));
if (group == null)
groups.Add(new List<Arc> { 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<Line>()
.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));
}
}
/// <summary>The most the arcs of a joined circle may overlap, in radians: 30 degrees.</summary>
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>(T a, T b, out T joined);
private static void MergePass<T>(
+4
View File
@@ -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<Bend>();
if (options.DetectBends && dxf.Document != null)
@@ -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<Entity> 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<Circle>(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<Entity> { ArcDeg(0, 0, 1, 210, 30), ArcDeg(0, 0, 1, 30, 215.5) };
GeometryOptimizer.JoinFullCircles(entities);
Assert.IsType<Circle>(Assert.Single(entities));
}
[Fact]
public void Joins_ArcsThatMeetEndToEnd_InAnyOrderAndDirection()
{
var reversed = ArcDeg(0, 0, 1, 120, 240);
reversed.Reverse();
var entities = new List<Entity> { ArcDeg(0, 0, 1, 240, 0), reversed, ArcDeg(0, 0, 1, 0, 120) };
GeometryOptimizer.JoinFullCircles(entities);
Assert.IsType<Circle>(Assert.Single(entities));
}
[Fact]
public void Joins_WhenAWrappingArcBridgesTheFirstGap()
{
var entities = new List<Entity>
{
ArcDeg(0, 0, 1, 10, 30),
ArcDeg(0, 0, 1, 40, 300),
ArcDeg(0, 0, 1, 300, 40),
};
GeometryOptimizer.JoinFullCircles(entities);
Assert.IsType<Circle>(Assert.Single(entities));
}
[Fact]
public void LeavesArcs_ThatDoNotCloseTheCircle()
{
var entities = new List<Entity> { 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<Arc>(e));
}
[Fact]
public void LeavesDuplicateArcs_WhoseSweepsOnlySumPastACircle()
{
var entities = new List<Entity> { 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<Entity> { 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<Entity> { ArcDeg(0, 0, 1, 0, 180), ArcDeg(0, 0, 1, 180, 0, other) };
var marks = new List<Entity>
{
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<Circle>());
}
[Fact]
public void HoleTable_ListsTheJoinedHoles()
{
var entities = new List<Entity> { 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));
}
}