From ca5eb53bc11a84cf7e8a5310cdb48615e0765cf5 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 25 Mar 2026 23:30:10 -0400 Subject: [PATCH] feat: add GeometrySimplifier.Analyze with incremental arc fitting Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Core/Geometry/GeometrySimplifier.cs | 159 ++++++++++++++++++- OpenNest.Tests/GeometrySimplifierTests.cs | 67 ++++++++ 2 files changed, 225 insertions(+), 1 deletion(-) diff --git a/OpenNest.Core/Geometry/GeometrySimplifier.cs b/OpenNest.Core/Geometry/GeometrySimplifier.cs index 1cddadd..c684a24 100644 --- a/OpenNest.Core/Geometry/GeometrySimplifier.cs +++ b/OpenNest.Core/Geometry/GeometrySimplifier.cs @@ -23,7 +23,30 @@ public class GeometrySimplifier public List Analyze(Shape shape) { - throw new NotImplementedException(); + var candidates = new List(); + var entities = shape.Entities; + var i = 0; + + while (i < entities.Count) + { + if (entities[i] is not Line firstLine) + { + i++; + continue; + } + + // Collect consecutive lines on the same layer + var runStart = i; + var layer = firstLine.Layer; + while (i < entities.Count && entities[i] is Line line && line.Layer == layer) + i++; + var runEnd = i - 1; + + // Try to find arc candidates within this run + FindCandidatesInRun(entities, runStart, runEnd, candidates); + } + + return candidates; } public Shape Apply(Shape shape, List candidates) @@ -31,6 +54,140 @@ public class GeometrySimplifier throw new NotImplementedException(); } + private void FindCandidatesInRun(List entities, int runStart, int runEnd, List candidates) + { + var j = runStart; + + while (j <= runEnd - MinLines + 1) + { + // Start with MinLines lines + var k = j + MinLines - 1; + var points = CollectPoints(entities, j, k); + var (center, radius) = FitCircle(points); + + if (!center.IsValid() || MaxDeviation(points, center, radius) > Tolerance) + { + j++; + continue; + } + + // Extend as far as possible + var prevCenter = center; + var prevRadius = radius; + var prevMaxDev = MaxDeviation(points, center, radius); + + while (k + 1 <= runEnd) + { + k++; + points = CollectPoints(entities, j, k); + var (newCenter, newRadius) = FitCircle(points); + if (!newCenter.IsValid()) + { + k--; + break; + } + + var newMaxDev = MaxDeviation(points, newCenter, newRadius); + if (newMaxDev > Tolerance) + { + k--; + break; + } + + prevCenter = newCenter; + prevRadius = newRadius; + prevMaxDev = newMaxDev; + } + + // Build the candidate + var finalPoints = CollectPoints(entities, j, k); + var arc = BuildArc(prevCenter, prevRadius, finalPoints, entities[j]); + var bbox = ComputeBoundingBox(finalPoints); + + candidates.Add(new ArcCandidate + { + StartIndex = j, + EndIndex = k, + FittedArc = arc, + MaxDeviation = prevMaxDev, + BoundingBox = bbox, + }); + + j = k + 1; + } + } + + private static List CollectPoints(List entities, int start, int end) + { + var points = new List(); + points.Add(((Line)entities[start]).StartPoint); + for (var i = start; i <= end; i++) + points.Add(((Line)entities[i]).EndPoint); + return points; + } + + private static double MaxDeviation(List points, Vector center, double radius) + { + var maxDev = 0.0; + for (var i = 0; i < points.Count; i++) + { + var dev = System.Math.Abs(points[i].DistanceTo(center) - radius); + if (dev > maxDev) + maxDev = dev; + } + return maxDev; + } + + private static Arc BuildArc(Vector center, double radius, List points, Entity sourceEntity) + { + var firstPoint = points[0]; + var lastPoint = points[^1]; + + 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); + + // Determine direction by summing signed angular changes + var totalAngle = 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; + totalAngle += da; + } + + var isReversed = totalAngle < 0; + + // Normalize angles to [0, 2pi) + if (startAngle < 0) startAngle += Angle.TwoPI; + if (endAngle < 0) endAngle += Angle.TwoPI; + + var arc = new Arc(center, radius, startAngle, endAngle, isReversed); + arc.Layer = sourceEntity.Layer; + arc.Color = sourceEntity.Color; + return arc; + } + + private static Box ComputeBoundingBox(List points) + { + var minX = double.MaxValue; + var minY = double.MaxValue; + var maxX = double.MinValue; + var maxY = double.MinValue; + + for (var i = 0; i < points.Count; i++) + { + if (points[i].X < minX) minX = points[i].X; + if (points[i].Y < minY) minY = points[i].Y; + if (points[i].X > maxX) maxX = points[i].X; + if (points[i].Y > maxY) maxY = points[i].Y; + } + + return new Box(minX, minY, maxX - minX, maxY - minY); + } + internal static (Vector center, double radius) FitCircle(List points) { var n = points.Count; diff --git a/OpenNest.Tests/GeometrySimplifierTests.cs b/OpenNest.Tests/GeometrySimplifierTests.cs index 91ace71..b88d172 100644 --- a/OpenNest.Tests/GeometrySimplifierTests.cs +++ b/OpenNest.Tests/GeometrySimplifierTests.cs @@ -40,4 +40,71 @@ public class GeometrySimplifierTests Assert.False(fitCenter.IsValid()); } + + [Fact] + public void Analyze_LinesFromSemicircle_FindsOneCandidate() + { + // Create 20 lines approximating a semicircle of radius 10 + var arc = new Arc(new Vector(0, 0), 10, 0, System.Math.PI, false); + var points = arc.ToPoints(20); + var shape = new Shape(); + for (var i = 0; i < points.Count - 1; i++) + shape.Entities.Add(new Line(points[i], points[i + 1])); + + var simplifier = new GeometrySimplifier { Tolerance = 0.1 }; + var candidates = simplifier.Analyze(shape); + + Assert.Single(candidates); + Assert.Equal(0, candidates[0].StartIndex); + Assert.Equal(19, candidates[0].EndIndex); + Assert.Equal(20, candidates[0].LineCount); + Assert.InRange(candidates[0].FittedArc.Radius, 9.5, 10.5); + Assert.True(candidates[0].MaxDeviation <= 0.1); + } + + [Fact] + public void Analyze_TooFewLines_ReturnsNoCandidates() + { + // Only 2 consecutive lines — below MinLines threshold + var shape = new Shape(); + shape.Entities.Add(new Line(new Vector(0, 0), new Vector(1, 1))); + shape.Entities.Add(new Line(new Vector(1, 1), new Vector(2, 0))); + + var simplifier = new GeometrySimplifier { Tolerance = 0.1, MinLines = 3 }; + var candidates = simplifier.Analyze(shape); + + Assert.Empty(candidates); + } + + [Fact] + public void Analyze_MixedEntitiesWithArc_OnlyAnalyzesLines() + { + // Line, Line, Line, Arc, Line, Line, Line — should find candidates only in line runs + var shape = new Shape(); + // First run: 5 lines on a curve + var arc1 = new Arc(new Vector(0, 0), 10, 0, System.Math.PI / 2, false); + var pts1 = arc1.ToPoints(5); + for (var i = 0; i < pts1.Count - 1; i++) + shape.Entities.Add(new Line(pts1[i], pts1[i + 1])); + + // An existing arc entity (breaks the run) + shape.Entities.Add(new Arc(new Vector(20, 0), 5, 0, System.Math.PI, false)); + + // Second run: 4 lines on a different curve + var arc2 = new Arc(new Vector(30, 0), 8, 0, System.Math.PI / 3, false); + var pts2 = arc2.ToPoints(4); + for (var i = 0; i < pts2.Count - 1; i++) + shape.Entities.Add(new Line(pts2[i], pts2[i + 1])); + + var simplifier = new GeometrySimplifier { Tolerance = 0.5, MinLines = 3 }; + var candidates = simplifier.Analyze(shape); + + Assert.Equal(2, candidates.Count); + // First candidate covers indices 0-4 (5 lines) + Assert.Equal(0, candidates[0].StartIndex); + Assert.Equal(4, candidates[0].EndIndex); + // Second candidate covers indices 6-9 (4 lines, after the arc at index 5) + Assert.Equal(6, candidates[1].StartIndex); + Assert.Equal(9, candidates[1].EndIndex); + } }