From 8664656658d598e55d7bf85c3b392ef6888a593f Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 30 Sep 2026 08:57:34 -0400 Subject: [PATCH] test(geometry): characterize converter winding before ArcFit extraction --- .../Geometry/EllipseConverterTests.cs | 69 ++++++++++ .../Geometry/GeometrySimplifierTests.cs | 128 ++++++++++++++++++ .../Geometry/SplineConverterTests.cs | 114 ++++++++++++++++ 3 files changed, 311 insertions(+) diff --git a/OpenNest.Tests/Geometry/EllipseConverterTests.cs b/OpenNest.Tests/Geometry/EllipseConverterTests.cs index de20833..92a4bd8 100644 --- a/OpenNest.Tests/Geometry/EllipseConverterTests.cs +++ b/OpenNest.Tests/Geometry/EllipseConverterTests.cs @@ -375,6 +375,75 @@ public class EllipseConverterTests ); } + // 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; diff --git a/OpenNest.Tests/Geometry/GeometrySimplifierTests.cs b/OpenNest.Tests/Geometry/GeometrySimplifierTests.cs index b659734..4caac48 100644 --- a/OpenNest.Tests/Geometry/GeometrySimplifierTests.cs +++ b/OpenNest.Tests/Geometry/GeometrySimplifierTests.cs @@ -241,6 +241,134 @@ public class GeometrySimplifierTests ); } + // Frozen Analyze + Apply output from 5bf3c5f. The circles are order markers; + // the rotated ellipse also pins the two trailing lines left unfitted. + [Theory] + [MemberData(nameof(SignedAngleCharacterizationCases))] + public void Apply_SignedAngleCharacterization_PreservesOrderedEntities( + string scenario, double[][] expected, bool[] reversed, int expectedEndIndex + ) + { + var points = SplineConverterTests.SignedAngleCharacterizationPoints(scenario); + var shape = new Shape(); + shape.Entities.Add(new Circle(new Vector(-30, 40), 2.5)); + for (var i = 0; i < points.Count - 1; i++) + shape.Entities.Add(new Line(points[i], points[i + 1])); + shape.Entities.Add(new Circle(new Vector(30, -40), 3.5)); + var simplifier = new GeometrySimplifier { Tolerance = 0.05 }; + + var candidate = Assert.Single(simplifier.Analyze(shape)); + Assert.Equal(1, candidate.StartIndex); + Assert.Equal(expectedEndIndex, candidate.EndIndex); + var result = simplifier.Apply(shape, new List { candidate }); + var assertions = new List> + { + entity => AssertCharacterizedCircle(entity, -30, 40, 2.5), + entity => AssertCharacterizedArc(entity, expected[0], reversed[0]), + }; + if (scenario == "rotated-partial-ellipse") + { + assertions.Add(entity => AssertCharacterizedLine(entity, + new[] { 2.644054114237731, 2.288101855169011, 2.3955895038427424, 2.1920946874414766 })); + assertions.Add(entity => AssertCharacterizedLine(entity, + new[] { 2.3955895038427424, 2.1920946874414766, 2.148394352966971, 2.087282751199381 })); + } + assertions.Add(entity => AssertCharacterizedCircle(entity, 30, -40, 3.5)); + Assert.Collection(result.Entities, assertions.ToArray()); + } + + public static IEnumerable SignedAngleCharacterizationCases() + { + yield return new object[] + { + "ccw", + new double[][] + { + new[] { 2.999999999999998, -2.000000000000008, 5.000000000000004, 0.30000000000000143, 2.2999999999999985 }, + }, + new[] { false }, + 24, + }; + yield return new object[] + { + "cw", + new double[][] + { + new[] { 3.0000000000000053, -1.9999999999999813, 4.999999999999989, 2.300000000000003, 0.2999999999999968 }, + }, + new[] { true }, + 24, + }; + yield return new object[] + { + "reversed", + new double[][] + { + new[] { 2.999999999999998, -2.000000000000008, 5.000000000000004, 2.2999999999999985, 0.30000000000000143 }, + }, + new[] { true }, + 24, + }; + yield return new object[] + { + "atan-seam", + new double[][] + { + new[] { 2.9999999999999436, -2.0000000000000098, 4.999999999999949, 2.799999999999994, 3.8000000000000047 }, + }, + new[] { false }, + 24, + }; + yield return new object[] + { + "positive-x-seam", + new double[][] + { + new[] { 2.9999999999999565, -2.000000000000001, 5.00000000000004, 5.800000000000003, 0.5168146928204091 }, + }, + new[] { false }, + 24, + }; + yield return new object[] + { + "rotated-partial-ellipse", + new double[][] + { + new[] { 4.738877816517773, -2.224586867329932, 4.975203096337214, 1.0053406925186938, 2.0054021506562583 }, + }, + new[] { false }, + 22, + }; + } + + 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 void AssertCharacterizedCircle(Entity entity, double x, double y, double radius) + { + var circle = Assert.IsType(entity); + Assert.Equal(x, circle.Center.X); + Assert.Equal(y, circle.Center.Y); + Assert.Equal(radius, circle.Radius); + } + + private static void AssertCharacterizedLine(Entity entity, double[] expected) + { + var line = Assert.IsType(entity); + var actual = new[] { line.StartPoint.X, line.StartPoint.Y, line.EndPoint.X, line.EndPoint.Y }; + for (var i = 0; i < expected.Length; i++) + Assert.InRange(actual[i], expected[i] - 1e-10, expected[i] + 1e-10); + } + private static Vector ArcTangentAt(Arc arc, Vector pt) { var ang = System.Math.Atan2(pt.Y - arc.Center.Y, pt.X - arc.Center.X); diff --git a/OpenNest.Tests/Geometry/SplineConverterTests.cs b/OpenNest.Tests/Geometry/SplineConverterTests.cs index 752c310..c7eca92 100644 --- a/OpenNest.Tests/Geometry/SplineConverterTests.cs +++ b/OpenNest.Tests/Geometry/SplineConverterTests.cs @@ -112,6 +112,120 @@ public class SplineConverterTests Assert.Empty(result); } + // Recorded on the unmodified 5bf3c5f converter. Preserve even the final CW + // arc of the rotated ellipse: this extraction must not change fitting decisions. + [Theory] + [MemberData(nameof(SignedAngleCharacterizationCases))] + public void Convert_SignedAngleCharacterization_PreservesOrderedArcs( + string scenario, double[][] expected, bool[] reversed + ) + { + var points = SignedAngleCharacterizationPoints(scenario); + var result = SplineConverter.Convert(points, isClosed: false, tolerance: 0.05); + + Assert.Collection(result, expected.Select((parameters, i) => + (Action)(entity => AssertCharacterizedArc(entity, parameters, reversed[i])) + ).ToArray()); + } + + public static IEnumerable SignedAngleCharacterizationCases() + { + yield return new object[] + { + "ccw", + new double[][] + { + new[] { 3.0000000000000004, -1.9999999999999991, 4.999999999999999, 0.2999999999999998, 2.3000000000000003 }, + }, + new[] { false }, + }; + yield return new object[] + { + "cw", + new double[][] + { + new[] { 3, -1.9999999999999991, 4.999999999999999, 2.3, 0.2999999999999999 }, + }, + new[] { true }, + }; + yield return new object[] + { + "reversed", + new double[][] + { + new[] { 3, -1.9999999999999991, 4.999999999999999, 2.3, 0.2999999999999998 }, + }, + new[] { true }, + }; + yield return new object[] + { + "atan-seam", + new double[][] + { + new[] { 2.9999999999999982, -2.0000000000000004, 4.999999999999998, 2.7999999999999994, 3.8 }, + }, + new[] { false }, + }; + yield return new object[] + { + "positive-x-seam", + new double[][] + { + new[] { 2.999999999999999, -2, 5.000000000000002, 5.8, 0.5168146928204133 }, + }, + new[] { false }, + }; + yield return new object[] + { + "rotated-partial-ellipse", + new double[][] + { + new[] { 4.7398647354210635, -2.2095236378718557, 4.9619610272064785, 1.0038820776565072, 2.0068607655184447 }, + new[] { 0.4338148276802185, 7.03129213222217, 5.232877956552578, 5.148453419108238, 5.046209652075572 }, + }, + new[] { false, true }, + }; + } + + internal static List SignedAngleCharacterizationPoints(string scenario) + { + var (start, end, ellipse) = scenario switch + { + "cw" => (2.3, 0.3, false), + "atan-seam" => (2.8, 3.8, false), + "positive-x-seam" => (5.8, 6.8, false), + "rotated-partial-ellipse" => (0.2, 1.3, true), + _ => (0.3, 2.3, false), + }; + var points = new List(); + for (var i = 0; i <= 24; i++) + { + var angle = start + (end - start) * i / 24; + var x = (ellipse ? 6 : 5) * System.Math.Cos(angle); + var y = (ellipse ? 4 : 5) * System.Math.Sin(angle); + var rotation = ellipse ? 0.6 : 0; + points.Add(new Vector( + 3 + x * System.Math.Cos(rotation) - y * System.Math.Sin(rotation), + -2 + x * System.Math.Sin(rotation) + y * System.Math.Cos(rotation) + )); + } + if (scenario == "reversed") + points.Reverse(); + return points; + } + + 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 Vector GetStartPoint(Entity e) { return e switch