Files
OpenNest/docs/superpowers/plans/2026-03-27-direct-arc-conversion.md
2026-03-27 14:47:13 -04:00

39 KiB

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<Entity> 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

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:

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
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:

[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<Arc>(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<Arc>(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<Arc>(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<Arc>(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:

public static List<Entity> 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<Entity>();
    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<Entity> 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<Entity>
        {
            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<Entity> { new Arc(center, radius, sa, ea, false) };
}

private static List<double> GetInitialSplits(double startParam, double endParam)
{
    var splits = new List<double> { 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<Entity> 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<Vector> { 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<Vector> 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
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

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<Vector>();
        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<Arc>(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<Vector>();
        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<Line>(e));
    }

    [Fact]
    public void Convert_SCurve_ProducesMultipleArcs()
    {
        // S-curve: semicircle up, then semicircle down
        var points = new System.Collections.Generic.List<Vector>();
        // 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<Vector>
        {
            new Vector(0, 0),
            new Vector(10, 5)
        };

        var result = SplineConverter.Convert(points, isClosed: false, tolerance: 0.001);

        Assert.Single(result);
        Assert.IsType<Line>(result[0]);
    }

    [Fact]
    public void Convert_EndpointContinuity_EntitiesConnect()
    {
        // Full circle of points
        var points = new System.Collections.Generic.List<Vector>();
        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<Vector>(),
            isClosed: false, tolerance: 0.001);
        Assert.Empty(result);
    }

    [Fact]
    public void Convert_SinglePoint_ReturnsEmpty()
    {
        var points = new System.Collections.Generic.List<Vector> { 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:

using OpenNest.Math;
using System;
using System.Collections.Generic;

namespace OpenNest.Geometry
{
    public static class SplineConverter
    {
        private const int MinPointsForArc = 3;

        public static List<Entity> Convert(List<Vector> points, bool isClosed, double tolerance = 0.001)
        {
            if (points == null || points.Count < 2)
                return new List<Entity>();

            var entities = new List<Entity>();
            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<Vector> 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<Vector> 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<Vector> 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<Vector> 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<Vector> 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<Vector> 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
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:

public static List<Geometry.Entity> 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<CSMath.XYZ> 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<CSMath.XYZ>(spline.ControlPoints);
        if (curvePoints.Count < 2)
            return new List<Geometry.Entity>();
    }

    var points = new List<Vector>(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:

public static List<Geometry.Entity> 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:

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):

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
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:

[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
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.