From c40941ed359418741b2aca039dcbd49d9da7b217 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 27 Mar 2026 14:50:06 -0400 Subject: [PATCH] feat: add EllipseConverter evaluation helpers with tests Add EllipseConverter static class with foundational methods for converting ellipse parameters to circular arcs: EvaluatePoint, EvaluateTangent, EvaluateNormal, and IntersectNormals. All 8 unit tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Core/Geometry/EllipseConverter.cs | 67 ++++++++++++++++++ OpenNest.Tests/EllipseConverterTests.cs | 81 ++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 OpenNest.Core/Geometry/EllipseConverter.cs create mode 100644 OpenNest.Tests/EllipseConverterTests.cs diff --git a/OpenNest.Core/Geometry/EllipseConverter.cs b/OpenNest.Core/Geometry/EllipseConverter.cs new file mode 100644 index 0000000..100753b --- /dev/null +++ b/OpenNest.Core/Geometry/EllipseConverter.cs @@ -0,0 +1,67 @@ +using OpenNest.Math; +using System; +using System.Collections.Generic; + +namespace OpenNest.Geometry +{ + public static class EllipseConverter + { + private const int MaxSubdivisionDepth = 12; + private const int DeviationSamples = 20; + + internal static Vector EvaluatePoint(double semiMajor, double semiMinor, double rotation, Vector center, double t) + { + var x = semiMajor * System.Math.Cos(t); + var y = semiMinor * System.Math.Sin(t); + + var cos = System.Math.Cos(rotation); + var sin = System.Math.Sin(rotation); + + return new Vector( + center.X + x * cos - y * sin, + center.Y + x * sin + y * cos); + } + + internal static Vector EvaluateTangent(double semiMajor, double semiMinor, double rotation, double t) + { + var tx = -semiMajor * System.Math.Sin(t); + var ty = semiMinor * System.Math.Cos(t); + + var cos = System.Math.Cos(rotation); + var sin = System.Math.Sin(rotation); + + return new Vector( + tx * cos - ty * sin, + tx * sin + ty * cos); + } + + internal static Vector EvaluateNormal(double semiMajor, double semiMinor, double rotation, double t) + { + // Inward normal: perpendicular to tangent, pointing toward center of curvature. + // In local coords: N(t) = (-b*cos(t), -a*sin(t)) + var nx = -semiMinor * System.Math.Cos(t); + var ny = -semiMajor * System.Math.Sin(t); + + var cos = System.Math.Cos(rotation); + var sin = System.Math.Sin(rotation); + + return new Vector( + nx * cos - ny * sin, + nx * sin + ny * cos); + } + + internal static Vector IntersectNormals(Vector p1, Vector n1, Vector p2, Vector n2) + { + // Solve: p1 + s*n1 = p2 + t*n2 + var det = n1.X * (-n2.Y) - (-n2.X) * n1.Y; + if (System.Math.Abs(det) < 1e-10) + return Vector.Invalid; + + var dx = p2.X - p1.X; + var dy = p2.Y - p1.Y; + var s = (dx * (-n2.Y) - dy * (-n2.X)) / det; + + return new Vector(p1.X + s * n1.X, p1.Y + s * n1.Y); + } + } +} diff --git a/OpenNest.Tests/EllipseConverterTests.cs b/OpenNest.Tests/EllipseConverterTests.cs new file mode 100644 index 0000000..0294353 --- /dev/null +++ b/OpenNest.Tests/EllipseConverterTests.cs @@ -0,0 +1,81 @@ +using OpenNest.Geometry; +using OpenNest.Math; +using Xunit; + +namespace OpenNest.Tests; + +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()); + } +}