From bbc02f6f3fce61efce13ac0a63e1f4cb28856a50 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 25 Mar 2026 23:22:05 -0400 Subject: [PATCH] feat: add ArcCandidate and Kasa circle fitting Foundation for the geometry simplifier that will replace consecutive line segments with fitted arcs. Adds ArcCandidate data class, GeometrySimplifier with stub Analyze/Apply methods, and FitCircle using the Kasa algebraic least-squares method. Also adds InternalsVisibleTo for OpenNest.Tests on OpenNest.Core. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Core/Geometry/GeometrySimplifier.cs | 93 ++++++++++++++++++++ OpenNest.Core/OpenNest.Core.csproj | 3 + OpenNest.Tests/GeometrySimplifierTests.cs | 43 +++++++++ 3 files changed, 139 insertions(+) create mode 100644 OpenNest.Core/Geometry/GeometrySimplifier.cs create mode 100644 OpenNest.Tests/GeometrySimplifierTests.cs diff --git a/OpenNest.Core/Geometry/GeometrySimplifier.cs b/OpenNest.Core/Geometry/GeometrySimplifier.cs new file mode 100644 index 0000000..1cddadd --- /dev/null +++ b/OpenNest.Core/Geometry/GeometrySimplifier.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using OpenNest.Math; + +namespace OpenNest.Geometry; + +public class ArcCandidate +{ + public int ShapeIndex { get; set; } + public int StartIndex { get; set; } + public int EndIndex { get; set; } + public int LineCount => EndIndex - StartIndex + 1; + public Arc FittedArc { get; set; } + public double MaxDeviation { get; set; } + public Box BoundingBox { get; set; } + public bool IsSelected { get; set; } = true; +} + +public class GeometrySimplifier +{ + public double Tolerance { get; set; } = 0.005; + public int MinLines { get; set; } = 3; + + public List Analyze(Shape shape) + { + throw new NotImplementedException(); + } + + public Shape Apply(Shape shape, List candidates) + { + throw new NotImplementedException(); + } + + internal static (Vector center, double radius) FitCircle(List points) + { + var n = points.Count; + if (n < 3) + return (Vector.Invalid, 0); + + double sumX = 0, sumY = 0, sumX2 = 0, sumY2 = 0, sumXY = 0; + double sumXZ = 0, sumYZ = 0, sumZ = 0; + + for (var i = 0; i < n; i++) + { + var x = points[i].X; + var y = points[i].Y; + var z = x * x + y * y; + sumX += x; + sumY += y; + sumX2 += x * x; + sumY2 += y * y; + sumXY += x * y; + sumXZ += x * z; + sumYZ += y * z; + sumZ += z; + } + + // Solve: [sumX2 sumXY sumX] [A] [sumXZ] + // [sumXY sumY2 sumY] [B] = [sumYZ] + // [sumX sumY n ] [C] [sumZ ] + var det = sumX2 * (sumY2 * n - sumY * sumY) + - sumXY * (sumXY * n - sumY * sumX) + + sumX * (sumXY * sumY - sumY2 * sumX); + + if (System.Math.Abs(det) < 1e-10) + return (Vector.Invalid, 0); + + var detA = sumXZ * (sumY2 * n - sumY * sumY) + - sumXY * (sumYZ * n - sumY * sumZ) + + sumX * (sumYZ * sumY - sumY2 * sumZ); + + var detB = sumX2 * (sumYZ * n - sumY * sumZ) + - sumXZ * (sumXY * n - sumY * sumX) + + sumX * (sumXY * sumZ - sumYZ * sumX); + + var detC = sumX2 * (sumY2 * sumZ - sumYZ * sumY) + - sumXY * (sumXY * sumZ - sumYZ * sumX) + + sumXZ * (sumXY * sumY - sumY2 * sumX); + + var a = detA / det; + var b = detB / det; + var c = detC / det; + + var cx = a / 2.0; + var cy = b / 2.0; + var rSquared = cx * cx + cy * cy + c; + + if (rSquared <= 0) + return (Vector.Invalid, 0); + + return (new Vector(cx, cy), System.Math.Sqrt(rSquared)); + } +} diff --git a/OpenNest.Core/OpenNest.Core.csproj b/OpenNest.Core/OpenNest.Core.csproj index c3e24a7..d303768 100644 --- a/OpenNest.Core/OpenNest.Core.csproj +++ b/OpenNest.Core/OpenNest.Core.csproj @@ -4,6 +4,9 @@ OpenNest OpenNest.Core + + + diff --git a/OpenNest.Tests/GeometrySimplifierTests.cs b/OpenNest.Tests/GeometrySimplifierTests.cs new file mode 100644 index 0000000..91ace71 --- /dev/null +++ b/OpenNest.Tests/GeometrySimplifierTests.cs @@ -0,0 +1,43 @@ +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Tests; + +public class GeometrySimplifierTests +{ + [Fact] + public void FitCircle_PointsOnKnownCircle_ReturnsCorrectCenterAndRadius() + { + // 21 points on a semicircle centered at (5, 3) with radius 10 + var center = new Vector(5, 3); + var radius = 10.0; + var points = new List(); + for (var i = 0; i <= 20; i++) + { + var angle = i * System.Math.PI / 20; + points.Add(new Vector( + center.X + radius * System.Math.Cos(angle), + center.Y + radius * System.Math.Sin(angle))); + } + + var (fitCenter, fitRadius) = GeometrySimplifier.FitCircle(points); + + Assert.InRange(fitCenter.X, 4.999, 5.001); + Assert.InRange(fitCenter.Y, 2.999, 3.001); + Assert.InRange(fitRadius, 9.999, 10.001); + } + + [Fact] + public void FitCircle_CollinearPoints_ReturnsInvalidCenter() + { + // Collinear points should produce degenerate result + var points = new List + { + new(0, 0), new(1, 0), new(2, 0), new(3, 0), new(4, 0) + }; + + var (fitCenter, _) = GeometrySimplifier.FitCircle(points); + + Assert.False(fitCenter.IsValid()); + } +}