feat: dual-tangent arc fitting and DXF version export
Add ArcFit.FitWithDualTangent to constrain replacement arcs to match tangent directions at both endpoints, preventing kinks without introducing gaps. Add DXF year selection to CAD converter export. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -56,6 +56,60 @@ namespace OpenNest.Geometry
|
||||
return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fits a circular arc constrained to be tangent to the given directions at both
|
||||
/// the first and last points. The center lies at the intersection of the normals
|
||||
/// at P1 and Pn, guaranteeing the arc departs P1 in the start direction and arrives
|
||||
/// at Pn in the end direction. Uses the radius from P1 (exact start tangent);
|
||||
/// deviation includes any endpoint gap at Pn.
|
||||
/// </summary>
|
||||
internal static (Vector center, double radius, double deviation) FitWithDualTangent(
|
||||
List<Vector> points, Vector startTangent, Vector endTangent)
|
||||
{
|
||||
if (points.Count < 3)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var p1 = points[0];
|
||||
var pn = points[^1];
|
||||
|
||||
var stLen = System.Math.Sqrt(startTangent.X * startTangent.X + startTangent.Y * startTangent.Y);
|
||||
var etLen = System.Math.Sqrt(endTangent.X * endTangent.X + endTangent.Y * endTangent.Y);
|
||||
if (stLen < 1e-10 || etLen < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
// Normal to start tangent at P1 (perpendicular)
|
||||
var n1x = -startTangent.Y / stLen;
|
||||
var n1y = startTangent.X / stLen;
|
||||
|
||||
// Normal to end tangent at Pn
|
||||
var n2x = -endTangent.Y / etLen;
|
||||
var n2y = endTangent.X / etLen;
|
||||
|
||||
// Solve: P1 + t1*N1 = Pn + t2*N2
|
||||
var det = n1x * (-n2y) - (-n2x) * n1y;
|
||||
if (System.Math.Abs(det) < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var dx = pn.X - p1.X;
|
||||
var dy = pn.Y - p1.Y;
|
||||
var t1 = (dx * (-n2y) - (-n2x) * dy) / det;
|
||||
|
||||
var cx = p1.X + t1 * n1x;
|
||||
var cy = p1.Y + t1 * n1y;
|
||||
|
||||
// Use radius from P1 (guarantees exact start tangent and passes through P1)
|
||||
var r1 = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y));
|
||||
if (r1 < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
// Measure endpoint gap at Pn
|
||||
var r2 = System.Math.Sqrt((cx - pn.X) * (cx - pn.X) + (cy - pn.Y) * (cy - pn.Y));
|
||||
var endpointDev = System.Math.Abs(r2 - r1);
|
||||
|
||||
var interiorDev = MaxRadialDeviation(points, cx, cy, r1);
|
||||
return (new Vector(cx, cy), r1, System.Math.Max(endpointDev, interiorDev));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the maximum radial deviation of interior points from a circle.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
@@ -127,4 +130,52 @@ public class GeometrySimplifierTests
|
||||
// Index 6 is the fitted arc replacing the second run
|
||||
Assert.IsType<Arc>(result.Entities[6]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_DynaPanDxf_NoGapsAfterSimplification()
|
||||
{
|
||||
var path = @"C:\Users\aisaacs\Desktop\Sullys Q29 DXFs\SULLYS-031 Dyna Pan.dxf";
|
||||
if (!File.Exists(path))
|
||||
return; // skip if file not available
|
||||
|
||||
var importer = new DxfImporter();
|
||||
var result = importer.Import(path);
|
||||
var shapes = ShapeBuilder.GetShapes(result.Entities);
|
||||
|
||||
var simplifier = new GeometrySimplifier { Tolerance = 0.004 };
|
||||
|
||||
foreach (var shape in shapes)
|
||||
{
|
||||
var candidates = simplifier.Analyze(shape);
|
||||
if (candidates.Count == 0) continue;
|
||||
|
||||
var simplified = simplifier.Apply(shape, candidates);
|
||||
|
||||
// Check for gaps between consecutive entities
|
||||
for (var i = 0; i < simplified.Entities.Count - 1; i++)
|
||||
{
|
||||
var current = simplified.Entities[i];
|
||||
var next = simplified.Entities[i + 1];
|
||||
|
||||
var currentEnd = current switch
|
||||
{
|
||||
Line l => l.EndPoint,
|
||||
Arc a => a.EndPoint(),
|
||||
_ => Vector.Invalid
|
||||
};
|
||||
var nextStart = next switch
|
||||
{
|
||||
Line l => l.StartPoint,
|
||||
Arc a => a.StartPoint(),
|
||||
_ => Vector.Invalid
|
||||
};
|
||||
|
||||
if (!currentEnd.IsValid() || !nextStart.IsValid()) continue;
|
||||
|
||||
var gap = currentEnd.DistanceTo(nextStart);
|
||||
Assert.True(gap < 0.005,
|
||||
$"Gap of {gap:F4} between entities {i} ({current.GetType().Name}) and {i + 1} ({next.GetType().Name})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,13 +494,30 @@ namespace OpenNest.Forms
|
||||
|
||||
using var dlg = new SaveFileDialog
|
||||
{
|
||||
Filter = "DXF Files|*.dxf",
|
||||
Filter = "DXF 2018 (*.dxf)|*.dxf|" +
|
||||
"DXF 2013 (*.dxf)|*.dxf|" +
|
||||
"DXF 2010 (*.dxf)|*.dxf|" +
|
||||
"DXF 2007 (*.dxf)|*.dxf|" +
|
||||
"DXF 2004 (*.dxf)|*.dxf|" +
|
||||
"DXF 2000 (*.dxf)|*.dxf|" +
|
||||
"DXF R14 (*.dxf)|*.dxf",
|
||||
FileName = Path.ChangeExtension(item.Name, ".dxf"),
|
||||
};
|
||||
|
||||
if (dlg.ShowDialog() != DialogResult.OK) return;
|
||||
|
||||
var doc = new ACadSharp.CadDocument();
|
||||
var version = dlg.FilterIndex switch
|
||||
{
|
||||
2 => ACadSharp.ACadVersion.AC1027,
|
||||
3 => ACadSharp.ACadVersion.AC1024,
|
||||
4 => ACadSharp.ACadVersion.AC1021,
|
||||
5 => ACadSharp.ACadVersion.AC1018,
|
||||
6 => ACadSharp.ACadVersion.AC1015,
|
||||
7 => ACadSharp.ACadVersion.AC1014,
|
||||
_ => ACadSharp.ACadVersion.AC1032,
|
||||
};
|
||||
|
||||
var doc = new ACadSharp.CadDocument(version);
|
||||
foreach (var entity in item.Entities)
|
||||
{
|
||||
switch (entity)
|
||||
|
||||
Reference in New Issue
Block a user