refactor: organize test project into subdirectories by feature area
Move 43 root-level test files into feature-specific subdirectories mirroring the main codebase structure: Geometry, Fill, BestFit, CutOffs, CuttingStrategy, Engine, IO. Update namespaces to match folder paths. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Tests.Geometry;
|
||||
|
||||
public class CollisionTests
|
||||
{
|
||||
/// Two unit squares overlapping by 0.5 in X.
|
||||
/// Square A: (0,0)-(1,1), Square B: (0.5,0)-(1.5,1)
|
||||
/// Expected overlap: (0.5,0)-(1,1), area = 0.5
|
||||
[Fact]
|
||||
public void Check_OverlappingSquares_ReturnsOverlapRegion()
|
||||
{
|
||||
var a = MakeSquare(0, 0, 1, 1);
|
||||
var b = MakeSquare(0.5, 0, 1.5, 1);
|
||||
|
||||
var result = Collision.Check(a, b);
|
||||
|
||||
Assert.True(result.Overlaps);
|
||||
Assert.True(result.OverlapArea > 0.49 && result.OverlapArea < 0.51);
|
||||
Assert.NotEmpty(result.OverlapRegions);
|
||||
}
|
||||
|
||||
/// Two squares that don't touch at all.
|
||||
[Fact]
|
||||
public void Check_NonOverlappingSquares_ReturnsNone()
|
||||
{
|
||||
var a = MakeSquare(0, 0, 1, 1);
|
||||
var b = MakeSquare(5, 5, 6, 6);
|
||||
|
||||
var result = Collision.Check(a, b);
|
||||
|
||||
Assert.False(result.Overlaps);
|
||||
Assert.Empty(result.OverlapRegions);
|
||||
Assert.Equal(0, result.OverlapArea);
|
||||
}
|
||||
|
||||
/// Two squares sharing an edge (touching but not overlapping).
|
||||
[Fact]
|
||||
public void Check_EdgeTouchingSquares_ReturnsNone()
|
||||
{
|
||||
var a = MakeSquare(0, 0, 1, 1);
|
||||
var b = MakeSquare(1, 0, 2, 1);
|
||||
|
||||
var result = Collision.Check(a, b);
|
||||
|
||||
Assert.False(result.Overlaps);
|
||||
}
|
||||
|
||||
/// One square fully inside another. Inner: (0.25,0.25)-(0.75,0.75), area = 0.25
|
||||
[Fact]
|
||||
public void Check_ContainedSquare_ReturnsInnerArea()
|
||||
{
|
||||
var a = MakeSquare(0, 0, 1, 1);
|
||||
var b = MakeSquare(0.25, 0.25, 0.75, 0.75);
|
||||
|
||||
var result = Collision.Check(a, b);
|
||||
|
||||
Assert.True(result.Overlaps);
|
||||
Assert.True(result.OverlapArea > 0.24 && result.OverlapArea < 0.26);
|
||||
}
|
||||
|
||||
/// L-shaped concave polygon overlapping a square.
|
||||
[Fact]
|
||||
public void Check_ConcavePolygonOverlap_ReturnsOverlap()
|
||||
{
|
||||
// L-shape: 2x2 with a 1x1 notch cut from top-right
|
||||
var lShape = new Polygon();
|
||||
lShape.Vertices.Add(new Vector(0, 0));
|
||||
lShape.Vertices.Add(new Vector(2, 0));
|
||||
lShape.Vertices.Add(new Vector(2, 1));
|
||||
lShape.Vertices.Add(new Vector(1, 1));
|
||||
lShape.Vertices.Add(new Vector(1, 2));
|
||||
lShape.Vertices.Add(new Vector(0, 2));
|
||||
lShape.Close();
|
||||
lShape.UpdateBounds();
|
||||
|
||||
// Square overlapping the notch area and bottom-right
|
||||
var square = MakeSquare(1.5, 0, 2.5, 1.5);
|
||||
|
||||
var result = Collision.Check(lShape, square);
|
||||
|
||||
Assert.True(result.Overlaps);
|
||||
// Overlap is 0.5 x 1.0 = 0.5 (the part of the square inside the L bottom-right)
|
||||
Assert.True(result.OverlapArea > 0.49 && result.OverlapArea < 0.51);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Square A has a hole. Square B overlaps only the hole area.
|
||||
/// This should NOT be a collision — B fits inside A's cutout.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Check_OverlapInsideHole_ReturnsNone()
|
||||
{
|
||||
var a = MakeSquare(0, 0, 4, 4);
|
||||
var holeA = new List<Polygon> { MakeSquare(1, 1, 3, 3) };
|
||||
|
||||
// B fits entirely inside the hole
|
||||
var b = MakeSquare(1.5, 1.5, 2.5, 2.5);
|
||||
|
||||
var result = Collision.Check(a, b, holesA: holeA);
|
||||
|
||||
Assert.False(result.Overlaps);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Square A has a hole. Square B partially overlaps the hole and
|
||||
/// partially overlaps solid material. Should still be a collision.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Check_PartialOverlapWithHole_StillOverlaps()
|
||||
{
|
||||
var a = MakeSquare(0, 0, 4, 4);
|
||||
var holeA = new List<Polygon> { MakeSquare(1, 1, 3, 3) };
|
||||
|
||||
// B extends beyond the hole into solid material
|
||||
var b = MakeSquare(2, 2, 5, 5);
|
||||
|
||||
var result = Collision.Check(a, b, holesA: holeA);
|
||||
|
||||
// Hole subtraction uses a conservative approach (keeps partial overlaps),
|
||||
// so we only verify that a collision is still detected for solid material.
|
||||
Assert.True(result.Overlaps);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HasOverlap with holes returns false when overlap is inside cutout.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HasOverlap_InsideHole_ReturnsFalse()
|
||||
{
|
||||
var a = MakeSquare(0, 0, 4, 4);
|
||||
var holeA = new List<Polygon> { MakeSquare(1, 1, 3, 3) };
|
||||
var b = MakeSquare(1.5, 1.5, 2.5, 2.5);
|
||||
|
||||
Assert.False(Collision.HasOverlap(a, b, holesA: holeA));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckAll_MultiplePolygons_FindsAllOverlaps()
|
||||
{
|
||||
var a = MakeSquare(0, 0, 1, 1);
|
||||
var b = MakeSquare(0.5, 0, 1.5, 1); // overlaps A
|
||||
var c = MakeSquare(5, 5, 6, 6); // overlaps nobody
|
||||
|
||||
var results = Collision.CheckAll(new List<Polygon> { a, b, c });
|
||||
|
||||
Assert.Single(results);
|
||||
Assert.True(results[0].Overlaps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckAll_NoOverlaps_ReturnsEmpty()
|
||||
{
|
||||
var a = MakeSquare(0, 0, 1, 1);
|
||||
var b = MakeSquare(3, 3, 4, 4);
|
||||
|
||||
var results = Collision.CheckAll(new List<Polygon> { a, b });
|
||||
|
||||
Assert.Empty(results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasAnyOverlap_WithOverlap_ReturnsTrue()
|
||||
{
|
||||
var a = MakeSquare(0, 0, 1, 1);
|
||||
var b = MakeSquare(0.5, 0, 1.5, 1);
|
||||
|
||||
Assert.True(Collision.HasAnyOverlap(new List<Polygon> { a, b }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasAnyOverlap_NoOverlap_ReturnsFalse()
|
||||
{
|
||||
var a = MakeSquare(0, 0, 1, 1);
|
||||
var b = MakeSquare(3, 3, 4, 4);
|
||||
|
||||
Assert.False(Collision.HasAnyOverlap(new List<Polygon> { a, b }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Check_IdenticalSquares_FullOverlap()
|
||||
{
|
||||
var a = MakeSquare(0, 0, 1, 1);
|
||||
var b = MakeSquare(0, 0, 1, 1);
|
||||
|
||||
var result = Collision.Check(a, b);
|
||||
|
||||
Assert.True(result.Overlaps);
|
||||
Assert.True(result.OverlapArea > 0.99 && result.OverlapArea < 1.01);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasAnyOverlap_SinglePolygon_ReturnsFalse()
|
||||
{
|
||||
var a = MakeSquare(0, 0, 1, 1);
|
||||
Assert.False(Collision.HasAnyOverlap(new List<Polygon> { a }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasAnyOverlap_EmptyList_ReturnsFalse()
|
||||
{
|
||||
Assert.False(Collision.HasAnyOverlap(new List<Polygon>()));
|
||||
}
|
||||
|
||||
private static Polygon MakeSquare(double left, double bottom, double right, double top)
|
||||
{
|
||||
var p = new Polygon();
|
||||
p.Vertices.Add(new Vector(left, bottom));
|
||||
p.Vertices.Add(new Vector(right, bottom));
|
||||
p.Vertices.Add(new Vector(right, top));
|
||||
p.Vertices.Add(new Vector(left, top));
|
||||
p.Close();
|
||||
p.UpdateBounds();
|
||||
return p;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.Geometry;
|
||||
|
||||
public class ContourClassificationTests
|
||||
{
|
||||
private static Shape MakeRectShape(double x, double y, double w, double h)
|
||||
{
|
||||
var shape = new Shape();
|
||||
shape.Entities.Add(new Line(new Vector(x, y), new Vector(x + w, y)));
|
||||
shape.Entities.Add(new Line(new Vector(x + w, y), new Vector(x + w, y + h)));
|
||||
shape.Entities.Add(new Line(new Vector(x + w, y + h), new Vector(x, y + h)));
|
||||
shape.Entities.Add(new Line(new Vector(x, y + h), new Vector(x, y)));
|
||||
return shape;
|
||||
}
|
||||
|
||||
private static Shape MakeCircleShape(double cx, double cy, double r)
|
||||
{
|
||||
var shape = new Shape();
|
||||
shape.Entities.Add(new Circle(new Vector(cx, cy), r));
|
||||
return shape;
|
||||
}
|
||||
|
||||
private static Shape MakeEtchShape()
|
||||
{
|
||||
var etchLayer = new Layer("ETCH");
|
||||
var shape = new Shape();
|
||||
shape.Entities.Add(new Line(new Vector(10, 10), new Vector(50, 10)) { Layer = etchLayer });
|
||||
return shape;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_identifies_largest_shape_as_perimeter()
|
||||
{
|
||||
var shapes = new List<Shape>
|
||||
{
|
||||
MakeCircleShape(25, 25, 5),
|
||||
MakeRectShape(0, 0, 100, 50),
|
||||
MakeCircleShape(75, 25, 5),
|
||||
};
|
||||
|
||||
var contours = ContourInfo.Classify(shapes);
|
||||
|
||||
Assert.Equal(3, contours.Count);
|
||||
Assert.Single(contours, c => c.Type == ContourClassification.Perimeter);
|
||||
var perimeter = contours.First(c => c.Type == ContourClassification.Perimeter);
|
||||
Assert.Same(shapes[1], perimeter.Shape);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_identifies_closed_non_perimeter_as_holes()
|
||||
{
|
||||
var shapes = new List<Shape>
|
||||
{
|
||||
MakeCircleShape(25, 25, 5),
|
||||
MakeRectShape(0, 0, 100, 50),
|
||||
MakeCircleShape(75, 25, 5),
|
||||
};
|
||||
|
||||
var contours = ContourInfo.Classify(shapes);
|
||||
|
||||
var holes = contours.Where(c => c.Type == ContourClassification.Hole).ToList();
|
||||
Assert.Equal(2, holes.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_identifies_etch_layer_shapes()
|
||||
{
|
||||
var shapes = new List<Shape>
|
||||
{
|
||||
MakeRectShape(0, 0, 100, 50),
|
||||
MakeEtchShape(),
|
||||
};
|
||||
|
||||
var contours = ContourInfo.Classify(shapes);
|
||||
|
||||
Assert.Single(contours, c => c.Type == ContourClassification.Etch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_identifies_open_shapes()
|
||||
{
|
||||
var openShape = new Shape();
|
||||
openShape.Entities.Add(new Line(new Vector(0, 0), new Vector(10, 0)));
|
||||
openShape.Entities.Add(new Line(new Vector(10, 0), new Vector(10, 5)));
|
||||
// Not closed — doesn't return to (0,0)
|
||||
|
||||
var shapes = new List<Shape>
|
||||
{
|
||||
MakeRectShape(0, 0, 100, 50),
|
||||
openShape,
|
||||
};
|
||||
|
||||
var contours = ContourInfo.Classify(shapes);
|
||||
|
||||
Assert.Single(contours, c => c.Type == ContourClassification.Open);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_orders_holes_first_perimeter_last()
|
||||
{
|
||||
var shapes = new List<Shape>
|
||||
{
|
||||
MakeRectShape(0, 0, 100, 50),
|
||||
MakeCircleShape(25, 25, 5),
|
||||
};
|
||||
|
||||
var contours = ContourInfo.Classify(shapes);
|
||||
|
||||
Assert.Equal(ContourClassification.Hole, contours[0].Type);
|
||||
Assert.Equal(ContourClassification.Perimeter, contours[^1].Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_labels_holes_sequentially()
|
||||
{
|
||||
var shapes = new List<Shape>
|
||||
{
|
||||
MakeRectShape(0, 0, 100, 50),
|
||||
MakeCircleShape(25, 25, 5),
|
||||
MakeCircleShape(75, 25, 5),
|
||||
};
|
||||
|
||||
var contours = ContourInfo.Classify(shapes);
|
||||
|
||||
var holes = contours.Where(c => c.Type == ContourClassification.Hole).ToList();
|
||||
Assert.Equal("Hole 1", holes[0].Label);
|
||||
Assert.Equal("Hole 2", holes[1].Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_single_shape_is_perimeter()
|
||||
{
|
||||
var shapes = new List<Shape> { MakeRectShape(0, 0, 50, 30) };
|
||||
|
||||
var contours = ContourInfo.Classify(shapes);
|
||||
|
||||
Assert.Single(contours);
|
||||
Assert.Equal(ContourClassification.Perimeter, contours[0].Type);
|
||||
Assert.Equal("Perimeter", contours[0].Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reverse_changes_direction_label()
|
||||
{
|
||||
var shape = MakeRectShape(0, 0, 100, 50);
|
||||
var contours = ContourInfo.Classify(new List<Shape> { shape });
|
||||
var contour = contours[0];
|
||||
|
||||
var originalDirection = contour.DirectionLabel;
|
||||
contour.Reverse();
|
||||
var newDirection = contour.DirectionLabel;
|
||||
|
||||
Assert.NotEqual(originalDirection, newDirection);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using Xunit;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Tests.Geometry;
|
||||
|
||||
public class EllipseConverterTests
|
||||
{
|
||||
private const double Tol = 1e-10;
|
||||
|
||||
[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 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
|
||||
{
|
||||
if (System.IO.File.Exists(tempPath))
|
||||
System.IO.File.Delete(tempPath);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace OpenNest.Tests.Geometry;
|
||||
|
||||
public class GeometrySimplifierTests
|
||||
{
|
||||
[Fact]
|
||||
public void Analyze_LinesFromSemicircle_FindsOneCandidate()
|
||||
{
|
||||
// Create 20 lines approximating a semicircle of radius 10
|
||||
var arc = new Arc(new Vector(0, 0), 10, 0, System.Math.PI, false);
|
||||
var points = arc.ToPoints(20);
|
||||
var shape = new Shape();
|
||||
for (var i = 0; i < points.Count - 1; i++)
|
||||
shape.Entities.Add(new Line(points[i], points[i + 1]));
|
||||
|
||||
var simplifier = new GeometrySimplifier { Tolerance = 0.1 };
|
||||
var candidates = simplifier.Analyze(shape);
|
||||
|
||||
Assert.Single(candidates);
|
||||
Assert.Equal(0, candidates[0].StartIndex);
|
||||
Assert.Equal(19, candidates[0].EndIndex);
|
||||
Assert.Equal(20, candidates[0].LineCount);
|
||||
Assert.InRange(candidates[0].FittedArc.Radius, 9.5, 10.5);
|
||||
Assert.True(candidates[0].MaxDeviation <= 0.1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_TooFewLines_ReturnsNoCandidates()
|
||||
{
|
||||
// Only 2 consecutive lines — below MinLines threshold
|
||||
var shape = new Shape();
|
||||
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(1, 1)));
|
||||
shape.Entities.Add(new Line(new Vector(1, 1), new Vector(2, 0)));
|
||||
|
||||
var simplifier = new GeometrySimplifier { Tolerance = 0.1, MinLines = 3 };
|
||||
var candidates = simplifier.Analyze(shape);
|
||||
|
||||
Assert.Empty(candidates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_MixedEntitiesWithArc_FindsSeparateCandidates()
|
||||
{
|
||||
// Lines on one curve, then an arc at a different center, then lines on another curve
|
||||
// The arc is included in the run but can't merge with lines on different curves
|
||||
var shape = new Shape();
|
||||
// First run: 5 lines on a curve
|
||||
var arc1 = new Arc(new Vector(0, 0), 10, 0, System.Math.PI / 2, false);
|
||||
var pts1 = arc1.ToPoints(5);
|
||||
for (var i = 0; i < pts1.Count - 1; i++)
|
||||
shape.Entities.Add(new Line(pts1[i], pts1[i + 1]));
|
||||
|
||||
// An existing arc entity (breaks the run)
|
||||
shape.Entities.Add(new Arc(new Vector(20, 0), 5, 0, System.Math.PI, false));
|
||||
|
||||
// Second run: 4 lines on a different curve
|
||||
var arc2 = new Arc(new Vector(30, 0), 8, 0, System.Math.PI / 3, false);
|
||||
var pts2 = arc2.ToPoints(4);
|
||||
for (var i = 0; i < pts2.Count - 1; i++)
|
||||
shape.Entities.Add(new Line(pts2[i], pts2[i + 1]));
|
||||
|
||||
var simplifier = new GeometrySimplifier { Tolerance = 0.5, MinLines = 3 };
|
||||
var candidates = simplifier.Analyze(shape);
|
||||
|
||||
Assert.Equal(2, candidates.Count);
|
||||
// First candidate covers indices 0-4 (5 lines)
|
||||
Assert.Equal(0, candidates[0].StartIndex);
|
||||
Assert.Equal(4, candidates[0].EndIndex);
|
||||
// Second candidate covers indices 6-9 (4 lines, after the arc at index 5)
|
||||
Assert.Equal(6, candidates[1].StartIndex);
|
||||
Assert.Equal(9, candidates[1].EndIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_SingleCandidate_ReplacesLinesWithArc()
|
||||
{
|
||||
// 20 lines approximating a semicircle
|
||||
var arc = new Arc(new Vector(0, 0), 10, 0, System.Math.PI, false);
|
||||
var points = arc.ToPoints(20);
|
||||
var shape = new Shape();
|
||||
for (var i = 0; i < points.Count - 1; i++)
|
||||
shape.Entities.Add(new Line(points[i], points[i + 1]));
|
||||
|
||||
var simplifier = new GeometrySimplifier { Tolerance = 0.1 };
|
||||
var candidates = simplifier.Analyze(shape);
|
||||
var result = simplifier.Apply(shape, candidates);
|
||||
|
||||
Assert.Single(result.Entities);
|
||||
Assert.IsType<Arc>(result.Entities[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_OnlySelectedCandidates_LeavesUnselectedAsLines()
|
||||
{
|
||||
// Two runs of lines with an arc between them
|
||||
var shape = new Shape();
|
||||
var arc1 = new Arc(new Vector(0, 0), 10, 0, System.Math.PI / 2, false);
|
||||
var pts1 = arc1.ToPoints(5);
|
||||
for (var i = 0; i < pts1.Count - 1; i++)
|
||||
shape.Entities.Add(new Line(pts1[i], pts1[i + 1]));
|
||||
|
||||
shape.Entities.Add(new Arc(new Vector(20, 0), 5, 0, System.Math.PI, false));
|
||||
|
||||
var arc2 = new Arc(new Vector(30, 0), 8, 0, System.Math.PI / 3, false);
|
||||
var pts2 = arc2.ToPoints(4);
|
||||
for (var i = 0; i < pts2.Count - 1; i++)
|
||||
shape.Entities.Add(new Line(pts2[i], pts2[i + 1]));
|
||||
|
||||
var simplifier = new GeometrySimplifier { Tolerance = 0.5, MinLines = 3 };
|
||||
var candidates = simplifier.Analyze(shape);
|
||||
|
||||
// Deselect the first candidate
|
||||
candidates[0].IsSelected = false;
|
||||
|
||||
var result = simplifier.Apply(shape, candidates);
|
||||
|
||||
// First run (5 lines) stays as lines + middle arc + second run replaced by arc
|
||||
// 5 original lines + 1 original arc + 1 fitted arc = 7 entities
|
||||
Assert.Equal(7, result.Entities.Count);
|
||||
// First 5 should be lines
|
||||
for (var i = 0; i < 5; i++)
|
||||
Assert.IsType<Line>(result.Entities[i]);
|
||||
// Index 5 is the original arc
|
||||
Assert.IsType<Arc>(result.Entities[5]);
|
||||
// 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})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.Geometry;
|
||||
|
||||
public class PolyLabelTests
|
||||
{
|
||||
private static Polygon Square(double size)
|
||||
{
|
||||
var p = new Polygon();
|
||||
p.Vertices.Add(new Vector(0, 0));
|
||||
p.Vertices.Add(new Vector(size, 0));
|
||||
p.Vertices.Add(new Vector(size, size));
|
||||
p.Vertices.Add(new Vector(0, size));
|
||||
return p;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Square_ReturnsCenterPoint()
|
||||
{
|
||||
var poly = Square(100);
|
||||
|
||||
var result = PolyLabel.Find(poly);
|
||||
|
||||
Assert.Equal(50, result.X, 1.0);
|
||||
Assert.Equal(50, result.Y, 1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Triangle_ReturnsIncenter()
|
||||
{
|
||||
var p = new Polygon();
|
||||
p.Vertices.Add(new Vector(0, 0));
|
||||
p.Vertices.Add(new Vector(100, 0));
|
||||
p.Vertices.Add(new Vector(50, 86.6));
|
||||
|
||||
var result = PolyLabel.Find(p);
|
||||
|
||||
// Incenter of equilateral triangle is at (50, ~28.9)
|
||||
Assert.Equal(50, result.X, 1.0);
|
||||
Assert.Equal(28.9, result.Y, 1.0);
|
||||
Assert.True(p.ContainsPoint(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LShape_ReturnsPointInBottomLobe()
|
||||
{
|
||||
// L-shape: 100x100 with 50x50 cut from top-right
|
||||
var p = new Polygon();
|
||||
p.Vertices.Add(new Vector(0, 0));
|
||||
p.Vertices.Add(new Vector(100, 0));
|
||||
p.Vertices.Add(new Vector(100, 50));
|
||||
p.Vertices.Add(new Vector(50, 50));
|
||||
p.Vertices.Add(new Vector(50, 100));
|
||||
p.Vertices.Add(new Vector(0, 100));
|
||||
|
||||
var result = PolyLabel.Find(p);
|
||||
|
||||
Assert.True(p.ContainsPoint(result));
|
||||
// The bottom 100x50 lobe is the widest region
|
||||
Assert.True(result.Y < 50, $"Expected label in bottom lobe, got Y={result.Y}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThinRectangle_CenteredOnBothAxes()
|
||||
{
|
||||
var p = new Polygon();
|
||||
p.Vertices.Add(new Vector(0, 0));
|
||||
p.Vertices.Add(new Vector(200, 0));
|
||||
p.Vertices.Add(new Vector(200, 10));
|
||||
p.Vertices.Add(new Vector(0, 10));
|
||||
|
||||
var result = PolyLabel.Find(p);
|
||||
|
||||
Assert.Equal(100, result.X, 1.0);
|
||||
Assert.Equal(5, result.Y, 1.0);
|
||||
Assert.True(p.ContainsPoint(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SquareWithLargeHole_AvoidsHole()
|
||||
{
|
||||
var outer = Square(100);
|
||||
|
||||
var hole = new Polygon();
|
||||
hole.Vertices.Add(new Vector(20, 20));
|
||||
hole.Vertices.Add(new Vector(80, 20));
|
||||
hole.Vertices.Add(new Vector(80, 80));
|
||||
hole.Vertices.Add(new Vector(20, 80));
|
||||
|
||||
var result = PolyLabel.Find(outer, new[] { hole });
|
||||
|
||||
// Point should be inside outer but outside hole
|
||||
Assert.True(outer.ContainsPoint(result));
|
||||
Assert.False(hole.ContainsPoint(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CShape_ReturnsPointInLeftBar()
|
||||
{
|
||||
// C-shape opening to the right: left bar is 20 wide, top/bottom arms are 20 tall
|
||||
var p = new Polygon();
|
||||
p.Vertices.Add(new Vector(0, 0));
|
||||
p.Vertices.Add(new Vector(100, 0));
|
||||
p.Vertices.Add(new Vector(100, 20));
|
||||
p.Vertices.Add(new Vector(20, 20));
|
||||
p.Vertices.Add(new Vector(20, 80));
|
||||
p.Vertices.Add(new Vector(100, 80));
|
||||
p.Vertices.Add(new Vector(100, 100));
|
||||
p.Vertices.Add(new Vector(0, 100));
|
||||
|
||||
var result = PolyLabel.Find(p);
|
||||
|
||||
Assert.True(p.ContainsPoint(result));
|
||||
// Label should be in the left vertical bar (x < 20), not at bbox center (50, 50)
|
||||
Assert.True(result.X < 20, $"Expected label in left bar, got X={result.X}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DegeneratePolygon_ReturnsFallback()
|
||||
{
|
||||
var p = new Polygon();
|
||||
p.Vertices.Add(new Vector(5, 5));
|
||||
|
||||
var result = PolyLabel.Find(p);
|
||||
|
||||
Assert.Equal(5, result.X, 0.01);
|
||||
Assert.Equal(5, result.Y, 0.01);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Tests.Geometry;
|
||||
|
||||
public class PolygonHelperTests
|
||||
{
|
||||
[Fact]
|
||||
public void ExtractPerimeterPolygon_ReturnsPolygon_ForValidDrawing()
|
||||
{
|
||||
var drawing = TestHelpers.MakeSquareDrawing();
|
||||
var result = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
|
||||
Assert.NotNull(result.Polygon);
|
||||
Assert.True(result.Polygon.Vertices.Count >= 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPerimeterPolygon_InflatesPolygon_WhenSpacingNonZero()
|
||||
{
|
||||
var drawing = TestHelpers.MakeSquareDrawing(10);
|
||||
var noSpacing = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
|
||||
var withSpacing = PolygonHelper.ExtractPerimeterPolygon(drawing, 1);
|
||||
|
||||
noSpacing.Polygon.UpdateBounds();
|
||||
withSpacing.Polygon.UpdateBounds();
|
||||
|
||||
// The offset polygon should differ in size from the non-offset polygon.
|
||||
// OffsetSide.Left offsets outward or inward depending on winding,
|
||||
// but either way the result must be a different size.
|
||||
Assert.True(
|
||||
System.Math.Abs(withSpacing.Polygon.BoundingBox.Width - noSpacing.Polygon.BoundingBox.Width) > 0.5,
|
||||
$"Expected polygon width to differ by >0.5 with 1mm spacing. " +
|
||||
$"No-spacing width: {noSpacing.Polygon.BoundingBox.Width:F3}, " +
|
||||
$"With-spacing width: {withSpacing.Polygon.BoundingBox.Width:F3}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPerimeterPolygon_InflatedPolygonIsLarger_ForCWWinding()
|
||||
{
|
||||
// CW winding (standard CNC convention): (0,0)→(0,10)→(10,10)→(10,0)→(0,0)
|
||||
var drawing = TestHelpers.MakeSquareDrawing(10);
|
||||
var noSpacing = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
|
||||
var withSpacing = PolygonHelper.ExtractPerimeterPolygon(drawing, 1);
|
||||
|
||||
noSpacing.Polygon.UpdateBounds();
|
||||
withSpacing.Polygon.UpdateBounds();
|
||||
|
||||
Assert.True(withSpacing.Polygon.BoundingBox.Width > noSpacing.Polygon.BoundingBox.Width,
|
||||
$"Inflated width {withSpacing.Polygon.BoundingBox.Width:F3} should be > original {noSpacing.Polygon.BoundingBox.Width:F3}");
|
||||
Assert.True(withSpacing.Polygon.BoundingBox.Length > noSpacing.Polygon.BoundingBox.Length,
|
||||
$"Inflated length {withSpacing.Polygon.BoundingBox.Length:F3} should be > original {noSpacing.Polygon.BoundingBox.Length:F3}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPerimeterPolygon_InflatedPolygonIsLarger_ForCCWWinding()
|
||||
{
|
||||
// CCW winding: (0,0)→(10,0)→(10,10)→(0,10)→(0,0)
|
||||
var pgm = new CNC.Program();
|
||||
pgm.Codes.Add(new CNC.RapidMove(new Vector(0, 0)));
|
||||
pgm.Codes.Add(new CNC.LinearMove(new Vector(10, 0)));
|
||||
pgm.Codes.Add(new CNC.LinearMove(new Vector(10, 10)));
|
||||
pgm.Codes.Add(new CNC.LinearMove(new Vector(0, 10)));
|
||||
pgm.Codes.Add(new CNC.LinearMove(new Vector(0, 0)));
|
||||
var drawing = new Drawing("ccw-square", pgm);
|
||||
|
||||
var noSpacing = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
|
||||
var withSpacing = PolygonHelper.ExtractPerimeterPolygon(drawing, 1);
|
||||
|
||||
noSpacing.Polygon.UpdateBounds();
|
||||
withSpacing.Polygon.UpdateBounds();
|
||||
|
||||
Assert.True(withSpacing.Polygon.BoundingBox.Width > noSpacing.Polygon.BoundingBox.Width,
|
||||
$"Inflated width {withSpacing.Polygon.BoundingBox.Width:F3} should be > original {noSpacing.Polygon.BoundingBox.Width:F3}");
|
||||
Assert.True(withSpacing.Polygon.BoundingBox.Length > noSpacing.Polygon.BoundingBox.Length,
|
||||
$"Inflated length {withSpacing.Polygon.BoundingBox.Length:F3} should be > original {noSpacing.Polygon.BoundingBox.Length:F3}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPerimeterPolygon_ReturnsNull_ForEmptyDrawing()
|
||||
{
|
||||
var pgm = new Program();
|
||||
var drawing = new Drawing("empty", pgm);
|
||||
var result = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
|
||||
Assert.Null(result.Polygon);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPerimeterPolygon_CorrectionVector_ReflectsOriginDifference()
|
||||
{
|
||||
var drawing = TestHelpers.MakeSquareDrawing();
|
||||
var result = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
|
||||
Assert.NotNull(result.Polygon);
|
||||
Assert.True(System.Math.Abs(result.Correction.X) < 1);
|
||||
Assert.True(System.Math.Abs(result.Correction.Y) < 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RotatePolygon_AtZero_ReturnsSamePolygon()
|
||||
{
|
||||
var polygon = new Polygon();
|
||||
polygon.Vertices.Add(new Vector(0, 0));
|
||||
polygon.Vertices.Add(new Vector(10, 0));
|
||||
polygon.Vertices.Add(new Vector(10, 10));
|
||||
polygon.Vertices.Add(new Vector(0, 10));
|
||||
polygon.UpdateBounds();
|
||||
|
||||
var rotated = PolygonHelper.RotatePolygon(polygon, 0);
|
||||
Assert.Same(polygon, rotated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RotatePolygon_At90Degrees_SwapsDimensions()
|
||||
{
|
||||
var polygon = new Polygon();
|
||||
polygon.Vertices.Add(new Vector(0, 0));
|
||||
polygon.Vertices.Add(new Vector(20, 0));
|
||||
polygon.Vertices.Add(new Vector(20, 10));
|
||||
polygon.Vertices.Add(new Vector(0, 10));
|
||||
polygon.UpdateBounds();
|
||||
|
||||
var rotated = PolygonHelper.RotatePolygon(polygon, Angle.HalfPI);
|
||||
rotated.UpdateBounds();
|
||||
|
||||
Assert.True(System.Math.Abs(rotated.BoundingBox.Width - 10) < 0.1);
|
||||
Assert.True(System.Math.Abs(rotated.BoundingBox.Length - 20) < 0.1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using Xunit;
|
||||
|
||||
namespace OpenNest.Tests.Geometry;
|
||||
|
||||
public class SplineConverterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Convert_SemicirclePoints_ProducesSingleArc()
|
||||
{
|
||||
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()
|
||||
{
|
||||
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);
|
||||
|
||||
Assert.All(result, e => Assert.IsType<Line>(e));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Convert_SCurve_ProducesMultipleArcs()
|
||||
{
|
||||
var points = new System.Collections.Generic.List<Vector>();
|
||||
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)));
|
||||
}
|
||||
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);
|
||||
|
||||
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()
|
||||
{
|
||||
var points = new System.Collections.Generic.List<Vector>();
|
||||
for (var i = 0; i <= 80; i++)
|
||||
{
|
||||
var t = Angle.TwoPI * i / 80;
|
||||
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")
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user