Files
OpenNest/OpenNest.Tests/Geometry/EllipseConverterTests.cs
T

569 lines
18 KiB
C#

using System.Linq;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Math;
using Xunit;
using Xunit.Abstractions;
namespace OpenNest.Tests.Geometry;
public class EllipseConverterTests
{
private readonly ITestOutputHelper _output;
private const double Tol = 1e-10;
public EllipseConverterTests(ITestOutputHelper output) => _output = output;
[Fact]
public void EvaluatePoint_AtZero_ReturnsMajorAxisEnd()
{
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()
{
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()
{
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()
{
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()
{
var p1 = new Vector(5, 0);
var n1 = new Vector(-1, 0);
var p2 = new Vector(0, 5);
var n2 = new Vector(0, -1);
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);
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 < 1e-6, $"Gap of {gap:E4} 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 < 1e-6, $"Closing gap of {closingGap:E4}");
}
[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}"
);
}
}
[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 stream = System.IO.File.Create(tempPath))
using (var writer = new ACadSharp.IO.DxfWriter(stream, doc, false))
writer.Write();
var result = OpenNest.IO.Dxf.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
{
if (System.IO.File.Exists(tempPath))
System.IO.File.Delete(tempPath);
}
}
[Fact]
public void DxfImport_ArcBoundingBoxes_Diagnostic()
{
var path = @"C:\Users\aisaacs\Desktop\11ga tab.dxf";
if (!System.IO.File.Exists(path))
return;
var result = Dxf.Import(path);
var all = (System.Collections.Generic.IEnumerable<IBoundable>)result.Entities;
var bbox = all.GetBoundingBox();
_output.WriteLine(
$"Overall: X={bbox.X:F4} Y={bbox.Y:F4} W={bbox.Length:F4} H={bbox.Width:F4}"
);
for (var i = 0; i < result.Entities.Count; i++)
{
var e = result.Entities[i];
var b = e.BoundingBox;
var flag = (b.Length > 1 || b.Width > 1) ? " ***" : "";
_output.WriteLine(
$"{i + 1,3}. {e.GetType().Name,-8} X={b.X:F4} Y={b.Y:F4} W={b.Length:F4} H={b.Width:F4}{flag}"
);
}
}
[Fact]
public void ToOpenNest_FlippedNormalZ_ProducesCorrectArcs()
{
var normal = new ACadSharp.Entities.Ellipse
{
Center = new CSMath.XYZ(-0.275, -0.245, 0),
MajorAxisEndPoint = new CSMath.XYZ(0.0001, 1.245, 0),
RadiusRatio = 0.28,
StartParameter = 0.017,
EndParameter = 1.571,
Normal = new CSMath.XYZ(0, 0, 1),
};
var flipped = new ACadSharp.Entities.Ellipse
{
Center = new CSMath.XYZ(0.275, -0.245, 0),
MajorAxisEndPoint = new CSMath.XYZ(-0.0001, 1.245, 0),
RadiusRatio = 0.28,
StartParameter = 0.017,
EndParameter = 1.571,
Normal = new CSMath.XYZ(0, 0, -1),
};
var normalArcs = normal.ToOpenNest();
var flippedArcs = flipped.ToOpenNest();
Assert.True(normalArcs.Count > 0);
Assert.True(flippedArcs.Count > 0);
Assert.True(normalArcs.All(e => e is Arc));
Assert.True(flippedArcs.All(e => e is Arc));
var normalFirst = (Arc)normalArcs.First();
var flippedFirst = (Arc)flippedArcs.First();
var normalStart = GetArcStart(normalFirst);
var flippedStart = GetArcStart(flippedFirst);
Assert.True(
normalStart.X < 0,
$"Normal ellipse start X should be negative, got {normalStart.X}"
);
Assert.True(
flippedStart.X > 0,
$"Flipped ellipse should bulge right, got {flippedStart.X}"
);
var normalBbox = GetBoundingBox(normalArcs.Cast<Arc>());
var flippedBbox = GetBoundingBox(flippedArcs.Cast<Arc>());
Assert.True(
flippedBbox.minX > 0,
$"Flipped ellipse should stay on positive X side, minX={flippedBbox.minX}"
);
Assert.True(
normalBbox.maxX < 0,
$"Normal ellipse should stay on negative X side, maxX={normalBbox.maxX}"
);
}
// Frozen ordered output from 5bf3c5f, including both angular seams and a
// translated, rotated partial ellipse (the public API traverses parameters CCW).
[Theory]
[MemberData(nameof(SignedAngleCharacterizationCases))]
public void Convert_SignedAngleCharacterization_PreservesOrderedArcs(
string scenario, double[][] expected, bool[] reversed
)
{
var (start, end, rotation) = scenario switch
{
"atan-seam" => (2.8, 3.8, 0.0),
"positive-x-seam" => (5.8, 0.5, 0.0),
_ => (0.2, 1.3, 0.6),
};
var result = EllipseConverter.Convert(new Vector(3, -2), 6, 4, rotation,
start, end, tolerance: 0.1);
Assert.Collection(result, expected.Select((parameters, i) =>
(Action<Entity>)(entity => AssertCharacterizedArc(entity, parameters, reversed[i]))
).ToArray());
}
public static IEnumerable<object[]> SignedAngleCharacterizationCases()
{
yield return new object[]
{
"atan-seam",
new double[][]
{
new[] { -0.16603986922559466, -2.0183680896443055, 2.8340196558132877, 2.6417457217524727, 3.1351113230149354 },
new[] { -0.17776605557071123, -1.983538836846724, 2.8222819502985983, 3.1474252588278113, 3.624130294015861 },
new[] { 0.7441132153098247, -1.4836007849113604, 3.8709161624146646, 3.6280252302389693, 4.013669489810318 },
},
new[] { false, false, false },
};
yield return new object[]
{
"positive-x-seam",
new double[][]
{
new[] { 6.004270273073607, -1.9488971518180598, 2.996165565832901, 5.592171901293686, 6.26612839723762 },
new[] { 5.9818198770623745, -2.0564884222942372, 3.0187086968352994, 0.018713869149703036, 0.7128403110339223 },
},
new[] { false, false },
};
yield return new object[]
{
"rotated-partial-ellipse",
new double[][]
{
new[] { 5.1346691175944725, -1.0670403345989339, 3.79655973712418, 0.9299300117607583, 1.5841942790174177 },
new[] { 5.075469862824099, -4.265516161507367, 6.994699783814414, 1.5696047599218284, 2.002556947193732 },
},
new[] { false, false },
};
}
private static void AssertCharacterizedArc(Entity entity, double[] expected, bool reversed)
{
var arc = Assert.IsType<Arc>(entity);
var actual = new[]
{
arc.Center.X, arc.Center.Y, arc.Radius, arc.StartAngle, arc.EndAngle,
};
for (var i = 0; i < expected.Length; i++)
Assert.InRange(actual[i], expected[i] - 1e-10, expected[i] + 1e-10);
Assert.Equal(reversed, arc.IsReversed);
}
private static (double minX, double maxX) GetBoundingBox(IEnumerable<Arc> arcs)
{
var minX = double.MaxValue;
var maxX = double.MinValue;
foreach (var arc in arcs)
{
var s = GetArcStart(arc);
var e = GetArcEnd(arc);
minX = System.Math.Min(minX, System.Math.Min(s.X, e.X));
maxX = System.Math.Max(maxX, System.Math.Max(s.X, e.X));
}
return (minX, maxX);
}
private static Vector GetArcStart(Arc arc)
{
var angle = arc.IsReversed ? arc.EndAngle : arc.StartAngle;
return new Vector(
arc.Center.X + arc.Radius * System.Math.Cos(angle),
arc.Center.Y + arc.Radius * System.Math.Sin(angle)
);
}
private static Vector GetArcEnd(Arc arc)
{
var angle = arc.IsReversed ? arc.StartAngle : arc.EndAngle;
return new Vector(
arc.Center.X + arc.Radius * System.Math.Cos(angle),
arc.Center.Y + arc.Radius * System.Math.Sin(angle)
);
}
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;
}
}