feat: add EllipseConverter arc fitting with normal-constrained G1 continuity
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -63,5 +63,150 @@ namespace OpenNest.Geometry
|
||||
|
||||
return new Vector(p1.X + s * n1.X, p1.Y + s * n1.Y);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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)
|
||||
{
|
||||
var startAngle1 = Angle.NormalizeRad(startParam + rotation);
|
||||
var midAngle = Angle.NormalizeRad(startParam + System.Math.PI + rotation);
|
||||
var endAngle2 = startAngle1;
|
||||
|
||||
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 };
|
||||
|
||||
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);
|
||||
|
||||
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)
|
||||
{
|
||||
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);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,4 +78,175 @@ public class EllipseConverterTests
|
||||
var center = EllipseConverter.IntersectNormals(p1, n1, p2, n2);
|
||||
Assert.False(center.IsValid());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Convert_Circle_ProducesOneOrTwoArcs()
|
||||
{
|
||||
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));
|
||||
|
||||
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;
|
||||
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));
|
||||
|
||||
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);
|
||||
|
||||
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}");
|
||||
}
|
||||
|
||||
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;
|
||||
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)
|
||||
{
|
||||
var maxDev = 0.0;
|
||||
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);
|
||||
|
||||
// Coarse search over 1000 samples
|
||||
var bestT = 0.0;
|
||||
var minDist = double.MaxValue;
|
||||
for (var j = 0; j <= 1000; j++)
|
||||
{
|
||||
var t = (double)j / 1000 * Angle.TwoPI;
|
||||
var ep2 = EllipseConverter.EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t);
|
||||
var dist = arcPoint.DistanceTo(ep2);
|
||||
if (dist < minDist) { minDist = dist; bestT = t; }
|
||||
}
|
||||
|
||||
// Refine with local bisection around bestT
|
||||
var lo = bestT - Angle.TwoPI / 1000;
|
||||
var hi = bestT + Angle.TwoPI / 1000;
|
||||
for (var r = 0; r < 20; r++)
|
||||
{
|
||||
var t1 = lo + (hi - lo) / 3;
|
||||
var t2 = lo + 2 * (hi - lo) / 3;
|
||||
var d1 = arcPoint.DistanceTo(EllipseConverter.EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t1));
|
||||
var d2 = arcPoint.DistanceTo(EllipseConverter.EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t2));
|
||||
if (d1 < d2) hi = t2; else lo = t1;
|
||||
}
|
||||
var bestDist = arcPoint.DistanceTo(EllipseConverter.EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, (lo + hi) / 2));
|
||||
if (bestDist > maxDev) maxDev = bestDist;
|
||||
}
|
||||
|
||||
return maxDev;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user