Files
OpenNest/OpenNest.Tests/Geometry/CollisionHoleTests.cs
T
aj 22d21924b8 feat(astra): add independent nesting engine and fix hole validation
Replace the Terra scaffold with an independent configuration-space contact placer and bounded stock-plan search. Include plugin tests, synthetic and DXF benchmark drivers, results, and deployment documentation.

Correct shared collision clipping and hole subtraction so curved-hole inserts validate consistently. Cover translated layouts, spacing violations, operand order, winding, and independent boolean-area comparisons.

Validation: 1,293 tests passed with 12 fixture skips; all 34 synthetic/generated and four DXF cases are valid and complete.
2026-09-24 00:14:36 -04:00

147 lines
6.0 KiB
C#

using Clipper2Lib;
using OpenNest.Benchmark;
using OpenNest.Geometry;
using OpenNest.Shapes;
namespace OpenNest.Tests.Geometry;
public class CollisionHoleTests
{
[Theory]
[InlineData(5.2, 5.4, 0)]
[InlineData(5.2, 5.4, 0.0003)]
[InlineData(5.2, 5.4, 0.05)]
[InlineData(5.2, 5.4, 0.1)]
[InlineData(-36.8, 5.4, 0)]
[InlineData(-36.8, 5.4, 0.0003)]
[InlineData(-36.8, 5.4, 0.05)]
[InlineData(-36.8, 5.4, 0.1)]
public void Check_CircularInsertClearsShrunkHole(double x, double y, double offset)
{
var ring = ClipperBridge.OffsetForValidation(Profile(5, 3.5), 0.175, 0.001);
var insert = ClipperBridge.OffsetForValidation(Profile(3), 0, 0.001);
var outer = ring.LargestOuter();
var hole = Assert.Single(ring.Holes);
var disk = insert.LargestOuter();
Move(outer, x, y);
Move(hole, x, y);
Move(disk, x + offset, y);
// Check both operands and windings: neither translation nor triangulation
// order should change the material represented by these polygons.
for (var winding = 0; winding < 2; winding++)
{
var result = Collision.Check(outer, disk, ring.Holes);
Assert.False(result.Overlaps, $"Unexpected overlap area: {result.OverlapArea:R}");
Assert.False(Collision.HasOverlap(disk, outer, holesB: ring.Holes));
outer.Reverse();
hole.Reverse();
disk.Reverse();
}
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void NestValidator_RingInsertAcceptsClearanceAndRejectsViolations(int quadrant)
{
var ring = new RingShape { OuterDiameter = 10, InnerDiameter = 7 }.GetDrawing();
var insert = new CircleShape { Diameter = 6 }.GetDrawing();
var plate = new Plate(24, 42) { Quadrant = quadrant, PartSpacing = 0.175 };
var x = (quadrant is 1 or 4 ? 0 : -42) + 5.2;
var y = (quadrant is 1 or 2 ? 0 : -24) + 5.4;
foreach (var offset in new[] { 0.0, 0.0003, 0.05, 0.1, 0.32, 0.34, 0.6 })
{
// Analytic clearance is 3.5 - 3 - offset. The last two positions
// violate spacing; the last also physically overlaps the ring.
var a = new Part(ring) { Location = new Vector(x, y) };
var b = new Part(insert) { Location = new Vector(x + offset, y) };
var result = NestValidator.Validate(
new List<(Plate, List<Part>)> { (plate, new List<Part> { a, b }) },
new Dictionary<Drawing, (string, int)> { [ring] = ("ring", 1), [insert] = ("insert", 1) });
Assert.True(result.Valid == (offset <= 0.32),
$"Quadrant {quadrant}, offset {offset}: {string.Join("; ", result.Violations)}");
if (offset > 0.32)
Assert.Contains(result.Violations, v => v.Contains("required spacing"));
}
}
[Fact]
public void Check_HoleSubtractionDoesNotDuplicateSurvivingArea()
{
var outer = Polygon((0, 0), (10, 0), (10, 10), (0, 10));
var hole = Polygon((2, 2), (8, 2), (5, 8));
var result = Collision.Check(outer, outer, new List<Polygon> { hole });
Assert.True(result.Overlaps);
Assert.Equal(82, result.OverlapArea, 6);
}
[Fact]
public void Check_MultipleConcaveHolesMatchesIndependentBooleanArea()
{
var random = new Random(240924);
for (var trial = 0; trial < 80; trial++)
{
var outer = Polygon((0, 0), (20, 0), (20, 20), (0, 20));
var holes = new List<Polygon>
{
Polygon((2, 5), (5, 2), (8, 5), (5, 8)),
Polygon((9, 4), (16, 4), (16, 11), (13, 11), (13, 7), (9, 7))
};
var x = random.NextDouble() * 19 - 2;
var y = random.NextDouble() * 19 - 2;
var w = 3 + random.NextDouble() * 6;
var h = 3 + random.NextDouble() * 6;
var other = Polygon((x, y), (x + w, y), (x + w, y + h), (x, y + h));
var otherHoles = new List<Polygon>
{
Polygon((x + 1, y + 1), (x + w - 1, y + 1), (x + w / 2, y + h - 1))
};
foreach (var polygon in new[] { outer, other }.Concat(holes).Concat(otherHoles))
{
Move(polygon, trial % 2 == 0 ? -42.123456 : 5.2, trial % 3 == 0 ? -24.654321 : 5.4);
if (trial % 2 == 0)
polygon.Reverse();
}
var materialA = new PathsD { ClipperBridge.ToPath(outer, true) };
materialA.AddRange(holes.Select(p => ClipperBridge.ToPath(p, false)));
var materialB = new PathsD { ClipperBridge.ToPath(other, true) };
materialB.AddRange(otherHoles.Select(p => ClipperBridge.ToPath(p, false)));
var expected = System.Math.Abs(Clipper.Area(Clipper.Intersect(materialA, materialB, FillRule.NonZero, 6)));
var result = Collision.Check(outer, other, holes, otherHoles);
var swapped = Collision.Check(other, outer, otherHoles, holes);
Assert.True(System.Math.Abs(result.OverlapArea - expected) < 0.001,
$"Trial {trial}: expected area {expected:R}, got {result.OverlapArea:R}");
Assert.True(System.Math.Abs(swapped.OverlapArea - expected) < 0.001,
$"Swapped trial {trial}: expected area {expected:R}, got {swapped.OverlapArea:R}");
Assert.Equal(expected > 0.001, result.Overlaps);
Assert.Equal(result.Overlaps, swapped.Overlaps);
}
}
private static ShapeProfile Profile(params double[] radii) =>
new(radii.Select(r => (Entity)new Circle(0, 0, r)).ToList());
private static void Move(Polygon polygon, double x, double y)
{
polygon.Offset(x, y);
polygon.UpdateBounds();
}
private static Polygon Polygon(params (double X, double Y)[] points)
{
var polygon = new Polygon();
polygon.Vertices.AddRange(points.Select(p => new Vector(p.X, p.Y)));
polygon.Close();
polygon.UpdateBounds();
return polygon;
}
}