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:
2026-04-01 20:46:43 -04:00
parent 7a6c407edd
commit 3e340e67e0
43 changed files with 62 additions and 61 deletions
+218
View File
@@ -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})");
}
}
}
}
+129
View File
@@ -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")
};
}
}