mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-03 14:41:37 -05:00
The repo mixed variants such as center/centre and color/colour, so a name could be spelled either way and searches missed half the hits. Standardize on the US spelling, matching .NET's own APIs (Color, Center). Wire values owned by other systems keep their spelling: the material ledger's "Cancelled" hold status and GitHub's "cancelled" job status. No behavior change beyond the spelling of a few status messages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
435 lines
19 KiB
C#
435 lines
19 KiB
C#
using OpenNest.CNC;
|
|
using OpenNest.Converters;
|
|
using OpenNest.Engine;
|
|
using OpenNest.Engine.Fill;
|
|
using OpenNest.Engine.Strategies;
|
|
using OpenNest.Geometry;
|
|
using OpenNest.Shapes;
|
|
using OpenNest.Tests.BestFit;
|
|
using Xunit.Abstractions;
|
|
|
|
namespace OpenNest.Tests.Fill;
|
|
|
|
/// <summary>
|
|
/// PartOverlapChecker, and both in-fill HasOverlappingParts checks built on it, must reproduce
|
|
/// the frozen pre-change nested loop exactly: the same verdict and the same first overlapping
|
|
/// (a, b) indices. The only change is that each distinct Program is prepared once per check.
|
|
/// </summary>
|
|
[Collection(nameof(FillCacheCollection))]
|
|
public class PartOverlapCheckerTests
|
|
{
|
|
private static readonly string[] Shapes = { "rectangle", "concave", "arc", "circle", "ring" };
|
|
|
|
private readonly ITestOutputHelper output;
|
|
|
|
public PartOverlapCheckerTests(ITestOutputHelper output) => this.output = output;
|
|
|
|
public static IEnumerable<object[]> GridCases()
|
|
{
|
|
foreach (var shape in Shapes)
|
|
foreach (var spacing in new[] { 0.0, 0.5 })
|
|
foreach (var angle in new[] { 0.0, System.Math.PI / 2, 0.37 })
|
|
yield return new object[] { shape, spacing, angle };
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(GridCases))]
|
|
public void FillLinearGrids_MatchFrozenLoops(string shape, double spacing, double angle)
|
|
{
|
|
var area = new Box(3.1, 5.3, 61, 37);
|
|
var filler = new FillLinear(area, spacing);
|
|
var drawing = Fixture(shape);
|
|
foreach (var direction in new[] { NestDirection.Horizontal, NestDirection.Vertical })
|
|
{
|
|
var grid = filler.Fill(drawing, angle, direction);
|
|
Assert.NotEmpty(grid);
|
|
AssertMatchesLegacy(grid);
|
|
// Nudge copies onto their neighbors so overlapping verdicts are exercised too.
|
|
foreach (var shift in new[] { 0.05, 1e-9, -1e-9 })
|
|
AssertMatchesLegacy(Shifted(grid, shift));
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("shared")]
|
|
[InlineData("rotated")]
|
|
[InlineData("built-pair")]
|
|
public void PatternGrids_MatchFrozenLoops(string kind)
|
|
{
|
|
foreach (var shape in Shapes)
|
|
{
|
|
var pattern = MakePattern(kind, Fixture(shape));
|
|
var filler = new FillLinear(new Box(3.1, 5.3, 61, 37), 0.5);
|
|
foreach (var direction in new[] { NestDirection.Horizontal, NestDirection.Vertical })
|
|
{
|
|
var grid = filler.Fill(pattern, direction);
|
|
AssertMatchesLegacy(grid);
|
|
AssertMatchesLegacy(Shifted(grid, 0.05));
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void OverlappingSeedTiling_MatchesFrozenLoops()
|
|
{
|
|
var first = new Part(Fixture("rectangle"));
|
|
var pattern = new Pattern();
|
|
pattern.Parts.AddRange(new[] { first, first.CloneAtOffset(new Vector(0.25, 0.25)) });
|
|
pattern.UpdateBounds();
|
|
var legacy = new LegacyFillLinear(new Box(0, 0, 35, 8.25), 0.5);
|
|
var raw = new List<Part>(pattern.Parts);
|
|
raw.AddRange((List<Part>)FillExtentsTests.Invoke(legacy, "TilePattern", pattern, NestDirection.Horizontal));
|
|
Assert.True(LegacyPartOverlap.FillHelpersHasOverlappingParts(raw));
|
|
AssertMatchesLegacy(raw);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0.0)]
|
|
[InlineData(1e-12)]
|
|
[InlineData(1e-9)]
|
|
[InlineData(-1e-9)]
|
|
[InlineData(1e-7)]
|
|
[InlineData(-1e-7)]
|
|
public void TouchingAndEpsilonEdgeOffsets_MatchFrozenLoops(double gap)
|
|
{
|
|
foreach (var shape in Shapes)
|
|
{
|
|
var drawing = Fixture(shape);
|
|
var a = Part.CreateAtOrigin(drawing, 0);
|
|
var right = a.CloneAtOffset(new Vector(a.BoundingBox.Length + gap, 0));
|
|
var above = a.CloneAtOffset(new Vector(0, a.BoundingBox.Width + gap));
|
|
var diagonal = a.CloneAtOffset(new Vector(a.BoundingBox.Length + gap, a.BoundingBox.Width + gap));
|
|
AssertMatchesLegacy(new List<Part> { a, right, above, diagonal });
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void NonMaterialAndEmptyPrograms_MatchFrozenLoops()
|
|
{
|
|
var square = Part.CreateAtOrigin(Fixture("rectangle"), 0);
|
|
var scribeOnly = new Program();
|
|
scribeOnly.Codes.Add(new RapidMove(new Vector(0, 0)));
|
|
scribeOnly.Codes.Add(new LinearMove(new Vector(4, 0)) { Layer = LayerType.Scribe });
|
|
scribeOnly.Codes.Add(new LinearMove(new Vector(4, 4)) { Layer = LayerType.Scribe });
|
|
scribeOnly.Codes.Add(new LinearMove(new Vector(0, 0)) { Layer = LayerType.Scribe });
|
|
var rapidOnly = new Program();
|
|
rapidOnly.Codes.Add(new RapidMove(new Vector(0, 0)));
|
|
rapidOnly.Codes.Add(new RapidMove(new Vector(5, 5)));
|
|
foreach (var program in new[] { scribeOnly, rapidOnly })
|
|
{
|
|
var odd = new Part(new Drawing("odd", program));
|
|
Assert.Empty(Part.MaterialEntities(odd.Program));
|
|
var parts = new List<Part> { square, odd, square.CloneAtOffset(new Vector(2, 2)) };
|
|
AssertMatchesLegacy(parts);
|
|
Assert.False(new PartOverlapChecker().Overlaps(square, odd));
|
|
Assert.False(new PartOverlapChecker().Overlaps(odd, odd));
|
|
}
|
|
|
|
// An empty program has no material entities, so both paths return false before ShapeProfile.
|
|
var empty = new Part(new Drawing("empty", new Program()));
|
|
Assert.Empty(Part.MaterialEntities(empty.Program));
|
|
Assert.Equal(Capture(() => LegacyPartOverlap.Intersects(square, empty, out _)),
|
|
Capture(() => new PartOverlapChecker().Overlaps(square, empty)));
|
|
Assert.Equal(Capture(() => LegacyPartOverlap.Intersects(empty, square, out _)),
|
|
Capture(() => new PartOverlapChecker().Overlaps(empty, square)));
|
|
AssertMatchesLegacy(new List<Part> { square, empty, square.CloneAtOffset(new Vector(20, 0)) });
|
|
}
|
|
|
|
[Fact]
|
|
public void SharedProgram_PartsTestedAgainstEachOtherAndItself()
|
|
{
|
|
var a = Part.CreateAtOrigin(Fixture("concave"), 0);
|
|
var b = a.CloneAtOffset(new Vector(1, 1));
|
|
var c = a.CloneAtOffset(new Vector(30, 0));
|
|
Assert.Same(a.Program, b.Program);
|
|
var checker = new PartOverlapChecker();
|
|
Assert.Equal(LegacyPartOverlap.Intersects(a, b, out _), checker.Overlaps(a, b));
|
|
Assert.Equal(LegacyPartOverlap.Intersects(a, c, out _), checker.Overlaps(a, c));
|
|
Assert.Equal(LegacyPartOverlap.Intersects(b, a, out _), checker.Overlaps(b, a));
|
|
Assert.Equal(LegacyPartOverlap.Intersects(a, a, out _), checker.Overlaps(a, a));
|
|
Assert.True(checker.Overlaps(a, b));
|
|
Assert.False(checker.Overlaps(a, c));
|
|
}
|
|
|
|
[Fact]
|
|
public void WorldPolygons_MatchPreChangeBitwise()
|
|
{
|
|
foreach (var shape in Shapes)
|
|
{
|
|
var part = Part.CreateAtOrigin(Fixture(shape), 0.37);
|
|
part.Offset(new Vector(11.1, -3.3));
|
|
var clone = part.CloneAtOffset(new Vector(7.77, 1.234567));
|
|
var checker = new PartOverlapChecker();
|
|
// Prepare the shared Program through the clone first, then build the original.
|
|
checker.Overlaps(clone, part);
|
|
foreach (var p in new[] { part, clone })
|
|
{
|
|
var expected = LegacyPartOverlap.WorldPolygon(p);
|
|
var actual = WorldPolygon(checker, p);
|
|
Assert.Equal(PolygonBits(expected), PolygonBits(actual));
|
|
// Cached triangles equal a fresh triangulation of the pre-change world polygon.
|
|
var expectedTriangles = Collision.Triangulate(expected);
|
|
var actualTriangles = WorldTriangles(checker, p);
|
|
Assert.Equal(expectedTriangles.Count, actualTriangles.Count);
|
|
for (var t = 0; t < expectedTriangles.Count; t++)
|
|
Assert.Equal(PolygonBits(expectedTriangles[t]), PolygonBits(actualTriangles[t]));
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Check_DoesNotMutateParts()
|
|
{
|
|
var pattern = MakePattern("rotated", Fixture("arc"));
|
|
var grid = new FillLinear(new Box(3.1, 5.3, 61, 37), 0.5).Fill(pattern, NestDirection.Horizontal);
|
|
var before = grid.Select(Snapshot).ToArray();
|
|
FillHelpers.HasOverlappingParts(grid);
|
|
Invoke(grid, out _, out _);
|
|
var checker = new PartOverlapChecker();
|
|
for (var i = 0; i + 1 < grid.Count; i++)
|
|
checker.Overlaps(grid[i], grid[i + 1]);
|
|
Assert.Equal(before, grid.Select(Snapshot).ToArray());
|
|
}
|
|
|
|
[Fact]
|
|
public void ConcurrentChecks_MatchSequential()
|
|
{
|
|
var grids = Shapes.SelectMany(shape => new[]
|
|
{
|
|
Shifted(new FillLinear(new Box(3.1, 5.3, 61, 37), 0.5).Fill(Fixture(shape), 0.37, NestDirection.Horizontal), 0.05),
|
|
new FillLinear(new Box(3.1, 5.3, 61, 37), 0.5).Fill(MakePattern("built-pair", Fixture(shape)), NestDirection.Vertical),
|
|
}).ToArray();
|
|
var expected = grids.Select(g => (Helpers: FillHelpers.HasOverlappingParts(g), Linear: Indices(g))).ToArray();
|
|
Parallel.For(0, grids.Length * 4, new ParallelOptions { MaxDegreeOfParallelism = 4 }, i =>
|
|
{
|
|
var grid = grids[i % grids.Length];
|
|
Assert.Equal(expected[i % grids.Length].Helpers, FillHelpers.HasOverlappingParts(grid));
|
|
Assert.Equal(expected[i % grids.Length].Linear, Indices(grid));
|
|
});
|
|
}
|
|
|
|
#if DEBUG
|
|
[Theory]
|
|
[InlineData("single", 1)]
|
|
[InlineData("pair", 2)]
|
|
public void Work_PreparesEachDistinctProgramOncePerCheck(string kind, long distinctPrograms)
|
|
{
|
|
var grid = WorkGrid(kind);
|
|
Assert.Equal(distinctPrograms, grid.Select(g => g.Program).Distinct(ReferenceEqualityComparer.Instance).Count());
|
|
PerfCounters.Reset();
|
|
try
|
|
{
|
|
var legacyVerdict = LegacyPartOverlap.FillHelpersHasOverlappingParts(grid);
|
|
Assert.Equal(0, PerfCounters.OverlapPolygonPreparations);
|
|
// Old path: two preparations per exact test, through Part.Intersects.
|
|
var exactTests = 0;
|
|
var legacyPreparations = 0L;
|
|
PerfCounters.Reset();
|
|
ForEachBoxOverlappingPair(grid, (a, b) =>
|
|
{
|
|
exactTests++;
|
|
a.Intersects(b, out _);
|
|
});
|
|
legacyPreparations = PerfCounters.OverlapPolygonPreparations;
|
|
PerfCounters.Reset();
|
|
var verdict = FillHelpers.HasOverlappingParts(grid);
|
|
var preparations = PerfCounters.OverlapPolygonPreparations;
|
|
output.WriteLine($"work {kind}: parts={grid.Count}; exact tests={exactTests}; old preparations={legacyPreparations}; new={preparations}; verdict={verdict}");
|
|
Assert.False(legacyVerdict);
|
|
Assert.False(verdict);
|
|
Assert.True(exactTests > 2);
|
|
Assert.Equal(2L * exactTests, legacyPreparations);
|
|
Assert.Equal(distinctPrograms, preparations);
|
|
}
|
|
finally
|
|
{
|
|
PerfCounters.Reset();
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("single")]
|
|
[InlineData("pair")]
|
|
public void Work_TriangulatesEachPartAtMostOncePerCheck(string kind)
|
|
{
|
|
var grid = WorkGrid(kind);
|
|
// Parts that reach an exact test: their part boxes and their world polygon boxes overlap.
|
|
var reaching = new HashSet<Part>(ReferenceEqualityComparer.Instance);
|
|
var exactTests = 0;
|
|
ForEachBoxOverlappingPair(grid, (a, b) =>
|
|
{
|
|
if (!PolygonBoxesOverlap(LegacyPartOverlap.WorldPolygon(a), LegacyPartOverlap.WorldPolygon(b)))
|
|
return;
|
|
exactTests++;
|
|
reaching.Add(a);
|
|
reaching.Add(b);
|
|
});
|
|
PerfCounters.Reset();
|
|
try
|
|
{
|
|
ForEachBoxOverlappingPair(grid, (a, b) => a.Intersects(b, out _));
|
|
var legacy = PerfCounters.PolygonTriangulations;
|
|
PerfCounters.Reset();
|
|
Assert.False(FillHelpers.HasOverlappingParts(grid));
|
|
var actual = PerfCounters.PolygonTriangulations;
|
|
output.WriteLine($"triangulations {kind}: parts={grid.Count}; exact tests={exactTests}; parts reaching={reaching.Count}; old={legacy}; new={actual}");
|
|
Assert.True(exactTests > 2);
|
|
Assert.Equal(2L * exactTests, legacy);
|
|
Assert.Equal(reaching.Count, actual);
|
|
}
|
|
finally
|
|
{
|
|
PerfCounters.Reset();
|
|
}
|
|
}
|
|
#endif
|
|
|
|
private static List<Part> WorkGrid(string kind)
|
|
{
|
|
// Rotated grids: neighboring boxes overlap but parts do not, so every box-overlapping
|
|
// pair reaches an exact test and there is no early exit.
|
|
var filler = new FillLinear(new Box(3.1, 5.3, 96, 48), 0.5);
|
|
if (kind == "single")
|
|
return filler.Fill(Fixture("arc"), 0.37, NestDirection.Horizontal);
|
|
var first = Part.CreateAtOrigin(Fixture("concave"), 0);
|
|
var second = Part.CreateAtOrigin(Fixture("concave"), System.Math.PI);
|
|
second.Offset(new Vector(first.Right + 0.5, first.Bottom));
|
|
return filler.Fill(FillHelpers.BuildRotatedPattern(new List<Part> { first, second }, 0.37),
|
|
NestDirection.Horizontal);
|
|
}
|
|
|
|
private static bool PolygonBoxesOverlap(Polygon a, Polygon b)
|
|
{
|
|
var overlapX = System.Math.Min(a.BoundingBox.Right, b.BoundingBox.Right) - System.Math.Max(a.BoundingBox.Left, b.BoundingBox.Left);
|
|
var overlapY = System.Math.Min(a.BoundingBox.Top, b.BoundingBox.Top) - System.Math.Max(a.BoundingBox.Bottom, b.BoundingBox.Bottom);
|
|
return overlapX > OpenNest.Math.Tolerance.Epsilon && overlapY > OpenNest.Math.Tolerance.Epsilon;
|
|
}
|
|
|
|
private static void AssertMatchesLegacy(List<Part> parts)
|
|
{
|
|
var legacy = Capture(() => LegacyPartOverlap.FillHelpersHasOverlappingParts(parts));
|
|
var actual = Capture(() => FillHelpers.HasOverlappingParts(parts));
|
|
Assert.Equal(legacy, actual);
|
|
|
|
var legacyIndices = Capture(() =>
|
|
{
|
|
var hit = LegacyPartOverlap.FillLinearHasOverlappingParts(parts, out var a, out var b);
|
|
return (hit, a, b);
|
|
});
|
|
var actualIndices = Capture(() => Indices(parts));
|
|
Assert.Equal(legacyIndices, actualIndices);
|
|
}
|
|
|
|
private static (T Value, Type? Exception) Capture<T>(Func<T> run)
|
|
{
|
|
try
|
|
{
|
|
return (run(), null);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
return (default!, (exception as System.Reflection.TargetInvocationException)?.InnerException?.GetType()
|
|
?? exception.GetType());
|
|
}
|
|
}
|
|
|
|
private static (bool Hit, int A, int B) Indices(List<Part> parts)
|
|
{
|
|
var hit = Invoke(parts, out var a, out var b);
|
|
return (hit, a, b);
|
|
}
|
|
|
|
private static bool Invoke(List<Part> parts, out int a, out int b)
|
|
{
|
|
var method = typeof(FillLinear).GetMethod("HasOverlappingParts",
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!;
|
|
var args = new object[] { parts, -1, -1 };
|
|
var hit = (bool)method.Invoke(null, args)!;
|
|
a = (int)args[1];
|
|
b = (int)args[2];
|
|
return hit;
|
|
}
|
|
|
|
private static void ForEachBoxOverlappingPair(List<Part> parts, Action<Part, Part> action)
|
|
{
|
|
for (var i = 0; i < parts.Count; i++)
|
|
for (var j = i + 1; j < parts.Count; j++)
|
|
{
|
|
var b1 = parts[i].BoundingBox;
|
|
var b2 = parts[j].BoundingBox;
|
|
var overlapX = System.Math.Min(b1.Right, b2.Right) - System.Math.Max(b1.Left, b2.Left);
|
|
var overlapY = System.Math.Min(b1.Top, b2.Top) - System.Math.Max(b1.Bottom, b2.Bottom);
|
|
if (overlapX > OpenNest.Math.Tolerance.Epsilon && overlapY > OpenNest.Math.Tolerance.Epsilon)
|
|
action(parts[i], parts[j]);
|
|
}
|
|
}
|
|
|
|
private static Polygon WorldPolygon(PartOverlapChecker checker, Part part) =>
|
|
(Polygon)PreparedPartMember(checker, part, "Polygon");
|
|
|
|
private static List<Polygon> WorldTriangles(PartOverlapChecker checker, Part part) =>
|
|
((Func<List<Polygon>>)PreparedPartMember(checker, part, "Triangles"))();
|
|
|
|
private static object PreparedPartMember(PartOverlapChecker checker, Part part, string property)
|
|
{
|
|
var field = typeof(PartOverlapChecker).GetField("preparedParts",
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
|
|
var prepared = ((System.Collections.IDictionary)field.GetValue(checker)!)[part]!;
|
|
return prepared.GetType().GetProperty(property)!.GetValue(prepared)!;
|
|
}
|
|
|
|
/// <summary>Every other part replaced by a copy moved by (-shift, -shift).</summary>
|
|
private static List<Part> Shifted(List<Part> grid, double shift)
|
|
{
|
|
var result = new List<Part>(grid.Count);
|
|
for (var i = 0; i < grid.Count; i++)
|
|
result.Add(i % 2 == 0 ? grid[i] : grid[i].CloneAtOffset(new Vector(-shift, -shift)));
|
|
return result;
|
|
}
|
|
|
|
private static Drawing Fixture(string shape) => shape switch
|
|
{
|
|
"circle" => new RingShape { OuterDiameter = 8, InnerDiameter = 0 }.GetDrawing(),
|
|
"ring" => new RingShape { OuterDiameter = 8, InnerDiameter = 3 }.GetDrawing(),
|
|
_ => FillExtentsTests.MakeFixture(shape),
|
|
};
|
|
|
|
private static Pattern MakePattern(string kind, Drawing drawing)
|
|
{
|
|
var first = Part.CreateAtOrigin(drawing, 0);
|
|
first.Offset(new Vector(11.1, -3.3));
|
|
var second = kind == "shared" ? first.CloneAtOffset(new Vector(first.BoundingBox.Length + 0.5, 0))
|
|
: Part.CreateAtOrigin(drawing, System.Math.PI / 2);
|
|
if (kind != "shared")
|
|
second.Offset(new Vector(first.Right + 0.5, first.Bottom));
|
|
if (kind == "built-pair")
|
|
return FillHelpers.BuildRotatedPattern(new List<Part> { first, second }, 0.37);
|
|
var pattern = new Pattern();
|
|
pattern.Parts.AddRange(new[] { first, second });
|
|
pattern.UpdateBounds();
|
|
Assert.Equal(kind == "shared", ReferenceEquals(first.Program, second.Program));
|
|
return pattern;
|
|
}
|
|
|
|
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
|
|
|
|
private static long[] PolygonBits(Polygon polygon) => new[]
|
|
{
|
|
Bits(polygon.BoundingBox.X), Bits(polygon.BoundingBox.Y),
|
|
Bits(polygon.BoundingBox.Length), Bits(polygon.BoundingBox.Width),
|
|
}
|
|
.Concat(polygon.Vertices.SelectMany(v => new[] { Bits(v.X), Bits(v.Y) }))
|
|
.ToArray();
|
|
|
|
private static object[] Snapshot(Part part) => new object[]
|
|
{
|
|
part, part.Program, part.Program.Codes.Count, Bits(part.Location.X), Bits(part.Location.Y),
|
|
Bits(part.Rotation), Bits(part.BoundingBox.X), Bits(part.BoundingBox.Y),
|
|
Bits(part.BoundingBox.Length), Bits(part.BoundingBox.Width),
|
|
}
|
|
.Concat(part.Program.Codes.Cast<object>())
|
|
.Concat(ConvertProgram.ToGeometry(part.Program).SelectMany(e => new object[] { e.GetType(), e.Layer,
|
|
Bits(e.BoundingBox.X), Bits(e.BoundingBox.Y), Bits(e.BoundingBox.Length), Bits(e.BoundingBox.Width) }))
|
|
.ToArray();
|
|
}
|