refactor(geometry): share signed-angle accumulation through ArcFit

EllipseConverter, SplineConverter and GeometrySimplifier each carried an
identical private SumSignedAngles. Move the unchanged body to
ArcFit.SumSignedAngles and call it from all three, keeping the ordered
accumulation, strict half-turn comparisons and empty/single-point result.

ArcFitTests compares the shared method bit-for-bit with a separate
test-local accumulator across half turns, the atan2 seam, multiple
turns, translated centres and NaN inputs, and checks that inputs are not
mutated. The converter winding characterization from 8664656 still
passes unchanged.
This commit is contained in:
aj
2026-09-30 21:20:58 -04:00
parent c55e375cf3
commit 8450ceee40
5 changed files with 205 additions and 63 deletions
+22
View File
@@ -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);
}
/// <summary>
/// Sums signed angular change traversing consecutive points around a center.
/// Positive = CCW, negative = CW.
/// </summary>
public static double SumSignedAngles(Vector center, List<Vector> 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;
}
/// <summary>
/// Computes the maximum radial deviation of interior points from a circle.
/// </summary>
+1 -18
View File
@@ -330,7 +330,7 @@ namespace OpenNest.Geometry
var endAngle = System.Math.Atan2(p1.Y - arcCenter.Y, p1.X - arcCenter.X);
var points = new List<Vector> { 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<Vector> 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;
}
}
}
+4 -25
View File
@@ -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,
};
/// <summary>
/// Sums signed angular change traversing consecutive points around a center.
/// Positive = CCW, negative = CW.
/// </summary>
private static double SumSignedAngles(Vector center, List<Vector> 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;
}
/// <summary>
/// Measures the maximum distance from sampled points along the fitted arc
/// back to the original line segments. This catches cases where points lie
+3 -20
View File
@@ -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<Vector> 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<Vector> 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;