From d6184fdc8f01dcef165ff0686787c7f4d8b20f42 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 27 Mar 2026 14:47:13 -0400 Subject: [PATCH] docs: add implementation plan for direct arc conversion Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-27-direct-arc-conversion.md | 1167 +++++++++++++++++ 1 file changed, 1167 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-27-direct-arc-conversion.md diff --git a/docs/superpowers/plans/2026-03-27-direct-arc-conversion.md b/docs/superpowers/plans/2026-03-27-direct-arc-conversion.md new file mode 100644 index 0000000..6afce3e --- /dev/null +++ b/docs/superpowers/plans/2026-03-27-direct-arc-conversion.md @@ -0,0 +1,1167 @@ +# Direct Arc Conversion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Convert DXF splines and ellipses directly to circular arcs during import, eliminating the intermediate 200-line-segment representation. + +**Architecture:** Two new static classes in `OpenNest.Core/Geometry/` — `EllipseConverter` (analytical curvature-based arc fitting with normal-constrained G1 continuity) and `SplineConverter` (tangent-chained greedy arc fitting on evaluated curve points). Both produce `List` of arcs and lines. The existing `Extensions.cs` and `DxfImporter.cs` in `OpenNest.IO` are updated to delegate to these converters. + +**Tech Stack:** .NET 8, xUnit, OpenNest.Core geometry primitives (`Vector`, `Arc`, `Line`, `Entity`, `Angle`), ACadSharp `Spline.PolygonalVertexes()`. + +**Spec:** `docs/superpowers/specs/2026-03-27-direct-arc-conversion-design.md` + +--- + +### Task 1: EllipseConverter — Evaluation Helpers + +**Files:** +- Create: `OpenNest.Core/Geometry/EllipseConverter.cs` +- Create: `OpenNest.Tests/EllipseConverterTests.cs` + +- [ ] **Step 1: Write failing tests for ellipse evaluation methods** + +```csharp +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() + { + // Unrotated ellipse: a=10, b=5, center=(0,0) + 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() + { + // 90-degree rotation: major axis now points up + 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() + { + // At t=0: tangent is (-a*sin(0), b*cos(0)) = (0, b) in local coords + 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() + { + // At t=0 on unrotated ellipse, inward normal points in -X direction + 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() + { + // Two points with known normals that intersect at origin + var p1 = new Vector(5, 0); + var n1 = new Vector(-1, 0); // points left + var p2 = new Vector(0, 5); + var n2 = new Vector(0, -1); // points down + 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); // same direction — parallel + var center = EllipseConverter.IntersectNormals(p1, n1, p2, n2); + Assert.False(center.IsValid()); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~EllipseConverterTests" --no-build 2>&1 | head -5` +Expected: Build error — `EllipseConverter` does not exist. + +- [ ] **Step 3: Write EllipseConverter with evaluation methods** + +Create `OpenNest.Core/Geometry/EllipseConverter.cs`: + +```csharp +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 + // s*n1.x - t*n2.x = p2.x - p1.x + // s*n1.y - t*n2.y = p2.y - p1.y + 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); + } + } +} +``` + +- [ ] **Step 4: Build and run tests** + +Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~EllipseConverterTests" -v minimal` +Expected: All 8 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Core/Geometry/EllipseConverter.cs OpenNest.Tests/EllipseConverterTests.cs +git commit -m "feat: add EllipseConverter evaluation helpers with tests" +``` + +--- + +### Task 2: EllipseConverter — Arc Fitting and Convert Method + +**Files:** +- Modify: `OpenNest.Core/Geometry/EllipseConverter.cs` +- Modify: `OpenNest.Tests/EllipseConverterTests.cs` + +- [ ] **Step 1: Write failing tests for Convert method** + +Add to `EllipseConverterTests.cs`: + +```csharp +[Fact] +public void Convert_Circle_ProducesOneOrTwoArcs() +{ + // A circle is an ellipse with ratio=1 — should produce minimal arcs + var result = EllipseConverter.Convert( + new Vector(0, 0), semiMajor: 10, semiMinor: 10, rotation: 0, + startParam: 0, endParam: Angle.TwoPI, tolerance: 0.001); + + Assert.All(result, e => Assert.IsType(e)); + Assert.InRange(result.Count, 1, 4); +} + +[Fact] +public void Convert_ModerateEllipse_AllArcsWithinTolerance() +{ + var a = 10.0; + var b = 7.0; + var tolerance = 0.001; + var result = EllipseConverter.Convert( + new Vector(0, 0), a, b, rotation: 0, + startParam: 0, endParam: Angle.TwoPI, tolerance: tolerance); + + Assert.True(result.Count >= 4, $"Expected at least 4 arcs, got {result.Count}"); + Assert.All(result, e => Assert.IsType(e)); + + // Verify each arc stays within tolerance of the ellipse + foreach (var entity in result) + { + var arc = (Arc)entity; + var maxDev = MaxDeviationFromEllipse(arc, new Vector(0, 0), a, b, 0, 50); + Assert.True(maxDev <= tolerance, + $"Arc at center ({arc.Center.X:F4},{arc.Center.Y:F4}) r={arc.Radius:F4} " + + $"deviates {maxDev:F6} from ellipse (tolerance={tolerance})"); + } +} + +[Fact] +public void Convert_HighlyEccentricEllipse_ProducesMoreArcs() +{ + var a = 20.0; + var b = 3.0; + var tolerance = 0.001; + var result = EllipseConverter.Convert( + new Vector(0, 0), a, b, rotation: 0, + startParam: 0, endParam: Angle.TwoPI, tolerance: tolerance); + + Assert.True(result.Count >= 8, $"Expected at least 8 arcs for eccentric ellipse, got {result.Count}"); + Assert.All(result, e => Assert.IsType(e)); + + foreach (var entity in result) + { + var arc = (Arc)entity; + var maxDev = MaxDeviationFromEllipse(arc, new Vector(0, 0), a, b, 0, 50); + Assert.True(maxDev <= tolerance, + $"Deviation {maxDev:F6} exceeds tolerance {tolerance}"); + } +} + +[Fact] +public void Convert_PartialEllipse_CoversArcOnly() +{ + var a = 10.0; + var b = 5.0; + var tolerance = 0.001; + // Quarter ellipse + var result = EllipseConverter.Convert( + new Vector(0, 0), a, b, rotation: 0, + startParam: 0, endParam: System.Math.PI / 2, tolerance: tolerance); + + Assert.NotEmpty(result); + Assert.All(result, e => Assert.IsType(e)); + + // First arc should start near (a, 0) + var firstArc = (Arc)result[0]; + var sp = firstArc.StartPoint(); + Assert.InRange(sp.X, a - 0.01, a + 0.01); + Assert.InRange(sp.Y, -0.01, 0.01); + + // Last arc should end near (0, b) + var lastArc = (Arc)result[^1]; + var ep = lastArc.EndPoint(); + Assert.InRange(ep.X, -0.01, 0.01); + Assert.InRange(ep.Y, b - 0.01, b + 0.01); +} + +[Fact] +public void Convert_EndpointContinuity_ArcsConnect() +{ + var result = EllipseConverter.Convert( + new Vector(5, 10), semiMajor: 15, semiMinor: 8, rotation: 0.5, + startParam: 0, endParam: Angle.TwoPI, tolerance: 0.001); + + for (var i = 0; i < result.Count - 1; i++) + { + var current = (Arc)result[i]; + var next = (Arc)result[i + 1]; + var gap = current.EndPoint().DistanceTo(next.StartPoint()); + Assert.True(gap < 0.001, + $"Gap of {gap:F6} between arc {i} and arc {i + 1}"); + } + + // Closed ellipse: last arc should connect back to first + var lastArc = (Arc)result[^1]; + var firstArc = (Arc)result[0]; + var closingGap = lastArc.EndPoint().DistanceTo(firstArc.StartPoint()); + Assert.True(closingGap < 0.001, + $"Closing gap of {closingGap:F6}"); +} + +[Fact] +public void Convert_WithRotationAndOffset_ProducesValidArcs() +{ + var center = new Vector(50, -30); + var rotation = System.Math.PI / 3; // 60 degrees + var a = 12.0; + var b = 6.0; + var tolerance = 0.001; + + var result = EllipseConverter.Convert(center, a, b, rotation, + startParam: 0, endParam: Angle.TwoPI, tolerance: tolerance); + + Assert.NotEmpty(result); + foreach (var entity in result) + { + var arc = (Arc)entity; + var maxDev = MaxDeviationFromEllipse(arc, center, a, b, rotation, 50); + Assert.True(maxDev <= tolerance, + $"Deviation {maxDev:F6} exceeds tolerance {tolerance}"); + } +} + +private static double MaxDeviationFromEllipse(Arc arc, Vector ellipseCenter, + double semiMajor, double semiMinor, double rotation, int samples) +{ + // Find which parameter range this arc covers by checking start/end points + var sp = arc.StartPoint(); + var ep = arc.EndPoint(); + + var maxDev = 0.0; + + // Sample points along the arc and measure distance to nearest ellipse point + var sweep = arc.SweepAngle(); + var startAngle = arc.StartAngle; + if (arc.IsReversed) + startAngle = arc.EndAngle; + + for (var i = 0; i <= samples; i++) + { + var frac = (double)i / samples; + var angle = startAngle + frac * sweep; + var px = arc.Center.X + arc.Radius * System.Math.Cos(angle); + var py = arc.Center.Y + arc.Radius * System.Math.Sin(angle); + var arcPoint = new Vector(px, py); + + // Find closest point on ellipse by searching parameter space + var minDist = double.MaxValue; + for (var j = 0; j <= 200; j++) + { + var t = (double)j / 200 * Angle.TwoPI; + var ep2 = EllipseConverter.EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t); + var dist = arcPoint.DistanceTo(ep2); + if (dist < minDist) minDist = dist; + } + if (minDist > maxDev) maxDev = minDist; + } + + return maxDev; +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~EllipseConverterTests" --no-build 2>&1 | head -5` +Expected: Build error — `EllipseConverter.Convert` does not exist. + +- [ ] **Step 3: Implement Convert method and helpers** + +Add the following methods to `EllipseConverter.cs`, inside the class body after the existing `IntersectNormals` method: + +```csharp +public static List Convert(Vector center, double semiMajor, double semiMinor, + double rotation, double startParam, double endParam, double tolerance = 0.001) +{ + if (endParam <= startParam) + endParam += Angle.TwoPI; + + // True circle — emit a single arc (or two for full circle) + if (System.Math.Abs(semiMajor - semiMinor) < Tolerance.Epsilon) + return ConvertCircle(center, semiMajor, rotation, startParam, endParam); + + // Compute initial split parameters at curvature extremes within range + var splits = GetInitialSplits(startParam, endParam); + + var entities = new List(); + for (var i = 0; i < splits.Count - 1; i++) + FitSegment(center, semiMajor, semiMinor, rotation, + splits[i], splits[i + 1], tolerance, entities, 0); + + return entities; +} + +private static List ConvertCircle(Vector center, double radius, + double rotation, double startParam, double endParam) +{ + var sweep = endParam - startParam; + var isFull = System.Math.Abs(sweep - Angle.TwoPI) < 0.01; + + if (isFull) + { + // Full circle: two semicircular arcs (a single arc can't represent 360 degrees) + var startAngle1 = Angle.NormalizeRad(startParam + rotation); + var midAngle = Angle.NormalizeRad(startParam + System.Math.PI + rotation); + var endAngle2 = startAngle1; // wraps back + + return new List + { + new Arc(center, radius, startAngle1, midAngle, false), + new Arc(center, radius, midAngle, endAngle2, false) + }; + } + + var sa = Angle.NormalizeRad(startParam + rotation); + var ea = Angle.NormalizeRad(endParam + rotation); + return new List { new Arc(center, radius, sa, ea, false) }; +} + +private static List GetInitialSplits(double startParam, double endParam) +{ + var splits = new List { startParam }; + + // Add quadrant boundaries (curvature extremes) that fall within range + // These occur at multiples of PI/2 + var firstQuadrant = System.Math.Ceiling(startParam / (System.Math.PI / 2)) * (System.Math.PI / 2); + for (var q = firstQuadrant; q < endParam; q += System.Math.PI / 2) + { + if (q > startParam + 1e-10 && q < endParam - 1e-10) + splits.Add(q); + } + + splits.Add(endParam); + return splits; +} + +private static void FitSegment(Vector center, double semiMajor, double semiMinor, + double rotation, double t0, double t1, double tolerance, List results, int depth) +{ + var p0 = EvaluatePoint(semiMajor, semiMinor, rotation, center, t0); + var p1 = EvaluatePoint(semiMajor, semiMinor, rotation, center, t1); + + // Skip tiny segments + if (p0.DistanceTo(p1) < 1e-10) + return; + + var n0 = EvaluateNormal(semiMajor, semiMinor, rotation, t0); + var n1 = EvaluateNormal(semiMajor, semiMinor, rotation, t1); + + var arcCenter = IntersectNormals(p0, n0, p1, n1); + + if (!arcCenter.IsValid() || depth >= MaxSubdivisionDepth) + { + // Normals parallel (straight segment) or max depth reached + results.Add(new Line(p0, p1)); + return; + } + + var radius = p0.DistanceTo(arcCenter); + var maxDev = MeasureDeviation(center, semiMajor, semiMinor, rotation, + t0, t1, arcCenter, radius); + + if (maxDev <= tolerance) + { + results.Add(CreateArc(arcCenter, radius, center, semiMajor, semiMinor, rotation, t0, t1)); + } + else + { + var tMid = (t0 + t1) / 2.0; + FitSegment(center, semiMajor, semiMinor, rotation, t0, tMid, tolerance, results, depth + 1); + FitSegment(center, semiMajor, semiMinor, rotation, tMid, t1, tolerance, results, depth + 1); + } +} + +private static double MeasureDeviation(Vector center, double semiMajor, double semiMinor, + double rotation, double t0, double t1, Vector arcCenter, double radius) +{ + var maxDev = 0.0; + for (var i = 1; i < DeviationSamples; i++) + { + var t = t0 + (t1 - t0) * i / DeviationSamples; + var p = EvaluatePoint(semiMajor, semiMinor, rotation, center, t); + var dist = p.DistanceTo(arcCenter); + var dev = System.Math.Abs(dist - radius); + if (dev > maxDev) maxDev = dev; + } + return maxDev; +} + +private static Arc CreateArc(Vector arcCenter, double radius, + Vector ellipseCenter, double semiMajor, double semiMinor, double rotation, + double t0, double t1) +{ + var p0 = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t0); + var p1 = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t1); + + var startAngle = System.Math.Atan2(p0.Y - arcCenter.Y, p0.X - arcCenter.X); + var endAngle = System.Math.Atan2(p1.Y - arcCenter.Y, p1.X - arcCenter.X); + + // Determine direction: sample a midpoint and check signed angles + var pMid = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, (t0 + t1) / 2); + var points = new List { p0, pMid, p1 }; + var isReversed = SumSignedAngles(arcCenter, points) < 0; + + if (startAngle < 0) startAngle += Angle.TwoPI; + if (endAngle < 0) endAngle += Angle.TwoPI; + + return new Arc(arcCenter, radius, startAngle, endAngle, isReversed); +} + +private static double SumSignedAngles(Vector center, List 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; +} +``` + +- [ ] **Step 4: Build and run tests** + +Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~EllipseConverterTests" -v minimal` +Expected: All tests pass. If any tolerance tests fail, adjust `DeviationSamples` upward or debug the specific case. + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Core/Geometry/EllipseConverter.cs OpenNest.Tests/EllipseConverterTests.cs +git commit -m "feat: add EllipseConverter arc fitting with normal-constrained G1 continuity" +``` + +--- + +### Task 3: SplineConverter — Tangent-Chained Arc Fitting + +**Files:** +- Create: `OpenNest.Core/Geometry/SplineConverter.cs` +- Create: `OpenNest.Tests/SplineConverterTests.cs` + +- [ ] **Step 1: Write failing tests** + +```csharp +using OpenNest.Geometry; +using OpenNest.Math; +using Xunit; + +namespace OpenNest.Tests; + +public class SplineConverterTests +{ + [Fact] + public void Convert_SemicirclePoints_ProducesSingleArc() + { + // Generate 50 points on a semicircle of radius 10 + var points = new System.Collections.Generic.List(); + for (var i = 0; i <= 50; i++) + { + var t = System.Math.PI * i / 50; + points.Add(new Vector(10 * System.Math.Cos(t), 10 * System.Math.Sin(t))); + } + + var result = SplineConverter.Convert(points, isClosed: false, tolerance: 0.001); + + Assert.Single(result); + Assert.IsType(result[0]); + var arc = (Arc)result[0]; + Assert.InRange(arc.Radius, 9.99, 10.01); + } + + [Fact] + public void Convert_StraightLinePoints_ProducesSingleLine() + { + // 10 collinear points + var points = new System.Collections.Generic.List(); + for (var i = 0; i <= 10; i++) + points.Add(new Vector(i, 2 * i + 1)); + + var result = SplineConverter.Convert(points, isClosed: false, tolerance: 0.001); + + // Should produce lines, not arcs (collinear points can't form an arc) + Assert.All(result, e => Assert.IsType(e)); + } + + [Fact] + public void Convert_SCurve_ProducesMultipleArcs() + { + // S-curve: semicircle up, then semicircle down + var points = new System.Collections.Generic.List(); + // First half: arc centered at (0,0) radius 10, going 0 to PI + for (var i = 0; i <= 30; i++) + { + var t = System.Math.PI * i / 30; + points.Add(new Vector(10 * System.Math.Cos(t), 10 * System.Math.Sin(t))); + } + // Second half: arc centered at (-20,0) radius 10, going 0 to -PI + for (var i = 1; i <= 30; i++) + { + var t = -System.Math.PI * i / 30; + points.Add(new Vector(-20 + 10 * System.Math.Cos(t), 10 * System.Math.Sin(t))); + } + + var result = SplineConverter.Convert(points, isClosed: false, tolerance: 0.001); + + // Should produce at least 2 arcs (one for each half) + var arcCount = result.Count(e => e is Arc); + Assert.True(arcCount >= 2, $"Expected at least 2 arcs, got {arcCount}"); + } + + [Fact] + public void Convert_TwoPoints_ProducesSingleLine() + { + var points = new System.Collections.Generic.List + { + new Vector(0, 0), + new Vector(10, 5) + }; + + var result = SplineConverter.Convert(points, isClosed: false, tolerance: 0.001); + + Assert.Single(result); + Assert.IsType(result[0]); + } + + [Fact] + public void Convert_EndpointContinuity_EntitiesConnect() + { + // Full circle of points + var points = new System.Collections.Generic.List(); + for (var i = 0; i <= 80; i++) + { + var t = Angle.TwoPI * i / 80; + // Ellipse-like shape: different X and Y radii + points.Add(new Vector(15 * System.Math.Cos(t), 8 * System.Math.Sin(t))); + } + + var result = SplineConverter.Convert(points, isClosed: false, tolerance: 0.001); + + for (var i = 0; i < result.Count - 1; i++) + { + var endPt = GetEndPoint(result[i]); + var startPt = GetStartPoint(result[i + 1]); + var gap = endPt.DistanceTo(startPt); + Assert.True(gap < 0.001, + $"Gap of {gap:F6} between entity {i} and {i + 1}"); + } + } + + [Fact] + public void Convert_EmptyPoints_ReturnsEmpty() + { + var result = SplineConverter.Convert(new System.Collections.Generic.List(), + isClosed: false, tolerance: 0.001); + Assert.Empty(result); + } + + [Fact] + public void Convert_SinglePoint_ReturnsEmpty() + { + var points = new System.Collections.Generic.List { new Vector(5, 5) }; + var result = SplineConverter.Convert(points, isClosed: false, tolerance: 0.001); + Assert.Empty(result); + } + + private static Vector GetStartPoint(Entity e) + { + return e switch + { + Arc a => a.StartPoint(), + Line l => l.StartPoint, + _ => throw new System.Exception("Unexpected entity type") + }; + } + + private static Vector GetEndPoint(Entity e) + { + return e switch + { + Arc a => a.EndPoint(), + Line l => l.EndPoint, + _ => throw new System.Exception("Unexpected entity type") + }; + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~SplineConverterTests" --no-build 2>&1 | head -5` +Expected: Build error — `SplineConverter` does not exist. + +- [ ] **Step 3: Implement SplineConverter** + +Create `OpenNest.Core/Geometry/SplineConverter.cs`: + +```csharp +using OpenNest.Math; +using System; +using System.Collections.Generic; + +namespace OpenNest.Geometry +{ + public static class SplineConverter + { + private const int MinPointsForArc = 3; + + public static List Convert(List points, bool isClosed, double tolerance = 0.001) + { + if (points == null || points.Count < 2) + return new List(); + + var entities = new List(); + var i = 0; + var chainedTangent = Vector.Invalid; + + while (i < points.Count - 1) + { + var result = TryFitArc(points, i, chainedTangent, tolerance); + if (result != null) + { + entities.Add(result.Arc); + chainedTangent = result.EndTangent; + i = result.EndIndex; + } + else + { + // Can't fit an arc here — emit a line and reset tangent chain + entities.Add(new Line(points[i], points[i + 1])); + chainedTangent = Vector.Invalid; + i++; + } + } + + return entities; + } + + private static ArcFitResult TryFitArc(List points, int start, + Vector chainedTangent, double tolerance) + { + // Need at least 3 points for an arc + var minEnd = start + MinPointsForArc - 1; + if (minEnd >= points.Count) + return null; + + // Compute start tangent: use chained tangent if available, otherwise from first two points + var tangent = chainedTangent.IsValid() + ? chainedTangent + : new Vector(points[start + 1].X - points[start].X, + points[start + 1].Y - points[start].Y); + + // Try fitting with minimum points first + var subPoints = points.GetRange(start, MinPointsForArc); + var (center, radius, dev) = FitWithStartTangent(subPoints, tangent); + if (!center.IsValid() || dev > tolerance) + return null; + + // Extend the arc as far as possible + var endIdx = minEnd; + while (endIdx + 1 < points.Count) + { + var extPoints = points.GetRange(start, endIdx + 1 - start + 1); + var (nc, nr, nd) = FitWithStartTangent(extPoints, tangent); + if (!nc.IsValid() || nd > tolerance) + break; + + endIdx++; + center = nc; + radius = nr; + dev = nd; + } + + // Reject tiny arcs (nearly-straight segments that happen to fit a huge circle) + var finalPoints = points.GetRange(start, endIdx - start + 1); + var sweep = System.Math.Abs(SumSignedAngles(center, finalPoints)); + if (sweep < Angle.ToRadians(5)) + return null; + + var arc = CreateArc(center, radius, finalPoints); + var endTangent = ComputeEndTangent(center, finalPoints); + + return new ArcFitResult(arc, endTangent, endIdx); + } + + private static (Vector center, double radius, double deviation) FitWithStartTangent( + List points, Vector tangent) + { + if (points.Count < 3) + return (Vector.Invalid, 0, double.MaxValue); + + var p1 = points[0]; + var pn = points[^1]; + + // Perpendicular bisector of chord P1->Pn + var mx = (p1.X + pn.X) / 2; + var my = (p1.Y + pn.Y) / 2; + var dx = pn.X - p1.X; + var dy = pn.Y - p1.Y; + var chordLen = System.Math.Sqrt(dx * dx + dy * dy); + if (chordLen < 1e-10) + return (Vector.Invalid, 0, double.MaxValue); + + var bx = -dy / chordLen; + var by = dx / chordLen; + + // Normal at P1 (perpendicular to tangent) + var tLen = System.Math.Sqrt(tangent.X * tangent.X + tangent.Y * tangent.Y); + if (tLen < 1e-10) + return (Vector.Invalid, 0, double.MaxValue); + + var nx = -tangent.Y / tLen; + var ny = tangent.X / tLen; + + // Intersect: P1 + s*N = midpoint + t*bisector + var det = nx * by - ny * bx; + if (System.Math.Abs(det) < 1e-10) + return (Vector.Invalid, 0, double.MaxValue); + + var s = ((mx - p1.X) * by - (my - p1.Y) * bx) / det; + + var cx = p1.X + s * nx; + var cy = p1.Y + s * ny; + var radius = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y)); + + if (radius < 1e-10) + return (Vector.Invalid, 0, double.MaxValue); + + return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius)); + } + + private static double MaxRadialDeviation(List points, double cx, double cy, double radius) + { + var maxDev = 0.0; + for (var i = 1; i < points.Count - 1; i++) + { + var px = points[i].X - cx; + var py = points[i].Y - cy; + var dist = System.Math.Sqrt(px * px + py * py); + var dev = System.Math.Abs(dist - radius); + if (dev > maxDev) maxDev = dev; + } + return maxDev; + } + + private static double SumSignedAngles(Vector center, List 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 points) + { + var lastPt = points[^1]; + var totalAngle = SumSignedAngles(center, points); + + var rx = lastPt.X - center.X; + var ry = lastPt.Y - center.Y; + + return totalAngle >= 0 + ? new Vector(-ry, rx) + : new Vector(ry, -rx); + } + + private static Arc CreateArc(Vector center, double radius, List points) + { + 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); + var isReversed = SumSignedAngles(center, points) < 0; + + if (startAngle < 0) startAngle += Angle.TwoPI; + if (endAngle < 0) endAngle += Angle.TwoPI; + + return new Arc(center, radius, startAngle, endAngle, isReversed); + } + + private sealed class ArcFitResult + { + public Arc Arc { get; } + public Vector EndTangent { get; } + public int EndIndex { get; } + + public ArcFitResult(Arc arc, Vector endTangent, int endIndex) + { + Arc = arc; + EndTangent = endTangent; + EndIndex = endIndex; + } + } + } +} +``` + +- [ ] **Step 4: Build and run tests** + +Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~SplineConverterTests" -v minimal` +Expected: All 7 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Core/Geometry/SplineConverter.cs OpenNest.Tests/SplineConverterTests.cs +git commit -m "feat: add SplineConverter with tangent-chained arc fitting" +``` + +--- + +### Task 4: Wire Up Extensions.cs and DxfImporter.cs + +**Files:** +- Modify: `OpenNest.IO/Extensions.cs:59-95` (spline method) +- Modify: `OpenNest.IO/Extensions.cs:175-239` (ellipse method) +- Modify: `OpenNest.IO/DxfImporter.cs:47-61` (spline and ellipse cases) + +- [ ] **Step 1: Modify the spline extension method to use SplineConverter** + +In `OpenNest.IO/Extensions.cs`, replace the `ToOpenNest(this Spline spline)` method (lines 59-95) with: + +```csharp +public static List ToOpenNest(this Spline spline, int precision) +{ + var layer = spline.Layer.ToOpenNest(); + var color = spline.ResolveColor(); + var lineTypeName = spline.ResolveLineTypeName(); + + // Evaluate actual points on the spline curve (not control points) + List curvePoints; + if (!spline.TryPolygonalVertexes(precision > 0 ? precision : 200, out curvePoints) + || curvePoints == null || curvePoints.Count < 2) + { + // Fallback: use control points if evaluation fails + curvePoints = new List(spline.ControlPoints); + if (curvePoints.Count < 2) + return new List(); + } + + var points = new List(curvePoints.Count); + foreach (var pt in curvePoints) + points.Add(pt.ToOpenNest()); + + var entities = SplineConverter.Convert(points, spline.IsClosed, tolerance: 0.001); + + foreach (var entity in entities) + { + entity.Layer = layer; + entity.Color = color; + entity.LineTypeName = lineTypeName; + } + + return entities; +} + +**Note:** `Geometry.Entity` prefix is required because `Extensions.cs` imports both `ACadSharp.Entities` and `OpenNest.Geometry`, making the bare `Entity` name ambiguous. This follows the existing pattern in the file (e.g., `Geometry.Arc`, `Geometry.Line`). +``` + +- [ ] **Step 2: Modify the ellipse extension method to use EllipseConverter** + +In `OpenNest.IO/Extensions.cs`, replace the `ToOpenNest(this Ellipse ellipse, int precision)` method (lines 175-239) with: + +```csharp +public static List ToOpenNest(this ACadSharp.Entities.Ellipse ellipse, double tolerance = 0.001) +{ + var center = new Vector(ellipse.Center.X, ellipse.Center.Y); + var majorAxis = new Vector(ellipse.MajorAxisEndPoint.X, ellipse.MajorAxisEndPoint.Y); + var semiMajor = System.Math.Sqrt(majorAxis.X * majorAxis.X + majorAxis.Y * majorAxis.Y); + var semiMinor = semiMajor * ellipse.RadiusRatio; + var rotation = System.Math.Atan2(majorAxis.Y, majorAxis.X); + + var startParam = ellipse.StartParameter; + var endParam = ellipse.EndParameter; + + var layer = ellipse.Layer.ToOpenNest(); + var color = ellipse.ResolveColor(); + var lineTypeName = ellipse.ResolveLineTypeName(); + + var entities = EllipseConverter.Convert(center, semiMajor, semiMinor, rotation, + startParam, endParam, tolerance); + + foreach (var entity in entities) + { + entity.Layer = layer; + entity.Color = color; + entity.LineTypeName = lineTypeName; + } + + return entities; +} +``` + +- [ ] **Step 3: Update DxfImporter.cs to handle mixed entity returns** + +In `OpenNest.IO/DxfImporter.cs`, modify the spline and ellipse cases in `GetGeometry()` (lines 47-61): + +Replace the spline case (lines 47-49). Note: `DxfImporter.cs` only imports `OpenNest.Geometry` (no `ACadSharp.Entities`), so `Line` and `Arc` are unambiguous here: +```csharp +case ACadSharp.Entities.Spline spline: + foreach (var e in spline.ToOpenNest(SplinePrecision)) + { + if (e is Line l) lines.Add(l); + else if (e is Arc a) arcs.Add(a); + } + break; +``` + +Replace the ellipse case (lines 59-61): +```csharp +case ACadSharp.Entities.Ellipse ellipse: + foreach (var e in ellipse.ToOpenNest()) + { + if (e is Line l) lines.Add(l); + else if (e is Arc a) arcs.Add(a); + } + break; +``` + +- [ ] **Step 4: Build the solution** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeds with no errors. There may be warnings about the unused `SplinePrecision` on the ellipse path — that's fine, the property is still used for spline precision. + +- [ ] **Step 5: Run all tests** + +Run: `dotnet test OpenNest.Tests -v minimal` +Expected: All tests pass, including the existing `GeometrySimplifierTests` and `DxfRoundtripTests`. + +- [ ] **Step 6: Commit** + +```bash +git add OpenNest.IO/Extensions.cs OpenNest.IO/DxfImporter.cs +git commit -m "feat: wire up EllipseConverter and SplineConverter in DXF import pipeline" +``` + +--- + +### Task 5: Integration Test with Real DXF + +**Files:** +- Modify: `OpenNest.Tests/EllipseConverterTests.cs` (add integration-level test) + +The project has test DXF files in `OpenNest.Tests/Bending/TestData/`. We can also create a programmatic integration test. + +- [ ] **Step 1: Write integration test** + +Add to `EllipseConverterTests.cs`: + +```csharp +[Fact] +public void DxfImporter_EllipseInDxf_ProducesArcsNotLines() +{ + // Create a DXF in memory with an ellipse and verify import produces arcs + var doc = new ACadSharp.CadDocument(); + var ellipse = new ACadSharp.Entities.Ellipse + { + Center = new CSMath.XYZ(0, 0, 0), + MajorAxisEndPoint = new CSMath.XYZ(10, 0, 0), + RadiusRatio = 0.6, + StartParameter = 0, + EndParameter = System.Math.PI * 2 + }; + doc.Entities.Add(ellipse); + + // Write to temp file and re-import + var tempPath = System.IO.Path.GetTempFileName() + ".dxf"; + try + { + using (var writer = new ACadSharp.IO.DxfWriter(tempPath, doc, false)) + writer.Write(); + + var importer = new OpenNest.IO.DxfImporter { SplinePrecision = 200 }; + var result = importer.Import(tempPath); + + var arcCount = result.Entities.Count(e => e is Arc); + var lineCount = result.Entities.Count(e => e is Line); + + // Should have arcs, not hundreds of lines + Assert.True(arcCount >= 4, $"Expected at least 4 arcs, got {arcCount}"); + Assert.Equal(0, lineCount); + } + finally + { + System.IO.File.Delete(tempPath); + } +} +``` + +- [ ] **Step 2: Run integration test** + +Run: `dotnet test OpenNest.Tests --filter "DxfImporter_EllipseInDxf_ProducesArcsNotLines" -v minimal` +Expected: Test passes. If ACadSharp has issues creating/writing the ellipse entity programmatically, adjust the test to use a simpler verification approach (call `EllipseConverter.Convert` directly with parameters matching a real-world ellipse and verify entity count and types). + +- [ ] **Step 3: Run full test suite** + +Run: `dotnet test OpenNest.Tests -v minimal` +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Tests/EllipseConverterTests.cs +git commit -m "test: add DXF import integration test for ellipse-to-arc conversion" +``` + +--- + +### Task 6: Final Verification and Cleanup + +**Files:** +- Review: All modified/created files + +- [ ] **Step 1: Verify build with no warnings** + +Run: `dotnet build OpenNest.sln -warnaserror 2>&1 | tail -5` +Expected: Build succeeds. Fix any warnings (e.g., unused usings, nullable warnings). + +- [ ] **Step 2: Run full test suite one final time** + +Run: `dotnet test OpenNest.Tests -v minimal` +Expected: All tests pass. + +- [ ] **Step 3: Verify no regressions in existing DXF roundtrip tests** + +Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~DxfRoundtrip" -v normal` +Expected: All existing roundtrip tests pass. + +- [ ] **Step 4: Commit any cleanup** + +Only if there were warnings or minor fixes from steps 1-3.