diff --git a/OpenNest.Core/Geometry/ArcFit.cs b/OpenNest.Core/Geometry/ArcFit.cs index 72b1dab..1773cb0 100644 --- a/OpenNest.Core/Geometry/ArcFit.cs +++ b/OpenNest.Core/Geometry/ArcFit.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using OpenNest.Math; namespace OpenNest.Geometry { @@ -118,6 +119,27 @@ namespace OpenNest.Geometry return System.Math.Atan2(ux * to.Y - uy * to.X, ux * to.X + uy * to.Y); } + /// + /// Sums signed angular change traversing consecutive points around a center. + /// Positive = CCW, negative = CW. + /// + public 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; + } + /// /// Computes the maximum radial deviation of interior points from a circle. /// diff --git a/OpenNest.Core/Geometry/EllipseConverter.cs b/OpenNest.Core/Geometry/EllipseConverter.cs index 8f6d8a8..6d62106 100644 --- a/OpenNest.Core/Geometry/EllipseConverter.cs +++ b/OpenNest.Core/Geometry/EllipseConverter.cs @@ -330,7 +330,7 @@ namespace OpenNest.Geometry var endAngle = System.Math.Atan2(p1.Y - arcCenter.Y, p1.X - arcCenter.X); var points = new List { p0, pMid, p1 }; - var isReversed = SumSignedAngles(arcCenter, points) < 0; + var isReversed = ArcFit.SumSignedAngles(arcCenter, points) < 0; if (startAngle < 0) startAngle += Angle.TwoPI; @@ -339,22 +339,5 @@ namespace OpenNest.Geometry 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.Core/Geometry/GeometrySimplifier.cs b/OpenNest.Core/Geometry/GeometrySimplifier.cs index 72cf371..18a7ce8 100644 --- a/OpenNest.Core/Geometry/GeometrySimplifier.cs +++ b/OpenNest.Core/Geometry/GeometrySimplifier.cs @@ -435,7 +435,7 @@ public class GeometrySimplifier // Reject arcs that subtend a tiny angle — these are nearly-straight lines // that happen to fit a huge circle. Applied after extension so that many small // segments can accumulate enough sweep to qualify. - var sweep = System.Math.Abs(SumSignedAngles(center, points)); + var sweep = System.Math.Abs(ArcFit.SumSignedAngles(center, points)); if (sweep < Angle.ToRadians(5)) return null; @@ -454,7 +454,7 @@ public class GeometrySimplifier continue; // Check that the arc doesn't bulge away from the original line segments - var isReversed = SumSignedAngles(center, points) < 0; + var isReversed = ArcFit.SumSignedAngles(center, points) < 0; var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed); if (arcDev > Tolerance) continue; @@ -673,7 +673,7 @@ public class GeometrySimplifier var lastPt = points[^1]; var rx = lastPt.X - center.X; var ry = lastPt.Y - center.Y; - var sign = SumSignedAngles(center, points) >= 0 ? 1 : -1; + var sign = ArcFit.SumSignedAngles(center, points) >= 0 ? 1 : -1; return new Vector(-sign * ry, sign * rx); } @@ -792,7 +792,7 @@ public class GeometrySimplifier var endAngle = NormalizeAngle( System.Math.Atan2(lastPoint.Y - center.Y, lastPoint.X - center.X) ); - var isReversed = SumSignedAngles(center, points) < 0; + var isReversed = ArcFit.SumSignedAngles(center, points) < 0; var arc = new Arc(center, radius, startAngle, endAngle, isReversed); arc.Layer = sourceEntity.Layer; @@ -816,27 +816,6 @@ public class GeometrySimplifier _ => Vector.Invalid, }; - /// - /// Sums signed angular change traversing consecutive points around a center. - /// Positive = CCW, negative = CW. - /// - 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; - } - /// /// Measures the maximum distance from sampled points along the fitted arc /// back to the original line segments. This catches cases where points lie diff --git a/OpenNest.Core/Geometry/SplineConverter.cs b/OpenNest.Core/Geometry/SplineConverter.cs index 6ab4eff..fc28217 100644 --- a/OpenNest.Core/Geometry/SplineConverter.cs +++ b/OpenNest.Core/Geometry/SplineConverter.cs @@ -80,7 +80,7 @@ namespace OpenNest.Geometry } var finalPoints = points.GetRange(start, endIdx - start + 1); - var sweep = System.Math.Abs(SumSignedAngles(center, finalPoints)); + var sweep = System.Math.Abs(ArcFit.SumSignedAngles(center, finalPoints)); if (sweep < Angle.ToRadians(5)) return null; @@ -151,27 +151,10 @@ namespace OpenNest.Geometry double radius ) => ArcFit.MaxRadialDeviation(points, cx, cy, radius); - 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; - } - private static Vector ComputeEndTangent(Vector center, List points) { var lastPt = points[^1]; - var totalAngle = SumSignedAngles(center, points); + var totalAngle = ArcFit.SumSignedAngles(center, points); var rx = lastPt.X - center.X; var ry = lastPt.Y - center.Y; @@ -186,7 +169,7 @@ namespace OpenNest.Geometry var startAngle = System.Math.Atan2(firstPoint.Y - center.Y, firstPoint.X - center.X); var endAngle = System.Math.Atan2(lastPoint.Y - center.Y, lastPoint.X - center.X); - var isReversed = SumSignedAngles(center, points) < 0; + var isReversed = ArcFit.SumSignedAngles(center, points) < 0; if (startAngle < 0) startAngle += Angle.TwoPI; diff --git a/OpenNest.Tests/Geometry/ArcFitTests.cs b/OpenNest.Tests/Geometry/ArcFitTests.cs new file mode 100644 index 0000000..45182f3 --- /dev/null +++ b/OpenNest.Tests/Geometry/ArcFitTests.cs @@ -0,0 +1,175 @@ +using OpenNest.Geometry; + +namespace OpenNest.Tests.Geometry; + +public class ArcFitTests +{ + [Theory] + [InlineData("empty")] + [InlineData("single")] + [InlineData("positive-half-turn")] + [InlineData("negative-half-turn")] + [InlineData("repeated")] + [InlineData("translated")] + [InlineData("closed-ccw")] + [InlineData("closed-cw")] + [InlineData("multiple-turns-ccw")] + [InlineData("multiple-turns-cw")] + [InlineData("irregular-multiple-turns")] + [InlineData("two-points")] + [InlineData("atan-seam-ccw")] + [InlineData("atan-seam-cw")] + [InlineData("positive-x-seam")] + [InlineData("collinear-through-center")] + [InlineData("nan-point-x")] + [InlineData("nan-point-y")] + [InlineData("nan-center")] + [InlineData("nan-empty-center")] + [InlineData("nan-single-center")] + [InlineData("nan-single-point")] + public void SumSignedAngles_MatchesFrozenAccumulatorBitExactlyWithoutMutation(string scenario) + { + var (center, points) = Corpus(scenario); + var centerBefore = Snapshot(center); + var pointsBefore = points.Select(Snapshot).ToArray(); + var expected = FrozenSumSignedAngles(center, points); + + var actual = ArcFit.SumSignedAngles(center, points); + + Assert.Equal(BitConverter.DoubleToInt64Bits(expected), BitConverter.DoubleToInt64Bits(actual)); + Assert.Equal(centerBefore, Snapshot(center)); + Assert.Equal(pointsBefore, points.Select(Snapshot).ToArray()); + + switch (scenario) + { + // Strict > / < comparisons must leave exact +PI and -PI unreduced. + case "positive-half-turn": + Assert.Equal(BitConverter.DoubleToInt64Bits(System.Math.PI), BitConverter.DoubleToInt64Bits(actual)); + break; + case "negative-half-turn": + Assert.Equal(BitConverter.DoubleToInt64Bits(-System.Math.PI), BitConverter.DoubleToInt64Bits(actual)); + break; + case "empty": + case "single": + case "repeated": + case "collinear-through-center": + case "nan-empty-center": + case "nan-single-center": + case "nan-single-point": + Assert.Equal(BitConverter.DoubleToInt64Bits(0.0), BitConverter.DoubleToInt64Bits(actual)); + break; + case "multiple-turns-ccw": + case "irregular-multiple-turns": + Assert.True(actual > 2 * System.Math.PI); + break; + case "multiple-turns-cw": + Assert.True(actual < -2 * System.Math.PI); + break; + case "nan-point-x": + case "nan-point-y": + case "nan-center": + // Characterized before extraction: a visited NaN propagates into the + // total; neither normalization loop runs because comparisons are false. + // Empty/single lists never evaluate Atan2, even with a NaN center/point. + Assert.True(double.IsNaN(actual)); + break; + } + } + + private static (long x, long y) Snapshot(Vector point) => + (BitConverter.DoubleToInt64Bits(point.X), BitConverter.DoubleToInt64Bits(point.Y)); + + private static (Vector center, List points) Corpus(string scenario) + { + var center = new Vector(0, 0); + var points = scenario switch + { + "empty" => new List(), + "single" => new List { new Vector(2, -3) }, + "positive-half-turn" => new List { new Vector(1, 0), new Vector(-1, 0) }, + "negative-half-turn" => new List { new Vector(-1, 0), new Vector(1, 0) }, + "repeated" => new List { new Vector(1, 2), new Vector(1, 2), new Vector(1, 2) }, + "translated" => new List + { + new Vector(9, -11), new Vector(7, -9), new Vector(5, -11), + new Vector(7, -13), new Vector(9, -11), + }, + "closed-ccw" or "closed-cw" => CardinalLoop(1), + "multiple-turns-ccw" or "multiple-turns-cw" => CardinalLoop(3), + "irregular-multiple-turns" => IrregularTurns(), + "two-points" => new List { new Vector(1, 1), new Vector(-1, 1) }, + "atan-seam-ccw" => new List { new Vector(-1, 0.001), new Vector(-1, -0.001) }, + "atan-seam-cw" => new List { new Vector(-1, -0.001), new Vector(-1, 0.001) }, + "positive-x-seam" => new List { new Vector(1, -0.001), new Vector(1, 0.001) }, + "collinear-through-center" => new List + { + new Vector(1, 0), new Vector(0, 0), new Vector(-1, 0), + new Vector(0, 0), new Vector(1, 0), + }, + "nan-point-x" => new List + { + new Vector(1, 0), new Vector(double.NaN, 1), new Vector(0, 1), + }, + "nan-point-y" => new List + { + new Vector(1, 0), new Vector(1, double.NaN), new Vector(0, 1), + }, + "nan-center" => new List { new Vector(1, 0), new Vector(0, 1) }, + "nan-empty-center" => new List(), + "nan-single-center" => new List { new Vector(1, 0) }, + "nan-single-point" => new List { new Vector(double.NaN, double.NaN) }, + _ => throw new ArgumentOutOfRangeException(nameof(scenario)), + }; + if (scenario == "translated") + center = new Vector(7, -11); + else if (scenario is "nan-center" or "nan-empty-center" or "nan-single-center") + center = new Vector(double.NaN, 0); + if (scenario is "closed-cw" or "multiple-turns-cw") + points.Reverse(); + return (center, points); + } + + private static List CardinalLoop(int turns) + { + var cycle = new[] { new Vector(1, 0), new Vector(0, 1), new Vector(-1, 0), new Vector(0, -1) }; + var points = new List(); + for (var i = 0; i <= turns * 4; i++) + points.Add(cycle[i % 4]); + return points; + } + + private static List IrregularTurns() + { + var steps = new[] { 0.13, 0.81, -0.27, 1.01, 0.34, 0.0 }; + var points = new List(); + var angle = 2.9; + for (var i = 0; i < 80; i++) + { + points.Add(new Vector(System.Math.Cos(angle), System.Math.Sin(angle))); + angle += steps[i % steps.Length]; + } + return points; + } + + // Independent frozen test oracle, written before the production extraction. + // Do not route this through ArcFit or any other production angle helper. + private static double FrozenSumSignedAngles(Vector center, List points) + { + const double fullTurn = 2.0 * System.Math.PI; + var sum = 0.0; + for (var index = 1; index < points.Count; index++) + { + var previous = points[index - 1]; + var current = points[index]; + var previousAngle = System.Math.Atan2(previous.Y - center.Y, previous.X - center.X); + var currentAngle = System.Math.Atan2(current.Y - center.Y, current.X - center.X); + var change = currentAngle - previousAngle; + while (change > System.Math.PI) + change -= fullTurn; + while (change < -System.Math.PI) + change += fullTurn; + sum += change; + } + return sum; + } +}