using System.Linq; using OpenNest.Geometry; using OpenNest.IO; using OpenNest.Math; using Xunit; namespace OpenNest.Tests.Geometry; public class EllipseConverterTests { private const double Tol = 1e-10; [Fact] public void EvaluatePoint_AtZero_ReturnsMajorAxisEnd() { var p = EllipseConverter.EvaluatePoint(10, 5, 0, new Vector(0, 0), 0); Assert.InRange(p.X, 10 - Tol, 10 + Tol); Assert.InRange(p.Y, -Tol, Tol); } [Fact] public void EvaluatePoint_AtHalfPi_ReturnsMinorAxisEnd() { var p = EllipseConverter.EvaluatePoint(10, 5, 0, new Vector(0, 0), System.Math.PI / 2); Assert.InRange(p.X, -Tol, Tol); Assert.InRange(p.Y, 5 - Tol, 5 + Tol); } [Fact] public void EvaluatePoint_WithRotation_RotatesCorrectly() { var p = EllipseConverter.EvaluatePoint(10, 5, System.Math.PI / 2, new Vector(0, 0), 0); Assert.InRange(p.X, -Tol, Tol); Assert.InRange(p.Y, 10 - Tol, 10 + Tol); } [Fact] public void EvaluatePoint_WithCenter_TranslatesCorrectly() { var p = EllipseConverter.EvaluatePoint(10, 5, 0, new Vector(100, 200), 0); Assert.InRange(p.X, 110 - Tol, 110 + Tol); Assert.InRange(p.Y, 200 - Tol, 200 + Tol); } [Fact] public void EvaluateTangent_AtZero_PointsUp() { var t = EllipseConverter.EvaluateTangent(10, 5, 0, 0); Assert.InRange(t.X, -Tol, Tol); Assert.True(t.Y > 0); } [Fact] public void EvaluateNormal_AtZero_PointsInward() { var n = EllipseConverter.EvaluateNormal(10, 5, 0, 0); Assert.True(n.X < 0); Assert.InRange(n.Y, -Tol, Tol); } [Fact] public void IntersectNormals_PerpendicularNormals_FindsCenter() { var p1 = new Vector(5, 0); var n1 = new Vector(-1, 0); var p2 = new Vector(0, 5); var n2 = new Vector(0, -1); var center = EllipseConverter.IntersectNormals(p1, n1, p2, n2); Assert.InRange(center.X, -Tol, Tol); Assert.InRange(center.Y, -Tol, Tol); } [Fact] public void IntersectNormals_ParallelNormals_ReturnsInvalid() { var p1 = new Vector(0, 0); var n1 = new Vector(1, 0); var p2 = new Vector(0, 5); var n2 = new Vector(1, 0); var center = EllipseConverter.IntersectNormals(p1, n1, p2, n2); Assert.False(center.IsValid()); } [Fact] public void Convert_Circle_ProducesOneOrTwoArcs() { var result = EllipseConverter.Convert( new Vector(0, 0), semiMajor: 10, semiMinor: 10, rotation: 0, startParam: 0, endParam: Angle.TwoPI, tolerance: 0.001 ); Assert.All(result, e => Assert.IsType(e)); Assert.InRange(result.Count, 1, 4); } [Fact] public void Convert_ModerateEllipse_AllArcsWithinTolerance() { var a = 10.0; var b = 7.0; var tolerance = 0.001; var result = EllipseConverter.Convert( new Vector(0, 0), a, b, rotation: 0, startParam: 0, endParam: Angle.TwoPI, tolerance: tolerance ); Assert.True(result.Count >= 4, $"Expected at least 4 arcs, got {result.Count}"); Assert.All(result, e => Assert.IsType(e)); foreach (var entity in result) { var arc = (Arc)entity; var maxDev = MaxDeviationFromEllipse(arc, new Vector(0, 0), a, b, 0, 50); Assert.True( maxDev <= tolerance, $"Arc at center ({arc.Center.X:F4},{arc.Center.Y:F4}) r={arc.Radius:F4} " + $"deviates {maxDev:F6} from ellipse (tolerance={tolerance})" ); } } [Fact] public void Convert_HighlyEccentricEllipse_ProducesMoreArcs() { var a = 20.0; var b = 3.0; var tolerance = 0.001; var result = EllipseConverter.Convert( new Vector(0, 0), a, b, rotation: 0, startParam: 0, endParam: Angle.TwoPI, tolerance: tolerance ); Assert.True( result.Count >= 8, $"Expected at least 8 arcs for eccentric ellipse, got {result.Count}" ); Assert.All(result, e => Assert.IsType(e)); foreach (var entity in result) { var arc = (Arc)entity; var maxDev = MaxDeviationFromEllipse(arc, new Vector(0, 0), a, b, 0, 50); Assert.True( maxDev <= tolerance, $"Deviation {maxDev:F6} exceeds tolerance {tolerance}" ); } } [Fact] public void Convert_PartialEllipse_CoversArcOnly() { var a = 10.0; var b = 5.0; var tolerance = 0.001; var result = EllipseConverter.Convert( new Vector(0, 0), a, b, rotation: 0, startParam: 0, endParam: System.Math.PI / 2, tolerance: tolerance ); Assert.NotEmpty(result); Assert.All(result, e => Assert.IsType(e)); var firstArc = (Arc)result[0]; var sp = firstArc.StartPoint(); Assert.InRange(sp.X, a - 0.01, a + 0.01); Assert.InRange(sp.Y, -0.01, 0.01); var lastArc = (Arc)result[^1]; var ep = lastArc.EndPoint(); Assert.InRange(ep.X, -0.01, 0.01); Assert.InRange(ep.Y, b - 0.01, b + 0.01); } [Fact] public void Convert_EndpointContinuity_ArcsConnect() { var result = EllipseConverter.Convert( new Vector(5, 10), semiMajor: 15, semiMinor: 8, rotation: 0.5, startParam: 0, endParam: Angle.TwoPI, tolerance: 0.001 ); for (var i = 0; i < result.Count - 1; i++) { var current = (Arc)result[i]; var next = (Arc)result[i + 1]; var gap = current.EndPoint().DistanceTo(next.StartPoint()); Assert.True(gap < 1e-6, $"Gap of {gap:E4} between arc {i} and arc {i + 1}"); } var lastArc = (Arc)result[^1]; var firstArc = (Arc)result[0]; var closingGap = lastArc.EndPoint().DistanceTo(firstArc.StartPoint()); Assert.True(closingGap < 1e-6, $"Closing gap of {closingGap:E4}"); } [Fact] public void Convert_WithRotationAndOffset_ProducesValidArcs() { var center = new Vector(50, -30); var rotation = System.Math.PI / 3; var a = 12.0; var b = 6.0; var tolerance = 0.001; var result = EllipseConverter.Convert( center, a, b, rotation, startParam: 0, endParam: Angle.TwoPI, tolerance: tolerance ); Assert.NotEmpty(result); foreach (var entity in result) { var arc = (Arc)entity; var maxDev = MaxDeviationFromEllipse(arc, center, a, b, rotation, 50); Assert.True( maxDev <= tolerance, $"Deviation {maxDev:F6} exceeds tolerance {tolerance}" ); } } [Fact] public void DxfImporter_EllipseInDxf_ProducesArcsNotLines() { // Create a DXF in memory with an ellipse and verify import produces arcs var doc = new ACadSharp.CadDocument(); var ellipse = new ACadSharp.Entities.Ellipse { Center = new CSMath.XYZ(0, 0, 0), MajorAxisEndPoint = new CSMath.XYZ(10, 0, 0), RadiusRatio = 0.6, StartParameter = 0, EndParameter = System.Math.PI * 2, }; doc.Entities.Add(ellipse); // Write to temp file and re-import var tempPath = System.IO.Path.GetTempFileName() + ".dxf"; try { using (var stream = System.IO.File.Create(tempPath)) using (var writer = new ACadSharp.IO.DxfWriter(stream, doc, false)) writer.Write(); var result = OpenNest.IO.Dxf.Import(tempPath); var arcCount = result.Entities.Count(e => e is Arc); var lineCount = result.Entities.Count(e => e is Line); // Should have arcs, not hundreds of lines Assert.True(arcCount >= 4, $"Expected at least 4 arcs, got {arcCount}"); Assert.Equal(0, lineCount); } finally { if (System.IO.File.Exists(tempPath)) System.IO.File.Delete(tempPath); } } [Fact] public void ToOpenNest_FlippedNormalZ_ProducesCorrectArcs() { var normal = new ACadSharp.Entities.Ellipse { Center = new CSMath.XYZ(-0.275, -0.245, 0), MajorAxisEndPoint = new CSMath.XYZ(0.0001, 1.245, 0), RadiusRatio = 0.28, StartParameter = 0.017, EndParameter = 1.571, Normal = new CSMath.XYZ(0, 0, 1), }; var flipped = new ACadSharp.Entities.Ellipse { Center = new CSMath.XYZ(0.275, -0.245, 0), MajorAxisEndPoint = new CSMath.XYZ(-0.0001, 1.245, 0), RadiusRatio = 0.28, StartParameter = 0.017, EndParameter = 1.571, Normal = new CSMath.XYZ(0, 0, -1), }; var normalArcs = normal.ToOpenNest(); var flippedArcs = flipped.ToOpenNest(); Assert.True(normalArcs.Count > 0); Assert.True(flippedArcs.Count > 0); Assert.True(normalArcs.All(e => e is Arc)); Assert.True(flippedArcs.All(e => e is Arc)); var normalFirst = (Arc)normalArcs.First(); var flippedFirst = (Arc)flippedArcs.First(); var normalStart = GetArcStart(normalFirst); var flippedStart = GetArcStart(flippedFirst); Assert.True( normalStart.X < 0, $"Normal ellipse start X should be negative, got {normalStart.X}" ); Assert.True( flippedStart.X > 0, $"Flipped ellipse should bulge right, got {flippedStart.X}" ); var normalBbox = GetBoundingBox(normalArcs.Cast()); var flippedBbox = GetBoundingBox(flippedArcs.Cast()); Assert.True( flippedBbox.minX > 0, $"Flipped ellipse should stay on positive X side, minX={flippedBbox.minX}" ); Assert.True( normalBbox.maxX < 0, $"Normal ellipse should stay on negative X side, maxX={normalBbox.maxX}" ); } // Frozen ordered output from 5bf3c5f, including both angular seams and a // translated, rotated partial ellipse (the public API traverses parameters CCW). [Theory] [MemberData(nameof(SignedAngleCharacterizationCases))] public void Convert_SignedAngleCharacterization_PreservesOrderedArcs( string scenario, double[][] expected, bool[] reversed ) { var (start, end, rotation) = scenario switch { "atan-seam" => (2.8, 3.8, 0.0), "positive-x-seam" => (5.8, 0.5, 0.0), _ => (0.2, 1.3, 0.6), }; var result = EllipseConverter.Convert(new Vector(3, -2), 6, 4, rotation, start, end, tolerance: 0.1); Assert.Collection(result, expected.Select((parameters, i) => (Action)(entity => AssertCharacterizedArc(entity, parameters, reversed[i])) ).ToArray()); } public static IEnumerable SignedAngleCharacterizationCases() { yield return new object[] { "atan-seam", new double[][] { new[] { -0.16603986922559466, -2.0183680896443055, 2.8340196558132877, 2.6417457217524727, 3.1351113230149354 }, new[] { -0.17776605557071123, -1.983538836846724, 2.8222819502985983, 3.1474252588278113, 3.624130294015861 }, new[] { 0.7441132153098247, -1.4836007849113604, 3.8709161624146646, 3.6280252302389693, 4.013669489810318 }, }, new[] { false, false, false }, }; yield return new object[] { "positive-x-seam", new double[][] { new[] { 6.004270273073607, -1.9488971518180598, 2.996165565832901, 5.592171901293686, 6.26612839723762 }, new[] { 5.9818198770623745, -2.0564884222942372, 3.0187086968352994, 0.018713869149703036, 0.7128403110339223 }, }, new[] { false, false }, }; yield return new object[] { "rotated-partial-ellipse", new double[][] { new[] { 5.1346691175944725, -1.0670403345989339, 3.79655973712418, 0.9299300117607583, 1.5841942790174177 }, new[] { 5.075469862824099, -4.265516161507367, 6.994699783814414, 1.5696047599218284, 2.002556947193732 }, }, new[] { false, false }, }; } private static void AssertCharacterizedArc(Entity entity, double[] expected, bool reversed) { var arc = Assert.IsType(entity); var actual = new[] { arc.Center.X, arc.Center.Y, arc.Radius, arc.StartAngle, arc.EndAngle, }; for (var i = 0; i < expected.Length; i++) Assert.InRange(actual[i], expected[i] - 1e-10, expected[i] + 1e-10); Assert.Equal(reversed, arc.IsReversed); } private static (double minX, double maxX) GetBoundingBox(IEnumerable arcs) { var minX = double.MaxValue; var maxX = double.MinValue; foreach (var arc in arcs) { var s = GetArcStart(arc); var e = GetArcEnd(arc); minX = System.Math.Min(minX, System.Math.Min(s.X, e.X)); maxX = System.Math.Max(maxX, System.Math.Max(s.X, e.X)); } return (minX, maxX); } private static Vector GetArcStart(Arc arc) { var angle = arc.IsReversed ? arc.EndAngle : arc.StartAngle; return new Vector( arc.Center.X + arc.Radius * System.Math.Cos(angle), arc.Center.Y + arc.Radius * System.Math.Sin(angle) ); } private static Vector GetArcEnd(Arc arc) { var angle = arc.IsReversed ? arc.StartAngle : arc.EndAngle; return new Vector( arc.Center.X + arc.Radius * System.Math.Cos(angle), arc.Center.Y + arc.Radius * System.Math.Sin(angle) ); } private static double MaxDeviationFromEllipse( Arc arc, Vector ellipseCenter, double semiMajor, double semiMinor, double rotation, int samples ) { var maxDev = 0.0; var sweep = arc.SweepAngle(); var startAngle = arc.StartAngle; if (arc.IsReversed) startAngle = arc.EndAngle; for (var i = 0; i <= samples; i++) { var frac = (double)i / samples; var angle = startAngle + frac * sweep; var px = arc.Center.X + arc.Radius * System.Math.Cos(angle); var py = arc.Center.Y + arc.Radius * System.Math.Sin(angle); var arcPoint = new Vector(px, py); // Coarse search over 1000 samples var bestT = 0.0; var minDist = double.MaxValue; for (var j = 0; j <= 1000; j++) { var t = (double)j / 1000 * Angle.TwoPI; var ep2 = EllipseConverter.EvaluatePoint( semiMajor, semiMinor, rotation, ellipseCenter, t ); var dist = arcPoint.DistanceTo(ep2); if (dist < minDist) { minDist = dist; bestT = t; } } // Refine with local bisection around bestT var lo = bestT - Angle.TwoPI / 1000; var hi = bestT + Angle.TwoPI / 1000; for (var r = 0; r < 20; r++) { var t1 = lo + (hi - lo) / 3; var t2 = lo + 2 * (hi - lo) / 3; var d1 = arcPoint.DistanceTo( EllipseConverter.EvaluatePoint( semiMajor, semiMinor, rotation, ellipseCenter, t1 ) ); var d2 = arcPoint.DistanceTo( EllipseConverter.EvaluatePoint( semiMajor, semiMinor, rotation, ellipseCenter, t2 ) ); if (d1 < d2) hi = t2; else lo = t1; } var bestDist = arcPoint.DistanceTo( EllipseConverter.EvaluatePoint( semiMajor, semiMinor, rotation, ellipseCenter, (lo + hi) / 2 ) ); if (bestDist > maxDev) maxDev = bestDist; } return maxDev; } }