diff --git a/OpenNest.Core/Geometry/EllipseConverter.cs b/OpenNest.Core/Geometry/EllipseConverter.cs index 100753b..416015a 100644 --- a/OpenNest.Core/Geometry/EllipseConverter.cs +++ b/OpenNest.Core/Geometry/EllipseConverter.cs @@ -63,5 +63,150 @@ namespace OpenNest.Geometry return new Vector(p1.X + s * n1.X, p1.Y + s * n1.Y); } + + public static List Convert(Vector center, double semiMajor, double semiMinor, + double rotation, double startParam, double endParam, double tolerance = 0.001) + { + if (endParam <= startParam) + endParam += Angle.TwoPI; + + // True circle — emit a single arc (or two for full circle) + if (System.Math.Abs(semiMajor - semiMinor) < Tolerance.Epsilon) + return ConvertCircle(center, semiMajor, rotation, startParam, endParam); + + var splits = GetInitialSplits(startParam, endParam); + + var entities = new List(); + for (var i = 0; i < splits.Count - 1; i++) + FitSegment(center, semiMajor, semiMinor, rotation, + splits[i], splits[i + 1], tolerance, entities, 0); + + return entities; + } + + private static List ConvertCircle(Vector center, double radius, + double rotation, double startParam, double endParam) + { + var sweep = endParam - startParam; + var isFull = System.Math.Abs(sweep - Angle.TwoPI) < 0.01; + + if (isFull) + { + var startAngle1 = Angle.NormalizeRad(startParam + rotation); + var midAngle = Angle.NormalizeRad(startParam + System.Math.PI + rotation); + var endAngle2 = startAngle1; + + return new List + { + new Arc(center, radius, startAngle1, midAngle, false), + new Arc(center, radius, midAngle, endAngle2, false) + }; + } + + var sa = Angle.NormalizeRad(startParam + rotation); + var ea = Angle.NormalizeRad(endParam + rotation); + return new List { new Arc(center, radius, sa, ea, false) }; + } + + private static List GetInitialSplits(double startParam, double endParam) + { + var splits = new List { startParam }; + + var firstQuadrant = System.Math.Ceiling(startParam / (System.Math.PI / 2)) * (System.Math.PI / 2); + for (var q = firstQuadrant; q < endParam; q += System.Math.PI / 2) + { + if (q > startParam + 1e-10 && q < endParam - 1e-10) + splits.Add(q); + } + + splits.Add(endParam); + return splits; + } + + private static void FitSegment(Vector center, double semiMajor, double semiMinor, + double rotation, double t0, double t1, double tolerance, List results, int depth) + { + var p0 = EvaluatePoint(semiMajor, semiMinor, rotation, center, t0); + var p1 = EvaluatePoint(semiMajor, semiMinor, rotation, center, t1); + + if (p0.DistanceTo(p1) < 1e-10) + return; + + var n0 = EvaluateNormal(semiMajor, semiMinor, rotation, t0); + var n1 = EvaluateNormal(semiMajor, semiMinor, rotation, t1); + + var arcCenter = IntersectNormals(p0, n0, p1, n1); + + if (!arcCenter.IsValid() || depth >= MaxSubdivisionDepth) + { + results.Add(new Line(p0, p1)); + return; + } + + var radius = p0.DistanceTo(arcCenter); + var maxDev = MeasureDeviation(center, semiMajor, semiMinor, rotation, + t0, t1, arcCenter, radius); + + if (maxDev <= tolerance) + { + results.Add(CreateArc(arcCenter, radius, center, semiMajor, semiMinor, rotation, t0, t1)); + } + else + { + var tMid = (t0 + t1) / 2.0; + FitSegment(center, semiMajor, semiMinor, rotation, t0, tMid, tolerance, results, depth + 1); + FitSegment(center, semiMajor, semiMinor, rotation, tMid, t1, tolerance, results, depth + 1); + } + } + + private static double MeasureDeviation(Vector center, double semiMajor, double semiMinor, + double rotation, double t0, double t1, Vector arcCenter, double radius) + { + var maxDev = 0.0; + for (var i = 1; i <= DeviationSamples; i++) + { + var t = t0 + (t1 - t0) * i / DeviationSamples; + var p = EvaluatePoint(semiMajor, semiMinor, rotation, center, t); + var dist = p.DistanceTo(arcCenter); + var dev = System.Math.Abs(dist - radius); + if (dev > maxDev) maxDev = dev; + } + return maxDev; + } + + private static Arc CreateArc(Vector arcCenter, double radius, + Vector ellipseCenter, double semiMajor, double semiMinor, double rotation, + double t0, double t1) + { + var p0 = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t0); + var p1 = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t1); + + var startAngle = System.Math.Atan2(p0.Y - arcCenter.Y, p0.X - arcCenter.X); + var endAngle = System.Math.Atan2(p1.Y - arcCenter.Y, p1.X - arcCenter.X); + + var pMid = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, (t0 + t1) / 2); + var points = new List { p0, pMid, p1 }; + var isReversed = SumSignedAngles(arcCenter, points) < 0; + + if (startAngle < 0) startAngle += Angle.TwoPI; + if (endAngle < 0) endAngle += Angle.TwoPI; + + return new Arc(arcCenter, radius, startAngle, endAngle, isReversed); + } + + private static double SumSignedAngles(Vector center, List points) + { + var total = 0.0; + for (var i = 0; i < points.Count - 1; i++) + { + var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X); + var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X); + var da = a2 - a1; + while (da > System.Math.PI) da -= Angle.TwoPI; + while (da < -System.Math.PI) da += Angle.TwoPI; + total += da; + } + return total; + } } } diff --git a/OpenNest.Tests/EllipseConverterTests.cs b/OpenNest.Tests/EllipseConverterTests.cs index 0294353..aa0a4dd 100644 --- a/OpenNest.Tests/EllipseConverterTests.cs +++ b/OpenNest.Tests/EllipseConverterTests.cs @@ -78,4 +78,175 @@ public class EllipseConverterTests 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 < 0.001, + $"Gap of {gap:F6} 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 < 0.001, + $"Closing gap of {closingGap:F6}"); + } + + [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}"); + } + } + + 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; + } }